玩转 C++ lambda

lambda 是 c++11 引入,c++14 完善,是最常用的特性之一。本文是小组 C++ 学习分享系列之 - lambda 表达式,内容主要从 lambda 解决的问题、用法、实现、对比、总结这几方面来讲述。

一、lambda 解决的问题

find_if 是算法库中用于指定区域查找符合要求第一个元素,函数声明:InputIterator find_if (InputIterator first, InputIterator last, UnaryPredicate pred)。其中,first 和 last 是输入迭代器,其组合 [first, last) 用于指定要查找的区域;pred 是接收一个参数的表达式。

1
2
3
4
5
6
7
8
>find_if(_InputIterator __first, _InputIterator __last, _Predicate __pred)
>{
> for (; __first != __last; ++__first)
> if (__pred(*__first))
> break;
> return __first;
>}
>

现在我们查找输入序列{"a", "bbb", "cc", "d", "ee", "fff"} 中长度大于的1的第一个元素,可以用算法库中的 find_if 来实现一下:

1
2
3
4
5
6
7
bool isOverOneLength(const string& s) {
return s.size() > 1;
}

void bigs(vector<string>& words) {
auto words_over_sz = find_if(words.begin(), words.end(), isOverOneLength);
}

find_if 的第三个参数是一元谓词,即只接收一个参数的表达式 func(args),为此我们实现一个函数,并返回长度是否大于1。

对于需要寻找长度大于 1 的第一个元素,也会需要寻找长度大于 2 的第一个元素,此时的方式是再写一个函数isOverTwoLength ,这种情况下,是否有用完即扔的代码表达式,无需像函数那样需要提前定义或声明

一般来说,我们可以把长度抽象到函数的参数上,避免硬编码来实现函数:

1
2
3
void biggies(vector<string>& words, vector<string>::size_type sz) {
auto words_over_sz = find_if(words.begin(), words.end(), isOverOneLength);
}

但对于 find_if 的第三个参数是严格的一元谓词,实现的函数 isOverOneLength 已经接收了迭代过程中的元素,不可能再接收局部作用域的 sz 长度参数。为解决这个问题,就需要使用一些新的表达式,如 lambda 表达式。

二、lambda 用法

lambda 表达式表示一个可调用的代码单元,可以理解为一个未命名的内联函数。一个 lambda 表达式有如下形式:

1
[capture list] (params list) -> return type { function body }
  • [capture list] :捕获列表,能够捕获局部作用域的变量(包含形参)以供lambda函数使用。
  • (parameters) :参数列表,与函数的参数列表一致,若不需要参数传递,可同括号一起省略。
  • ->return-type :返回类型。编译器会对返回类型自动推导,可以省略该部分。
  • {function body} :函数体,跟函数一致。除了可以使用的形参之外,还可以使用所有捕获的变量,和非局部的变量。

现在尝试使用 lambda 表达式来构造接收局部作用域 sz 长度参数来查找第一个元素:

1
2
3
4
5
void biggies(vector<string>& words, vector<string>::size_type sz) {
auto words_over_sz = find_if(words.begin(), words.end(), [sz](const string& s) {
return s.size() > sz;
});
}

lambda 表达式捕获 sz 变量,在函数体判断长度迭代的元素长度是否大于 sz,其中返回类型 bool 由编译器自动判断:

1
2
3
auto f = [sz](const string& s) {
return s.size() > sz;
});

显示捕获

值捕获

值捕获需要变量是可以拷贝的,需要注意的是,被捕获的变量的值是在 lambda 创建时拷贝,而不是调用时拷贝

1
2
3
4
5
6
7
8
9
void testLambdaValue() {
size_t v1 = 42; // 局部变量
auto f = [v1]() { // 将 v1 拷贝到名为 f 的可调用对象
return v1;
};
v1 = 0;
auto j = f();
cout << "testLambdaValue:" << j << endl; // j 为 42,f 保存了创建它时 v1的拷贝
}

