潮.C++14 | Generic Lambda:無腦寫不用管型別!?還能特技表演

TJSW
8 min readMay 8, 2021

--

Photo by Dakota Roos on Unsplash

C++11 帶來了大家期待已久的 lambda,相信同學們在 2021 年的今天已經很熟悉這個翻天覆地的革新所帶來的好處了。今天想稍微提一下 C++14 開始為 lambda 添加的更方便的語法糖。

從 Lambda 的好處開始說起

同學們想要排序一個裝著 intvector 古時候可能這樣寫

#include <iostream>
#include <vector>
#include <algorithm>
bool icmp(int a, int b) { return a < b; }int main()
{
std::vector<int> iv{4, 3, 1, 6, 5};
std::sort(iv.begin(), iv.end(), icmp);
for (auto && i : iv) std::cout << i << " ";
std::cout << '\n';
// 1 3 4 5 6
}

需要額外寫一個比較 int 大小用的函式叫 icmp 傳給 std::sort。好的幫同學們回憶到這。

C++11 引入了 lambda 之後,我們想要排序一個 intvector 以及 std::stringvector,上面的情況就變成這樣:

int main()
{
using namespace std::string_literals;
std::vector iv{4, 3, 1, 6, 5};
std::vector sv{"abc"s, "ab"s, "a"s};
auto icmp = [] (int a, int b) { return a < b; };
auto scmp = [] (std::string a, std::string b) { return a < b; };
std::sort(iv.begin(), iv.end(), icmp);
std::sort(sv.begin(), sv.end(), scmp);
for (auto && i : iv) std::cout << i << " ";
std::cout << '\n'; // 1 3 4 5 6
for (auto && s : sv) std::cout << s << " ";
std::cout << '\n'; // a ab abc
}

好處大概就是

  • icmpscmp 這兩個功能局限性的函式可以一起綁在 main() 裡面,防止污染其他作用域。
  • 代碼放的距離實際使用地點越靠近,閱讀方便性就提升惹。

但上面這樣搞還是有一個顯而易見的問題,那就是如果我們還想要排序一個裝著 doublevector,那就必需再寫一個 lambda 比較兩個 double 的大小:

auto dcmp = [] (double a, double b) { return a < b; }

每次排序不同的型別就要寫一個 lambda 也是有點兒累🥱。所以講到這邊同學們應該看出來我們想要做到:輸入各種型別但實作同一套(類似的)邏輯,也就是泛型 generic 啦 。

泛型的比較函式和 generic lambda

實作泛型的需求,很自然就就想到老朋友函式模板 function template。就是那一位利用一連串不明所以的語法像是 template<typename T> bool cmp (T);呼叫時推導參數型別 T 達到泛型的需求。

在 C++14 就把這個推導型別的概念跟 C++11 的 auto 攪拌在一起,帶入了我們的 lambda 同學。同學們應該很熟悉在 C++11 利用 auto 省略很長懶得寫的型別,或是當前邏輯區段他是什麼不重要的型別(反正編譯器自己知道那是什麼)。

auto i = 2 + 3; // int
auto d = 2. + 3; // double

這裡的 auto 正是型別推導的概念,C++14 把這個型別推導的概念直接放入 lambda 的參數,稱為 Generic lambda。

上面的兩種 icmpscmp 就可以縮減成一個宣告:

// C++11: lambda
auto icmp = [] (int a, int b) { return a < b; };
auto scmp = [] (std::string a, std::string b) { return a < b; };
// C++14: generic lambda
auto cmp = [] (auto a, auto b) { return a < b; };
std::sort(iv.begin(), iv.end(), cmp);
std::sort(sv.begin(), sv.end(), cmp);

不管要排序 intvector 或是 std::stringvector,全部都無腦傳入 cmp 即可,而且裡頭邏輯就是一行簡單的 return a < b; 就可以應付各種型別。

同學們可以想像成和函式模板一樣的概念:需要呼叫 cmp 的地方,編譯器會推導出型別,立即生成 cmp(int, int)cmp(std::string, std::string) 兩種版本的 cmp

當然如果你傳入了不能比較大小的物件還是會壞掉的啦…

