C++11 lambda表示式精講

2020-07-16 10:04:49
lambda 表示式是 C++11 最重要也最常用的一個特性之一,C# 3.5 和 Java 8 中就引入了 lambda 表示式。

lambda 來源於函數語言程式設計的概念,也是現代程式語言的一個特點。C++11 這次終於把 lambda 加進來了。

lambda表示式有如下優點:
  • 宣告式程式設計風格:就地匿名定義目標函數或函數物件,不需要額外寫一個命名函數或者函數物件。以更直接的方式去寫程式,好的可讀性和可維護性。
  • 簡潔:不需要額外再寫一個函數或者函數物件,避免了程式碼膨脹和功能分散,讓開發者更加集中精力在手邊的問題,同時也獲取了更高的生產率。
  • 在需要的時間和地點實現功能閉包,使程式更靈活。

下面,先從 lambda 表示式的基本功能開始介紹它。

lambda 表示式的概念和基本用法

lambda 表示式定義了一個匿名函數,並且可以捕獲一定範圍內的變數。lambda 表示式的語法形式可簡單歸納如下:

[ capture ] ( params ) opt -> ret { body; };

其中 capture 是捕獲列表,params 是參數列,opt 是函數選項,ret 是返回值型別,body是函數體。

因此,一個完整的 lambda 表示式看起來像這樣:
auto f = [](int a) -> int { return a + 1; };
std::cout << f(1) << std::endl;  // 輸出: 2
可以看到,上面通過一行程式碼定義了一個小小的功能閉包,用來將輸入加 1 並返回。

在 C++11 中,lambda 表示式的返回值是通過前面介紹的《C++返回值型別後置》語法來定義的。其實很多時候,lambda 表示式的返回值是非常明顯的,比如這個例子。因此,C++11 中允許省略 lambda 表示式的返回值定義:

auto f = [](int a){ return a + 1; };

這樣編譯器就會根據 return 語句自動推匯出返回值型別。

需要注意的是,初始化列表不能用於返回值的自動推導:

auto x1 = [](int i){ return i; };  // OK: return type is int
auto x2 = [](){ return { 1, 2 }; };  // error: 無法推匯出返回值型別

這時我們需要顯式給出具體的返回值型別。

另外,lambda 表示式在沒有參數列時,參數列是可以省略的。因此像下面的寫法都是正確的:

auto f1 = [](){ return 1; };
auto f2 = []{ return 1; };  // 省略空參數列

使用 lambda 表示式捕獲列表

lambda 表示式還可以通過捕獲列表捕獲一定範圍內的變數:
  • [] 不捕獲任何變數。
  • [&] 捕獲外部作用域中所有變數,並作為參照在函數體中使用(按參照捕獲)。
  • [=] 捕獲外部作用域中所有變數,並作為副本在函數體中使用(按值捕獲)。
  • [=,&foo] 按值捕獲外部作用域中所有變數,並按參照捕獲 foo 變數。
  • [bar] 按值捕獲 bar 變數,同時不捕獲其他變數。
  • [this] 捕獲當前類中的 this 指標,讓 lambda 表示式擁有和當前類成員函數同樣的存取許可權。如果已經使用了 & 或者 =,就預設新增此選項。捕獲 this 的目的是可以在 lamda 中使用當前類的成員函數和成員變數。

下面看一下它的具體用法,如下所示。

【範例】lambda 表示式的基本用法。
class A
{
    public:
    int i_ = 0;
    void func(int x, int y)
    {
        auto x1 = []{ return i_; };                    // error,沒有捕獲外部變數
        auto x2 = [=]{ return i_ + x + y; };           // OK,捕獲所有外部變數
        auto x3 = [&]{ return i_ + x + y; };           // OK,捕獲所有外部變數
        auto x4 = [this]{ return i_; };                // OK,捕獲this指標
        auto x5 = [this]{ return i_ + x + y; };        // error,沒有捕獲x、y
        auto x6 = [this, x, y]{ return i_ + x + y; };  // OK,捕獲this指標、x、y
        auto x7 = [this]{ return i_++; };              // OK,捕獲this指標,並修改成員的值
    }
};
int a = 0, b = 1;
auto f1 = []{ return a; };               // error,沒有捕獲外部變數
auto f2 = [&]{ return a++; };            // OK,捕獲所有外部變數,並對a執行自加運算
auto f3 = [=]{ return a; };              // OK,捕獲所有外部變數,並返回a
auto f4 = [=]{ return a++; };            // error,a是以複製方式捕獲的,無法修改
auto f5 = [a]{ return a + b; };          // error,沒有捕獲變數b
auto f6 = [a, &b]{ return a + (b++); };  // OK,捕獲a和b的參照,並對b做自加運算
auto f7 = [=, &b]{ return a + (b++); };  // OK,捕獲所有外部變數和b的參照,並對b做自加運算
從上例中可以看到,lambda 表示式的捕獲列表精細地控制了 lambda 表示式能夠存取的外部變數,以及如何存取這些變數。

