摘要訊息 : C++ 14 及其之後 C++ 為 Lambda 表達式引入的新特性.

0. 前言

我們在《【C++ 14】泛型 (動態) Lambda 表達式》介紹了 C++ 14 引入的關於 Lambda 表達式的新特性, 接下來我們將介紹 C++ 14, C++ 17 和 C++ 20 中關於 Lambda 表達式的所有新特性.

本文的目錄中的標題過長, 可能影響前面章節的閱讀體驗, 故本篇文章的目錄預設為隱藏不展開狀態, 需要閣下手動展開.

更新紀錄 :

  • 2022 年 5 月 17 日進行第一次更新和修正.

1. 泛型捕獲列表

C++ 14 提案 N3648《Wording Changes for Generalized Lambda-capture》提出了泛型捕獲列表, 這其實是為了彌補本來 C++ 11 要引入的移動捕獲. 但是由於時間關係, C++ 11 並沒有引入移動捕獲, C++ 14 中的泛型捕獲列表是比移動捕獲列表更強的新特性.

泛型捕獲列表支援在捕獲列表中進行初始化 :

#include <utility>
#include <string>


int main(int argc, char *argv[]) {
    std::string a {"Hello World!"};
    auto lambda = [b {0}, c = 1, a {std::move(a)}]() -> void {};
}

左側右側的名稱相同是允許的, 左側的 a 代表 Lambda 表達式內的 a, 右側的 a 代表外側型別為 std::stringa. 相當於 Lambda 表達式內的 a 通過移動捕獲的方式覆蓋了外面的 a.

關於Lambda 表達式的生存週期問題是比較複雜的. 一般地, 在 Lambda 表達式內部宣告的變數在 Lambda 被呼叫終結的時候就會被回收. 但是捕獲列表內的變數則不同, 如果某個變數在 Lambda 表達式的捕獲列表中初始化, 那麼它將在對應的可視範圍結束的時候才被回收. 說到底, 其實宣告一個 Lambda 表達式就是一個不具名類別對應的物件. 但是對於初始化列表則不同, 它會在 Lambda 表達式初始化結束的時候就被回收 :

#include <iostream>

using namespace std;
class Foo {
    int *p;
public:
    Foo() : p {new int {123}} {
        cout << "construct" << endl;
    }
    Foo(const Foo &) : p {new int {111}} {
        cout << "copy" << endl;
    }
    Foo(Foo &&rhs) noexcept : p {rhs.p} {
        cout << "move" << endl;
        rhs.p = nullptr;
    }
    ~Foo() noexcept {
        cout << "destruct" << endl;
        delete this->p;
    }
    void print() const {
        cout << *this->p << endl;
    }
};
int main(int argc, char *argv[]) {
    auto lambda1 {[x {Foo()}]() -> const Foo & {
        return x;
    }};
    auto lambda2 {[x = {Foo()}]() -> const Foo & {
        return x;
    }};
    auto &f1 = lambda1();
    f1.print();
    auto &f2 = lambda2();
    f2.print();
}

/* 輸出 :
    construct
    123
    destruct
    construct
    destruct
    ???
*/

我們可以看到, 輸出的最後有一個 ???. 這是因為在成員函式 print 被呼叫之前, x 中的第一個物件就已經被回收了, 再使用這個物件只會導致未定行為. 在 Apple Clang 中可能足夠幸運還能夠輸出 123, 但是 MSVC 可能就是一個隨機的數字.

2. constexpr Lambda

C++ 17 提案 P0170R1 《Wording for Constexpr Lambda》為 Lambda 表達式引入了 constexpr. 實際上, 在 C++ 11 和 C++ 14 中, Lambda 表達式不可以被 constexpr 標識, 它也就是失去了編碼期計算的能力. 但實際上, Lambda 表達式本質上還是一個函式, 如果它失去了編碼期計算的能力, 那麼有些程式設計師可能更傾向於使用普通函式. 總共有五種方式可以為 Lambda 表達式標識 constexpr :

  • 允許 Lambda 表達式出現在 constexpr 函式內, 在 constexpr 函式內的 Lambda 表達式預設被 constexpr 標識;
  • 允許 Lambda 表達式被 constexpr 標識;
  • constexpr 標識在 Lambda 表達式參數列表之後, 也就是 mutablenoexcept 的位置 (mutableconstexpr 出現的位置可以互換, 但是 noexcept 必定是出現在最後的);
  • 只要編碼器檢測到可以進行編碼期計算, 那麼沒有被 constexpr 標識的 Lambda 表達式也可以產生編碼期常數 (換句話說, 任意 Lambda 表達式都是潛在的 constexpr 函式, 即時它沒有被明確標識 constexpr);
  • 允許 Lambda 表達式向被 constexpr 標識的指標轉型.
constexpr int add1(int i) {
    return [i] {
        return i + 1;
    }();
}

