C++ lambda的演化
- 2019 年 10 月 3 日
- 筆記
翻譯自https://www.bfilipek.com/2019/02/lambdas-story-part1.html、https://www.bfilipek.com/2019/02/lambdas-story-part2.html與https://leanpub.com/cpplambda。
原作者授權:
C++ lambda的演化
C++ Lambda Story
The evolution of a powerful modern C++ feature:
From C++03 to C++20
目錄
前言
本書是bfilipek.com上兩篇文章的更新版:
Lambdas: From C++11 to C++20, Part 1
Lambdas: From C++11 to C++20, Part 2
本書介紹lambda表達式,我們將從C++03開始,一路進入最新的C++標準。
· C++11——早期。你將了解到lambda表達式的組成以及一些技巧。這是最長的一章,因為我們要講很多內容。
· C++14——更新。lambda被加入標準以後,我們發現有很多地方可以優化。
· C++17——更多改進,特別是處理this指針,以及允許constexpr。
· C++20——在這一章中我們將一瞥未來。
回饋
如果你發現任何錯誤,請讓我們知曉!可以給bartlomiej.filipek AT bfilipek.com(將AT替換為@)發郵件,或在Leanpub的回饋頁面留下回饋。
這本書中的程式碼基於Creative Commons協議。
本書中的很多程式碼可在Wandbox在線編譯器中運行,正文中有相應鏈接。這裡還有一個在線編譯器列表:在線C++編譯器列表。
關於作者
Bartłomiej Filipek是一位擁有超過11年專業經驗的C++軟體開發者。他於2010年畢業於波蘭克拉科夫市雅蓋隆大學並獲得電腦科學碩士學位,現供職於Xara。
Bartek自2011年開始在他的網站bfilipek.com寫部落格。早期的主題圍繞圖形編程,現在的博文聚焦核心C++。他也是克拉科夫C++用戶組的協辦者。你還可以在CppCast episode上收聽他講的C++17、部落格與文字處理。
自2018年10月,Bartek成為直接與ISO/IEC JTC 1/SC 22(C++標準委員會)協作的波蘭國家機構的一位C++專家。同月,他獲得第一個2019/2020年度微軟MVP頭銜。Bartek同時還是C++17 In Detail的作者。
譯者的話
本書圍繞lambda表達式的主題,從C++03講到C++20,花了近萬字篇幅(中文)。時間跨度長而又緊扣主題,這樣縱向地介紹一項具體技術的文章是不多的。讀者們不僅可以從中學到知識,更應該從這一語言特性的演化中發現背後的道理。
這是我第一次翻譯完整的文章。英語和中文對同一內容的表達方式有所不同,閱讀英語原文讓我更好地理解作者的意圖,從而更有效地學習知識;有些很容易理解的英語表達,想翻譯成中文卻不太容易;逐字逐句的閱讀也讓我注意到了許多原本可能忽略的細節——這些感受是我翻譯過程中最大的收穫。
初次翻譯,經驗不足;如有疏漏,煩請指正。
1. C++03中的lambda
從早期的標準庫開始,std::sort之類的演算法就接受可調用對象作為參數,用容器中的每個對象調用它。然而,在C++03中這隻能是函數指針與仿函數,例如:(點擊程式碼標題在線運行程式碼)
#include <algorithm>
#include <iostream>
#include <vector>
struct PrintFunctor {
void operator()(int x) const {
std::cout << x << std::endl;
}
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::for_each(v.begin(), v.end(), PrintFunctor());
}
這個例子定義了一個帶有operator()的簡單仿函數。
函數指針是無狀態的,而仿函數可以包含一些狀態。一個例子是數調用次數:
#include <algorithm>
#include <iostream>
#include <vector>
struct PrintFunctor {
PrintFunctor(): numCalls(0) { }
void operator()(int x) const {
std::cout << x << ‘n’;
++numCalls;
}
mutable int numCalls;
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
PrintFunctor visitor = std::for_each(v.begin(), v.end(), PrintFunctor());
std::cout << “num calls: ” << visitor.numCalls << ‘n’;
}
在上面的例子中,我們用一個成員變數來數調用運算符被調用的次數。由於調用運算符是const的,我們必須用一個mutable變數。
我們還可以從主調作用域中“捕獲”變數——在仿函數中創建一個成員變數並在構造函數中初始化。
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct PrintFunctor {
PrintFunctor(const std::string& str):
strText(str), numCalls(0) { }
void operator()(int x) const {
std::cout << strText << x << ‘n’;
++numCalls;
}
std::string strText;
mutable int numCalls;
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
const std::string introText(“Elem: “);
PrintFunctor visitor = std::for_each(v.begin(), v.end(),
PrintFunctor(introText));
std::cout << “num calls: ” << visitor.numCalls << ‘n’;
}
在迭代(遍歷)中,PrintFunctor接受一個額外的參數以初始化成員變數,然後這個變數在調用運算符中被使用。
問題
正如所見,仿函數很強大。它是一個單獨的類,你可以自如地設計它。但問題是你必須在不同於演算法調用處的另一個作用域中寫一個單獨的函數或仿函數。
一個可能的方案是,你可以寫局部仿函數類——既然C++總是支援這種句法。然而這樣並不可以……看這段程式碼:
局部仿函數
int main() {
struct PrintFunctor {
void operator()(int x) const {
std::cout << x << std::endl;
}
};
std::vector<int> v;
std::for_each(v.begin(), v.end(), PrintFunctor());
}
在GCC中用-std=c++98編譯,你會得到以下錯誤:
error: template argument for
‘template<class _IIter, class _Funct> _Funct
std::for_each(_IIter, _IIter, _Funct)’
uses local type ‘main()::PrintFunctor’
通常來說,在C++98/03中你不能用局部類型來實例化一個模板。
新特性的動機
在C++11中,委員會放寬了對局部變數實例化模板的限制,你可以在離使用處更近的地方寫仿函數。
但C++11也提供了另一個方案:讓編譯器幫開發者寫這樣的小仿函數會怎樣?這意味著新的句法,我們可以“就地”創建仿函數,使程式碼更乾淨簡潔。這就是“lambda表達式”的起源!
在C++11的最終草案N3337中,我們可以看到單獨的一節描述lambda:[expr.prim.lambda]。我們將在下一章中介紹這個新特性。
2. C++11中的lambda
棒極了!C++委員會聽取了C++03開發者的建議,從C++11開始我們有了lambda表達式!lambda很快成為了現代C++最突出的特性之一。
我們可以在C++11最終草案N3337中閱讀lambda的特性,有單獨的一節:[expr.prim.lambda]。
我認為lambda被以一個聰明的方式加入了語言。它們使用相同的句法,但編譯器將它擴展為實際的類。在真·強類型語言中,這種方法給我們帶來很多好處(但有時也有壞處)。
在這一章中你將學到:
· lambda的基本句法
· 如何捕獲變數
· 如何捕獲成員變數
· lambda的返回類型是什麼
· 什麼是閉包
· 一些邊界情況
· 向函數指針的轉換
· IIFE
讓我們開始吧!
句法
這是一個基本的程式碼實例,同時也展示了相應的局部仿函數對象。
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
struct {
void operator()(int x) const {
std::cout << x << ‘n’;
}
} someInstance;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::for_each(v.begin(), v.end(), someInstance);
std::for_each(v.begin(), v.end(), [] (int x) {
std::cout << x << ‘n’;
}
);
}
在這個例子中編譯器將:
[](int x) { std::cout << x << ‘n’; }
轉換為這樣(簡化形式):
struct{
voidoperator()(intx) const{
std::cout << x << ‘n’;
}
} someInstance;
lambda表達式的句法:
[] () { code; }
^ ^ ^
| | |
| | 可選:mutable、異常、尾置返回……
| |
| 可選:形參列表
|
lambda引導與捕獲列表
在開始之前,先看一些定義。[expr.prim.lambda#2]:
lambda表達式的求值結果是純右值臨時變數,這個臨時變數稱為閉包對象。
lambda表達式的類型(也就是閉包對象的類型)是一個獨一無二的、無名的非聯合體類型,稱為閉包類型。
一些lambda表達式的例子:
[]{} // 最簡單的lambda
[](float f, int a) { return a*f; }
[](MyClass t) -> int { auto a = t.compute(); return a; }
[](int a, int b) { return a < b; }
[x](int a, int b) mutable { return a < b; ++x; }
lambda的類型
由於編譯器為每個lambda生成一個獨一無二的類名,我們沒法提前知道它的類型。這就是你為什麼必須用auto(或decltype)來推斷類型。
auto myLambda = [](int a) -> double { return 2.0 * a; }
與lambda表達式相關聯的閉包類型有刪除的默認構造函數與拷貝構造函數。
這就是為什麼你不能寫:
auto foo = [&x, &y]() { ++x; ++y; };
decltype(foo) fooCopy;
GCC對這段程式碼給出以下錯誤:
error: use of deleted function ‘main()::<lambda()>::<lambda>()’
decltype(foo) fooCopy;
^~~~~~~
note: a lambda closure type has a deleted default constructor
另一個問題是,如果你有兩個lambda:
auto firstLam = [](int x) { return x*2; };
auto secondLam = [](int x) { return x*2; };
它們的類型是不同的!即使“背後的程式碼”是相同的……總之編譯器被要求為每個lambda聲明獨一無二的無名類型。
然而,你可以拷貝lambda:
拷貝lambda
#include <type_traits>
int main() {
auto firstLam = [](int x) { return x*2; };
auto secondLam = firstLam;
static_assert(std::is_same_v<decltype(firstLam), decltype(secondLam)>);
}
拷貝lambda也同時拷貝了它的狀態。在有變數捕獲時這很重要,閉包類型會把捕獲變數作為成員域存儲。
預見未來
在C++20中無狀態的lambda可以被默認構造和賦值。
調用運算符
lambda函數體中寫的程式碼被“翻譯”為對應閉包類型的operator()中的程式碼。它默認是一個const inline方法,你可以在參數聲明子句後指明mutable以改變這一限定:
auto myLambda = [](int a) mutable { std::cout << a; }
對於空捕獲列表的lambda,const方法沒有問題,但當你要從局部作用域捕獲變數時,情況就不同了。捕獲子句是下一節的主題:
捕獲
[]不只是引導一個lambda,它也包含了捕獲變數列表,稱為“捕獲子句”。
通過捕獲變數,你在閉包類型中創建了那個變數的拷貝作為成員,然後你可以在lambda函數體中存取它。在C++03那一章中我們為PrintFunctor做過一件類似的事。在那個類中,我們加入了一個成員變數std::string strText;,由構造函數初始化。
捕獲的基本句法:
· [&]——引用捕獲所有聲明在可觸及作用域中的自動存取期限變數;
· [=]——值捕獲,值被拷貝(拷貝捕獲);
· [x, &y]——顯式地值捕獲x、引用捕獲y。
例如:
捕獲變數
std::string str {“Hello World”};
auto foo = [str]() { std::cout << str << ‘n’; };
foo();
在上面的lambda中,編譯器可能生成這樣的局部仿函數:
可能的編譯器生成的仿函數,單個變數
_unnamedLambda(std::string s) : str(s) { }
void operator() const {
std::cout << str << ‘n’;
}
std::string str;
};
從概念上講,傳入構造函數的變數在lambda聲明時被使用。標準[expr.prim.lambda#21]中有更精確的描述:
當lambda表達式被求值時,拷貝捕獲的實體被用於直接初始化結果閉包類型中對應的非static數據成員。
上面展示的可能的構造函數(_unnamedLambda)只是演示用途,編譯器可能用不同的實現,並且不會暴露出來。
int x = 1, y = 1;
std::cout << x << ” ” << y << std::endl;
auto foo = [&x, &y]() { ++x; ++y; };
foo();
std::cout << x << ” ” << y << std::endl;
對於上面的lambda,編譯器可能生成這樣的局部仿函數:
可能的編譯器生成的仿函數,兩個引用
struct _unnamedLambda {
_unnamedLambda(int& a, int& b) : x(a), y(b) { }
void operator() const {
++x; ++y;
}
int& x;
int& y;
};
由於我們引用捕獲x和y,閉包類型也會包含引用成員變數。
注意
值捕獲變數的值在lambda被定義時的值——不是在使用時!引用捕獲變數的值是在lambda被使用時的值——不是在定義時。
儘管寫[=]或[&]方便,因為它捕獲所有自動存儲期限的變數,但顯式捕獲變數更加清楚,並且編譯器會就不想要的效果給你警告(參見全局與靜態變數)。你也可以在Scott Meyers的《Effective Modern C++》中條款31“避免默認捕獲方式”中閱讀更多。
注意
C++閉包不會延長捕獲引用的生命周期。要確保捕獲的變數在lambda調用時仍存在。
Mutable
operator()默認為const,你不能在lambda函數體中修改捕獲的變數。如果要改變這種行為,需要在參數列表後加上mutable關鍵字:
拷貝捕獲兩個變數
int x = 1, y = 1;
std::cout << x << ” ” << y << std::endl;
auto foo = [x, y]() mutable { ++x; ++y; };
foo();
std::cout << x << ” ” << y << std::endl;
在上面的例子中,我們可以改變x和y的值。當然,由於他們只是父級作用域中x和y的拷貝,foo調用之後他們沒有新的值。
另一方面,如果你以引用捕獲,在非mutable的lambda中,你不能重新綁定引用,但你可以改變被引用的變數。
引用捕獲一個變數
int x = 1;
std::cout << x << ‘n’;
auto foo = [&x]() { ++x; };
foo();
std::cout << x << ‘n’;
在上面的例子中,lambda不是mutable的,但它可以改變被引用的值。
捕獲全局變數
如果有一個全局變數,然後你在lambda中用[=],你可能覺得全局變數被值捕獲了……然而並沒有。
int global = 10;
int main()
{
std::cout << global << std::endl;
auto foo = [=] () mutable { ++global; };
foo();
std::cout << global << std::endl;
[] { ++global; } ();
std::cout << global << std::endl;
[global] { ++global; } ();
}
只有自動存取期限的變數才可以被捕獲。GCC甚至會給出以下警告:
warning: capture of variable ‘global’ with non-automatic storage duration
這個警告只會在你顯式捕獲全局變數時出現,所以如果你用[=],編譯器也幫不了你。
Clang編譯器更好,它產生一個錯誤:(去看看)
error: ‘global’ cannot be captured because it does not have automatic storage d
uration
捕獲靜態變數
與捕獲全局變數類似,對於靜態變數的情況,你會得到相同結果:
#include <iostream>
void bar()
{
static int static_int = 10;
std::cout << static_int << std::endl;
auto foo = [=] () mutable { ++static_int; };
foo();
std::cout << static_int << std::endl;
[] { ++static_int; } ();
std::cout << static_int << std::endl;
[static_int] { ++static_int; } ();
}
int main()
{
bar();
}
輸出是:
10
11
12
同樣地,警告只會在你顯式捕獲靜態變數時出現,如果你用[=],編譯器幫不上忙。
捕獲成員變數與this
在類方法中,情況就有些複雜了:
#include <iostream>
struct Baz {
void foo() {
auto lam = [s]() { std::cout << s; };
lam();
}
std::string s;
};
int main() {
Baz b;
b.foo();
}
程式碼試圖捕獲成員變數s,但編譯器會給出一個錯誤:
In member function ‘void Baz::foo()’:
error: capture of non-variable ‘Baz::s’
error: ‘this’ was not captured for this lambda function
…
為了解決這個問題,你必須捕獲this指針,然後才可以存取成員變數。我們可以將程式碼改為:
struct Baz {
void foo() {
auto lam = [this]() { std::cout << s; };
lam();
}
std::string s;
};
編譯器不再產生錯誤。
你可以用[=]或[&]來捕獲this(效果是相同的!),但請注意我們捕獲的是this指針,所以存取的將是成員變數,而不是它的拷貝。
在C++11(甚至C++14)中你不能寫:
auto lam = [*this]() { std::cout << s; };
來捕獲對象的拷貝。
如果你在一個方法的上下文中使用lambda,捕獲this一切安好,但對於更複雜的情況如何呢?你知道以下程式碼會發生什麼嗎?
#include <iostream>
#include <functional>
struct Baz
{
std::function<void()> foo()
{
return [=] { std::cout << s << std::endl; };
}
std::string s;
};
int main()
{
auto f1 = Baz{“ala”}.foo();
auto f2 = Baz{“ula”}.foo();
f1();
f2();
}
程式碼聲明了一個Baz對象然後調用foo()。請注意foo()返回一個捕獲類成員的lambda(存儲在std::function中)。
由於使用臨時對象,我們無法確定調用f1和f2時會發生什麼。這是一個懸空引用問題,會導致未定義行為,類似於:
struct Bar {
std::string const& foo() const { return s; };
std::string s;
};
auto&& f1 = Bar{“ala”}.foo(); // 懸空引用
同樣地,如果你顯式捕獲([s]):
std::function<void()> foo()
{
return [s] { std::cout << s << std::endl; };
}
總之,由於lambda可以作用於對象生存周期之外(outlive the object),捕獲this看起來有些狡猾。在非同步調用或多執行緒環境中,這種情況就會發生。
我們將在C++17一章中回到這一話題。
僅可移動對象
如果你有一個只能移動不能拷貝的對象(比如std::unique_ptr),你不能把它作為捕獲變數移動到lambda中。值捕獲是不行的,所以你只能引用捕獲,然而這不會轉交所有權,而且多半不是你想要的。
std::unique_ptr<int> p(new int{10});
auto foo = [p] () {}; // 不能通過編譯……
保留const
如果你捕獲一個const變數,它的常量性被保留:(測試)
int const x = 10;
auto foo = [x] () mutable {
std::cout << std::is_const<decltype(x)>::value << std::endl;
x = 11;
};
foo();
返回類型
在C++11中,你可以跳過lambda的尾置返回類型,編譯器會幫你推斷。
起初,返回類型推斷僅限於只有一個return語句的lambda,但由於更實用的版本實現起來沒有問題,這個限制很快就被放寬了。參見C++標準核心語言缺陷報告與接受的問題。
所以從C++11開始,只要你的return語句都是同一個類型的,編譯器就能推斷返回類型。
如果所有return語句返回一個表達式並且在左值–右值轉換(7.1 [conv.lval])、數組–指針轉換(7.2 [conv.array]),以及函數–指針轉換(7.3 [conv.func])後返回類型相同,(返回類型就是)這個相同的類型。
auto baz = [] () {
int x = 10;
if ( x < 20)
return x * 1.1;
else
return x * 2.1;
};
(在線運行)
這個lambda中有兩個return語句,但它們都返回double,所以編譯器能推斷類型。在C++14中lambda的返回類型更新為適用於普通函數的auto類型推斷規則。
IIFE——立即調用函數表達式
在之前的例子中,我總是先定義一個lambda,然後用閉包對象調用它。但你也可以立即調用它:
int x = 1, y = 1;
[&]() { ++x; ++y; }(); // <– 調用()
std::cout << x << ” ” << y << std::endl;
這樣的表達式可用於初始化一個複雜的const對象:
const auto val = []() { /* 幾行程式碼… */ }();
我在這篇部落格裡面寫了更多相關內容:利用IIFE進行複雜初始化。
轉換為函數指針
如果一個lambda沒有捕獲,則:
沒有捕獲的lambda表達式的閉包類型有一個public的、非virtual的、隱式的向函數指針的const轉換函數,此函數指針與閉包類型的函數調用運算符有相同參數與返回類型。這種轉換返回的值應該是一個函數的地址,當調用時與調用閉包類型的函數調用運算符有相同的效果。
換言之,你可以將沒有捕獲的lambda轉換為函數指針,比如:
轉換為函數指針
#include <iostream>
void callWith10(void(* bar)(int))
{
bar(10);
}
int main()
{
struct
{
using f_ptr = void(*)(int);
void operator()(int s) const { return call(s); }
operator f_ptr() const { return &call; }
private:
static void call(int s) { std::cout << s << std::endl; };
} baz;
callWith10(baz);
callWith10([](int x) { std::cout << x << std::endl; });
}
小結
在這一章中,你學到了如何創建與使用lambda表達式。我介紹了句法、捕獲子句、lambda的類型,等等。
lambda表達式是現代C++最值得注意的標誌之一。在更多的使用案例中,開發者們發現了改進lambda的可能性。這就是為什麼你現在可以看向下一章,了解委員會在C++14中加入的更新。
3. C++14中的lambda
C++為lambda表達式加入了兩個重要的改進:
· 帶有初始化的捕獲
· 泛型lambda
此外,標準還更新了一些規則,比如:
· lambda的默認參數
· auto作為返回類型
這些特性可以解決C++11中存在的部分問題。
你可以在N4140和[expr.prim.lambda]中閱讀特性。
lambda的默認參數
在C++14中你可以在閉包函數調用中使用默認參數。這是個小特性,但讓lambda更像普通函數。
帶有默認參數的lambda
#include <iostream>
int main() {
auto lam = [](int x = 10) { std::cout << x << ‘n’; };
lam();
lam(100);
return 0;
}
有趣的是GCC和Clang從C++11開始就支援這個特性了。
返回類型
在C++14中lambda的返回類型推斷更新為與函數auto推斷規則一致。
lambda的返回類型是auto,如果提供尾置返回類型可以替換,或按照[dcl.spec.auto]由return語句推導。
如果你有多個return語句,它們必須推斷出相同類型:
auto foo = [] (int x) {
if (x < 0)
return x * 1.1f; // float!
else
return x * 2.1; // double!
};
上面的程式碼不能通過編譯,因為第一個return語句返回float,而第二個推斷出double。
另一個與返回類型相關的重要概念是我們可以不再使用std::function來返回lambda!編譯器會推導出正確的閉包類型:
auto CreateMulLambda(int x) {
return [x](int param) { return x * param; };
}
auto lam = CreateMulLambda(10);
帶初始化的捕獲
來看更大的更新!
在lambda表達式中你可以捕獲變數:編譯器擴展捕獲句法,在閉包類型中創建成員變數。現在,在C++14中,你可以創建新的成員變數,在捕獲子句中初始化它們。然後你可以在lambda中使用這些變數。例如:
簡單的帶初始化的捕獲
int main() {
int x = 10;
int y = 11;
auto foo = [z = x+y]() { std::cout << z << ‘n’; };
foo();
}
在上面的例子中,編譯器會生成一個新的成員變數,用x+y初始化它。所以從概念上講,它解析為:
struct _unnamedLambda {
void operator()() const {
std::cout << z << ‘n’;
}
int z;
} someInstance;
當lambda表達式被求值時,z將被用x+y直接初始化。
這個特性能解決一些問題,比如僅可移動類型。我們來回看這個問題。
移動
在之前的C++11中,你不能值捕獲一個std::unique_ptr。而現在,我們可以將對象移動到閉包類型的成員中:
捕獲一個僅可移動對象
#include <memory>
int main(){
std::unique_ptr<int> p(new int{10});
auto foo = [x=10] () mutable { ++x; };
auto bar = [ptr=std::move(p)] {};
auto baz = [p=std::move(p)] {};
}
多虧了初始化,你才能給std::unique_ptr賦以合適的值。
優化
另一個想法是把捕獲初始化作為一種優化技術。與其在每次調用lambda的時候計算一些值,我們可以僅在初始化中計算一次:
#include <iostream>
#include <algorithm>
#include <vector>
#include <memory>
#include <string>
int main() {
using namespace std::string_literals;
std::vector<std::string> vs;
std::find_if(vs.begin(), vs.end(),
[](std::string const& s) {
return s == “foo”s + “bar”s;
}
);
std::find_if(vs.begin(), vs.end(),
[p=“foo”s + “bar”s](std::string const& s) {
return s == p;
}
);
}
上面你的程式碼展示了兩次std::find_if調用。第一次我們沒有捕獲任何東西,僅僅將輸入的值與“foo”s+”bar”s作比較。每次lambda被調用時都會創建一個臨時變數用於存儲兩個字元串的和(連接)。第二次std::find_if調用使用了優化:我們創建一個捕獲變數p,計算一次兩字元串的和,然後我們在lambda函數體中安全地使用它。
捕獲成員變數
初始化可以捕獲成員變數。我們可以捕獲成員變數的拷貝,無需擔心懸空引用問題。例如:
struct Baz {
auto foo() {
return [s=s] { std::cout << s << std::endl; };
}
std::string s;
};
int main() {
auto f1 = Baz{“ala”}.foo();
auto f2 = Baz{“ula”}.foo();
f1();
f2();
}
在foo()中通過拷貝進閉包類型捕獲了一個成員變數。此外,我們還對整個方法的返回類型推導用了auto(在之前的C++11中我們可以用std::function)。
泛型lambda
lambda的另一個重要改進是泛型lambda。從C++14開始你可以寫:
auto foo = [](auto x) { std::cout << x << ‘n’; };
foo(10);
foo(10.1234);
foo(“hello world”);
注意lambda的一個參數是auto x。這等價於在閉包類型中使用模板聲明函數調用運算符:
struct {
template<typename T>
void operator()(T x) const {
std::cout << x << ‘n’;
}
} someInstance;
譯者注
寫模板的時候總會有一個問題:模板聲明放在哪一層?在泛型lambda的問題中,是寫模板類還是寫類的模板方法?
由於lambda表達式返回一個閉包類型的對象,它必須是一個確定的類型,然而此時並不知道將來會以什麼參數調用它,甚至可以用不同類型參數調用,因此泛型lambda的閉包類型一定是一個類,其中含有模板方法,而不是模板類的一系列實例。
在泛型lambda 中你不僅可以用auto x,也能像其他auto變數一樣添加修飾符。
當類型推斷困難的時候,泛型lambda非常有用,例如:
std::map<std::string, int> numbers {
{ “one”, 1 }, {“two”, 2 }, { “three”, 3 }
};
// 每次函數入口都是std::pair<const std::string, int>的拷貝!
std::for_each(std::begin(numbers), std::end(numbers),
[](const std::pair<std::string, int>& entry) {
std::cout << entry.first << ” = ” << entry.second << ‘n’;
}
);
我有犯聲明錯誤嗎?enrty是否具有正確的類型?
·
·
·
恐怕沒有吧,因為std::map的value_type是std::pair<const Key, T>。所以我的程式碼會執行額外的字元串拷貝。
這個問題可以由auto解決:
std::for_each(std::begin(numbers), std::end(numbers),
[](auto& entry) {
std::cout << entry.first << ” = ” << entry.second << ‘n’;
}
);
Bonus——利用lambda放寬限制
目前,把重載函數傳入標準庫演算法(或任何需要可調用對象的東西)是不行的:
// 兩個重載:
void foo(int) {}
void foo(float) {}
int main() {
std::vector<int> vi;
std::for_each(vi.begin(), vi.end(), foo);
}
在GCC 9(主線版本)中我們得到以下錯誤:
error: no matching function for call to
for_each(std::vector<int>::iterator, std::vector<int>::iterator,
<unresolved overloaded function type>)
std::for_each(vi.begin(), vi.end(), foo);
^^^^^
然而,一個技巧是我們可以用lambda,然後調用所需要的重載函數。一個基本的形式是,對於簡單的值類型,對於我們的兩個函數,可以寫這樣的程式碼:
std::for_each(vi.begin(), vi.end(), [](auto x) { return foo(x); });
在最通用的形式中,我們需要多打一點字:
#define LIFT(foo)
[](auto&&… x)
noexcept(noexcept(foo(std::forward<decltype(x)>(x)…)))
-> decltype(foo(std::forward<decltype(x)>(x)…))
{ return foo(std::forward<decltype(x)>(x)…); }
好複雜的程式碼啊……不是嗎?🙂 我們來試著解釋它:
我們創建了一個泛型lambda,然後轉發所有得到的參數。為了正確地定義,我們需要指明noexcept和返回類型。這就是為什麼我們必須複製調用程式碼——為了得到正確的類型。
這個LIFT宏可以在任何支援C++14的編譯器中工作。
小結
正如本章所述,C++14帶來了一些lambda表達式的關鍵改進。自C++14開始你可以在lambda作用域內定義新的變數,也可以在模板程式碼中有效地使用它們。在下一章中我們將進入帶來更多更新的C++17!
4. C++17中的lambda
標準(出版前的草案)N4659以及lambda一節:[expr.prim.lambda]。
C++17為lambda表達式加入了兩個重要的改進:
· constexpr lambda
· 捕獲*this
這些特性對你來說意味著什麼?一起來看吧。
constexpr lambda表達式
自C++17開始,如果可行,標準將lambda類型的operator()隱式地定義為constexpr。摘自expr.prim.lambda #4:
函數調用運算符是constexpr函數,當對應的lambda表達式的參數聲明字句後面有constexpr,或它滿足constexpr函數的要求。
例如:
constexpr auto Square = [] (int n) { return n*n; }; // 隱式constexpr
static_assert(Square(2) == 4);
回憶一下,C++17中constexpr函數有以下規則:
· 不能是virtual;
· 返回類型是字面值類型;
· 參數都是字面值類型;
· 函數體為=delete,=default,或不含有以下內容的複合表達式
– asm定義、
– goto語句、
– 標識符標籤、
– try語句塊,或
– 非字面值類型,或靜態或執行緒存儲期限,或沒有初始化的變數的定義
來看個更實際的例子:
template<typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(const Range& range, Func func, T init) {
for (auto &&elem: range) {
init += func(elem);
}
return init;
}
int main() {
constexpr std::array arr{ 1, 2, 3 };
static_assert(SimpleAccumulate(arr, [](inti) {
return i * i;
}, 0) == 14);
}
這段程式碼寫了一個constexpr lambda,然後把它傳給簡單的演算法SimpleAccumulate。這個演算法還用到了一些C++17元素:除了std::array可以constexpr外,std::begin和std::end(在基於範圍的循環中使用)也可以成為constexpr,使整段程式碼都可以在編譯器執行。
當然,還有更多。你還可以捕獲變數(假設它們都是常量表達式):
constexpr lambda,捕獲
constexpr int add(int const& t, int const& u) {
return t + u;
}
int main() {
constexpr int x = 0;
constexpr auto lam = [x](int n) { return add(x, n); };
static_assert(lam(10) == 10);
}
有一個有趣的情況,就是當你不“傳”你捕獲的參數時,像這樣:
constexpr int x = 0;
constexpr auto lam = [x](int n) { return n + x };
在這種情況下,在Clang,我們可能得到以下警告:
warning: lambda capture ‘x’ is not required to be captured for this use
這可能是因為每處使用的x都可以被就地替換(除非你把它傳走或取地址)。
但請告訴我你是否知道這種行為的官方規則。我只找到(cppreference)(我在草案中沒找到)(譯者註:expr.const#2.12有一些相關內容):
lambda表達式可以不捕獲就讀取變數的值,如果它是非volatile整值或枚舉類型且已被常量表達式初始化,或是constexpr且沒有mutable成員。
迎接未來
在C++20中我們將有constexpr標準演算法,甚至一些容器,所以在那樣的環境中comstexpr lambda會變得很方便。你的運行期版本和constexpr(編譯期)版本看起來會是一樣的!
概括來講:constexpr lambda允許你混合模板編程,並多半會縮短程式碼。
我們來看第二個自C++17起可用的重要特性:
捕獲*this
你還記得當我們想捕獲類成員時候的問題嗎?
我們默認地捕獲了this(作為一個指針!),這就是為什麼臨時變數離開作用域以後我們會遇到麻煩……我們可以用帶初始化的捕獲來解決,就像我在C++14一章中描述的那樣。
但現在,在C++17中我們有另一種方式。我們可以捕獲*this的拷貝:
#include <iostream>
struct Baz {
auto foo() {
return [*this] { std::cout << s << std::endl; };
}
std::string s;
};
int main() {
auto f1 = Baz{“ala”}.foo();
auto f2 = Baz{“ula”}.foo();
f1();
f2();
}
通過初始化捕獲需要的成員變數解決了臨時變數可能導致的錯誤,但當我們想調用類型方法的時候不能這麼做,例如:
捕獲this以調用方法
struct Baz {
auto foo() {
return [this] { print(); };
}
void print() const { std::cout << s << ‘n’; }
std::string s;
};
在C++14中一個更安全的方法是用初始化捕獲*this:
auto foo() {
return[self=*this] { self.print(); };
}
但C++17中有一個更清晰的寫法:
auto foo() {
return[*this] { print(); };
}
還有一點:當你在成員函數中寫[=]時,this被隱式捕獲了!
指南
好,我們應該捕獲[this]或[*this],為什麼這個選擇很重要呢?
在大多數情況下,當你在類作用域中工作時,[this](或[&])是很好的。沒有多餘的拷貝,在你的對象很大時這尤其必要。
當你真的想要拷貝,或lambda可能超出對象的作用域時,你可以考慮[*this]。
在非同步或並行執行中,為了避免數據競爭,這更加重要。同時,在非同步或多執行緒執行模式下,lambda可能超出對象作用域,this指針不再有效。
小結
在這一章中你看到了C++17將兩個重要的元素結合起來:constexpr與lambda。現在你可以在constexpr上下文中使用lambda了!另外C++17標準還解決了捕獲this的問題。
在下一章中,我們將一瞥C++20帶來的未來。
5. C++20中的未來
我們來一瞥C++20帶來的改變。
在這一章中你將了解到:
· C++20改變了什麼
· 捕獲this的新方法
· 模板lambda是什麼
快速概覽
在C++20中我們將有以下特性:
· 允許[=, this]作為lambda捕獲(P0409R2),廢棄通過[=]隱式捕獲this(P0806)
· lambda初始化捕獲中的包展開:[…args = std::move(args)](){}(P0780)
· static、thread_local與lambda捕獲中的結構化綁定(P1091)
· 模板lambda(與concept)(P0428R2)
· 簡化隱式lambda捕獲(P0588R1)
· 可默認構造與複製的無狀態lambda(P0624R2)
· 不求值上下文中的lambda
新加入的特性大多“清理”了lambda的使用,並允許更高級的用法。比如用P1091你可以捕獲結構化綁定。
捕獲this得到澄清。在C++20中如果你在方法中捕獲[=],你會得到警告:(試試看)
struct Baz {
auto foo() {
return [=] { std::cout << s << std::endl; };
}
std::string s;
};
GCC 9:
warning: implicit capture of ‘this’ via ‘[=]’ is deprecated in C++20
之所以有這個警告是因為即使用[=]你也會捕獲this指針。所以最好顯式寫出你想要什麼:[=, this]或[=, *this]。
還有些與高級用法相關的改變,比如不求值上下文與無狀態lambda可默認構造。
有了這兩個改進,你可以寫:
std::map<int, int, decltype([](int x, int y) { return x > y; })> map;
注意
C++20標準已經特性完整,所以我們不用再期待有什麼關於lambda的新特性了。但即使已選出的元素也可能稍微改動,所以應該視上面的列表為進行中而不是過時。
我們來看看一個有趣的特性:模板lambda。
模板lambda
C++14中我們有泛型lambda,聲明為auto的參數是模板參數。
對於一個lambda:
[](auto x) { x; }
編譯器生成一個對應以下模板方法的函數調用運算符:
template<typename T>
void operator(T x) { x; }
但是我們不能改變這個模板參數而使用“真正的”模板參數,而這在C++20中是可行的。比如,我們如何限制lambda只能接受某種類型的std::vector?
我們可以寫一個泛型lambda:
auto foo = [](auto& vec) {
std::cout<< std::size(vec) << ‘n’;
std::cout<< vec.capacity() << ‘n’;
};
但如果你用int實參調用它(像foo(10);),你會得到一些難以閱讀的錯誤:
prog.cc: In instantiation of ‘main()::<lambda(const auto:1&)> [with auto:1 = in
t]’:
prog.cc:16:11: required from here
prog.cc:11:30: error: no matching function for call to ‘size(const int&)’
11 | std::cout<< std::size(vec) << ‘n’;
在C++20中我們可以寫:(在線編譯)
auto foo = []<typename T>(std::vector<T> const& vec) {
std::cout<< std::size(vec) << ‘n’;
std::cout<< vec.capacity() << ‘n’;
};
以上lambda解析為一個模板函數調用運算符:
template<typename T>
void operator(std::vector<T> const& s) { … }
模板參數出現在捕獲子句[]之後。
如果你用int(foo(10);)調用它,你會得到一條可讀的資訊:
note: mismatched types ‘const std::vector<T>’ and ‘int’
在上面這個例子中,編譯器可以就lambda介面不匹配給我們警告,而不是在函數體中。
另一個重要的地方是在泛型lambda中你只有變數而沒有其模板類型。如果你要存取它,你必須用decltype(x)(對於參數為(auto x)的lambda),這讓程式碼有些冗長複雜。例如(引用P0428中的程式碼):
auto f = [](auto const& x) {
using T = std::decay_t<decltype(x)>;
T copy = x;
T::static_function();
using Iterator = typename T::iterator;
}
現在可以寫成:
auto f = []<typename T>(T const& x) {
T::static_function();
T copy = x;
using Iterator = typename T::iterator;
}
小結
在這一章中,你看到了lambda的一些改變。lambda是現代C++的一個穩定特性,所以多數新元素與高級用法相關,比如不求值上下文或捕獲結構化綁定。還有一些“擴展”,比如模板lambda。在大多數情況下泛型lambda就夠用了,但對更高級的場景,你可能想要顯式聲明模板參數。
參考文獻
· C++11 – [expr.prim.lambda]
· C++14 – [expr.prim.lambda]
· C++17 – [expr.prim.lambda]
· Lambda Expressions in C++ | Microsoft Docs
· Demystifying C++ lambdas – Sticky Bits – Powered by FeabhasSticky Bits – Powered by Feabhas
· The View from Aristeia: Lambdas vs. Closures
· Simon Brand – Passing overload sets to functions
· Jason Turner – C++ Weekly – Ep 128 – C++20’s Template Syntax For Lambdas
· Jason Turner – C++ Weekly – Ep 41 – C++17’s constexpr Lambda Support