需要注意的是,預設狀態下 lambda 表示式無法修改通過複製方式捕獲的外部變數。如果希望修改這些變數的話,我們需要使用參照方式進行捕獲。

一個容易出錯的細節是關於 lambda 表示式的延遲呼叫的:
int a = 0;
auto f = [=]{ return a; };      // 按值捕獲外部變數
a += 1;                         // a被修改了
std::cout << f() << std::endl;  // 輸出?
在這個例子中,lambda 表示式按值捕獲了所有外部變數。在捕獲的一瞬間,a 的值就已經被複製到f中了。之後 a 被修改,但此時 f 中儲存的 a 仍然還是捕獲時的值,因此,最終輸出結果是 0。

如果希望 lambda 表示式在呼叫時能夠即時存取外部變數,我們應當使用參照方式捕獲。

從上面的例子中我們知道,按值捕獲得到的外部變數值是在 lambda 表示式定義時的值。此時所有外部變數均被複製了一份儲存在 lambda 表示式變數中。此時雖然修改 lambda 表示式中的這些外部變數並不會真正影響到外部,我們卻仍然無法修改它們。

那麼如果希望去修改按值捕獲的外部變數應當怎麼辦呢?這時,需要顯式指明 lambda 表示式為 mutable:
int a = 0;
auto f1 = [=]{ return a++; };             // error,修改按值捕獲的外部變數
auto f2 = [=]() mutable { return a++; };  // OK,mutable
需要注意的一點是,被 mutable 修飾的 lambda 表示式就算沒有引數也要寫明參數列。

lambda 表示式的型別

最後,介紹一下 lambda 表示式的型別。

lambda 表示式的型別在 C++11 中被稱為“閉包型別(Closure Type)”。它是一個特殊的,匿名的非 nunion 的類型別。

因此,我們可以認為它是一個帶有 operator() 的類,即仿函數。因此,我們可以使用 std::function 和 std::bind 來儲存和操作 lambda 表示式:
std::function<int(int)>  f1 = [](int a){ return a; };
std::function<int(void)> f2 = std::bind([](int a){ return a; }, 123);
另外,對於沒有捕獲任何變數的 lambda 表示式,還可以被轉換成一個普通的函數指標:
using func_t = int(*)(int);
func_t f = [](int a){ return a; };
f(123);
lambda 表示式可以說是就地定義彷函數閉包的“語法糖”。它的捕獲列表捕獲住的任何外部變數,最終均會變為閉包型別的成員變數。而一個使用了成員變數的類的 operator(),如果能直接被轉換為普通的函數指標,那麼 lambda 表示式本身的 this 指標就丟失掉了。而沒有捕獲任何外部變數的 lambda 表示式則不存在這個問題。

這裡也可以很自然地解釋為何按值捕獲無法修改捕獲的外部變數。因為按照 C++ 標準,lambda 表示式的 operator() 預設是 const 的。一個 const 成員函數是無法修改成員變數的值的。而 mutable 的作用,就在於取消 operator() 的 const。

需要注意的是,沒有捕獲變數的 lambda 表示式可以直接轉換為函數指標,而捕獲變數的 lambda 表示式則不能轉換為函數指標。看看下面的程式碼:
typedef void(*Ptr)(int*);
Ptr p = [](int* p){delete p;};  // 正確,沒有狀態的lambda(沒有捕獲)的lambda表示式可以直接轉換為函數指標
Ptr p1 = [&](int* p){delete p;};  // 錯誤,有狀態的lambda不能直接轉換為函數指標
上面第二行程式碼能編譯通過,而第三行程式碼不能編譯通過,因為第三行的程式碼捕獲了變數,不能直接轉換為函數指標。

宣告式的程式設計風格,簡潔的程式碼

就地定義匿名函數,不再需要定義函數物件,大大簡化了標準庫演算法的呼叫。比如,在 C++11 之前,我們要呼叫 for_each 函數將 vector 中的偶數列印出來,如下所示。

【範例】lambda 表示式代替函數物件的範例。
class CountEven
{
    int& count_;
public:
    CountEven(int& count) : count_(count) {}
    void operator()(int val)
    {
        if (!(val & 1))       // val % 2 == 0
        {
            ++ count_;
        }
    }
};
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each(v.begin(), v.end(), CountEven(even_count));
std::cout << "The number of even is " << even_count << std::endl;
這樣寫既煩瑣又容易出錯。有了 lambda 表示式以後,我們可以使用真正的閉包概念來替換掉這裡的仿函數,程式碼如下:
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each( v.begin(), v.end(), [&even_count](int val)
        {
            if (!(val & 1))  // val % 2 == 0
            {
                ++ even_count;
            }
        });
std::cout << "The number of even is " << even_count << std::endl;
lambda 表示式的價值在於,就地封裝短小的功能閉包,可以極其方便地表達出我們希望執行的具體操作,並讓上下文結合得更加緊密。