constexpr auto add2 {[](int i) noexcept -> int {
    return i + 1;
}};

auto add3 {[](int i) constexpr noexcept -> int {
    return i + 1;
}};

auto add4 {[](int i) noexcept -> int {
    return i + 1;
}};

constexpr int (*add5)(int) {add};

static_assert(add1(1) == 2, "compile error!");      // OK
static_assert(add2(1) == 2, "compile error!");      // OK
static_assert(add3(1) == 2, "compile error!");      // OK
static_assert(add4(1) == 2, "compile error!");      // OK
static_assert(add5(1) == 2, "compile error!");      // OK

允許 Lambda 表達式向被 constexpr 標識的指標轉型這個特性連普通的函式都無法做到.

3. 值捕獲 *this

在 C++ 17 之前, 若 Lambda 表達式要捕獲 *this, 不論是明確通過值捕獲還是明確通過參考捕獲, 最終的結果都是以參考的方式捕獲 *this. 也就是說在 C++ 17 之前, 並不存在 *this 的值捕獲方式, 也沒有任何辦法值捕獲 *this. C++ 17 提案 P0018R3 《Lambda Capture of *this by Value as [=,*this]》提出了使用 [=, *this] 的方法來對 *this 產生值捕獲的效果. 但是如果在 Lambda 表達式內直接使用 this, 那麼仍然將作用於原本的類別, 必須配合 C++ 14 引入的泛型捕獲列表給 *this 起別名才行 :

#include <iostream>

struct Foo {
    int a {0};
    void func() {
        auto lambda {[=, tmp = *this]() mutable {
            tmp.a = 222;
        }};
        lambda();
    }
    void func(int) {
        auto lambda {[=, tmp = *this]() mutable {
            this->a = 222;
            tmp.a = 111;
        }};
        lambda();
    }
};

int main(int argc, char *argv[]) {
    Foo f;
    f.func();
    std::cout << f.a << std::endl;      // 輸出結果 : 0
    f.func(1);
    std::cout << f.a << std::endl;      // 輸出結果 : 222
}

4. 遺棄 [=] 隱含參考捕獲 this

C++ 20 提案 P0806R2 《Deprecate implicit capture of this via [=]》提出了遺棄使用 [=] 的方式預設參考捕獲 this, 也就是 [=] 在 C++ 20 之後不再隱含地對 this 進行參考捕獲. C++ 20 提案 P0409R2 《Allow lambda capture [=, this]》提出必須明確地在捕獲列表中寫出 [=, this] 才可以參考捕獲 *this.

5. Lambda 表達式樣板

在 C++ 14 引入泛型 Lambda 表達式的時候, 所有的泛型 Lambda 表達式都是通過 auto 的方式進行宣告, 這就造成了一些不太方便的地方 :

  1. 泛型 Lambda 表達式可以接受任何型別的引數. 若想要明確拒絕, 只能通過 static_assert 或者其它方式強行產生編碼錯誤;
  2. 要獲取引數的具體型別只能通過 decltype 進行推導, 這導致了諸多不便之處;
  3. 完美轉遞也需要借助 decltype 來完成.

為了解決這些問題, C++ 20 提案 P0428R2 《Familiar template syntax for generic lambdas》提出為 Lambda 表達式引入 template 語義. Lambda 表達式的樣板參數是寫在捕獲列表之後, 參數列表之前的 :

#include <vector>

const auto lambda {[]<typename T, typename U, typename ...Ts>(const T &t, std::vector<U> &, Ts &&...args) -> auto {
    //...
}};

對於第一個問題, 若我們想要 Lambda 表達式接受 std::vector 型別的物件, 那麼一旦檢測到非 std::vector 型別的物件, 我們只能通過 static_assert 來強行產生編碼錯誤, 這和 C++ 的 SFINAE 思想不太相符. 但是現在, 可以直接宣告接受任意 std::vector 特製化的型別 :

#include <vector>
#include <type_traits>

template <typename T>
struct is_std_vector : false_type {};
template <typename T>
struct is_std_vector<std::vector<T>> : true_type {};

const auto lambda0 {[](auto &&vec) -> auto {
    static_assert(is_std_vector<typename std::decay<decltype(vec)>::type>::value, "The type of argument must be std::vector!");
    //...
}};     // before C++ 20

const auto lambda {[]<typename T>(vector<T> &) -> auto {
    //...
}};     // since C++ 20

對於第二個第三個問題, 我們就不給出範例了, Lambda 表達式可以宣告參數列表之後, 這兩個問題的解決方案就和普通函式樣板是一樣的.

6. 無狀態 Lambda 表達式的預設建構和指派

C++ 中的無狀態物件是指其對應型別是一個類別, 但是類別中不存在任何非靜態成員變數. 編碼器會為任意一個 Lambda 表達式生成一個不具名類別. 在 C++ 20 之前, 編碼器並不會為 Lambda 表達式所對應的類別生成預設建構子和指派操作. 也就是說, 在 C++ 20 之前, 以下程式碼是不能通過編碼的 :

