浅墨浓香

想要天亮进城,就得天黑赶路。

导航

第18课 捕获机制及陷阱

Posted on 2019-08-21 18:46  浅墨浓香  阅读(1740)  评论(2编辑  收藏  举报

一. lambda的捕获方式

(一)3种捕获方式:

  1. 按值捕获:  [=]或[var],前者为按值的默认捕获方式

  2. 按引用捕获:[&]或[&var],前者为按引用的默认捕获方式

  3. 初始化捕获(C++14):  见后面的《广义捕获》及由其引申出来的移动捕获功能。这种捕获方式可以做到C++11中所有捕获方式能够做到的所有事情

(二)默认捕获方式的陷阱[=]和[&]

  1.按引用捕获会导致闭包(由lambda表达式创建的对象)中包含指向局部对象或形参的引用。一旦该闭包超过该局部变量或形参的生命期,那么闭包内的引用就会发生“引用悬空”。当然如果闭包和局部变量/形参的生命期相同,就不会出现这个问题

  2.按值的默认捕获极易受悬空指针影响(尤其是this),并且会让人产生lambda表达式是独立的、不受外界影响的错觉

【编程实验】默认捕获方式的陷阱

#include <iostream>
#include <vector>
#include <memory>
#include <functional>

using namespace std;

using FilterContainer = std::vector < std::function<bool(int)>>; //筛选函数的容器
FilterContainer filters;

//1. 按引用捕获造成的“引用悬空”问题
void addDivisorFilter()
{
    auto divisor = 10; //divisor可以是其它表达式通过运算的结果。

    //将跟这个除数(divisor)相关的过滤函数添加到vector中。
    //1.1 “引用悬空”问题
    filters.emplace_back(
        [&](int value) {return value % divisor == 0;} //由于divisor是个局部变量,被按引用捕获。当addDivisorFilter
                                                      //函数结束后,divisor被销毁。filters中相应的该闭包对象就存在
                                                      //一个绑定到被销毁变量的引用,形为“引用悬空”。由于这里是默
                                                      //认方式的按引用捕获,会捕获到所有的局部变量或形参,当lambda
                                                      //表达式与局部变量的生命期不同时,由于捕获到变量众多,很容易
                                                      //一不小心使用到这些“悬空”的引用。
    ); 

    //1.2 解决方案
    //filters.emplace_back(
    //    [=](int value) {return value % divisor == 0; } //ok, 这里改成按值捕获,则lambda中的divisor是局部变量的副本。
    //);
}

//2. 按值捕获this指针造成的“指针悬空”现象
class Widget
{
    int divisor;
public:
    Widget(int div = 5):divisor(div){}

    void addFilter() const
    {
        //2.1 可能存在空悬指针现象
        filters.emplace_back(
            [=](int value) {return value % divisor == 0; } //注意这里的divisor是成员变量,不能被捕获。
                                                           //当按值捕获时,它是通过this指针来访问的。
                                                           //即divisor的生命期依赖于this所指对象本身。
        );

        //2.2 解决方案:复制divisor的副本到lambda中。
        auto divisorCopy = divisor;

        filters.emplace_back(
            [divisorCopy](int value) {return value % divisorCopy == 0; } //捕获divisor的副本。
        );
    }
};

void doSomeWork()
{
    auto pw = std::make_unique<Widget>(5);
   
    //...     //做些其它事情

    pw->addFilter(); //当doSomeWork函数结束后,由于智能指针会自动释放widget对象。
                     //由于Widget对象的生命期比filters中相应的元素(lambda表达式)生命期短。
                     //因此,filters中就含有一个带有空悬指针的元素。
}

//3. 按值捕获的表达式并不完全独立(lambda可能依赖外部的静态变量)
void ByValDependentcy()
{
    static auto divisor = 10;
    filters.emplace_back(
        [=](int value) {return value % divisor == 0; }  //由于static无法被捕获,lambda是直接使用该
                                                        //变量的。
    );

    ++divisor;  //意外修改了divisor。上述的lambda中的[=],由于按值捕获会给人造成lambda式是独立的错觉。
                //实际上该lambda中是直接使用static变量的,其值会随着ByValDependentcy函数的调用而逐次
                //递增。这与按值默认捕获所暗示的含义直接相矛盾。解决的方案:不要采用按值默认的捕获
                //方案,取而代之的是采用广义捕获(见后面的《广义捕获》内容)
}

int main()
{
    return 0;
}

二. 初始化捕获也称为广义lambda捕获

(一)C++11中捕获机制的局限

  1. lambda捕获的是局部变量或形参,不管是按值还是按引用捕获这些都是具名的左值对象。而右值对象是匿名对象,无法被捕获

  2. 按值捕获时,左值是被复制到闭包中的。如果被捕获的对象是个只移动类型的对象时,因其无法被复制,就会出错。此外,如果被捕获的对象如果是一个占用内存较大的对象时,按值捕获显然效率很低。

(二)初始化捕获(C++14)

  1. 格式:形如[mVar1 = localVar1, mVar2 = std::move(localVar2)](){};

  2. 说明:

  (1)mVar1和mVar2是闭包类的成员变量的名字而位于“=”右侧的则是初始化表达式。

  (2)mVar1和mVar2的作用域就是闭包类的作用域即仅限于闭包类部可用。而“=”右侧的作用域则与该lambda表达式加以定义之处的作用域(即lambda式的父作用域)相同

  (3)初始化捕获使得可以在闭包类中指定成员变量的名字,以及使用表达式来初始化这些成员变量。

(三)利用初始化捕获来实现移动捕获功能

  1. C++14中的移动捕获(通过初始化捕获将对象移入闭包)

  2. C++11中通过类或std::bind模拟移动捕获(std::bind模拟的手法)

  (1)将lambda表达式绑定到std::bind函数对象中,同时将需要捕获的对象移动到std::bind对象中。

  (2)在lambda表达式中通过引用绑定到要“捕获”的对象上。

【编程实验】初始化捕获及移动对象

#include <iostream>
#include <memory>
#include <functional>
#include <vector>

using namespace std;

//1. 通过初始化捕获将对象移动闭包中
class Widget
{
public:
    bool isValidated() const { return true; } //是否有效
    bool isProcessed() const { return true; } 
    bool isArchived() const { return true; }  //是否存档
};

//2. 使用类来模拟移动对象
class IsValAndArch
{
    using DataType = std::unique_ptr<Widget>;
    DataType mPW;

public:

    //移动构造函数
    explicit IsValAndArch(DataType&& ptr):mPW(std::move(ptr))
    {
    }
};

int main()
{
    auto pw = std::make_unique<Widget>(); //创建Widget对象

    //1. 通过初始化捕获将对象移动闭包中
    //1.1 使用std::move将pw这个只移动对象移入闭包中(C++14)
    auto func1 = [mPW = std::move(pw)]{ return mPW->isValidated() && mPW->isArchived(); };
    //1.2 可以在捕获列表中直接用表达式来初始化mPW成员变量。
    auto func2 = [mPW =  std::make_unique<Widget>()]{ return mPW->isValidated() && mPW->isArchived(); };

    //2.使用类模拟将只移动对象移入仿函数中
    auto func3 = IsValAndArch(std::make_unique<Widget>()); //仿函数对象,其中的unique是个只移动对象。

    //3.通过std::bind模拟初始化捕获
    std::vector<double> data;
    //3.1 使用初始化捕获,实现容易
    auto func4 = [data = std::move(data)]{}; //将data对象移入闭包中。
    
    //3.2 std::bind模拟移动对象到lambda中
    //注意事项:
    //(1)bind对象(即func5)会将所有实参的副本保存其中。对于左值会实施复制构造,对于右值会实施移
    //     动构造。因此,bind对象中,保留了第1个实参lambda和第2个实参data的副本,而第2个实参是通过
    //     std::move移动到bind对象中,成为其中的一个成员变量(模拟将data移入绑定对象)
    //(2)当调用func5时,该函数会将上述的data副本作为实参传递给其中的lambda表达式。
    //(3)lambda表达式的形参data是个引用,它是个指向func5对象中的data的左值引用(注意不是右值,因
    //     为在func5中副本本身是一个左值)。因此lambda对data的操作,都会实施在func5对象的data副本身上。
    //     由于lambda的operator()是个const函数,因此其内的成员变量都带有const属性。但bind对象上的data
    //     副本并不带const修饰符。为了防止该data副本在lambda中被修改,将lambda的形参声明为常量引用。
    //(4)由于bind对象存储所有实参的副本,因此bind对象中的lambda表达式也是一个副本,其生命期与bind对象一致。
    auto func5 = std::bind(
                          [](const std::vector<double>& data) {}, //第1个参数:lambda表达式,
                             std::move(data)  //第2个参数,将data移动到bind对象中
                           );

    return 0;
}