【转载】泛化之美 —— C++11 可变模版参数的妙用(上)

243 阅读5分钟

参考原文地址: 泛化之美 —— C++11 可变模版参数的妙用

我对文章的格式和错别字进行了调整,并补充并标注出了重要的部分。以下是正文。

正文

概述

C++11 的新特性 —— 可变参数模板(variadic templates)是 C++11 新增的最强大的特性之一,它对参数进行了高度泛化,它能表示 0 到任意个数、任意类型的参数。相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以它也是 C++11 中最难理解和掌握的特性之一。虽然掌握可变参数模板有一定难度,但是它却是C++11 中最有意思的一个特性,本文希望带领读者由浅入深的认识和掌握这一特性,同时也会通过一些实例来展示可变参数模版的一些用法。

可变模板的参数展开

可变参数模板和普通模板的语义是一样的,只是写法上稍有区别,声明可变参数模板时需要在 typename 或 class 后面带上省略号 “...” 。比如我们常常这样声明一个可变模版参数: template<typename...> 或者 template<class...> ,一个典型的可变模版参数的定义是这样的:

template <class... T>
void f(T... args);

上面的可变模版参数的定义当中,省略号的作用有两个:

  • 声明一个参数包 T... args ,这个参数包中可以包含 0 到任意个模板参数;

  • 在模板定义的右边,可以将参数包展开成一个一个独立的参数。

上面的参数 args 前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为 “参数包” ,它里面包含了 0 到 N(N>=0)个模版参数。我们无法直接获取参数包 args 中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。

可变模版参数和普通的模版参数语义是一致的,所以可以应用于函数和类,即可变参数模版函数可变参数模版类,然而,模版函数不支持偏特化,所以可变参数模版函数和可变参数模版类展开可变参数的方法还不尽相同,下面我们来分别看看他们展开可变参数的方法。

可变参数函数模板

一个简单的可变参数函数模板:

template <class... T>
void f(T... args)
{
    cout << sizeof...(args) << endl; // 打印变参的个数
}

f();           //0
f(1, 2);       //2
f(1, 2.5, ""); //3

上面的例子中, f() 没有传入参数,所以参数包为空,输出的 size 为 0 ,后面两次调用分别传入两个和三个参数,故输出的 size 分别为 2 和 3 。由于可变模版参数的类型和个数是不固定的,所以我们可以传任意类型个数的参数给函数 f 。这个例子只是简单的将可变参数模版的个数打印出来,如果我们需要将参数包中的每个参数打印出来的话就需要通过一些方法了。展开可变模版参数函数的方法一般有两种:

  • 一种是通过递归函数来展开参数包,
  • 另外一种是通过逗号表达式来展开参数包。

下面来看看如何用这两种方法来展开参数包。

递归函数方式展开参数包

通过递归函数展开参数包,需要提供一个参数包展开的函数和一个递归终止函数,递归终止函数正是用来终止递归的,来看看下面的例子。

#include <iostream>
using namespace std;

/// @note 递归终止函数
void print()
{
    cout << "empty" << endl;
}

/// @note 展开函数
template <class T, class... Args>
void print(T head, Args... rest)
{
    cout << "parameter " << head << endl;
    print(rest...);
}

int main(void)
{
    print(1, 2, 3, 4);
    return 0;
}

上例会输出每一个参数,直到为空时输出 empty 。展开参数包的函数有两个,一个是递归函数,另外一个是递归终止函数,参数包 Args... 在展开的过程中递归调用自己,每调用一次参数包中的参数就会少一个,直到所有的参数都展开为止,当没有参数时,则调用非模板函数 print 终止递归过程。

递归调用的过程是这样的:

print(1,2,3,4);
print(2,3,4);
print(3,4);
print(4);
print();

上面的递归终止函数还可以写成这样:

template <class T>
void print(T t)
{
    cout << t << endl;
}

修改递归终止函数后,上例中的调用过程是这样的:

print(1, 2, 3, 4);
print(2, 3, 4);
print(3, 4);
print(4);

当参数包展开到最后一个参数时递归终止。

再看一个通过可变模版参数求和的例子:

template <typename T>
T sum(T t)
{
    return t;
}
template <typename T, typename... Types>
T sum(T first, Types... rest)
{
    return first + sum<T>(rest...);
}

sum(1, 2, 3, 4); //10

sum 在展开参数包的过程中将各个参数相加求和,参数的展开方式和前面的打印参数包的方式是一样的。

逗号表达式展开参数包

递归函数展开参数包是一种标准做法,也比较好理解,但也有一个缺点,就是必须要一个重载的递归终止函数,即必须要有一个同名的终止函数来终止递归,这样可能会感觉稍有不便。有没有一种更简单的方式呢?其实还有一种方法可以不通过递归方式来展开参数包,这种方式需要借助逗号表达式初始化列表。比如前面 print 的例子可以改成这样:

template <class T>
void printarg(T t)
{
    cout << t << endl;
}

template <class... Args>
void expand(Args... args)
{
    int arr[] = {(printarg(args), 0)...};
}

expand(1, 2, 3, 4);

这个例子将分别打印出 1,2,3,4 四个数字。这种展开参数包的方式,不需要通过递归终止函数,是直接在 expand 函数体中展开的, printarg 不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式,比如:

d = (a = b, c); 

这个表达式会按顺序执行:b 会先赋值给 a ,接着括号中的逗号表达式返回 c 的值,因此 d 将等于 c 。

expand 函数中的逗号表达式:

(printarg(args), 0)

也是按照这个执行顺序,先执行 printarg(args) ,再得到逗号表达式的结果 0 。同时还用到了 C++11 的另外一个特性 —— 初始化列表,通过初始化列表来初始化一个变长数组

{(printarg(args), 0)...}

将会展开成

((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0),  etc... )

最终会创建一个元素值都为 0 的数组 int arr[sizeof...(Args)]。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分 printarg(args) 打印出参数,也就是说在构造 int 数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。我们可以把上面的例子再进一步改进一下,将函数作为参数,就可以支持 lambda 表达式了,从而可以少写一个递归终止函数了,具体代码如下:

template <class Fclass... Args>
void expand(const F & f, Args &&...args) 
{
    // 这里用到了完美转发,关于完美转发,读者可以参考原作者的文章 《通过 4 行代码看右值引用》
      initializer_list<int>{ (f(std::forward<Args>(args)), 0)... };
}

expand([](int i)
       { cout << i << endl; },
       1, 2, 3);

上面的例子将打印出每个参数,这里如果再使用 C++14 的新特性泛型 lambda 表达式的话,可以写更泛化的 lambda 表达式了:

expand([](auto i)
       { cout << i << endl; },
       1, 2.0, "test");

(未完待续)