条款31.避免默认捕获模式

lambda表达式

  • lambda表达式,是表达式的一种,它是源代码组成部分。
std::find_if(container.begin(), container.end(),
            [](int val){ return 0 < val && val < 10; });
  • 闭包是lambda创建的运行期对象,根据不同的捕获模式,闭包会持有数据的副本或引用。在上面的例子中,闭包就是作为第三个实参在运行期传递给std::find_if的对象。4
  • 闭包类就是实例化闭包的类,每个lambda式都会触发编译器生成一个独一无二的闭包类。而闭包中的语句会变成它的闭包类成员函数的可执行指令。

lambda式常用于创建闭包并仅将其用作传递给函数的实参。不过,一般而言,闭包可以复制,所以,对应于单独一个lambda式的闭包类型可以有多个闭包。

避免默认捕获模式

C++11中有两种默认捕获模式:按引用或按值。按引用的默认捕获模式可能导致空悬引用,按值得默认捕获模式会忽悠你,好像可以对空悬引用免疫(其实并没有),还让你认为你的闭包是独立的。

按引用捕获会导致闭包含指向到局部变量的引用,或者指向到定义lambda式的作用域内的形参的引用。一旦由lambda所创建的闭包越过了该局部变量或形参的生命期,那么闭包内的引用就会空悬。例如,我们有一个元素为筛选函数的容器,其中每个筛选函数都接受一个int,并返回一个bool以表示传入的值是否满足筛选条件:

using FilterContainer = std::vector<std::function<bool(int)>>;			//关于using

FilterContainer filters;			//元素为筛选函数的容器

我们可以像下面这样添加一个筛选5的倍数的函数:

filters.emplace_back(
	[](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_back返回的那一刻。所以这就等于说,添加到筛选器的那个函数刚刚被添加完就消亡了。使用这个筛选器,从它刚被创建的那一刻,就会产生未定义行为。

就算不这样做,换做以显式方式按引用捕获divisor,问题依旧。

filters.emplace_back(
[&divisor](int value)
    { return value % divisor == 0; }		//危险,对divisor的指向可能空悬
);

不过,通过显式捕获,确实较容易看出lambda式的生存依赖于divisor的生命期。而且,明白地写出名字divisor还提醒了我们,再次确认了divisor至少和该lambda式的闭包具有一样长的生命期。比起[&]所传达的这种不痛不痒的”要保证没有空悬“式的劝告,显式指明更让人印象深刻。

如果你知道闭包会被立即使用(例如,传递给STL算法)并且不会复制,那么在lambda式中使用引用将不会有风险。

例如,我们的筛选器lambda式仅用作C++11的std::all_of的实参,后者的作用是返回某作用域内的元素是否都满足某条件的判断。

template<typename C>
void workWithContainer(const C& container)
{
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    
    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)					//都是divisor
        { return value % divisor == 0; }))		//的倍数
    {
        ...
    }
    else
    {
        ...
    }
}

不错,这样使用的确安全,但是这样的安全可谓朝不保夕。如果发现该lambda式在其他语境中有用(例如,加入到filters容器中成为一个函数元素),然后被复制并黏贴到其他闭包比divisor生命期更长的语境中的话,你就要被拖回空悬的困境了。

从长远观点来看,显式地列出lambda式所依赖的局部变量或形参是更好的软件工程实践

顺便说下,C++14提供了在lambda式的形参声明中使用auto的能力,这意味着上面的代码在C++14中可以简化,ContElemT的声明可以删去,而if条件可以更改如下。

if(std::all_of(begin(container), end(container),
              [&](const auto& value)
               { return value % divisor == 0; }))

解决这个问题的一种方法就是对divisor采用按值得默认捕获模式。即,我们这样向容器添加lambda式。

filters.emplace_back(
	[=](int value) { return value % divisor == 0; }
);

对于本例来说,这样做已经足够了。但是,总的来说,按值的默认捕获并非你想象中能够避免空悬的灵丹妙药。问题在于,按值捕获了一个指针以后,在lambda式创建的闭包中持有的是这个指针的副本,但你并没办法阻止lambda式之外的代码去针对该指针实施delete操作所导致的指针副本空悬

假设Widget类可以实施的一个操作是向筛选器容器中添加条目:

class Widget{
public:
    ...							//构造函数等
    void addFilter() const; 	//向filters添加一个条目

private:
	int divisor;				//用于Widget的filters元素    
};

Widget::addFilter可能作如下定义

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

这段代码错了,错得彻底。

捕获只能针对于在创建lambda式的作用域内可见的非静态局部变量(包括形参)divisor并非局部变量,而是Widget类的成员变量,它根本不能被捕获

这么一来,如果默认捕获模式被消除,代码就不会通过编译:

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

而且,如果试图显式捕获divisor(无论是按值还是按引用),这个捕获语句都不能通过编译,因为divisor既不是局部变量,也不是形参;

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

为什么这两个语句会编译失败,关键在于一个裸指针的隐式应用,这就是this每一个非静态成员函数都持有一个this指针,然后每当提及该类的成员变量时都会用到这个指针。例如,在Widget的任何成员函数中,编译器内部都会把divisor替换成this->divisor。在Widget::addFilter的按值默认捕获版本中

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

被捕获的实际上是Widgetthis指针,而不是divisor。从编译器的视角来看,上述代码相当于

void Widget::addFilter() const
{
    auto currentObjectPtr = this;

    filters.emplace_back(
    [currentObjectPtr](int value)
        { return value % currentObject->divisor == 0; }
    );
}

理解了这一点,也就相当于理解了lambda闭包的存活于它含有其this指针副本的Widget对象的生命期是绑在一起的。特别的,考虑下面的代码,它掌握了第4章精髓,仅使用了智能指针。

using FilterContainer = std::vector<std::function<bool(int)>>;

FilterContainer filters;

void doSomeWork()
{
    auto pw = std::make_unique<Widget>();
    
    pw->addFilter();		//添加使用了Widget::divisor的筛选函数
    
    ...						//Widget被销毁,filters现在持有空悬指针
}

当调用doSomeWork时创建了一个筛选函数,它依赖于std::make_unique创建的Widget对象,即,一个含有指向Widget的指针(Widgetthis指针的副本)的筛选函数。该函数被添加到filters中,不过当doSomeWork执行结束之后,Widget对象即被管理着它的生命周期的std::unique_ptr销毁,从那一刻起,filter中就含有了一个带有空悬指针的元素。

这一特定问题可以通过将你想捕获的成员变量拷贝到局部变量中,然后捕获该局部副本加以解决

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; }		//使用副本
    );
}

在C++14中,捕获成员变量的一种更好的办法是使用广义lambda捕获

void Widget::addFilter() const
{
    filters.emplace_back(
    [divisor = divisor](int value){ return value % divisor == 0; }	//将divisor拷贝入闭包
    );
}

对于广义lambda捕获而言,没有按默认捕获模式一说。但是就算在C++14中,本条款的建议仍然成立。

使用默认值捕获模式的另外一个缺点是,在于它们似乎表明相关的闭包是独立的并且不受外部数据变化的影响。一般来说,这是不对的。因为lambda式不仅依赖于局部变量和参数,它们还会依赖静态存储器对象这样的对象定义在全局或名字空间作用域中,又或在类中,在函数中,在文件中以static饰词声明。这样的对象可以在lambda对象中使用,但是它们不能被捕获,但如果使用了按默认值捕获模式,这些对象就会给人错觉,认为它们可以被捕获

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; }		//未捕获任何东西
    );													
	
    ++divisor;				//意外修改了divisor
}

一目十行的读者在看到代码中有着[=]后,就会想当然地认为,lambda是独立的。但是lambda并非独立,因为它没有使用任何的非静态局部变量和形参,所以它没能捕获任何东西。然而lambda的代码引用了静态变量divisor,任何lambda被添加到filters之后,divisor都会递增。通过这个函数,会把许多lambda都添加到filters中,但每一个lambda的行为都不一样。

从实际效果来看,这个lambda式实现的效果是按引用捕获divisor,和按值默认捕获所暗示的含义有着直接的矛盾。

要点速记

  • 按引用的默认捕获会导致空悬指针问题
  • 按值的默认捕获极易受到空悬指针的影响(尤其是this),并会误导人们认为lambda是独立的
上一篇:Integer缓冲区


下一篇:[HOJ 10178] 最大公约数 (Greatest Common Divisor)