C++中的lambda表达式与函数对象

1、lambda表达式

lambda表达式是C++11中引入的一项新技术,利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象,并且使代码更可读。从本质上来讲,lambda表达式只是一种语法糖,因为所有其能完成的工作都可以用其它稍微复杂的代码来实现,但是它简便的语法却给C++带来了深远的影响。从广义上说,lamdba表达式产生的是函数对象。在类中,可以重载函数调用运算符(),此时类的对象可以将具有类似函数的行为,我们称这些对象为函数对象(Function Object)或者仿函数(Functor)。相比lambda表达式,函数对象有自己独特的优势。

lambda表达式一般都是从方括号[]开始,然后结束于花括号{},花括号里面就像定义函数那样,包含了lamdba表达式体,一个最简单的例子如下:

1
2
3
4
// 定义简单的lambda表达式
auto basicLambda = [] { cout << "Hello, world!" << endl; };
// 调用
basicLambda(); // 输出:Hello, world!

上面是最简单的lambda表达式,没有参数。如果需要参数,那么就要像函数那样,放在圆括号里面,如果有返回值,返回类型要放在->后面,即拖尾返回类型,当然你也可以忽略返回类型,lambda会帮你自动推断出返回类型

1
2
3
4
5
6
7
// 指明返回类型,托尾返回类型
auto add = [](int a, int b) -> int { return a + b; };
// 自动推断返回类型
auto multiply = [](int a, int b) { return a * b; };

int sum = add(2, 5); // 输出:7
int product = multiply(2, 5); // 输出:10

最前边的[]lambda表达式的一个很重要的功能,就是闭包。先说明一下lambda表达式的大致原理:每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包实例。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。例子如下:

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int x = 10;

auto add_x = [x](int a) { return a + x; }; // 复制捕捉x,lambda表达式无法修改此变量
auto multiply_x = [&x](int a) { return a * x; }; // 引用捕捉x,lambda表达式可以修改此变量

cout << add_x(10) << " " << multiply_x(10) << endl;
// 输出:20 100
return 0;
}

捕获的方式可以是引用也可以是复制,但是具体说来会有以下几种情况来捕获其所在作用域中的变量:

  • []:默认不捕获任何变量;
  • [=]:默认以值捕获所有变量;
  • [&]:默认以引用捕获所有变量;
  • [x]:仅以值捕获x,其它变量不捕获;
  • [&x]:仅以引用捕获x,其它变量不捕获;
  • [=, &x]:默认以值捕获所有变量,但是x是例外,通过引用捕获;
  • [&, x]:默认以引用捕获所有变量,但是x是例外,通过值捕获;
  • [this]:通过引用捕获当前对象(其实是复制指针);
  • [*this]:通过传值方式捕获当前对象;

在上面的捕获方式中,注意最好不要使用[=]和[&]默认捕获所有变量。首先说默认引用捕获所有变量,你有很大可能会出现悬挂引用(Dangling references),因为引用捕获不会延长引用的变量的声明周期:

1
2
3
4
std::function<int(int)> add_x(int x)
{ // x仅是一个临时变量,函数调用后就被销毁,但是返回的lambda表达式却引用了该变量,但调用这个表达式时,引用的是一个垃圾值,
return [&](int a) { return x + a; };
}

使用默认传值方式可以避免悬挂引用问题,但是采用默认值捕获所有变量仍然有风险,看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Filter
{
public:
Filter(int divisorVal):
divisor{divisorVal}
{}

std::function<bool(int)> getFilter()
{ // `lambda`表达式实际上捕捉的是this指针的副本
return [=](int value) {return value % divisor == 0; };
}

private:
int divisor;
};

这个类中有一个成员方法,可以返回一个lambda表达式,这个表达式使用了类的数据成员divisor,而且采用默认值方式捕捉所有变量。你可能认为这个lambda表达式也捕捉了divisor的一份副本,但是实际上大错特错。问题出现在哪里呢?因为数据成员divisorlambda表达式并不可见,即使他是public你可以用下面的代码验证:

1
2
3
4
5
// 类的方法,下面无法编译,因为divisor并不在lambda捕捉的范围
std::function<bool(int)> getFilter()
{ // divisor是public成员变量也不可
return [divisor](int value) {return value % divisor == 0; };
}

那么原来的代码也就是return [=](int value) ...为什么能够捕捉到呢?仔细想想,原来每个非静态方法都有一个this指针变量,利用this指针,你可以接近任何成员变量,所以lambda表达式实际上捕捉的是this指针的副本,所以原来的代码等价于:

1
2
3
4
std::function<bool(int)> getFilter()
{
return [this](int value) {return value % this->divisor == 0; };
}

尽管还是以值方式捕获,但是捕获的是指针,其实相当于以引用的方式捕获了当前类对象,那么类似“悬挂引用”的问题也会产生。

通过前面的例子,还可以看到lambda表达式可以作为返回值。我们知道lambda表达式可以赋值给对应类型的函数指针,但是使用函数指针貌似并不是那么方便。所以STL在<functional>头文件提供了一个多态的函数对象封装std::function,其类似于函数指针。它可以绑定任何类函数对象,只要参数与返回类型相同。如下面的返回一个bool且接收两个int的函数包装器:

1
std::function<bool(int, int)> wrapper = [](int x, int y) { return x < y; };

lambda表达式一个更重要的应用是其可以用于函数的参数,通过这种方式可以实现回调函数。其实,最常用的是在STL算法中,比如你要统计一个数组中满足特定条件的元素数量,通过lambda表达式给出条件,传递给count_if函数:

1
2
3
4
int value = 3;
vector<int> v {1, 3, 5, 2, 6, 10};

int count = std::count_if(v.beigin(), v.end(), [value](int x) { return x > value; }); // v中大于3的元素数量

更多的例子见这里。前面讲完了lambda表达式的基本使用,最后给出lambda表达式的完整语法:

1
2
3
4
5
6
[ capture-list ] ( params ) mutable(optional) constexpr(optional)(c++17) exception attribute -> ret { body }

// 可选的简化语法
[ capture-list ] ( params ) -> ret { body }
[ capture-list ] ( params ) { body }
[ capture-list ] { body }

各个部分的说明:

  • capture-list:捕捉列表,这个不用多说,前面已经讲过,它不能省略;

  • params:参数列表,可以省略(但是后面必须紧跟函数体);

  • mutable:可选,将lambda表达式标记为mutable后,函数体就可以修改传值方式捕获的变量;

  • constexpr:可选,C++17,可以指定lambda表达式是一个常量函数;

  • exception:可选,指定lambda表达式可以抛出的异常;

  • attribute:可选,指定lambda表达式的特性;

  • ret:可选,返回值类型;

  • body:函数执行体。

在C++14中,lambda又得到了增强,一个是泛型lambda表达式,一个是lambda可以捕捉表达式,简单的介绍见这里

2、函数对象

函数对象是一个广泛的概念,因为所有具有函数行为的对象都可以称为函数对象。这是一个高级抽象,我们不关心对象到底是什么,只要其具有函数行为。所谓的函数行为是指的是可以使用()调用并传递参数:

1
function(arg1, arg2, ...);   // 函数调用

这样来说,lambda表达式也是一个函数对象。但是这里我们所讲的是一种特殊的函数对象,这种函数对象实际上是一个类的实例,只不过这个类重载了函数调用符():

1
2
3
4
5
6
7
8
class X
{
public:
// 定义函数调用符
ReturnType operator()(params) const;

// ...
};

这样,我们可以使用这个类的对象,并把它当做函数(也称为仿函数)来使用:

1
2
3
X f;
// ...
f(arg1, arg2); // 等价于 f.operator()(arg1, arg2);

本质上,函数对象是类对象,这也使得函数对象相比普通函数有自己的独特优势:

函数对象带有状态:函数对象相对于普通函数是“智能函数”,这就如同智能指针相较于传统指针。因为函数对象除了提供函数调用符方法,还可以拥有其他方法和数据成员,所以函数对象有状态。即使同一个类实例化的不同的函数对象其状态也不相同,这是普通函数所无法做到的。而且函数对象是可以在运行时创建。

每个函数对象有自己的类型:对于普通函数来说,只要签名一致,其类型就是相同的。但是这并不适用于函数对象,因为函数对象的类型是其类的类型。这样,函数对象有自己的类型,这意味着函数对象可以用于模板参数,这对泛型编程有很大提升。

函数对象一般快于普通函数:因为函数对象一般用于模板参数,模板一般会在编译时会做一些优化。

一个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class IntSequence
{
public:
IntSequence(int initVal) : value{ initVal } {}

int operator()() { return ++value; }
private:
int value;
};


int main()
{
vector<int> v(10);
std::generate(v.begin(), v.end(), IntSequence{ 0 });// IntSequence(0)等效
/* lambda实现同样效果
int init = 0;
std::generate(v.begin(), v.end(), [&init] { return ++init; });
*/
std::for_each(v.begin(), v.end(), [](int x) { cout << x << ' '; });
//输出:1, 2, 3, 4, 5, 6, 7, 8, 9, 10

return 0;
}

可以看到,函数对象可以拥有一个私有数据成员,每次调用时递增,从而产生连续序列。

头文件<functional>中预定义了一些函数对象,如算术函数对象,比较函数对象,逻辑运算函数对象及按位函数对象,我们可以在需要时使用它们。比如less<>是STL排序算法中的默认比较函数对象,所以默认的排序结果是升序,但是如果你想降序排列,你可以使用greater<>函数对象:

1
2
3
4
5
vector<int> v{3, 4, 2, 9, 5};
// 默认的比较函数对象是less<>故而是升序排序
std::sort(v.begin(), v.end()); // output: 2, 3, 4, 5, 9
// 降序排列
std::sort(v.begin(), v.end(), std::greater<int>{}); // output: 9, 5, 4, 3, 2

3、函数适配器

从设计模式来说,函数适配器是一种特殊的函数对象,是将函数对象与其它函数对象,或者特定的值,或者特定的函数相互组合的产物。由于组合特性,函数适配器可以满足特定的需求,头文件<functional>定义了几种函数适配器:

  • std::bind(op, args…):将函数对象op的参数绑定到特定的值args
  • std::mem_fn(op):将类的成员函数转化为一个函数对象
  • std::not1(op), std::not2(op):一元取反器和二元取反器(C++17中被抛弃)

3.1、绑定器(binder)

绑定器std::bind是最常用的函数适配器,它可以将函数对象的参数绑定至特定的值。对于没有绑定的参数可以使用std::placeholers::_1, std::placeholers::_2等标记。我们从简单的例子开始,比如你想得到一个减去固定数的函数对象:

1
2
auto minus10 = std::bind(std::minus<int>{}, std::placeholders::_1, 10);
cout << minus10(20) << endl; // 输出10

有时候你可以利用绑定器重新排列参数的顺序,下面的绑定器交换两个参数的位置:

1
2
3
// 逆转参数顺序
auto vminus = std::bind(std::minus<int>{}, std::placeholders::_2, std::placeholders::_1);
cout << vminus(20, 10) << endl; // 输出-10

绑定器还可以互相嵌套,从而实现函数对象的组合:

1
2
3
4
5
// 定义3次方函数对象
auto pow3 = std::bind(std::multiplies<int>{},
std::bind(std::multiplies<int>{}, std::placeholders::_1, std::placeholders::_1),
std::placeholders::_1);
cout << pow3(3) << endl; // 输出:27

其他例子见这里

