C++闭包
1400 Words | Read in about 7 Min | View times
Overview
闭包,closure,一般是指带有状态的函数,这里的状态指的是调用环境的上下文。一个函数带上了状态,就是闭包。那么闭包就需要有捕获并持有外部作用域变量的能力,闭包状态的捆绑发生在运行时。在C++中,闭包的实现方式包括仿函数、std::bind()
绑定器以及lambda表达式。仿函数在以前的文章《C++仿函数》中以前介绍过了,本文将重点讨论另外两种闭包实现类型的用法和原理。
本系列文章将包括以下领域:
本章其他内容请见 《现代C++》
闭包是什么?
闭包在维基百科中的定义是这样的:
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。
简单来说,闭包是指带有状态的函数,这里的状态指的是调用环境的上下文。一旦函数带上了状态,它就是闭包。这里的“带上状态”,意思是这个闭包拥有属于自己的变量,这些变量的值在闭包创建的时候设置,并在调用闭包的时候,可以访问这些变量。函数是代码,状态是一组函数外部的变量,将它们绑定在一起,就形成了闭包。这个绑定的过程发生在运行时。所以闭包具有捕获并持有外部作用域变量的能力。
闭包只有在被调用时才执行操作,或者说运行时被捕获,即惰性求值,所以闭包可以被用来定义控制结构。
怎么样让闭包具有捕获并持有外部作用域变量的能力呢?
闭包的实现方式
闭包的实现思路都是将外部变量或者自由变量,通过传值或传引用的方式,递交给函数的上下文,作为函数的状态,这一步就是上下文绑定,或者叫上下文捕获。当闭包在运行时调用时,函数体内可以访问这些原本不属于函数体作用域的变量。
仿函数
仿函数在以前的文章《C++仿函数》中以前有具体介绍过。
仿函数是一个函数对象,构造函数对象时把外部变量或自由变量作为初始化参数传入,就相当于实现了上下文绑定,将外部变量转换为对象的内部成员。函数对象内部重载了operator()
操作符,函数对象就可以像函数一样进行运行时调用,当调用时,operator()
内部就可以访问到初始化传入的外部变量。
1class Foo {
2public:
3 explicit Foo(int base) : m_base(base) { }
4 int operator()(int param) { return m_base + param; }
5private:
6 int m_base;
7};
8
9int main() {
10 int base = 2;
11 Foo foo(base); //绑定上下文
12
13 std::cout << "foo(1) = " << foo(1) << std::endl; //3
14 std::cout << "foo(2) = " << foo(2) << std::endl; //4
15 return 0;
16}
这是传统的实现闭包的方式,但是并不方便,并不能隐式捕获全体外部作用域变量,需要每个变量都对应构造函数的参数,而且每一段闭包代码都要单独定义一个仿函数的类。
std::bind()绑定器
我们在《C++引用包装》中介绍std::ref()
的时候用过std::bind()
,它是标准库头文件<functional>
中提供的一种绑定机制。
std::function
在了解这种绑定机制之前,需要了解C++11引入的一个模板类std::function<T>
,它是对C++中所有可调用实体的一种类型安全的包装。通过指定它的模板参数,可以统一处理函数、函数指针、函数引用、仿函数、实现了operator()
的对象、lambda等,并运行它们延迟执行。std::function
是一种通用的多态函数封装,它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作。换句话说,它提供了一种函数的容器,将函数容器化之后我们可以更加方便地将它们进行对象化操作。
可调用实体转换为std::function
对象需要遵循两个原则,即转换后的std::function
对象的参数能转换为可调用实体的参数,可调用实体的返回值能转换为std::function
对象的返回值。有一点需要注意,std::function
不能被用来检查相等或不等,但可以与nullptr
进行比较。
- 包装普通函数或函数指针
1//普通函数
2int func(int a) { return a; }
3
4int main() {
5 auto f1 = std::function<int(int)>(func);
6 std::cout << "f1(1) = " << f1(1) << std::endl;
7
8 auto f2 = std::function<int(int)>(&func);
9 std::cout << "f2(1) = " << f2(1) << std::endl;
10 return 0;
11}
普通函数作为参数时,隐式转换为函数指针,所以上述两种包装是等价的。
- 包装lambda表达式
1//lambda表达式
2auto lambda = [](int a) -> int { return a; };
3
4int main() {
5 auto f = std::function<int(int)>(lambda);
6 std::cout << "f(1) = " << f(1) << std::endl;
7 return 0;
8}
- 包装仿函数
1//仿函数
2class Functor {
3public:
4 int operator()(int a) { return a; }
5};
6
7int main() {
8 auto f = std::function<int(int)>(Functor{});
9 std::cout << "f(1) = " << f(1) << std::endl;
10 return 0;
11}
- 包装类成员函数和静态函数
1class A {
2public:
3 //类成员函数
4 int foo(int a) { return a; }
5 //类静态函数
6 static int bar(int a) { return a; }
7};
8
9int main() {
10 A obj;
11
12 //类成员函数
13 auto f1 = std::function<int(int)>(std::bind(&A::foo, &obj, std::placeholders::_1));
14 std::cout << "f1(1) = " << f1(1) << std::endl;
15
16 //类静态函数
17 auto f2 = std::function<int(int)>(&A::bar);
18 //auto f2 = std::function<int(int)>(A::bar); //等价
19 std::cout << "f2(1) = " << f2(1) << std::endl;
20
21 return 0;
22}
std::bind
std::bind
提供一种特殊的绑定机制,可以把可调用实体的某些指定的参数绑定到已有的变量或值,产生一个新的可调用实体。
我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数,我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。
从这个意义看,std::bind
可以看作是一个通用的函数适配器,它接收可调用实体,生成一个新的可调用实体来适配原来的可调用实体的参数列表。std::bind
将可调用实体与其参数一起进行绑定,绑定后的结果使用std::function
进行保存。
- 绑定普通函数或函数指针
1int func(int a, int b) { return a / b; }
2
3int main() {
4 auto f1 = std::bind(func, std::placeholders::_1, 10);
5 std::cout << "f1(1) = " << f1(1) << std::endl; //f1(1) = 0
6
7 auto f2 = std::bind(&func, 5, std::placeholders::_1);
8 std::cout << "f2(1) = " << f2(1) << std::endl; //f2(1) = 5
9
10 auto f3 = std::bind(&func, std::placeholders::_1, std::placeholders::_1);
11 std::cout << "f3(20, 5) = " << f3(20, 5) << std::endl; //f3(20, 5) = 4
12
13 auto f4 = std::bind(&func, 30, 5);
14 std::cout << "f4() = " << f4() << std::endl; //f4() = 6
15 return 0;
16}
std::placeholders::_n
表示占位符。
- 绑定lambda
1auto lambda = [](int a) -> int { return a; };
2
3int main() {
4 auto f = std::bind(lambda, std::placeholders::_1);
5 std::cout << "f(1) = " << f(1) << std::endl;
6 return 0;
7}
- 绑定仿函数
1class Functor {
2public:
3 int operator()(int a) { return a; }
4};
5
6int main() {
7 auto f = std::bind(Functor{}, std::placeholders::_1);
8 std::cout << "f(1) = " << f(1) << std::endl;
9 return 0;
10}
- 绑定类成员函数和静态函数
1class A {
2public:
3 int foo(int a) { return a; }
4 static int bar(int a) { return a; }
5};
6
7int main() {
8 A obj;
9 auto f1 = std::bind(&A::foo, &obj, std::placeholders::_1);
10 std::cout << "f1(1) = " << f1(1) << std::endl;
11
12 auto f2 = std::bind(&A::bar, std::placeholders::_1);
13 std::cout << "f2(1) = " << f2(1) << std::endl;
14 return 0;
15}
绑定类的成员函数时,第一个参数表示对象的成员函数指针,第二个参数表示对象的地址,后续参数都是成员函数的参数。前两个参数都要加&
,这种情况下,对象的成员函数不会隐式转换为函数指针。
绑定类的静态函数时,第一个参数表示类的静态函数指针,后续参数都是静态函数的参数。
- 绑定传引用参数
我们在《C++引用包装》中提到过,std::bind
绑定一个函数,对于需要传入函数的参数,统一使用值传递的方式传参,而不是引用传递的方式传参。但是有些使用场景确实需要引用传参,就需要配合std::ref()
来进行:
1void func(int& a, int& b) {
2 std::cout << "call f()" << std::endl;
3 ++a;
4 ++b;
5 std::cout << "a: " << &a << ", " << a << std::endl;
6 std::cout << "b: " << &b << ", " << b << std::endl;
7}
8
9int main() {
10 auto f = std::bind(&func, std::placeholders::_1, std::placeholders::_2);
11
12 int a = 0, b = 0;
13 int& ra = a;
14
15 std::cout << "before call f()" << std::endl;
16 std::cout << "a: " << &a << ", " << a << std::endl;
17 std::cout << "b: " << &b << ", " << b << std::endl;
18
19 f(ra, std::ref(b));
20
21 std::cout << "after call f()" << std::endl;
22 std::cout << "a: " << &a << ", " << a << std::endl;
23 std::cout << "b: " << &b << ", " << b << std::endl;
24
25 return 0;
26}
运行结果如下:
1before call f()
2a: 0x7ffed44df7e8, 0
3b: 0x7ffed44df7ec, 0
4call f()
5a: 0x7ffed44df810, 1 # <--- 注意这里a的地址,说明参数a是值传递参数
6b: 0x7ffed44df7ec, 1 # <--- 注意这里b的地址,说明参数b是引用传递参数
7after call f()
8a: 0x7ffed44df7e8, 0
9b: 0x7ffed44df7ec, 1
lambda表达式
lambda表达式是C++11引入的一个新特性,是C++闭包实现的另一种方式,lambda得名于数学中的λ演算。lambda表达式其实是一个匿名函数,C++11中的lambda表达式用于定义并创建匿名的函数对象。
lambda并不是完美的闭包,但是它提供了多种捕获方式,完美结合了C++的语言特性。因为捕获变量并不能真正延长变量的生命周期,它只是提供了复制和引用的捕获语义。因为语言特性的关系(RAII),C++无法延长变量作用域,因为局部变量永远都需要在作用域结束后析构。
lambda表达式原型
lambda表达式的完整声明格式如下:
1//完整声明
2[capture_list] (params_list) mutable exception-> return_type { function_body }
3
4//变体1:const表达式,不能修改捕获列表中的值
5[capture_list] (params_list) -> return_type {function_body}
6
7//变体2:根据function_body自动推导返回值类型
8[capture_list] (params_list) {function_body}
9
10//变体3:无参数列表
11[capture_list] {function_body}
- 捕获列表capture_list
[]
是一个lambda表达式开始的表示,不可省略。lambda表达式在编译器编译时会自动转成仿函数,而捕获列表就是用来传递给仿函数构造函数的参数。捕获列表只能使用截至定义lambda表达式为止所有可见的局部变量,包括this
。
捕获列表的内容有以下形式:
捕获列表形式 | 含义 |
---|---|
空 | 没有任何捕获的参数 |
= | 值传递lambda所在作用域内所有可见的局部变量,包含this |
& | 引用传递lambda所在作用域内所有可见的局部变量,包含this |
this | 捕获lambda所在类中的成员变量 |
a | 将变量a 按值传递,函数体内不能修改拷贝进来的变量,默认情况下函数是const 的,除非改为mutable |
&a | 将变量a 按引用传递 |
a, &b | 将变量a 按值传递,b 按引用传递 |
=, &a | 除了变量a 按引用传递外,其他变量都按值传递 |
&, a | 除了变量a 按值传递外,其他变量都按引用传递 |
C++11 lambda的捕获列表不管是值传递还是引用传递,只能捕获左值,不能捕获右值。
C++14 lambda的捕获列表允许捕获的成员用任意表达式进行初始化,代表着可以捕获右值。我们简单看一下例子,在C++14章节中我们还会进一步探讨lambda的改进:
1auto res = std::make_unique<int>(1);
2//res是unique_ptr,不能被=捕获,但可以通过std::move()转成右值,然后在表达式中初始化
3auto add = [v1 = 1, v2 = std::move(res)](int x, int y) -> int {
4 return x + y + v1 + (*v2);
5};
- 参数列表params_list
指在重载operator()
操作符中的参数列表,没有参数时可以省略。参数可以按值传递,也可以按引用传递。
C++11 lambda的参数列表不能使用auto
关键字声明类型,而C++14开始就可以了。
- 可变修饰符mutable
mutable
修饰函数时,与const
修饰函数相反。函数默认是const
修饰的,捕获列表按值传递的参数不可以在函数体内修改,但加上mutable
后是可以修改的。注意修改的也只是传递进函数体的一份拷贝,对外部作用域的变量没有影响。
- 异常提示符exception
用于抛出指定异常,如throw(int)
,不抛出异常时可以省略。
- 函数返回类型return_type
->
指明函数返回的类型,当返回值为void
,或者函数体内只有一处return
时,可以省略,由编译器自动推导返回类型。
- 函数体function_body
{}
内的逻辑就是函数体,不可以省略,但可以为空。
我们通过几个例子进一步认识一下值传递和引用传递,以及mutable
的作用:
1int a = 0;
2std::cout << "a = " << a << std::endl; //a = 0
3
4auto f1 = [=]{ return a; };
5std::cout << "f1() = " << f1() << ", a = " << a << std::endl; //f1() = 0, a = 0
6
7//值传递捕获 + 默认const:函数体内不可以修改a
8//auto f2 = [=]{ return a++; }; //error: increment of read-only variable ‘a’
9//std::cout << "f2() = " << f2() << ", a = " << a << std::endl;
10
11//值传递捕获 + mutable:函数体内可以修改a,但是仅仅是修改函数体内的拷贝版本,不影响外部的a
12auto f3 = [=]() mutable { return ++a; };
13std::cout << "f3() = " << f3() << ", a = " << a << std::endl; //f3() = 1, a = 0
14
15//引用传递捕获:函数体内可以修改a,且影响外部的a
16auto f4 = [&]{ return a++; };
17std::cout << "f4() = " << f4() << ", a = " << a << std::endl; //f4() = 0, a = 1
lambda表达式与仿函数
lambda表达式在编译器编译之后,其实是转换成了仿函数的实现方式,lambda在捕获列表中捕获参数的动作被改写为仿函数构造函数中的形参。在C++11中,lambda表达式可以看作仿函数的一种等价形式。
主要是针对捕获列表进行转换,值传递的捕获参数转换为相同类型的仿函数成员变量,引用传递的捕获参数转换为对应类型的指针类型的仿函数成员变量。参数列表、返回类型和函数体转换为仿函数的重载operator()
操作符。
1int a = 0, b = 0;
2auto f = [a, &b](int x, int y) { return x + y; };
3
4//等价于
5
6struct Functor {
7 int a;
8 int* b;
9 int operator()(int x, int y) { return x + y; }
10};
lambda表达式与函数指针
lambda表达式会产生一个临时对象,这个临时对象是右值,lambda表达式并不是函数指针。但是lambda表达式是允许向函数指针隐式转换的,前提是lambda表达式的捕获列表为空,且函数指针的函数签名必须与lambda表达式有相同的调用方式:
1auto f1 = [](int a, int b) { return a + b; };
2std::cout << "f1(10, 10) = " << f1(10, 10) << std::endl;
3
4using FuncPtr1 = int(*)(int, int);
5
6//lambda隐式转换为函数指针
7FuncPtr1 pFunc1 = f1;
8std::cout << "pFunc1(10, 10) = " << pFunc1(10, 10) << std::endl;
9
10//以下是几种错误行为:
11
12//1.函数指针不允许转换为lambda
13decltype(f1) f2 = pFunc1; //error: conversion from ‘FuncPtr1’ {aka ‘int (*)(int, int)’} to non-scalar type ‘main()::<lambda(int, int)>’ requested
14
15//2.lambda不能转换为签名不一致的函数指针
16using FuncPtr2 = int(*)(int);
17FuncPtr2 pFunc2 = f1; //error: invalid user-defined conversion from ‘main()::<lambda(int, int)>’ to ‘FuncPtr2’ {aka ‘int (*)(int)’}
18
19//3.lambda捕获列表非空不可以转成函数指针
20auto f3 = [=](int a, int b) { return a + b; };
21FuncPtr1 pFunc2 = f3; // error: cannot convert ‘main()::<lambda(int, int)>’ to ‘FuncPtr1’ {aka ‘int (*)(int, int)’} in initialization
lambda表达式的大小
捕获列表为空的lambda表达式,等价于一个空对象,一般只占用1个字节。原因我们在《内存对齐》这篇文章中解释过了:
为了保证每个对象拥有彼此独立的内存地址,C++空类的内存大小为1字节。
捕获列表非空的lambda表达式,大小等于按值捕获的变量的大小 + 按引用捕获的变量的数量 * sizeof(intptr_t)
,然后再按捕获变量最大类型的整数倍进行内存对齐。引用捕获的变量转换到仿函数中,实际上是指针实现的。
1std::cout << sizeof(int) << std::endl; //4
2std::cout << sizeof(intptr_t) << std::endl; //8
3
4int a = 0, b = 0, c = 0;
5auto f1 = []() { };
6std::cout << "sizeof(f1) = " << sizeof(f1) << std::endl; //1
7
8auto f2 = [=]() { return a + b + c; };
9std::cout << "sizeof(f2) = " << sizeof(f2) << std::endl; //12, 3 * sizeof(int)
10
11auto f3 = [&]() { return a + b + c; };
12std::cout << "sizeof(f3) = " << sizeof(f3) << std::endl; //24, 3 * sizeof(intptr_t)
13
14auto f4 = [a, &b]() { return a + b; };
15std::cout << "sizeof(f4) = " << sizeof(f4) << std::endl; //16, 4 + 1 * sizeof(intptr_t) = 12,最大大小为8,内存对齐为8的整数倍,所以是16
16
17auto f5 = [a, &b, &c]() { return a + b + c; };
18std::cout << "sizeof(f5) = " << sizeof(f5) << std::endl; //24, 4 + 2 * sizeof(intptr_t) = 20,最大大小为8,内存对齐为8的整数倍,所以是24
总结
-
闭包是指带有状态的函数,这里的状态指的是调用环境的上下文。函数是代码,状态是一组函数外部的变量,将它们绑定在一起,就形成了闭包。这个绑定的过程发生在运行时。
-
闭包具有捕获并持有外部作用域变量的能力。
-
闭包的实现方式有三种:仿函数、
std::bind
和lambda表达式。 -
std::bind
可以将一切可调用实体绑定到另一个函数,并存储在std::function
中。 -
lambda表达式是C++11引入的新特性,在编译器层面本质是一种仿函数。值传递捕获的参数转成对应类型的成员变量,引用传递捕获的参数转换成对应类型指针的成员变量。
-
lambda表达式的捕获列表可以进行值传递或引用传递,捕获外部作用域的变量,传递到lambda函数体中。
-
lambda表达式的函数体默认是
const
修饰的,不能修改值传递捕获列表中的变量;但是改用mutable
修饰之后就可以修改值传递的捕获列表中的变量。 -
lambda表达式在特殊情况下可以隐式转换为函数指针。
-
lambda表达式的大小可以通过转换为仿函数对象的思路来计算占用大小,仅考虑捕获列表参数的总大小。值传递的捕获参数按类型的大小计算,引用传递的捕获参数按指针的大小计算,总大小遵循内存对齐规则。