lambda 表达式是 C++ 中一个极具颠覆性的语言特性,它主要用于创建匿名函数,从而可以更便捷地在一些场合下,快速生成函数对象后传递给需要的位置。这一章中,会陈述几个和 lambda 表达式相关的问题。

首先,需要明确几个概念。lambda 表达式是一种语法,它是静态的,编译器处理 lambda 表达式时,会利用它生成一个闭包类,然后在利用 lambda 表达式定义对象时,利用这个闭包类生成一个闭包对象。闭包对象是动态行为。

虽然我们无法方便地知道 lambda 表达式生成地闭包对象是什么类型,但好在可以用 auto 类型来让编译器自己推导。也由于闭包对象和其他对象没有什么大的不同,也可以实现赋值和移动等操作。

条款 31:避免使用默认捕获模式

所谓默认捕获模式,就是直接使用 & 或 = 捕获当前作用域中所有可以捕获的对象(按引用或按值)。本条款建议,尽量将 lambda 表达式中需要使用到的外部对象,显式写在捕获列表中。虽然无法完全避免下文可能存在的问题,但默认捕获模式,可能会带来更危险的陷阱。

情况 1:通过引用捕获

一个显而易见的问题是,lambda 表达式生成的闭包对象,其生命周期可能会超出当前作用域,如果 lambda 表达式体中捕获了只在当前作用域中生存的对象,那么当被捕获对象离开作用域被析构时,lambda 表达式中的按引用捕获,实际就变成了空悬引用。

所以第一种最简单的情况就是,按引用捕获时,注意被捕获对象的生命周期,看例子:

1
2
3
4
5
6
7
8
// 定义一个全局的闭包对象容器
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;

void func() {
auto divisor = getDivisor();
filters.emplace_back([&](int value) { return value % divisor == 0; });
}

这个代码中,filters 会保存多个满足类型的闭包对象(闭包对象是一种可调用类型,所以可以使用 std::function<> 来定义类型)。函数 func 中,局部对象 divisor 被按默认捕获的方式捕获到 lambda 表达式中。

那么,当在其他位置访问 filters 中的闭包对象时,闭包中的 divisor 就已经是空悬引用了,它的值是否合法是不确定的。

一个可以改进的策略便是,不要使用默认捕获模式,将需要捕获的对象 divisor 显式写在捕获列表中:filters.emplace_back([&divisor](int value) { return value % divisor == 0; });。当然,在这个例子中,这么写依然避免不了问题,但至少更容易发现问题。当 lambda 表达式体中的代码比较复杂时,默认捕获模式会更不容易看出来 lambda 表达式依赖哪些外部对象。

情况 2:通过值捕获

当你预料到情况 1 的结论,打算在之后的代码中,谨慎使用引用捕获,转而使用按值捕获,可能依然会带来一些潜在的问题。 虽然按值捕获可以让值对象采用复制方式传入 lambda 表达式体,但如果按值捕获的是指针,那么指针指向的内容仍然会随时变化,这依然是和引用捕获一样的问题。

看一个书中很典型的例子:

1
2
3
4
5
6
7
8
9
class Widget {
public:
void addFilter() const;
private:
int divisor;
};
void Widget::addFilter() const {
filters.emplace_back( [=](int value) { return value % divisor == 0; });
}

这个例子中,在一个类内,定义一个类数据 divisor,然后在类的成员函数中实现保存闭包对象到容器。看似没有什么问题,每一次创建闭包对象时,都会拷贝一份 divisor 的值进去。然而,这是误解。

捕获行为只能捕获在创建 lambda 表达式所在作用域内的可见非静态局部对象,而对于例子中的这种捕获 divisor 的方式,编译器会将指向当前类对象的 this 指针,捕获到 lambda 表达式中,也就是说,等价于 [=this](int value) { return value % this.divisor == 0; }。 那么,当 Widget 的对象,调用完 addFilter 之后,早于 filters 的生命周期而提前被析构,那 this 指针就会变成悬空指针,之后使用 filters 中的闭包,就会遇到未定义问题。

