31.避免默认捕获模式

前言

 
C++11中有两种默认捕获模式:by-reference和by-value。默认的引用捕获可能导致空悬引用。默认的按值捕获让你觉得你的闭包是自包含(self-contained)的(它们可能不是)。


by-reference

 
引用捕获会导致闭包包含对局部变量或lambda的作用域中可用参数的引用,如果从该lambda创建的闭包的生命周期超过局部变量或参数的生命周期,则闭包引用空悬。举例而言,假设当前存在一个容器,其元素为过滤函数,每个函数都接受一个int并返回一个bool,指示传入的值是否满足过滤器:

1
2
using 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
6
void 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
17
template<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
2
if (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
7
class Widget {
public:
// ctors, etc.
void addFilter() const; // add an entry to filters
private:
int divisor; // used in Widget's filter
};

Widget::addFilter可能被定义为如下形式:
1
2
3
void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}

这看起来很安全,但实际上并非如此。

捕获仅适用于在创建lambda范围内可见的非静态局部变量(包括参数)。在Widget::addFilter的主体中,divisor不是局部变量,它是Widget类的数据成员,因此无法捕获。但如果不写默认捕获模式,则下述代码无法编译:

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

此外,如果尝试显式捕获divisor(无论是by-reference还是by-value),代码亦将无法编译,因为divisor既非局部变量亦非参数:
1
2
3
4
5
void Widget::addFilter() const{
filters.emplace_back(
[divisor](int value){ return value % divisor == 0; }
);
}

因此,默认的by-value capture子句并不捕获divisor,但没它又将无法编译,那该如何是好?答案在于this指针,当我们采用默认by-value捕获时:
1
2
3
void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}

被捕获的是this指针,而非divisor,编译器会将其视为:
1
2
3
4
5
6
void 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
7
using 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
7
void 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
6
void 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
11
void 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的变化,也就是说,它似乎具备了引用捕获的某些特性。如果不采用默认按值捕获,我们则可以清楚看出当前并未捕获任何对象。


总结

  1. 默认引用捕获可能会导致空悬引用。
  2. 默认按值捕获可能会导致空悬指针(尤其是this指针),并且给人以该lambda自包含的错觉。