C++ 踩坑: Lambda 表达式

1,432 阅读8分钟

C++ 踩坑: lambda 表达式

废话不多说, lambda 表达式应该是 C++11 之后的一个非常重要且实用的特性.

lambda 基础

C++ 参考文档列出了几种 Lambda 的形式 Lambda expressions

语法

[captures] (params) specs requires(optional) {body}(1)
[captures] { body }(2)(until C++23)
[captures] specs {body}(2)(since C++23)
[captures] <tparams> requires(optional) (params) specs requires(optional) {body}(3)(since C++20)
[captures] <tparams> requires(optional) {body}(4)(since C++20) (until C++23)
[captures] <tparams> requires(optional) specs {body}(4)(since C++23)
  1. 完整形式
  2. 省略参数列表: 函数无形参, 等价于 ()
  3. 同 1), 泛型 lambda, 显式提供模板参数列表
  4. 同 3)

参数说明

  • captures: 捕获参数列表, 逗号分隔, 可以以默认捕获开头 (注: 空列表, &, =).
    • Lambda 表达式可以直接使用以下类型的变量, 而无须捕获:
      • 非局部变量, 或者具有 static, 或者 线程局部存储 thread local storage duration (变量无法被捕获) 的变量, 或者
      • 已经由常量表达式 (constant expression) 初始化的引用.
    • Lambda 表达式读取以下类型的变量, 而无需捕获:
      • 具有 const non-volatile 整形或者枚举值, 或者已经被常值表达式初始化的变量, 或者
      • constexpr 并且 非 mutable 成员的变量.
  • tparams: 非空的模板参数列表, 一般为提供泛型 lambda 的模板参数名.
  • params: 参数列表, 和普通函数的参数列表一样.
  • specs: 按 specifiers, expection, attr, trailing-return-type 的顺序组成, 每种标识都是可选的.
    • specifiers: 可选的修饰符 (specifiers) 序列. 如果没有提供, 被拷贝捕获(注: 按值捕获) 的变量将是 const 的. 以下修饰符在序列中最多允许出现一次.
      • mutable: 允许按值捕获的变量在函数体内被修改, 并且可以调用 non-const 的成员函数.
      • constexpr (since C++17): 显式指定函数调用符或者任一给定的模板特化的调用符是一个 constexpr 函数. 若无此修饰符, 当函数调用符或者给定的模板特化的调用符满足所有 constexpr 函数的前提时, 它们也将是 constexpr 的.
      • consteval (since C++20): 指定函数调用符或者给定的模板特化的调用符是 immediate function. constexprconsteval 不能同时使用.
    • expection: 为闭包类型的 operator() 提供动态异常说明 (dynamic exception specification) 或者无异常修饰符.
    • attr: 为函数调用符或者闭包类型的调用符模板提供属性指示符. 任何被指定的属性不能和函数调用符或者调用符模板本身相关. (例如, 不能使用 [[noreturn]] 属性).
    • trailing-return-type: -> ret, 其中 ret 指定返回类型. 如果没有 trailing-return-type, 那么闭包的 operator() 的返回类型则会从 return 语句推导, 类似于函数返回一个以 auto 声明的类型.
  • requires: (since C++20) 给闭包类型的 operator() 添加限定符.
  • body: 函数体.

MSDN 有一张图也表达的比较清晰

image.png

  1. captures,
  2. parameters,
  3. mutable,
  4. expection,
  5. trailing-return-type,
  6. lambda body

lambda 捕获

captures: 捕获参数列表, 逗号分隔, 可以以默认捕获开头. 被捕获的外部变量可以在 lambda 函数体内被访问. 默认捕获包括:

  • &: 隐式地通过引用捕获已被用到的自动变量
  • =: 隐式地通过拷贝捕获 (值捕获) 已被用到的自动变量

如果没有默认捕获, 那么当前对象(*this)将被隐式捕获. 如果被隐式捕获了, 它总是被引用捕获的, 即使默认捕获是 = (从 C++20 开始, 当默认捕获是 = 时, 隐式捕获 *this 被废弃).