除了 this 指针,其他位于当前作用域内的普通指针,一样会遇到相同的问题。一种改善的方案便是,手动复制一份需要捕获的对象(平凡类型)到当前作用域。保险起见,也建议手动把捕获列表补充上去:

1
2
3
4
void Widget::addFilter() const {
auto divisorCopy = divisor;
filters.emplace_back( [=divisorCopy](int value) { return value % divisorCopy == 0; });
}

在 C++17 中,提供了一种特性来帮助我们解决这个问题,使用 [*this] 这种捕获列表,可以允许我们将一个对象的副本捕获到 lambda 表达式作用域内,从而避免因原对象被析构后的悬挂引用问题。注意到因为这里创建了对象的副本给 lambda,所以可能会有较大的性能开销,具体选择哪个方案则因情况而定。

在 C++20 中,进一步改进了捕获 this 的特性,如果仍然使用 [=] 这种值捕获方式,将不会再包含 this 指针,如果想要捕获 this,需要手动写入捕获列表:[=, this],这避免了上述可能存在的问题。

感谢 @机械索尼克 的补充。

情况 3:捕获静态对象

lambda 表达式除了可以通过捕获来使用局部变量和形参,也可以直接使用静态对象(和普通函数一样),也就是定义在全局或名字作用域中,或者是类中和函数中以 static 修饰的对象。这种静态对象,不是通过捕获来访问的,但却会给人以错觉,认为是捕获访问。

1
2
3
4
5
void func() {
static auto divisor = getDivisor();
filters.emplace_back([=](int value) { return value % divisor == 0; });
++ divisor;
}

这个例子中,lambda 表达式使用默认值捕获,但实际上它什么也没有捕获,divisor 对象只是因为 static 属性而被直接访问的。这样,每次调用 func 函数时,divisor 都会发生变化,这可能会导致非预期的行为(我们应该是期待按值捕获 divisor,从而 lambda 中的值不会变化)。

谬误:lambda 表达式访问静态对象,并不是通过捕获方式访问。

虽然无法直接避免这种误解,但如果不使用默认值捕获方式,就更容易发现这个细节:filters.emplace_back([](int value) { return value % divisor == 0; });,没有捕获任何东西,但访问了静态对象 divisor

综上几种情况,做个总结,lambda 表达式的捕获行为可能会让程序出现一些非预期的错误,虽然从编译层面无法规避这种问题,但不要使用默认捕获方式(包括按值捕获和按引用捕获),就更容易发现问题。

条款 32:将对象通过移动方式传入闭包

之前提到的无论是按值捕获还是按引用捕获,都无法将一个只移对象(比如 std::unique_ptr)传入闭包,C++ 11 做不到。在 C++14 中,支持了通过一种特殊的捕获方式,将对象移动入闭包。 这种捕获方式叫做初始化捕获(init capture),它可以实现除了默认捕获之外的任何捕获行为,所以也被称为通用 lambda 捕获(generalized lambda capture)。

它的语法是:

1
2
3
4
5
auto pw = std::make_unique<Widget>();  // 准备一个只移对象

//... some other code

auto func = [pw = std::move(pw)] { ... };

语法很巧妙的区分了捕获列表中 = 左右的作用域,左边的作用域在 lambda 表达式体内部,右边的作用域位于定义 lambda 表达式所在的作用域,所以可以使用相同的名称(如例子中的 pw)。

如果想要在定义 lambda 表达式时初始化对象,也可以:

1
auto func = [pw = std::make_unique<Widget>()] { ... };

捕获列表中可以放置任何表达式,所以说它是一种通用捕获模式。例子中,表达式返回了一个只移右值,可以通过移动方式传入闭包。

以上是 C++14 中支持的语法,在 C++11 中,无法使用。如果 C++11 依然想要通过移动传入对象,可以用一些曲线救国的办法。

