C/C++语言中闭包的探究及比较
这里主要讨论的是C语言的扩展特性block。该特性是Apple为C、C++、Objective-C增加的扩展,让这些语言可以用类Lambda表达式的语法来创建闭包。前段时间,在对CoreData存取进行封装时(让开发人员可以更简洁快速地写相关代码),我对block机制有了进一步了解,觉得可以和C++ 11中的Lambda表达式相互印证,所以最近重新做了下整理,分享给大家。
0. 简单创建匿名函数
下面两段代码的作用都是创建匿名函数并调用,输出Hello, World语句。分别使用Objective-C和C++ 11:
| 
 1 
 | 
^{ printf("Hello, World!\n"); } (); | 
| 
 1 
 | 
[] { cout << "Hello, World" << endl; } (); | 
Lambda表达式的一个好处就是让开发人员可以在需要的时候临时创建函数,便捷。
在创建闭包(或者说Lambda函数)的语法上,Objective-C采用的是上尖号^,而C++ 11采用的是配对的方括号[]。
不过“匿名函数”一词是针对程序员而言的,编译器还是采取了一定的命名规则。
比如下面Objective-C代码中的3个block,
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
 | 
#import <Foundation/Foundation.h>int (^maxBlk)(int , int) = ^(int m, int n){ return m > n ? m : n; };int main(int argc, const char * argv[]){    ^{ printf("Hello, World!\n"); } ();    int i = 1024;    void (^blk)(void) = ^{ printf("%d\n", i); };    blk();    return 0;} | 
会产生对应的3个函数:
| 
 1 
2 
3 
 | 
__maxBlk_block_func_0__main_block_func_0__main_block_func_1 | 
可见函数的命名规则为:__{$Scope}_block_func_{$index}。其中{$Scope}为block所在函数,如果{$Scope}为全局就取block本身的名称;{$index}表示该block在{$Scope}作用域内出现的顺序(第几个block)。
1. 从语法上看如何捕获外部变量
在上面的代码中,已经看到“匿名函数”可以直接访问外围作用域的变量i:
| 
 1 
2 
3 
 | 
int i = 1024;void (^blk)(void) = ^{ printf("%d\n", i); };blk(); | 
当匿名函数和non-local变量结合起来,就形成了闭包(个人看法)。
 这一段代码可以成功输出i的值。
我们把一样的逻辑搬到C++上:
| 
 1 
2 
3 
 | 
int i = 1024;auto func = [] { printf("%d\n", i); };func(); | 
GCC会输出:错误:‘i’未被捕获。可见在C++中无法直接捕获外围作用域的变量。
以BNF来表示Lambda表达式的上下文无关文法,存在:
| 
 1 
2 
 | 
lambda-expression : lambda-introducer lambda-parameter-declarationopt compound-statementlambda-introducer : [ lambda-captureopt ] | 
因此,方括号中还可以加入一些选项:
| 
 1 
2 
3 
4 
5 
6 
 | 
[]        Capture nothing (or, a scorched earth strategy?)[&]       Capture any referenced variable by reference[=]       Capture any referenced variable by making a copy[=, &foo] Capture any referenced variable by making a copy, but capture variable foo by reference[bar]     Capture bar by making a copy; don't copy anything else[this]    Capture the this pointer of the enclosing class | 
根据文法,对代码加以修改,使其能够成功运行:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
 | 
bash-3.2# vi testLambda.cppbash-3.2# g++-4.7 -std=c++11 testLambda.cpp -o testLambdabash-3.2# ./testLambda1024bash-3.2# cat testLambda.cpp#include <iostream>using  namespace std;int main(){     int i = 1024;     auto func = [=] { printf("%d\n", i); };     func();     return 0;}bash-3.2# | 
2. 从语法上看如何修改外部变量
上面代码中使用了符号=,通过拷贝方式捕获了外部变量i。
 但是如果尝试在Lambda表达式中修改变量i:
| 
 1 
 | 
auto func = [=] { i = 0; printf("%d\n", i); }; | 
会得到错误:
| 
 1 
2 
 | 
testLambda.cpp: 在 lambda 函数中:testLambda.cpp:9:24: 错误:向只读变量‘i’赋值 | 
可见通过拷贝方式捕获的外部变量是只读的。Python中也有一个类似的经典case,个人觉得有相通之处:
| 
 1 
