C++17折叠表达式

Share on:
800 Words | Read in about 4 Min | View times

Overview

C++11引入了可变参数模板(variadic template),它可以接收任意数量的模板参数,但是参数包不能直接展开,需要通过递归或者逗号表达式的方式进行展开,写法非常繁琐。C++17对这个问题进行了优化,引入了折叠表达式的概念,用来简化对可变参数模板中参数包的展开过程。本节内容重点介绍折叠表达式的使用方法。

本系列文章将包括以下领域:

本章其他内容请见 《现代C++》

引子

考虑一个场景,我们需要对逐一打印不定长的参数,C++17之前常见的做法为定义一个可变参数模板函数进行处理。

可变参数中的参数包(parameter pack)需要展开,在C++17之前是不能直接展开的,需要借助递归或者逗号表达式来实现。

递归展开参数包

 1//C++11,应对单参数的递归出口
 2void print() {
 3    std::cout << std::endl;
 4}
 5
 6template<typename T, typename... Ts>
 7void print(T arg, Ts... args) {
 8    std::cout << arg << ", ";
 9    print(args...);
10}
11
12int main() {
13    print("hello", "zhxilin", 1, 2, 3);
14    return 0;
15}

我们定义了一个可变参数模板函数,C++17之前没办法直接进行参数包展开。

那么为了参数包展开,需要采用递归思路来展开。第一个print为递归出口,第二个print为递归调用逻辑。

逗号表达式展开参数包

参数包展开还可以使用逗号表达式来展开:

 1template<typename T>
 2void print_item(T arg) {
 3    std::cout << arg << ", ";
 4}
 5
 6template<typename... T>
 7void expand(T... args) {
 8    int arr[] = { (print_item(args), 0)... }; //逗号表达式
 9    std::cout << std::endl;
10}
11
12int main() {
13    expand("hello", "zhxilin", 1, 2, 3);
14    return 0;
15}

回顾一下逗号表达式a = (b = c, d),表达式的执行顺序是,先进行b = c的赋值,接着括号中的逗号表达式返回d,因此最终表达式a等于d

所以上面的例子中,int arr[] = { (print_item(args), 0)... };语句,其实核心部分是这一句(print_item(args), 0),先指定print_item()函数,然后逗号表达式返回0;然后利用初始化列表(initialize list)来初始化一个初值都是0、大小为sizeof...(args)的数组。通过初始化列表,最终展开为

1int arr[] = {
2    (print_item(args0), 0),
3    (print_item(args1), 0),
4    (print_item(args2), 0),
5    ...
6};

可以看出,在C++17之前,可变参数模板的参数包展开非常麻烦,所以C++17引入了折叠表达式,来简化参数包的展开过程。

折叠表达式

折叠表达式用来简化参数包的展开过程。

语法规则

折叠表达式有4种语法形式:

1(pack op ...) //一元右折叠
2
3(... op pack) //一元左折叠
4
5(pack op ... op init) //二元右折叠
6
7(init op ... op pack) //二元左折叠

其中各个部分的含义如下:

  • op

折叠表达式支持以下32个二元运算符:

+, -, *, /, %, ^, &, |, =, <, >, <<, >>, +=, -=, *=, /=, %=, ^=, &=, |=, <<=, >>=, ==, !=, <=, >=, &&, ||, ,, .*, ->*

在折叠表达式中,所有op操作符必须相同。

  • pack

含有未展开的参数包,且在顶层不含优先级低于转型表达式的运算符的表达式。

  • init

不含未展开的参数包,且在顶层不含优先级低于转型表达式的运算符的表达式。

  • ...

折叠标记。

  • ( )

要注意,括号也是折叠表达式的一部分。

一元折叠

一元折叠分为一元右折叠和一元左折叠。

假设表达式是E(包含参数包),操作符是op

  • 一元右折叠

形如(E op ...)的折叠表达式称为一元右折叠。

一元右折叠展开之后的含义是(E1 op (... op (En-1 op En)))

  • 一元左折叠

形如(... op E)的折叠表达式称为一元左折叠。

一元左折叠展开之后的含义是(((E1 op E2) op ...) op En)

最简单的折叠表达式是一个求和函数:

 1//一元右折叠
 2template<typename... T>
 3auto sumR(T... args) {
 4    return (args + ...);
 5}
 6
 7//一元左折叠
 8template<typename... T>
 9auto sumL(T... args) {
10    return (... + args);
11}
12
13int main() {
14    std::cout << sumR(1, 2, 3, 4, 5) << std::endl; //15
15    std::cout << sumL(1, 2, 3, 4, 5) << std::endl; //15
16    return 0;
17}

sumR(1, 2, 3, 4, 5)右折叠展开后等价于1 + (2 + (3 + (4 + 5)))sumL(1, 2, 3, 4, 5)左折叠展开后等价于(((1 + 2) + 3) + 4) + 5

