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

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

这些就是本条款的纲领性内容了。但如果你本性上更偏向于工程师而不是领导,你就会不仅要一个骨架,还得有血有肉。所以我们就默认捕获模式的危害说起吧。

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

using FilterContainer =                    //关于using,参见条款9
    std::vector<std::function<bool(int)>>; //关于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<typename C>
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<std::function<bool(int)>>;  //同前
FilterContainer filters;    //as before

void doSomeWork()
{
    auto pw =
        std::make_unique<Widget>();     //创建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,和按值默认捕获所暗示的含义有着直接的矛盾,如果从一开始就远离按值的默认捕获模式,也就能消除代码被如此勿读的风险了。

要点速记

  • 按引用的默认捕获会导致空悬指针问题
  • 按值的默认捕获极易受空悬指针影响(尤其是this),并会误导人们认为lambda式是自洽的。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值