第六章 lambda表达式

lambda表达式是表达式的一种,它是源代码的组成部分。

std::find_if(container.begin(), contianer.end(), [](int val){ return 0 < val; });

闭包是lambda表达式创建的运行期对象,根据不同的捕获模式,闭包会持有数据的副本或者引用。

闭包类就是实例化闭包的类,每个lambda式都会出发编译器生成一个独一无二的闭包类,而闭包类中的语句会变成它的闭包类成员函数的可执行指令。

关于lambda表达式在第二章做过探讨,这里简单再介绍以下lambda表达式的实现原理。

编译器会将lambda表达式生成一个匿名类的匿名对象,并在类中重载函数调用运算符。

也就是说,lambda表达式是被编译器变成了一个函数对象。并且是const属性的。这就导致了当lambda表达式按值捕获变量的时候,内部无法更改这个值(因为成员函数有const属性),想要更改值必须增加mutable。

简单的举个例子:

void testf(){
    int a = 10;
    int b = 20;
    int c = 30;
    auto testlf = [&, b]() {c *= 10; a *= 10; };
    //auto testlf = [&, b]() mutable {c *= 10; a *= 10; b *= 10; }; //增加mutable才能更改捕获到的b的值(不影响原来b的值)
    testlf();
    cout << a << ' ' << b << ' ' << c << endl;
}

在编译器内部该lambda表达式是以函数对象的形式存在:

class testlf_class
{
private:
    int &a;
    //mutable int b;    //对应增加了mutable属性的lambda
    int b;
    int &c;
public:
    testlf_class(int &_a, int _b, int &_c) :a(_a), b(_b), c(_c){ }
    void operator()() const
    {
        c *= 10;
        a *= 10;
    }
};

三十一 避免默认捕获模式

C++11有两种默认捕获模式:按引用或按值,按引用的默认捕获模式可能导致空悬引用。但是按值捕获有有可能会存在空悬指针(类内部按照=捕获的时候,捕获到的this指针可能在类析构后空悬,或者直接是按值捕获指针的话,指针所指向的内容可能在lambda表达式的生存期内被析构)。

按引用捕获会导致闭包包含指涉到局部变量的引用,或者指涉到定义lambda式的作用域内的形参的引用,一旦由lambda所创建的闭包越过了该局部变量或者形参的生命期,那么闭包内的引用就会空悬。所以最好是显式指明lambda表达式要捕获的对象

C++14中增加了泛型lambda,这种泛型lambda传入的参数都可以用auto来指定,而不是具体的型别。这样的lambda表达式相当于带有模板参数的函数对象。或者说相当于带有状态的函数模板。

例子如下:

auto lf = [](auto a, auto b) { return a * b; };
template<typename T, typename U>
class lf_class  //等价于lf表达式
{
public:
    //c++11中需要使用拖尾类型来表示返回类型
    //auto operator()(T x, U y)->decltype(x * y)
    auto operator()(T x, U y) 
    {
        return x * y;
    }
};
int main()
{
    auto ret = lf(3, 5);    //15
    cout << ret << endl;
    auto ret2 = lf(2.2, 3.3);   //7.26
    cout << ret2 << endl;
    auto ret3 = lf_class<int, int>()(3, 5); //15
    cout << ret3 << endl;
    auto ret4 = lf_class<double, double>()(2.2, 3.3);   //7.26
    cout << ret4 << endl;

    return 0;
}

刚才说过,类内的this指针在按值捕获的时候可能会导致空悬,具体代码如下:

vector<function<bool(int)>> Filter; //存放函数的数组
class A
{
public:
    A(int _d) : d(_d) {}
    ~A() = default;
    void addFilter() const;
private:
    int d;
};
void A::addFilter() const
{
    Filter.emplace_back([=](int v) { return v % d == 0; }); //将这个lambda表达式放入数组中
}
int main()
{
    A a(3);
    a.addFilter();
    a.~A();    //超过lambda表达式所捕获的变量d的生存期
    auto f = Filter.front();
    cout << f(12) << endl;  //运算结果可能错误

    return 0;
}

首先要说的是,在类的成员函数里面,是没有d这个变量的,类的成员函数和对象有关的就只有一个隐式传入的this指针,也就是通过这个this指针,我们才得以访问类内的私有成员变量。所以类的成员函数中的lambda表达式,无法是下面这个样子:

Filter.emplace_back([d](int v) { return v % d == 0; });

所以按值捕获,其实捕获到的是this指针,并且是通过this指针来访问d的。也就是说,lambda表达式其实是以下这样:

Filter.emplace_back([this](int v) { return v % this->d == 0; });

在C++11中,我们对以上问题的解决方法就是创造局部变量并按值捕获这个局部变量,这样便不会产生空悬的指针了。

