概述

在C++中,可调对象(Callable Objects)是指可以像函数一样被调用的对象,通常包括:

  • 函数指针
  • 仿函数
  • std::function
  • lambda 表达式

它们大部分都是基于面向对象实现的,但是函数指针是个例外,因此给对象两个字加上引号其实更合适。

可调用对象是函数的扩展概念,引入它们的主要目的就是补上函数天生的短板:

  • 函数在语法上不可以像变量一样作为参数被传递给其它函数;可调用对象可以。
  • 函数在语法上通常不具有内部状态,或者即使有,也只是通过局部静态变量实现的唯一内部状态;每一个可调用对象都可以拥有独立的内部状态。

这使得可调用对象在泛型编程、回调函数和函数式编程中发挥重要的作用。

函数的这两个短板是针对C/C++这种系统级编程语言来说的,但是对于某些高级语言来说,这些完全不是问题, 例如对于JavaScript、Python和Lua来说,函数在语法上就是一个可调用的变量,可以像普通变量一样直接作为参数传递给其他函数,并且允许拥有内部状态,称之为闭包可能更合适。 因为这些高级语言的执行由其解释器或虚拟机负责,而C/C++需要直接执行。

TODO:C++17 的std::invoke以全局模板函数的方式提供了统一的对可调用对象的调用语句,有空写一下。

函数指针(C)

函数指针在C语言中就已经存在了,目的是让函数也能作为参数被传递,但是仍然不能为其添加内部状态。

C语言中的函数指针有几点需要特别注意:

  • 函数类型不等于函数指针类型,但是函数到函数指针之间也存在类似于数组到指针之间的隐式类型转换,这看起来有点令人困惑。
  • 定义函数指针类型的语法是不够直观的,尤其是某些复杂的情况下,可以说是极其晦涩难懂的!

函数类型与函数指针类型

首先需要注意的,函数类型和函数指针类型是不一样的,例如

1
2
3
4
5
6
7
8
9
int sum(int a,int b){
return a+b;
}

// sum的函数类型
int(int,int)

// 可以指向sum的函数指针类型
int (*)(int,int)

但是在使用过程中,两者区别并不明显,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

int sum(int a, int b) { return a + b; }

int main(int argc, char *argv[]) {
printf("sum = %d\n", sum(1, 2));

int (*f1)(int, int) = &sum;
printf("sum = %d\n", (*f1)(2, 3));

int (*f2)(int, int) = sum;
printf("sum = %d\n", (*f2)(3, 4));

int (*f3)(int, int) = &sum;
printf("sum = %d\n", f3(4, 5));

int (*f4)(int, int) = sum;
printf("sum = %d\n", f4(5, 6));

return 0;
}

这里的四种使用方式都是等效的:

  • 在给函数指针赋值时,既可以对函数名用&取地址用来赋值,也可以直接用函数名赋值;
  • 在通过函数指针调用函数时,既可以先使用*解引用再调用,也可以直接调用。

函数与函数指针之间就像数组名和指向数组首位元素的指针一样可以灵活地进行转换。

使用typedef定义别名

由于函数指针类型通常很繁琐,我们可以用typedef给函数指针类型定义更简洁的别名,例如

1
typedef int (*SumPtrType)(int, int);

然后就可以直接使用类型别名来定义函数指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int sum(int a, int b) { return a + b; }

// 函数指针类型的别名
typedef int (*SumPtrType)(int, int);

int main(int argc, char *argv[]) {
printf("sum = %d\n", sum(1, 2));

SumPtrType g = &sum;
printf("sum = %d\n", g(1, 2));

return 0;
}

事实上我们也可以给函数类型起别名,下面的例子可以说明函数类型和函数指针类型的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

int sum(int a, int b) { return a + b; }

// 函数类型的别名
typedef int SumType(int, int);

// 函数指针类型的别名
typedef int (*SumPtrType)(int, int);

int main(int argc, char *argv[]) {
printf("sum = %d\n", sum(1, 2));

SumPtrType g1 = &sum;
printf("sum = %d\n", g1(1, 2));

SumType *g2 = &sum; // 注意这里需要加星号*
printf("sum = %d\n", g2(1, 2));

return 0;
}

