本篇作为 Understanding variadic templates 的进阶内容,同时,Fold Expressions 也是 C++17 最常用的特性之一。

Fold Expressions 的基本概念

C++11中,参数包只能在需要参数列表的上下文展开,比如函数递归。而递归函数需要终止条件,因此往往需要提供一个同名的函数来终止递归。

举个例子:

void print()
{
    std::cout << '\n';
}

template<typename F, typename... Args>
void print(F first, Args... args)
{
    std::cout << first << ' ';
    print(args...);
}

我们无法在函数主体中展开,例如不能这样做:

template<typename... Args>
void print(Args... args)
{
    std::cout << ... << args << std::endl;
}

但是可以通过逗号表达式和初始化列表在函数主体展开参数包。

例子如下:

template<typename... T>
void print(T&&... args)
{
    std::initializer_list<int>{([](const auto& t) {
                std::cout << t << std::endl;
        }(std::forward<T>(args)), 0)...};
}

短短的一行代码中,便已使用了 initializer listlambdaperfect forwardingcomma operator 等多种特性。

由于参数包只能在需要参数列表的上下文展开,因而不用递归,便需要一个支持可变参数的类型,initializer_list 刚好满足条件。

此外,我们知道 initializer_list 只支持单类型,而需要处理的参数却是不同类型的,所以再借助逗号表达式来实现目的。

逗号表达式基于一个基本的事实,看如下代码:

int a = 1;
int b = 2;
int c = 5;
a = (a = b, c);

a 最终的结果将为 5,在逗号操作符所连接的表达式中,会从左往右依次执行。本例中,首先会执行 (a = b),然后返回 c,所以 a 的值便为 c 的值。

依据这个事实可以在 initializer_list 中展开参数包,表面上是在初始化 initializer_list,实际上是在初始化过程中利用逗号表达式展开参数包。

这样的代码写起来依旧麻烦,所以在 C++17,提供了 Fold Expressions(折叠表达式),可以直接在函数主体中展开参数包。

可以简单地通过 Fold expr 来改写上述例子:

template<typename... Args>
void print(Args... args)
{
    ((std::cout << args << ' '), ...);
    std::cout << '\n';
}

具体细节,见于下节。

Fold Expressions 的细节疏理

Fold Expression 的语法比较简单,可以直接参考 cppref

语法分为一元操作和二元操作。pack 指的就是参数包,op 指的是操作类型,有一个 op 的是一元操作,两个 op 的是二元操作。

我们先来看一元操作,来写一个最简单的输出函数:

template<typename... T>
void print(T&&... args)
{
        (std::cout << ... << args) << '\n';
}

这是使用的是第(2)个语法,即一元左折叠,需要注意的是:括号也是语法的一部分。

因为 cout 输出后会返回一个 ostream,所以可以不断输出所有参数,展开后的代码如下:

print(1, 2, 3, 4);
((((std::cout << 1) << 2) << 3) << 4) << '\n';

但是注意不能这样写:

std::cout << (args << ... << '\n');

这是右折叠,别忘了 operator<< 本义为左移操作符,所以执行结果会出人意料。

上述输出函数的缺点在于输出内容之间没有空格,若要在每个参数的输出之间加入空格,最简单的方式是借助逗号表达式,例子便是上节末尾的代码:

template<typename... Args>
void print(Args&&... args)
{
    ((std::cout << args << ' '), ...);
    std::cout << '\n';
}

当然还有其它的做法,若你想在处理参数之前或之后添加额外的操作,可以额外定义一个函数:

template<typename F, typename... Args>
void print(F first, const Args&... args)
{
    std::cout << first;
    auto space = [](const auto& arg) {
        std::cout << ' ' << arg;
    };
    (..., space(args));
    std::cout << '\n';
}

区别在于,第二种做法不支持 0 个参数,而逗号表达式可以支持,不过小做修改便可支持:

