目錄結構:
lambda表達式是C++11中引入的一項新技術,利用lambda表達式可以編寫內嵌的匿名函數,用以替換獨立函數或者函數對象,並且使代碼更可讀。但是從本質上來講,lambda表達式只是一種語法糖,因為所有其能完成的工作都可以用其它稍微復雜的代碼來實現。但是它簡便的語法卻給C++帶來了深遠的影響。如果從廣義上說,lamdba表達式產生的是函數對象。在類中,可以重載函數調用運算符(),此時類的對象可以將具有類似函數的行為,我們稱這些對象為函數對象(Function Object)或者仿函數(Functor)。相比lambda表達式,函數對象有自己獨特的優勢。下面我們開始具體講解這兩項黑科技。
1 lambda表達式
我們先從簡答的例子開始,我們定義一個可以輸出字符串的lambda表達式,表達式一般都是從方括號[]開始,然后結束於花括號{},花括號里面就像定義函數那樣,包含了lamdba表達式體:
// 定義簡單的lambda表達式 auto basicLambda = [] { cout << "Hello, world!" << endl; }; // 調用 basicLambda(); // 輸出:Hello, world!
上面是最簡單的lambda表達式,沒有參數。如果需要參數,那么就要像函數那樣,放在圓括號里面,如果有返回值,返回類型要放在->后面,即拖尾返回類型,當然你也可以忽略返回類型,lambda會幫你自動推斷出返回類型:
// 指明返回類型 auto add = [](int a, int b) -> int { return a + b; }; // 自動推斷返回類型 auto multiply = [](int a, int b) { return a * b; }; int sum = add(2, 5); // 輸出:7 int product = multiply(2, 5); // 輸出:10
大家可能會想lambda表達式最前面的方括號的意義何在?其實這是lambda表達式一個很要的功能,就是閉包。這里我們先講一下lambda表達式的大致原理:每當你定義一個lambda表達式后,編譯器會自動生成一個匿名類(這個類當然重載了()運算符),我們稱為閉包類型(closure type)。那么在運行時,這個lambda表達式就會返回一個匿名的閉包實例,其實一個右值。所以,我們上面的lambda表達式的結果就是一個個閉包。閉包的一個強大之處是其可以通過傳值或者引用的方式捕捉其封裝作用域內的變量,前面的方括號就是用來定義捕捉模式以及變量,我們又將其稱為lambda捕捉塊。看下面的例子:
int main() { int x = 10; auto add_x = [x](int a) { return a + x; }; // 復制捕捉x auto multiply_x = [&x](int a) { return a * x; }; // 引用捕捉x cout << add_x(10) << " " << multiply_x(10) << endl; // 輸出:20 100 return 0; }
當lambda捕捉塊為空時,表示沒有捕捉任何變量。但是上面的add_x是以復制的形式捕捉變量x,而multiply是以引用的方式捕捉x。前面講過,lambda表達式是產生一個閉包類,那么捕捉是回事?對於復制傳值捕捉方式,類中會相應添加對應類型的非靜態數據成員。在運行時,會用復制的值初始化這些成員變量,從而生成閉包。前面說過,閉包類也實現了函數調用運算符的重載,一般情況是:
class ClosureType { public: // ... ReturnType operator(params) const { body }; }
這意味着lambda表達式無法修改通過復制形式捕捉的變量,因為函數調用運算符的重載方法是const屬性的。有時候,你想改動傳值方式捕獲的值,那么就要使用mutable,例子如下:
int main() { int x = 10; auto add_x = [x](int a) mutable { x *= 2; return a + x; }; // 復制捕捉x cout << add_x(10) << endl; // 輸出 30 return 0; }
這是為什么呢?因為你一旦將lambda表達式標記為mutable,那么實現的了函數調用運算符是非const屬性的:
class ClosureType { public: // ... ReturnType operator(params) { body }; }
對於引用捕獲方式,無論是否標記mutable,都可以在lambda表達式中修改捕獲的值。至於閉包類中是否有對應成員,C++標准中給出的答案是:不清楚的,看來與具體實現有關。既然說到了深處,還有一點要注意:lambda表達式是不能被賦值的:
auto a = [] { cout << "A" << endl; }; auto b = [] { cout << "B" << endl; }; a = b; // 非法,lambda無法賦值 auto c = a; // 合法,生成一個副本
你可能會想a與b對應的函數類型是一致的(編譯器也顯示是相同類型:lambda [] void () -> void),為什么不能相互賦值呢?因為禁用了賦值操作符:
ClosureType& operator=(const ClosureType&) = delete;
但是沒有禁用復制構造函數,所以你仍然可以用一個lambda表達式去初始化另外一個lambda表達式而產生副本。並且lambda表達式也可以賦值給相對應的函數指針,這也使得你完全可以把lambda表達式看成對應函數類型的指針。
閑話少說,歸入正題,捕獲的方式可以是引用也可以是復制,但是具體說來會有以下幾種情況來捕獲其所在作用域中的變量:
[]:默認不捕獲任何變量;
[=]:默認以值捕獲所有變量;
[&]:默認以引用捕獲所有變量;
[x]:僅以值捕獲x,其它變量不捕獲;
[&x]:僅以引用捕獲x,其它變量不捕獲;
[=, &x]:默認以值捕獲所有變量,但是x是例外,通過引用捕獲;
[&, x]:默認以引用捕獲所有變量,但是x是例外,通過值捕獲;
[this]:通過引用捕獲當前對象(其實是復制指針);
[*this]:通過傳值方式捕獲當前對象;
在上面的捕獲方式中,注意最好不要使用[=]和[&]默認捕獲所有變量。首先說默認引用捕獲所有變量,你有很大可能會出現懸掛引用(Dangling references),因為引用捕獲不會延長引用的變量的聲明周期:
std::function<int(int)> add_x(int x) { return [&](int a) { return x + a; }; }
因為參數x僅是一個臨時變量,函數調用后就被銷毀,但是返回的lambda表達式卻引用了該變量,但調用這個表達式時,引用的是一個垃圾值,所以會產生沒有意義的結果。你可能會想,可以通過傳值的方式來解決上面的問題:
std::function<int(int)> add_x(int x) { return [=](int a) { return x + a; }; }
是的,使用默認傳值方式可以避免懸掛引用問題。但是采用默認值捕獲所有變量仍然有風險,看下面的例子:
class Filter { public: Filter(int divisorVal): divisor{divisorVal} {} std::function<bool(int)> getFilter() { return [=](int value) {return value % divisor == 0; }; } private: int divisor; };
這個類中有一個成員方法,可以返回一個lambda表達式,這個表達式使用了類的數據成員divisor。而且采用默認值方式捕捉所有變量。你可能認為這個lambda表達式也捕捉了divisor的一份副本,但是實際上大錯特錯。問題出現在哪里呢?因為數據成員divisor對lambda表達式並不可見,你可以用下面的代碼驗證:
// 類的方法,下面無法編譯,因為divisor並不在lambda捕捉的范圍 std::function<bool(int)> getFilter() { return [divisor](int value) {return value % divisor == 0; }; }
那么原來的代碼為什么能夠捕捉到呢?仔細想想,原來每個非靜態方法都有一個this指針變量,利用this指針,你可以接近任何成員變量,所以lambda表達式實際上捕捉的是this指針的副本,所以原來的代碼等價於:
std::function<bool(int)> getFilter() { return [this](int value) {return value % this->divisor == 0; }; }
盡管還是以值方式捕獲,但是捕獲的是指針,其實相當於以引用的方式捕獲了當前類對象,所以lambda表達式的閉包與一個類對象綁定在一起了,這也很危險,因為你仍然有可能在類對象析構后使用這個lambda表達式,那么類似“懸掛引用”的問題也會產生。所以,采用默認值捕捉所有變量仍然是不安全的,主要是由於指針變量的復制,實際上還是按引用傳值。
通過前面的例子,你還可以看到lambda表達式可以作為返回值。我們知道lambda表達式可以賦值給對應類型的函數指針。但是使用函數指針貌似並不是那么方便。所以STL定義在<functional>頭文件提供了一個多態的函數對象封裝std::function,其類似於函數指針。它可以綁定任何類函數對象,只要參數與返回類型相同。如下面的返回一個bool且接收兩個int的函數包裝器:
std::function<bool(int, int)> wrapper = [](int x, int y) { return x < y; };
而lambda表達式一個更重要的應用是其可以用於函數的參數,通過這種方式可以實現回調函數。其實,最常用的是在STL算法中,比如你要統計一個數組中滿足特定條件的元素數量,通過lambda表達式給出條件,傳遞給count_if函數:
int value = 3; vector<int> v {1, 3, 5, 2, 6, 10}; int count = std::count_if(v.beigin(), v.end(), [value](int x) { return x > value; });
再比如你想生成斐波那契數列,然后保存在數組中,此時你可以使用generate函數,並輔助lambda表達式:
vector<int> v(10); int a = 0; int b = 1; std::generate(v.begin(), v.end(), [&a, &b] { int value = b; b = b + a; a = value; return value; }); // 此時v {1, 1, 2, 3, 5, 8, 13, 21, 34, 55}
此外,lambda表達式還用於對象的排序准則:
class Person { public: Person(const string& first, const string& last): firstName{first}, lastName{last} {} Person() = default; string first() const { return firstName; } string last() const { return lastName; } private: string firstName; string lastName; }; int main() { vector<Person> vp; // ... 添加Person信息 // 按照姓名排序 std::sort(vp.begin(), vp.end(), [](const Person& p1, const Person& p2) { return p1.last() < p2.last() || (p1.last() == p2.last() && p1.first() < p2.first()); }); // ... return 0; }
總之,對於大部分STL算法,可以非常靈活地搭配lambda表達式來實現想要的效果。
前面講完了lambda表達式的基本使用,最后給出lambda表達式的完整語法:
// 完整語法 [ capture-list ] ( params ) mutable(optional) constexpr(optional)(c++17) exception attribute -> ret { body } // 可選的簡化語法 [ capture-list ] ( params ) -> ret { body } [ capture-list ] ( params ) { body } [ capture-list ] { body }
第一個是完整的語法,后面3個是可選的語法。這意味着lambda表達式相當靈活,但是照樣有一定的限制,比如你使用了拖尾返回類型,那么就不能省略參數列表,盡管其可能是空的。針對完整的語法,我們對各個部分做一個說明:
capture-list:捕捉列表,這個不用多說,前面已經講過,記住它不能省略;
params:參數列表,可以省略(但是后面必須緊跟函數體);
mutable:可選,將lambda表達式標記為mutable后,函數體就可以修改傳值方式捕獲的變量;
constexpr:可選,C++17,可以指定lambda表達式是一個常量函數;
exception:可選,指定lambda表達式可以拋出的異常;
attribute:可選,指定lambda表達式的特性;
ret:可選,返回值類型;
body:函數執行體。
如果想了解更多,可以參考 cppreference lambda。
2 lambda c++14新特性
在C++14中,lambda又得到了增強,一個是泛型lambda表達式,一個是lambda可以捕捉表達式。這里我們對這兩項新特點進行簡單介紹。
2.1 lambda捕捉表達式
前面講過,lambda表達式可以按復制或者引用捕獲在其作用域范圍內的變量。而有時候,我們希望捕捉不在其作用域范圍內的變量,而且最重要的是我們希望捕捉右值。所以C++14中引入了表達式捕捉,其允許用任何類型的表達式初始化捕捉的變量。看下面的例子:
// 利用表達式捕獲,可以更靈活地處理作用域內的變量 int x = 4; auto y = [&r = x, x = x + 1] { r += 2; return x * x; }(); // 此時 x 更新為6,y 為25 // 直接用字面值初始化變量 auto z = [str = "string"]{ return str; }(); // 此時z是const char* 類型,存儲字符串 string
可以看到捕捉表達式擴大了lambda表達式的捕捉能力,有時候你可以用std::move初始化變量。這對不能復制只能移動的對象很重要,比如std::unique_ptr,因為其不支持復制操作,你無法以值方式捕捉到它。但是利用lambda捕捉表達式,可以通過移動來捕捉它:
auto myPi = std::make_unique<double>(3.1415); auto circle_area = [pi = std::move(myPi)](double r) { return *pi * r * r; }; cout << circle_area(1.0) << endl; // 3.1415
其實用表達式初始化捕捉變量,與使用auto聲明一個變量的機理是類似的。
2.2 泛型lambda表達式
從C++14開始,lambda表達式支持泛型:其參數可以使用自動推斷類型的功能,而不需要顯示地聲明具體類型。這就如同函數模板一樣,參數要使用類型自動推斷功能,只需要將其類型指定為auto,類型推斷規則與函數模板一樣。這里給出一個簡單例子:
auto add = [](auto x, auto y) { return x + y; }; int x = add(2, 3); // 5 double y = add(2.5, 3.5); // 6.0
3 函數對象
函數對象是一個廣泛的概念,因為所有具有函數行為的對象都可以稱為函數對象。這是一個高級抽象,我們不關心對象到底是什么,只要其具有函數行為。所謂的函數行為是指的是可以使用()調用並傳遞參數:
function(arg1, arg2, ...); // 函數調用
這樣來說,lambda表達式也是一個函數對象。但是這里我們所講的是一種特殊的函數對象,這種函數對象實際上是一個類的實例,只不過這個類實現了函數調用符():
class X { public: // 定義函數調用符 ReturnType operator()(params) const; // ... };
這樣,我們可以使用這個類的對象,並把它當做函數來使用:
X f; // ... f(arg1, arg2); // 等價於 f.operator()(arg1, arg2);
還是例子說話,下面我們定義一個打印一個整數的函數對象:
// T需要支持輸出流運算符 template <typename T> class Print { public: void operator()(T elem) const { cout << elem << ' ' ; } }; int main() { vector<int> v(10); int init = 0; std::generate(v.begin(), v.end(), [&init] { return init++; }); // 使用for_each輸出各個元素(送入一個Print實例) std::for_each(v.begin(), v.end(), Print<int>{}); // 利用lambda表達式:std::for_each(v.begin(), v.end(), [](int x){ cout << x << ' ';}); // 輸出:0, 1, 2, 3, 4, 5, 6, 7, 8, 9 return 0; }
可以看到Print<int>的實例可以傳入std::for_each,其表現可以像函數一樣,因此我們稱這個實例為函數對象。大家可能會想,for_each為什么可以既接收lambda表達式,也可以接收函數對象,其實STL算法是泛型實現的,其不關心接收的對象到底是什么類型,但是必須要支持函數調用運算:
// for_each的類似實現 namespace std { template <typename Iterator, typename Operation> Operation for_each(Iterator act, Iterator end, Operation op) { while (act != end) { op(*act); ++act; } return op; } }
泛型提供了高級抽象,不論是lambda表達式、函數對象,還是函數指針,都可以傳入for_each算法中。
本質上,函數對象是類對象,這也使得函數對象相比普通函數有自己的獨特優勢:
函數對象帶有狀態:函數對象相對於普通函數是“智能函數”,這就如同智能指針相較於傳統指針。因為函數對象除了提供函數調用符方法,還可以擁有其他方法和數據成員。所以函數對象有狀態。即使同一個類實例化的不同的函數對象其狀態也不相同,這是普通函數所無法做到的。而且函數對象是可以在運行時創建。
每個函數對象有自己的類型:對於普通函數來說,只要簽名一致,其類型就是相同的。但是這並不適用於函數對象,因為函數對象的類型是其類的類型。這樣,函數對象有自己的類型,這意味着函數對象可以用於模板參數,這對泛型編程有很大提升。
函數對象一般快於普通函數:因為函數對象一般用於模板參數,模板一般會在編譯時會做一些優化。
這里我們看一個可以擁有狀態的函數對象,其用於生成連續序列:
class IntSequence { public: IntSequence(int initVal) : value{ initVal } {} int operator()() { return ++value; } private: int value; }; int main() { vector<int> v(10); std::generate(v.begin(), v.end(), IntSequence{ 0 }); /* lambda實現同樣效果 int init = 0; std::generate(v.begin(), v.end(), [&init] { return ++init; }); */ std::for_each(v.begin(), v.end(), [](int x) { cout << x << ' '; }); //輸出:1, 2, 3, 4, 5, 6, 7, 8, 9, 10 return 0; }
可以看到,函數對象可以擁有一個私有數據成員,每次調用時遞增,從而產生連續序列。同樣地,你可以用lambda表達式實現類似的效果,但是必須采用引用捕捉方式。但是,函數對象可以實現更復雜的功能,而用lambda表達式則需要復雜的引用捕捉。考慮一個可以計算均值的函數對象:
class MeanValue { public: MeanValue(): num{0}, sum{0} {} void operator()(int e) { ++num; sum += num; } double value() { return static_cast<double>(sum) / static_cast<double>(num); } private: int num; int sum; }; int main() { vector<int> v{ 1, 3, 5, 7 }; MeanValue mv = std::for_each(v.begin(), v.end(), MeanValue{}); cout << mv.value() << endl; // output: 2.5 return 0; }
可以看到MeanValue對象中保存了兩個私有變量num和sum分別記錄數量與總和,最后可以通過兩者計算出均值。lambda表達式也可以利用引用捕捉實現類似功能,但是會有點繁瑣。這也算是函數對象獨特的優勢。
頭文件<functional>中預定義了一些函數對象,如算術函數對象,比較函數對象,邏輯運算函數對象及按位函數對象,我們可以在需要時使用它們。比如less<>是STL排序算法中的默認比較函數對象,所以默認的排序結果是升序,但是如果你想降序排列,你可以使用greater<>函數對象:
vector<int> v{3, 4, 2, 9, 5}; // 升序排序 std::sort(v.begin(), v.end()); // output: 2, 3, 4, 5, 9 // 降序排列 std::sort(v.begin(), v.end(), std::greater<int>{}); // output: 9, 5, 4, 3, 2
更多有關函數對象的信息大家可以參考這里。
4 函數適配器
從設計模式來說,函數適配器是一種特殊的函數對象,是將函數對象與其它函數對象,或者特定的值,或者特定的函數相互組合的產物。由於組合特性,函數適配器可以滿足特定的需求,頭文件<functional>定義了幾種函數適配器:
std::bind(op, args...):將函數對象op的參數綁定到特定的值args
std::mem_fn(op):將類的成員函數轉化為一個函數對象
std::not1(op), std::not2(op):一元取反器和二元取反器
4.1 綁定器(binder)
綁定器std::bind是最常用的函數適配器,它可以將函數對象的參數綁定至特定的值。對於沒有綁定的參數可以使用std::placeholers::_1, std::placeholers::_2等標記。我們從簡單的例子開始,比如你想得到一個減去固定樹的函數對象:
auto minus10 = std::bind(std::minus<int>{}, std::placeholders::_1, 10); cout << minus10(20) << endl; // 輸出10
有時候你可以利用綁定器重新排列參數的順序,下面的綁定器交換兩個參數的位置:
// 逆轉參數順序 auto vminus = std::bind(std::minus<int>{}, std::placeholders::_2, std::placeholders::_1); cout << vminus(20, 10) << endl; // 輸出-10
綁定器還可以互相嵌套,從而實現函數對象的組合:
// 定義一個接收一個參數,然后將參數加10再乘以2的函數對象 auto plus10times2 = std::bind(std::multiplies<int>{}, std::bind(std::plus<int>{}, std::placeholders::_1, 10), 2); cout << plus10times2(4) << endl; // 輸出: 28 // 定義3次方函數對象 auto pow3 = std::bind(std::multiplies<int>{}, std::bind(std::multiplies<int>{}, std::placeholders::_1, std::placeholders::_1), std::placeholders::_1); cout << pow3(3) << endl; // 輸出:27
利用不同函數對象組合,函數適配器可以調用全局函數,下面的例子是不區分大小寫來判斷一個字符串是否包含一個特定的子串:
// 大寫轉換函數 char myToupper(char c) { if (c >= 'a' && c <= 'z') return static_cast<char>(c - 'a' + 'A'); return c; } int main() { string s{ "Internationalization" }; string sub{ "Nation" }; auto pos = std::search(s.begin(), s.end(), sub.begin(), sub.end(), std::bind(std::equal_to<char>{}, std::bind(myToupper, std::placeholders::_1), std::bind(myToupper, std::placeholders::_2))); if (pos != s.end()) { cout << sub << " is part of " << s << endl; } // 輸出:Nation is part of Internationalization return 0; }
注意綁定器默認是以傳值方綁定參數,如果需要引用綁定值,那么要使用std::ref和std::cref函數,分別代表普通引用和const引用綁定參數:
void f(int& n1, int& n2, const int& n3) { cout << "In function: " << n1 << ' ' << n2 << ' ' << n3 << '\n'; ++n1; ++n2; // ++n3; //無法編譯 } int main() { int n1 = 1, n2 = 2, n3 = 3; auto boundf = std::bind(f, n1, std::ref(n2), std::cref(n3)); n1 = 10; n2 = 11; n3 = 12; cout << "Before function: " << n1 << ' ' << n2 << ' ' << n3 << '\n'; boundf(); cout << "After function: " << n1 << ' ' << n2 << ' ' << n3 << '\n'; // Before function : 10 11 12 // In function : 1 11 12 // After function : 10 12 12 return 0; }
可以看到,n1是以默認方式綁定到函數f上,故僅是一個副本,不會影響原來的n1變量,但是n2是以引用綁定的,綁定到f的參數與原來的n2相互影響,n3是以const引用綁定的,函數f無法修改其值。
綁定器可以用於調用類中的成員函數:
class Person { public: Person(const string& n) : name{ n } {} void print() const { cout << name << endl; } void print2(const string& prefix) { cout << prefix << name << endl; } private: string name; }; int main() { vector<Person> p{ Person{"Tick"}, Person{"Trick"} }; // 調用成員函數print std::for_each(p.begin(), p.end(), std::bind(&Person::print, std::placeholders::_1)); // 此處的std::placeholders::_1表示要調用的Person對象,所以相當於調用arg1.print() // 輸出:Tick Trick std::for_each(p.begin(), p.end(), std::bind(&Person::print2, std::placeholders::_1, "Person: ")); // 此處的std::placeholders::_1表示要調用的Person對象,所以相當於調用arg1.print2("Person: ") // 輸出:Person: Tick Person: Trick return 0; }
而且綁定器對虛函數也有效,你可以自己做一下測試。
前面說過,C++11中lambda表達式無法實現移動捕捉變量,但是使用綁定器可以實現類似的功能:
vector<int> data{ 1, 2, 3, 4 }; auto func = std::bind([](const vector<int>& data) { cout << data.size() << endl; }, std::move(data)); func(); // 4 cout << data.size() << endl; // 0
可以看到綁定器可以實現移動語義,這是因為對於左值參數,綁定對象是復制構造的,但是對右值參數,綁定對象是移動構造的。
4.2 std::mem_fn()適配器
當想調用成員函數時,你還可以使用std::mem_fn函數,此時你可以省略掉用於調用對象的占位符:
vector<Person> p{ Person{ "Tick" }, Person{ "Trick" } }; std::for_each(p.begin(), p.end(), std::mem_fn(&Person::print)); // 輸出: Trick Trick Person n{ "Bob" }; std::mem_fn(&Person::print2)(n, "Person: "); // 輸出:Person: Bob
所以,使用std::men_fn不需要綁定參數,可以更方便地調用成員函數。再看一個例子,std;:mem_fn還可以調用成員變量:
class Foo { public: int data = 7; void display_greeting() { cout << "Hello, world.\n"; } void display_number(int i) { cout << "number: " << i << '\n'; } }; int main() { Foo f; // 調用成員函數 std::mem_fn(&Foo::display_greeting)(f); // Hello, world. std::mem_fn(&Foo::display_number)(f, 20); // number: 20 // 調用數據成員 cout << std::mem_fn(&Foo::data)(f) << endl; // 7 return 0; }
取反器std::not1與std::not2很簡單,就是取函數對象的反結果,不過在C++17兩者被棄用了,所以就不講了。