由于被捕获变量的值时在 lambda 创建时拷贝的,因此之后的修改不会影响到 lambda 内对应的值。

引用捕获

使用引用捕获时,在 lambda 函数体使用的是引用所绑定的对象:

1
2
3
4
5
6
7
8
9
void testLambdaRef() {
size_t v1 = 42; // 局部变量
auto f = [&v1]() { // 对象 f 包含 v1 的引用
return v1;
};
v1 = 0;
auto j = f();
cout << "testLambdaValue:" << j << endl; // j 为0,f2 保存 v1 的引用,而非拷贝
}

使用引用捕获时,必须确保被被引用的对象在 lambda 执行的时候时存在的。lambda 捕获的都是局部变量,这些变量在函数结束后就不复存在,如果 lambda 在函数结束后执行,则捕获的引用指向的局部变量已经消失,造成悬垂指针

隐式捕获

除了显示列出希望使用来自所在函数的变量外,还可以让编译器根据 lambda 体中的代码,推断捕我们要使用的变量。此时使用 & 或 = 来告诉编译起采用的捕获方式,例如以下使用 = 来默认捕获 sz:

1
2
3
4
5
void biggies(vector<string>& words, vector<string>::size_type sz) {
auto words_over_sz = find_if(words.begin(), words.end(), [=](const string& s) {
return s.size() > sz;
});
}

如果希望对一部分变量采用值捕获,对其他变量采用引用捕获,可以混合使用隐式和显示捕获:

1
2
3
4
5
6
7
8
9
10
void biggies(vector<string>& words, ostream &os = cout, string str = "|") {
// os 隐式捕获, str 显示捕获
for_each(words.begin(), words.end(), [&, str](const string &s) {
os << s << str;
});
// os 显示捕获, str 隐式捕获
for_each(words.begin(), words.end(), [=, &os](const string &s) {
os << s << str;
});
}

混合使用的情况下,捕获列表的第一个元素必须是 & 或 = ,代表默认的捕获方式。剩下其他的元素就必须与第一个元素不同的捕获方式。以下是详细列表说明:

捕获形式 说明
[] 空捕获列表,lambda不能使用所在函数的变量
[names] nanes 是一个逗号分隔的名字列表,这些名字都是lambda所在函数的局部变量。默认情况下捕获的变量都被拷贝,名字如果前采用&,则采用引用捕获的方式
[&] 隐士捕获列表,采用引用捕获方式。lambda体中所使用的来自函数的实体都采用引用的方式
[=] 隐士捕获列表,采用值捕获方式。lambda 体将拷贝所使用的来自所在函数的实体的值
[&, identifier_list] identifier_list 是一个逗号分隔列表,包含 0 个或多个来自函数的变量。这些变量采用值捕获的方式,而任何隐式捕获的变量都采用引用方式捕获。 identifier_list 的名字前不能使用&
[=, identifier_list] identifier_list的变量都采用引用的方式捕获,而任何隐式捕获的变量都采用值方式捕获。identifier_list 中的名字不能包括this,且这些名字前必须采用&

但在实践的过程中,需要避免使用默认捕获,默认捕获很难显示的注意被捕获的变量生命周期与局部函数的生命周期情况,容易造成悬垂指针的错误等。

可变值

默认情况下,对于按值捕获的变量,lambda 不会改变其值。如果希望改变被捕获的变量的值,必须在参数列表首加上关键字 mutable:

1
2
3
4
5
6
7
8
9
void testLambdaChangeValue() {
size_t v1 = 42;
auto f = [v1]() mutable {
return ++v1;
};
v1 = 0;
auto j = f();
cout << "testLambdaValue:" << j << endl; // j 为 43
}

而引用捕获的变量是否可以修改,取决于它的引用指向的是否为一个 const 类型。

示例练习

