Lambda表达式介绍
Lambda 本质
在 lambda 之前,你肯定接触过仿函数(Functor / Function Object,函数对象)这个概念。比如:
1 | |
对于一个类或者结构体,我们可以重载它的函数调用运算符
operator(),让对象拥有类似函数的行为:
1 | |
这里的 add(5) 实际上就是
add.operator()(5)。
而 lambda
基本是同样的原理,区别在于这个类由编译器自动生成:它是一个匿名的闭包类(closure
class),每一个 lambda
表达式都会生成一个独一无二的类型。因此我们通常使用 auto
推导它的类型,而无法(也没有必要)显式写出类型名。
一个 lambda 表达式由三部分组成:捕获列表、参数列表以及函数体:
1 | |
除此之外,lambda 还支持
mutable、noexcept、尾置返回类型等可选部分,完整的语法形式如下:
1 | |
其中 mutable 与返回类型的推导规则会在后文详细介绍。
Capture 捕获
如果一个 lambda 表达式没有访问任何外部变量,我们称它是无状态的(stateless);反之,如果它捕获了外部作用域中的变量,就是有状态的(stateful)。
闭包类虽然是在 lambda 所在的作用域中生成的,但它的成员函数
operator()
是一个独立的作用域,无法直接访问外围函数的局部变量。因此想要在 lambda
内部使用外部变量,就必须先在捕获列表中把它”搬”进闭包类。
lambda 表达式的捕获方式主要有值捕获、引用捕获和初始化捕获,此外还有
[=] 和 [&] 两种默认捕获模式。
值捕获
值捕获会在闭包类内部拷贝一份被捕获的变量,语法是在捕获列表中直接写出变量名:
1 | |
值捕获能够且只能捕获在 lambda 声明所在作用域内可见的非静态局部变量(包括函数形参),并在闭包类内部拷贝一份数据。
由于闭包类的 operator() 默认带有 const
限定,这份副本在 lambda 内部默认是只读的,想要修改就必须加上
mutable(详见后文)。
值捕获在直觉上不会因为捕获对象的生命周期引发问题,但是事实并非如此。比如当你尝试使用值捕获方式获取成员变量时:
1 | |
这里的 [=] 是默认值捕获模式,表示”按值捕获 lambda
中用到的所有外部变量”。但是成员变量 divisor
并不是局部变量,无法直接复制一份,于是编译器会将代码隐式转换成类似这样:
1 | |
也就是说,[=] 在这里复制的其实是 this
指针,而不是 divisor 的值,lambda
通过这份指针间接访问成员变量。一旦 Widget 对象先于 lambda
被销毁,this 就会变成悬空指针,再次调用该 lambda
便会崩溃。
另外需要注意的是,[=] 隐式捕获 this
的做法在 C++20 中已经被弃用,编译器会给出警告,需要显式写成
[=, this]。
如果想按值捕获成员变量,可以使用 C++14 的初始化捕获或者 C++17 的捕获
*this 副本,当然也可以在局部作用域复制一份成员变量,再由
lambda 捕获这个副本:
1 | |
有时,使用值捕获并不意味着 lambda 内部一定持有变量的副本,比如遇到静态变量(static)时:
1 | |
尽管使用了 [=],lambda 并没有复制
divisor,因为它既不是局部变量也不是 this
指针。lambda 会直接引用静态变量 divisor,因此外部对
divisor 的修改会直接影响 lambda
的执行结果:上面这个例子中,后续调用该 lambda 时比较的已经是
10 而不是 5。
同理,全局变量也不会被捕获,lambda 会直接引用它们。这说明值捕获的”值”只对非静态局部变量成立,静态变量和全局变量始终是与外部共享的。
引用捕获
引用捕获会在闭包类内部持有一份被捕获变量的引用,语法是在变量名前加上
&:
1 | |
引用捕获能够且只能捕获在 lambda 声明所在作用域内可见的非静态局部变量(包括函数形参),并在闭包类内部持有一份引用。
由于持有的是引用而非副本,闭包读取到的值与外部变量始终同步,也正因如此,在
lambda 内部可以直接修改外部变量,不需要 mutable。
但是如果 lambda 的生命周期长于被捕获的变量(比如把 lambda 存入容器、作为异步回调或函数返回值返回),当变量出了作用域被销毁后,闭包内部的引用就会变成悬空引用,引发未定义行为:
1 | |
在 C++11 中,引用捕获还有一个用途:捕获无法拷贝的对象,比如
std::ostream、std::unique_ptr
等。不过这同样无法保证生命周期安全,C++14
之后应当优先使用初始化捕获。
初始化捕获
C++11 中,lambda
只支持按值捕获或按引用捕获,这引发了一个问题:无法将”仅支持移动、不支持拷贝”的对象(如
std::unique_ptr、std::future、std::thread
等)按值捕获进 lambda 中。
于是 C++14 引入了初始化捕获(init capture),其语法格式为
[闭包内的新变量名 = 外部表达式]。等号左边是闭包类中新引入的成员变量名,右边则是在
lambda 所在作用域中求值的初始化表达式。
使用初始化捕获可以完美支持移动语义:
1 | |
通过初始化捕获和 std::move,我们实现了 lambda
对独占资源的使用。
初始化捕获不只可以捕获外部变量,也可以直接在捕获列表中构造/初始化变量:
1 | |
如果是在 C++11 中想实现初始化捕获的类似功能,可以使用
std::bind。比如把一个
std::vector<double> 移入闭包:
1 | |
1 | |
对于只能移动的对象也是类似的思路:
1 | |
1 | |
这两种写法看似等价,但 C++11 的模拟版本存在两个明显缺陷:
std::bind会把绑定的实参以左值的形式传递给目标可调用对象,因此 lambda 无法按值接收std::unique_ptr,只能退而使用const引用,也就没有真正把对象”移进闭包”。lambda 对象被存放在
bind对象内部,调用时需要穿透std::bind的模板层级,编译器很难将其内联。
因此,C++14 之后没有必要再使用这种写法。
尽量使用显式捕获
前面几个例子中出现的 [=] 和 [&]
被称为默认捕获模式:它们不列出具体的变量名,而是让编译器自行判断 lambda
中到底用到了哪些外部变量,再统一按值或按引用捕获。
默认捕获模式写起来省事,但会把”捕获了谁”和”以何种方式捕获”这两个关键信息隐藏起来,从而带来一些问题:
[&]无法保证被引用对象的生命周期,一旦 lambda 的生命周期更长,就会产生悬空引用;并且当 lambda 的函数体被修改、引入了新的外部变量时,这个变量会被悄悄捕获,很容易埋下隐患。[=]看似安全,但遇到成员变量时会隐式捕获this指针,同样存在悬空风险,并且这一行为在 C++20 中已被弃用。静态变量和全局变量不会被任何方式捕获,即使写出
[=]也会直接引用外部对象,容易让人误以为操作的是副本。
因此更好的做法是显式写出每一个要捕获的变量,并标明捕获方式,例如
[divisor, &count]。这样不仅能避免上述陷阱,也让读者一眼就能看出哪些外部变量会被修改。
mutable 与捕获变量的修改
闭包类的 operator() 默认带有 const
限定,因此通过值捕获得到的成员变量在 lambda 内部是只读的:
1 | |
想要修改这份副本,需要在参数列表之后加上 mutable:
1 | |
需要注意的是,mutable
修改的只是闭包内部的那份副本,外部变量 count
不受影响;并且这份副本的生命周期与闭包对象相同,因此多次调用
f() 会让状态持续累加。
对于引用捕获的变量则不需要
mutable:operator() 的 const
限定的是闭包对象自身,而引用成员无论是否为
const,都可以用来修改被引用的对象:
1 | |
另外,mutable 会把 operator() 变成非
const 的版本,这意味着 const
的闭包对象将无法被调用,因此除非确有需要,否则不要无谓地添加
mutable。
返回类型的推导
lambda
的返回类型通常由编译器自动推导,但推导规则比较严格:函数体中所有
return
语句的表达式类型必须完全一致,否则编译器无法确定返回类型,只能报错:
1 | |
此时需要使用尾置返回类型显式指定:
1 | |
另外,当函数体只有一条 return 语句时,lambda
的推导规则与 auto 相同:会忽略顶层 const
和引用。因此如果需要返回引用,同样要显式写出返回类型:
1 | |
泛型 Lambda
C++14 允许在 lambda 的形参列表中使用 auto
关键字,从而创建泛型 Lambda(generic lambda):
1 | |
编译器会将这个 lambda 转化为一个带有模板化 operator()
的闭包类:
1 | |
也正因如此,auto 形参会像模板参数一样发生类型推导,泛型
lambda 可以接收任意支持 normalize 的类型。在 C++20
之后,还可以写成显式的模板参数列表
[]<typename T>(T x) { ... }。
使用 decltype 转发
auto&& 形参
我们可以使用万能引用类型作为 lambda 参数,从而在 lambda 函数内部实现完美转发。
在普通模板函数中,我们使用模板参数 T 进行完美转发:
1 | |
但是在 C++14 的泛型 Lambda 中,形参写为
auto&& x,没有显式的模板参数名
T,此时我们可以使用 decltype(x):
1 | |
这里的 decltype(x) 得到的是形参 x
的声明类型(包含引用),也就是 auto 推导后与
&& 折叠出的类型,正好可以作为
std::forward 的模板实参。
如果传入的是左值(Lvalue,如 int a):
x的推导类型是左值引用int&。auto&&成为int& &&,发生引用折叠得到int&。decltype(x)的结果就是int&。std::forward<int&>(x)会将x作为左值转发给target。
如果传入的是右值(Rvalue,如 10 或
std::move(a)):
x的推导类型是右值引用int&&。auto&&成为int&& &&,发生引用折叠得到int&&。decltype(x)的结果就是int&&。- 根据
std::forward的实现机制,传递右值引用类型int&&能够正确将x作为右值转发给target。
需要注意的是,decltype
多加一层括号后含义会发生变化:decltype(x) 得到的是变量
x 的声明类型,而 decltype((x)) 得到的是表达式
(x)
的类型。由于带括号的具名变量是左值表达式,结果恒为左值引用
int&,写错会导致所有实参都被当作左值转发,彻底丢失右值语义:
1 | |
std::bind 与 Lambda
std::bind 是 C++11
引入的一个”函数绑定器”,它的核心作用是:把一个现有的函数(或函数对象、成员函数)拿过来,“预先绑定”一部分参数,或者改变参数的顺序,从而生成一个新的可调用对象(Callable
Object)。
它的基本调用格式如下:
1 | |
- 第一个参数:你要绑定的目标函数(或函数指针、成员函数指针、Lambda 等)。
- 后续参数:你要传给目标函数的参数。这些参数可以是具体的值,也可以是占位符(Placeholders)。
std::bind 确实很实用,但是与 lambda
相比,存在一些缺点。
1. 可读性差
假设我们要设置一个定时器:
使用 lambda:
1 | |
使用 std::bind:
1 | |
在 std::bind 中,求值时机非常反直觉:如果直接传
steady_clock::now() + 1h,时间会在 bind
被调用的那刻计算,而不是在警报触发时计算。为了实现延迟响应,不得不嵌套另一个
std::bind,代码极其臃肿且难以阅读。
2. 处理重载与参数传递更容易出错
函数重载问题:
当 lambda 内部调用的函数被重载时,lambda 表达式无需修改便可以自动适应并匹配到正确的重载。
但是 std::bind
会编译报错,因为编译器无法确定要取哪个函数指针,你必须手动做强制类型转换:
1 | |
参数按值/按引用:
在 std::bind
中,传递给绑定的实参默认都是按值拷贝的(除非显式包装
std::ref),而占位符(如
_1)则会把调用时的实参原样转发给目标函数。这种隐式行为极易引入
bug。
而在 Lambda 中,捕获列表和形参的传递方式全部写在函数签名里,尤其是像
[&count, divisor]
这样的显式捕获,哪些变量会被修改一目了然。
3. 运行效率差异
Lambda 的内联:Lambda 表达式生成的是一个独一无二的闭包类,其
operator()定义在类内部,因此默认是内联的。编译器可以非常轻松地将 Lambda 内部的调用内联展开,做到零开销抽象。std::bind的开销:std::bind返回的是一个通用函数对象,内部通过函数指针调用。绝大多数编译器很难穿透std::bind内部繁琐的模板层级去实现函数内联,因此std::bind往往伴随着额外的函数调用开销。
在 C++14 之后,初始化捕获和泛型 lambda 彻底抹平了
std::bind 的优势,因此优先使用 lambda 而不是
std::bind 总是更明智的做法。
std::bind 唯一还值得一提的场景是在 C++11
中转发数量不定的实参(C++11 的 lambda 形参列表是固定的),但这一场景在
C++14 的泛型 lambda 出现之后也已经不复存在了。
这次的插图来自画师 Rune Xiao
图片地址:https://www.pixiv.net/artworks/123634161
文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item 31-Item 34