Runtime源代码解读(实现面向对象初探)

1,331 阅读10分钟

2019-09-26

The Objective-C runtime is a runtime library that provides support for the dynamic properties of the Objective-C language, and as such is linked to by all Objective-C apps. Objective-C runtime library support functions are implemented in the shared library found at /usr/lib/libobjc.A.dylib.(Objective-C Runtime

文章的开头是Apple Documentation对runtime的定义,很官方也很抽象。个人对runtime的理解是:在狭义上,runtime用面向过程的C语言实现了面向对象特性,也就是实现了类和对象;在广义上,runtime实现了Objective-C语言的动态特性(深入Objective-C的动态特性)。动态特性主要包括动态类型(Dynamic typing)、动态绑定(Dynamic binding)和动态加载(Dynamic loading)。与之相对应,runtime具体实现了ClassNSObject抽象类型、Class的继承链、方法的响应链、方法动态解析以及消息转发流程、运行时动态加载类型/方法/属性等动态元素。

动态加载类库属于dyld范畴(源代码),当然runtime为了实现动态特性需要依赖dyld的API。

一、类和对象

如果要找出Objective-C中最动态的两个类型,那一定是Class(类)和id(对象的引用),两者也恰是runtime实现面向对象的基础。通过#import <objc/runtime.h>#import <objc/objc.h>语句跳转到runtime.h、objc.h头文件,可以从中找到类和对象定义的代码:

  • objc_class结构体表示类。其中super_class指针指向父类用于实现类的继承特性,instanceSize记录类的实例占用内存大小,ivars描述类的成员变量列表,methodLists保存类的方法列表,protocols保存类所遵循的协议列表,cache是方法缓存用于记录最近使用的方法,其他成员可以暂不关注;
  • Class是类的引用;
  • objc_object结构体表示对象,仅包含isa指针,指向对象的类(新版本runtime中isa指针不一定简单指向对象的类);
  • id表示指向objc_object结构体的指针,也就是对象引用,本质是对象的地址;

重要提醒:从#import <objc/runtime.h>#import <objc/objc.h>语句跳转到的runtime.hobjc/objc.h头文件中,都是旧版本runtime的代码。凡新旧代码处理逻辑存在不同之处的,文中有特别声明。

/* 对象的引用的定义 */
typedef struct objc_object *id;

/** 对象的定义 */
struct objc_object {
    Class _Nonnull isa;  // 对象的类
};

/** 类的定义 */
typedef struct objc_class *Class;
struct objc_class {
    Class _Nonnull isa;  // 元类

    Class _Nullable super_class;  // 父类
    const char * _Nonnull name;   // 类名
    long version;
    long info;
    long instance_size;  // 实例的大小
    struct objc_ivar_list * _Nullable ivars;  // 成员列表
    struct objc_method_list * _Nullable * _Nullable methodLists;  // 方法列表
    struct objc_cache * _Nonnull cache;  // 方法缓存
    struct objc_protocol_list * _Nullable protocols;  // 所遵循的协议列表
};

objc_object结构体只有一个指针类型的isa成员,也就是说一个objc_object仅占用了8个字节内存,但并不说明对象仅占8个字节内存空间。当调用类的allocallocWithZone方法构建对象时,runtime会分配类的instanceSize大小的连续内存空间用于保存对象。在该内存块的前8个字节写入类的地址,其余空间用于保存其他成员变量。最后构建方法返回的id实际上是指向该内存块的首地址的指针。

注意:以上是旧版本runtime构建对象的处理,在新版本runtime中略有不同。原因是新版本runtime重新定义了isa指针数据结构,而且在引入Non-fragile instance variable机制后instanceSize不再是固定的值,这些会在后续的文章中介绍。

1.1 继承链

根据objc_classsuper_class成员可以建立起类的继承结构,由于类的super_class指向单一的类,这是Objective-C之所以是单继承语言的原因。类的继承链的顶端是是根类(root class),通常是NSObject,根类的super_class指向NULL。

objc_objectobject_class结构体均包含isa指针,在runtime中类也是对象,是对象就会有类型,类的类型是元类(meta class)object_classisa指针指向元类。元类也是用objc_class结构体保存,也就是说元类也是类。元类也具有继承特性,继承链的顶端是根元类(root meta class)。根元类的是根类的元类,根元类的super_class指向根类。判断objc_class是否为元类的方式有两种:

  • 根据version的值,所有元类的version值为6(新版本runtime中是7),非元类为0
  • 元类的isa指针指向根元类,包括根元类自己。

至此,总结出runtime的继承结构如下图所示。

Runtime中类的继承结构.jpg

二、成员变量

objc_ivar结构体表示类的成员变量。

  • objc_ivarivar_name为成员变量名;
  • ivar_type为成员变量的类型编码(官方文档),用字符串表示成员变量的数据类型,例如:'@'表示成员变量保存对象的引用,可以使用@encode()以类型为参数查询类型编码;
  • offset为成员变量的在实例内存块中的偏移。
struct objc_ivar {
    char * _Nullable ivar_name;
    char * _Nullable ivar_type;
    int ivar_offset;
#ifdef __LP64__
    int space;
#endif
} 

objc_ivar_list结构体表示类的成员变量列表。objc_ivar_listobjc_ivar结构体的数组ivar_list用于保存类的所有成员变量,ivar_count为成员变量列表的长度。

struct objc_ivar_list {
    int ivar_count;
#ifdef __LP64__
    int space;
#endif
    /* variable length structure */
    struct objc_ivar ivar_list[1];
} 

2.1 成员变量布局

类构建成员变量列表的过程,包含确定成员变量布局(ivar layout) 的过程。成员变量布局就是定义对象占用内存空间中哪块区域保存哪个成员变量,具体为确定类的instanceSize、内存对齐字节数、成员变量的offset。类的继承链上所有类的成员变量布局,共同构成了对象内存布局(object layout)。成员变量布局和对象内存布局的关系可以用一个公式表示:类的对象内存布局 = 父类的对象内存布局 + 类的成员变量布局。成员变量布局的计算法则如下:

  • 成员变量的偏移量offset必须大于等于父类的instanceSize
  • 成员变量的布局和结构体的对齐遵循同样的准则,类的对齐字节数必须大于等于父类的对齐字节数。例如,占用4字节的int类型成员变量的起始内存地址必须是4的倍数,占用8字节的id类型成员变量的起始内存地址必须是8的倍数;
  • instanceSize的计算公式是类的instanceSize = 父类的instanceSize + 类的成员变量在实例中占用的内存空间 + 对齐填补字节instanceSize必须是类的对齐字节数的整数倍;

NSObject类的定义中,包含一个Class类型的isa成员,因此实际上isa指针的8个字节内存空间也属于对象内存布局的范畴。

举个具体的例子:用以下代码定义一个继承NSObjectTestObjectLayout类:

@interface TestObjectLayout : NSObject{
    bool bo;
    int num;
    char ch;
    id obj;
}
@end

@implementation TestObjectLayout

@end

其成员变量布局的计算过程如下:

  • instanceSize初始化为父类的instanceSize的值,并按父类的对齐字节数对齐。父类NSObject仅包含isa一个成员变量,isa占用8个字节offset为0,因此父类instanceSize为8,按8字节对齐;
  • instanceSize初始化,按照对齐法则依次添加成员变量,并更新instanceSizebo按字节对齐(注意bool类型占用1字节空间并不是1位),偏移量为8,instanceSize更新为16;
  • num按4字节对齐,偏移量为12,instanceSize仍为16;
  • ch按字节对齐,偏移量为16,instanceSize更新为24;
  • obj按8字节对齐,偏移量为24,instanceSize更新为32。最终确定instanceSize为32字节,按8字节对齐。

由上文对象内存布局计算公式可以看出,计算类的对象内存布局实际上是从根类开始递推计算继承链上所有类成员变量布局的过程(也可视为从类递归到根类)。假设TestObjectLayout对象的起始内存地址为0x100BB134000,则按照上述步骤可计算该对象内存布局如下图所示:

实例内存图.jpg

类的构建过程之所以要计算类的成员变量布局,是因为构建一个对象时需要确定需要为对象分配的内存空间大小,且构建对象仅返回对象的内存首地址,而通过成员变量的offset结合ivar_type,则可以轻而易取地通过对象地址定位到保存成员变量的内存空间。

注意:新版本runtime的成员变量列表保存位置有所变化。

三、方法

objc_method结构体表示方法,其中:

  • SEL类型的method_name表示方法名;
  • 字符串类型的method_types表示方法的类型编码,类型编码描述了方法的返回值类型以及参数类型;
  • IMP类型的method_imp表示方法的实现。
/* 对象的方法的定义 */
struct objc_method {
    SEL method_name;      // 方法选择器,即方法名
    char *method_types;   // 方法的类型编码
    IMP method_imp;       // 方法的实现,即方法的函数指针、方法的IMP
}  

/* 方法选择器定义 */
typedef struct objc_selector *SEL;

/* 方法实现定义 */
typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); 

