跳到正文

Wiki

Lambda 表示式

約 14 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

1.本節解決什麼問題

很多地方需要傳入一小段邏輯:排序規則、過濾條件、按鈕回撥、定時器回撥。舊寫法通常有三種:

  1. 寫一個普通函式。
  2. 寫一個函式指標。
  3. 寫一個函式物件,也就是帶 operator() 的類或結構體。

這些寫法都能工作,但小邏輯會被迫挪到遠處,或者要額外寫一個型別。Lambda 表示式讓你可以在使用現場直接寫一個小函式,而且可以捕獲外部變數。

2.這個特性是什麼

Lambda 是匿名可呼叫物件。它看起來像一個函式,但本質上是編譯器生成的一個類物件。

基本結構是:

部分 例子 含義
捕獲列表 [x, &y] 從外部作用域拿哪些變數
引數列表 (int a, int b) 呼叫 lambda 時傳入什麼
返回值 -> int 返回型別,通常可以省略
函式體 { return a + b; } 真正執行的程式碼

完整形式可以寫成 [x](int n) -> int { return x + n; },常見情況下返回值型別可以省略。

3.C++ 標準版本

  • C++11:基礎 Lambda。
  • C++14:泛型 Lambda,也就是引數可以寫 auto
  • C++17:constexpr Lambda、[*this] 捕獲。

Lambda 是語言特性,不需要額外標頭檔案。只有配合 STL 演算法、std::function 等庫工具時,才需要包含對應標頭檔案。

4.捕獲列表速查

寫法 含義 適用場景
[] 不捕獲外部變數 只用引數或區域性臨時變數
[x] 按值捕獲 x 儲存一份副本,適合儲存回撥
[&x] 按引用捕獲 x 需要修改外部變數,且能保證生命週期
[x, &y] 混合捕獲 明確表達哪些複製、哪些引用
[=] 預設按值捕獲用到的變數 小例子方便,工程中不建議濫用
[&] 預設按引用捕獲用到的變數 非同步或儲存回撥中尤其危險
[this] 捕獲當前物件指標 物件必須比 lambda 活得久
[*this] 捕獲當前物件副本 C++17 起可用,適合避免懸空 this

5.示例程式碼

5.1.示例 1:舊回撥寫法和 lambda 的區別

普通函式和函式物件都可以作為演算法條件,但 Lambda 更適合寫簡短的區域性邏輯。

#include <algorithm>
#include <iostream>
#include <vector>

bool is_even(int n)
{
    return n % 2 == 0;
}

struct GreaterThan
{
    int limit;

    bool operator()(int n) const
    {
        return n > limit;
    }
};

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    // vector 是动态数组,元素数量可以在运行时变化。
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};

    int even_count = std::count_if(numbers.begin(), numbers.end(), is_even);
    std::cout << "even count = " << even_count << "\n";

    int greater_count1 = std::count_if(numbers.begin(), numbers.end(), GreaterThan{3});
    std::cout << "> 3 count (functor) = " << greater_count1 << "\n";

    int limit = 3;
    int greater_count2 = std::count_if(numbers.begin(), numbers.end(),
                                       [limit](int n) {
                                           return n > limit;
                                       });
    std::cout << "> 3 count (lambda) = " << greater_count2 << "\n";

    return 0;
}

執行結果

even count = 3
> 3 count (functor) = 3
> 3 count (lambda) = 3

5.1.1.count_if 的第三個引數是什麼?

先看這一句:

int even_count = std::count_if(numbers.begin(), numbers.end(), is_even);

std::count_if 可以簡單理解為:

從指定範圍中逐個取出元素,呼叫你提供的“判斷規則”,統計其中返回 true 的元素有多少個。

它的基本形式是:

std::count_if(起点, 终点, 判断规则);

第三個引數就是這個“判斷規則”。它並不一定非得是普通函式,只要是可以像函式一樣呼叫的物件就可以,包括普通函式、函式物件和 Lambda。這些東西統稱為可呼叫物件(Callable)

這裡第三個引數傳入的是:

is_even

is_even 是:

bool is_even(int n)
{
    return n % 2 == 0;
}

count_if 會把 numbers 中的元素一個一個傳給它,過程可以近似理解成:

is_even(1);  // false,不计数
is_even(2);  // true,计数 +1
is_even(3);  // false,不计数
is_even(4);  // true,计数 +1
is_even(5);  // false,不计数
is_even(6);  // true,计数 +1

因此最後得到 3

可以把 count_if 的內部工作近似理解成:

int count = 0;

for (每一个元素)
{
    if (判断规则(当前元素))
    {
        ++count;
    }
}

也就是說,count_if 自己負責:

  • 怎麼遍歷容器;
  • 怎麼統計數量。

第三個引數負責告訴它:

什麼樣的元素才算符合條件?

同一個位置也可以傳入函式物件:

std::count_if(numbers.begin(), numbers.end(), GreaterThan{3});

GreaterThan{3} 可以像函式一樣被呼叫,因為結構體定義了:

bool operator()(int n) const
{
    return n > limit;
}

它回答的問題就是:

當前這個 n 是否大於 3

還可以直接傳 Lambda:

std::count_if(numbers.begin(), numbers.end(),
              [limit](int n) {
                  return n > limit;
              });

count_if 同樣會反覆呼叫這個 Lambda:

lambda(1);  // false
lambda(2);  // false
lambda(3);  // false
lambda(4);  // true
lambda(5);  // true
lambda(6);  // true

所以這三種寫法雖然形式不同,本質上都是在給 count_if 提供一個判斷規則:

is_even
GreaterThan{3}
[limit](int n) { return n > limit; }

Lambda 的優勢在於:如果這個判斷規則只在這裡使用一次,就可以直接寫在演算法旁邊,不需要額外定義一個函式或結構體。

5.2.示例 2:最基本的 lambda 語法、引數和返回值

#include <iostream>
#include <string>

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    auto add = [](int a, int b) {
        return a + b;
    };

    auto describe_score = [](int score) -> std::string {
        if (score >= 60)
        {
            return "pass";
        }
        return "fail";
    };

    std::cout << "add(3, 5) = " << add(3, 5) << "\n";
    std::cout << "score 80 is " << describe_score(80) << "\n";
    std::cout << "score 40 is " << describe_score(40) << "\n";

    // 返回 0 表示程序正常结束。
    return 0;
}

執行結果

add(3, 5) = 8
score 80 is pass
score 40 is fail

5.3.示例 3:按值捕獲和按引用捕獲

按值捕獲會儲存定義 lambda 時的副本;按引用捕獲會訪問外部變數本身。

#include <iostream>

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    int score = 10;

    auto add_by_value = [score](int bonus) {
        return score + bonus;
    };

    auto add_by_ref = [&score](int bonus) {
        score += bonus;
        return score;
    };

    score = 20;

    std::cout << "value capture result = " << add_by_value(5) << "\n";
    std::cout << "ref capture result = " << add_by_ref(5) << "\n";
    std::cout << "score after ref capture = " << score << "\n";

    // 返回 0 表示程序正常结束。
    return 0;
}

執行結果

value capture result = 15
ref capture result = 25
score after ref capture = 25

5.4.示例 4:mutable 允許修改按值捕獲的副本

按值捕獲的變數預設在 lambda 內是隻讀的。加上 mutable 後,可以修改 lambda 自己儲存的副本,但不會修改外部變數。

#include <iostream>

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    int start = 0;

    auto counter = [start]() mutable {
        ++start;
        return start;
    };

    std::cout << "counter() = " << counter() << "\n";
    std::cout << "counter() = " << counter() << "\n";
    std::cout << "outside start = " << start << "\n";

    // 返回 0 表示程序正常结束。
    return 0;
}

執行結果

counter() = 1
counter() = 2
outside start = 0

5.4.1.為什麼連續呼叫 counter() 會繼續累加?

這裡不是因為外部 start 的作用域變大了,而是因為 Lambda 物件 counter 自己儲存了一份 start

int start = 0;