void A::addFilter() const
{
    int tmp = d;
    Filter.emplace_back([=](int v) { return v % tmp == 0; });
}

在C++14中,还有一种新式的lambda表达式,叫做广义lambda捕获。在这个广义lambda捕获中,可以对捕获列表的捕获变量“赋值“,如以下代码:

int tmp = 3;
auto tf = [tmp = 2 * tmp](int v) {return v + tmp; };
cout << tf(10) << endl; //16

在上述代码中,lambda表达式先是捕获了tmp变量,然后将其进行运算,并赋值给另一个tmp变量。很明显,这两个tmp不是一个变量,右边的tmp类型为int,值为3;而赋值运算符=左边的tmp是一个新定义的变量,这个变量的作用域是Lambda体,类型由=右边的表达式推断出来为int,值等于=右边的值*2。(一行代码中,同一个标识符,指代不同的变量)

C++14中的这个新特性允许了在捕获列表中定义前面没有出现过的变量,但必须赋予一个值,并且不使用类型说明符和auto,类型由编译器自动推断,编译器根据捕获列表中的变量是否被赋值区分捕获变量和新定义的变量。(如果是tmp,就代表是lambda外界的tmp,如果是tmp = 2 * tmp,就代表这个tmp是外界运算过的tmp)

所以,通过这个广义lambda捕获,我们可以获得安全的按值捕获:

Filter.emplace_back([d = d](int v) { return v % d == 0; });

lambda表达式对于静态存储期的对象(这样的对象处于全局或者命名空间作用域内,又或者在类中、函数中以static声明),无法捕获,但是可以在内部使用。也就是说如果函数内部恰好有局部变量和全局变量重名并且此时正好希望捕获全局变量的话,就会错误的捕捉到局部变量(并且由于局部变量的存在,编译器也不会报错)。

三十二 使用初始化捕获将对象移入闭包

对于上一节说的广义lambda捕获,其实还有更多的应用。使用这种初始化捕获,可以指定由lambda生成的闭包类中成员变量的名字,以及用一个表达式初始化该变量。

首先,可以通过初始化捕获,将只移对象传入到lambda闭包中。如unique_ptr。

unique_ptr<int> up = make_unique<int>(10);
auto lf = [up = move(up)](){ cout << *up << endl; };
lf();

对于初始化捕获来说,位于”=“左侧的,是你指定的闭包类的成员变量的名字,位于右侧的是其初始化表达式。而且,”=“左右两侧储于不同的作用域,左侧作用域就是闭包类的额作用域,右侧作用域则与lambda式加以定义之处的作用域相同。

up = move(up)表示的意思是:在闭包中创建一个成员变量up,然后针对局部变量up实施move的结果来初始化该成员变量。

如果想使用函数对象来模仿广义lambda捕获的话,需要书写更多的代码。

class lf_class
{
public:
    using datatype = unique_ptr<int>;
    explicit lf_class(datatype &&d) : up(move(d)) {}
    void operator()() const
    {
        cout << *up << endl;
    }
private:
    datatype up;
};
unique_ptr<int> up2 = make_unique<int>(10);
lf_class(move(up2))();

如果使用lambda式,按移动捕获在C++中可以采用以下方法模拟。

  • 把需要移动的对象移动到bind产生的函数对象中
  • 给到lambda式一个指涉到欲”捕获“的对象的引用

同样功能的代码对比:

//lambda表达式(c++14)
vector<double> data;
auto func = [data = move(data)]{...};   //将data移动到lambda闭包类中的data成员变量中
//bind+lambda(c++11)
auto func = bind([](const vector<double>& data){...}, move(data));

bind也会生成函数对象,bind返回的函数对象一般可称为绑定对象。bind的第一个实参是可调用对象,接下来的所有实参代表传给该对象的值。

绑定对象内部含有传递给bind所有实参的副本,对于每一个左值实参,在绑定对象内的对应的对象内,对其实施的是复制构造。对于右值实参则是移动构造。而该移动构造则是模拟移动捕获的核心所在,因为把右值移入到绑定对象,可以弥补C++11无法通过右值绑定到闭包的缺陷。

形参为const vector<double>& data的原因是,首先按引用传入的成员变量不带有const饰词,为了防止引用data在lambda表达式内被更改,要加入const饰词。

而如果lambda表达式带有mutable,则不需要const饰词。因为带有mutable的lambda表达式需要更改按值捕获来的成员变量的值。

在C++11中:

  • 移动构造一个对象入C++11闭包是不可能实现的,但是移动构造一个对象入绑定对象则是可能实现的。
  • 欲在C++11中模拟移动捕获包括以下步骤,先移动构造一个对象入绑定对象(bind实现),然后按引用把该移动构造所得的对象传递给lambda式。
  • 因为绑定对象的生命期和闭包相同,所以针对绑定对象中的对象和闭包里的对象可以采用同样书法加以处置。