objc_method_list结构体表示方法列表。objc_method_list结构与objc_ivar_list类似

struct objc_method_list {
    struct objc_method_list *obsolete;

    int method_count;
#ifdef __LP64__
    int space;
#endif
    /* variable length structure */
    struct objc_method method_list[1];
} 

注意到类的定义中,方法列表methodLists的类型为struct objc_method_list * _Nullable * _Nullable,因此类的方法列表的保存形式是二维数组,也就是数组的数组。

注意:新版本runtime的方法列表数据结构有较大变化。

3.1 方法的响应链

类包含实例方法(instance method)和类方法(class method),两种方法是保存在完全不同的地方。实例方法保存在类的方法列表中,类方法保存在元类的方法列表中

调用实例方法和类方法,接收消息的对象是不一样的,实例方法接收消息的对象是实例,类方法接收消息的对象是类,例如:[someObj init][NSObject alloc]。这是因为两种方法保存在完全不同的地方,实例方法保存在“类”的方法列表中,类方法保存在“元类”的方法列表中。那么 runtime 响应实例方法和类方法的流程有什么不一样呢?用下图可以表示,其中:

  • 子类SubClass包含subClassInstanceMethod实例方法,图中橘色线表示该消息的响应过程;
  • 根类RootClass包含rootInstanceMethod实例方法,图中洋红色线表示该消息的响应过程;
  • 根类RootClass包含rootClassMethod类方法,图中蓝色线表示该消息的响应过程;

