摘要訊息 : C++ 17 引入了折疊表達式, 幫助程式碼設計師簡化引數包相關的程式碼.

0. 前言

C++ 11 引入了可變參數樣板, 但是我們在處理引數包的時候通常要使用遞迴來展開 :

template <typename T, typename U>
auto sum(T t, U u) {
    return t + u;
}
template <typename T, typename U, typename ...Args>
auto sum(T t, U u, Args ...args) {
    return sum(t + u, args...);
}

由於遞迴需要一個結束的標誌, 因此我們通常都需要函式多載, 寫出兩個函式來展開引述報導.

N4295《Folding Expressions》提出了使用折疊表達式來簡化處理引數包.

更新紀錄 :

  • 2022 年 4 月 20 日進行第一次更新和修正.

1. 提案內容

C++ 17 引入了四種折疊表達式 :

  • 一元右摺疊 : (pack operator ...);
  • 一元左摺疊 : (... operator pack);
  • 二元右摺疊 : (pack operator ... operator argument);
  • 二元左摺疊 : (argument operator ... operator pack).

其中, pack 是引數包, operator 是運算子, argument 是單個運算引數. 支援折疊表達式的運算子有 : +, -, *, /, %, ^, &, |, =, <, >, <<, >>, +=, -=, *=, /=, %=, ^=, &=, |=, <<=, >>=, ==, !=, <=, >=, &&, ||, ,, .*->*. C++ 20 引入的新運算子 <=> 也支援折疊表達式.

一元折疊表達式和二元折疊表達式的區別就在於折疊表達式中有沒有單個運算引數, 而左折疊和右折疊的區別就在於折疊表達式的計算是從左邊開始還是從右邊開始.

1.1 一元右折疊

以表達式 1 + 2 + 3 + 4 為例, 一元右折疊表示運算子是 +, 引數包 args 中有 1, 2, 3, 4. 一元右折疊表達式 args + ... 計算的順序是首先計算 3 + 4 = 7, 然後從引數包中去除 34, 在引數包的尾部加入 7, 現在引數包中有 1, 2, 7; 再計算 2 + 7, 然後從引數包中去除 27, 在引數包尾部加入 9, 現在引數包中有 1, 9; 最後計算 1 + 9 並回傳結果.

#include <iostream>

#define expand f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]

auto count {0};

class Foo {
private:
    int flag;
public:
    Foo() : flag {::count++} {}
    Foo operator+(const Foo &foo) noexcept {
        std::cout << this->flag << " ";
        return {};
    }
};

int main(int argc, char *argv[]) {
    Foo f[10] {};
    auto unary_right_fold {[](auto ...args) noexcept -> auto {
        (args + ...);
    }};
    unary_right_fold(expand);       // 輸出 : 8 7 6 5 4 3 2 1 0 
}

1.2 一元左折疊

以表達式 1 + 2 + 3 + 4 為例, 一元右折疊表示運算子是 +, 引數包 args 中有 1, 2, 3, 4. 一元左折疊表達式 ... + args 計算的順序是首先計算 1 + 2 = 3, 然後從引數包中去除 12, 在引數包的頭部加入 3, 現在引數包中有 3, 3, 4; 再計算 3 + 3, 然後從引數包中去除 33, 在引數包頭部加入 6, 現在引數包中有 6, 4; 最後計算 6 + 4 並回傳結果.

#include <iostream>

#define expand f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]

auto count {0};

class Foo {
private:
    int flag;
public:
    Foo() : flag {::count++} {}
    Foo operator+(const Foo &foo) noexcept {
        std::cout << this->flag << " ";
        return {};
    }
};

int main(int argc, char *argv[]) {
    Foo f[10] {};
    auto unary_left_fold {[](auto ...args) noexcept -> auto {
        (... + args);
    }};
    unary_left_fold(expand);        //輸出 : 0 10 11 12 13 14 15 16 17 
}

Code 1Code 2 表現完全不同, 關鍵就在於隱含繫結到 Foo::operator+ 的第一個參數的 this 不同. 在 Code 1 中, 首先計算的是 f[8] + f[9], 實際上呼叫的是 f[8].operator+(f[9]). 因此最終的輸出是 8. 而在 Code 2 中, 首先計算的是 f[0] + f[1], 實際上呼叫的是 f[0].operator+(f[1]). 這裡會輸出 1. 但是向引數包中移除 f[0]f[1] 之後, 放入的是成員變數 flag 值為 10 的物件, 於是第二個輸出便是 10. 接下來也是類似.

1.3 二元右折疊

二元右折疊的計算順序和一元右折疊是相同的, 但是二元右折疊在引數包後面多了一個運算元. 例如現在引數包 args 中的引數是 1, 2, 3, 4, 那麼二元右折疊表達式 args + ... + 5 相當於在引數包 args 的最後加上一個 5, 得到一個新的引數包 new_args = 1, 2, 3, 4, 5, 然後對新的引數包進行一元右折疊 new_args + ... 計算. 也就是先計算 4 + 5 的值.

#include <iostream>

#define expand f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]

auto count {0};

class Foo {
private:
    int flag;
public:
    Foo() : flag {::count++} {}
    Foo operator+(const Foo &foo) noexcept {
        cout << this->flag << " ";
        return {};
    }
};

int main(int argc, char *argv[]) {
    Foo f[10] {};
    auto binary_right_fold {[](auto ...args) noexcept -> auto {
        (args + ... + Foo());
    }};
    binary_right_fold(expand);      //輸出 : 9 8 7 6 5 4 3 2 1 0 
}

1.4 二元左折疊

二元左折疊的計算順序和一元左折疊是相同的, 但是二元左折疊在引數包和引數包展開符前面多了一個運算元. 例如現在引數包 args 中的引數是 1, 2, 3, 4, 那麼二元左折疊表達式 5 + ... + args 相當於在引數包 args 的最前加上一個 5, 得到一個新的引數包 new_args = 5, 1, 2, 3, 4, 然後對新的引數包進行一元左折疊 ... + new_args 計算. 也就是先計算 5 + 1 的值.

#include <iostream>

#define expand f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]

auto count {0};

class Foo {
private:
    int flag;
public:
    Foo() : flag {::count++} {}
    Foo operator+(const Foo &foo) noexcept {
        cout << this->flag << " ";
        return {};
    }
};

int main(int argc, char *argv[]) {
    Foo f[10] {};
    auto binary_left_fold {[](auto ...args) noexcept -> auto {
        (Foo() + ... + args);
    }};
    binary_left_fold(expand);       //輸出 : 10 11 12 13 14 15 16 17 18 19 
}

1.5 特殊情況

當面對參數包為空的時候, 折疊表達式也有一些額外的處理. 對於 && 運算子, 折疊表達式的預設值為 true; 對於 ||運算子, 折疊表達式的預設值為 false; 對於 , 運算子, 折疊表達式的回傳結果是 void().

2. 評論

折疊表達式可以說是大大地簡化了引數包的處理, 原來對引數包展開需要至少實作兩個多載的函式, 現在僅僅需要一個表達式. 當然, 折疊表達式並不能處理樣板參數包, 樣板參數包的處理需要使用樣板超編程.