《iOS三问》 -- Objective-C类结构体系与面向对象的实现

Objective-C的“圣经” – 《CFHipsterRef》对Objective-C runtime有句经典的描述(下文我们将Objective-C简称OCObjective-C runtime简称Runtime):

Objective-C, by itself, is just talk. All of its high-falutin’ ideas about message passing and dynamism is nothing but a bunch of hot air without a runtime to do the hard work.

OC语言的动态及消息传递的特性,都是基于Runtime实现的,Runtime之于OC语言,就像一个小型的操作系统。而其中最本质的,OC中定义的类、方法的实现、表达式等都会编译成C语言实现,然后再与Runtime交互。但是,这一切对一个OC程序员来说是透明的,一般情况下,我们无需知道这些底层的细节就可以运用OC的这些特性为我们服务。

但是,基于我们的“三问精神”,我们希望了解这些底层实现细节,做到知其然也知其所以然。本章,我们就一起学习下Runtime中有关类结构与面向对象的实现相关技术。

1 概述

其实当我们说面向对象编程的时候,我们主要就是在说他的几大编程特性,或者也可以说是思想(因为这几个特性太过著名,已经深入大家之灵魂了):

  1. 封装;
  2. 继承;
  3. 多态。

封装是OOP(下文中将面向对象编程都简称为OOP)中基础中的基础,其本质就是将我们抽象出来的客观事物使用一堆函数和变量或其它对象的引用表示出来。对外暴露出接口,而隐藏其实现细节的技术。通过本章的学习,我们可以发现OC其实就是使用C++中的结构体(struct)来实现类、对象的结构。

继承是OOP的另一个重要思想。通过继承,实现了对已有数据结构、数据抽象的良好复用,以及可以为我们项目建立优良的模型架构。我相信本文的读者大多都知道OOP中的继承是怎么一回事了,那么在Runtime中,类的继承关系是怎样实现的,子类通过怎样的方式访问父类的成员变量及方法的,这就是后面我们主要共同学习的内容。

多态的特性其实是基于继承的,他使子类拥有相同的接口,使上层可以使用统一的调用,有了这个技术,他可以使我们的上层代码统一、简洁而优雅。本文中,我们将探究下OC多态实现相关的内容,实例方法是如何调用的,通过方法调用的原理,我们就可以体会到多态的实现。

总而言之,通过本章的学习,我们可以深入地了解OC中面向对象编程这套范式的实现方式,对加深Runtime的理解也是有所帮助滴~

1.1 准备工作

这里先推荐一个牛逼的Runtime源码项目:https://github.com/RetVal/objc-runtime

当然,学iOS开发的无人不知objc4源码,推荐这个项目的因为是他太牛逼了,也太无私了,哈哈哈。这位大神将objc4源码中所有依赖问题都解决了,然后还准备好了Demo项目供我们直接使用,可以为我们省很多事,强烈推荐。

我后面的研究都是基于他的这个库,在这里对他表示一下感谢与崇拜。

然后在开始之前我这里还是要多说几句

2 Runtime中的类结构

面向对象思想的第一个有意思的思想就是一切皆对象。众所周知,在面向对象的世界中,一切都是由对象组成的。

但是虽说一切皆对象,熟悉面向对象技术的同学都知道,实际上面向对象的世界是使用来抽象的,而对象只不过是的实例。因此,我们的分析就从的定义开始。在Runtime源码中,的定义如下:

Class源码定义
1
typedef struct objc_class *Class;

这里可以知道的是,Class可以用来定义一个类结构,但是其不是关键字,其实是objc_class这个结构体的别名。那么我们通过分析objc_class这个结构体,就可以一窥其实现细节了!

这里在看objc_class之前,我们巩固下我们面向对象的世界观~在OC中,所有类都有一个共同的祖先就是NSObject,这个大家都知道。我们不妨看下NSObject的定义,可以看出,他就是一个 – 其实就是一个Class结构体的实例。我们可以想像,程序启动后,会把我们定义的类加载到内存中,其中每个定义的类都会对应唯一一个Class结构体实例,而他们共同的祖先就是这个NSObject类结构体实例

1
2
3
4
5
6
7
@interface NSObject <NSObject> {
Class isa OBJC_ISA_AVAILABILITY;
}
// ... NSObject方法列表及定义
@end

