谈谈C++的lambda表达式

基本概念

我能想到的在 C++ 中有 3 种调用代码段的方法:

  1. 函数指针,通过函数指针可以将一段代码作为函数参数传递。
  2. 仿函数(重载了函数运算符的对象)。
  3. Lambda 表达式。

函数指针就不说了,是 C 语言中十分常用的方式,许多系统级 API 都使用这种形式进行代码段的传递。仿函数类似于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Functor {
public:
Functor(doubel d) : div(d) {}
~Functor() = default;
double operator()(double a, double b) {
return (a + b) / div;
}
private:
double div;
}

Functor f(2);
f(12, 4); // 可调用对象

定义了一个类,并对其函数调用运算符进行了重写,这样可以通过对象()的形式进行调用。

使用 Lambda 表达式实现上面功能就十分简洁:

1
2
3
div = 2;
auto lamb = [div](double a, double b) { return (a + b) / div; }
lamb(12, 4);

Lambda 表达式使编译器创建一个闭包,闭包是运行期对象,根据不同的捕获方式,闭包持有数据的副本或者引用。实际上就是编译器创建了一个上面写的仿函数对象,将繁杂的工作交给了编译器,同时使得我们的代码变得简洁了。

这里不讨论语法(实际上也很简单),读者可以查阅其它资料了解 Lambda 表达式的值捕获、引用捕获和默认捕获等概念,这里讨论一下使用时的注意点,都是在《Effective Modern C++》中提到的条款。

避免默认捕获

C++ 11 提供了值捕获和引用捕获两种默认捕获方式,分别是在捕获表达式中采用=&符号,按照引用捕获非常容易出现悬空引用的后果,使用按照值捕获可能也会出现类似问题。

  • 默认引用捕获
1
2
3
4
5
6
7
8
9
10
void addDivisorFilter() {
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);

// 引用捕获,危险 !!
filters.emplace_back([&](int value) {
return value % divisor == 0;
})
}

上面是按照默认默认引用捕获的方式,引用捕获了divisor变量,但是divisor是一个局部变量,离开addDivisorFilter函数作用域之后被销毁,这样 Lambda 表达式闭包中持有了一个悬空引用,在调用 Lambda 表达式时将发生未定义行为!

实际上改为显式的引用捕获也存在同样的问题:

  • 显式引用捕获
1
2
3
filters.emplace_back([&divisor](int value) {
return value % divisor == 0;
})

没有解决上面的引用悬空问题,但是却能够清楚的看出该闭包是依赖于divisor变量的引用的,程序员在编写时更不容易犯错,相比于[&]要直观的多。

解决这个问题的方法就是采用值捕获:

1
2
3
4
// 值捕获,将局部变量 divisor 复制一份进入闭包
filters.emplace_back([divisor](int value) {
return value % divisor == 0;
})

那么使用默认值捕获是不是就安全了呢?答案是否定的。考虑下面例子:

  • 默认值捕获
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
public:
void addFilter() const;

private:
int divisor;
}

void Widget::addFileter() const {
filters.emplace_back([=](int value) {
return value % divisor == 0;
})
}

这个例子使用了默认值捕获,然而闭包中并不会持有一个 divisor 变量的复制,因为捕获只能对于在创建 Lambda 表达式的作用域内可见的非静态局部变量生效,也就是说这里根本没有捕获divisor变量,换句话说,如果使用显式捕获的方式根本无法通过编译:

1
2
3
4
// 无法通过编译
filters.emplace_back([divisor](int value) {
return value % divisor == 0;
})

实际上,上面的默认值捕获捕获的并不是divisor变量,而是Widget对象的this指针,等同于下面代码:

1
2
3
4
auto ptr = this;
filters.emplace_back([ptr](int value) {
return value % ptr->divisor == 0;
})

这样导致闭包中含有一个指向 Widget 对象的指针,从而存在悬空的危险。因此使用显式捕获就不会有这个问题,如果想把成员变量复制进闭包中,可以先定义一个局部变量,然后按值捕获来解决:

1
2
3
4
auto div = divisor;
filters.emplace_back([div](int value) {
return value % div == 0;
})

在 C++ 14 中,可以广义捕获(generalized lambda capture):

1
2
3
filters.emplace_back([divisor=divisor](int value) {
return value % divisor == 0;
})

但不管怎么说,你都不应该使用默认值捕获。下面是使用默认值捕获得到意料之外结果的例子:

1
2
3
4
5
6
7
8
9
10
11
void addDivisorFilter() {
static auto calc1 = computeSomeValue1();
static auto calc2 = computeSomeValue2();
static auto divisor = computeDivisor(calc1, calc2);

filters.emplace_back([=](int value) {
return value % divisor == 0;
})

++divisor; // 这里修改了 divisor
}

上面咋一看divisor变量通过默认值捕获被复制进了闭包中,但实际上捕获对静态变量捕获是无效的,上面的默认捕获没有捕获任何东西,而闭包内部使用的divisor变量就是外部的静态变量,因此改变这个变量会对 Lambda 表达式造成影响。

总之,使用默认捕获方式(无论是按值捕获还是按引用捕获)都有可能以你意料之外的方式工作,因此应该避免使用默认捕获方式。

使用 Lambda 表达式代替 std::bind

在 C++ 11 之前的代码中,经常能够看到使用 std::bind 来返回一个函数对象,std::bind 参数为一个函数对象和若干参数及占为符,能够对传入函数对象进行调用形式改造。

这里也是举一个常见用法的例子:

1
2
3
4
5
6
7
8
9
10
11
class Channel : noncopyable{
public:
typedef std::function<void(Timestamp )> ReadEventCallback;

Channel(EventLoop* loop, int fd);
~Channel() = default;

void handleEvent(Timestamp receiveTime);
void setReadCallback(const ReadEventCallback& cb) { readCallback_ = cb; }
...
}

另一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std;
using namespace std::placeholders;

class EventLoop : noncopyable {
public:
EventLoop() : channel_(std::make_unique(...)) {
channel_->setReadCallback(bind(&EventLoop::handleRead, this, _1))
}

private:
void handleRead(Timestamp t);
std::unique_ptr<Channel> channel_;
}

需要的函数是只有一个形参的void handleEvent(Timestamp t)的形式,而我们已有的函数是EventLoop类的成员函数,这个函数具有一个隐含的EventLoop*类型的this指针参数,为此使用std::bind进行改造:

1
bind(&EventLoop::handleRead, this, _1);  // 返回形式就是只有一个参数的可调用对象

即使是比较有经验的程序员也需要对_1,_2这种占为符和实际参数的映射关系做一定的分析。改用 Lambda 表达式可以更加清楚简洁的实现上述效果:

1
2
3
channel_->setReadCallback([this](Timestamp t){
handleRead(t);
})

除此之外,使用std::bind时,将this参数传入时,其生成的可调用对象需要对其进行保存,从std::bind调用上来看看不出来是按引用还是按值复制的,只能按照std::bind的原理知道是按值复制存储的,也就是相当于 Lambda 表达式的按值捕获了 this 变量。

同样,std::bind返回的可调用对象中,参数的传递方式是按值还是引用呢?也不能从代码中看出来,答案是按照引用传递,其函数调用运算符采用的完美转发。

相反,使用 Lambda 表达式很容易看出this变量是按值复制保存的(值捕获),返回的可调用对象参数传递方式也是显然的(按值传递)。

所以经过上述讨论,使用 Lambda 表达式相比于 std::bind 而言,可读性和表达能力更强,没有什么理由可以使用后者了(除非所在的工作小组 C++ 标准低于 C++ 14 或 C++ 11)。

总结

本篇博客讨论了 C++ 中的 Lambda 表达式,使用时需要注意区分不同的参数捕获方式,以及避免使用默认捕获方式,尽量使用 Lambda 表达式代替古老的 std::bind 写法,使用现代的一些工具写出更具可读性的代码。

参考文献

  1. 《Effective Modern C++》