iOS Runtime之类与对象的本质

Runtime 解析 2.0

类与对象的本质

Runtime是Objective-C语言与C语言最大的一个不同,通过Runtime库OC实现了C语言没有的面向对象特性与动态语言特性。就如名字一样,Runtime指的是运行时,即程序已经在计算机系统中装载运行起来后的时期,区别于编译期。Runtime本身是一个由C/C++编写的库,包含了大部分我们在OC中日常使用到的数据结构与方法,目前已经开源,源代码可以在Apple Open Source网站上下载到。

本文参考Objc4-781版本源码

类与对象

类与对象,是面向对象语言中的基石,每个第一次学习面向对象语言的开发者都会面对一个灵魂拷问问题:什么是类与对象。

在Objective-C中,我们使用.h和.m文件,通过@interface @implement就可以定义一个Objective-C Class。而当我们使用该Class创建一个Class的实例时,该实例(instance)就被叫做对象。

如图,我们在main方法中创建了一个名为t1的对象,t1Test1类的实例。

那么在Objective-C中,类和对象在底层的定义是什么样的呢?我们先将main.m转换成main.cpp,看看能不能在其中发现什么端倪。

OC to C++

通过clang -rewrite-objc main.m命令,main.m文件被转换成了main.cpp,也就是C++代码,Runtime本身就是一个包含了大量C++代码的运行库。

查看转换出来的main.cpp,我们在第一行就可以看到一段疑似是类的定义的代码。

记住这个struct objc_class,同时也要注意到我们的运行环境是__OBJC2__,这也是源码阅读的一个关键点,因为在OBJC4的代码中依然存在一些已经老旧的OBJC1代码。

将代码往下拉,找到我们的main函数。在main函数的上方,我们可以找到这样的一条定义。

从命名可以得知,struct objc_object就是对象在Runtime中的定义,这点在之后我们会继续验证。

接着,让我们把注意点放到main方法中。在这里我们可以看到我们的源代码中的Test1 *t1 = [[Test1 alloc] init];变成了什么样子。

-w580

注意这个objc_msgSend,该方法是OC的灵魂,使OC有了动态语言的特性。该方法的实现是直接用汇编语言实现的,可以看到我们的方法调用都是变成了void objc_msgSend(void);的形式。

struct objc_object

在cpp代码文件中,我们得知了对象的底层类型是struct objc_object,并且如果我们翻一遍该文件,可以发现万物皆对象这句话的来源。可以发现无论是ProtocolNSArray等常用数据类型、id等都是struct objc_object类型。那么我们到OBJC4源码中找找objc_object是一个什么样的结构。

objc_object

可以看到,除了一些公开函数外,在private部分objc_object只有一个值,一个类型为isa_t的变量。

让我们再找一下isa_t是什么

isa

实际上,isa_t对应的就是OBJC1中的isa指针。在OBJC2中对isa指针做了更多的一些优化和封装,而它的关键功能还是不变的,就是包含了一个Class类型的值cls

这里的Class就是。在此处的上方可以找到Class的定义,也就是objc_class

自此,类与对象的底层结构都已经找到了。 这里有很关键的一点:OBJC1与OBJC2对于类与对象的结构定义有所区别,我们在学习探究的时候参考的应该是objc-private,objc-runtime-new等文件,由于在objc-private.h中定义了#define OBJC_TYPES_DEFINED 1,所以objc.h已不适用。

struct objc_class

首先先展示旧版OBJC1的objc_class结构体 旧版objc_class-w417

接着是OBJC2版本的objc_class结构体 新版objc_class 以上是OBJC2版本的objc_class结构,当前我们使用的OC版本都是这个结构,其与OBJC1版本的objc_class结构区别非常大,诸如method列表、properties列表等已不再直接在结构体中暴露出来。

在新版的objc_class中,结构体内由于继承了objc_object,所以其实它也是保留了一个ISA指针的。学过旧版Runtime知识的同学应该知道,对象的ISA指针指向对象所属的类,而类的ISA指针指向元类(metaclass)

