从函数指针开始的回调编程

在日常编码工作中肯定会接触一个基础概念——回调

在计算机程序设计中,回调函数,或简称回调(Callback 即call then back 被主函数调用运算后会返回主函数),是指通过函数参数传递到其它代码的,某一块可执行代码的引用。这一设计允许了底层代码调用在高层定义的子程序。

可以看到回调这个概念实际上是编程思想中依赖翻转(Dependency Injection)设计原则的体现。

在不同语言都可以找到回调这个概念的表现形式,比如C/C++中的函数指针、Objective-C中的Block、Swift中的Closure、C++11中的Lambda表达式等等。对于它们的实现上我想探究一下以这些形式实现的代码中变量们的行为。

变量以及它的作用域、生命期

变量顾明思议是指在程序执行过程中数值可以变化的量,与之相对应的常量指的是编译时期就已知且不可变的量。

变量的作用域指的是这个变量的有效区域,按作用域可以将变量分为局部变量全局变量。顾名思义,局部变量只能由声明它的函数或代码块{}中访问,全局变量是在所有作用域都可访问的变量。

对于数值类型的变量错误使用了它的作用域可能就是造成计算结果不一致,而对于指针类型的变量错误它的作用域,比如下面这种在本身代码块作用域外被引用会造成无法预测的行为。

变量的生命期则指的是这块变量所占用的内存从生成到释放的整个周期。如果这个变量是一个栈(Stack)上的变量的话,它的生命周期将由编译器在编译期就决定好了,假如它是默认的auto变量的话则它的生命期从代码块{}开始到代码块结束。如果是一个堆(Heap)上的变量,这个变量的在程序运行时的生命期则将由工程师们来决定。

函数指针、Block、Closure、Lambda表达式

回到回调(Call Back)这个话题上,我们知道在C语言中有函数指针、Objective-C中有Block、Swift中有Closure、C++11中有Lambda表达式。

函数指针没有啥好说的吧,就是一个指向函数名的指针。我们可以让将它作为另一个函数的形参传递下去,同时auto变量的作用域和生命期具体要看当前上下文是什么环境才能确定。比如下面这段代码中data[8]是直接在栈上定义的所以它的内部变量应该也是存在于栈上的,若我们通过malloc定义一个空间则它的内部变量则会是存在于堆上的。

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
struct object {
int data;
};

int object_compare(struct object * a,struct object * z) {
return a->data < z->data ? 1 : 0;
}

struct object *maximum(struct object * begin,struct object * end,int (* compare)(struct object *, struct object *)) {
struct object * result = begin;
while(begin != end) {
if(compare(result, begin)) {
result = begin;
}
++ begin;
}
return result;
}

int main(void) {
struct object data[8] = {{0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}};
struct object *max;
max = maximum(data + 0, data + 8, & object_compare);
return 0;
}

函数指针是一种比较原理的形式,而其他几种形式在这基础之上实现了一个重要的特性——允许捕获外部变量。这个特性的实现跟变量的作用域、生命期有密切的关系。接下来我们就来研究一下Objective-C中这个特性是如何实现的。

Objective-C中Block是如何捕获外部变量?

这里通过clang -rewrite-objc block.m这条命令将Objective-C代码转换成C++代码来观察观察Objective-C的Block是如何实现捕获外部变量的。

通过C++代码可以看到实际上Block捕获外部变量就是生成的Block结构体实现方式上的不同,通过巧妙的手法将外部变量赋予这个结构体中相应的变量,从而实现外部变量的捕获。

以只读的形式捕获外部变量

对于auto自动局部变量可以看到在编译器生成的__hellowrold_block_iml_0结构体中新增了一个对应的成员变量,然后以形参的形式传入这个结构体中。故进入block前和进入block中的两个变量地址不是同一个。进入前是一个在栈上的局部变量,进入后是一个在堆上结构体的局部变量。

对于static局部变量或者全局变量可以看到在编译器生成的__hellowrold_block_iml_0结构体中并未生成一个新的成员变量,那是因为对于static局部变量或者全局变量它们的作用域都可以直接被结构体访问。故进入block前和进入block中的两个变量地址是同一个(假如跨线程访问则会存在竞态问题,需要注意)。

__block修饰后可读可写捕获外部变量

__block只能修饰局部变量,static局部变量或者全局变量Xcode会直接报错。__block修饰语义是要实现在进入Block后让它捕获的变量全部变成Block中相应的变量这个目的(实现Block中修改对外部也起作用)。

对于auto自动局部变量可以看到,这里相对于以只读形式捕获变量用__block修饰后源码里面多了一个__Block_byref_error_0这么一个重定义变量类型的结构体,它将这段代码块{}上下文以及Block中的error对象都转换成这个结构体来访问。可以看到和前面的原理是一致的这个结构体作为形参传入Block中故变量从栈上变到了堆上,而当在Block外部通过__forwarding实现让指向堆那个error。

参考

回调函数
C 程序存储和运行时的几个区域
【基础编程】聊聊C语言-变量的寿命
Blocks Programming Topics
深入研究Block捕获外部变量和__block实现原理