C++11:lambda表达式的陷阱

本文根据众多互联网博客内容整理后形成,引用内容的版权归原始作者所有,仅限于学习研究使用

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

1.1 对于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:

      这满足这个例子的需要,但是,总的来说,默认以值捕获不是对抗空悬的长生不老药。问题在于,如果你用值捕获了个指针,你在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,即,捕获语句可以是表达式[divisor = divisor],看条款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,和默认值捕获语句表示的含义有直接的矛盾。如果你一开始就远离默认的值捕获模式,你就能消除理解错代码的风险。


1.2 总结

需要记住的2点:

  • 默认引用捕获会导致空悬引用。
  • 默认值捕获对空悬指针(尤其是this)很敏感,而且它会误导地表明lambda是独立的。

 

2 Modern C++中lambda表达式的陷阱

      lambda表达式给stl带来了无与伦比的便利,尤其对像std::for_each这种使用函数指针的场合更是方便,但却是写的爽快,维护的蛋疼,前几天还遇到了一个陷阱,这里特意记录一下

2.1 陷阱1:默认引用捕获可能带来的悬挂引用问题


      在捕获参数时喜欢使用[&]来一次捕获包括this在内的所有内容,此方法非常方便,但在遇到局部变量时,引用捕获却是非常容易出现问题,尤其用在事件系统,信号槽系统里时。

一个简单的lambda程序如下:

#include <iostream>
#include <functional>
using namespace std;

typedef std::function<void()> FP;
void run_fun_ptr(FP fp);
FP get_fun_ptr();
FP get_fun_ptr_ref();
int main()
{
    run_fun_ptr(get_fun_ptr());
    run_fun_ptr(get_fun_ptr_ref());
    return 0;
}

void run_fun_ptr(FP fp)
{
    if(fp)
    {
        fp();
    }
}

FP get_fun_ptr()
{
    int a = 2;
    return [=](){cout << "= a:"<<a << endl;};
}

FP get_fun_ptr_ref()
{
    int a = 2;
    return [&](){cout << "& a:"<<a << endl;};
}


结果输出:

= a:2
& a:4200153


      这里get_fun_ptr正常输出,因为使用的是=号捕获,但get_fun_ptr_ref使用的是引用捕获,就会出现未定义的行为,因为捕获了一个临时变量,引用实际可以看成指针,在get_fun_ptr_ref之后,get_fun_ptr_ref中int a = 2;的临时变量会释放(出栈),此时指针就不知道指的是什么东西了

      在有事件循环系统时,最典型的就是ui程序,若lambda的触发是依据某个事件,如一个鼠标对按钮的点击,但lambda却引用捕获了一个局部变量,在创建时变量存在,但在触发点击时,变量很有可能已经销毁了,这时就会有未定义错误发生。

      如下例子是SA的一个生成最近打开文件菜单项目的例子,作用就是把记录最近打开的文件路径生成一系列菜单项目,在第二个lambda表达式中,若用默认引用捕获,会把QAction* act作为引用捕获,在此函数结束后,将变成悬空引用

   

std::for_each(m_recentOpenFiles.begin(),m_recentOpenFiles.end(),[&](const QString& strPath){
    QAction* act = new QAction(strPath,this);
    connect(act,&QAction::triggered,this,[this,act](bool on){
        Q_UNUSED(on);
        this->openFile(act->text());
    });
    ui->menuRecentOpenFile->addAction(act);
});

 

陷阱2:捕获this陷阱

      后来在网上看到这篇文章Effective Modern C++ 条款31 对于lambda表达式,避免使用默认捕获模式

      看来这是Modern C++的新坑,还好Effective系列把这些都点明了,文章除了上面说的捕获悬挂引用情况,还有一种情况会导致问题,就是lambda使用当前类外的变量时要异常小心其捕获的this指针,如lambda使用了全局变量,或者lambda所在类以外生命周期比这个类长的变量

#include <iostream>
#include <functional>
#include <vector>
#include <memory>
#include <algorithm>
typedef std::function<void()> FP;

class Point
{
public:
    Point(int x,int y):m_x(x),m_y(y)
    {

    }
    void print()
    {
        s_print_history.push_back([=](){std::cout << "(X:" << m_x << ",Y:" << m_y <<")" << std::endl;});
        std::cout << "(X:" << m_x << ",Y:" << m_y <<")" << std::endl;
    }

