Effective Modern C++读书笔记(四)
lambda 表达式
首先来明确一下三个概念:
lambda 表达式只是一个表达式。它是源码的一部分。
closure 是由lambda创造的运行期对象。根据捕获的模式,closure 持有被捕获对象的副本或者引用。
closure class 是给 closure 实例化的。每个 lambda 会让编译器生成一个独一无二的 closure class 。lambda 内的声明将变成它 closure class 内的成员函数的可执行指令。
Item 31
在C++11里有两种默认的捕获模式: by-reference 和 by-value 。默认的 by-reference 捕获可以导致空悬引用。比如说这个 closure (或者它的副本)比捕获的引用的对象的声明周期长。 (这也应该是处理引用本就应该小心的地方)。应该不使用默认捕获,捕获的时候添上引用的对象。这样可以让程序员注意到引用的对象,从而对它的生命周期有了考量。或者直接就通过传值来捕获。但如果传值的对象是 raw pointer ,那又得考虑是否会出现空悬指针的问题。
有种情况 by-value 捕获会造成意想不到的悬垂指针。看个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14std::vector<std::function<bool(int)>> filters;
class Widget
{
...
int divisor;
}
void Widget::addFilter() const
{
//
filters.emplace_back(
[=](int value){ return value % divisor} == 0; }
);
}这里看似 by-value 捕获了divisor,但是却并非如此。首先,divisor是类内 non-static 数据成员 ,是不能被捕获的。但是这段代码却可以编译过。原因就在与 this 指针。类的 non-static 函数体中隐含了一个 this 指针,它是个纯右值(prvalue)表达式,表示调用这个函数的对象的地址。所以其实捕获的是 this 指针,而lambda表达式中的
divisor
其实也是this->divisor
。所以既然是 by-value 捕获的 this 指针,那么就有可能出现空悬指针。为了避免这种情况,还是老实地在
addFilters
函数体内用一个 local 变量作为 divisor 的副本,然后 by-value 捕获这个副本。还有就是要注意,静态存储周期的对象都是可以直接在lambda 表达式中使用的,而且不能捕获 。所以在lambda表达式中用默认 by-value 捕获,就会让人产生错觉。但实际上什么都没捕获。
Item 32
C++14 提供了 init capture 这种新的捕获方式。具体来说就是在捕获列表中可以通过
=
的方式,等号左边是从 lambda 表达式生成的闭包类的数据成员,右边是表达式,用于初始化左边。可以明显看出,=
左边和右边的作用域也不同。所以,通过这种语法,让“移动”捕获成为了可能,而非 C++11 那样只能传值和传引用捕获。不过在C++11中也是可以模拟出“移动”捕获的。
lambda 表达式可以看成定义一个函数类并构造它的对象。所以没有什么是直接定义一个类不能解决的。类中自然可以通过移动的方式初始化数据成员。
通过
std::bind
来模拟。可以看个例子:1
2
3
4
5
6
7
8
9
10std::vector<double> data;
// In C++14
auto func = [data = std::move(data)](){ /*uses of data*/ };
// 以下是等价的版本
// In C++11
auto func = std::bind(
[](const std::vector<double>& data)
{ /*uses of data*/ },
std::move(data)
);std::bind
构造的对象持有所有参数的副本。当参数是左值,就拷贝构造;当参数是右值,就移动构造。所以参数data
通过移动构造传进了 bind 对象内(姑且称之为d),然后每次调用func
的operator()
的时候,都会调用那个 lambda 表达式的operator()
。而参数就是 bind 对象内的data对象的副本。由于 lambda 没有加multable
,闭包类内的operator()
是const
限定的的。而 bind 对象的data的副本却不是const的。为了防止它被修改,所以参数加了const
。但其实这样也可以:1
2
3
4
5
6auto func =
std::bind(
[](std::vector<double>& data) mutable
{ /* uses of data */ },
std::move(data)
);
Item 33
C++14 引入了 generic lambdas 的特性。具体来说就是 lambda 表达式的参数列表的类型是 auto
。这个特性的实现也很直观:生成的闭包类的 operator()
函数是个模板。所以,随着来的 universal reference 和完美转发都可以实现了。完美转发的关键还是在对引用折叠和 std::forward
函数实现的理解。
1 | auto f = [](auto&& param) |
Item 34
用 lambda 表达式与 std::bind
相比有些优势,如下:
lambda 表达式比较清晰。(这里书中给的代码有误,最后再详细讲)
lambda 表达式内可以调用重载的函数,但是
std::bind
第一个参数是重载函数的话得明确调用的版本(通过static_cast
)。而且由于使用的指针,还会减少它被内联优化的可能性。std::bind
默认是将参数拷贝的。想要保存参数的引用,必须要用std::ref
。比如:1
auto compressRateB = std::bind(compress, std::ref(w), _1); // compressRateB acts as if it holds a reference to w, rather than a copy
这点就很不如 lambda 表达式直观。
在 C++14 中,lambda 表达式已经十分优秀,再也没有使用 std::bind
的理由了。但是在 C++11 中,std::bind
在以下情况下还是很有用的:
- Move capture 。C++11 lambda 表达式没有提供通过移动捕获的方式,但是能通过
std::bind
模拟出来。但 C++14 开始, lambda 表达式通过 init capture 已经能够实现这个。 - Polymorphic function objects 。
std::bind
绑定一个模板函数的时候,它能完美转发任何参数。但是 C++14 开始,lambda 表达式可以通过auto
实现泛型。
ps:这章里的代码竟然还有错误的地方,琢磨半天= =。 最后在 StackOverflow 上查到了正确的写法。
先还原一下问题的引入:
1 | using namespace std::chrono; |
可以看到 setSoundL
和 setSoundB
看似完成相同的功能,但是实际上却并非如此。因为 在调用 setSoundL
时会调用setAlarm
函数。显然,此时才会now
函数。而对于 setSoundB
来说,它在生成的时候就已经被调用了 now()
函数。然后每次调用它的时候,并不会再对 now()
调用。所以可以看到,在语义上双方已经不同了。解决方法也很简单,将后者对 now
函数的调用推迟到它自身被调用的时候。
书上给出的修改是这样的:
1 | // 省略了一系列的 using |
但这显然也不能达成目标,now
函数还是会在 setSoundB
生成的时候就调用。不难想到,std::bind
第一个参数,即可调用对象,是不会提前被调用的。所以应该这么改:
1 | // 省略了一系列的 using |