auto counter = [start]() mutable {
    ++start;
    return start;
};

[start] 是按值捕獲。可以近似理解為編譯器生成了這樣的物件:

struct Counter
{
    int start;

    int operator()()
    {
        ++start;
        return start;
    }
};

Counter counter{start};

因此程式中實際上有兩個不同的 start

  • 外部 start:始終還是 0
  • counter 內部儲存的 start:第一次呼叫變成 1,第二次呼叫繼續變成 2
counter();  // 内部 start:0 -> 1
counter();  // 内部 start:1 -> 2

關鍵在於:

auto counter = [start]() mutable { ... };

這裡只建立了一次 Lambda 物件。後面的兩個 counter() 都是在呼叫同一個物件,不會每次呼叫時重新捕獲一次外部 start

mutable 的作用則是允許修改 Lambda 內部按值捕獲的副本,它並不會讓外部的 start 一起變化。

5.5.示例 5:lambda 配合 STL 演算法

Lambda 和 STL 演算法配合時最常見:排序、查詢、計數、轉換都可以把區域性邏輯直接寫在呼叫處。

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    // vector 是动态数组,元素数量可以在运行时变化。
    std::vector<std::string> names = {"Bob", "Alice", "Charlie", "David"};

    std::sort(names.begin(), names.end(),
              [](const std::string& a, const std::string& b) {
                  if (a.size() == b.size())
                  {
                      return a < b;
                  }
                  return a.size() < b.size();
              });

    std::cout << "sort by length: ";
    for (const auto& name : names)
    {
        std::cout << name << " ";
    }
    std::cout << "\n";

    int min_length = 6;
    auto it = std::find_if(names.begin(), names.end(),
                           [min_length](const std::string& name) {
                               return name.size() >= static_cast<std::size_t>(min_length);
                           });

    if (it != names.end())
    {
        std::cout << "first long name = " << *it << "\n";
    }

    return 0;
}

執行結果

sort by length: Bob Alice David Charlie
first long name = Charlie

5.5.1.示例中使用的 API 和語法

這個示例除了 Lambda,還使用了一些常見的 STL API。

寫法 簡單說明
std::vector<std::string> 動態陣列,這裡用於儲存多個字串
names.begin() 返回指向第一個元素的迭代器
names.end() 返回最後一個元素後一個位置的迭代器
std::sort(begin, end, rule) 對指定範圍排序,第三個引數指定比較規則
string.size() 返回字串長度
const std::string& 只讀引用字串,避免額外複製
for (const auto& x : container) 範圍 for,依次訪問容器中的每個元素
auto 讓編譯器自動推導變數型別
std::find_if(begin, end, rule) 查詢第一個滿足條件的元素
static_cast<T>(value) value 顯式轉換為型別 T
std::size_t STL 中常用於表示大小、數量和下標的無符號整數型別
*it 取得迭代器 it 當前指向的元素

其中 begin()end() 經常一起出現:

names.begin(), names.end()

表示整個 names 的遍歷範圍。需要注意,end() 並不指向最後一個元素,而是最後一個元素後面的結束位置:

Bob    Alice    David    Charlie    [结束位置]
 ↑                                      ↑
begin()                                end()

5.5.2.STL 演算法為什麼要接收一個“規則函式”?

std::count_ifstd::find_ifstd::sort 有一個共同特點:

演算法自己負責“怎麼做”,你提供的函式負責“按照什麼規則做”。

例如 std::sort 自己知道排序演算法應該怎樣移動和比較元素,但是它不知道你希望按照數值大小、字串長度還是其他規則排序。

同樣,std::find_if 自己知道怎樣從前往後查詢,但是它不知道“什麼樣的元素才算你要找的元素”。

因此這些演算法會接收一個可呼叫物件,並在執行過程中反覆呼叫它。

在示例 1 中已經看到:

std::count_if(begin, end, rule);

它會對每個元素呼叫:

rule(element);

第三個引數回答的是:

這個元素算不算?

find_ifsort 使用的是同一種設計思想,只是它們向規則函式提出的問題不同。

5.5.3.std::find_if:這個元素是不是我要找的?