    static void print_history()
    {
        std::for_each(s_print_history.begin(),s_print_history.end(),[](FP p){
            if(p)
                p();
        });
    }

private:
    int m_x;
    int m_y;
    typedef std::function<void(void)> FP;
    static std::vector<FP> s_print_history;
};
std::vector<FP> Point::s_print_history = std::vector<FP>();

int main()
{
    std::unique_ptr<Point> p;
    p.reset(new Point(1,1));p->print();
    p.reset(new Point(2,2));p->print();
    p.reset(new Point(3,3));p->print();
    Point::print_history();
    return 0;
}



输出结果

(X:1,Y:1)
(X:2,Y:2)
(X:3,Y:3)
print history:
(X:3,Y:3)
(X:2,Y:2)
(X:3,Y:3)

这个历史输出明显不是正确的结果,这个历史已经是一个未定义的行为,别的编译器输出的结果和我这里编译的结果应该是不一样的,这里就是this的捕获陷阱

s_print_history.push_back([=](){std::cout << "(X:" << m_x << ",Y:" << m_y <<")" << std::endl;});
1
这句lambda通过默认值捕获,其实只是捕获了this指针,在lambda里使用m_x,相当于this->m_x。在this销毁后在调用这个lambda,这时候的this就不知指到哪里了。

由于lambda里有比创建这个lambda的this生命周期更长的变量,一般是引入这个类的其他类型变量或者是静态变量和全局变量,一旦涉及到这三种东西,不建议用lambda,但任性要用,需要做一个中转,上述打印代码应该改为:

void print()
{
    int x = m_x;
    int y = m_y;
    s_print_history.push_back([x,y](){std::cout << "(X:" << x << ",Y:" << y <<")" << std::endl;});
    std::cout << "(X:" << m_x << ",Y:" << m_y <<")" << std::endl;
}



这时,会把x,y通过传值捕获,lambda里没有保存this指针信息,避免隐藏this的影响。

具体建议大家看看这篇文章Effective Modern C++ 条款31 对于lambda表达式,避免使用默认捕获模式

总结
引用捕获陷阱:引用捕获[&]别使用局部变量

this陷阱:lambda里避免有全局变量或静态变量或者比当前类生命周期更长的变量

尽量避免使用复杂的lambda

本文源码请见: 
https://github.com/czyt1988/czyBlog/tree/master/tech/lambda_ref_trap
 

参考:

https://blog.csdn.net/czyt1988/article/details/80149695

https://blog.csdn.net/big_yellow_duck/article/details/52468051

 

  • 5
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在C++中,lambda表达式可以用于创建匿名函数对象。与普通函数不同,lambda函数可以捕获其所在作用域中的变量,并在函数体内使用这些变量。然而,当lambda函数被以引用方式传递给其他函数时,可能会发生this陷阱。 this指针在C++类中用于访问当前对象的成员变量和成员函数。当lambda函数被捕获并在类的成员函数中使用时,它会捕获当前对象的this指针。然而,如果lambda函数以引用方式传递给其他函数,并且在其他函数中被调用,lambda函数将继续使用原始的this指针,这可能导致未定义的行为。 为了避免this陷阱,可以使用值捕获或使用std::function来封装lambda函数。通过值捕获,lambda函数会复制当前对象的this指针,而不是引用它。使用std::function可以将lambda函数转换为可调用对象,确保正确地处理this指针。 下面是一个示例代码,展示了this陷阱的问题和解决方法: ```cpp #include <iostream> #include <functional> class MyClass { public: void foo() { auto lambda = [this]() { std::cout << "This is lambda." << std::endl; }; // 错误的传递方式(可能导致this陷阱) // process(lambda); // 使用值捕获避免this陷阱 process([lambda]() { lambda(); }); // 使用std::function封装lambda函数 std::function<void()> func = lambda; process(func); } void process(std::function<void()> func) { func(); } }; int main() { MyClass obj; obj.foo(); return 0; } ``` 在上述示例中,如果我们直接将lambda函数传递给process函数,可能会导致this陷阱。但是,通过使用值捕获或std::function,我们可以避免这个问题,并确保lambda函数在其他函数中正确地使用this指针。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值