绑定器可以用于调用类中的成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person
{
public:
Person(const string& n) : name{ n } {}
void print() const { cout << name << endl; }
void print2(const string& prefix) { cout << prefix << name << endl; }
private:
string name;
};
int main()
{
vector<Person> p{ Person{"Tick"}, Person{"Trick"} };
// 调用成员函数print
std::for_each(p.begin(), p.end(), std::bind(&Person::print, std::placeholders::_1));
// 此处的std::placeholders::_1表示要调用Person的对象,所以相当于调用arg1.print()
// 输出:Tick Trick
std::for_each(p.begin(), p.end(), std::bind(&Person::print2, std::placeholders::_1,
"Person: "));
// 此处的std::placeholders::_1表示要调用的Person对象,所以相当于调用arg1.print2("Person: ")
// 输出:Person: Tick Person: Trick

return 0;
}

4.2、std::mem_fn()适配器

当想调用成员函数时,你还可以使用std::mem_fn函数,此时你可以省略掉用于调用对象的占位符:

1
2
3
4
5
6
vector<Person> p{ Person{ "Tick" }, Person{ "Trick" } };
std::for_each(p.begin(), p.end(), std::mem_fn(&Person::print));
// 输出: Trick Trick
Person n{ "Bob" };
std::mem_fn(&Person::print2)(n, "Person: "); //向返回一个函数对象,之后传递参数
// 输出:Person: Bob

所以,使用std::men_fn不需要绑定参数,可以更方便地调用成员函数。

普通函数的参数中有引用类型

想象一下如果原函数参数中包含引用类型应该怎样写?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void func4(int n1, int n2, int& n3)
{
cout << n1 << ' ' << n2 << ' ' << n3 << endl;
n3 = 101;
}

void test4_1()
{
int n = 10;
auto f4 = std::bind(func4, 11, 22, n); // 默认会发生拷贝
n = 33;
f4(); // same as call func4(11, 22, 10)
cout << "n = " << n << endl;
}

void test4_2()
{
const int n = 30;
auto f4 = std::bind(func4, 11, 22, n);
f4(); // same as call func4(11, 22, 30)
}

void test4_3()
{
int n = 30;
auto f4 = std::bind(func4, 11, 22, ref(n)); // 正确的按照引用传递n
n = 33;
f4(); // same as call func4(11, 22, n)
cout << "n = " << n << endl;
}

void test4_4()
{
const int n = 30;
auto f4 = std::bind(func4, 11, 22, ref(n));
//f4(); // 编译错误,无法将参数 3 从“const int”转换为“int &”
}

// 输出
//11 22 10
//n = 33
//11 22 30
//11 22 33
//n = 101

std::bind()的官方文档中有这样一句话,std::bind()函数中的参数在被复制或者移动时绝不会以引用的方式传递,除非你使用了std::ref()或者std::cref()包装的参数。

在函数test4_1()std::bind(func4, 11, 22, n)就相当于std::bind(func4, 11, 22, 10),所以输出结果为11 22 10,可是函数func4()中还有一句 n3 = 101;,这就很让人奇怪了,我们知道常数是没办法作为参数传递给可变引用变量的,如果说把10作为参数传递给参数int& n3肯定会报错,而函数test4_1()却正常执行,没有任何问题。
我们猜测常数10到参数int& n3并不是直接传递,而是发生了拷贝,而函数func4()中修改的n3变量也是修改的拷贝内容,所以我们做了test4_2()这个实验,发现将变量n改为常量也是可以正常执行的,甚至直接写成std::bind(func4, 11, 22, 10)也是没问题的,这也验证了我们上面的想法。

既然文档了提到了std::ref()std::cref()函数,那么我们想传递引用给原函数只能使用它们了,看下函数test4_3()的实现,这才是正确传递引用变量的方式,变量n被函数 std::ref()包装之后,既能够感受到本函数中变量n的变化,也能够传入到原函数中被原函数的逻辑改变,并将结果反映回来。

函数test4_4()只是一个常量传递的简单测试,将一个常量作为可变引用变量来传递肯定是无法通过编译的,这在函数调用时很明确,但是在std::bind()加入之后显得有些混乱,只要记住一点,常量不应该被改变,如果传递之后内容可能会变化,那么很可能这种写法就是错误的。

听说打赏我的人,最后都找到了真爱。