captures 中的单个捕获语法:

  • identifier: 拷贝捕获.
  • identifier ...: 拷贝捕获包扩展(parameter pack).
  • identifier initializer: 带初始化器的拷贝捕获. (since C++14)
  • & identifier: 引用捕获.
  • & identifier...: 引用捕获包扩展
  • & identifier initializer: 带初始化器的引用捕获. (since C++14)
  • this: 引用捕获当前对象.
  • *this: 拷贝捕获当前对象. (since C++17)
  • ... identifier initilizer: 带初始化器的拷贝捕获包扩展. (since C++20)
  • & ... identifier initilizer: 带初始化器的引用捕获包扩展. (since C++20)

如果默认捕获是 &, 那么后续的捕获不能再以 & 捕获:

struct S2 { void f(int i); };
void S2::f(int i)
{
    [&]{};          // OK: by-reference capture default
    [&, i]{};       // OK: by-reference capture, except i is captured by copy
    [&, &i] {};     // Error: by-reference capture when by-reference is the default
    [&, this] {};   // OK, equivalent to [&]
    [&, this, i]{}; // OK, equivalent to [&, i]
}

如果默认捕获是 =, 那么后续的捕获必须以 & 捕获, 或者 *this (since C++17), 或者 this (since C++20):

struct S2 { void f(int i); };
void S2::f(int i)
{
    [=]{};        // OK: by-copy capture default
    [=, &i]{};    // OK: by-copy capture, except i is captured by reference
    [=, *this]{}; // until C++17: Error: invalid syntax
                  // since C++17: OK: captures the enclosing S2 by copy
    [=, this] {}; // until C++20: Error: this when = is the default
                  // since C++20: OK, same as [=]
}

任一捕获只可能出现一次, 且名称必须和其他参数名不同:

struct S2 { void f(int i); };
void S2::f(int i)
{
    [i, i] {};        // Error: i repeated
    [this, *this] {}; // Error: "this" repeated (C++17) 
    [i] (int i) {};   // Error: parameter and capture have the same name
}

示例

下面两个示例均在 C++20 标准下编译.

捕获局部变量

运行代码

class S {
public:
    void f() {
        int x = 0;
        // OK, copy capture   
        auto l1 = [x=x] { std::cout << "x: " << x << std::endl; };       
        x = 1;
        l1();       // x: 0

        // OK, copy capture
        auto l2 = [x] { std::cout << "x: " << x << std::endl; };   
        x = 2;
        l2();       // x: 1

        // OK, implicitly capture by value
        auto l3 = [=] { std::cout << "x: " << x << std::endl; }; 
        x = 3;
        l3();       // x: 2

        // OK, reference capture
        auto l4 = [&x=x] { std::cout << "x: " << x << std::endl; }; 
        x = 4;
        l4();       // x: 4

        // OK, reference capture
        auto l5 = [&x] { std::cout << "x: " << x << std::endl; };   
        x = 5;
        l5();       // x: 5

        // OK, implicitly capture by reference
        auto l6 = [&] { std::cout << "x: " << x << std::endl; }; 
        x = 6;
        l6();       // x: 6
    }
};
  1. 拷贝捕获 x. (= 左侧为显式声明的别名, 可以为其他名).
  2. 拷贝捕获 x.
  3. 拷贝捕获, 隐式.
  4. 引用捕获 x. (= 左侧为显式声明的别名, 可以为其他名).
  5. 引用捕获 x.
  6. 引用捕获, 隐式.

捕获成员变量

运行代码

class S {
public:
    int x = 0;
 
    void f() {
        // OK, copy capture   
        auto l1 = [x=x] { std::cout << "x: " << x << std::endl; };       
        x = 1;
        l1();       // x: 0

        // OK, reference capture
        auto l2 = [&x=x] { std::cout << "x: " << x << std::endl; }; 
        x = 2;
        l2();       // x: 2

        // OK, copy capture this pointer
        auto l3 = [this] { std::cout << "x: " << x << std::endl; };
        x = 3;
        l3();       // x: 3

        // OK, copy capture this pointer, implicit capture of '**this**' via ' **[=]** ' is deprecated in C++20
        auto l4 = [=] { std::cout << "x: " << x << std::endl; }; 
        x = 4;
        l4();       // x: 4

        // OK, capture this by reference
        auto l5 = [&] { std::cout << "x: " << x << std::endl; }; 
        x = 5;
        l5();       // x: 5

        // OK, capture this by value
        auto l6 = [*this] { std::cout << "x: " << x << std::endl; }; 
        x = 6;
        l6();       // x: 5

        // error: capture of non-variable 'S::x'
        // auto l = [x] { std::cout << "x: " << x << std::endl; };   
    }
};
  1. 拷贝捕获 x, = 左侧为显式声明的别名, 可以为其他名.
  2. 引用捕获 x, 同上.
  3. 拷贝捕获 this 指针, 相当于隐式引用捕获 x.
  4. 同上. C++20 已经废弃.
  5. 同上.
  6. 拷贝捕获 *this, 包括拷贝其中的成员.
  7. 成员变量无法通过显式捕获列表来捕获, 除非有通过初始化器 (默认/显式初始化器, 参考 1 ~ 6).