以下函数俩次打印值分别是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void testLambdaUse() {
int a = 1, b = 1, c = 1;

auto m1 = [a, &b, &c]() mutable {
auto m2 = [a, b, &c]() mutable {
std::cout << a << b << c << '\n';
a = 4; b = 4; c = 4;
};
a = 3; b = 3; c = 3;
m2();
};

a = 2; b = 2; c = 2;

m1();
std::cout << a << b << c << '\n';
}
  • 创建 m1 的 lambda 时,a=1 按值拷贝捕获,b 和是 c 是 2 的引用
  • 创建 m2 的 lambda 时,a=1,b=2 按值拷贝捕获,c 按引用捕获
  • m1 调用时,会接着调用 m2,此时打印的 a=1,b=2,c 为 m2 被调用前最新修改的值 3
  • 最后一次打印时,a 都是按值捕获,外部赋值不影响, b 受 m1 的引用捕获,最终是 m1 中改变的值,c 受 m1 和 m2 引用捕获,是最终 m2 修改的值。

最后的输出:

1
2
// 调用 m2() 并打印 123
// 打印 234

三、lambda 本质

lambda 表达式在编译时被翻译成一个未命名的未命名对象,产生的类中含有一个重载的函数调用运算符operator(),即仿函数。

以下 lambda 表达式,接收 ostream 和 间隔符 str 来迭代打印字符:

1
2
3
auto lambda = [&os, str](const string &s) {
os << s << str;
};

其对应编译起翻译后的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class LambdaImpl {
public:
// [] 引用/值捕获
LambdaImpl(ostream &os, string str) : os_(os), str_(str) {}

// () 调用参数
void operator()(const string& s) const {
cout << s << str_;
}

private:
ostream &os_;
string str_;
};
  • 引用/值的变量捕获通过构造函数传入,当作类的成员变量
  • 重载函数操作符(),并声明行参,该函数默认为 const,不允许修改任何类成员变量
  • lambda 产生的类不含默认构造、赋值运算符及默认析构函数,是否含有拷贝/移动构造函数则视捕获的数据成员类型而定

使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
void biggies(vector<string>& words, ostream &os = cout, string str = "|") {
// lambda 方式
for_each(words.begin(), words.end(), lambda);
// 重载函数调用符对象
for_each(words.begin(), words.end(), LambdaImpl(os, str));

// lambda 方式
lambda("this is lambda");
auto lambda_impl = LambdaImpl(os, str);
// 重载函数调用符对象
lambda_impl("this is lambda impl");
}

四、lambda 对比

C++ 中有几种可调用的对象:函数、函数指针、lambda表达式、bind 创建的对象以及重载了函数调用运算符的类,他们都可以用 f(args) 的调用形式。

前边提到 find_if 的第三个参数只接收一元谓词的参数,当如果还想把长度参数 sz 传递时,普通的函数/函数指针就不能胜任。那除了 lambda 表达式或重载函数调用的类外,还有其他方式解决吗?

C++ 的 bind

在 functional 中有一个 bind 的通用函数适配器,它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表:

1
auto newCallable = bind(callable, arg_list)
  • arg_list:逗号分隔的参数列表,会当作参数传递给 callback 的函数
    • arg_list 可能包含 std::placeholders::_n 的名字,其中 n 为一个整数,是“占位符”,表示 newCallable 的参数,它们占据了 newCallable 的参数的位置。_1 表示 第一个参数,_2 表示第二个参数,依次类推。
  • newCallable:可调用对象,调用 newCallable 时,它会调用 callable,并传递 arg_list 中的参数

回顾之前 find_if 查找 sz 长度的第一个元素的实现:

1
2
3
4
5
void biggies(vector<string>& words, vector<string>::size_type sz) {
auto words_over_sz = find_if(words.begin(), words.end(), [sz](const string& s) {
return s.size() > sz;
});
}

我们使用 bind 实现如下:

1
2
3
4
5
6
7
8
bool isOverSzLength(const string& s, string::size_type sz) {
return s.size() > sz;
}