这里g2在定义时必须加上*,因为我们无法定义函数类型SumType的变量,只能定义函数指针类型SumType *的变量。

在一些复杂的情况下,涉及函数指针类型的语法是非常晦涩难懂的,例如

1
2
3
4
int *(*(*f1)(int))[10];
int (*(*f2)[10])(int *);
int (*(*f3)(int, int))(int);
(*(void (*)())0)();

在实际编程中写这么复杂的表达式除了炫技没有任何意义,在下文中会用可读性更高的using来拆解化简并解释它们的含义。

函数指针(C++)

C++直接继承了C语言中函数指针的语法,但是有几点不同:

  • 支持并且建议使用using来定义函数指针类型的别名;
  • 对于成员函数的指针类型有额外的处理。

使用using定义别名

使用using定义别名的方式比typedef可读性强得多,因为using的语序更加合理,在定义函数指针类型时体现的特别明显, 例如

1
2
3
4
5
6
7
// typedef
typedef int SumType(int, int);
typedef int (*SumPtrType)(int, int);

// using
using SumType = int(int,int);
using SumPtrType = int(*)(int,int);

使用using可以更好地对前一节中的几个例子进行化简和解释。

第一个例子

1
2
3
4
5
6
7
8
int *(*(*f1)(int))[10];

// i.e.

using ArrayOfIntPtr1 = int *[10];
using FuncPtr1 = ArrayOfIntPtr1* (*)(int);

FuncPtr1 f1;

解释:f1是一个函数指针,对应的函数接受一个int参数并返回一个数组指针,返回的数组指针会指向包含10个int*数据的指针数组。

第二个例子

1
2
3
4
5
6
7
8
9
int (*(*f2)[10])(int *);

// i.e.

using FuncReturningInt2 = int (*)(int *);
using ArrayOfFuncPtr2 = FuncReturningInt2[10];
using ArrayPtr2 = ArrayOfFuncPtr2*;

ArrayPtr2 f2;

解释:f2是一个数组指针,指向一个包含10个数据的指针数组,数组中的数据类型是函数指针,对应的函数接受一个int*参数并返回int

第三个例子

1
2
3
4
5
6
7
int (*(*f3)(int, int))(int);

// i.e.

using FuncReturningInt3 = int (*)(int);
using FuncPtr3 = FuncReturningInt3 (*)(int, int);
FuncPtr3 f3;

解释:f3是一个函数指针,对应的函数接受两个int参数并返回一个函数指针,返回的函数指针所对应的函数接受一个int参数并返回int

第四个例子和前几个不同,它并不是一个变量的定义或声明,而是一个表达式。

1
2
3
4
5
6
(*(void (*)())0)();

// i.e.

using VoidFuncPtr = void (*)();
(*static_cast<VoidFuncPtr>(nullptr))();

解释:这个语句将nullptr0转换为函数指针类型并立即调用,但是这里的函数指针类型对于的是既没有输入也没有输出的函数。严格来说,将空指针转换并执行是未定义行为,但是在实践中通常会被编译器扔掉,相当于一个无效语句。

C++的using定义的类型别名除了支持指针类型,还支持数组类型,引用类型等,例如

1
2
3
using IntRef = int &;
using Int5 = int[5];
using Int5Ref = int(&)[5];

using还支持与模板结合,定义模板类型,例如

1
2
3
4
template<typename T>
using Vec = std::vector<T>;

Vec<int> myVec; // std::vector<int> myVec;

成员函数指针

由于C++是面向对象的,这带来了一个额外的问题:如何表示类的成员函数指针?由于可能含有this指针参数,我们必须分成两类情况进行讨论:

  • 静态成员函数,不传递this指针
  • 普通成员函数,传递this指针

通过例子说明

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
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <string>