第一种办法是,手写一个可调用类型(仿函数),也就是带有 operator() 重载的类,我们知道,lambda 表达式经过编译器处理后,实际上也生成了这样一种类型。我们手动为其定义支持右值引用作为参数的构造函数,便可以在定义函数对象时,传入右值。之后使用函数对象的方法和 lambda 表达式完全一致。

第二种办法是利用 std::bind,虽然 C++11 不支持移动捕获 lambda,但支持绑定一个函数并通过移动方式传入参数。

1
2
3
4
5
6
std::vector<double> data;  // 准备一个对象,希望通过移动传入闭包

auto func = std::bind(
[](const std::vector<double> &data) { ... },
std::move(data);
);

这个代码中,将对象 data 转换为右值后,绑定到 lambda 表达式中的第一个形参,以此来实现移动一个对象到 lambda 表达式中的目的。这里没有利用 lambda 表达式的捕获,所以 C++11 是支持的。

最后,要提一下,无论是 C++14 的初始化捕获,还是 C++11 的 std::bind,他们中涉及到的表达式求值,都是在定义 lambda 或绑定对象时求值。但 C++11 中,绑定对象只有在被调用时(也就是 func() 调用时),存储在绑定对象中的实参(例子中的右值 std::move(data) 经移动构造生成在绑定对象中的副本 )才会传入 lambda 表达式中,所以事实上,lambda 表达式中操作的 data 实际上是绑定对象中的副本(注意到,lambda 表达式参数列表中的 data 类型是左值引用)。

条款 33:泛型 lambda 表达式

在 C++14 中,增加了一个非常实用的特性,即泛型 lambda 表达式,它允许我们编写 lambda 表达式时,对其形参类型自推导:

1
auto f = [](auto x) { return g(x); };

它类似于模板函数中的类型推导,方便我们编写泛型的匿名函数。

然而,这里要引出本条款要讨论的问题,如果我们需要将这个 lambda 表达式编写为完美转发形参,会遇到什么问题?回忆一下,使用模板函数编写完美转发函数:

1
2
3
4
template <typename T>
void ff(T&& param) {
gg(std::forward<T>(param));
}

我们需要利用 std::forward<T> 来对形参 param 做一次转换,它的作用是,如果类型 T 是左值引用,则转换为左值引用;如果类型 T 是右值引用,则转换为右值引用。

然后,我们需要利用 decltype 这个工具,手动推导形参的类型,好在 decltype 的返回类型,也是符合预期的,当传入形参是左值时,返回左值引用,传入形参是右值时,返回右值引用。 最后,我们编写的完美转发的泛型 lambda 表达式为:

1
auto f = [](auto&& x) { return g(std::forward<decltype(x)>(x)); };

考虑不定长参数列表的版本:

1
auto f = [](auto&&... x) { return g(std::forward<decltype(x)>(x)...); };

对于书中对 std::forward<T> 和 decltype 在传入右值类型时的正确性讨论,我认为想地太复杂了,所以这里略掉,感兴趣的朋友可以去翻原文。

条款 34:优先使用 lambda 表达式替代 std::bind

lambda 表达式和 std::bind 在大多数场景下的功能是重叠的,在 C++98 那个没有 lambda 表达式可以使用的年代,想要灵活地包装一个函数,并返回一个新的函数对象,使用 std::bind 是非常常见的做法。

然而,随着 Modern C++ 的发展,lambda 表达式的能力越来越强大,以至于可以完全取代 std::bind

理由 1:lambda 表达式的语法更清晰,更易读

考虑一个书中的例子。现在有一个可以发出报警的函数,支持几个配置,我们希望使用 lambda 表达式或 std::bind 来包装这个函数,让其中一部分配置使用默认值:

1
2
3
4
5
6
using Time = std::chrono::steady_clock::time_point;
using Duration = std::chrono::steady_clock::duration;
enum class Sound { Beep, Siren, Whistle }; // 声音类型

// 这是需要包装的函数,指定开始报警事件、声音类型和持续时间
void setAlarm(Time t, Sound s, Duration d);