2 
3 
4 
5 
 | 
x = 10def foo():    print(x)    x += 1foo() | 
这段代码会抛出UnboundLocalError错误,原因可以参见FAQ。
在C++的闭包语法中,如果需要对外部变量的写权限,可以使用符号&,通过引用方式捕获:
| 
 1 
2 
3 
 | 
int i = 1024;auto func = [&] { i = 0; printf("%d\n", i); };func(); | 
反过来,将修改外部变量的逻辑放到Objective-C代码中:
| 
 1 
2 
3 
 | 
int i = 1024;void (^blk)(void) = ^{ i = 0; printf("%d\n", i); };blk(); | 
会得到如下错误:
| 
 1 
2 
3 
4 
 | 
main.m:14:29: error: variable is not assignable (missing __block type specifier)    void (^blk)(void) = ^{ i++; printf("%d\n", i); };                           ~^1 error generated. | 
可见在block的语法中,默认捕获的外部变量也是只读的,如果要修改外部变量,需要使用__block类型指示符进行修饰。
 为什么呢?请继续往下看 :)
3. 从实现上看如何捕获外部变量
闭包对于编程语言来说是一种语法糖,包括Block和Lambda,是为了方便程序员开发而引入的。因此,对Block特性的支持会落地在编译器前端,中间代码将会是C语言。
先看如下代码会产生怎样的中间代码。
| 
 1 
2 
3 
4 
5 
6 
7 
8 
 | 
int main(int argc, const char * argv[]){    int i = 1024;    void (^blk)(void) = ^{ printf("%d\n", i); };    blk();    return 0;} | 
首先是block结构体的实现:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
 | 
#ifndef BLOCK_IMPL#define BLOCK_IMPLstruct __block_impl {    void *isa;    int Flags;    int Reserved;    void *FuncPtr;};// 省略部分代码#endif | 
第一个成员isa指针用来表示该结构体的类型,使其仍然处于Cocoa的对象体系中,类似Python对象系统中的PyObject。
第二、三个成员是标志位和保留位。
第四个成员是对应的“匿名函数”,在这个例子中对应函数:
| 
 1 
2 
3 
4 
 | 
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {    int i = __cself->i; // bound by copy    printf("%d\n", i);} | 
函数__main_block_func_0引入了参数__cself,为struct __main_block_impl_0 *类型,从参数名称就可以看出它的功能类似于C++中的this指针或者Objective-C的self。
 而struct __main_block_impl_0的结构如下:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
 | 
struct __main_block_impl_0 {    struct __block_impl impl;    struct __main_block_desc_0* Desc;    int i;    __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _i, int flags=0) : i(_i) {        impl.isa = &_NSConcreteStackBlock;        impl.Flags = flags;        impl.FuncPtr = fp;        Desc = desc;    }}; | 
从__main_block_impl_0这个名称可以看出该结构体是为main函数中第零个block服务的,即示例代码中的blk;也可以猜到不同场景下的block对应的结构体不同,但本质上第一个成员一定是struct __block_impl impl,因为这个成员是block实现的基石。
结构体__main_block_impl_0又引入了一个新的结构体,也是中间代码里最后一个结构体:
| 
 1 
2 
3 
4 
 | 
static struct __main_block_desc_0 {    unsigned long reserved;    unsigned long Block_size;} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)}; | 
可以看出,这个描述性质的结构体包含的价值信息就是struct __main_block_impl_0的大小。
最后剩下main函数对应的中间代码:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
 | 
int main(int argc, const char * argv[]){    int i = 1024;    void (*blk)(void) = (void (*)(void))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, i);    ((void (*)(struct __block_impl *))((struct __block_impl *)blk)->FuncPtr)((struct __block_impl *)blk);    return 0;} | 
从main函数对应的中间代码可以看出执行block的本质就是以block结构体自身作为__cself参数,这里对应__main_block_impl_0,通过结构体成员FuncPtr函数指针调用对应的函数,这里对应__main_block_func_0。
其中,局部变量i是以值传递的方式拷贝一份,作为__main_block_impl_0的构造函数的参数,并以初始化列表的形式赋值给其成员变量i。所以,基于这样的实现,不允许直接修改外部变量是合理的——因为按值传递根本改不到外部变量。
4. 从实现上看如何修改外部变量(__block类型指示符)
如果想要修改外部变量,则需要用__block来修饰:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
 | 