class Demo {
private:
int m_x = 1;
inline static double m_y = 2;

public:
void f1(int x, const std::string &msg) {
std::cout << "Member function f1 called with x: " << x
<< " and msg: " << msg << std::endl;
std::cout << "m_x: " << m_x << std::endl;

m_x = x;
}

void f2(int x, const std::string &msg) const {
std::cout << "Const member function f2 called with x: " << x
<< " and msg: " << msg << std::endl;
std::cout << "m_x: " << m_x << std::endl;
}

static void fs(double y, const std::string &msg) {
std::cout << "Static member function fs called with y: " << y
<< " and msg: " << msg << std::endl;
std::cout << "m_y: " << m_y << std::endl;

m_y = y;
}
};

using MemberFuncPtr = void (Demo::*)(int, const std::string &);
using ConstMemberFuncPtr = void (Demo::*)(int, const std::string &) const;
using StaticMemberFuncPtr = void (*)(double, const std::string &);

int main() {
Demo obj;
Demo *obj_ptr = new Demo{};

MemberFuncPtr f1_ptr = &Demo::f1;
(obj.*f1_ptr)(10, "Hello");
(obj_ptr->*f1_ptr)(11, "Hello");

ConstMemberFuncPtr f2_ptr = &Demo::f2;
(obj.*f2_ptr)(12, "Hi");
(obj_ptr->*f2_ptr)(13, "Hi");

StaticMemberFuncPtr fs_ptr = &Demo::fs;
fs_ptr(3.14, "Bye");

return 0;
}

运行结果如下

1
2
3
4
5
6
7
8
9
10
Member function f1 called with x: 10 and msg: Hello
m_x: 1
Member function f1 called with x: 11 and msg: Hello
m_x: 1
Const member function f2 called with x: 12 and msg: Hi
m_x: 10
Const member function f2 called with x: 13 and msg: Hi
m_x: 11
Static member function fs called with y: 3.14 and msg: Bye
m_y: 2

对于普通成员函数,除了需要说明形参列表和返回值类型,如果存在针对this的修饰词(例如const),我们也必须要加上,因为这直接关系到this指针的类型

1
2
using MemberFuncPtr = void (Demo::*)(int, const std::string &);
using ConstMemberFuncPtr = void (Demo::*)(int, const std::string &) const;

在使用普通成员函数给指针赋值时,需要加上类型前缀(和前面一样,这里加不加&都可以)

1
2
MemberFuncPtr f1_ptr = &Demo::f1;
ConstMemberFuncPtr f2_ptr = &Demo::f2;

普通成员函数指针在调用时,仍然需要依附于对应类型的对象或者指针,对应的语法细节比较繁琐,因为要考虑到语法上的优先级顺序,避免被错误解析

1
2
3
4
5
(obj.*f1_ptr)(10, "Hello");
(obj_ptr->*f1_ptr)(11, "Hello");

(obj.*f2_ptr)(12, "Hi");
(obj_ptr->*f2_ptr)(13, "Hi");

静态成员函数指针的使用则更加简单,因为静态成员函数和普通函数几乎一样,只是在命名上从属于一个类,并且参与到类的权限管理之中。在定义对应静态成员函数的函数指针类型时不需要做任何的额外处理,只要说明形参列表和返回值类型即可

1
using StaticMemberFuncPtr = void (*)(double, const std::string &);

在使用静态成员函数给指针赋值时,还是需要使用类型前缀(和前面一样,这里加不加&都可以)

1
StaticMemberFuncPtr fs_ptr = &Demo::fs

调用则和普通函数完全一样

1
fs_ptr(3.14, "Bye");

然后我们还需要考虑类当中的权限问题:

  • 在通过成员函数指针进行的调用过程中,仍然会保持与原本成员函数相同的访问权限,例如上面的几个成员函数都成功地对私有的数据成员进行了读写。
  • 如果成员函数自身是非公开的,在外部获取函数地址并赋值的操作会报错,例如非公开的f1函数会导致在外部使用&Demo::f1无法通过编译。

仿函数

C++的面向对象支持了对很多运算符的重载,这其中也包括了括号运算符,这导致我们可以对一个对象调用括号运算——看起来像函数调用一样, 对于重载了 operator() 的类对象,通常将其称为仿函数(Functor)。

仿函数可以一次性补全函数的两个短板:它作为一个对象,当然可以作为参数被传递,而且也可以拥有独立的内部状态,为此付出的代价是传递和调用过程的开销可能会更大。

