Item 31 避免使用默认捕获模式
lambda
Lambda 表达式是 C++ 编程中的游戏规则改变者。这有点令⼈惊讶,因为它没有给语⾔带来新的表达能⼒。Lambda 可以做的所有事情都可以通过其他⽅式完成。但是 lambda 是创建函数对象相当便捷的⼀种⽅法,对于⽇常的 C++ 开发影响是巨⼤的。没有 lambda 时,标准库中的 _if
算法(⽐如,std::find_if
, std::remove_if
, std::count_if
等)通常需要繁琐的谓词,但是当有 lambda 可⽤时,这些算法使⽤起来就变得相当⽅便。⽐较函数(⽐如,std::sort
, std::nth_element
,std::lower_bound
等)与算法函数也是相同的。在标准库外,lambda 可以快速创建 std::unique_ptr
和 std::shared_ptr
的⾃定义 deleter
,并且使线程 API
中条件变量的条件设置变得同样简单(参⻅ Item 39)。除了标准库,lambda 有利于即时的回调函数,接口适配函数和特定上下⽂中的⼀次性函数。Lambda 确实使 C++ 成为更令⼈愉快的编程语⾔。
与 Lambda 相关的词汇可能会令⼈疑惑,这⾥做⼀下简单的回顾:
- lambda 表达式就是⼀个表达式。在代码的⾼亮部分就是lambda
1 | std::find_if(container.begin(), container.end(), |
- 闭包是lambda创建的运⾏时对象。依赖捕获模式,闭包持有捕获数据的副本或者引⽤。在上⾯的
std::find_if
调⽤中,闭包是运⾏时传递给std::find_if
第三个参数。 - 闭包类是从中实例化闭包的类。每个 lambda 都会使编译器⽣成唯⼀的闭包类。Lambda 中的语句成为其闭包类的成员函数中的可执⾏指令。
Lambda 通常被⽤来创建闭包,该闭包仅⽤作函数的参数。上⾯对 std::find_if
的调⽤就是这种情况。然而,闭包通常可以拷⻉,所以可能有多个闭包对应于⼀个 lambda。⽐如下⾯的代码:
1 | { |
c1, c2,c3都是 lambda 产⽣的闭包的副本。
⾮正式的讲,模糊 lambda,闭包和闭包类之间的界限是可以接受的。但是,在随后 的Item 中,区分编译期还是运⾏时以及它们之间的相互关系是重要的。
避免使用默认捕获模式
C++11 中有两种默认的捕获模式:按引⽤捕获和按值捕获。但按引⽤捕获可能会带来悬空引⽤的问题,而按值引⽤可能会诱骗你让你以为能解决悬空引⽤的问题(实际上并没有),还会让你以为你的闭包是独⽴的(事实上也不是独⽴的)。
按引⽤捕获会导致闭包中包含了对局部变量或者某个形参(位于定义 lambda 的作⽤域)的引⽤,如果该 lambda 创建的闭包⽣命周期超过了局部变量或者参数的⽣命周期,那么闭包中的引⽤将会变成悬空引⽤。举个例⼦,假如我们有⼀个元素是过滤函数的容器,该函数接受⼀个 int
作为参数,并返回⼀个布尔值,该布尔值的结果表⽰传⼊的值是否满⾜过滤条件。
1 | using FilterContainer = // see Item 9 for |
我们可以添加⼀个过滤器,⽤来过滤掉 5 的倍数。
1 | filters.emplace_back( // see Item 42 for |
这个代码实现是⼀个定时炸弹。lambda 对局部变量 divisor
进⾏了引⽤,但该变量的⽣命周期会在 addDivisorFilter
返回时结束,刚好就是在语句 filters
. emplace_back
返回之后,因此该函数的本质就是容器添加完,该函数就死亡了。使⽤这个 filter
会导致未定义⾏为,这是由它被创建那⼀刻起就决定了的。
现在,同样的问题也会出现在 divisor
的显式按引⽤捕获。
1 | filters.emplace_back( |
但通过显式的捕获,能更容易看到 lambda 的可⾏性依赖于变量 divisor
的⽣命周期。另外,写成这种形式能够提醒我们要注意确保 divisor
的⽣命周期⾄少跟 lambda 闭包⼀样⻓。⽐起 "[&]"
传达的意思,显式捕获能让⼈更容易想起“确保没有悬空变量”。
如果你知道⼀个闭包将会被⻢上使⽤(例如被传⼊到⼀个stl算法中)并且不会被拷⻉,那么在lambda环
境中使⽤引⽤捕获将不会有⻛险。在这种情况下,你可能会争论说,没有悬空引⽤的危险,就不需要避
免使⽤默认的引⽤捕获模式。例如,我们的过滤lambda只会⽤做C++11中std::all_of的⼀个参数,返回
满⾜条件的所有元素:
1 | template<typename C> |
的确如此,这是安全的做法,但这种安全是不确定的。如果发现 lambda 在其它上下⽂中很有⽤(例如作为⼀个函数被添加在 filters
容器中),然后拷⻉粘贴到⼀个 divisor
变量已经死亡的,但闭包⽣命周期还没结束的上下⽂中,你⼜回到了悬空的使⽤上了。同时,在该捕获语句中,也没有特别提醒了你注意分析 divisor
的⽣命周期。
从⻓期来看,使⽤显式的局部变量和参数引⽤捕获⽅式,是更加符合软件⼯程规范的做法。
额外提⼀下,C++14 ⽀持了在 lambda 中使⽤ auto
来声明变量,上⾯的代码在 C++14 中可以进⼀步简化,ContElemT
的别名可以去掉,if
条件可以修改为:
1 | if (std::all_of(begin(container), end(container), |
⼀个解决问题的⽅法是,divisor
按值捕获进去,也就是说可以按照以下⽅式来添加 lambda:
1 | filters.emplace_back( // now |
这⾜以满⾜本实例的要求,但在通常情况下,按值捕获并不能完全解决悬空引⽤的问题。这⾥的问题是如果你按值捕获的是⼀个指针,你将该指针拷⻉到 lambda
对应的闭包⾥,但这样并不能避免 lambda
外删除指针的⾏为,从而导致你的指针变成悬空指针。
也许你要抗议说:“这不可能发⽣。看过了第四章,我对智能指针的使⽤⾮常热衷。只有那些失败的 C++98 的程序员才会⽤裸指针和 delete
语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使⽤裸指针,也的确存在被你删除的可能性。只不过在现代的 C++ 编程⻛格中,不容易在源代码中显露出来而已。
假设在⼀个 Widget
类,可以实现向过滤容器添加条⽬:
1 | class Widget { |
这是 Widget::addFilter
的定义:
1 | void Widget::addFilter() const |
这个做法看起来是安全的代码,lambda 依赖于变量 divisor
,但默认的按值捕获被拷⻉进了 lambda 对应的所有⽐保重,这真的正确吗?
错误,完全错误。
闭包只会对 lambda 被创建时所在作⽤域⾥的⾮静态局部变量⽣效。在 Widget::addFilter()
的视线⾥,divisor
并不是⼀个局部变量,而是 Widget
类的⼀个成员变量。它不能被捕获。如果默认捕获模式被删除,代码就不能编译了:
1 | void Widget::addFilter() const |
另外,如果尝试去显式地按引⽤或者按值捕获 divisor
变量,也⼀样会编译失败,因为 divisor
不是这⾥的⼀个局部变量或者参数。
1 | void Widget::addFilter() const |
因此这⾥的默认按值捕获并不是不会变量 divisor
,但它的确能够编译通过,这是怎么⼀回事呢?
解释就是这⾥隐式捕获了 this
指针。每⼀个⾮静态成员函数都有⼀个 this
指针,每次你使⽤⼀个类内的成员时都会使⽤到这个指针。例如,编译器会在内部将 divisor
替换成 this->divisor
。这⾥ Widget::addFilter()
的版本就是按值捕获了 this
。
1 | void Widget::addFilter() const |
真正被捕获的是 Widget
的 this
指针。编译器会将上⾯的代码看成以下的写法:
1 | void Widget::addFilter() const |
明⽩了这个就相当于明⽩了 lambda 闭包的⽣命周期与 Widget
对象的关系,闭包内含有 Widget
的 this
指针的拷⻉。特别是考虑以下的代码,再参考⼀下第四章的内容,只使⽤智能指针:
1 | using FilterContainer = // as before |
当调⽤ doSomeWork
时,就会创建⼀个过滤器,其⽣命周期依赖于由 std::make_unique
管理的 Widget
对象。即⼀个含有 Widget this
指针的过滤器。这个过滤器被添加到 filters
中,但当 doSomeWork
结束时,Widget
会由 std::unique_ptr
去结束其⽣命。从这时起,filter
会含有⼀个悬空指针。
这个特定的问题可以通过做⼀个局部拷⻉去解决:
1 | void Widget::addFilter() const |
事实上如果采⽤这种⽅法,默认的按值捕获也是可⾏的。
1 | void Widget::addFilter() const |
但为什么要冒险呢?当你⼀开始捕获 divisor
的时候,默认的捕获模式就会⾃动将 this
指针捕获进来了。
在 C++14 中,⼀个更好的捕获成员变量的⽅式时使⽤通⽤的 lambda 捕获:
1 | void Widget::addFilter() const |
这种通⽤的 lambda 捕获并没有默认的捕获模式,因此在 C++14 中,避免使⽤默认捕获模式的建议仍然时成⽴的。
使⽤默认的按值捕获还有另外的⼀个缺点,它们预⽰了相关的闭包是独⽴的并且不受外部数据变化的影响。⼀般来说,这是不对的。lambda 并不会独⽴于局部变量和参数,但也没有不受静态存储⽣命周期的影响。⼀个定义在全局空间或者指定命名空间的全局变量,或者是⼀个声明为 static
的类内或⽂件内的成员。这些对象也能在 lambda ⾥使⽤,但它们不能被捕获。但按值引⽤可能会因此误导你,让你以为捕获了这些变量。参考下⾯版本的 addDivisorFilter()
函数:
1 | void addDivisorFilter() |
随意地看了这份代码的读者可能看到 "[=]"
,就会认为“好的,lambda 拷⻉了所有使⽤的对象,因此这是独⽴的”。但上⾯的例⼦就表现了不独⽴闭包的⼀种情况。它没有使⽤任何的⾮ static
局部变量和形参,所以它没有捕获任何东西。然而 lambda 的代码引⽤了静态变量 divisor
,任何 lambda 被添加到 filters
之
后,divisor
都会递增。通过这个函数,会把许多 lambda 都添加到 filiters
⾥,但每⼀个 lambda 的⾏为都是新的(分别对应新的 divisor
值)。这个 lambda 是通过引⽤捕获 divisor
,这和默认的按值捕获表⽰的含义有着直接的⽭盾。如果你⼀开始就避免使⽤默认的按值捕获模式,你就能解除代码的⻛险。
总结
- 默认的按引⽤捕获可能会导致悬空引⽤;
- 默认的按值引⽤对于悬空指针很敏感(尤其是
this
指针),并且它会误导⼈产⽣ lambda 是独⽴的想法;