Non-Profit, International

Spirit unsterblich.

C++ 17 折叠表达式

字数统计:1996 blog

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

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

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

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

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

一元折叠

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

  • unary left fold: (... op E) expends to (E1 op (... op (EN-1 op EN)))
  • unary right fold: (E op ...) expends to (((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 来构造一个 constexpr 的 “std::string”,以及 C++ 20 添加了 std::string 的 constexpr 构造,但是目前(2021,G++ 11)编译器并没有很好的支持这两个特性。

一元折叠技巧

  通过上面以及之前的代码可以实现一个打印函数,这个函数每打印一次就可以换一次行,也可以打印多次最后再换行,那么可不可以像参数列表一样只在前 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), ...);
}

二元折叠

  • binary left fold: (I op ... op E) expand to ((((I op E1) op E2) op ...) op EN)
  • binary right fold: (E op ... op I) expand to (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 许可协议 提供。