C++11中有两种默认捕获模式:按引用或按值。按引用的默认捕获模式可能导致空悬引用,按值的默认捕获模式会忽悠你,好像可以对空悬引用免疫(其实并没有),你,让你认为你的闭包是独立的(事实上他们可能不是独立的)。
这些就是本条款的纲领性内容了。但如果你本性上更偏向于工程师而不是领导,你就会不仅要一个骨架,还得有血有肉。所以我们就默认捕获模式的危害说起吧。
按引用捕获会导致闭包包含指涉到局部变量的引用,或者指涉到定义lambda式的作用域内的形参的引用。一旦由lambda式所创建的闭包越过了该局部变量或形参的生命期,那么闭包内的引用就会空悬。例如,我们有一个元素为筛选函数的容量,其中每个筛选函数都接受一个int,并返回一个bool以表示传入的值是否满足筛选条件:
using FilterContainer = //关于using,参见条款9
std::vector>; //关于std::function,参见条款2
FilterContainer filters; //元素为筛选函数的容器
我们可以像下面这样添加一个筛选5的倍数的函数:
filters.emplace_back( //欲知emplace_back的详情,参见条款42
[](int value) { return value % 5 == 0;}
);
但是,我们可能需要在运行期计算出除数,而不是把硬编码的"5"写入lambda式中,所以,添加筛选器的代码多少可能与下面的代码相似:
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back( //危险!
[&](int value) { return value % divisor == 0; } //对divisor的指涉可能空悬
);
}
这段代码随时会出错。lambda式是指涉到局部变量divisor的引用,但该变量的在addDivisorFilter返回时即不再存在。换言之,该变量的销毁就是紧接着filters.emplace返回的那一时刻。所以这就等于说,添加到筛选器聚集的那个函数刚刚被添加完就消亡了。使用这个筛选器,从它刚被创建的那一刻起,就会产生未定义的行为。
就算不这样做,换做以显示方式按引用捕获divisor,问题依旧:
filters.emplace_back(
[&divisor](int value) //危险
{ return value % divisor == 0; } //对divisor的指涉仍然可能空悬!
);
不过,通过显式捕获,确实较容易看出lambda式的生存依赖于divisor的生命期。而且,明白地写出名字"divisor"还提醒了我们,再次确认了divisor的至少和该lambda式的闭包具有一样长的声明周期。比起“[&]”所传达的这种不痛不痒的“要保证没有空悬”式的劝告,显式指明更让人印象深刻。
如果你知道闭包会被立即使用(例如,传递给STL算法)并且不会被复制,那么引用比它持有的局部变量或形参生命期更长,就不存在风险。你可能会争论说,这样的情况下,既然没有引用空悬风险,也就没有理由要避免使用默认引用捕获模式。例如,我们的筛选器lambda式仅用作C++11的std::all_of的实参,后者的作用是返回某作用域内的元素是否都满足某条件的判断:
template
void workWithContainer(const C& container)
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
using ContElemt = typename C::value_type; //为实现泛型算法
//取得容器中的元素型别,参见条款13
using std::begin;
using std::end;
if(std::all_of( //如果所有容器中的
begin(container), end(container), //元素值都是divisor的倍数
[&](const ContElemt& value)
{
return value % divisor == 0;
}
){
... //若全是,执行这里
}else{
... //若至少有一个不是,执行这里
}
}
不错,这样使用的确安全,但是这样的安全可谓朝不保夕。如果发现该lambda式在其他语境中有用(例如,加入到filters容器中成为一个函数元素),然后被复制并粘贴到其他闭包比divisor生命期更长的语境中的话,你就又被拖回悬空的困境了。这一回,在捕获语句中,可没有任何让你对divisor进行生命期分析的提示之物了。
从长远看,显式地列出lambda所依赖的局部变量或形参时更好的软件工程实践。
顺便说下,C++14提供了在lambda式的形参声明中使用auto的能力,这意味着上面的代码在C++14中可以简化,ContElemt的声明可以删其,而if条件可以更改如下:
if(std::all_of(
begin(container), end(container),
[&](const auto& value) //C++14
{
return value % divisor == 0;
}
))
解决这个问题的一种办法是对divisor采用按值的默认捕获模式。即,我们这样向容器添加lambda式:
filters.emplace_back(
[=](int value) { return value % divisor == 0; } //现在divisor不会空悬
);
对于本例而言,这样做已经足够。但是,总的来说,按值的默认捕获并非你想象中能够避免空悬的灵丹妙药。问题在于,按值捕获了一个指针以后,在lambda式创建的闭包中持有的是这个指针的副本,但你并无法阻止lambda式之外的代码去针对该指针实施delete操作所导致的指针副本空悬。
"这种事根本不会发生!"你抗议道,“我已经看完了第4章,智能指针是我的崇拜!只有没前途的C++98程序员才会使用裸指针和delete。”可能的确如此,但你很难脱离干系,因为事实上,有时你真的会使用裸指针,还有的时候,它们会在你眼皮底下实施delete操作。只不过现代C++编程风格中,在源代码中经常难觅其迹。
假设Widget类可以实施的一个操作是向筛选器容器中添加条目:
class Widget
{
public:
... //构造函数等
void addFilter() const; //向filters添加一个条目
private:
int divisor; //用于Widget的filter元素
};
Widget::addFilter可能作如下定义:
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0;}
);
}
在满心欢喜的外行看来,这像是安全的代码。虽然lambda式对divisor有依赖,但按值的默认捕获模式会确保divisor被复制到该lambda式创建的任何闭包里,对吗?
错。错的彻底。错的离谱、错的无药可救。
捕获只能针对于在创建lambda式的作用域内可见的非静态局部变量(包括形参)。在Widget::addFilter的函数体内,divisor并非局部变量,而是Widget类的成员变量。它压根无法被捕获。这么一来,如果默认捕获模式被消除,代码就不会通过编译:
void Widget::addFilter() const
{
filters.emplace_back( //错误!
[](int value) { return value % divisor == 0;} //没有可捕获的divisor
);
}
而且,如果试图显式捕获divisor(无论按值还是引用,这无关紧要),这个捕获语句都不能通过编译,因为divisor既不是局部变量,也不是形参:
void Widget::addFilter() const
{
filters.emplace_back( //错误!
[divisor](int value) { return value % divisor == 0;} //局部没有可捕获的divisor
);
}
所以如果在按值的默认捕获语句中捕获的并非divisor,并且如果这句按值的默认捕获语句不存在,代码不能编译,那么到底实际发生了什么呢?
要解释这一现象,关键在于一个裸指针隐式应用,这就是this.每一个非静态成员函数都持有一个this指针,然后每当提及该类的成员变量时都会用到这个指针。例如,在Widget的任何成员函数中,编译器内部都会把divisor替换成this->divisor。在Widget::addFilter的按值默认捕获版本中,
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0;}
);
}
被捕获的实际上是Widget的this,指针,而不是divisor。从编译器视角来看,上述代码相当于:
void Widget::addFilter() const
{
auto currentObjectPtr = this;
filter.emplace_back(
[currentObjectPtr](int value)
{ return value % currentObjectPtr->divisor == 0;}
);
}
理解了这一点,也就相当于理解了lambda闭包存活与它含有其this指针副本的Widget对象的生命期是绑在一起的。特别地,考虑下面的代码,它掌握了第4章精髓,仅使用了智能指针:
using FilterContainer = std::vector>; //同前
FilterContainer filters; //as before
void doSomeWork()
{
auto pw =
std::make_unique(); //创建Widget,关于std::make_unique参见条款21
pw->addFilter(); //添加使用了Widget::divisor的筛选函数
...
} //Widget被销毁,filters现在持有空悬指针
当调用doSomeWork时创建了一个筛选函数,它依赖于std::make_unique创建的Widget对象,即,一个含有指向Widget指针(Widget的this指针的副本)的筛选函数。该函数被添加到filters中,不过当doSomeWork执行结束之后,Widget对象即被管理着它的生命期的std::unique_ptr销毁。从那一刻起,filters中就含有了一个带有空悬指针的元素。
这一特定问题可以通过将你想捕获的成员变量复制到局部变量中,尔后捕获该局部副本加以解决。
void Widget::addFilter() const
{
auto divisorCopy = divisor; //复制成员变量
filters.emplace_back(
[divisorCopy](int value) { return value % divisorCopy == 0;} //捕获及使用副本
);
}
实话实说,如果你采用这种方法,那么按值的默认捕获也能够运作:
void Widget::addFilter() const
{
auto divisorCopy = divisor; //复制成员变量
filters.emplace_back(
[=](int value) { return value % divisorCopy == 0;} //捕获及使用副本
);
}
但是为何要冒此不必要的风险?按值的默认捕获才是最开始造成意外地捕获了this指针,而不是期望中的divisor的始作俑者。
在C++14中,捕获成员变量的一种更好的方法是使用广义Lambda捕获(条款32):
void Widget::addFilter() const
{
filters.emplace_back( //C++14
[divisor= divisor](int value) //将divisor复制入闭包
{ return value % divisorCopy == 0;} //使用副本
);
}
对广义lambda捕获而言,没有默认捕获模式一说。但是,就算在C++14中,本条款的建议(避免使用默认捕获模式)依然成立。
使用默认值捕获模式的另一个缺点是,在于它似乎表明闭包是自洽的,与闭包外的数据变化绝缘。作为一般的结论,这是不正确的。因为lambda式可能不仅依赖于局部变量和形参(它们可以被捕获),它们还会依赖于静态存储器对象。这样的对象定义在全局或名字空间作用域中,又或在类中、在函数中、在文件中以static饰词声明。这样的对象可以在lambda内使用,但是它们不能被捕获。但如果使用了默认值捕获模式,这些对象就会给人以错觉,认为它们可以加以捕获,思考下面这个前面见过的addDivisorFilter函数的修改版:
void addDivisorFilter()
{
static auto calc1 = computeSomeValue1(); //现在以static饰词声明
static auto calc2 = computeSomeValue2(); //现在以static饰词声明
static auto divisor = computeDivisor(calc1, calc2); //现在以static饰词声明
filters.emplace_back( //未捕获任何东西!
[&](int value) { return value % divisor == 0; } //指涉到前述以static饰词声明的对象
);
++divisor; //意外修改了divisor
}
一目十行的读者在看到代码中有着"[=]"后,就会想当然地任务,“很好,lambda式复制了它内部使用的对象,因此lambda式是自洽的”。这无可厚非,但该lambda式是在并不独立,因为它没有使用任何的非静态局部变量和形参,所以它没能捕获任何东西。更糟糕的是lambda式的代码中指涉了静态变量divisor。因而,每次调用addDivisorFilter的最后divisor都会被递增,从而在把好多个lambda式添加到filters时每个lambda式的行为都不一样对应于divisor的新值)。从实际效果来说,这个lambda式实现的效果是按引用捕获divisor,和按值默认捕获所暗示的含义有着直接的矛盾,如果从一开始就远离按值的默认捕获模式,也就能消除代码被如此勿读的风险了。