int main(int argc, const char * argv[]){    __block int i = 1024;    void (^blk)(void) = ^{ i = 0; printf("%d\n", i); };    blk();    return 0;} | 
此时再看中间代码,发现多了一个结构体:
| 
 1 
2 
3 
4 
5 
6 
7 
 | 
struct __Block_byref_i_0 {    void *__isa;    __Block_byref_i_0 *__forwarding;    int __flags;    int __size;    int i;}; | 
于是,用__block修饰的int变量i化身为__Block_byref_i_0结构体的最后一个成员变量。
代码中blk对应的结构体也发生了变化:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
 | 
struct __main_block_impl_0 {    struct __block_impl impl;    struct __main_block_desc_0* Desc;    __Block_byref_i_0 *i; // by ref    __main_block_impl_0(void *fp, struct__main_block_desc_0 *desc, __Block_byref_i_0 *_i, int flags=0) : i(_i->__forwarding) {        impl.isa = &_NSConcreteStackBlock;        impl.Flags = flags;        impl.FuncPtr = fp;        Desc = desc;    }}; | 
__main_block_impl_0发生的变化就是int类型的成员变量i换成了__Block_byref_i_0 *类型,从名称可以看出现在要通过引用方式来捕获了。
对应的函数也不同了:
| 
 1 
2 
3 
4 
5 
 | 
static void __main_block_func_0(struct  __main_block_impl_0 *__cself) {    __Block_byref_i_0 *i = __cself->i; // bound by ref    (i->__forwarding->i) = 0; // 看起来很厉害的样子    printf("%d\n", (i->__forwarding->i));} | 
main函数也有了变动:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
 | 
int main(int argc, const char * argv[]){    __block __Block_byref_i_0 i = {(void*)0,(__Block_byref_i_0 *)&i, 0, sizeof(__Block_byref_i_0), 1024};    void (*blk)(void) = (void (*)(void))&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (struct __Block_byref_i_0 *)&i, 570425344);    ((void (*)(struct __block_impl *))((struct __block_impl *)blk)->FuncPtr)((struct __block_impl *)blk);    return 0;} | 
前两行代码创建了两个关键结构体,特地高亮显示。
这里没有看__main_block_desc_0发生的变化,放到后面讨论。
使用__block类型指示符的本质就是引入了__Block_byref_{$var_name}_{$index}结构体,而被__block关键字修饰的变量就被放到这个结构体中。另外,block结构体通过引入__Block_byref_{$var_name}_{$index}指针类型的成员,得以间接访问到外部变量。
通过这样的设计,我们就可以修改外部作用域的变量了,再一次应了那句话:
There is no problem in computer science that can’t be solved by adding another level of indirection.
指针是我们最经常使用的间接手段,而这里的本质也是通过指针来间接访问,为什么要特地引入__Block_byref_{$var_name}_{$index}结构体,而不是直接使用int *来访问外部变量i呢?
另外,__Block_byref_{$var_name}_{$index}结构体中的__forwarding指针成员有何作用?
请继续往下看 :)
5. 背后的内存管理动作
在Objective-C中,block特性的引入是为了让程序员可以更简洁优雅地编写并发代码(配合看起来像敏感词的GCD)。比较常见的就是将block作为函数参数传递,以供后续回调执行。
先看一段完整的、可执行的代码:
| 
 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 
 | 
#import <Foundation/Foundation.h>#include <pthread.h>typedef void (^DemoBlock)(void);void test();void *testBlock(void *blk);int main(int argc, const char * argv[]){    printf("Before test()\n");    test();    printf("After test()\n");    sleep(5);    return 0;}void test(){    __block int i = 1024;    void (^blk)(void) = ^{ i = 2048; printf("%d\n", i); };    pthread_t thread;    int ret = pthread_create(&thread, NULL, testBlock, (void *)blk);    printf("thread returns : %d\n", ret);    sleep(3); // 这里睡眠1s的话,程序会崩溃}void *testBlock(void *blk){    sleep(2);    printf("testBlock : Begin to exec blk.\n");    DemoBlock demoBlk = (DemoBlock)blk;    demoBlk();    return NULL;} | 
在这个示例中,位于test()函数的block类型的变量blk就作为函数参数传递给testBlock。
正常情况下,这段代码可以成功运行,输出:
| 
 1 