template<typename... Args>
void printer(const Args&... args)
{
    auto space = [](const auto& arg) {
        std::cout << arg << ' ';
    };

    (..., space(args));
    std::cout << '\n';
}

但是不是任何时候都可折叠0个参数的,比如:

template<typename... Args>
auto sum(Args... args)
{
    return (... + args);
}

这是个求和函数,它必须要返回一个值,所以 0 参时会报错。

这种情况就应该使用二元折叠,你可以翻回本节头部去查看二元的语法,其中的 init 的意思就是初始值,面对 0 参时依旧可以运行。

因此例子可以更改为:

template<typename... Args>
auto sum(Args... args)
{
    // binary left fold
    return (0 + ... + args);

    // binary right fold
    //return (args + ... + 0);  
}

这里使用了二元左折叠,在这种情况下和二元右折叠没有区别,但一般都优先使用左折叠。

假如我们要相加字符串,那么左折叠与右折叠便有差异了。

重写一个例子用来连接字符串:

template<typename... Args>
auto strcat(Args&&... args)
{
    // unary left fold
    return (... + args);
}

若像下面这样调用:

std::cout << strcat(std::string{"Have"}, "a", "nice", "day!") << '\n';

输出结果将为:

空格问题暂且不论。现在若把参数调用的顺序稍微切换,

std::cout << strcat("Have", "a", "nice", std::string("day!")) << '\n';

便是完全不同的结果:

现在编译不过了。

这是由于原生字符串不支持 operator+,第一个调用中将 string 放在首位,因为它内部重载了 operator+,所以可以进行加法,之后会返回一个 string,遂可将所有参数组成完整的字符串。

而第二个调用中,string 处于尾部,而实现却为一元左折叠,由于前面的参数不支持 operator+ 而编译出错。

只需将 strcat 改为一元右折叠便能编译第二个调用,但此时又无法支持第一个调用。

解决之道如下:

template<typename... Args>
auto strcat(Args&&... args)
{
    // unary left fold
    return ((std::string{} + args + " ") + ...);
}

对于每一个字符串,都在开头添加一个空 string,这样就能针对所有形式。

Filter Fold Expressions

若你想对解包结果添加约束,可以组合逗号表达式和逻辑运算符(&&, ||, !)使用来添加过滤。

举个例子,若想写一个只打印偶数的输出函数,可以这样编写:

#include <iostream>

template<typename... Args>
void print_even_number(Args... args)
{
    bool b = false;
    ((b = [](int arg) { return arg % 2 == 0; }(args) && (std::cout << args << ' ')), ...);
    std::cout << '\n';
}

int main()
{
    print_even_number(1, 2, 3, 4, 5, 6, 12, 11, 18, 19);

    return 0;
}

输出结果为:

给了一个未使用变量警告,可以使用 C++17 的 [[maybe_unused]] 特性来消除。

bool b [[maybe_unused]] = false;

此外,还可以结合 type traits 来使用,比如我们想实现一个判断容器类型是否一致的功能,可以这样编写:

template<typename H, typename... Ts>
struct is_same_type {
    static constexpr bool value =  (std::is_same_v<H, Ts> && ...);
};

// will be true
is_same_type<int, int, decltype(3)>::value;

标准中的 array 就使用了这个技巧来判断类型是否一致。

编译期排序

Fold Expressions 也可以和一些算法结合起来使用。

这里有个来自网络的编译期排序例子,可以参考一 二。

#include <iostream>
#include <array>
#include <utility>

template<typename Values> struct SortImpl;

template<typename I, I... values>
constexpr auto sort(std::integer_sequence<I, values...> sequence)
{
    return SortImpl<decltype(sequence)>::sort();
}

template<typename I, I... values>
struct SortImpl<std::integer_sequence<I, values...>>
{
    static constexpr auto sort() {
        // 创建4位索引序列
        return sort(std::make_index_sequence<sizeof...(values)>{});
    }

