C++17 Fold Expression

| | 0 Comments| 09:33
Categories:

之前 Heresy 曾經寫過一篇《C++11 的「…」:Parameter Pack》,算是簡單介紹了 C++11 新增的 「parameter pack」;這項功能,基本上就是用「...」來處理數量不固定的函式引數、以及 template parameter。

而在當時來說,真的要去處理這些數量不固定的 parameter pack,大概都是得用類似遞迴的方法來寫了。

到了 C++17,為了簡化這邊的開發難度,則是又引進了「fold expressions」的語法、來讓開發者可以更簡單地處理  parameter pack。(參考

基本語法

這邊基本語法有四個:

  • (pack op ...):unary right fold
  • (... op pack):unary left fold
  • (pack op ... op init):binary right fold
  • (init op ... op pack):binary left fold

這邊的 pack 就是包含 parameter pack 的一段 expression,在最簡單的狀況下會是之前用代表 parameter pack 的變數。

op 則是有支援的 binary operator(二元運算子)、包含常見的加減乘除在內總計 32 個;至於 init 的話,則應該算是這一連串處理時的一個額外的初始值,同樣也是一段 expression。

其中可能要注意的一點是,這邊的 () 是必要的,不可以省略。

而這邊雖然雖然有四種寫法,不過 binary fold 基本上是 unary fold 加上初始值,算是一種延伸寫法了。

至於 ... 放在前面或後面,則是影響處理的順序,如果連續的 op 操作不會受到順序影響的話,其實兩者的結果會完全相同。

以第一個 (pack op ...) 來說,假設 pack 裡面的資料是 { E1, E2, E3, E4 } 的話,那它實際上的運作就會是 ( E1 op ( E2 op ( E3 op E4 ))),也就是從右側開始做處理。

(... op pack) 則是順序反過來,變成是 ((( E1 op E2 ) op E3 ) op E4 )

Binary fold 的部分,則是額外把初始值也拿進來用而已,像是 (pack op ... op init) 就是變成 ( E1 op ( E2 op ( E3 op ( E4 op I ))))


簡單的範例

而實際寫的話,大概會是像下面這樣:

#include <iostream>
 
template<typename... Args>
bool all(Args... args)
{
  return (... && args);
}
 
int main()
{
  bool b = all(true, true, true, false);
  std::cout << b << std::endl;
}

all() 這個函式裡面,實際運作就是 return ((true && true) && true) && false 了。

如果像是搭配初始值使用的的 binary fold 的話,也可以寫成:

#include <iostream>
 
template<typename... Args>
void output(Args... args)
{
  (std::cout << ... << args);
}
 
int main()
{
  output(1, 2.5, "Hello");
}

這邊的 std::cout 就是 binary fold 的 init,實際上在運作時會是 ( ( (std::cout << 1) << 2.5 ) << "Hello" )


Parameter pack 的處理

前面有提到,pack 實際上是包含 parameter pack 的一段 expression,也就是其實是可以做一些額外的處理後,再進行 fold 運算的。

比如說下面這段程式:

#include <iostream>
#include <string>
 
template<typename... Args>
size_t size_sum(Args... strs)
{
  return (std::string_view(strs).size() + ...);
}
 
int main()
{
  std::cout << size_sum("abc", "defg", "x") << "\n";
}

在這個例子裡,size_sum() 這個函式是用來計算多個字串長度的總和的;而裡面的 unary right fold 黃底的部分,就是 pack

這邊會先把 strs 這個 parameter pack 的每一個元素先轉換成 std::string_view、然後再透過 size() 取得字串的大小,最後再用 + 這個 operator、把得到的字串大小都累加起來。

也由於是允許一個 expression,所以其實使用上的彈性也算滿大的、可以玩出很多功能。


搭配 comma operator

在 C++ 裡面,其實「,」也是一個 binary operator,他兩邊的程式都會去執行,但是會忽視左邊的回傳值、只採用右邊的回傳值。(老實說,個人幾乎沒有特別去注意過他?)

下面是一個簡單的例子:

#include <iostream>
 
int comp(const int v)
{
  std::cout << "compute with " << v << "\n";
  return v;
}
 
int main()
{
  int v = (comp(1), comp(2), comp(3));
  std::cout << "return: " << v << "\n";
}

這邊會去呼叫 comp() 三次,但是只有最後一次的結果會回傳出來,所以最後 v 的值會是 3。

而如果拿「,」來搭配 fold 使用的話,就可以做到類似 for 迴圈、針對 parameter pack 裡面的所有的元素各自去做彼此不相關的運算。

下面就是一個使用的例子:

#include <iostream>
#include <vector>
 
template<typename... Args>
std::vector<int> build_vec(Args... vals)
{
  std::vector<int> v;
  (v.push_back(vals), ...);
  return v;
}
 
int main()
{
  auto v = build_vec(1,2,3,4,5);
}

這邊就是透過 fold 來把 build_vec() 的所有引數,都丟到 std::vector 裡面。

不過這邊比較特別的,是在搭配「,」的時候,「...」放在前面或後面似乎都不會影響執行的順序?也就是說 unary right fold 和 unary left fold 的結果看來都是一樣的。


改良版的輸出

前面 output() 的例子,其實輸出的結果算是相當不好看的;而如果想要讓輸出結果更漂亮,可以寫成:

#include <iostream>
 
template<typename T>
void output_impl(const T& v)
{
  std::cout << ", " << v;
}
 
template<typename T1, typename... Args>
void output(const T1& v1, Args... args)
{
  std::cout << "[ " << v1;
  (output_impl(args), ...);
  std::cout << " ]";
}
 
int main()
{
  output(1, 2.5, "Hello");
}

這邊的 output() 是修改成把第一個引數獨立出來當作 v1 來處理、 剩下的才當作 parameter pack;而在透過 fold 展開的時候,則是呼叫 output_impl() 這個函式,先輸出 ", " 再輸出變數的值、以作為間隔。這樣的結果,會比本來的版本好看許多~

而如果不想另外定義一個函式的話,其實也可以透過 lambda 來簡化:

#include <iostream>
 
template<typename T1, typename... Args>
void output(const T1& v1, Args... args)
{
  std::cout << "[ " << v1;
  ([](const auto& v) {std::cout << ", " << v; }(args), ...);
  std::cout << " ]";
}
 
int main()
{
  output(1, 2.5, "Hello");
}

這樣寫起來就更單純了。


針對所有元素個別處理

前面的寫法基本上都是靠 parameter pack 來自動展開,不過如果真的要針對個別元素做處理的話,其實也是可以很簡單地透過建立對應的函式來做到的。

例如下面的程式:

#include <iostream>
#include <typeinfo>
 
template<typename T>
void check_impl(const T& v)
{
  std::cout << typeid(v).name() << ": " << v << "\n";
}
 
template<typename... Args>
void check_type(Args... args)
{
  (check_impl(args), ...);
}
 
int main()
{
  check_type(1, 2.5, "Hello");
}

這邊的 check_impl() 就是會取得個別的元素、並輸出他的型別名稱以及值;而如果要針對不同型別做不同的處理,就只要實作針對不同型別的 overload 就可以了。

而如果不想要另外定義一個函式話,其實也可以透過 lambda 來實作:

template<typename... Args>
void check_type(Args... args)
{
  ([](const auto& v) {
    std::cout << typeid(v).name() << ": " << v << "\n";
  }(args), ...);
}

這篇就寫到這裡了。

基本上,fold expression 本質上基本上就是搭配不確定數量引數的 parameter pack 的東西,基本上主要會是編譯階段就要處理的東西。

老實說,這種也算是 Heresy 這邊比較少碰到的東西了。所以這邊雖然花了好些時間來研究、整理,但是實際上到底要用在哪裡?其實還沒有真的想清楚…


附註:

  • 支援的 binary operator 有:+-*/%^&|=<><<>>+=-=*=/=%=^=&=|=<<=>>===!=<=>=&&||,.*->*

參考:潮.C++17 | Fold Expression 寫 C++ 像寫數學式

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *