这里主要讨论的是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-statement
lambda-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.cpp
bash-3.2# g++-4.7 -std=c++11 testLambda.cpp -o testLambda
bash-3.2# ./testLambda
1024
bash-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 = 10
def foo():
    print(x)
    x += 1
foo()

这段代码会抛出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_IMPL
struct __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 : 0
testBlock : Begin to exec blk.
2048
After test()

如果按照注释,将test()函数最后一行改为休眠1s的话,正常情况下程序会在输出如下结果后崩溃:

1
2
3
4
Before test()
thread returns : 0
After 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 : 0
After 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;
}
  1. 在test()函数中调用pthread_create创建线程时,blk被复制了一份到堆上作为testBlock函数的参数。
  2. test()函数中的blk结构体位于栈中,在休眠1s后被执行,对i进行自增动作。
  3. testBlock函数在休眠2s后,执行位于堆上的block结构体,这里为demoBlk。

上述代码执行后输出:

1
2
3
4
5
6
Before test()
thread returns : 0
1025
After test()
testBlock : Begin to exec blk.
1026

可见无论是栈上的还是堆上的block结构体,修改的都是同一个__block变量。

这就是前面提到的__forwarding指针成员的作用了:

起初,栈上的__block变量的成员指针__forwarding指向__block变量本身,即栈上的__Block_byref_i_0结构体。

当__block变量被复制到堆上后,栈上的__block变量的__forwarding成员会指向堆上的那一份拷贝,从而保持一致。

参考资料:

C/C++语言中闭包的探究及比较的更多相关文章

  1. 探究C语言中的前++和后++

    小波带您探究c语言中的前++与后++: 欢迎吐槽,欢迎加QQ463431476. 欢迎关注!  现在来探究: 咱们先看第一个 i被赋值0,i++(后++)并没有输出1.   现在i被赋值0,++i,也 ...

  2. Go语言中的闭包

    一.函数的变量作用域和可见性 1.全局变量在main函数执行之前初始化,全局可见 2.局部变量在函数内部或者if.for等语句块有效,使用之后外部不可见 3.全局变量和局部变量同名的情况下,局部变量生 ...

  3. 001_解析go语言中的闭包

    go语言中的闭包,是大家学习go语言的一个大难点,笔者在学习时候也是痛苦不堪,在来回对比了其它语言的用法,并且查阅了很多网上的文章,终于对闭包有了一个较为清晰的认识,以下就是关于闭包的解析 首先看一个 ...

  4. 如何在C语言中调用Swift函数

    在Apple官方的<Using Swift with Cocoa and Objectgive-C>一书中详细地介绍了如何在Objective-C中使用Swift的类以及如何在Swift中 ...

  5. Go语言中函数的实现

    Go 语言函数 函数是基本的代码块,用于执行一个任务. Go 语言最少有个 main() 函数. 你可以通过函数来划分不同功能,逻辑上每个函数执行的是指定的任务. 函数声明告诉了编译器函数的名称,返回 ...

  6. C#中闭包的陷阱

    我们在使用lambda的时候会遇到闭包,在闭包中有一个陷阱是在for循环中产生的,先上代码: class Program { static void Main(string[] args) { Act ...

  7. go语言中的函数

    package main; import "fmt" func main() { a, b, c := A(1, 2, 3); fmt.Println(a, b, c); //调用 ...

  8. js中闭包和对象相关知识点

    学习js时候,读到几篇不错的博客.http://www.cnblogs.com/yexiaochai/p/3802681.html一,作用域 和C.C++.Java 等常见语言不同,JavaScrip ...

  9. c语言中会遇到的面试题

    预处理器(Preprocessor) 1 . 用预处理指令#define 声明一个常数,用以表明1年中有多少秒(忽略闰年问题)         #define SECONDS_PER_YEAR (60 ...

随机推荐

  1. Java Synchronized 关键字

    本文内容 Synchronized 关键字 示例 Synchronized 方法 内部锁(Intrinsic Locks)和 Synchronization 参考资料 下载 Demo Synchron ...

  2. Solidworks如何打开swb文件

    把swb文件拖放到Solidworks里面,会弹出窗口选择一个文件夹   随后会自动生成对应的文件,装配体  

  3. javascript获取和设置URL中的参数

    勘误版 function getQuery(key, url) { url = url || window.location.href; if (url.indexOf('#') !== -1) ur ...

  4. VMware的存储野心(上):软件定义、分布式DAS支持

    ChinaByte比特网 http://storage.chinabyte.com/291/12477791_2.shtml 11月29日(文/黄亮)- SDN(软件定义的网络,Software De ...

  5. ububuntu配置ip和dns

    装完ubuntu 第一件事情就是连上网,换个源,进行更新操作,但前提条件是要配好ip和dns. 下面把自己配置的过程记录下来,权且当作一份备份,以便不时之需. 一.配置ip ubuntu的网络配置信息 ...

  6. Shell编程初步

      一:Hello World 新建一个文件,命名时以 .sh 为后缀.每个bash文件开头第一行表名文件类型: #!/bin/bash 然后在下面输入代码. 比如输出hello world: #!/ ...

  7. 关于Git HEAD^与HEAD~的关系

    关于Git HEAD^与HEAD~的关系 请参考下图,来自stackoverflow http://stackoverflow.com/questions/2221658/whats-the-diff ...

  8. Linux修改系统主机名

    Linux修改系统主机名 一.查看主机名   [root@xqzt ~]# uname -n xqzt [root@xqzt ~]# hostname xqzt 二.修改hostname的四种方式? ...

  9. Java 底层机制(JVM/堆/栈/方法区/GC/类加载)

    转载:https://www.jianshu.com/p/ae97b692614e?from=timeline JVM体系结构 JVM是一种解释执行class文件的规范技术.   JVM体系结构 我翻 ...

  10. Citrix Port(常用端口)

    组件 类型 端口 描述 CitrixLicenseServer       许可管理器守护程序 TCP 27000 处理初始接触点的许可证要求(Lmadmin.exe) 思杰供应商守护程序 TCP 7 ...