C++17可变参数模板与折叠表达式

一、什么是可变参数模板

一个可变参数模板(variadic template)就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包(parameter packet)。存在两种参数包:模板参数包(template parameter packet),表示零个或多个模板参数。以及函数参数包(function parameter packet),表示零个或多个函数参数。

我们用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中, class… 或 typename… 指出接下来的参数表示零个或多个类型的列表。一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。例如:

// Args 是一个模板参数包;rest是一个函数参数包
// Args 表示零个或多个模板类型参数
// rest 表示零个或多个函数参数
template <typename T, typename... Args>
void foo(const T& t, const Args& ... rest);

以上声明了foo是一个可变参数函数模板,它有一个名为T的类型参数,和一个名为Args的模板参数包。这个包表示零个或多个额外的类型参数。foo的函数参数列表包含一个const& 类型的参数,指向 T 的类型,还包含一个名为rest的函数参数包,此包表示零个或多个函数参数。

和往常一样,编译器从函数的实参推断模板参数类型。对于一个可变参数模板,编译器还会推断包中的参数的数目。例如:

int i = 0;
double d = 3.14;
string s = "hello";
foo(i, s, 42, d);   // 包中有三个参数
foo(s, 42, "hi");   // 包中有两个参数
foo(d, s);          // 包中有一个参数
foo("hi");          // 空包

每个实例中,T 的类型都是从第一个实参的类型推断出来的。剩下的实参(如果有的话)提供函数额外实参的数目和类型。

当我们需要知道包中有多少元素时,可以使用 sizeof… 运算符(注意,不是sizeof运算符,后面多三个点)。 sizeof…也返回一个常量表达式,而且不会对其实参求值:

#include <iostream>

using namespace std;

template<typename ... Args>
void g(Args ... args)
{
    cout << sizeof...(Args) << endl;    // 类型参数的数目
    cout << sizeof...(args) << endl;    // 函数参数的数目
}

int main()
{
    g(1, 2.0, 3.0, "hi");   // 4, 4
    g("hi", "hello");       // 2, 2

    return 0;
}

二、编写可变参数函数模板

可变参数函数通常都是递归的。第一步调用处理包中的第一个实参,然后用剩余实参调用自身。

我们首先顶一个print函数,它在一个给定流上打印给定实参列表的内容。print函数也遵循上述递归模式,每次递归调用将第二个实参打印到第一个实参表示的流中。为了终止递归,我们还需要定义一个非可变参数的print函数,它接受一个流和一个对象。

// 用来终止递归并打印最后一个元素的函数
// 此函数必须在可变参数版本的print之前定义
template<class T>
ostream& print(ostream& os, const T& t)
{
    return os << t; //包中最后一个元素之后不打印分隔符
}

// 包中除了最后一个元素之外的其他元素都会调用这个版本的print
template<class T, class... Args>
ostream& print(ostream& os, const T& t, const Args&... rest)
{
    os << t << "," << endl;     // 打印第一个实参
    return print(os, rest...);   // 递归调用,打印其他实参
}

关键部分是:

return print(os, rest...)   // 递归调用,打印其他实参

我们的可变参数版本的print函数接受3个参数,而此调用只传递了两个实参。这是为何呢?这是因为 rest 中的第一个实参被绑定到了 t, 剩余实参形成下一个print调用的参数包。因此,在每个调用中,包中的第一个实参被移除,称为绑定到 t 的实参。

即对于:

print(cout, i, s, 42);  // 包中有2个参数

调用                       t                         rest...
--------------------------------------------------------------------
print(cout, i, s, 42)      i                         s, 42
print(cout, s, 42)         s                         42
print(cout, 42)            调用非可变版本的print

对于最后一次调用print(cout, 42),两个print版本都是可行的,提供了同样好的匹配,但是非可变版本比可变参数模板更加地特化,因此编译器选择非可变参数版本。

三、包扩展

对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展(expand)它。当扩展一个包时,我们还需要提供用于每个扩展元素的模式(pattern)。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。我们通过在模式右边放一个省略号(…)来触发扩展操作。

例如,上面的print函数就包含两个扩展:

template<class T, class... Args>
ostream& print(ostream& os, const T& t, const Args&... rest) // 扩展Args
{
    os << t << ",";
    return print(os, rest...);  // 扩展 rest
}

第一个扩展操作扩展模板参数包,为print生成函数参数列表。第二个扩展操作出现在对print的调用中。此模式为print调用生成实参列表。

对Args的扩展中,编译器将模式 const Args& 应用到模板参数包 Args 中的每个元素。因此,此模式的扩展结果是一个逗号分隔的零个或多个类型的列表,每个类型都形如 const Args& 。 例如:

print(cout, i, s, 42)

最后两个实参的类型和模式一起确定了尾置参数的类型,此调用被实例化为:

ostream& print(ostream&, const int&, const string&, const int&);

第二个扩展发生在对print的递归调用中。在此情况下,模式是函数参数包的名字(即rest)。此模式扩展出一个由包中元素组成的、逗号分隔的列表。因此,这个调用等价于:

print(os, s, 42);

四、折叠表达式

4.1 C++11 递归解包

在C++11标准中,要对可变参数模板形参包的包展开进行逐个计算需要用到递归的方法,在上面print函数中,我们已经展示过用递归的方式进行解包。

再比如下面的求和函数:

template<class T>
T sum(T arg)
{
    return arg;
}

template<class T1, class... Args>
auto sum(T1 arg1, Args... args)
{
    return arg1 + sum(args...);
}

int main()
{
    std::cout << sum(1, 5.0, 11.7) << std::endl;
    return 0;
}

注意,这里使用C++14的特性将auto作为返回类型的占位符,把返回类型的推导交给编译器。

4.2 C++17 折叠表达式

递归解包的方式过于繁琐,为了能够更好地进行解包,C++委员会在C++17 标准中引入了折叠表达式的新特性。让我们使用折叠表达式的特性改写递归的例子:

// C++ 17 折叠表达式新特性
template<class... Args>
auto sum_17(Args... args)
{
    return (args + ...);
}

int main()
{
    std::cout << sum_17(1, 5.0, 11.7) << std::endl;
    return 0;
}

对比之下,这个代码何其简洁!我们不再需要编写多个sum函数,然后通过递归的方式求和。需要做的仅仅是按照折叠表达式的规则折叠形参包(args + …)。根据折叠表达式的规则,(args + …) 会被折叠为:

arg0 + (arg1 + arg2)
即 1 + ( 5.0 + 11.7 )

现在,让我们来看下折叠表达式的折叠规则。

在C++17的标准中有4种折叠规则,分别是一元向左折叠、一元向右折叠、二元向左折叠和二元向右折叠。

上面的例子就是一个典型的一元向右折叠:

(args op ...) 折叠为 (arg0 op (arg1 op ... (argN-1 op argN)))

对于一元向左折叠,折叠方向正好相反:

(... op args) 折叠为 ((((arg0 op arg1) op arg2) op ...) op argN)

二元折叠总体上和一元相同,唯一的区别是多了一个初始值,比如二元向右折叠:

(args op ... op init) 折叠为 (arg0 op (arg1 op ...(argN-1 op (argN op init))))

二元向左折叠在方向上相反:

(init op ... op args) 折叠为 ((((init op arg0) op arg1) op ... ) op argN)

在上面的示例中,args 表示的是形参包的名称,init 表示初始值,op 代表任意一个二元运算符。需要注意的是,在二元折叠中,两个运算符必须相同。

在折叠规则中最重要的一点就是操作数之间的结合顺序。如果使用折叠表达式的时候不能很清楚地区分它们,可能会造成编译失败,例如:

// C++ 17 折叠表达式新特性
template<class... Args>
auto sum_17(Args... args)
{
    return (args + ...);
}

cout << sum_17(string("hello"), "world", "how are u");

以上代码会折叠失败,因为折叠表达式 (args + …) 是向右折叠,展开后的代码是:

string("hello") + ("world" + "how are u")

而两个原生的字符串类型 const char* 是无法相加的。

为了能通过编译,我们只需修改一下折叠表达式即可:

template<class... Args>
auto sum_17_2(Args... args)
{
    return (... + args);
}

改为了一元向左折叠,此时展开后的形式为:

(string("hello") + "world") + "how are u"

string类型的字符串是可以使用 + 将两个字符串连接起来的,所以可以顺利编译。

再来看一个有初始化值的例子:

// 二元向左折叠
template<class... Args>
void myprint(const Args&... args)
{
    (cout << ... << args) << endl;
}

myprint(string("hello "), "world ", "how r u");

上面的代码使用了二元向左折叠,其中std::cout是初始化值。

4.3 一元折叠表达式中空参数包的特殊处理

一元折叠表达式对空参数包展开有一些特殊规则,这是因为编译器很难确定折叠表达式最终的求值类型,比如:

// 一元向右折叠
template<class... Args>
auto sum(Args... args)
{
    return (args + ...);
}

假如我们的模板函数sum的实参为空,那么折叠表达式 (args + …) 是无法确定求值类型的。当然,二元折叠表达式不存在这个问题,因为它可以指定一个初始值。

为了解决一元折叠表达式中参数包为空的问题,下面的规则是必须遵守的:

  • 只有 &&、|| 和 , 运算符能够在空参数包的一元折叠表达式中使用
  • && 的求值结果一定为true
  • || 的求值结果一定为false
  • , 的求值结果为void()
  • 其他运算符都是非法的

例如:

// 一元折叠中的空包
template<class... Args>
auto andop(Args... args)
{
    return (args && ...);
}

cout << boolalpha << andop() << endl;   // 输出 true
cout << andop(1, 0) << endl;            // 输出 false

上述代码中,虽然模板函数 andop 的参数包为空,但是依然可以成功编译并输出运算结果 true。


参考文献:

  1. 《C++ Primer第五版》
  2. 《现代C++语言核心特性解析》

猜你喜欢

转载自blog.csdn.net/hubing_hust/article/details/128663397