引言

C语言传递不一定参数,需要使用stdarg.h库,个人虽然感觉不复杂,但是容易出错,毕竟涉及到指针会让人容易出错。那么C++是如何解决的呢?这得拿出C+ +编程语言界的独特能力 模板,这简直是C+ +黑暗魔法。

通过这边笔记,我打算深入研究C+ +模板,以及他是如何实现不定长参数的。当然在研究的过程中,又练习了C+ +17/20的一些新特性:

  1. 带模板的lambda ,这需要C++20标准的支持
  2. 折叠表达式 ,需要C++ 17标准支持
  3. if constexpr

参考书籍:

  1. 现代 C++ 教程:高速上手 C++ 11/14/17/20
  2. C++ primer Plus 第6版 p827
  3. C++标准库 第二版

参考博文:

  1. 折叠表达式
  2. C++17 折叠表达式

我是用的环境为 Clion + MinGW64 8.10

C++ 可变参数模板

窥探STL

我们想要传入多个参数,其实也无需那么麻烦,在C的角度我们可以把多个参数打包成struct,或者使用STL的容器tuple ,但是这一点都不智能,需要调用者做一些准备工作,难道就没更好的办法了吗?

C++ tuple 可以有不定个不同类型的成员,ta是如何实现的,可以窥探一下。

tuple<int,char,string> tuple1(1,'a',"tuple");

//截取部分源码
template <class _This, class... _Rest>
class tuple<_This, _Rest...> : private tuple<_Rest...> { // recursive tuple definition
public:
    using _This_type = _This;
    using _Mybase    = tuple<_Rest...>;
    ...
}//这里MSVC的写法  GNUC的没看懂 QWQ

STL实现tuple的使用就是使用了今天的主角,**可变参数模板 **<typename... _Elements>,这里可以发现一个独特的用法,第五行出现了套娃行为,自己继承自己? 不,这里看似自己在基础自己,其实这里实在解包。我们带入案例。

//<int,char,string>
//第一层
template<typename _This=int,typename... _Rest={char,string}>
class tuple: private tuple<char,string>
{
    _This=int;
    _Mybase=tuple<char,string>;
}
//第二层
class tuple: private tuple<string>
{
    _This=char;
    _Mybase=tuple<string>;
}
//第三层
class tuple: private tuple<string>
{
    _This=string;
    _Mybase=tuple<>;
}

可以很直观的看到,通过不断的递归继承“自己”,tuple就拿出了参数包的每个类型。可以说C++编译器帮我们定义了四种种tuple 分别为

tuple<int,char,string>,tuple<char,string>,tuple<string>,tuple<>;

前者的数据类型来自后者的定义,非常巧妙。

初步相识

C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定,也就是刚才tuple给带来的神奇操作的根本所在。

这里我们需要掌握一下要点:

  1. 模板参数包 parameter pack
  2. 函数参数包
  3. 如何解参数包
template<typename... U>
void Test(const U&... u)
{
    cout<<"packSize"<<sizeof...(u)<<endl;
}
    Test();//0
    Test(1,"test",0.3f);//3

typename... 就是前面所说的 模板参数包,这里保存这 任意个任意个参数类型模板;U...就是函数参数包,这里就存储着我们传入的参数。

这里我们使用 const & 修饰了 传入的参数包,sizeof...() 是当前参数包大小的运算符,这里强调一点:模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,其实这里编译器帮我们生成了两个函数 原型如下:

void Test();
void Test(const int&,const char*&,float);//很舒服 这与我们传入参数顺序是一致的

sizeof...() 是告诉我们参数包的大小,当然它也是在编译期间就处理好的。我们使用gdbdisas查看一下反汇编验证一下,的确如此

image-20211026200916125

其实声明非常简单,这里的难点就在如何解包的过程中,到目前为止还没有一种简单的方法能够处理参数包,我目前发现了四种不错的方法。

  1. 递归模板函数
  2. 变参模板展开
  3. 折叠表达式 ,适合对多个参数进行相同的操作
  4. 模板lambda表达式 (需要C++20) 和 折叠表达式
  5. 其他 如bind绑定等

难度又简单到复杂,当然程序变得越来越简化。为了后面演示统一,我们都实现一个 println(…)打印多个参数到一行的函数。

当然我在参考资料中发现了一个花里胡哨的方法,后面单独研究一下。

递归模板函数解包

前有tuple不断递归继承自己,那么函数是否可以不断调用自己呢?

template<typename T,typename... Args>
void println1(T t,Args... args)
{
    cout<<t<<',';
    println1(args...);
    cout<<endl;
}
println1("1",2,5.0f);

很可惜这样报错了

这里说我们没有定义那么一大串函数,why?别忘了我们这是递归,由于我们没有定义 println1(float)函数,你一定会反驳说,我不是定义了模板了吗,目前我们模板函数只能至少接受两个参数,所以这里报错了。和递归函数必须有终止条件一样,这里也可以处理一下终止条件,没有一个参数的对吧,那么我们定义一个不行吗?

//该函数需要定义 前面一个重载的前面,因为前面一个重载在调用该函数
template<typename T>
void println1(T t)
{
    cout<<t<<endl;
};