三十三 对auto&&型别的形参使用的decltype,以std::forward之

之前介绍过的泛型lambda,即在lambda表达式的形参中使用auto,实现原理就是在对应闭包类中用模板实现。

如果在lambda之内采用了完美转发,并且也使用了泛型lambda,那么对于完美转发的参数填写来说,将会是一个问题。代码如下:

auto lf = [](auto&& x) {return func(forward<???>(x)); };

上述的???应该填入什么呢?答案就是decltype。首先对于decltype,如果传入左值x,decltype(x)将会产生左值引用型别;如果传入右值x,decltype(x)将会产生右值引用型别。

当需要转发左值的时候,应该传入左值引用,这时,edcltype(x)可以很好的完成任务。但是对于惯例来说,当需要完美转发右值引用的时候,forward内部传入的应该是一个非引用类型,而不是右值引用。

这里就要提到引用折叠了。首先再次列出C++14中forward的实现代码:

template<typename T>
T && forward(remove_reference_t<T>& param)
{
    return static_cast<T&&>(param); //int& &&引用折叠后是int&
}

当传入左值引用的forward,模板参数T被推导为int&,最终推导出来的结果也是左值引用:

int& && forward(int& param)
{
    return static_cast<int& &&>(param); //int& &&引用折叠后是int&
}

当传入非引用的forward,模板参数被推导为int,最终推导出来的结果是右值引用:

int&& forward(int& param)
{
    return static_cast<int&&>(param);
}

但是当传入右值引用的时候,参数模板就会被推导为int&&,这时候推导的结果是什么呢?

int&& && forward(int& param)
{
    return static_cast<int&& &&>(param);    //int&& &&引用折叠后是int&&
}

显然还是右值引用,这就说明传入右值引用和非引用得到的结果相同。

综上所述,通过decltype可以得到正确的转发类型。

具体代码如下:(非常简洁精炼的代码)

int func(int x)
{
    cout << "this is " << x << endl;
    return x;
}
auto lf = [](auto&& x) {return func(forward<decltype(x)>(x)); };
int main()
{
    lf(3);

    return 0;
}

三十四 优先选用lambda式,而非std::bind

首先介绍以下std::bind,bind函数是C++98中std::bind1st和std::bind2nd的后继特性。bind用来将可调用对象与其参数一起进行绑定成为一个函数对象(可指定绑定部分对象)。绑定后可以使用std::function进行保存。

bind的思想实际上是一种延迟计算的思想,将可调用对象保存起来,然后在需要的时候再调用。

在绑定参数的时候,使用std::placeholders来决定空位参数将会是第几个参数。

bind函数定义于头文件\中。

//函数模板 bind 生成f的转发调用包装器。调用此包装器等价于以一些绑定到args的参数调用f
template<class F, class... Args>
bind(F&& f, Args&&... args);
template <class R, class F, class... Args>  //R为返回值类型
bind (Fn&& fn, Args&&... args);

参数f代表可调用的对象包括函数对象、指向函数指针、函数引用、指向成员函数指针或指向数据成员指针。args是要绑定的参数列表,未绑定参数为placeholders的占位符_1、_2、_3所替换。

首先用一段代码来介绍bind的用法。

int func(int &a, int &b)
{
    cout << a++ << ' ' << b++ << endl;
    a++;
    return a * b;
}
int main()
{
    int x = 3, y = 5;
    auto f = bind(func, ref(x), ref(y));    //按引用传入
    //auto f = bind(func, x, y);    //按值传入
    f();

    auto f_1 = bind(func, placeholders::_1, ref(y));    //第一个参数暂时不绑定,使用占位符先代替
    //auto f_1 = bind(func, placeholders::_1, x);
    f_1(x);

    return 0;
}

首先func是按引用传值,所以对于传入的值来说,函数内部发生了改变,则原来的值也会改变。但是当我们以auto f = bind(func, x, y);来调用的时候,我们发现,传入的值在f()执行后并没有被改变。原因就是f()在绑定参数的时候是按值传入的。如果想要按引用传入,则可以使用std::ref函数。

auto f_1 = bind(func, placeholders::_1, y);方式调用的时候,占位符须在调用时刻传入,并且placeholders传值是通过引用传入(因为此种对象的函数调用运算符利用了完美转发)。也就是说,现在f_1在调用的时候,如果传入x,则事实上是以引用方式传入x,即func(x, y);中的x是通过占位符传入的(按引用传入),y是事先绑定好的(按值传入)。

并且bind还可以绑定到其他的函数类型上。