接下来我们具体看看Runtime中objc_class的定义。这里留意下,我专门罗列了objc2.0老版本和新版本中objc_class的定义,因为网上会有挺多讲objc_class的文章,很多用的是老的定义,可能会给大家带来很多困惑。但这里我更想大家体会一下的就是,其实版本的新旧问题不大,我们真正要做的是感受OOP设计与实现的原理及奥妙,因为思想的东西其实是很少会变的,所改动的地方无非是提高效率与优化而已。

我们先来看看老的定义:

objc2.0之前的class定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct objc_class {
//指针, 表示是一个什么
//实例的isa指向类对象,类对象的isa指向元类
Class isa OBJC_ISA_AVAILABILITY;
#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
} OBJC2_UNAVAILABLE; // OBJC2_UNAVAILABLE与前面的#if !__OBJC2__宏,说明在objc2.0中已不使用了

可以看出,老的Class结构是独立的,包括了元类的设计(这个后面会介绍),还有类结构相关的信息(类名、父类),成员变量列表、方法列表、协议、方法缓存等。

我们再对比看看新的Class定义。新的objc_class定义在源码objc-runtime-new.h中。

新objc_class定义(objc-runtime-new.h)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct objc_class : objc_object {
// Class ISA;
Class superclass; // 父类
cache_t cache; // 缓存key-IMP映射,用于方法缓存与快速寻找
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
class_rw_t *data() {
return bits.data();
}
void setData(class_rw_t *newData) {
bits.setData(newData);
}
// 省略其他方法
// 。。。
}

可以看到,objc_class继承自objc_object, 也就是说,在新一版的objc4实现上做了重新设计。即在runtime中,class也被看做一种对象。这样做有什么好处呢?我能想到的就是这样使其OOP的实现设计更加统一了。因为在OC中类(Calss)也具有一些对象一样的特性,比如类方法,你可以给发送消息调用其类方法。而类实例在内存中也会像一个对象实例一样占有一份内存,这样对象和类的关系就有点像JS里面的原型(prototype),学完本文后感兴趣的童鞋可以去了解下~

而从上面类的结构中我们可以看出其中几个重要的信息:

  1. superclass指针(Class就是objc_class *,上文已经分析了哈),指向其父类,这就回答了我们关于OC类继承的实现的问题。类的继承和我们想像中的一样就是子类有指向其父类的指针。方便要访问父类成员或方法时可以引用。
  2. 第2点就是OC的一个特性。因为OC方法的调用采用的是消息传递的机制,而针对消息的方法查找需要一个过程。Runtime就在类结构中加了一个这样的缓存表,常命中的方法放到缓存中去,以便消息查找时先从缓存表中去查(这里涉及一个28理论,就是一个类常用的方法也就是80%会被调用的通常只是它所有方法中的20%。)。
  3. class_data_bits_t,这个数据结构比较巧妙,它主要是一个指针,指向了类的主要封装的数据结构。我们下面就来主要看看这个。

在分析class_data_bits_t之前我们小看一下cache_t cache;。我们说它其实是一个方法的缓存表。

cache_t方法缓存表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct cache_t {
struct bucket_t *_buckets;
//...
}
struct bucket_t {
private:
cache_key_t _key;
IMP _imp;
//...
}
typedef uintptr_t cache_key_t;
typedef unsigned long uintptr_t;

后面我们介绍消息转发机制时会详细介绍这几个数据结构。这里你只要了解下IMP就是具体方法实现。uintptr_t这个数据结构Runtime中使用的较多,你可以将之看作为void *或一个引用。所以这里我将cache称为key-IMP映射,使用key对此缓存表查找可以快速地找到常用的方法。

下面我们就来看看class_data_bits_t,前面说过了,它指向了类的主要封装的数据结构,也就是是类封装特性的主要实现。

2.1 类封装结构指针class_data_bits_t

class_data_bits_t是Class类结构的核心。它是一个复合指针,相应的位表示特定的信息,用不同的flags与与可以取位得到想要的数据。

class_data_bits_t结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct class_data_bits_t {
// Values are the FAST_ flags above.
uintptr_t bits;
public:
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
void setData(class_rw_t *newData)
{
assert(!data() || (newData->flags & (RW_REALIZING | RW_FUTURE)));
uintptr_t newBits = (bits & ~FAST_DATA_MASK) | (uintptr_t)newData;
atomic_thread_fence(memory_order_release);
bits = newBits;
}
// ...
}