lambdatemplate

C++14开始, 我们可以通过 auto 接受模板参数, 来定义泛型 lambda. 比如:

    auto lambda = [](auto i) { std::cout << typeid(i).name() << std::endl; };
    lambda(1.1);
    lambda(1);
    lambda("abc");
    lambda(std::vector<int>{1, 2});

等效于

template<typename T>
void generic_function(T x) { std::cout << typeid(x).name() << std::endl; }

但是 auto 的声明方式限制了我们在 lambda 表达式内部想使用实际类型的需求. 比如, 我们仅需要处理传入的 vector 类型, 就很难进行限制. 如果调用出错, 错误信息也不太能定位到原始的问题. 比如,

    auto lambda = [](auto i) { 
        std::cout << typeid(i).name() << std::endl;
        std::cout << i.size() << std::endl; 
    };

    lambda(std::vector<int>{1, 2});

    // error: request for member 'size' in 'i', which is of non-class type 'const char*'
    lambda("abc");

参考模板, C++20 使得我们可以定义模板 lambda 表达式. 如果类型不匹配, 编译器也能清晰地输出错误信息. 比如,

    auto lambda = []<typename T>(std::vector<T> i) { 
        std::cout << typeid(i).name() << std::endl;
        std::cout << i.size() << std::endl; 
    };

    lambda(std::vector<int>{1, 2});

    // error: no match for call to '(main()::<lambda(std::vector<T>)>) (const char [4])'
    lambda("abc");

这种 lambda 等价于

template<typename T>
void generic_function(std::vector<T> x) { std::cout << typeid(x).name() << std::endl; }

lambdafunctor

functor 译名为仿函数,但仿函数不是函数,而是定义了 operator() 方法的函数对象。而 lambda 则是匿名函数。Functor 可以维护多个内部状态变量。两者有很多类似之处,lambda 通常可以转换为 functor 表示。

无捕获

// functor
struct functor
{
    auto operator()(auto x) { return x * x; }
};

// lambda
auto lambda = [](auto x) { return x * x; };

运行代码, 可以看到 functorlambda 编译后的结果是一模一样的。

auto functor::operator()<double>(double):
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        movsd   QWORD PTR [rbp-16], xmm0
        movsd   xmm0, QWORD PTR [rbp-16]
        mulsd   xmm0, xmm0
        movq    rax, xmm0
        movq    xmm0, rax
        pop     rbp
        ret