基本使用

仿函数的最大特点就是拥有了内部状态,这是函数/函数指针很难完成的,它们必须依赖于某些全局变量或静态变量才能实现。

仿函数可以维持并在调用过程中更新内部状态,简单示例如下

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
#include <iostream>
#include <vector>

struct PrintAndCount {
void operator()(int n) {
std::cout << n << std::endl;
++m_cnt;
m_sum += n;
}

void show() const {
std::cout << "cnt = " << m_cnt << "; sum = " << m_sum << ";\n";
}

private:
int m_cnt = 0;
int m_sum = 0;
};

int main() {
PrintAndCount demo;

std::vector<int> data{1, 1, 2, 3, 5, 8};

for (auto i : data) { demo(i); }

demo.show();

return 0;
}

运行结果如下

1
2
3
4
5
6
7
1
1
2
3
5
8
cnt = 6; sum = 20;

仿函数是一个特殊的类,因此可以在构造时传递参数以生成独有的内部状态。 如果在构造函数中使用引用或指针参数,我们还可以实现更灵活的功能:捕获外部变量并将其作为仿函数的内部状态。(这其实是下文中lambda表达式的一部分原理)

示例如下

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
#include <iostream>
#include <vector>

struct PrintAndCount {
explicit PrintAndCount(int &cnt) : m_cnt{cnt} {}

void operator()(int n) {
std::cout << n << std::endl;
++m_cnt;
m_sum += n;
}

void show() const {
std::cout << "cnt = " << m_cnt << "; sum = " << m_sum << ";\n";
}

private:
int &m_cnt;
int m_sum = 0;
};

int main() {
int cnt = 10;
PrintAndCount demo{cnt};

std::vector<int> data{1, 1, 2, 3, 5, 8};

for (auto i : data) { demo(i); }

demo.show();

std::cout << "cnt = " << cnt << std::endl;

return 0;
}

运行结果如下

1
2
3
4
5
6
7
8
1
1
2
3
5
8
cnt = 16; sum = 20;
cnt = 16

需要注意的是,通过引用绑定可能会出现生命周期问题,也被称为悬垂引用问题,例如

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
#include <iostream>
#include <vector>

struct PrintAndCount {
explicit PrintAndCount(int &cnt) : m_cnt{cnt} {}

void operator()(int n) {
std::cout << n << std::endl;
++m_cnt;
m_sum += n;
}

void show() const {
std::cout << "cnt = " << m_cnt << "; sum = " << m_sum << ";\n";
}

private:
int &m_cnt;
int m_sum = 0;
};

PrintAndCount func() {
int cnt = 100;
return PrintAndCount{cnt};
}

int main() {
auto demo = func();
std::vector<int> data{1, 1, 2, 3, 5, 8};

for (auto i : data) { demo(i); }

demo.show();

return 0;
}

运行结果如下

1
2
3
4
5
6
7
1
1
2
3
5
8
cnt = 0; sum = 20;

结果是错误的:cnt=0是调试模式下最可能出现的结果,在编译器优化之后,cnt输出的值是随机的。 错误的原因是m_cnt绑定了一个栈上的变量cnt,但是在使用时cnt已经被析构了,这是未定义行为。

仿函数与函数指针转换

仿函数和普通的函数很相似,对外部提供的主要信息就是形参列表和返回值类型,那么具有相同形参列表和返回值类型的仿函数和函数指针之间能否进行转换?

  • 如果仿函数需要实际存储并使用内部状态,那么转换为无内部状态的函数指针显然是不合适的;
  • 如果仿函数实际上不需要内部状态,那么相互之间的转换是可以做到的。(这其实也是下文中lambda表达式的一部分原理)

对于不需要内部状态的仿函数,我们可以将功能完全委托给静态成员函数,并且提供与对应函数指针类型进行转换的方法,就可以做到仿函数与函数指针的转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

class Demo {
public:
using FuncType = void(int);

void operator()(int x) const { Demo::staticMemberFunc(x); }

static void staticMemberFunc(int x) {
std::cout << "Static member function called with x: " << x << std::endl;
}

explicit operator FuncType *() const { return &Demo::staticMemberFunc; }
};