2 
3 
4 
5 
 | 
Before test()thread returns : 0testBlock : Begin to exec blk.2048After test() | 
如果按照注释,将test()函数最后一行改为休眠1s的话,正常情况下程序会在输出如下结果后崩溃:
| 
 1 
2 
3 
4 
 | 
Before test()thread returns : 0After test()testBlock : Begin to exec blk. | 
从输出可以看出,当要执行blk的时候,test()已经执行完毕回到main函数中,对应的函数栈也已经展开,此时栈上的变量已经不存在了,继续访问导致崩溃——这也是不用int *直接访问外部变量i的原因。
5.1 拷贝block结构体
上文提到block结构体__block_impl的第一个成员是isa指针,使其成为NSObject的子类,所以我们可以通过相应的内存管理机制将其拷贝到堆上:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
 | 
void test(){    __block int i = 1024;    void (^blk)(void) = ^{ i = 2048; printf("%d\n", i); };    pthread_t thread;    int ret = pthread_create(&thread, NULL, testBlock, (void *)[blk copy]);    printf("thread returns : %d\n", ret);    sleep(1);}void *testBlock(void *blk){    sleep(2);    printf("testBlock : Begin to exec blk.\n");    DemoBlock demoBlk = (DemoBlock)blk;    demoBlk();    [demoBlk release];    returnNULL;} | 
再次执行,得到输出:
| 
 1 
2 
3 
4 
5 
 | 
Before test()thread returns : 0After test()testBlock : Begin to exec blk.2048 | 
可以看出,在test()函数栈展开后,demoBlk仍然可以成功执行,这是由于blk对应的block结构体__main_block_impl_0已经在堆上了。不过这还不够——
5.2 拷贝捕获的变量(__block变量)
在拷贝block结构体的同时,还会将捕获的__block变量,即结构体__Block_byref_i_0,复制到堆上。这个任务落在前面没有讨论的__main_block_desc_0结构体身上:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
 | 
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->i, (void*)src->i, 8/*BLOCK_FIELD_IS_BYREF*/);}static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->i, 8/*BLOCK_FIELD_IS_BYREF*/);}static struct __main_block_desc_0 {    unsigned long reserved;    unsigned long Block_size;    void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);    void (*dispose)(struct __main_block_impl_0*);} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0}; | 
栈上的__main_block_impl_0结构体为src,堆上的__main_block_impl_0结构体为dst,当发生复制动作时,__main_block_copy_0函数会得到调用,将src的成员变量i,即__Block_byref_i_0结构体,也复制到堆上。
5.3 __forwarding指针的作用
当复制动作完成后,栈上和堆上都存在着__main_block_impl_0结构体。如果栈上、堆上的block结构体都对捕获的外部变量进行操作,会如何?
下面是一段示例代码:
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
 | 
void test(){    __block int i = 1024;    void (^blk)(void) = ^{ i++; printf("%d\n", i); };    pthread_t thread;    int ret = pthread_create(&thread, NULL, testBlock, (void *)[blk copy]);    printf("thread returns : %d\n", ret);    sleep(1);    blk();}void *testBlock(void *blk){    sleep(2);    printf("testBlock : Begin to exec blk.\n");    DemoBlock demoBlk = (DemoBlock)blk;    demoBlk();    [demoBlk release];    returnNULL;} | 
- 在test()函数中调用pthread_create创建线程时,blk被复制了一份到堆上作为testBlock函数的参数。
 - test()函数中的blk结构体位于栈中,在休眠1s后被执行,对i进行自增动作。
 - testBlock函数在休眠2s后,执行位于堆上的block结构体,这里为demoBlk。
 
上述代码执行后输出:
| 
 1 
2 
3 
4 
5 
6 
 | 
Before test()thread returns : 01025After test()testBlock : Begin to exec blk.1026 | 
可见无论是栈上的还是堆上的block结构体,修改的都是同一个__block变量。
这就是前面提到的__forwarding指针成员的作用了:
起初,栈上的__block变量的成员指针__forwarding指向__block变量本身,即栈上的__Block_byref_i_0结构体。
当__block变量被复制到堆上后,栈上的__block变量的__forwarding成员会指向堆上的那一份拷贝,从而保持一致。
参考资料:
C/C++语言中闭包的探究及比较的更多相关文章
- 探究C语言中的前++和后++
		
