[译]理解 Objective-C 运行时(上篇)

叁叁肆2018-09-19 14:00

本文来自网易云社区

作者:宋申易


翻译自 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 运行时是开源的,随时可以从 http://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

那么会发生这几件事: 

  1. [MyObject alloc] 首先被执行。MyObject 类没有实现alloc,所以我们在类中找不到 +alloc,于是根据父类指针找到 NSObject
  2. 我们询问 NSObject,得出它能响应 +alloc+alloc 检查接受者类,即 MyObject,并且分配一块我们类的大小的内存,并初始化它的 isa 指针指向 MyObject 类。现在我们有了一个实例,最后我们把 +alloc 放到 NSObject 类对象的类缓存中。
  3. 刚才我们发送的是一个类消息(class message),但现在我们通 过调用 -init 或者指定初始化方法(designated initializer)来调用一个实例方法(instance message)。当然,我们的类对这个 -init 消息可以作出响应,所以 -(id)init 被放入缓存。
  4. 然后 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 返回的类不同。


网易云免费体验馆0成本体验20+款云产品!

更多网易研发、产品、运营经验分享请访问网易云社区