现在我们就解决了该问题了,当然终止条件可以只有一个参数,也可以看作没有参数

void println1(){}

但是这样显然效率没有前者高。

这样做很繁琐,还需要声明一个没有必要但是必须定义一个终止递归的函数,emm接着往下看把。

变参模板展开解包

C++ 为了在推出模板的使用推出一个新的运算符 sizeof…(),前文以及使用过了,就是求得参数包的大小,并且这里再编译期间就确定好的。这样我们就可以添加条件来终止递归,从而我们也就无需再定义一个多余的函数。

template<typename T,typename... Args>
void println2(T t,Args... args)
{
    if (sizeof...(args)>0)
    {
        cout<<t<<',';
        println2(args...);
    }
    else//终止情况
        cout<<t<<endl;
}

看似很完美还是报错了 !!!

导致这一点的原因就是编译器在编译期间无法知道何时args为空,我们使用了 if去规避arg为空,但是编译器没有我们聪明,它却无法规避。这就很难受,难道就到了这里了吗?别忘了这可是C++,使用if constexpr 就可以告诉编译器后面的条件可以在编译期间就确定,从而在编译期间就进行了if判断。

template<typename T,typename... Args>
void println2(T t,Args... args)
{
    //加入 constexpr 完美解决
    if constexpr (sizeof...(args)>0)
    {
        cout<<t<<',';
        println2(args...);
    }
    else//终止情况
        cout<<t<<endl;
}

折叠表达式解包

折叠表达式,这个语法出在其他编程语言也出现过,这里我先不详细的介绍,不明白可以先看下面的博文。

  1. 折叠表达式

  2. C++17 折叠表达式

一元折叠已经足够好用,你能够熟练掌握就可以往下看了。

不过防止我以后也看不懂了,这里放一个例子:

template<typename ... T>
auto sum(T ... t) {
    return (t + ...);
}
int main() {
    std::cout << sum(1, 2, 3, 4) << std::endl;
}

折叠表达式其实说白了就是专门为参数包设计的,它可以变长参数套娃式地解包而且带入执行前面运算符,这个例子折叠表达式展开为:

(1+(2+(3+(4+5))))

这里都折叠表达式极大简化了我们语法,当然不是所有情况需要 +,比如我们只需输出,那该使用那个运算符呢?答案是 , ,想不到把,也是运算符,而且他也没要干啥,仅仅是顺序的执行我们的语句,这不正是我们想要的吗?

//逗号表达式
template<typename... Args>
void println3(Args... args)
{
    ((cout<<args<<','),...)<<endl;
}

一行就完成了前面那么复杂操作, 逗号表达式就是这么好用. 当然这回导致我的强迫症发难,在最后一个参数末尾会多输出一个 , ,是否可以解决它呢?

模板lambda表达式

这个是我认为最舒服的方法.

template<typename... Args>
void println4(Args... args)
{
    int i=0;
    auto iter=[&i]<typename Arg>(Arg arg)
    {
        if(sizeof...(Args)==++i)cout<<arg<<endl;
        else cout<<arg<<',';
    };
    (iter(args),...);
}

这里其实看似复杂,但是如果你熟悉lambda表达式的话,那么这一点完全不在话下,简单解释一下吧.

该lambda表示就是为了帮我们判断是解包到最后一个参数了,如果是最后一个输出换行而不是,,这里的i 务必使用 引用类型捕获,或者你使用mutable 扩宽i的作用域也是可以的.

再结合 折叠表达式非常nice.

初始化列表展开 + lambda表达式

初始化列表让C++ 构造语法统一,我们还可以使用它做很多事情。

template<typename... Args>
void println5(Args... args)
{
    initializer_list<char>{
            ([&args]
            {
                cout<<args<<',';
            }(),'a')...
    };
}

这里巧妙使用了C++的逗号表达式和初始化列表,这一段代码可读性并不是很高,所以个人不喜欢这样,所以就当积累经验了。

初始化列表会让(lambda 表达式, 'a')... 展开逗号表达式 会先执行前面的 lambda表达式,而lambda表达式恰好被我设计成输出参数,这里的参数又来自哪里呢,当然是被初始化列表解包的args...

initializer_list<char>初始化需要若干个char类型,这里char是我随便指定的,ta的存在完全是为了让初始化列表能够存在,可以让他成为任意类型,我这里选择是用为char类型占用空间小,但是这个方法不是一个好方法,有点挂羊头卖狗肉的意思,不仅可读性差,而且还需要多出现许多没用的char。

补充

bind

事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用 std::bind 及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的,这样效率更好。后面我写一个案例补充再这里吧.

模板还可以传值

C++模板我一直以为只能传类型,但是还可以传值,简直离谱,放在这里补充一下吧,案例如下

template<auto... t>
auto sum()
{
    return (t+...);
}
//...
//这样调用
cout<<sum<1,2,3>();

使用模板传递参数,嗯,非常颠覆我三观. 顺带说一下auto真好用.

结语

通过这一篇笔记我练习了很多现代C++的高级用法. 不错

C++天下第一!

努力成长的程序员