int main() {
Demo functor;
functor(10);

auto *func_ptr = static_cast<Demo::FuncType *>(functor);
func_ptr(20);

return 0;
}

运行结果如下

1
2
Static member function called with x: 10
Static member function called with x: 20

需要注意的是,这里的operator方法的返回值类型需要使用别名而不是原始形式。 通常的写法会导致编译器解析错误,当然也可能有某种正确写法,但是正确写法就像前面的typedef一样晦涩。

上文中只定义了从仿函数到函数指针的单向转换,但是我们也可以支持两者之间的双向转换,只需要在仿函数内部存储一个函数指针即可

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
#include <iostream>

class Demo {
public:
using FuncType = void(int);

void operator()(int x) const { m_func_ptr(x); }

Demo() = default;

explicit Demo(FuncType *func_ptr) : m_func_ptr(func_ptr) {}

static void staticMemberFunc(int x) {
std::cout << "Static member function called with x: " << x << std::endl;
}

explicit operator FuncType *() const { return m_func_ptr; }

private:
FuncType *m_func_ptr = &Demo::staticMemberFunc;
};

void hello(int x) {
std::cout << "Function hello called with x: " << x << std::endl;
}

int main() {
Demo functor;
functor(10);

auto *func_ptr = static_cast<Demo::FuncType *>(functor);
func_ptr(20);

Demo functor2 = static_cast<Demo>(&hello);
functor2(30);

return 0;
}

运行结果如下

1
2
3
Static member function called with x: 10
Static member function called with x: 20
Function hello called with x: 30

std::function

到目前为止,C++已经有了函数指针和仿函数,虽然对于实际不含内部状态的仿函数,我们可以实现其与函数指针的相互转换,但是它们似乎仍然存在一个不可逾越的鸿沟,尤其是具有内部状态的仿函数和没有内部状态的函数指针。

我们希望有一种统一的方式去使用它们:只需要用户提供一个可调用的对象,只关注调用它所需要的形参列表以及返回值类型, 至于它实际上是函数指针还是仿函数,其实是无所谓的。

C++11引入了std::function(头文件为<functional>),它可以满足我们的上述需求,利用模板类型将函数指针和仿函数进行统一的封装,使用者只需要关注它们所对应的函数类型,并将其作为模板类型参数即可。

functional 在数学上作为专业名词指的是泛函,这里表达的意思是类似的,即可以操作函数的对象。

基本使用

std::function可以用统一的方式去使用上文中的各种可调用对象,示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <functional>
#include <iostream>

int sum1(int a, int b) { return a + b + 1; }

struct Sum2 {
int m = 2;

int operator()(int a, int b) const { return a + b + 2; }
};

int main() {
int a = 1;
int b = 2;

std::function<int(int, int)> f1 = &sum1;
std::cout << "call sum1 ptr: " << f1(a, b) << "\n";

std::function<int(int, int)> f2 = Sum2();
std::cout << "call Sum2 object: " << f2(a, b) << "\n";

return 0;
}

运行结果如下

1
2
call sum1 ptr: 4
call Sum2 object: 5

模仿实现

参考知乎的一篇文章:深入浅出C++的function,我们选择基于模板类来模仿实现。当然实际上 STL 可能主要是基于指针来实现的,并且进行了各种各样的优化,降低封装带来的额外开销。

实现代码如下

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>

namespace demo {

template <typename T>
struct Mfunction {};

template <typename ReturnType, typename... ArgTypes>
struct Mfunction<ReturnType(ArgTypes...)> {
// 可调用对象基类
struct Callbase {
virtual ReturnType eval(ArgTypes... arg) = 0;
virtual ~Callbase() = default;
};

// 继承的可调用对象
template <typename F>
struct CallFunction : public Callbase {
F m_func;

explicit CallFunction(F raw_func) : m_func(raw_func){};

ReturnType eval(ArgTypes... arg) override { return m_func(arg...); }
};

Callbase *base; // 可调用对象指针

// 代入参数求值
ReturnType operator()(ArgTypes... arg) {
return base->eval(arg...); // 这里调用基类对象的()操作符
}

// 基于模板类F,自动构造对应的可调用对象
template <typename F>
explicit Mfunction(F raw_func) : base(new CallFunction<F>(raw_func)) {}

using FunctionPtrType = ReturnType (*)(ArgTypes...);

explicit Mfunction(FunctionPtrType raw_func_ptr)
: base(new CallFunction<FunctionPtrType>(raw_func_ptr)) {}

Mfunction(const Mfunction &) = delete;

Mfunction &operator=(const Mfunction &) = delete;

~Mfunction() {
if (base) {
delete base;
base = nullptr;
}
}
};

} // namespace demo

