Effective Modern C++ 条款31 对于lambda表达式,避免使用默认捕获模式

对于lambda表达式,避免使用默认捕获模式

个人看法 英文原著中使用的是“avoid default capture modes”,所以我在文中翻译为“避免使用默认捕获模式”,但是我认为把“默认捕获模式”称为“隐式捕获模式”更好,因为作者所指的“默认捕获模式”是指在捕获语句中只出现等号或者引用符号(即“[=]”或“[&]”),而不出现捕获的变量名,但为了符合英文,还是把“default capture”翻译为“默认捕获”。

C++11中有两种默认捕获模式:引用捕获或值捕获。默认的引用捕获模式可能会导致悬挂引用,默认的值捕获模式诱骗你——让你认为你可以免疫刚说的问题(事实上没有免疫),然后它又骗你——让你认为你的闭包是独立的(事实上它们可能不是独立的)。

那就是本条款的总纲。如果你是工程师,你会想要更具体的内容,所以让我们从默认捕获模式的危害开始说起吧。

引用捕获会导致闭包包含一个局部变量的引用或者一个形参的引用(在定义lamda的作用域)。如果一个由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_back返回之后,所以添加到容器的函数本质上就像是一到达容器就死亡了。使用那个过滤器会产生未定义行为,这实际上是在创建过滤器的时候就决定好的了。

现在呢,如果显式引用捕获divisor,会存在着同样的问题:

filters.emplace(
  [&divisor](int value)          // 危险!对divisor的引用依然会空悬
  { return value % divisor == 0; }
);

不过使用显示捕获,很容易就可以看出lambda的活性依赖于divisor的生命期。而且,写出名字“divisor”会提醒我们,要确保divisor的生命期至少和lambda闭包一样长。比起用“[&]”表达“确保不会空悬”,显式捕获更容易让你想起这个告诫。

如果你知道一个闭包创建后马上被使用(例如,传递给STL算法)而且不会被拷贝,那么引用的局部变量或参数就没有风险。在这种情况下,你可能会争论,没有空悬引用的风险,因此没有理由避免使用默认的引用捕获模式,例如,我们的过滤lambda只是作为C++11的std::all_of的参数(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;    // 关于通用性,看条款13
    using std::end;      // 条款13

    if (std::all_of(                    // 如果容器中的元素都是divisor的倍数...
         begin(container), end(container),
         [&](const ContElemT& value)
         { return value % divisor == 0; }
        )  {
        ...         
    } else {
        ...
    }
} 

当然,这是安全的,但是它的安全有点不稳定,如果发现lambda在其他上下文很有用(例如,作为函数加入到过滤器容器),然后拷贝及粘贴到其他上下文,在那里divisor已经死亡,而闭包还健全,你又回到了空悬的境地,同时,在捕获语句中,也没有特别提醒你对divisor进行生命期分析(即没有显式捕获)。

从长期来看,显式列出lambda依赖的局部变量或形参是更好的软件工程。

顺便说下,C++14的lambda形参可以使用auto声明,意味着上面的代码可以用C++14简化,ContElemT的那个typedef可以删去,然后把if语句的条件改成这样:

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

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

filters.emplace_back(            // 现在divisor就不会空悬
  [=](int value)  { return value % divisor == 0; }

这满足这个例子的需要,但是,总的来说,默认以值捕获不是对抗空悬的长生不老药。问题在于,如果你用值捕获了个指针,你在lambda创建的闭包中持有这个指针的拷贝,但你不能阻止lambda外面的代码删除指针指向的内容,从而导致你拷贝的指针空悬。

“这不可能发生”你在抗议,“自从看了第四章,我十分热爱智能指针。只有智障的C++98程序员才会使用原生指针和delete。”你说的可能是正确的,但这是不相关的,因为实际上你真的会使用原生指针,而它们实际上也会在你眼皮底下被删除,只不过在你的现代C++编程风格中,它们(原生指针)在源代码中不露迹象。。

假如Widget类可以做的其中一件事是,向过滤器容器添加条目:

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

private:
    int divisor;         // 用于Widget的过滤器中
};

Widget::addFilter可能定义成这样:

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

对于外行人,这看起来像是安全的代码。lambda依赖divisor,但默认的以值捕获模式确保了divisor被拷贝到lambda创建的闭包里,对吗?

错了,完全错了。

捕获只能用于可见(在创建lambda的作用域可见)的非static局部变量(包含形参)。在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,而不是默认值捕获语句就不能编译,那么前者发生了什么?

问题解释取决于原生指针的隐式使用:this。每一个非static成员函数都有一个this指针,然后每当你使用类的成员变量时都用到这个指针。例如,在Widget的一些成员函数中,编译器内部会把divisor替换成this->divisor。在Widget::addFiliter的默认值捕获版本中,

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

被捕获的是Widget的this指针,而不是divisor,编译器把上面的代码视为这样写的:

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

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

理解了这个就相当于理解了lambda闭包的活性与Widget对象的生命期有紧密关系,闭包内含有Widget的this指针的拷贝。特别是,思考下面的代码,它根据第4章,只是用智能指针:

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

FilterContainer filters;         // 如前

void doSomeWork()
{
    auto pw = std::make_unique<Widget>();  // 创建Widge
                                           // 关于std::make_unique,看条款21
    pw->addFilter();     // 添加使用Widget::divisor的过滤函数

    ...
}             // 销毁Widget,filters现在持有空悬指针!

当调用doSomeWork时,创建了一个过滤函数,它依赖std::make_unique创建的Widget对象,即,那个过滤函数内含有指向Widget指针——即,Widget的this指针——的拷贝。这个函数被添加到filters中,不过当doSomeWork执行结束之后,Widget对象被销毁,因为它的生命期由std::unique_ptr管理(看条款18)。从那一刻起,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捕获(generalized lambda capture,即,捕获语句可以是表达式,看条款32):

void Widget::addFilter() const 
{
    filters.emplace_back(               // C++14
      [divisor = divisor](int value)    // 在闭包中拷贝divisor
      { return value % divisor == 0; }  // 使用拷贝
    );
}

广义lambda捕获没有默认捕获模式,但是,就算在C++14,本条款的建议——避免使用默认捕获模式——依然成立。

使用默认值捕获模式的一个另外的缺点是:它们表明闭包是独立的,不受闭包外数据变化的影响。总的来说,这是不对的,因为lambda可能不会依赖于局部变量和形参,但它们会依赖于静态存储周期的对象(static storage duration)。这样的对象定义在全局作用域或者命名空间作用域,又或者在类中、函数中、文件中声明为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; }  // 引用了上面的divisor
    );

    ++divisor;          // 修改divisor
};

这份代码,对于随便的读者,他们看到“[=]”然后想,“很好,lambda拷贝了它内部使用的对象,因此lambda是独立的。”,这可以被谅解。但这lambda不是独立的,它没有使用任何的非static局部变量和形参,所以它没有捕获任何东西。更糟的是,lambda的代码引用了static变量divisor。在每次调用addDivisorFilter的最后,divisor都会被递增,通过这个函数,会把好多个lambda添加到filiters,每一个lambda的行为都是新的(对应新的divisor值)。从实践上讲,这个lambda是通过引用捕获divisor,和默认值捕获语句表示的含义有直接的矛盾。如果你一开始就远离默认的值捕获模式,你就能消除理解错代码的风险。


总结

需要记住的2点:

  • 默认引用捕获会导致空悬引用。
  • 默认值捕获对空悬指针(尤其是this)很敏感,而且它会误导地表明lambda是独立的。
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页