翻译自 Understanding the Objective-C Runtime
Objective-C 的运行时(runtime)是刚刚了解 Cocoa/Objective-C 的人很容易忽视的一个特性。因为新手们常常花费了大量时间在 Cocoa 框架上以及如何调整和使用 Cocoa 框架,虽然 Objective-C 只需要几个小时就可以学会。每个人都需要了解运行时具体是怎么工作的,不仅仅是知道 [target doMethodWith: var1]
会被编译器翻译成 objc_msgSend(target, @selector(doMethodWith:), var1)
。了解运行时会使你对 Objective-C 语言和你的 app 是怎么工作的有更加深刻的理解。 我认为 Mac/iPhone 开发者无论经验水平,都将从中受益。
Objective-C 运行时是开源的
Objective-C 运行时是开源的,随时可以从 opensource.apple.com 查看。事实上研究 Objective-C 是我是我除苹果文档以外,最初弄明白运行时是如何工作的几种方法之一。
动态语言 vs 静态语言
Objective-C 是面向运行时的语言,这意味着它将具体的执行,从编译的时候和链接的时候推迟到它真正执行这段代码的时候。这给了你很大的灵活性,可以将消息重定向到适当的对象,或者你甚至可以有意地交换方法实现,等等。这就要求一个“运行时”来完成对象的内省,来看该对象能否响应,以及是否合适派发某些方法。和 C 语言对比,在 C 语言中,你的程序从一个 main()
方法开始,它就像你写的代码那样,自上而下的遵循着你的逻辑执行函数。一个 C 结构体不能将请求转发到其他目标上。很可能你有这样一个程序:
#include < stdio.h >
int main(int argc, const char **argv[]) {
printf("Hello World!");
return 0;
}
复制代码
编译器解析、优化,然后将你优化过的代码转换成汇编:
.text
.align 4,0x90
.globl _main
_main:
Leh_func_begin1:
pushq %rbp
Llabel1:
movq %rsp, %rbp
Llabel2:
subq $16, %rsp
Llabel3:
movq %rsi, %rax
movl %edi, %ecx
movl %ecx, -8(%rbp)
movq %rax, -16(%rbp)
xorb %al, %al
leaq LC(%rip), %rcx
movq %rcx, %rdi
call _printf
movl $0, -4(%rbp)
movl -4(%rbp), %eax
addq $16, %rsp
popq %rbp
ret
Leh_func_end1:
.cstring
LC:
.asciz "Hello World!"
复制代码
然后将汇编代码与一个库链接起来,最终生成一个可执行文件。这与 Objective-C 不同,虽然过程相似,但是 ObjC 编译器生成的代码依赖于“运行时”库的存在。刚认识 ObjC 时别人告诉我们(在过分简化的层面)我们的 ObjC 方括号代码发生了这些变化……
[target doMethodWith:var1];
复制代码
会被编译器翻译成
objc_msgSend(target, @selector(doMethodWith:), var1);
复制代码
但除此之外,我们对运行时所做的事情还不太了解。
什么是 Objective-C 运行时?
Objective-C 运行时是一个运行时库,主要由 C 和汇编语言写成,给 C 语言增加了面向对象的功能以创建 Objective-C。这就是说它负责加载类信息,做所有方法分发、方法转发等事情。Objective-C 的运行时本质上搭建了所有的基础结构,使得 Objectict-C 的面向对象编程成为可能。
Objective-C 运行时的术语
在我们更深入之前,为了达成共识,让我们先了解一些术语。
- 2 种运行时:
现代运行时(所有 64 位 Mac OS X App 和所有 iOS app)和古老的运行时(所有 32 位 Mac OS X App)。
- 2 种方法:
实例方法(例如 -(void)doFoo
)和类方法(例如 +(id)alloc
)。
- 方法:
就像 C 的“函数”一样,是一组代码,执行一个小任务:
- (NSString *)movieTitle {
return @"Futurama: Into the Wild Green Yonder";
}
复制代码
- Selector(选择器):
Objective-C 中的选择器本质上是一个 C struct,它可以用来识别你想要对象执行的 Objective-C 方法。在运行时中它是这样定义的:
typedef struct objc_selector *SEL;
复制代码
是这样用的:
SEL aSel = @selector(movieTitle);
复制代码
- 消息:
[target getMovieTitleForObject:obj];
复制代码
一个 Objective-C 消息包含中括号里面的全部内容:消息的发送目标、希望目标执行的方法以及任何你发送给目标的参数。Objective-C 消息和 C 的函数调用相似但是不同。事实上你给一个对象发送的消息不代表它会执行。对象会检查谁是消息的发送者,然后根据不同发送者执行不同的方法,或者转发给其他目标对象。
- 类(class):
当你查看运行时中的一个类你会看到这个:
typedef struct objc_class *Class;
typedef struct objc_object {
Class isa;
} *id;
复制代码
可以看到有几个东西。我们有一个 Objective-C 类(Class)的结构体和一个对象(Object)的结构体。objc_object 里只有一个定义为 isa 的类指针,这就是我们说的“isa 指针”。Objective-C 运行时只需要这个 isa 指针就可以检查一个对象,了解这个类是什么,然后看它是否能够响应你发送的消息对应的选择器。最后我们看到了这个 id 指针。默认情况下 id 指针只能告诉我们这是一个 Objective-C 对象。当你有一个 id 指针时,你可以查询它的类,看它能否对某个方法作出响应,等等。当你知道你所指向的对象是什么时,就可以更具体地操作。
- 闭包(Block):
本身被设计成和运行时兼容,所以它们可以看作是对象,可以响应消息,例如 -retain
,-release
,-copy
等等。你可以在 LLVM/Clang 的文档里看到 Block 的定义:
struct Block_literal_1 {
void *isa; // initialized to &_NSConcreteStackBlock or &_NSConcreteGlobalBlock
int flags;
int reserved;
void (*invoke)(void *, ...);
struct Block_descriptor_1 {
unsigned long int reserved; // NULL
unsigned long int size; // sizeof(struct Block_literal_1)
// optional helper functions
void (*copy_helper)(void *dst, void *src);
void (*dispose_helper)(void *src);
} *descriptor;
// imported variables
};
复制代码
-
IMP(方法实现指针):
typedef id (*IMP)(id self, SEL _cmd, ...);
IMP 是编译器为你生成的方法实现的函数指针,Objective-C 新人不需要直接接触 IMP,但 Objective-C 的运行时通过它来调用你的方法,我们很快会看到。
- Objective-C 类:
Objective-C 类的基本实现如下:
@interface MyClass : NSObject {
//vars
NSInteger counter;
}
//methods
-(void)doFoo;
@end
复制代码
但是运行时跟踪记录的比这要多:
#if !__OBJC2__
Class super_class OBJC2_UNAVAILABLE;
const char *name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list *ivars OBJC2_UNAVAILABLE;
struct objc_method_list **methodLists OBJC2_UNAVAILABLE;
struct objc_cache *cache OBJC2_UNAVAILABLE;
struct objc_protocol_list *protocols OBJC2_UNAVAILABLE;
#endif
复制代码
我们可以看到一个类有父类、名字、实例变量、方法、缓存和它声明要遵守的协议等的引用。运行时需要这些信息来响应你的类或实例的消息。
所以类(Class)定义对象(Object),但类本身就是对象,这是怎么做到的?
是的,我之前说过类本身也是对象,运行时通过创建元类(Meta classes)来解决这个问题。当你发送一个像 [NSObject alloc]
这样的消息时,你实际上是在向类对象(Class object)发送一个消息。这个类对象需要是 MetaClass 的一个实例,继而它本身就是根元类(root meta class)的一个实例。当你把你的一个类继承于 NSObject
的时候,实质上是把你的类的 “superclass” 引用指向 NSObject
。所有元类也指向根元类作为它的父类。元类里只有它们能响应的类方法的列表。所以当我们将一个消息发送给一个类对象,比如 [NSObject alloc]
时,objc_msgSend()
实际上会通过元类查看它能响应什么方法,如果找到了一个方法,就会在类对象上运行。
为什么我们要继承苹果提供的类?
当我们刚开始接触 Cocoa 编程时,教程告诉我们创建的对象要继承 NSObject,说只要继承苹果提供的类就能有很多好处。我们不知道的是,这其实是为了让我们自己创建的对象可以使用运行时。当我们创建我们一个类的实例时我们这样做:
MyObject *object = [[MyObject alloc] init];
复制代码
第一个执行的方法是 +alloc
。Apple 文档中说 “一个新实例的 isa 实例变量被初始化为一个用于描述该实例对应类的数据结构;其他实例变量内存都被设置为 0”。所以继承苹果提供的类,我们不仅继承了一些很好用的属性,更重要的是能很容易地在内存中创建和运行时期待的结构相匹配的对象(有一个指向我们的类的 isa
指针)。
那么类缓存(Class Cache)是什么呢?(objc_cache *cache)
当 Objective-C 运行时通过 isa
指针检查一个对象时,它可以找到一个实现了许多方法的对象。然而,你可能只调用其中的一小部分,因此每次执行查找类的分派表(dispatch table),搜索所有的 selector 是没有意义的。所以类实现了一个缓存,每当你搜索一个类分派表,并找到对应的选择器,它就把它放入缓存中。因此当 objc_msgSend()
通过一个类来查找一个选择器时,它首先会搜索类缓存。这是基于这样一种理论:如果一次在类上调用一条消息,那么以后可能会再次调用相同的消息。如果我们把缓存考虑进去,这意味着如果我们有一个 NSObject
的子类 MyObject
并运行下面的代码:
MyObject *obj = [[MyObject alloc] init];
@implementation MyObject
-(id)init {
if(self = [super init]){
[self setVarA:@”blah”];
}
return self;
}
@end
复制代码
那么会发生这几件事:
[MyObject alloc]
首先被执行。MyObject
类没有实现alloc,所以我们在类中找不到+alloc
,于是根据父类指针找到NSObject
。- 我们询问
NSObject
,得出它能响应+alloc
。+alloc
检查接受者类,即MyObject
,并且分配一块我们类的大小的内存,并初始化它的isa
指针指向MyObject
类。现在我们有了一个实例,最后我们把+alloc
放到NSObject
类对象的类缓存中。 - 刚才我们发送的是一个类消息(class message),但现在我们通 过调用
-init
或者指定初始化方法(designated initializer)来调用一个实例方法(instance message)。当然,我们的类对这个-init
消息可以作出响应,所以-(id)init
被放入缓存。 - 然后
self = [super init]
被调用。super
是一个神奇的关键字,指向对象的父类,所以我们到NSObject
中调用它的init
方法。这样做是为了确保 OOP 继承正确地工作,所有的父类都将正确地初始化变量,然后你(在子类中)也可以正确地初始化变量,再然后,如果需要的话,重写父类的方法。对于NSObject
来说,没有什么重要的事情发生,但事实并非总是如此。有时会发生重要的初始化。例如这个…
#import < Foundation/Foundation.h>
@interface MyObject : NSObject
{
NSString *aString;
}
@property(retain) NSString *aString;
@end
@implementation MyObject
-(id)init
{
if (self = [super init]) {
[self setAString:nil];
}
return self;
}
@synthesize aString;
@end
int main (int argc, const char * argv[]) {
NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
id obj1 = [NSMutableArray alloc];
id obj2 = [[NSMutableArray alloc] init];
id obj3 = [NSArray alloc];
id obj4 = [[NSArray alloc] initWithObjects:@"Hello", nil];
NSLog(@"obj1 class is %@", NSStringFromClass([obj1 class]));
NSLog(@"obj2 class is %@", NSStringFromClass([obj2 class]));
NSLog(@"obj3 class is %@", NSStringFromClass([obj3 class]));
NSLog(@"obj4 class is %@", NSStringFromClass([obj4 class]));
id obj5 = [MyObject alloc];
id obj6 = [[MyObject alloc] init];
NSLog(@"obj5 class is %@", NSStringFromClass([obj5 class]));
NSLog(@"obj6 class is %@", NSStringFromClass([obj6 class]));
[pool drain];
return 0;
}
复制代码
如果你是 Cocoa 新手,当我问你会打印出什么你很可能会说:
NSMutableArray
NSMutableArray
NSArray
NSArray
MyObject
MyObject
复制代码
但事实上结果是这样:
obj1 class is __NSPlaceholderArray
obj2 class is NSCFArray
obj3 class is __NSPlaceholderArray
obj4 class is NSCFArray
obj5 class is MyObject
obj6 class is MyObject
复制代码
这是因为在 Objective-C 中,有很大可能 +alloc
返回的类和 -init
返回的类不同。
所以到底 objc_msgSend 发生了什么?
很多事情。看一下这段代码:
[self printMessageWithString:@"Hello World!"];
复制代码
这实际上被编译器翻译成:
objc_msgSend(self, @selector(printMessageWithString:), @"Hello World!");
复制代码
我们顺着目标对象的 isa 指针查找,看该对象(或者它其中一个父类)是否能响应 @selector(printMessageWithString:)
选择器。假设我们在分派表(dispatch table)或者缓存中找到了该选择器,我们会跟踪函数指针并执行它。所以 objc_msgSend()
永远不会返回,它开始执行,然后跟踪一个指向你的方法的指针,然后你的方法返回,这看起来就像 objc_msgSend()
返回了一样。
Bill Bumgarner 在(Part 1, Part 2 & Part 3)里描述了更多 objc_msgSend()
的细节。总结一下他的文章结合你看到的 Objective-C 运行时代码:
- 检查被忽略的选择器和短路。显然,如果我们在垃圾收集下运行,我们可以忽略
-retain
,-release
等调用。 - 检查
nil
目标。和其他语言不同,在 ObjC 里向nil
发送消息十分合理并且有些情况下确实想要这么做。假如不是nil
则继续…… - 接下来在类中找到 IMP,首先通过类缓存来查找,如果找到就跟随指针跳转到对应的函数
- 如果在缓存中找不到 IMP,则通过分派表来查找,如果找到就跟随指针跳转到对应的函数
- 如果这两个地方都找不到 IMP,则跳转到转发(forwarding)机制。
这意味着最终你的代码会被编译器转译成 C 函数。你写的某个方法可能是这样:
-(int)doComputeWithNum:(int)aNum
复制代码
它会被转换成……
int aClass_doComputeWithNum(aClass *self, SEL _cmd, int aNum)
复制代码
ObjC 运行时会通过调用这些方法的函数指针来真正执行方法。我曾说过你不能直接调用这些转译后的方法,但其实 Cocoa 框架提供了一个获取函数指针的方法……
// C function pointer
int (computeNum *)(id, SEL, int);
// methodForSelector is COCOA & not ObjC Runtime
// gets the same function pointer objc_msgSend gets
computeNum = (int (*)(id, SEL, int))[target methodForSelector:@selector(doComputeWithNum:)];
// execute the C function pointer returned by the runtime
computeNum(obj, @selector(doComputeWithNum:), aNum);
复制代码
用这种方式你可以直接访问函数并且直接在运行时中执行它,甚至绕过运行时的动态特性(为了确保指定的方法被执行)。ObjC 运行时也用这种方法来调用你的函数,只是用了 objc_msgSend()
。
Objecetive-C 消息转发
在 Objective-C 中,发送消息给可能不能响应该消息的对象是合法的(可能是有意设计的)。苹果文档里提到可能的原因一个是模拟 Objective-C 并不原生支持的多重继承,或者是你想把真正接受消息的类或者对象隐藏起来。这也是运行时很有必要的一件事。具体是这样的:
- 运行时搜索类缓存、类分派表以及父类的所有方法,没有找到指定的方法。
- 运行时对你的类调用
+ (BOOL)resolveInstanceMethod:(SEL)aSEL
。这给你提供了一个方法实现的机会,告诉运行时你已经解决了这个方法,如果它应该开始进行搜索,它将会找到方法。具体你可以这样做,定义一个函数:
void fooMethod(id obj, SEL _cmd) {
NSLog(@"Doing Foo");
}
复制代码
然后可以使用 class_addMethod()
来解析它…
+ (BOOL)resolveInstanceMethod:(SEL)aSEL {
if(aSEL == @selector(doFoo:)) {
class_addMethod([self class],aSEL,(IMP)fooMethod,"v@:");
return YES;
}
return [super resolveInstanceMethod];
}
复制代码
class_addMethod()
的最后一部分中的 v@:
是该方法返回的内容,也是它的参数。你可以在运行时指南的 Type Encodings 章节中了解可以放入哪些内容。
- 如果我们不能解析该方法,运行时会继续调用
- (id)forwardingTargetForSelector:(SEL)aSelector
。它所做的是给你一个机会,让运行时指向在另一个可以响应消息的对象。最好在开销更大的- (void)forwardInvocation:(NSInvocation *)anInvocatio
方法接管之前调用,例如:
{
if(aSelector == @selector(mysteriousMethod:)) {
return alternateObject;
}
return [super forwardingTargetForSelector:aSelector];
}
复制代码
显然,你不想从这个方法中返回 self
,因为这样会导致无限循环。
- 运行时最后一次尝试发送一个消息发送到它的预定目标,调用
- (void)forwardInvocation:(NSInvocation *)anInvocation
。NSInvocation
本质上是一个 Objective-C 消息的的对象形式。一旦你有了一个NSInvocation
,你基本上可以改变任何信息,包括它的目标,选择器和参数。例如你可以做:
- (void)forwardInvocation:(NSInvocation *)invocation {
SEL invSEL = invocation.selector;
if([altObject respondsToSelector:invSEL]) {
[invocation invokeWithTarget:altObject];
} else {
[self doesNotRecognizeSelector:invSEL];
}
}
复制代码
如果你的对象继承了 NSObject
, 默认情况下 - (void)forwardInvocation:(NSInvocation *)anInvocation
实现会调用 -doesNotRecognizeSelector:
方法。你可以重写这个方法如果你想最后再做点什么。
不脆弱的(Non Fragile)实例变量列表(ivars) (现代运行时)
现代运行时新增加了不脆弱的(Non Fragile) ivars 的概念。当编译你的类的时候,编译器生成了一个实例变量内存布局(ivar layout),来告诉运行时去那里访问你的类的实例变量们。这是一个底层实现细节:ivars 是实例变量分别相对于你的对象地址的偏移量,读取 ivars 的字节数就是读取的变量的大小。你的 ivar 布局可能看起来像这样(第一列是字节偏移量):
这里我们画出了一个 NSObject
的实例变量内存布局。我们有一个继承了 NSObject
的类,增加了一些新的实例变量。这没什么问题,直到苹果发布了新的 Mac OS X 10.x 系统,NSObject
突然增加两个新的实例变量,于是:
你的自定义对象和 NSObject
对象重叠的部分被清除。如果 Apple 永远不改变之前的布局可以避免这种情况,但如果他们那样做,那么他们的框架就永远不会进步。在“脆弱的 ivars” 下,你必须重新编译你从 Apple 继承的类,来恢复兼容性。那么在不脆弱的情况下会发生什么呢?
在不脆弱的 ivars 下,编译器生成与脆弱 ivars 相同的 ivars 布局。然而,当运行时检测到和父类有重叠时,它会调整偏移量,以增加对类的补充,保留了在子类中添加的内容。
Objective-C 关联对象(Associated Objects)
Mac OS X 10.6 Snow Leopard 中引入了关联引用。Objective-C 没有原生支持动态地将变量添加到对象上。因此,你需要竭尽全力构建基础架构,以假装正在向类中添加一个变量。在 Mac OS X 10.6 中,Objective-C 运行时提供了原生支持。如果我们想给每个已经存在的类添加一个变量,比如 NSView
,我们可以这样做:
#import <Cocoa/Cocoa.h> //Cocoa
#include <objc/runtime.h> //objc runtime api’s
@interface NSView (CustomAdditions)
@property(retain) NSImage *customImage;
@end
@implementation NSView (CustomAdditions)
static char img_key; //has a unique address (identifier)
- (NSImage *)customImage {
return objc_getAssociatedObject(self,&img_key);
}
- (void)setCustomImage:(NSImage *)image {
objc_setAssociatedObject(self,&img_key,image,
OBJC_ASSOCIATION_RETAIN);
}
@end
复制代码
你可以在 runtime.h 看到。如何存储传递给 objc_setAssociatedObject()
的值的选项:
/* Associated Object support. */
/* objc_setAssociatedObject() options */
enum {
OBJC_ASSOCIATION_ASSIGN = 0,
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1,
OBJC_ASSOCIATION_COPY_NONATOMIC = 3,
OBJC_ASSOCIATION_RETAIN = 01401,
OBJC_ASSOCIATION_COPY = 01403
};
复制代码
这些与你可以在@property
语法中传递的选项相匹配。
混合 vTable 分发
如果你看一下现代运行时代码,你会看到这个(在 objc-runtime-new.m)。
/***********************************************************************
* vtable dispatch
*
* 每个类都有一个 vtable 指针。vtable 是一个 IMP 数组,
* 所有的类的 vtable 中表示的选择器数量都是相同的。(i.e.
* 没有一个类有更大或更小的 vtable).
* 每个 vtable 索引都有一个关联的蹦床,该蹦床在接收者类的
* vtable 的该索引处分派给 IMP(检查 NULL 后)。分派
* fixup 使用了蹦床而不是 objc_msgSend.
* 脆弱性:vtable 的大小和选择器列表在启动时已经设定好了。
* 编译器生成的代码无法依赖于任何特定的vtable配置,甚至
* 根本不使用 vtable 调度。
* 内存大小:如果一个类的 vtable 和它的父类相同(i.e. 该类
* 没有重写任何 vtable 选择器), 那么这个类直接指向它的父
* 类的 vtable。这意味着被选中包含在 vtable 中的选择器应
* 该有以下特点:
* (1) 经常被调用,但是 (2) 不经常被重写。
* 特别的是,-dealloc 是一个坏的选择。
* 转发: 如果一个类没有实现 vtable 中的部分选择器, 这个类的
* vtable 中的这些选择器的 IMP 会被设置成 objc_msgSend。
* +initialize: 每个类保持默认的 vtable(总是重定向到
* objc_msgSend)直到其 +initialize 初始化方法完成。否则,
* 一个类的第一个消息可能是一个 vtable 调度,而 vtable
* 蹦床不包括 +initialize 初始化检查。
* 改变: Categories, addMethod, 和 setImplementation 如果影响
* 到了 vtable 的选择器,类和所有的子类的 vtable 都将强制重建。
**********************************************************************/
复制代码
这背后的思想是,运行时试图在这个 vtable 里面存储最常被调用的选择器,这可以给 app 加速,因为这比 objc_msgSend
使用了更少的指令。这个 vtable 包含 16 个最常被调用的选择器,占据了绝大部分全局调用的选择器。你可以看到垃圾回收 app 和非垃圾回收 app 的默认选择器都是什么。
static const char * const defaultVtable[] = {
"allocWithZone:",
"alloc",
"class",
"self",
"isKindOfClass:",
"respondsToSelector:",
"isFlipped",
"length",
"objectForKey:",
"count",
"objectAtIndex:",
"isEqualToString:",
"isEqual:",
"retain",
"release",
"autorelease",
};
static const char * const defaultVtableGC[] = {
"allocWithZone:",
"alloc",
"class",
"self",
"isKindOfClass:",
"respondsToSelector:",
"isFlipped",
"length",
"objectForKey:",
"count",
"objectAtIndex:",
"isEqualToString:",
"isEqual:",
"hash",
"addObject:",
"countByEnumeratingWithState:objects:count:",
};
复制代码
那么你怎么知道是否使用了 vtable 中的方法了呢?你会在调试的堆栈跟踪中看到以下几个方法。这些方法你可以看成调试版的 objc_msgSend()
。
objc_msgSend_fixup
代表 runtime 调用一个方法并正要把它加入到 vtable 中。objc_msgSend_fixedup
代表你调用方法曾经在 vtable 中,现在已经不在里面了。objc_msgSend_vtable[0-15]
代表上述 vtable 中的一个常用方法。runtime 可以随意分配或取消它想要的值。所以这一次objc_msgSend_vtable10
对应于-length
方法,下一次运行可能对应方法就变了。
总结
我希望你喜欢这些,这篇文章大体上组成了我在我给 Des Moines Cocoaheads 的 ObjC 演讲中提到的内容。ObjC 运行时写的很棒,它提供了许多我们在 Cocoa / Objective-C 中习以为常的特性。如果你还没看过 Apple 的 ObjC 运行时文档,希望你去看一看。谢谢!