Non-Profit, International

Spirit unsterblich.

C++17 折叠表达式

字数统计:2186 blog

C++17 中对可变参数模板的参数包进行了一项改进,即使用折叠表达式(Fold Expression)来简化递归式的“函数调用”,简化了语法。

在之前的文章 C++ 可变参数模板C++17 constexpr if 和 constexpr lambda 中都有提到过参数包(parameter pack),其中利用参数包实现了一个接收任意数量的参数的 variadicPrint 打印函数,实际上还可以通过折叠表达式进一步简化。

使用折叠表达式的前提是使用受支持的 32 个操作符:+ - * / % ^ & | = < > << >> += -= *= /= %= ^= &= |= <<= >>= == != <= >= && || , .* ->*

C++17 的折叠表达式根据标识符的位置分为左折叠和右折叠,根据操作的对象数量分为一元折叠和二元折叠。

只有三个运算符允许参数包为空:&& ||,,其中&& 为 true,|| 为false,,void()

一元折叠

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

  • 一元左折叠:(... op E) 展开为 (E1 op (... op (EN-1 op EN)))
  • 一元右折叠:(E op ...) 展开为 (((E1 op E2) op ...) op EN)

折叠表达式其实就是使用折叠标记 ... 和参数包 args 将参数展开的的语法糖,任何折叠表达式都包含折叠标记,标识符和操作符三部分。

最简单的折叠表达式的实例是求和函数:


template <typename ... Ts>
auto sumL(Ts ... ts)
{
    return (ts + ...); // 右折叠
}

template <typename ... Ts>
auto sumR(Ts ... ts)
{
    return (... + ts); // 左折叠
}

当调用 sum(1, 2, 3, 4, 5) 时,右折叠会沿右侧不断将参数包展开,变为 1 + (2 + (3 + (4 + 5)))(括号只是为了说明展开方向,真实结果不会添加括号),这其中经历了 3 次展开,第一次展开为 1 + (2 + ts),然后继续进行第二次展开为 1 + (2 + (3 + ts))。左折叠的展开方向与之相反。

还可以有下面这个稍微复杂点的例子:


template <class... T>
void variadicPrint(T... t)
{
    ((std::cout << t), ...) << std::endl;
}

template <class... T>
void variadicPrint(T... t)
{
    (..., (std::cout << t << std::endl)); // 左右折叠都可
}

其中 (std::cout << t)(或者 (std::cout << t << std::endl))是包含参数包的表达式,编译阶段使用逗号运算符连接展开的表达式,复制 (std::cout << t),并将参数包 t 替换为实际参数。

对于大部分不需要考虑结合性的情况,左折叠和右折叠没有区别;其他一些情况则需要考虑结合性,例如 std::string 的字符串字面值赋值,必须使用右折叠,因为字符串字面值是右值,不能做加运算,此外除法和减法也类似。

由于模板是在编译期进行推导,所以其实不必通过函数的参数传递参数,允许直接将参数直接传递给模板:


template <auto... T>
void variadicPrint()
{
    ((std::cout << T), ...) << std::endl;
}

int main()
{
    variadicPrint<1,2,3>();
}

不过这也存在着非常明显的缺陷:模板参数类型必须为常量,所以必须为 constexpr 类型的变量才可做为模板参数,这极大的限制了这个函数的用途,因为用户自定义类基本都不是 constexpr 的。

C++17 添加了 std::string_view 来构造一个不需要内存分配的“字符串”,以及 C++20 添加了 std::string 的 constexpr 构造,因此可以通过此方法通过模板参数输出一个字符串:


#include <iostream>
#include <string_view>
using namespace std::literals;
template <const auto&... T>
void variadicPrint() {
    ((std::cout << T), ...) << std::endl;
}
constexpr auto a = "aaa"sv; // 注意,a 必须是具有静态储存期的常量表达式
int main() { variadicPrint<a>(); }


一元折叠技巧

通过上面以及之前的代码可以实现一个打印函数,这个函数每打印一次就可以换一次行,也可以打印多次最后再换行,那么可不可以像参数列表一样只在前 n - 1 次打印时输出逗号,最后一次不输出呢?答案肯定是可以的,有四种方法可以实现这种效果:

使用运行期迭代:


template <class ...T>
void variadicPrint(T... t)
{
    constexpr last = sizeof ...(t) - 1;
    int i = 0;
    ((i < last ? (std::cout << t << ", ") : (std::cout << t <<std::endl), ++i), ...);
}

使用 if constexpr:


template <typename T, typename... Ts>
void variadicPrint(T head, Ts... tail)
{
    std::cout << head << ", ";
    if constexpr (sizeof...(tail) > 0)
        variadicPrint(tail...);
    std::cout << std::endl;
}

使用 lambda 递归:


template<typename Head, typename... T>
void variadicPrint(const Head& head, const T&... args) {
    std::cout << first;
    auto wrapper = [](const auto& arg){
        std::cout<<", ";
        return arg;
    };
    (std::cout << ... << wrapper(args));
}

使用 lambda 迭代:


template <class ...T>
void variadicPrint()
{
    constexpr last = sizeof ...(t) - 1;
    int i = 0;
    auto wrapper = [i]<class Arg>(Arg arg) mutable // C++20
    {
        if (last == i)
        {
            std::cout << arg << endl;
        } else
        {
            std::cout << arg << ", ";
        }
    }
    (wrapper(t), ...);
}

constexpr if 的写法其实是效率最高也最直观的,因此一般推荐使用该方法。

二元折叠

  • 二元左折叠:(I op ... op E) 展开为 ((((I op E1) op E2) op ...) op EN)
  • 二元右折叠:(E op ... op I) 展开为 (E1 op (... op (EN-1 op (EN op I))))

虽然一元折叠已经足够好用,但是二元折叠仍然有其用武之地:


template<typename... Ts>
int removeFrom(int num, Ts... args)
{
    return (num - ... - args); //Binary left fold
    // Note that a binary right fold cannot be used
    // due to the lack of associativity of operator-
}

int result = removeFrom(1000, 5, 10, 15); //'result' is 1000 - 5 - 10 - 15 = 970

参考:

若无特殊声明,本文以 CC BY-SA 3.0 许可协议 提供。