void biggies(vector<string>& words, vector<string>::size_type sz) {
auto bind_over_sz = bind(isOverSzLength, std::placeholders::_1, sz);
auto words_over_sz = find_if(words.begin(), words.end(), bind_over_sz);
}

同样的,有些参数不希望以值的方式传递,希望以引用的方式传递,可以使用 ref 来生成一个 const 的引用:

1
2
3
4
5
6
7
void print(ostream& os, const string &s, string str) {
os << s << str;
}

void biggies(vector<string>& words, ostream &os = cout, string str = "|") {
for_each(words.begin(), words.end(), bind(print, ref(os), placeholders::_1, "|"));
}

lambda vs bind

  • lambda 表达式具有更强的可读性,表达力更好,lambda 的传参方式比 bind 函数更直观。
  • bind函数绑定重载函数时会遇到二义性问题。由于函数名只是个名字,不带形参类型等其他附加信息,所以无法知道被绑定的是哪个重载版本的函数。
  • lambda 表达式可能生成比使用 bind 运行效率更高的代码。lambda 表达式会生成一个确定的重载函数调用类的实例,编译器可以进行 inline 函数调用优化。但 bind 绑定的是函数指针,运行时分配,无法内联优化。

c++14 lambda 增加初始化捕获

在 c++ 11 的 lambda 里,捕获的值无法是初始化的变量,会带来一个问题:对于无法拷贝或引用的一些捕获,如 std::unique_ptr ,就只能进行移动,该如何实现?

lambda 表达式本质上也是重载函数调用的类,所以这里可以通过类来实现,成员变量是支持移动初始化的。如果强行用 lambda 来实现,则需要借助 bind :

1
2
auto pw = std::make_unique<UniqueObject>(); 
auto func = std::bind([](const std::unique_ptr<UniqueObject> &pw) {}, std::move(pw));

在 bind 的参数上对 std::unique_ptr 进行移动,作为参数传入到 lambda 表达式中。在 c++ 14 中 lambda 增加了初始化捕获:

1
auto func = [pw = std::move(pw)]{}

在 c++14 后,lambda 几乎可替代 bind 的场景。

五、lambda 总结

1、使用场景

如果出现以下情况的,优先考虑使用 Lambda 表达式

  • 需要捕获局部变量的,优先使用 Lambda 表达式,而不要使用 std::bind 或重载函数调用的仿函数类。
  • 如果函数仅在局部使用且短小,优先使用 Lambda 表达式,而不是全局静态函数。

2、捕获建议

1)对于局部使用的 Lambda (非常确定的在何时调用 Lambda,例如 std::find_if),优先采用按引用捕获非值捕获。

2)对于非局部使用的 Lambda (不确定何时调用 Lambda 或 Lambda 执行顺序和主调方有数据竞争,例如 std::thread),避免采用按引用捕获。

3)尽量避免使用默认捕获:

  • 如果捕获的 this,禁止使用默认捕获;
  • 如果某个 Lambda 表达式并未捕获任何局部变量,禁止添加默认捕获;
  • 仅在 Lambda 函数体较短,或生命周期比捕获的内容短时,才应该考虑使用默认捕获。

3、lambda 优缺点

优点:

  • Lambda 减少全局函数的声明,使得代码更加简洁。
  • Lambda 提升了可读性,比如使用 STL 算法传递函数对象 find_if 等场景时。
  • Lambda、重载函数调用的仿函数类、std::bind 相比函数、函数指针,可以接受或捕获更多的变量信息,更适用通用的回调机制。

缺点:

  • Lambda 捕获的变量可能导致悬垂指针错误,尤其在 lambda 离开当前作用域的场景。
  • 按值捕获也可能导致悬垂指针错误。因为捕获指针不会深拷贝,所以和按引用捕获一样,会遇到同样的生命周期问题。尤其在隐式使用 this,导致按值捕获 this 的场景下,使人困惑。
  • Lambda 可能被滥用,层层嵌套的匿名函数让代码难以理解。

相关参考:

知道是不会有人点的,但万一被感动了呢?