基本形式是:

std::find_if(起点, 终点, 判断规则);

示例中:

auto it = std::find_if(names.begin(), names.end(),
                       [min_length](const std::string& name) {
                           return name.size() >= static_cast<std::size_t>(min_length);
                       });

[min_length] 按值捕獲最小長度 6。這個 Lambda 接收一個字串,並判斷它的長度是否至少為 6

find_if 會依次呼叫這個 Lambda,可以近似理解成:

rule("Bob");      // false
rule("Alice");    // false
rule("David");    // false
rule("Charlie");  // true

一旦第一次得到 truefind_if 就停止查詢,並返回當前元素的位置。

因此 find_if 的第三個引數回答的是:

當前元素是不是我要找的?

它和 count_if 的規則函式形式很像,通常都是:

bool rule(一个元素);

區別在於演算法得到 true 之後做什麼:

演算法 規則返回 true 後的行為
count_if 計數加一,然後繼續檢查後面的元素
find_if 已經找到,立即停止並返回當前位置

5.5.4.std::sorta 應不應該排在 b 前面?

std::sort 稍微不同:

std::sort(起点, 终点, 比较规则);

它的第三個引數一次接收兩個元素

[](const std::string& a, const std::string& b) {
    if (a.size() == b.size())
    {
        return a < b;
    }
    return a.size() < b.size();
}

sort 會在排序過程中不斷呼叫:

rule(a, b);

這個函式要回答:

按照當前排序規則,a 是否應該排在 b 前面?

例如:

rule("Bob", "Charlie");

因為:

Bob      长度 3
Charlie  长度 7

最終判斷的是:

3 < 7

結果為 true,表示按照當前規則,Bob 應該排在 Charlie 前面。

如果兩個字串長度相同:

if (a.size() == b.size())
{
    return a < b;
}

就再使用字串的字典順序決定誰在前面。例如 AliceDavid 長度都是 5,因此比較:

"Alice" < "David"

結果為 true,所以 Alice 排在 David 前面。

三個演算法可以這樣記:

API 第三個引數回答什麼?
count_if 這個元素算不算?
find_if 這個元素是不是我要找的?
sort a 應不應該排在 b 前面?

這正是 Lambda 很常見的用途:STL 演算法負責通用流程,Lambda 負責描述具體規則。

5.5.5.const std::string&.size()

Lambda 中的:

const std::string& a

可以拆成:

  • std::string:引數是字串;
  • &:使用引用,不額外複製整個字串;
  • const:只讀取這個字串,不修改它。

因此 const std::string& 常用於只讀地接收 stringvector 等較大的物件。

字串的:

a.size()

返回字串長度。例如:

"Bob"       -> 3
"Alice"     -> 5
"Charlie"   -> 7
"David"     -> 5

所以示例的排序結果是:

Bob Alice David Charlie

5.5.6.範圍 for

for (const auto& name : names)
{
    std::cout << name << " ";
}

可以直接理解為:

依次取出 names 中的每個元素,並把當前元素命名為 name

這裡的:

const auto& name

編譯器會根據 names 的元素型別自動推導,近似等價於:

const std::string& name

5.5.7.static_cast<std::size_t>

這裡:

static_cast<std::size_t>(min_length)

是顯式型別轉換,可以簡單理解為:

min_length 轉換成 std::size_t 型別。

因為 name.size() 返回的是 std::size_t,而:

int min_length = 6;

中的 min_lengthint。轉換以後,兩邊使用適合的同類整數型別進行比較。

std::size_t 常用於表示容器大小、字串長度和陣列下標等非負數量。

5.5.8.it 和迭代器

auto it = std::find_if(...);

find_if 返回的不是字串本身,而是一個迭代器

迭代器可以暫時理解成類似指標的物件,用來表示容器中的某個位置。

排序後的資料是:

Bob    Alice    David    Charlie

                         it

因此:

*it

取得的就是:

Charlie

如果 find_if 一直沒有找到滿足條件的元素,它會返回:

names.end()

所以常見寫法是:

if (it != names.end())
{
    std::cout << *it << "\n";
}

意思就是:

如果確實找到了元素,再使用它。

5.5.9.整段程式碼的執行過程

程式首先建立:

std::vector<std::string> names = {
    "Bob",
    "Alice",
    "Charlie",
    "David"
};

然後 std::sort 使用 Lambda 作為排序規則:

  1. 長度不同,短的排前面。
  2. 長度相同,按照字串字典順序排列。

所以得到:

Bob Alice David Charlie

接下來:

int min_length = 6;

規定要尋找長度至少為 6 的名字。

std::find_if 會按照當前順序依次呼叫 Lambda:

Bob       -> 3 >= 6 -> false
Alice     -> 5 >= 6 -> false
David     -> 5 >= 6 -> false
Charlie   -> 7 >= 6 -> true

第一次得到 true 時停止,因此 it 最終指向 Charlie

*it

得到:

Charlie

最終輸出:

sort by length: Bob Alice David Charlie
first long name = Charlie

這個示例體現了 Lambda 非常典型的一種用途:

STL 演算法負責通用流程,Lambda 負責描述具體規則。

5.6.示例 6:儲存回撥時要注意捕獲生命週期

如果 lambda 只是立刻呼叫,引用捕獲通常看起來沒問題;如果儲存到容器、執行緒、定時器或非同步回撥裡,lambda 可能晚於區域性變數執行。儲存回撥時優先按值捕獲需要的資料。

#include <functional>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    // 程序从 main 函数开始执行,下面的语句会按顺序运行。
    // std::function 可以保存普通函数、lambda 或函数对象。
    // vector 是动态数组,元素数量可以在运行时变化。
    std::vector<std::function<void()>> callbacks;

    {
        std::string name = "Alice";
        int score = 95;

        auto print_now = [&name, &score]() {
            std::cout << "now: " << name << " " << score << "\n";
        };
        print_now();

        callbacks.push_back([name, score]() {
            std::cout << "saved: " << name << " " << score << "\n";
        });
    }

    for (const auto& callback : callbacks)
    {
        callback();
    }

    return 0;
}

執行結果

now: Alice 95
saved: Alice 95

這裡 print_now 立刻呼叫,所以引用捕獲沒問題。儲存到 callbacks 的 lambda 用按值捕獲,因為離開內部作用域後,namescore 已經銷燬。

5.7.示例 7:捕獲 this 訪問當前物件

在類的成員函式中,Lambda 可以使用 [this] 捕獲當前物件的指標。Lambda 訪問的仍然是原來的物件,因此物件成員之後發生的變化也能看到。

#include <iostream>

class Robot
{
public:
    Robot(int speed) : speed_(speed)
    {
    }

    void demo()
    {
        auto print_speed = [this]() {
            std::cout << "lambda speed = " << speed_ << "\n";
        };

        speed_ = 20;
        print_speed();
    }

private:
    int speed_;
};

int main()
{
    Robot robot(10);
    robot.demo();

    return 0;
}

執行結果

lambda speed = 20

這裡:

[this]() {
    std::cout << speed_;
}

可以近似理解為 Lambda 儲存了當前物件的 this 指標,因此:

speed_

實際上訪問的是:

this->speed_

建立 Lambda 時 speed_10,之後原物件把 speed_ 修改成 20。因為 [this] 指向的仍然是原物件,所以呼叫 Lambda 時讀到的是 20

需要特別注意:[this] 儲存的是物件指標,不是物件副本。如果 Lambda 被儲存下來,而原物件已經銷燬,再通過這個 Lambda 訪問成員就可能產生懸空指標問題。因此儲存回撥、非同步任務和執行緒回撥中使用 [this] 時要特別關注物件生命週期。

5.8.示例 8:使用 [*this] 捕獲當前物件副本

C++17 開始可以使用 [*this]。它不是儲存當前物件的指標,而是在建立 Lambda 時複製一份當前物件。

#include <iostream>

class Robot
{
public:
    Robot(int speed) : speed_(speed)
    {
    }