接下来我们解析一下struct objc_class结构体中几个值的作用。

  • Class ISA: 指向类的元类(Meta Class)
  • cache_t cache: 缓存列表。由于一个类的方法可能会有很多,所以当调用了一个方法后,Runtime会将方法加入到cache中,以减少下次调用该方法的查找时间,提高程序的运行效率
  • class_data_bits_t bits: 在旧版中方法列表等值都直接在结构体中暴露出来,而新版中objc_classbits将这些数据分隔并隐藏了起来,通过bits我们可以取到class_rw_t*类型的data,通过class_rw_t可以取到class_ro_t。也就是说类被分成了class_rw_tclass_ro_t两个部分。

继承链

从以上分析可得,新版与旧版的类与对象的关系链并没有太大区别,只是新版的类结构进行了一些优化和封装。关系链依然是 对象->类->元类

类的结构

与OBJC1版本的代码不同,OBJC2中类结构里并没有直接暴露出属性、方法等内容。这些内容都藏在了class_data_bits_t变量中。而在其中又分成了class_rw_tclass_ro_t,后来Apple为了内存占用方面的考虑,由再次优化了这个结构,在class_rw_tclass_ro_t这个结构上再次分别分化出了class_rw_ext_t.

struct objc_class

新版objc_class

这是OBJC2的Class结构,其中各个值的意义在上文中已讲过。在这里如果我们想要访问class_rw_t的话,需要先取到class_data_bits_t bits.

由于无法直接访问到bits,所以我们要利用结构体的内存分布规律来在lldb中获取到bits。由于ISA与superclass都是一个指针,所以他们各占8字节。cache通过查看它的结构可以得知它占16个字节,所以bits在结构体中的内存偏移应该为32字节。

通过这样的方法拿到bits后,调用data()方法,就可以取到class_rw_t

class_rw_t

在上文中提到了获取class_rw_t的方法,现在我们看看它里面有什么。

跳过一大堆的函数,我们可以看到熟悉的几个函数。

所以,调用这几个函数,应该就可以获取到对应旧版OBJC1代码中的methods,properties了。

同时,通过ro()函数也可以获取到对应的class_ro_t

class_ro_t

class_ro_tclass_ro_t中,可以看到有baseMethodList,baseProtocols,ivars,baseProperties等几个值。在OC中,实例方法的定义存放在类的class_rw_t中,而类方法、成员变量等则存放在元类class_ro_t中。在程序开始运行时,Runtime会基于class_ro_t拷贝出一份值作为class_rw_t,当我们进行动态添加方法时,改动的其实是class_rw_tclass_ro_tconst的,不可修改。

lldb调试验证

以上的内容都是我们根据对源代码的阅读和分析给出的一个结论,接下来我们将利用objc4-781源码与lldb进行编译调试后验证上述提到的结构和结论。

我们首先编写一个简单的main函数 -w521

其中LGPerson类拥有一个属性,一个类方法以及一个实例方法。

获取class_data_bits_t

首先,在前文的分析中我们知道要获取类结构中的数据我们首先需要取到class_data_bits_t, 由于内存偏移可知,要取得该值需要在类对象地址的基础上偏移8+8+16=32个字节,转化成16进制即0x20

进入lldb调试模式后我们来尝试获取class_data_bits_t

从上图可以看到,我们成功取到了class_data_bits_t类型的指针。

获取class_rw_t

class_rw_t结构是类中比较关键的一个结构,即使它还分出来了一个class_rw_ext_t,但由于后者是出于优化的设计,本文在讨论时约定默认提到class_rw_t隐含class_rw_ext_t.

查看class_data_bits_t的结构定义,我们可以发现在里面有一个public的data()方法,该方法通过bits & FAST_DATA_MASK返回class_rw_t的指针。

在上一小段得到的$2基础上调用data()函数,我们可以得到类的class_rw_t