#include <map>
#include <string>

int main(int argc, char *argv[]) {
    auto lambda {[](auto x, auto y) {
        return x < y;
    }};
    std::map<std::string, int, decltype(lambda)> m1, m2;        // Error
    m1 = m2;        // Error
}

C++ 20 提案 P0624R2 《Default constructible and assignable stateless lambdas》提出讓編碼器為無狀態的 Lambda 表達式生成預設建構和指派操作. 這樣在 C++ 20 中, Code 7 就可以通過編碼了.

7. 不可估量語境中的 Lambda 表達式

編碼期在為 Lambda 表達式生成不具名類別的時候, 不會判斷某些 Lambda 表達式內部的實作是否相同. 因此每一個 Lambda 表達式都有自己獨一無二的型別, 即使這些 Lambda 的內容完全一樣 :

auto lambda1 {[]() {}};
auto lambda2 {[]() {}};
static_assert(is_same<decltype(lambda1), decltype(lambda2)>::value, "Different type!");     // Compile Error : Different type!

那麼發揮想像, 我們可以寫出下面的多載函式 :

void func(decltype([]() {}));
void func(decltype([]() {})) {
    //...
}

但是對於函式 func 來說, 到底算是函式多載還是先宣告後定義呢? 對於這樣的語境, 我們稱為不可估量語境 (unevaluated context). 這樣的程式碼在 C++ 20 之前是無法通過編碼的, 這個限制在某些情況下對我們可能造成困擾. 在《【C++ 14】函式回傳型別推導》中, 我們在文章最後提到了通過編碼器推導函式回傳型別實現 std::tuple 元素排序. 如果我們希望得到函式 sort 的回傳型別, 那麼我們會使用 decltype :

#include <tuple>

template <typename F, typename ...Ts>
auto sort(std::tuple<Ts...>, F);
using sorted_tuple = decltype(sort(std::make_tuple(1, 'l', 0.0f, U'0'), [](auto a, auto b) noexcept {
    return a < b;
}));

由於 auto 的型別可能和 Lambda 表達式的型別有關, 於是編碼器會告訴你這是一個不可估量語境, 於是你得到了一個編碼錯誤. 在 C++ 20 之前, 必須使用確定的型別替換 auto. C++ 20 提案 P0315R4 《Wording for lambdas in unevaluated contexts》提出了將這個限制解除, 因此在 C++ 20 之後 Code 9Code 10 都可以通過編碼.

8. 捕獲引數包

自 C++ 11 引入參數包和引數包之後, 大家都知道參數包和引數包都不支援指派操作. 在 Lambda 表達式的捕獲列表中, 如果要捕獲一個引數包, 可以採用 [args...] 或者 [&args...] 的形式對引數包 args 進行值捕獲或者參考捕獲. 但是由於 C++ 沒有引入移動捕獲, 所以如果要對一個引數包進行移動捕獲, 不能像泛型捕獲列表中那些普通變數那樣直接使用 std::move, 而需要借助 std::tuple : [t = std::move(args)...]. C++ 20 提案 P0780R2 《Allow pack expansion in lambda init-capture》提出了一個新的語法 [...args = args...], 讓引數包也可以像普通變數那樣在 Lambda 表達式的泛型捕獲列表中以類似於指派的形式進行捕獲. 這樣, 我們就可以簡化移動捕獲引數包的形式 : [...args = std::move(args)...].

在 Lambda 表達式的泛型捕獲列表中, 對於引數包的參考捕獲本來是寫成 [...&args = args...], 但是 C++ 20 提案 P2095R0《Resolve lambda init-capture pack grammar (CWG2378)》修改了語法. 在 C++ 20 中, 泛型捕獲列表中以類似指派的形式參考捕獲引數包需要把參考標識放到最前面, 即 [&...args = args...]. 現在只有最新的形式才可以通過編碼, 前面那個形式是無法通過編碼的. 這個是為了和 C++ 17 引入的 template <auto &...> 這樣的樣板宣告保持語法一致 (《C++ 17 特性合集 (二)》).

9. 省略 ()

我們知道, 如果 Lambda 表達式的參數列表為空並且參數列表後面不帶有任何標識, 包括 :

  • noexcept;
  • mutable;
  • constexpr (since C++ 17);
  • 尾置回傳型別;
  • requires (since C++ 20),

那麼 Lambda 表達式就可以省略掉參數列表. 簡單地, 我們可以寫成 auto lambda {[]<typename T> {}};. 但是一旦後面帶有標識, 即使參數列表為空, 那麼也不能夠省略. C++ 2b (expected C++ 23) 提案 P1102R2《Down with ()!》提出解除這個限制. 那麼在 C++ 2b (expected C++ 23) 之後, 即時參數列表後面帶有標識, 只要參數列表為空的話, 就可以省略掉參數列表.