int add_func(int a, int b) {
std::cout << " (add_func) ";
return a + b;
}

class AddClass {
public:
int operator()(int a, int b) {
std::cout << " (AddClass) ";
return a + b;
}
};

int main() {
demo::Mfunction<int(int, int)> myfunc(add_func);
std::cout << "2 + 3 =" << myfunc(2, 3) << "\n";

demo::Mfunction<int(int, int)> myfunc2(AddClass{});
std::cout << "2 + 3 =" << myfunc2(2, 3) << "\n";

auto add_lambda = [](int a, int b) {
std::cout << " (add_lambda) ";
return a + b;
};

demo::Mfunction<int(int, int)> myfunc3(add_lambda);
std::cout << "2 + 3 =" << myfunc3(2, 3) << "\n";

return 0;
}

运行结果如下

1
2
3
2 + 3 = (add_func) 5
2 + 3 = (AddClass) 5
2 + 3 = (add_lambda) 5

这里的实现还是有一个小问题,例如接收一个函数时,不能自动推断出对应的类型,但是std::function却是可以的,因此仍然有很多改善的空间。

1
2
3
demo::Mfunction<int(int, int)> myfunc(add_func); // ok

demo::Mfunction myfunc(add_func); // compile error

lambda表达式

lambda表达式是C++11引入的,常被称为匿名函数,事实上称为匿名仿函数更为合适。

为了支持lambda表达式,C++专门引入了很多新语法,而且相关语法细节仍然在不断完善中。 但是不管语法细节如何,引入lambda表达式的核心目的都是非常明确的: 提供一个一次性生成仿函数类型及其对象的语法糖,避免出现为了一个只需要使用一两次的仿函数对象,不得不补充冗长的类型定义代码的情况。

我们可以大体上把lambda表达式分为两类,第一类需要捕获上下文中的变量并使用,第二类则不需要捕获任何变量。

lambda表达式具有如下特点:

  • 无论是哪种形式的lambda表达式,都可以被对应类型的std::function进行统一的包装,在使用上没有区别;
  • 对于无变量捕获的lambda表达式,还直接支持其向对应的函数指针类型进行隐式转换。

无变量捕获

直接通过例子呈现

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int main() {
auto f1 = []() { std::cout << "hello" << std::endl; };
f1();

auto f2 = [](int x, int y) { return x + y; };
std::cout << f2(1, 2) << std::endl;

return 0;
}

这里我们必须使用auto来接收变量,因为编译器会为lambda表达式生成一个匿名的类型,在编译器外部无法获取类型名,只能使用自动类型推断。

lambda表达式的实质就是仿函数的简写,我们可以利用 cpp insights 来探究:f1的定义语句可以理解为下面的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class __lambda_5_15 {
public:
inline void operator()() const {
std::cout << "hello" << std::endl;
}

using retType_5_15 = void (*)();

inline operator retType_5_15() const noexcept { return __invoke; };

private:
static inline void __invoke() { __lambda_5_15{}.operator()(); }
};

auto f1 = __lambda_5_15{};

f2的定义语句可以理解为下面的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class __lambda_12_15 {
public:
inline int operator()(int x, int y) const { return x + y; }

using retType_12_15 = int (*)(int, int);

inline operator retType_12_15() const noexcept { return __invoke; };

private:
static inline int __invoke(int x, int y) {
return __lambda_12_15{}.operator()(x, y);
}
};

auto f2 = __lambda_12_15{};