-w422

至此,我们可以验证前文中的结论是正确的了。

instance methods

实例方法存放于类的class_rw_t中,在class_rw_t中我们可以找到这样几个方法。 -w747

我们先尝试获取一下类的实例方法列表,看看能不能看到我们定义的实例方法。

-w616

调用method()函数后,返回的是一个method_array_t,从它的定义和结构来看,它是一个二维的容器。我们需要获取到里面的内容,取它的list。

获取到list之后,里面存放的是一个地址,我们接着获取这个地址。

可以看到,lldb对于$5.ptr的输出是一个method_list_t *const的地址,至此我们就获取到类的方法列表了。

由于获取到了方法列表的地址,我们使用*操作符来读取一下地址上的数据。

-w602

可以看到读出来的method_list_t里是一个entsize_list_tt的结构。我们可以在代码里找到这个结构的定义。 -w716

在这个结构体内部定义了获取数组内的值的方法。

-w573

显然,我们可以调用get()函数来获取到entsize_list_tt里的内容。当我们调用get()后却发现,读出来的数据为空,这是为什么呢? 查阅网上资料后才发现,method的具体内容被一个big()隐藏里,在之前的版本中big()的定义和实现是能在代码中找到的,但本文参考的objc4-781版本代码中貌似没有找到该函数的定义,若有读者知道big()的相关信息欢迎在评论区指出。

在调用big()后,lldb终于是输出了我们想看到的内容。

-w551

可以看到我们定义的实例方法instanceMethod1成功地被打印了出来,而method_t::big的结构就是method_t定义的经典三段式结构(name-types-imp)

-w590

class methods

前文中我们已经找到了属性、实例方法、成员变量(存放在类对象的class_ro_t)中,那么还剩下一个东西,那就是类方法class method。类方法其实存放在元类Meta Class里。我们知道objc_class继承了objc_object,也就是说它结构中是隐含了一个ISA指针的。 在对象中,对象的ISA指针指向了对象所属的类,那类中的ISA指针指向哪里呢?元类。

这张图中的链接关系,现在只剩下class->meta class这条没有被验证了。接下来我们利用lldb找一下元类。

第一步我们需要找到类对象的ISA指针。 -w371

objc_object结构体中有一个叫ISA()的函数 -w571

查看该函数的实现

-w454

发现,将isa.bits & ISA_MASK可以得到ISA指向的Class,查阅更多资料后发现这个的确是获取到元类的方法。

-w462

使用x/4gx指令读取类对象地址的内容,第一个地址即为类中的ISA指针存放的地址。

将该地址 & 上ISA_MASK, 即0x00007ffffffffff8ULL,用po打印得到的结果,发现的确是LGPerson类,并且显然地址与[objc2 class]方法得到的不一样,说明这个就是元类的地址了。

接下来的流程与其他的无异,将该地址加上0x20的偏移量,得到元类的class_data_bits_t。接着调用data()方法得到相关数据。

获取class_rw_t 类方法

可以看到,在这里我们成功找到了类方法,这说明我们关于找元类的方法是正确的,同时类方法也的确是存储在元类中。

其实,类方法最开始的存储位置应该是在元类的class_ro_t中的,通过打印class_ro_t的内容,我们同样可以找到类方法的定义。

-w607

写在最后

之前已经多次看过关于类与对象的底层源码,并且也尝试了lldb调试验证理论,但系统地记录并跑通所有验证还是第一次。个人认为理解类与对象的本质和原理非常重要,诸如method swizzling等runtime黑科技也是基于对类与对象的理解而产生的。通过对类与对象结构的学习,像为什么Category不能在运行时添加成员变量等问题也水到渠成地解决了。虽然这篇文章耗时非常长,时间大多耗在走通lldb的验证上,但最后还是收获满满。

如有错漏,欢迎提出


Tino Wu.
more at tinowu.top

猜你喜欢

转载自juejin.im/post/7030998165151023141