在这个例子中,左右折叠对于加法和乘法的结果都是相等的;但是有些情况下,比如减法和除法,左右折叠展开的结果是不同的,要谨慎使用。

我们再回头看一下引子中的打印问题,使用折叠表达式改写之后应该是这样的:

 1//一元右折叠
 2template<typename... T>
 3void printR(T... args) {
 4    ((std::cout << args << ", "), ...) << std::endl;
 5}
 6
 7//一元左折叠
 8template<typename... T>
 9void printL(T... args) {
10    (..., (std::cout << args << ", ")) << std::endl;
11}
12
13int main() {
14    printR("hello", "zhxilin", 1, 2, 3);
15    printL("hello", "zhxilin", 1, 2, 3);
16    return 0;
17}

在上面这个例子的折叠表达式中,op是逗号,E是输出语句std::cout << args << ", ",所以其实是一个逗号表达式的折叠表达式。注意E语句中<<操作符的返回结果是std::ostream,所以可以继续输出流操作。

通过折叠表达式,原来通过递归或复杂逗号表达式的展开方式就可以消除了。

二元折叠

二元折叠分为二元右折叠和二元左折叠。

假设表达式是E(包含参数包),操作符是op,初值是I

  • 二元右折叠

形如(E op ... op I)的折叠表达式称为二元右折叠。

二元右折叠展开之后的含义是(E1 op (... op (En-1 op (En op I))))

  • 二元左折叠

形如(I op ... op E)的折叠表达式称为二元左折叠。

二元左折叠展开之后的含义是((((I op E1) op E2) op ...) op En)

再看一下前面通过一元折叠表达式实现的累加函数,这种实现已经可以满足大多数情况了,但是有一种情况没法满足,就是函数传递了空参数。如果非要用一元折叠表达式实现,且支持传递空参数,那么需要对模板进行特化:

 1//一元右折叠
 2template<typename... T>
 3auto sumR(T... args) {
 4    return (args + ...);
 5}
 6auto sumR() { return 0; }
 7
 8//一元左折叠
 9template<typename... T>
10auto sumL(T... args) {
11    return (... + args);
12}
13auto sumL() { return 0; }
14
15int main() {
16    std::cout << sumR() << std::endl; //0
17    std::cout << sumL() << std::endl; //0
18    return 0;
19}

虽然可行,但是还需要实现特化版本,还是比较麻烦的。更简单的方式是使用二元折叠表达式给定初值:

 1//二元右折叠表达式
 2template<typename... T>
 3auto sumR(T... args) {
 4    return (args + ... + 0);
 5}
 6
 7//二元左折叠表达式
 8template<typename... T>
 9auto sumL(T... args) {
10    return (0 + ... + args);
11}
12
13int main() {
14    std::cout << sumR() << std::endl; //0
15    std::cout << sumL() << std::endl; //0
16    std::cout << sumR(1, 2, 3, 4, 5) << std::endl; //15
17    std::cout << sumL(1, 2, 3, 4, 5) << std::endl; //15
18    return 0;
19}

如此一来,只需要定义一个模板函数即可,不需要定义特化版本,就可以实现传空参数的累加求和了。

注意事项

从累加求和的一元折叠表达实现中我们得到结论,累加时一元折叠表达式不支持空参数的参数包展开,必须实现一个特化版本。

但是对于一元折叠表达式,以下三种操作符可以直接支持空参数的参数包展开:

  • &&,逻辑与。空包的值代表true

  • ||,逻辑或。空包的值代表false

  • ,,逗号运算符。空包的值代表void()

 1template<typename... T>
 2bool all(T... args) { return (... && args); }
 3
 4template<typename... T>
 5bool any(T... args) { return (... || args); }
 6
 7template<typename TFunc, typename... Ts>
 8void for_each(TFunc&& f, Ts&&... args) {
 9    (f(args), ...);
10}
11
12int main() {
13    std::cout<< std::boolalpha;
14
15    bool b1 = all();
16    std::cout << "all() = " << b1 << std::endl; //true
17
18    bool b2 = all(true, false, true);
19    std::cout << "all(true, false, true) = " << b2 << std::endl; //false
20
21    bool b3 = any();
22    std::cout << "any() = " << b3 << std::endl; //false
23
24    bool b4 = any(true, false, true);
25    std::cout << "any(true, false, true) = " << b4 << std::endl; //true
26
27    for_each([](auto a){ std::cout << a << std::endl; }, 1, 2, 3); //逐行输出
28    for_each([](auto a){ std::cout << a << std::endl; }); //没有任何输出
29
30    return 0;
31}

从运行结果可以看到:

一元折叠表达式中,空包展开时,&&的结果默认为true

一元折叠表达式中,空包展开时,||的结果默认为false

一元折叠表达式中,空包展开时,,的结果默认为void(),因此for_each()其实没有执行任何操作。

Prev Post: 『C++17 if/switch语句初始化』
Next Post: 『C++17 string_view的原理』