通过这两个例子我们可以发现,对于无变量捕获时的lambda表达式,它的实现方式为:

  • 编译器根据lambda表达式所在未知,生成唯一的匿名类和对应的对象;
  • 这个匿名类的实现具有如下特点:
    • 无参数直接构造,没有内部成员数据;
    • 重载operator()操作,因此属于仿函数。它接收的参数、执行的操作以及对应的返回值都是lambda表达式的内容所对应的;
    • 提供了一个接口与operator()一致的私有静态方法__invoke,在其中构造临时对象并调用operator()方法;
    • 提供类型转换函数,允许隐式转换为对应的函数指针类型,实际上就是返回了__invoke方法的地址。

补充一个小技巧:对于可以转换为函数指针类型的lambda表达式,我们可以直接在定义前加上一个+,就可以将其自动转换为函数指针

1
2
3
4
5
auto f2 = [](int x, int y) { return x + y; }; // f2: lambda
std::cout << f2(1, 2) << std::endl;

auto f3 = +[](int x, int y) { return x + y; }; // f3: int (*)(int, int)
std::cout << f3(1, 2) << std::endl;

对于数组向对应的指针转换也有同样的技巧。

有变量捕获

直接通过例子呈现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

int main() {
int n = 1;
double d = 2.0;

auto g1 = [n, d]() {
std::cout << "n = " << n << ", d = " << d << std::endl;
};

g1();

auto g2 = [&n, &d]() {
std::cout << "n = " << n << ", d = " << d << std::endl;
n++;
d++;
};

g2();
g2();

return 0;
}

运行结果如下

1
2
3
n = 1, d = 2
n = 1, d = 2
n = 2, d = 3

继续利用 cpp insights 来探究:g1的定义语句可以理解为下面的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class __lambda_9_15 {
public:
inline void operator()() const {
std::cout << "n = " << n << ", d = " << d << std::endl;
}

private:
int n;
double d;

public:
__lambda_9_15(int &_n, double &_d) : n{_n}, d{_d} {}
};

auto g1 = __lambda_9_15{n, d};

g2的定义语句可以理解为下面的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class __lambda_15_15 {
public:
inline void operator()() const {
std::cout << "n = " << n << ", d = " << d << std::endl;
n++;
d++;
}

private:
int &n;
double &d;

public:
__lambda_15_15(int &_n, double &_d) : n{_n}, d{_d} {}
};

auto g2 = __lambda_15_15{n, d};

通过这两个例子我们可以发现,对于有变量捕获时的lambda表达式,它的实现方式为:

  • 编译器根据lambda表达式所在未知,生成唯一的匿名类和对应的对象;
  • 这个匿名类的实现具有如下特点:
    • 需要提供参数构造,有内部成员数据;
    • 构造方法的参数可以分成两类:
      • [n,d]形式提供的参数使用传值方式,不妨称为值捕获;
      • [&n,&d]形式提供的参数使用传引用的方式,不妨称为引用捕获;
    • 重载operator()操作,因此属于仿函数。它接收的参数、执行的操作以及对应的返回值都是lambda表达式的内容所对应的;
    • 由于具有内部状态,不再提供静态成员函数以及向函数指针类型转换的方法;

有一个细节是值得注意的,编译器自动生成的operator()方法具有const修饰,对于之前的无捕获情形也是一样的。 const修饰不会对通过引用捕获得到的成员变量的修改产生任何影响,但是对值捕获得到的成员变量会禁止修改,例如下面的代码中的a += 1;会编译报错

1
2
3
4
5
int a = 1;
auto g3 = [a](){
a += 1;
std::cout << "a = " << a;
};

虽然对值捕获的成员变量的修改在调用结束后总是会失效的,修改无法影响到外界,但是有时直接修改会给编程带来很多便捷,C++允许我们移除这里的const修饰,做法是显式加上mutable修饰,例如

1
2
3
4
5
int a = 1;
auto g3 = [a]() mutable {
a += 1;
std::cout << "a = " << a;
};

在这种情况下,编译器就会为匿名类生成非const版本的operator()方法。

关于lambda表达式的语法细节非常多,这里不作详细讨论。