深入理解c++中的Lambda表达式

Lambda简介

Lambda表达式最重要的特点就是能够极其方便地创建函数对象。

其实,Lambda表达式能做到的事情,手工都能做到,无非就是多打一些字。

但是,Lambda表达式提供的简洁、易用、功能之强大,真是香啊!

总的来说,Lambda表达式经常用于以下场景:

  • 标准库STL中使用,如std::find_if, std::remove_if, std::count_if等
  • 自定义比较函数的算法,如std::sort, std::nth_element, std::lower_bound等
  • 能够为std::unique_ptr/std::shared_ptr快速创建自定义析构器
  • 临时创建回调函数、接口适配函数供一次性调用

注意点:

  • Lambda是表达式的一种,它是源码组成部分
  • 闭包是Lambda表达式创建的运行期对象,根据捕获方式的不同,它持有数据的副本或引用
  • 闭包类是实例化闭包的类。每个Lambda表达式都会触发编译器生成一个独一无二的闭包类,闭包中的语句为闭包类成员函数的可执行指令
  • Lambda式常用于创建闭包并仅将其用作传递给函数的实参
  • 闭包可以复制,对应于一个Lambda表达式的闭包类型可以有多个闭包,如下:
int x;

auto c1 = [x](int y){ return x * y > 34; };

auto c2 = c1; // c2是c1的副本
auto c3 = c2; // c3是c2的副本
// c1,c2,c3都是同一Lambda式产生的闭包的副本

本文不再介绍基本使用,而关注使用经验,如默认捕获可能带来的问题、有哪些好的实践及与std::bind的对比等,关于Lambda表达式的基础知识,请参考 c++中的Lambda表达式

避免默认捕获

先说结论,按引用或按值的默认捕获都可以导致使用空悬的引用,导致程序未定义的行为!

  1. 按引用默认捕获

按引用捕获会导致闭包包含对局部变量或定义Lambda式的作用域内(即定义Lambda式的那个函数)的形参的引用。

一旦闭包越过了该局部变量或形参的作用域,它们就会析构,闭包内对它们的引用就会空悬。

举例来说,如下代码:

// 一个添加除数筛选器的函数
void addDivisorFilter()
{
	auto divisor = calcDivisor(); // 通过某种方式计算得到除数

	// 把函数对象添加到vector中,vecotr的原型为:std::vector<std::function<bool(int)>>
	filters.emplace_back(
		[&](int value) {return value % divisor == 0;} // 局部变量按引用默认捕获
		);
}

注意,默认捕获了对局部变量divisor的引用,当函数走到右大括号时,局部变量析构,而筛选器对函数对象还在傻傻地运行着,但引用已经空悬!

其实,把引用默认捕获修改为 &divisor这样显式捕获时,依然还会存在那个问题。但这样大概能够直接看到这个局部变量在函数返回时就拜拜了,更容易查找问题。

那要怎么办呢,其实也简单,针对本例而言,把按引用的捕获修改为按值的捕获就行了。传入了闭包局部变量的副本,局部变量析构时对它不会有影响。

  1. 按值的默认捕获

但按值捕获也非万能之策。

考虑如下代码:

class Foo
{
public:
	//...
	void addFilter() const;
	
private:
	int divisor;
};

void Foo::addFilter() const
{
	filters.emplace_back(
		[=](int value) { return value % divisor == 0; } // 按值的默认捕获,能通过编译,但是去掉=号或者直接写divisor捕获,则编译错误
	);
}

如果有这个疑问:divisor并不是在创建Lambda式的作用域内可见的非静态局部变量或形参,它是怎么通过编译的?就要了解一下this裸指针的隐式应用了。

类内的每一个非静态成员函数都持有一个this,每当使用类的成员变量时,实际上使用的是 this->localVarName

所以在成员函数内按值默认捕获,就捕获了this。使用的也是 this->divisor,而不是单个变量。那么,既然是这样,this的生命期就重要了,它可不能比闭包短哦。

但下面的代码就没有那么幸运了:

void doSomework()
{
	auto pf = std::make_unique<Foo>(); // 使用智能指针,以自动管理资源,注意,需要c++14
	pf->addFilter(); // 使用addFilter函数,添加筛选器函数对象,它会捕获this
}

