作者:冬瓜
原文链接:Guardia · 瓜地
在Effective Objective-C 2.0 – 52 Specific Ways to Improve Your iOS and OS X Programs一书中,tip 11主要讲述了Objective-C中的消息传递机制。这也是Objective-C在C的基础上,做的最基础也是最重要的封装。
Static Binding And Dynamic Binding
C中的函数调用方式,是使用的静态绑定(static binding),即在编译期就能决定运行时所应调用的函数。而在Objective-C中,如果向某对象传递消息,就会使用动态绑定机制来决定需要调用的方法。而对于Objective-C的底层实现,都是C的函数。对象在收到消息之后,调用了哪些方法,完全取决于Runtime来决定,甚至可以在Runtime期间改变。
一般地,对对象发送消息,我们使用这种写法:
1 |
id returnValue = [DGObject test]; |
其中someObject
为接收者(receiver),messageName
为选择子(selector)。当Compiler看的这条语句时,会将其转换成为一条标准的消息传递的C函数,objc_msgSend
,形如:
1 |
void objc_msgSend(id self, SEL cmd, ...) |
其中,SEL
也就是之前对应的选择子,即为此文讨论的重点。我们对应的写出之前代码在Compiler处理后的C语句:
1 |
id returnValue = objc_msgSend(DGObject, @selector(test)); |
@selector()
对于SEL
类型,也就是我们经常使用的@selector()
,在很多的书籍资料中的定义是这样:
1 |
typedef struct objc_selector *SEL; |
而至于这个objc_selector
的结构体是如何定义的,这就要取决于我们Runtime框架的类型,在iOS开发中,我们使用的是Apple的(GNU也有Runtime的framework)。在OS X中
SEL
被映射成为一个C字符串(char[]),这个字符串也就是方法名。
我们在lldb中,进行测试:
(图释:test
是在DGObject
Class中已经定义的方法名,而not_define_test
和not_define_test_2
没有定义)
第一行我们验证了@selector
是一个char[]类型。其他的结果我们可以总结出:@selector()
选择子只与函数名有关。而且还有一个规律,那就是倘若选择子方法已经在编译期由Compiler进行静态绑定,则其存储的地址就会更加的具体。
发送消息所依托的选择子只与函数名有关,我们便可以猜想到为什么Objective-C中没有像C++、C#那样的函数重载特性,因为选择子并不由参数和函数名共同决定。
那么为什么要有这个选择子呢?在从源代码看 ObjC 中消息的发送一文中,作者Draveness对其原因进行了推断:
- Objective-C 为我们维护了一个巨大的选择子表
- 在使用
@selector()
时会从这个选择子表中根据选择子的名字查找对应的SEL
。如果没有找到,则会生成一个SEL
并添加到表中- 在编译期间会扫描全部的头文件和实现文件将其中的方法以及使用
@selector()
生成的选择子加入到选择子表中
objc_msgSend
在选择子拿到对应的地址后,objc_msgSend
会依据接收者与选择子的类型来调用适当方法。为了学习此过程,我从opensource.apple.com的git仓库中clone了Runtime源码,并在x86_64
架构下macOS环境进行运行。
另外,我在整个工程中增加了一个Class:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// DGObject.h @interface DGObject : NSObject - (void)test; @end // DGObject.m #import "DGObject.h" @implementation DGObject - (void)test { printf("Hello World. "); } @end |
并在main入口函数中进行改动:
1 2 3 4 5 6 7 8 |
int main(int argc, const char * argv[]) { @autoreleasepool { DGObject *obj = [[DGObject alloc]init]; NSLog(@"%p", @selector(test)); [obj test]; } return 0; } |
然后我们在objc-runtime-new.mm
中,进行debug。为了研究清楚Runtime是如何查询到调用函数,我们在lookUpImpOrForward
下断点。当程序执行[obj test]
后,我们发现到达断点位置,并且观察此时的调用栈情况:
objc_msgSend
并不是直接调用查询方法,而是先调用了_class_lookupMethodAndLoadCache3
这个函数。看下它的源码:
1 2 3 4 |
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls){ return lookUpImpOrForward(cls, sel, obj, YES/*initialize*/, NO/*cache*/, YES/*resolver*/); } |
_class_lookupMethodAndLoadCache3
就好像一个中转函数,并给出了在查询IMP指针前默认参量的几个布尔值。而由于我们的方法没有进行方法转发,则直接调用了_class_lookupMethodAndLoadCache3
这个函数。而当对象在收到无法解读的消息之后,即启动消息转发机制,这时候应该会进入lookUpImpOrNil
这个方法。这也是objc_msgSend的一种优化方式。
这里还要注意一点,就是关于Cache的默认参数是NO
,因为在objc_msgSend中已经进行过缓存查询。以下是objc_msgSend的汇编实现:
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 |
ENTRY _objc_msgSend MESSENGER_START // NilTest:宏,判断被发送消息的对象是否为nil。 // 如果为nil直接返回。 NilTest NORMAL // GetIsaFast快速获取isa指针地址,并放入r11寄存器 GetIsaFast NORMAL // r11 = self->isa // 查找类缓存中selector的IMP指针,并放到r10寄存器 // 如果不存在,则在class的方法list中查询 CacheLookup NORMAL // calls IMP on success // NilTest的许可量以及GetIsaFast的许可量 NilTestSupport NORMAL GetIsaSupport NORMAL // cache miss: go search the method lists LCacheMiss: // isa still in r11 // MethodTableLoopup这个宏是__class_lookupMethodAndLoadCache3函数的入口 // 调用条件是在缓存中没有查询到方法对应IMP MethodTableLookup %a1, %a2 // r11 = IMP cmp %r11, %r11 // set eq (nonstret) for forwarding jmp *%r11 // goto *imp END_ENTRY _objc_msgSend |
趁热打铁,再来看一下MethodTableLoopup这个宏的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
.macro MethodTableLookup MESSENGER_END_SLOW SaveRegisters // _class_lookupMethodAndLoadCache3(receiver, selector, class) // 从a1, a2, a3中分别拿到对应参数 movq $0, %a1 movq $1, %a2 movq %r11, %a3 // 调用__class_lookupMethodAndLoadCache3 call __class_lookupMethodAndLoadCache3 // IMP is now in %rax // 将IMP从r11挪至rax movq %rax, %r11 RestoreRegisters .endmacro |
而在objc-msg-x86_64.s
中有多个以objc_msgSend为前缀的方法,这个是根据返回值类型和调用者类型分别处理的,我列举三个常用的
OBJC_MSGSEND_STRET | 待发送的消息要返回结构体前提是只有当CPU的寄存器能够容纳的下消息返回类型。 |
---|---|
objc_msgSend_fpret | 消息返回的是浮点数。因为某些架构的CPU调用函数,需要对浮点数寄存器做特殊处理。 |
objc_msgSendSuper | 需要向superClass发送消息时调用。 |
lookUpImpOrForward
之后我们随着调用栈往上看,在接受到消息入口的命令后,Runtime要开始进行查找方法的操作,源码如下:
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, bool initialize, bool cache, bool resolver) { Class curClass; IMP imp = nil |