前言
C++11中有两种默认捕获模式:by-reference和by-value。默认的引用捕获可能导致空悬引用。默认的按值捕获让你觉得你的闭包是自包含(self-contained)的(它们可能不是)。
by-reference
引用捕获会导致闭包包含对局部变量或lambda的作用域中可用参数的引用,如果从该lambda创建的闭包的生命周期超过局部变量或参数的生命周期,则闭包引用空悬。举例而言,假设当前存在一个容器,其元素为过滤函数,每个函数都接受一个int并返回一个bool,指示传入的值是否满足过滤器:1
2using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
我们向其中添加一个元素:1
filters.emplace_back([](int value) { return value % 5 == 0; });
但实际上我们可能需要的是一个在运行期决定的除数,即5不可写死在lambda中,因此我们决定这么写:1
2
3
4
5
6void addDivisorFilter(){
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back([&](int value) { return value % divisor == 0; }); // danger
}
lambda引用了局部变量divisor,但当addDivisorFilter返回时,该变量不再存在,因此我们向容器内部添加了一个错误的函数对象。
显式声明引用捕获divisor也会导致同样的问题:1
filters.emplace_back([&divisor](int value){ return value % divisor == 0; });
但这种写法更容易看出该lambda的可行性取决于divisor的寿命,它提醒我们要确保divisor至少与lambda的闭包具备一样长的生命周期。
事实上,如果立即使用闭包(例如直接传入STL算法)且不copy它们,一般情况下不会触发引用空悬的情况,因此你可能会争辩说默认的by-reference不可能触发空悬引用。举例而言,我们的过滤lambda只能用作std::all_of的参数,它返回范围内的所有元素是否满足指定条件:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<typename C>
void workWithContainer(const C& container){
auto calc1 = computeSomeValue1(); // as above
auto calc2 = computeSomeValue2(); // as above
auto divisor = computeDivisor(calc1, calc2);
using ContElemT = typename C::value_type;
using std::begin;
using std::end;
if (std::all_of(begin(container), end(container),
[&](const ContElemT& value){ return value % divisor == 0; }
)
){
…
} else {
…
}
}
C++14的新特性可以简化上述程序为:1
2if (std::all_of(begin(container), end(container),
[&](const auto& value){ return value % divisor == 0; }))
确实,这是安全的,但它的安全性有些不稳定。如果该发现lambda在其他上下文中发挥作用(例如需要被添加到容器中)并且闭包存在复制行为(且当前局部对象已经析构),那么我们终将雪崩。
从软件工程的角度而言,在lambda中明确标识捕获对象具备更高的鲁棒性(它至少提示了该闭包捕获对象的生存期不应当小于闭包生存期)。
by-value
一种解决方案是采用默认的按值传递形式:1
filters.emplace_back([=](int value) { return value % divisor == 0; });
但按值捕获并不能完全解决空悬问题。举例而言,假设当前你copy了一个指针,你完全无力阻止在lambda之外的程序delete该指针从而导致触发空悬。
在成员函数中使用lambda
假设Widgets可以在过滤器容器中添加条目:1
2
3
4
5
6
7class Widget {
public:
… // ctors, etc.
void addFilter() const; // add an entry to filters
private:
int divisor; // used in Widget's filter
};
Widget::addFilter可能被定义为如下形式:1
2
3void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}
这看起来很安全,但实际上并非如此。
捕获仅适用于在创建lambda范围内可见的非静态局部变量(包括参数)。在Widget::addFilter的主体中,divisor不是局部变量,它是Widget类的数据成员,因此无法捕获。但如果不写默认捕获模式,则下述代码无法编译:1
2
3void Widget::addFilter() const{
filters.emplace_back([](int value) { return value % divisor == 0; });
}
此外,如果尝试显式捕获divisor(无论是by-reference还是by-value),代码亦将无法编译,因为divisor既非局部变量亦非参数:1
2
3
4
5void Widget::addFilter() const{
filters.emplace_back(
[divisor](int value){ return value % divisor == 0; }
);
}
因此,默认的by-value capture子句并不捕获divisor,但没它又将无法编译,那该如何是好?答案在于this指针,当我们采用默认by-value捕获时:1
2
3void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}
被捕获的是this指针,而非divisor,编译器会将其视为:1
2
3
4
5
6void Widget::addFilter() const{
auto currentObjectPtr = this;
filters.emplace_back(
[currentObjectPtr](int value){ return value % currentObjectPtr->divisor == 0; }
);
}
这一点非常重要,换而言之,它等价于在描述lambda所生成的闭包与该Widget的生存期有关,因为闭包内含this指针的副本,下文代码将使用智能指针来说明这一点:1
2
3
4
5
6
7using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
void doSomeWork(){
auto pw = std::make_unique<Widget>();
pw->addFilter(); // add filter that uses Widget::divisor
…
} // destroy Widget; filters now holds dangling pointer!
当dosomework完成后,widget对象析构,容器内加入了一个含有空悬引用的闭包,从而在后续引发雪崩。
解决方案
这一问题可通过制作data member副本,并在捕获时显式拷贝其副本得到解决:1
2
3
4
5
6
7void Widget::addFilter() const{
auto divisorCopy = divisor; // copy data member
filters.emplace_back(
[divisorCopy](int value) // capture the copy
{ return value % divisorCopy == 0; } // use the copy
);
}
C++14提出了广义lambda捕获(generalized lambda capture,见Item32),它可以更好地捕获data member:1
2
3
4
5
6void Widget::addFilter() const{
filters.emplace_back(
[divisor = divisor](int value) // copy divisor to closure
{ return value % divisor == 0; } // use the copy
);
}
self-contained
默认按值捕获的另一大缺点在于它使得闭包看起来具有自包含特性,并且与外界数据的更改产生了隔离。但事实并非如此。lambda不仅可以依赖于局部变量和参数(可以捕获),还可以依赖于具有静态存储持续时间的对象。 此类对象在全局作用域或命名空间内定义,又或者在函数、类、文件中被声明为static。它们可以在lambda中得到使用,但却无法被捕获。但默认按值捕获可能给我们造成了它们已被捕获的错觉,试看实例如下:1
2
3
4
5
6
7
8
9
10
11void addDivisorFilter(){
static auto calc1 = computeSomeValue1(); // now static
static auto calc2 = computeSomeValue2(); // now static
static auto divisor = // now static
computeDivisor(calc1, calc2);
filters.emplace_back(
[=](int value) // captures nothing!
{ return value % divisor == 0; } // refers to above static
);
++divisor; // modify divisor
}
某些开发者看到[=]
后即认为当前lambda已经具备self-contained属性,不再与外部数据有所交互,实则不然。每一次addDivisorFilter的调用,都会造成容器内部lambda的变化,也就是说,它似乎具备了引用捕获的某些特性。如果不采用默认按值捕获,我们则可以清楚看出当前并未捕获任何对象。
总结
- 默认引用捕获可能会导致空悬引用。
- 默认按值捕获可能会导致空悬指针(尤其是this指针),并且给人以该lambda自包含的错觉。