class A
{
public:
    int func3(int a, int b) {   //成员函数
        cout << a << ' ' << b << endl;
        return a * b;
    }
};
class B
{
    int operator()(int a, int b) {  //函数对象
        cout << a << ' ' << b << endl;
        return a * b;
    }
};

int func2(int a, int b) //普通函数
{
    cout << a << ' ' << b << endl;
    return a * b;
}

auto lf = [](int a, int b) { cout << a << ' ' << b << endl; return a * b; };    //lambda表达式
int main()
{
    int x = 3, y = 5;
    A aa;
    B bb;
    auto f1 = bind(func2, x, y);    //绑定普通函数指针
    auto f2 = bind(aa, &A::func3, x, y);    //绑定成员函数,需要类的实例
    auto f3 = bind(lf, x, y);   //绑定lambda表达式
    auto f4 = bind(bb, &B(), x, y); //绑定函数对象,也需要类的实例

    return 0;
}

但是本节要说的内容是优先选用lambda表达式,而非bind函数。

最主要的原因就是lambda具有更高的可读性。代码如下:

#include <iostream>
#include <chrono>
#include <functional>
using namespace std;
using namespace chrono;
using namespace literals;   //C++14标准时间后缀
using Time = steady_clock::time_point;  //表示时刻
enum class Sound{ beep, siren, whistle };
using Duration = steady_clock::duration;    //表示时长
void setAlarm(Time t, Sound s, Duration d)  //测试函数
{
    Time t1 = steady_clock::now();  //当前时间
    if (t > t1) cout << duration_cast<microseconds>(t - t1).count() * microseconds::period::num / microseconds::period::den << endl;    //时间间隔
    int tmp = static_cast<int>(d.count() * microseconds::period::num / microseconds::period::den);
    while (tmp-- > 0) {
        cout << static_cast<int>(s) << endl;
    }
}
auto setSoundL = [](Sound s) {  //lambda表达式
    setAlarm(steady_clock::now() + 3s, s, 10ms);
};
//bind
//C++14中,标准运算符模板的模板型别实参大多数情况下可以省略不写
auto setSoundB = bind(setAlarm, bind(plus<>(), steady_clock::now(), 3s), placeholders::_1, 10ms);
//C++11写法
//auto setSoundB = bind(setAlarm, bind(plus<steady_clock::time_point>(), steady_clock::now(), 3s), placeholders::_1, 10ms);
int main()
{
    setSoundL(Sound::siren);
    setSoundB(Sound::beep);

    return 0;
}

在可读性上,lambda表达式显然更强。并且在上述代码中,由lambda表达式可知,now()函数的计算时间是调用setAlarm的时候,而bind函数是在调用bind的时候,没有lambda准确。

而如果出现setAlarm的重载函数的时候,问题就出现了。因为lambda表达式可以正确的调用重载的函数,但是bind却无法正确的找到用哪一个setAlarm函数。

如果想让bind找到哪一个setAlarm,可以通过函数指针强转。

using setAlarmp = void(*)(Time t, Sound s, Duration d);
auto setSoundB = static_cast<setAlarmp>(setAlarm), bind(plus<>(), steady_clock::now(), 3s),  placeholder::_1, 30s);

而如果是较多问题的要求的话,这个时候lambda表达式的好处就更明显了,接下来是挑出数组中大于0小于10的元素个数的代码。

#include <iostream>
#include <vector>
#include <functional>
using namespace std;
vector<int> v{1,2,4,5,6,7,8,0,8,2,34,5,6,8,3,42,53,624,62,46,2};
int main()
{
    int lowval = 0, highval = 10;
    //c++14可以使用泛型lambda
    auto fl = [lowval, highval](const auto& x) {return x >= lowval && x <= highval; };
    //c++11可以不写尖括号中的比较型别
    auto fb = bind(logical_and<>(), bind(greater_equal<>(), placeholders::_1, lowval), bind(less_equal<>(), placeholders::_1, highval));
    for (auto it : v)
        if (fl(it)) cout << it << ' ';  //1 2 4 5 6 7 8 0 8 2 5 6 8 3 2
    cout << endl;
    for (auto it : v)
        if (fb(it)) cout << it << ' ';
    cout << endl;

    return 0;
}

很明显,使用lambda表达式将会更加方便和简洁。而使用bind表达式将会比较复杂。

在C++11中,lambda表达式不支持泛型,也不支持广义lambda捕获,在这两个方面,bind都是支持的,一方面bind的占位符是按引用传递的,另一方面,bind也支持对模板参数的绑定。故可以使用bind绑定函数进行。而在C++14中,lambda对上述条件都支持了,所以在C++14中,lambda可以完全替代bind。

猜你喜欢

转载自blog.csdn.net/u012630961/article/details/81057489