从runtime源码解析消息发送的动态性
写在前面的话
本文不是对runtime的使用的简单的阐述,而是我对runtime中消息发送的一些更深层的理解。
不要相信任何博客或者文章,apple 的 opensource 源代码会告诉我们想知道的一切,所以善用源码可能会事半功倍。
一、结构体 vs 类
我们知道,OC 是 C 语言的超集,是对 C 和 C++ 的进一步封装,一开始学习 OC 这门语言的时候,我们就被灌输过一句话:对象存储在堆内存,变量存储在栈内存,而 runtime 告诉我们类是对 C 和 C++ 中结构体的封装,而结构体是值类型(值类型 vs 引用类型),肯定是存储在栈上的,这不是自相矛盾吗?另外,OC1.0 是完全对 C 语言的封装,C 语言的结构体是不能声明和实现函数的,到底是怎么回事呢?现在我们用结构体实现一个简单的类: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
35struct Foo {
int val;
// 声明一个指针变量 sum,它的类型为具有一个 int 类型返回值,两个 int 类型参数的函数。
int(*sum)(int,int);
};
typedef struct Foo* PFoo; // PFoo 为一个指向 Foo 结构体的指针类型
int sum(int a, int b) {
return a + b;
}
int main(int argc, const char * argv[]) {
// 声明一个指针指向 Foo 结构体,PFoo就是引用类型,pFoo 就是分配在栈内存的变量
PFoo pFoo;
// 相当于 OC 中的alloc,将实例存入堆内存,现在 pFoo 就指向(引用)一个堆内
// 存的实例
pFoo = (PFoo)malloc(sizeof(PFoo));
// init 初始化操作
pFoo -> val = 4;
// 将函数 sum() 赋值给 pFoo 的成员变量 sum
pFoo -> sum = sum;
// use
// 通过函数指针调用函数,pFoo -> sum 是一个指向函数sum的指针
int result = (pFoo -> sum)(4, 5);
printf("result = %d\n", result);
// print "result = 9"
// 释放内存
free(pFoo);
// 将 pFoo 设置为空指针
pFoo = NULL;
return 0;
}
上述的代码就是用结构体实现一个简单的类,其实真正的runtime对类的实现比这个要复杂的多的多,函数的调用也不是简单的通过函数指针的成员变量调用,说这个只是想引入一下函数指针对类的意义以及值类型和引用类型的关系。
二、OC 消息发送的动态性
1. 动态性
提及 OC 及 runtime,我们听到最多的一句话就是 OC 是一门动态类型的语言,所谓的动态和静态的区分主要是指程序的执行是依赖于编译期还是运行期。
如果一段程序的执行在编译结束后就决定了它的内存分配,那么我们就可以说它是个静态类型的语言,而 OC 的动态性在于,它在编译期只是进行简单的语义语法检查,而不会分配内存。它在编译期只关心某个类型的某个对象能不能调用某个方法,而不会关心这个对象是不是 nil
,也不会关心这个方法的实现细节,甚至不关心到底有没有这个方法,这些事都是运行期才会去做的事。
这就决定了我们可以在运行期对我们的程序做更多的更改,当然也存在很多弊端,有句话说得好:“动态类型一时爽,代码重构火葬场”,运行期分配内存确实会让我们的程序出现很多运行时的错误,比如,访问了野指针、内存泄漏等等,确实会给程序带来很多灾难性的bug,甚至于必须重构代码才能解决。
因此,对运行时的充分了解能使我们尽最大可能的规避这些错误,从而减少我们踩坑的几率和填坑的时间。
2. 消息发送的动态性
举个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void hello() {
printf("Hello, world!");
}
void bye() {
printf("Goodbye, world!");
}
void doSomeThing(int anyState) {
// 函数的调用由编译时决定,函数的汇编指令是硬编码
if (anyState) {
hello();
} else {
bye();
}
}
上述代码是一段简单的C语言代码,不管会不会 C 语言,应该都能看得懂,当调用 doSomeThing()
的时候,不管 if
条件是不是成立,程序都会将 hello()
和 bye()
这两个函数的汇编指令硬编码进汇编指令集。
假设 hello()
和 bye()
这两个函数在代码区中的存储为上图,则在 doSomeThing()
中,编译器会在编译期,在 if
和 else
中都会将这两块内存生成的汇编指令硬编码进汇编指令集。类似于:
这就是一种静态的调用函数的方式,而动态的调用方法为:
1 | void hello() { |
这段代码和上述代码的差异为,在 if
条件语句中调用函数的方式变成了函数指针而不是简单的函数调用。它的动态性体现在,编译器在编译期仅仅获取函数的首地址,将指向函数的首地址硬编码进汇编指令集,而不是将整个函数的指令全部硬编码,到运行时再去决定调用那个函数(访问哪个函数的内存)。如果你在运行时强制将这个本来指向某个函数的指针指向另一个函数,那么这就是所谓的方法交换。
这就是所谓的调用函数的动态性。OC 这门语言就是采用这种函数指针的方式实现消息发送的动态性。当然也不可能实现的这么简单。
真正的汇编指令集肯定不可能这么简单,只是简单画了一下,更容易理解一点。
三、将方法存储到类
OC 的动态性并不仅仅体现在消息发送方面,还有其他的,比如,运行时添加属性、添加成员变量、消息转发等等,其实对属性、变量和方法的封装大同小异,这里仅分析了 runtime 对消息的存储和获取。
大家都知道的一件事就是,OC 中类的实质是结构体,结构体中存储了所有的成员方法列表、属性列表、协议列表等等。存储结构如下图:
可以看到一个 method_array_t
类型的变量 methods
,这就是类中的方法列表,method_array_t
是一个类,所以 methods
指向一个类实例,它在runtime中的组成为:
可以看到,方法列表最终存储的东西为 method_t
结构体,它有三个成员变量,一个 name
,可以理解为方法的签名,OC 会通过方法签名去列表中查找某个方法的实现,runtime 对它的定义为:
1 | /// An opaque type that represents a method selector. |
可以看出这是一个指针类型,指向 objc_selector
结构体。另一个成员为:const char *types
常量为 OC 运行时方法的 typeEncoding 集合,它指定了方法的参数类型以及在函数调用时参数入栈所要的内存空间,没有这个标识就无法动态的压入参数 Type Encoding。
而 IMP imp
就是一个指向函数的函数指针,就是一个指向方法的首地址的指针。IMP
类型被定义为:
1 | /// A pointer to the function of a method implementation. |
可以看出这也是一个指针类型,指向一个函数,即函数指针。当我们向对象的方法列表添加方法的时候,会调用:
1 | BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types) |
addMethod()
会返回一个 IMP
类型的函数指针,这个函数会将传入的 imp
添加进类的函数列表,并且更新缓存,最后返回这个 imp
。如果 addMethod()
方法返回为空指针,则添加失败,返回 false
。addMethod()
方法的具体实现细节为:
1 | static IMP |
根据注释顺序:
1、加写入锁。
2、检查类型,检查类是否实现。
3、声明一个指针变量,指向 method_t
结构体,判断方法是否已经存在。
4、如果方法已经存在,判断是替换方法还是添加方法,如果不是替换,直接返回已经存在的方法的实现,如果是替换,则直接覆盖原方法。
5、如果方法不存在,则将其添加进入方法列表。
更具体的实现:runtime,可以下载最新的 runtime 源码查看。
四、从类中查找方法
当我们向对象发送消息的时候:
1 | id returnValue = [obj doSomeThingWithParams:params]; |
编译器会将它编译成原型为:
1 | void objc_msgSend(id self, SEL cmd, ...); |
的 C 函数。所以上面的函数会被翻译成:
1 | id returnValue = objc_msgSend(obj, @selector(doSomeThingWithParams:), params); |
这是一个标准的 C 函数,而且知道运行时的 iOS 开发者大部分都对它有所了解。我们来看一下,runtime 如何通过这个函数实现 doSomeThingWithParams
这个方法的调用。
当我们使用 objc_msgSend()
调用函数时,函数的调用栈为:
1 | 0 lookUpImpOrForward |
可以看到在调用了 objc_msgSend
之后,调用了 class_lookupMethodAndLoadCache3
这个函数,这个函数名的字面意思为:从类中查找方法并且加载缓存。这个函数的实现为:
1 | IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) |
就调用了一个函数 lookUpImpOrForward()
,这个函数名的字面意思是:查找 imp
或者转发,可以看出来,这个方法应该就是从方法列表中查找函数指针的那个方法了。它的实现为:
1 | /*********************************************************************** |
源码中给的注释很清楚,先从优化缓存中查找 imp
,如果有直接返回,如果没有,先判断类是否实现,如果没有就去实现类,然后判断类是否初始化,如果没有就去初始化,再然后去类中的缓存列表中查找,找到就返回,如果没找到,再去父类的缓存和父类的方法列表中查找,找到就返回,如果还是没有,则允许一次 resolve
,如果还是没有,则进入消息转发。
然后就可以使用返回的 imp
和汇编指令完成方法的调用了。对汇编精通的可以参考源码中的 objc-msg
模块查看汇编指令对 imp
的使用。
One More Thing
runtime 是 objc 的核心动态库,基本涵盖了程序运行之后发生的一切,如果真正想学习它的编程思想的话,还请阅读源码,博客仅有参考和记录的意义,况且还有一些内容为一家之言,不可尽信。源码会告诉我们一切哦。