《iOS 三问》 -- Objective-C 类结构体系与面向对象的实现
Objective-C 的 “圣经” – 《CFHipsterRef》对 Objective-C runtime
有句经典的描述 (下文我们将 Objective-C
简称 OC
,Objective-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 概述
其实当我们说 面向对象编程
的时候,我们主要就是在说他的几大编程特性,或者也可以说是思想 (因为这几个特性太过著名,已经深入大家之灵魂了):
- 封装;
- 继承;
- 多态。
封装
是 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 源码中,类
的定义如下:
1 | typedef struct objc_class *Class; |
这里可以知道的是,Class
可以用来定义一个 类结构
,但是其不是关键字,其实是 objc_class
这个结构体的别名。那么我们通过分析 objc_class
这个结构体,就可以一窥其实现细节了!
这里在看 objc_class
之前,我们巩固下我们面向对象的世界观~在 OC 中,所有类都有一个共同的祖先就是 NSObject
,这个大家都知道。我们不妨看下 NSObject 的定义,可以看出,他就是一个 类
– 其实就是一个 Class 结构体的实例。我们可以想像,程序启动后,会把我们定义的类加载到内存中,其中每个定义的类都会对应唯一一个 Class 结构体实例,而他们共同的祖先就是这个 NSObject 类结构体实例
。
1 | @interface NSObject <NSObject> { |
接下来我们具体看看 Runtime 中 objc_class 的定义。这里留意下,我专门罗列了 objc2.0 老版本和新版本中 objc_class
的定义,因为网上会有挺多讲 objc_class 的文章,很多用的是老的定义,可能会给大家带来很多困惑。但这里我更想大家体会一下的就是,其实版本的新旧问题不大,我们真正要做的是感受 OOP 设计与实现的原理及奥妙,因为思想的东西其实是很少会变的,所改动的地方无非是提高效率与优化而已。
我们先来看看老的定义:
1 | struct objc_class { |
可以看出,老的 Class 结构是独立的,包括了 元类
的设计(这个后面会介绍),还有类结构相关的信息(类名、父类),成员变量列表、方法列表、协议、方法缓存等。
我们再对比看看新的 Class 定义。新的 objc_class 定义在源码 objc-runtime-new.h
中。
1 | struct objc_class : objc_object { |
可以看到,objc_class 继承自 objc_object, 也就是说,在新一版的 objc4 实现上做了重新设计。即在 runtime 中,class 也被看做一种对象。这样做有什么好处呢?我能想到的就是这样使其 OOP 的实现设计更加统一了。因为在 OC 中 类 (Calss)
也具有一些对象一样的特性,比如 类方法
,你可以给 类
发送消息调用其 类方法
。而 类实例
在内存中也会像一个对象实例一样占有一份内存,这样对象和类的关系就有点像 JS 里面的 原型 (prototype)
,学完本文后感兴趣的童鞋可以去了解下~
而从上面类的结构中我们可以看出其中几个重要的信息:
- superclass 指针 (
Class
就是objc_class *
,上文已经分析了哈),指向其父类,这就回答了我们关于 OC 类继承的实现的问题。类的继承和我们想像中的一样就是子类有指向其父类的指针。方便要访问父类成员或方法时可以引用。 - 第 2 点就是 OC 的一个特性。因为 OC 方法的调用采用的是消息传递的机制,而针对消息的方法查找需要一个过程。Runtime 就在类结构中加了一个这样的
缓存表
,常命中的方法放到缓存中去,以便消息查找时先从缓存表中去查(这里涉及一个 28 理论,就是一个类常用的方法也就是 80% 会被调用的通常只是它所有方法中的 20%。)。 class_data_bits_t
,这个数据结构比较巧妙,它主要是一个指针,指向了类的主要封装的数据结构。我们下面就来主要看看这个。
在分析 class_data_bits_t
之前我们小看一下 cache_t cache;
。我们说它其实是一个方法的缓存表。
1 | struct cache_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 与与可以取位得到想要的数据。
1 | struct class_data_bits_t { |
可以看出,class_data_bits_t
其实只有一个数据成员,就是 uintptr_t bits
。上面我们分析过,uintptr_t
是一个 8 字节 (u long) 无符类型,存的一般就是指针。这个指针的低位一定是没用到,所以 Runtime 没有浪费,用来存类的相关信息了,要用的时候就用相应的 flag 与之与操作 (就像掩码一样),就可以取出相应数据。在 Runtime 中有许多这样的设计,这样做的好处就是减少了数据结构的存储空间,使结构体内的成员分布最精简,因为面向对象语言嘛,肯定会产生大量的类和对象结构。
比如说 bits
中使用一位来表示这个是 Swift 类还是 OC 类,我们用 FAST_IS_SWIFT
这个 flag 和 bits
做与操作,就可以取出对应的位:
1 | bool isSwift() { |
这样我们就理解了 data ()
这个方法了,通过 FAST_DATA_MASK
取出的数据就是指向类封装数据的结构体指针,如下。
1 | class_rw_t* data() { |
而这个指针的类型是 – class_rw_t
。我们再看看 class_rw_t
。
2.2 类核心结构 class_rw_t
1 | // 外层 Runtime 类结构 |
我们可以看到,类的封装数据无非就是我们之前设想的成员变量与方法列表等,但是它是封装成 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) 的,因此,类可以看做是一类特殊的对象。
1 | struct objc_object { |
可以看到, objc_object
的定义很简单,仅包含一个 isa_t
类型。
3.1 isa_t
我们通过分析这个 isa_t
类型,可以发现这个属性和类的 class_data_bits_t
成员有点相像,都是使用了一个指针结构但是复用了指针内的一些位来表示一些信息或标志位。印象不够深的同学可以回上去复看一下 class_data_bits_t
。我寻思这种设计的初衷就是因为 Runtime 中许多内存使用是对齐的,因此一些特定结构的地址指针的低位就没有用,为了节约,Runtime 就往这些没用到的低位字节中 “塞” 了一些数据,要用的时候使用 flag 掩码按位与取出来用即可。
我们下面看看这个 isa_t
:
1 | union isa_t |
isa_t 是一个联合,可以表示 Class cls 或 uintptr_t bits 类型。实际上在 OC 2.0 里面,多数时间用的是 uintptr_t bits,因为 64 位系统中,这个指针结构可以额外存很多信息 (原因上面说过了)。要详细了解 isa_t
,可以学习下这篇文章:[《从 NSObject 的初始化了解 isa》 - Draveness](https://github.com/Draveness/analyze/blob/master/contents/objc/ 从 %20NSObject%20 的初始化了解 %20isa.md), 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 中,要调用一个对象的方法,得像这样给它发消息:
1 | id returnValue = [object messageWithParam:param] |
Runtime 会将像上面的消息语法转成下面这样:
1 | id objc_msgSend (id self, SEL op, ...); // 后面的... 为可变参数,表示接受 2 个或以上参数 |
其中,我们在 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
追溯其父类的元类!)
最后再多说一句,正如我们开头所说的,OC 中,NSObject
是所有类的父类。所以上图的 RootClass 就是 NSObject
,也就是 NSObject
的元类的父类,也还是 NSObject
类对象。最后定义 NSObject
类的父类为空,就可以用于结束继承方法的查找。
3.3 小实验
最后我们用 cocoawithlove 文章中的小实验验证一下上面的内容,实验动态创建一个子类 RuntimeErrorSubclass
,派生自 NSError
。然后打印看看其类对象、元类等信息:
1 | void ReportFunction(id self, SEL _cmd) |
这里小解释一下,对象的 [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 的基本实现类图结构,方便大家更直观地理解:
1 | ┌──────────────────┐ |
5 引用
- [《从 NSObject 的初始化了解 isa》- Draveness](https://github.com/Draveness/analyze/blob/master/contents/objc/ 从 %20NSObject%20 的初始化了解 %20isa.md),Draveness 关于 Runtime 的系列文章从源码入手,干货满满,是非常有城意的系列文章,强烈推荐大家阅读之。本文详细分析了 isa 指针。
- 《Objective-C runtime 机制 (1)—— 基本数据结构:objc_object & objc_class》- slunlun 分析 Runtime 源码讲的很好的一篇文章,条理很清楚。
- http://www.cocoawithlove.com/2010/01/what-is-meta-class-in-objective-c.html ,cocoawithlove 关于元类的一篇文章
- 《iOS-Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》 我学习本系列的主要参考书之一