基本消息响应链.jpg

综上,runtime 基于继承结构的基本响应链都有相同的结构:1、根据接收消息的对象的isa指针找到对象的类;2、从对象的类开始沿着其superclass串联起来的继承链,搜索可响应该消息的类,直到根类为止;3、若继承链上没有可响应该消息的类,则开始消息转发流程。

四、属性、协议、分类

runtime.h头文件中公布的属性、协议、分类等元素的相关信息很少,但这些都可以确定是类需要保存的元数据。这里仅简单收集其中公布的代码。

//属性相关数据结构
typedef struct objc_property *objc_property_t;

typedef struct {
    const char * _Nonnull name;  // 属性名
    const char * _Nonnull value;  // 特性的值
} objc_property_attribute_t;  // 属性的特性

//协议相关数据结构
#ifdef __OBJC__
@class Protocol;
#else
typedef struct objc_object Protocol;
#endif

struct objc_protocol_list {
    struct objc_protocol_list * _Nullable next;
    long count;
    __unsafe_unretained Protocol * _Nullable list[1];
};

//分类相关数据结构
typedef struct objc_category *Category;

struct objc_category {
    char * _Nonnull category_name;  //分类名
    char * _Nonnull class_name;  //分类所扩展的类名
    struct objc_method_list * _Nullable instance_methods;  //实例方法列表
    struct objc_method_list * _Nullable class_methods;  //类方法列表
    struct objc_protocol_list * _Nullable protocols; //协议列表
}

五、总结

前文不止一次提到,本文引用的runtime代码是旧版本的代码,之所以要从分析旧版本代码入手,是因为首先新版本代码在主体架构上仍然沿用了旧版本,只是在部分数据结构和实现细节上做了优化,分析旧版本接口文件已经足以对runtime框架有一个总体的认知,这样有利于对庞大的runtime源代码工程,有选择性、有针对性的学习;另外,从存在缺陷的旧代码入手,有助于加深新版本之所以要做优化的原因,并从中借鉴到代码优化的一些经验方法。