auto main::{lambda(auto:1)#1}::operator()<double>(double) const:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        movsd   QWORD PTR [rbp-16], xmm0
        movsd   xmm0, QWORD PTR [rbp-16]
        mulsd   xmm0, xmm0
        movq    rax, xmm0
        movq    xmm0, rax
        pop     rbp
        ret

拷贝捕获

注意,lambda 拷贝捕获的值为 const 类型,无法在函数体内部被修改。若想修改值,比如进行累加,则需要关键字 mutable 修饰 (见后续)。

// functor const
template <typename T>
struct functor
{
    const T i_;
    functor(T i) : i_(i) { }
    auto operator()(auto x) { return i_ + x; }
};

// lambda const
auto lambda = [i](auto x) { return i + x; };
// functor non-const
template <typename T>
struct functor
{
    const T i_;
    functor(T i) : i_(i) { }
    auto operator()(auto x) { return i_ + x; }
};

// lambda mutable
auto lambda = [i](auto x) mutable { return i + x; };

引用捕获

// functor
template <typename T>
struct functor
{
    T &i_;
    functor(T &i) : i_(i) { }
    auto operator()(auto x) { return i_ + x; }
};

// lambda
auto lambda = [&i](auto x) { return i + x; };

lambdamutable

一般来说, lambda 表达式拷贝捕获的变量是 const 的. 如下, lambda_const 将产生编译错误, 因为拷贝捕获的 n 是一个只读变量. 因此需要通过修饰符 mutable 来修饰. 这样, 在 lambda 函数体中允许修改 n. 但由于是拷贝捕获, 因此在退出 lambda 后, n 依然是 0, 而由于 m 是引用捕获, 因此其值被修改为 3.

int main()
{
    int m = 0;
    int n = 0;

    // error: increment of read-only variable 'n'
    // auto lambda_const = [&, n] (int a) { m = ++n + a; };
    // lambda_const();

    auto lambda_mutable  = [&, n] (int a) mutable { m = ++n + a; };
    lambda_mutable(2); 
    std::cout << m << ", " << n << std::endl;   // 3, 0
}

lambdaconstexpr

从 C++17 开始, lambda 表达式可以被声明为 constexpr (这里, 我们可以通过 static_assert 来验证是否为编译期的常量表达式).

constexpr int increment(int n) { return [n]{ return n + 1; }(); }
static_assert(3 == increment(2));

int main()
{
    constexpr int y = 2;
    auto answer = [y](auto x) constexpr { return y + x; };
    static_assert(5 == answer(3));
}

对于满足 constexpr specifier (since C++11) 的函数, 都将隐式声明为 constexpr. 在这个例子中 lambda 表达式的 constexpr 不是必须的.

/*constexpr*/ auto lambda = [](auto x) { return x + x; };
constexpr auto si = lambda(1);
static_assert(2 == si);

lambdathis, *this

访问数据成员

在如何捕获成员变量的例子中, 曾提到过通过捕获 this 或者 *this 来隐式访问到成员变量. 其中一种方式是通过捕获 this 指针来访问成员. 然而, 捕获 this (实际上是一个指针), 会造成潜在的隐患.

另一种方式是通过隐式捕获 this 对象. 不过, 隐式捕获这种方式在 C++20 中被废弃, 编译器将给出警告 **warning:** implicit capture of '**this**' via ' **[=]** ' is deprecated in C++20 [**-Wdeprecated**]. 假如实在想捕获在 this, 那么可以使用 [=, this] 方式.

struct Bar
{
    int i_ = 2;
    
    // C++11/C++14/C++17/C++20
    void CaptureThisPointerExplicitly() {
        auto lambda = [this]() { std::cout << i_ << std::endl; };
        lambda();
    }
    
    // C++20: **warning:** implicit capture of '**this**' via ' **[=]** ' is deprecated in C++20 [**-Wdeprecated**]
    // C++11/C++14/C++17
    void CaptureThisPointerImplicitlyUntilCpp20() {
        auto lambda = [=]() { std::cout << i_ << std::endl; };
        lambda();
    }
    
    // C++11/C++14/C++17/C++20
    void CaptureThisPointerExplicitly2() {
        auto lambda = [=, this]() { std::cout << i_ << std::endl; };
        lambda();
    }
};

那么该如何通过拷贝捕获 *this 对象呢? 在 C++17/C++20 中, 我们可以直接捕获 *this.

struct Bar
{
    int i_ = 2;
    
    // C++14: **warning:** ' ***this**' capture only available with '**-std=c++17**' or '**-std=gnu++17**' [**-Wc++17-extensions**]
    // C++17/C++20
    void CaptureThisValueExplicitlySinceCpp17() {
        auto lambda = [*this]() { std::cout << i_ << std::endl; };
        lambda();
    }
};

上述方式, 在 C++14 中, 编译器将给出警告 warning: '*this' capture only available with '-std=c++17' or '-std=gnu++17'. 除了隐式地拷贝捕获方法外, 我们还可以通过显式地初始化来捕获当前 *this 对象.

struct Bar
{
    int i_ = 2;
    
    // C++14/C++17/C++20
    void CaptureThisValueExplicitlySinceCpp14() {
        auto lambda = [self = *this]() { std::cout << self.i_ << std::endl; };
        lambda();
    }
};

访问方法成员

拷贝捕获对象指针, 对于指针指向的值并没有常量的限制, 因此, 允许修改成员数据.

struct Bar
{
    int i_ = 2;
    
    // C++11/C++14/C++17/C++20
    void CaptureThisPointerExplicitly() {
        auto lambda = [this]() { Print(); };
        lambda();
    }
    
    // C++11/C++14/C++17/C++20
    void CaptureThisPointerExplicitly2() {
        auto lambda = [=, this]() { Print(); };
        lambda();
    }

    // C++11/C++14/C++17
    void CaptureThisPointerImplicitlyUnitilCpp17() {
        auto lambda = [=]() { Print(); };
        lambda();
    }

    void Print() { std::cout << ++i_ << std::endl; }
};

int main()
{
    Bar bar;
    bar.CaptureThisPointerExplicitly();     // 3
    bar.CaptureThisPointerExplicitly2();    // 4
    bar.CaptureThisPointerImplicitly();     // 5
}

拷贝捕获指针对象, 由于拷贝捕获的类型默认是 const, 因此无法修改数据成员, 且 lambda 表达式访问的方法也必须是 const 方法.

struct Bar
{
    int i_ = 2;
    
    // C++17/C++20
    void CaptureThisValueExplicitlySinceCpp17() {
        auto lambda = [*this]() { Print(); };
        lambda();
    }

    // C++14/C++17/C++20
    void CaptureThisValueExplicitlySinceCpp14() {
        auto lambda = [self = *this]() { self.Print(); };
        lambda();
    }

    void Print() const { std::cout << i_ << std::endl; }
};

int main()
{
    Bar bar;
    bar.CaptureThisValueExplicitlySinceCpp17();     // 2
    bar.CaptureThisValueExplicitlySinceCpp14();     // 2
}

lambda 递归

既然 lambda 是一类函数, 那么能否通过 lambda 来实现递归呢? 答案是肯定的. 以递归计算阶乘为例. 普通函数版本, 我们能很快写出,

int factorial_function(int n) {
    return n > 1 ? n * factorial_function(n - 1) : 1;
}

那是否 lambda 表达式是否也可以呢?

int main()
{
    auto factorial = [](int n) {
        return n > 1 ? n * factorial(n - 1) : 1;
    };
    
    factorial(5);
}

很不幸, 上述例子无法通过编译, 即使在 C++20 标准下. 因为编译时需要明确 factorial 类型, 但 lambda 的表达式又依赖于 factorial, 导致类型无法推导出来, 产生编译错误.

error: use of 'factorial' before deduction of 'auto'

那么, 如何正确使用 lambda 来实现递归呢? 这里提供两种方式.

使用 std::function

通过将 lambda 定义为一个 std::function 对象, 然后对其进行捕获, 那么

int main()
{
    std::function<int(int)> factorial = [&factorial](int n) {
        return n > 1 ? n * factorial(n - 1) : 1;
    };
    assert(120 == factorial(5));
}

lambda 作为自身入参

这种方法 (从 C++14 开始) 将使我们无需依赖于 std::function. 需要注意的是, 必须声明尾置返回类型, 否则将无法通过编译.

int main() {
    // Since C++14
    auto factorial = [](int n, auto& impl) -> int {
        return n > 1 ? n * impl(n - 1, impl) : 1;
    };

    assert(120 == factorial(5, factorial));
}

lambda 作为默认参数

如果 lambda 表达式作为默认参数, 那么它将不能显式或隐式地捕获任何变量, 除非所有捕获均具有满足初始化器, 满足作为表达式出现在默认参数中的限制 (since C++14).

void f2()
{
    int i = 1;
 
    void g1(int = ([i]{ return i; })()); // error: captures something
    void g2(int = ([i]{ return 0; })()); // error: captures something
    void g3(int = ([=]{ return i; })()); // error: captures something
 
    void g4(int = ([=]{ return 0; })());       // OK: capture-less
    void g5(int = ([]{ return sizeof i; })()); // OK: capture-less
 
    // C++14
    void g6(int = ([x = 1] { return x; }))(); // OK: 1 can appear in a default argument
    void g7(int = ([x = i] { return x; }))(); // error: i cannot appear in a default argument
}
  1. g6: 可以理解为, 1 可以作为默认参数, 相当于 void g6(int = 1), 所以 g6 正确. 甚至这样可以: void g6(int = ([x = 1, y = 2] { return x + y; })());.
  2. g7: 而 i 无法作为默认参数出现, void g7(int = i) 编译错误.

参考资料