可以看出,class_data_bits_t其实只有一个数据成员,就是uintptr_t bits。上面我们分析过,uintptr_t是一个8字节(u long)无符类型,存的一般就是指针。这个指针的低位一定是没用到,所以Runtime没有浪费,用来存类的相关信息了,要用的时候就用相应的flag与之与操作(就像掩码一样),就可以取出相应数据。在Runtime中有许多这样的设计,这样做的好处就是减少了数据结构的存储空间,使结构体内的成员分布最精简,因为面向对象语言嘛,肯定会产生大量的类和对象结构。

比如说bits中使用一位来表示这个是Swift类还是OC类,我们用FAST_IS_SWIFT这个flag和bits做与操作,就可以取出对应的位:

通过class_data_bits_t与flag判断是Swift类还是OC类:
1
2
3
4
5
6
7
8
bool isSwift() {
return getBit(FAST_IS_SWIFT);
}
bool getBit(uintptr_t bit)
{
return bits & bit;
}

这样我们就理解了data()这个方法了,通过FAST_DATA_MASK取出的数据就是指向类封装数据的结构体指针,如下。

取objc_class类封装数据
1
2
3
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}

而这个指针的类型是 – class_rw_t。我们再看看class_rw_t

2.2 类核心结构class_rw_t

类封装数据核心结构--class_rw_t
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
// 外层Runtime类结构
struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro; // 类只读基础信息
// 下面三个array,method,property, protocol,可以被runtime 扩展,如Category
method_array_t methods; // 类方法列表
property_array_t properties; // 类属性列表
protocol_array_t protocols; // 类协议列表
// 和继承相关的东西
Class firstSubclass;
Class nextSiblingClass;
// Class对应的 符号名称
char *demangledName;
// ...
}
// 类不可修改部分数据结构(类基本信息)
// 存放类在编译期就确定的结构信息
struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t * ivarLayout;
const char * name; // 类名
method_list_t * baseMethodList;
protocol_list_t * baseProtocols;
const ivar_list_t * ivars; // 类
const uint8_t * weakIvarLayout;
property_list_t *baseProperties;
method_list_t *baseMethods() const {
return baseMethodList;
}
};

我们可以看到,类的封装数据无非就是我们之前设想的成员变量与方法列表等,但是它是封装成2层结构。外层是一些可扩展的成员列表(类方法、类属性、类协议),内层是一个只读的类基本信息。之所以这样做是因为OC是一个动态的语言,其方法与成员可以在运行时动态添加,而这个动态的特性就是这样实现的。比如当我们使用Category语法给一个类添加方法,会添加到外层的类可扩展的成员列表中,这样当进行消息查找时,就会先在外层查找,确保先找到我们动态添加的特性。

至此,对类的分析就基本结束了。

做个小结,我们知道了OC封装这个特性是靠类来实现的,类在Runtime中是objc_class结构体。其核心成员class_data_bits_t是一个复合指针,指向类核心封装数据结构class_rw_t。而class_rw_t采用了2层设计,外层存放了一些可扩展的成员列表。内层class_ro_t是类的只读基本信息。

下面我们看看Runtime中对象的实现。

3 Runtime中对象的实现

Runtime中,对象被定义为objc_object结构体。我们在上面的介绍中已经了解到,类(objc_class)是继承自对象结构体(objc_object)的,因此,类可以看做是一类特殊的对象。

Runtim中对象结构体的定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct objc_object {
private:
isa_t isa;
public:
// ISA() assumes this is NOT a tagged pointer object
Class ISA();
// getIsa() allows this to be a tagged pointer object
Class getIsa();
// ...
}

可以看到, objc_object的定义很简单,仅包含一个isa_t类型。

3.1 isa_t

我们通过分析这个isa_t类型,可以发现这个属性和类的class_data_bits_t成员有点相像,都是使用了一个指针结构但是复用了指针内的一些位来表示一些信息或标志位。印象不够深的同学可以回上去复看一下class_data_bits_t。我寻思这种设计的初衷就是因为Runtime中许多内存使用是对齐的,因此一些特定结构的地址指针的低位就没有用,为了节约,Runtime就往这些没用到的低位字节中“塞”了一些数据,要用的时候使用flag掩码按位与取出来用即可

我们下面看看这个isa_t

objc_object中的isa_t联合体
1
2
3
4
5
6
7
8
9
10
11
union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
// 省略其余
。。。
}