    template<std::size_t... index>
    static constexpr auto sort(std::index_sequence<index...>) {
        // 创建integer_sequence,用于返回排序好的结果
        return std::integer_sequence<I, ith<index>()...>{};
    }

    template<std::size_t i>
    static constexpr auto ith() {
        I result{};

        // 利用rankOf计算当前数值所应排列的位置,再和索引比较,相同则返回对应的数值
        ((i >= rankOf<values>() && i < rankOf<values>() + count<values>()
                ? result = values : I{}), ...);
        return result;
    }

    template<I x> // 排序位置,例如第一次调用:(0 > 5) + (0 > 2) + (0 > 2) == 0,则应排第0个
    static constexpr auto rankOf() { return ((x > values) + ...); }

    template<I x> // 计算相同数值的个数
    static constexpr auto count() { return ((x == values) + ...); }
};

template<typename I, I... values>
constexpr auto toArray(std::integer_sequence<I, values...>)
{
    // 转换成数组,以便输出
    return std::array<I, sizeof...(values)> { values... };
}

int main()
{
    auto y = toArray(sort(std::index_sequence<0, 5, 2, 2>{}));

    for(auto& elem : y) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';
}

关键位置我已经提供了注释,主要是利用了 integer_sequence 和 fold expression,前者用于提供索引,后者用于找出该索引所应对应的值的位置和计算重复次数,索引与位置重复次数一比较,就能找到索引对应的值。

运行结果如下:

Pretty-print std::tuple

我们还可以利用 fold expressions 来方便地打印 tuple 的元素,例子如下:

#include <iostream>
#include <tuple>
#include <utility>

template<typename T, std::size_t... Is>
void print_tuple(const T& tup, std::index_sequence<Is...>)
{
    std::cout << "(";
    (..., (std::cout << (Is == 0 ? "" : ", ") << std::get<Is>(tup)));
    std::cout << ")\n";
}

template<typename... T>
void print_tuple(const std::tuple<T...>& tup)
{
    print_tuple(tup, std::make_index_sequence<sizeof...(T)>());
}

int main()
{
    print_tuple(std::make_tuple("apple", "pineapple", "cherry", "lemon", "mango"));
}

输出如下:

std::tuple 提供的 get<I>(tup) 是编译期的,若需要运行期访问 tuple,也很简单,代码如下:

template<typename T, std::size_t... Is>
void get_tuple(std::size_t i, const T& tup, std::index_sequence<Is...>)
{
    (..., ((Is == i) && (std::cout << std::get<Is>(tup))));
    std::cout << '\n';
}

template<typename... T>
void get_tuple(std::size_t i, const std::tuple<T...>& tup)
{
    get_tuple(i, tup, std::make_index_sequence<sizeof...(T)>());
}

// call
auto tup = std::make_tuple("apple", "pineapple", "cherry", "lemon", "mango");
get_tuple(1, tup);

输出如下:

折叠函数调用

Fold expressions 也可以用折叠访问任意基类的共有成员,一个小例子:

#include <iostream>

template<typename... Bases>
struct Foo : private Bases...
{
    void print() {
        (..., Bases::print());
    }
};

struct A {
    void print() { std::cout << "A::print()\n"; }
};

struct B {
    void print() { std::cout << "B::print()\n"; }
};

struct C {
    void print() { std::cout << "C::print()\n"; }
};

int main()
{
    Foo<A, B, C> foo;
    foo.print();
}

输出将为:

总结

Fold expressions 可以简化代码,完成一些本来实现起来较麻烦的操作。

本篇介绍了基本概念与用法,并提供了大量的例子,相信大家读完之后对其已不陌生。

实际上,它的用法还有很多,有一些用法相当复杂,大家平时可以再多思考下它的其它用处。此外,C++20 的一些特性也能和它结合使用,等介绍到的时候会补充。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.