C++闭包

Share on:
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表达式的大小可以通过转换为仿函数对象的思路来计算占用大小,仅考虑捕获列表参数的总大小。值传递的捕获参数按类型的大小计算,引用传递的捕获参数按指针的大小计算,总大小遵循内存对齐规则。

Prev Post: 『C++复制消除与RVO/NRVO』
Next Post: 『C++类内初始化』