如注释所言,但是当该函数结束时,pf也就要销毁了,this将不复存在,啊,filters中就含有了空悬指针的元素。只能加班调试了。

怎么解决这个问题呢?

简单的方法是,在addFilter函数中先创建一个divisor的局部变量,再按值捕获即可。这规避了捕获this的问题。

c++14中有更好更直接的方法,就是广义Lambda捕获。如下:

// c++14
void Foo::addFilter() const
{
	filters.emplace_back(
		[divisor=divisor](int value) { return value % divisor == 0; } // 广义Lambda捕获,把等号右侧的变量赋值给左侧变量,左侧变量作为闭包的参数,不会空悬
	);
}

完美!

注意,虽然Lambda表达式只能捕获创建Lambda式的作用域内可见的非静态局部变量或形参,但Lambda式内依然可以使用静态存储期对象。按值捕获可能会产生误解。

如下代码改自上述示例:

// 一个添加除数筛选器的函数
void addDivisorFilter()
{
	static auto divisor = calcDivisor(); // 现在为static

	// 把函数对象添加到vector中,vecotr的原型为:std::vector<std::function<bool(int)>>
	filters.emplace_back(
		[=](int value) {return value % divisor == 0;} // 局部变量按值默认捕获,实际上未捕获任何变量,因为divisor是static的
		);
		
	++divisor;
}

你以为divisor是按值捕获,所以闭包内是它的副本,它永远正确不会改变。

但你以为的是你以为的,不是我以为的。你只是使用了static的divisor,按值的默认捕获未捕获任何东西!所以注意到最后一行的自增操作,你的程序每运行一次,divisor都会不一样哦!哈哈,又要加班调试了。

所以,尽量不要使用默认捕获模式。

c++14中的初始化捕获

c++14中不再支持默认捕获,而是改为初始化捕获(即前文使用过的广义Lambda捕获)。使用初始化捕获,有以下特点:

  • 指定由Lambda式生成的闭包类中成员变量的名字
  • 指定用以初始化成员变量的表达式
  • 支持移动对象

示例如下:

class Foo
{
public:
	bool isValidated() const;
}

auto func = [pf = std::make_unique_ptr<Foo>()] {return pf->isValidated();}; // 初始化捕获支持表达式

其中,捕获语句中:

  • 等号左侧为闭包类的成员变量的名字,作用域为闭包类的作用域
  • 等号右侧为初始化表达式,作用域与Lambda表达式定义处的作用域相同

与使用c11的Lambda式经常出现的问题相比,c14无疑是更好的选择。

优先选用Lambda式而非std::bind

两者对比:

  • Lambda式可读性高,编写简单,代码量少,bind会出现类似_1占位符(占位符难以理解,其具体类型需要查看原函数声明才能得知)
  • 对于需要调用重载函数时,Lambda式不会有歧义,当重载函数更改后能自动适配,bind需要通过强制类型转换到函数指针再使用
  • 由于Lambda式可以被编译器内联,而函数指针不可以,所以使用Lambda有可能生成运行效率更高的代码
  • bind对于实参是按值传递的事实未明显标示,对形参使用引用传递也是如此,使用Lambda式都是显式标识的

使用bind的场合:

  • 移动捕获。c11中未提供移动捕获特性,只能通过结合bind来模拟。
  • 多态函数对象。因为绑定对象的函数调用运算符利用了完美转发,可以接受任意类型的实参。如:
class PolyFoo
{
public:
	template<typename T>
	void operator()(const T& param);
}

PolyFoo pf;
auto boundPF = std::bind(pf, _1); // 可以通过任意类型的实参调用
// boundPf(200);
// boundPf(nullptr);
// boundPf("abc");

其实,上述两种使用场合在c14中已经不复存在:

  • c14默认直接支持移动,直接使用初始化捕获std::move即可
  • 使用auto类型形参的Lambda式,如下:
auto boundPF = [pf](const auto& param) { pf(param); };

总之,在现代化的代码中,尽量Lambda表达式会带来很多好处。

总结

Lambda表达式使用中,会有一些陷阱,需要注意,不要使用默认的捕获。

c14中,使用初始化捕获,不再支持默认捕获,这是一个非常有益的变动,推荐使用。

在c14中,Lambda表达式基本可以取代难懂的bind,并更有可能获得高效的执行代码。

参考资料

《Effective Modern C++》

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值