    void demo()
    {
        auto print_speed = [*this]() {
            std::cout << "lambda copy speed = " << speed_ << "\n";
        };

        speed_ = 20;

        print_speed();
        std::cout << "outside speed = " << speed_ << "\n";
    }

private:
    int speed_;
};

int main()
{
    Robot robot(10);
    robot.demo();

    return 0;
}

執行結果

lambda copy speed = 10
outside speed = 20

這裡 Lambda 建立時:

[*this]

會把當時的 Robot 物件複製一份儲存到 Lambda 內部。此時副本中的:

speed_ = 10

之後:

speed_ = 20;

修改的是外面的原物件,不會修改 Lambda 已經儲存的物件副本,因此 Lambda 仍然輸出 10

可以把兩種寫法這樣對比:

寫法 Lambda 儲存什麼 原物件後來修改成員後
[this] 當前物件的指標 Lambda 能看到新的成員值
[*this] 當前物件的副本 Lambda 保留捕獲時的成員值

[*this] 可以避免因為原物件銷燬而直接留下一個懸空的 this 指標,但它會複製整個物件,因此也要考慮物件是否適合複製以及複製成本。

如果需要在 Lambda 中修改這份物件副本,與普通按值捕獲類似,可以再配合 mutable

auto callback = [*this]() mutable {
    ++speed_;
};

這裡修改的仍然只是 Lambda 內部儲存的物件副本,不會修改外面的原物件。

6.關鍵語法解釋

示例 重點 說明
示例 1 舊回撥寫法對比 普通函式、函式物件都能做回撥,但 lambda 更適合區域性短邏輯
示例 2 引數和返回值 返回型別通常可推導,分支返回不同型別時要顯式寫清楚
示例 3 捕獲列表 [x] 複製,[&x] 引用
示例 4 mutable 修改的是 lambda 內部副本,不影響外部變數
示例 5 STL 演算法 sortfind_ifcount_if 常和 lambda 配合
示例 6 生命週期 儲存回撥、非同步回撥、執行緒回撥中不要隨便引用捕獲區域性變數
示例 7 [this] 捕獲當前物件指標,Lambda 訪問的是原物件
示例 8 [*this] C++17 起捕獲當前物件副本,與原物件狀態分離

7.常見錯誤

  1. 儲存回撥時使用預設引用捕獲 [&],導致區域性變數銷燬後仍被訪問。
  2. 以為按值捕獲會跟著外部變數變化。按值捕獲儲存的是定義 lambda 時的副本。
  3. 想修改按值捕獲的副本,卻忘記加 mutable
  4. 把有捕獲的 lambda 當成函式指標使用。有捕獲的 lambda 需要用模板引數、auto 變數或 std::function 儲存。
  5. 非同步場景捕獲 this 後,物件先被銷燬。需要保證物件生命週期,或使用智慧指標、[*this] 等更明確的方式。

8.使用建議

  1. 小而區域性的邏輯優先用 lambda。
  2. 捕獲列表儘量顯式寫 [x, &y],少用預設 [=][&]
  3. 儲存回撥、執行緒、定時器、非同步操作中優先按值捕獲需要的資料。
  4. 只調用一次、不需要儲存的回撥,可以直接把 lambda 傳給演算法或函式模板。
  5. 類成員函式中的回撥使用 [this] 時要保證物件生命週期;需要獨立儲存物件狀態時可以考慮 C++17 的 [*this]
  6. 需要統一儲存不同 lambda 時,再使用下一節的 std::function

9.小結

  • Lambda 是匿名可呼叫物件,寫法是 [捕获](参数) { 函数体 }
  • 捕獲列表決定 lambda 如何使用外部變數。
  • [x] 是按值捕獲,[&x] 是按引用捕獲。
  • mutable 允許修改按值捕獲的內部副本。
  • [this] 捕獲當前物件指標,[*this] 從 C++17 起可以捕獲當前物件副本。
  • Lambda 最常用於 STL 演算法、回撥和非同步任務。
  • 生命週期是 Lambda 最容易出錯的地方,尤其是儲存回撥和非同步回撥。