isa_t 是一个联合,可以表示Class cls或uintptr_t bits类型。实际上在OC 2.0里面,多数时间用的是uintptr_t bits,因为64位系统中,这个指针结构可以额外存很多信息(原因上面说过了)。要详细了解isa_t,可以学习下这篇文章:《从 NSObject 的初始化了解 isa》 - Draveness, Draveness的这篇文章详细地分析了这个数据结构中表示的对象信息。

我们现在只要知道的是isa_t中的bits其实主要也是Class指针。另外附带一些对象的特定信息,如:

  • has_assoc:对象含有或者曾经含有关联引用,没有关联引用的可以更快地释放内存
  • weakly_referenced: 对象被指向或者曾经指向一个 ARC 的弱变量,没有弱引用的对象可以更快释放
  • has_sidetable_rc: 对象的引用计数太大了,存不下
  • deallocating: 对象正在释放内存

所以现在我们知道了,objc_object中的这个唯一的成员isa_t其实也是一个指针,指向一个Class类结构。而这个Class,就是我们要引入的一个新概念 – 元类(meta class)

3.2 元类与消息查找机制

在 Objective-C 中,对象的方法并没有存储于对象的结构体中(实际上在任何一种面向对象语言的实现中,实例方法都不会放到对象结构中。因为对于同一个类的多个实例对象来说,同一个方法的实现都是一样的,没必要重复放在对象结构中从而浪费内存)。

根据本章上面的介绍我们都知道,方法们都封装在类结构体(objc_class)中。那么我们给实例发消息时,是怎样调用到类方法列表中的这些方法呢?

下面说一点本章的”超纲内容”,在后面的《深入Runtime之消息机制》中有更细的介绍:

在OC中,要调用一个对象的方法,得像这样给它发消息:

在objc中给对象发消息语法
1
id returnValue = [object messageWithParam:param]

Runtime会将像上面的消息语法转成下面这样:

objc发送消息的实质
1
2
3
4
id objc_msgSend(id self, SEL op, ...); // 后面的...为可变参数,表示接受2个或以上参数
// 比如示例1中的oc语句将转为如下
id returnValue = objc_msgSend(object, @selector(messageWithParam:), param);

其中,我们在OC中调用的方法名也就是发送的消息名,我们需要先把它使用 @selector(messageWithParam:)编码成一个SEL 变量,我们称之为选择器。这个选择器就是我们之前说key-IMP映射时的key,通过这个key来找到真正的方法实现IMP
而msgSend的内部就是根据这个选择器到类内部的方法列表中去查找,如果找不到将经过一个我们称之为消息转发的过程(后面会介绍《消息转发机制》)。

objc_msgSend怎么知道到哪个类的内部去找方法列表呢?— 就是通过isa当实例方法被调用时,它要通过自己持有的 isa 来查找对应的类,然后通过其class_data_bits_t结构体查找对应方法的实现。 如果是调用父类方法,则可通过 super_class指针用来查找继承的方法 – 看到这,我们可以利用我们的联想猜到我们开头的第2个问题 – OC中面向对象编程的继承是怎么实现的。

实例对象可以通过isa找到对应的类,那么类方法的实现又是如何查找并且调用的呢?这时,就需要“元类”出马了。

让每一个类对象的isa指向对应的元类,把类方法放到元类里面去!也就是说,实例对象的isa是类对象,而类对象的isa就是元类!这样的设计太巧妙了!这样,无论是类还是对象都能通过相同的机制查找方法的实现了(就是顺着其isa指针找):

  • 实例对象发消息时,通过它自己的 isa找到其类对象,并从类对象的方法列表中获取方法的实现(如找不到则通过superclass追溯其父类);
  • 而调用类方法时,通过类对象自己的 isa 找到其元类,并从中获取方法的实现(如找不到则通过superclass追溯其父类的元类!)

ios-runtime-oop-class_metaclass

最后再多说一句,正如我们开头所说的,OC中,NSObject是所有类的父类。所以上图的RootClass就是NSObject,也就是NSObject的元类的父类,也还是NSObject类对象。最后定义NSObject类的父类为空,就可以用于结束继承方法的查找

3.3 小实验

最后我们用cocoawithlove文章中的小实验验证一下上面的内容,实验动态创建一个子类RuntimeErrorSubclass,派生自NSError。然后打印看看其类对象、元类等信息:

小实验
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
void ReportFunction(id self, SEL _cmd)
{
NSLog(@"This object is %p.", self);
NSLog(@"Class is %@, and super is %@.", [self class], [self superclass]);
Class currentClass = [self class];
for( int i = 1; i < 5; ++i )
{
NSLog(@"Following the isa pointer %d times gives %p", i, currentClass);
currentClass = object_getClass(currentClass);
}
NSLog(@"NSObject's class is %p", [NSObject class]);
NSLog(@"NSObject's meta class is %p",object_getClass([NSObject class]));
}
// 动态创建一个类RuntimeErrorSubclass,继承自NSError
Class newClass = objc_allocateClassPair([NSError class], "RuntimeErrorSubclass", 0);
// 动态地给RuntimeErrorSubclass添加方法,并调用之
class_addMethod(newClass, @selector(report), (IMP)ReportFunction, "v@:");
objc_registerClassPair(newClass);
id instanceOfNewClass = [[newClass alloc] initWithDomain:@"some Domain" code:0 userInfo:nil];
[instanceOfNewClass performSelector:@selector(report)];
// 结果
// > This object is 0x103021390. (实例对象地址)
// > Class is RuntimeErrorSubclass, and super is NSError.
// > Following the isa pointer 1 times gives 0x103021cf0 (打印的是RuntimeErrorSubclass类实例的地址)
// > Following the isa pointer 2 times gives 0x103021d20 (访问RuntimeErrorSubclass类实例的isa,得到RuntimeErrorSubclass元类地址)
// > Following the isa pointer 3 times gives 0x7fff8ec730f0(访问RuntimeErrorSubclass元类的isa,因为所有元类的元类都是NSObject元类,所以此为NSObject元类的地址)
// > Following the isa pointer 4 times gives 0x7fff8ec730f0(因为NSObject元类的元类指向自己)
// > NSObject's class is 0x7fff8ec73140
// > NSObject's meta class is 0x7fff8ec730f0( 打印NSObject元类地址,证明了我们之前的解释)

这里小解释一下,对象的[obj class]方法得到的是实例对象的类对象。而object_getClass(class)为访问其isa指针,得到的是元类

4 小结

OC封装这个特性是靠类来实现的,类在Runtime中是objc_class结构体。其核心成员class_data_bits_t是一个复合指针,指向类核心封装数据结构class_rw_t。而class_rw_t采用了2层设计,外层存放了一些可扩展的成员列表。内层class_ro_t是类的只读基本信息。

继承的实现在于属性与方法的复用。实例对象成员的获取好理解,因为实例对象就是objc_class实例直接从class_rw_t中的列表中去取就行了。但是因为方法的复用是放在类中的,OC中运用了元类的概念,将实例对象的方法放在元类中。方法的调用使用消息传递机制,到元类中去获取。

多态的实现细节可以作为一个思考题,大家基于上面消息传递机制的图其实很容易就可以想通了。

最后附上我画了OC中OOP的基本实现类图结构,方便大家更直观地理解:

OC中OOP的基本实现类图结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌────────────────┐
│ objc_object │
├────────────────┤ ┌── ─── ── ── ───┐
│ isa_t isa ─────┼──────────▶│ metaClass │
└────────▲───────┘ └── ── ── ─── ───┘
┌─────────────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ objc_class │ │ class_rw_t │ │ class_ro_t │
├─────────────────────────┤ ├──────────────────┤ ├──────────────────┤
│ Class superclass │ │ flags │ │ name │
│ cache_t cache │ │ version │ │ ... │
│ │ ▶ .... │ │ baseMethodList │
│ class_data_bits_t bits ─┼─────│ methods │ │ baseProtocols │
│ │ │ properties │ │ baseProperties │
└─────────────────────────┘ │ protocols │ │ │
│ │ │ ivars │
│ class_ro_t *ro ──┼───▶ ivarLayout │
│ │ │ weakIvarLayout │
└──────────────────┘ └──────────────────┘

5 引用

  1. 《从 NSObject 的初始化了解 isa》- Draveness,Draveness关于Runtime的系列文章从源码入手,干货满满,是非常有城意的系列文章,强烈推荐大家阅读之。本文详细分析了 isa指针。
  2. 《Objective-C runtime机制(1)——基本数据结构:objc_object & objc_class》- slunlun 分析Runtime源码讲的很好的一篇文章,条理很清楚。
  3. http://www.cocoawithlove.com/2010/01/what-is-meta-class-in-objective-c.html ,cocoawithlove关于元类的一篇文章
  4. 《iOS-Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》 我学习本系列的主要参考书之一
坚持原创技术分享,您的支持将鼓励我继续创作!