如果我们想包装一个可调用对象,在调用发生时刻 1 小时后开始报警,持续 30 秒,但声音类型希望调用时指定。使用 lambda 表达式的实现方案非常简单:

1
2
3
4
5
6
7
8
9
10
11
auto setSoundL =  // 'L' 表示 lambda 方案
[](Sound s) {
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1), s, seconds(30)); };

// c++14 中可以利用字面值常量优化:
auto setSoundL =
[](Sound s) {
using namespace std::chrono;
using namespace std::literals;
setAlarm(steady_clock::now() + 1h, s, 30s); };

然而,使用 std::bind 的方案,不但不易读,反而还存在问题:

1
2
auto setSoundB =  // 'B' 表示 std::bind 方案
std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);

首先,我们在使用 setSoundB 时,需要搞清楚它的参数,对应原始函数 setAlarm 中的哪个参数。这里,我们可以通过函数名称来确定参数是 Sound,但很多时候,尤其是有多个占位符同时出现时,唯一的办法就是去查看 setAlarm 的声明。

而且,这里还存在一个问题,表达式延迟求值的问题。对于 lambda 的版本,steady_clock::now() 的求值(当前时间),是 setSoundL 被实际调用的时候;而 std::bind 的版本,steady_clock::now() 的求值,却是在 setSoundB 被定义的地方,这可能产生 Bug。 一种改进 setSoundB 的方法是:

1
2
3
4
auto setSoundB = 
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1, 30s);

虽然问题解决了,但这种写法就看起来更复杂了。

理由 2:如果被包装函数有重载版本

如果 setAlarm 有重载版本,比如有个带有 4 个形参的重载版本。那么 lambda 表达式依然可以正常找到正确的 3 参数重载版本,而 std::bind 则不行,会发生编译报错。

如果想继续改进 setSoundB,可以:

1
2
3
4
5
6
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);

auto setSoundB =
std::bind(static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1, 30s);

也就是手动对函数做向确定函数类型方向做类型转换。

另外,这里还会引出另一个细节。对于 lambda 表达式的方案,lambda 表达式体,可以由编译器做优化,比如对 setAlarm 做函数内联;但 std::bind 却基本无法做编译器优化。最后导致 lambda 表达式的版本会比 std::bind 的版本性能更好。

理由 3:std::bind 无法控制参数传入方式

对于 lambda 表达式的方案,我们想传入的 Sound 参数,可以通过按值捕获或按引用捕获的方式,在 C++14 中还可以选择按移动捕获。但对于 std::bind 则无法做到这样的自由。

std::bind 中的默认填好的绑定参数,只能按值传递,而其占位符参数,在绑定函数对象最终被调用时,只能按引用传递。如果想要写出正确的代码和高性能的代码,必须始终牢记这个设计原理。

技巧:总是试着使用 lambda 表达式去替代 std::bind 的代码。

最后,总结一下,在 Modern C++ 中,完全可以使用 lambda 表达式替代 std::bind,只有 C++11 中,有 2 个场景,还是需要 std::bind,在前边条款中提到过:

  1. C++11 中 lambda 表达式无法做到移动捕获,只能依赖 std::bind 实现;
  2. C++11 中 lambda 表达式无法做到泛型,需要编写带有调用运算符模板的函数对象,再使用 std::bind 来实现;

对于这两种情况,都是很少见的使用场景,而且 C++14 中的 lambda 表达式都已经得到妥善的支持。


本系列的其他文章:

  1. Effective Modern C++ 读书笔记:类型推导
  2. Effective Modern C++ 读书笔记:auto
  3. Effective Modern C++ 读书笔记:转向现代C++
  4. Effective Modern C++ 读书笔记:智能指针
  5. Effective Modern C++:右值引用、移动语义和完美转发
  6. Effective Modern C++:lambda 表达式
  7. Effective Modern C++:并发 API
  8. Effective Modern C++:微调

本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1952400191220094938