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

本文讨论了在C++中使用lambda表达式时,为什么应避免默认捕获模式,包括默认引用捕获可能导致的悬挂引用问题和默认值捕获可能带来的误解。通过实例解释了默认捕获模式的潜在风险,并提出显式捕获的必要性,以提高代码的清晰性和安全性。
摘要由CSDN通过智能技术生成

对于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是独立的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值