小波带您探究c语言中的前++与后++: 欢迎吐槽,欢迎加QQ463431476. 欢迎关注! 现在来探究: 咱们先看第一个 i被赋值0,i++(后++)并没有输出1. 现在i被赋值0,++i,也 ...
 - Go语言中的闭包
		
一.函数的变量作用域和可见性 1.全局变量在main函数执行之前初始化,全局可见 2.局部变量在函数内部或者if.for等语句块有效,使用之后外部不可见 3.全局变量和局部变量同名的情况下,局部变量生 ...
 - 001_解析go语言中的闭包
		
go语言中的闭包,是大家学习go语言的一个大难点,笔者在学习时候也是痛苦不堪,在来回对比了其它语言的用法,并且查阅了很多网上的文章,终于对闭包有了一个较为清晰的认识,以下就是关于闭包的解析 首先看一个 ...
 - 如何在C语言中调用Swift函数
		
在Apple官方的<Using Swift with Cocoa and Objectgive-C>一书中详细地介绍了如何在Objective-C中使用Swift的类以及如何在Swift中 ...
 - Go语言中函数的实现
		
Go 语言函数 函数是基本的代码块,用于执行一个任务. Go 语言最少有个 main() 函数. 你可以通过函数来划分不同功能,逻辑上每个函数执行的是指定的任务. 函数声明告诉了编译器函数的名称,返回 ...
 - C#中闭包的陷阱
		
我们在使用lambda的时候会遇到闭包,在闭包中有一个陷阱是在for循环中产生的,先上代码: class Program { static void Main(string[] args) { Act ...
 - go语言中的函数
		
package main; import "fmt" func main() { a, b, c := A(1, 2, 3); fmt.Println(a, b, c); //调用 ...
 - js中闭包和对象相关知识点
		
学习js时候,读到几篇不错的博客.http://www.cnblogs.com/yexiaochai/p/3802681.html一,作用域 和C.C++.Java 等常见语言不同,JavaScrip ...
 - c语言中会遇到的面试题
		
预处理器(Preprocessor) 1 . 用预处理指令#define 声明一个常数,用以表明1年中有多少秒(忽略闰年问题) #define SECONDS_PER_YEAR (60 ...
 
随机推荐
- Skyline开发4-IProject接口
			
IProject接口可以访问工程设置和打开保存工程的基本方法. 属性 FileVersion:返回 ITEVersionInfo.表示当前运行的TerraExplorer的版本,可通过ITEVersi ...
 - asp.net集合类
			
1.返回IEnumerable类型 protected void Page_Load(object sender, EventArgs e) { IEnumerable ie = AllGet(); ...
 - 保密员(baomi)
			
#include<iostream> #include<string> #include<stdio.h> #include<algorithm> #i ...
 - Nginx反向代理转发Host设置
			
默认情况下反向代理是不会转发请求中的Host头部,如果需要转发,则需要配置红色字体表示的选项参数. location /t02 { proxy_set_header Host $host; proxy ...
 - springboot整合mybatis的两种方式
			
https://blog.csdn.net/qq_32719003/article/details/72123917 springboot通过java bean集成通用mapper的两种方式 前言:公 ...
 - stingray中modal window
			
自定义内容modal window //show window for D&B suggestions function showDBMatch(resp) { console.log('xx ...
 - python 解析top文件格式
			
top - 16:14:35 up 2 days, 3:04, 7 users, load average: 2.22, 1.84, 1.77 Tasks: 512 total, 2 running, ...
 - 利用 pywin32 操作 excel
			
from win32com.client import Dispatch import win32com.client import time # 获取excel 对象 excel = win32co ...
 - JSP之include动态包含与静态包含
			
转载请注明原文地址:http://www.cnblogs.com/ygj0930/p/6044676.html JSP中,include是一个经常用到的标签.当应用程序中所有的页面的某些部分(如标题. ...
 - OpenCV实现任意大小图片的合并(转)
			
OpenCV实现图像合并主要有两种方法 方法一:使用Mat.push_back方法将列数相同的图像加到最后一行 方法二: 主要思路是将图像拷贝到待合并图像的感兴趣区域 (1)新建一个要合并的图像(容器 ...