基本操作

和 C++11 的 lambda 一樣的基本操作還是可以動的,例如參數可以傳參照,傳指標,可以加上 const 修飾,可以捕獲外部變數等等。

int a = 10;
auto f = [&a] (auto x, const auto &y) { a += x + y; return a; }
int b = f(10, 1);
int c = f(10, 1);
// a: 32, b: 21, c: 32

Generic Lambda 的高級操作

把 lambda 的呼叫部分撈出來用

根據 C++14 的 spec 條文提到:generic lambda 其實產生了一個物件(這點和 C++11 一樣)但其中擁有一個 inline public 的函式模板 operator()()。也就是說我們的 cmp 其實等價於:

auto cmp = [] (auto a, auto b) { return a < b; };struct /* anonymous */ {
template<typename T, typename U>
inline auto operator()(T t, U u) { return t < u; }

} cmp;

講這一段是要說你可以這樣操作 generic lambda

auto cmp = [] (auto a, auto b) { return a < b; };double a = 1.5, b = 1.1;
bool result1 = cmp.operator()<int, int>(a, b); // false
bool result2 = cmp.operator()<int, int>(b, a); // false

也就是把我們的泛型比較 lambda 當作吃兩個 int 去比較整數部分大小…

眼睛很利的同學可能要問了:參數一個是 T 一個是 U,那我能宣告一個限定同樣都是 T 的 generic lambda 嗎?

答案是:在 C++14 不行,之後再說 😁

退化成函式指標 function pointer

C++11 的時候同學們應該都知道,沒有捕獲 (capture) 外部變數的 lambda 可以退化成簡單的函式指標,這點同樣適用 C++14 的 generic lambda。利用這個特性,我們甚至可以直接指定退化的函式指標的參數型別,使得這個函式指標呼叫時等價於 generic lambda 強制轉型參數變成指定的型別。直接上代碼

std::vector dv{4.1, 3.4, 4.2, 3.3, 2.};auto cmp = [] (auto a, auto b) { return a < b; };bool(*icmp)(int, int) = cmp;std::stable_sort(dv.begin(), dv.end(), icmp);for (auto && d : dv) { std::cout << d << " "; }
std::cout << '\n';
// 2 3.4 3.3 4.1 4.2

這個例子中,我們把 cmp 退化成比較兩個 int 的函式指標 icmp,去排序小數的整數部分,且利用 stable_sort,保留 dv 裡面的原本順序,可以看到輸出結果真的按照整數部分大小排序了,但同樣整數部分的人維持著原本的順序 (3.4 在 3.3 前面,但 4.1 在 4.2 之前)。

Lambda 遞迴

相信同學們看標題就知道要做什麼噁心的事情了。在 C++11 其實就可以做到,但是很彆扭。假設我們要做一個計算階層 (factorial) 的 lambda,在 C++11 同學們可以這樣寫:

std::function<int(int)> fact;
fact = [&fact] (int n) {
return n <= 0 ? 1 : n * fact(n - 1);
};
std::cout << fact(5) << '\n';
// 120

關鍵在於多宣告一個 std::function<int(int)> fact 去當作本地變數,再利用 lambda capture 把他抓進去這樣才能遞迴呼叫。

到了 C++14 可以這樣搞:

auto fact = [] (auto self, int n) -> int {
return n <= 0 ? 1 : n * self(self, n - 1);
};
std::cout << fact(fact, 5) << '\n';

至於你問為什麼要 lambda 寫遞迴…呃,我本人是不會在開發產品中寫啦,我只是知道可以這樣子寫而已

講完了

簡單說 C++14 generic lambda 就是在 C++11 有了 lambda 這麼方便的東西之後,大家亂用發現很好用,但是覺得字還是打太多了,予取予求增加更多變化。

但這只是一個初步的泛型擴展,大家之後就發現這個太高自由度的 generic lambda 需要限縮 (例如上面提到的限制兩個泛型參數必須是同一個型別),或是又有人覺得想要更自由(例如想要不限定參數個數…),就留到之後 C++17 還有 C++20 來說啦,掰掰

--

--