利用lldb和源码探析了Runtime,系统整理了过程和结果,不然老是忘…本文主题为类与对象的本质,objc_msgSend、weak-table等不在本文讨论,将会另外开文章。
类与对象的本质
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
的对象,t1
是Test1
类的实例。
那么在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];
变成了什么样子。
注意这个objc_msgSend
,该方法是OC的灵魂,使OC有了动态语言的特性。该方法的实现是直接用汇编语言实现的,可以看到我们的方法调用都是变成了void objc_msgSend(void);
的形式。
struct objc_object
在cpp代码文件中,我们得知了对象的底层类型是struct objc_object
,并且如果我们翻一遍该文件,可以发现万物皆对象这句话的来源。可以发现无论是Protocol
、NSArray
等常用数据类型、id
等都是struct objc_object
类型。那么我们到OBJC4源码中找找objc_object是一个什么样的结构。
可以看到,除了一些公开函数外,在private部分objc_object
只有一个值,一个类型为isa_t
的变量。
让我们再找一下isa_t
是什么
实际上,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结构体
接着是OBJC2版本的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_class
用bits
将这些数据分隔并隐藏了起来,通过bits
我们可以取到class_rw_t*
类型的data,通过class_rw_t
可以取到class_ro_t
。也就是说类被分成了class_rw_t
和class_ro_t
两个部分。
继承链
从以上分析可得,新版与旧版的类与对象的关系链并没有太大区别,只是新版的类结构进行了一些优化和封装。关系链依然是 对象->类->元类
类的结构
与OBJC1版本的代码不同,OBJC2中类结构里并没有直接暴露出属性、方法等内容。这些内容都藏在了class_data_bits_t
变量中。而在其中又分成了class_rw_t
和class_ro_t
,后来Apple为了内存占用方面的考虑,由再次优化了这个结构,在class_rw_t
和class_ro_t
这个结构上再次分别分化出了class_rw_ext_t
.
struct 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_t
中,可以看到有baseMethodList
,baseProtocols
,ivars
,baseProperties
等几个值。在OC中,实例方法的定义存放在类的class_rw_t
中,而类方法、成员变量等则存放在元类的class_ro_t
中。在程序开始运行时,Runtime会基于class_ro_t
拷贝出一份值作为class_rw_t
,当我们进行动态添加方法时,改动的其实是class_rw_t
,class_ro_t
是const的,不可修改。
lldb调试验证
以上的内容都是我们根据对源代码的阅读和分析给出的一个结论,接下来我们将利用objc4-781
源码与lldb进行编译调试后验证上述提到的结构和结论。
我们首先编写一个简单的main函数
其中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
。
至此,我们可以验证前文中的结论是正确的了。
instance methods
实例方法存放于类的class_rw_t
中,在class_rw_t
中我们可以找到这样几个方法。
我们先尝试获取一下类的实例方法列表,看看能不能看到我们定义的实例方法。
调用method()
函数后,返回的是一个method_array_t
,从它的定义和结构来看,它是一个二维的容器。我们需要获取到里面的内容,取它的list。
获取到list之后,里面存放的是一个地址,我们接着获取这个地址。
可以看到,lldb对于$5.ptr
的输出是一个method_list_t *const
的地址,至此我们就获取到类的方法列表了。
由于获取到了方法列表的地址,我们使用*操作符
来读取一下地址上的数据。
可以看到读出来的method_list_t
里是一个entsize_list_tt
的结构。我们可以在代码里找到这个结构的定义。
在这个结构体内部定义了获取数组内的值的方法。
显然,我们可以调用get()
函数来获取到entsize_list_tt
里的内容。当我们调用get()
后却发现,读出来的数据为空,这是为什么呢?
查阅网上资料后才发现,method的具体内容被一个big()
隐藏里,在之前的版本中big()
的定义和实现是能在代码中找到的,但本文参考的objc4-781
版本代码中貌似没有找到该函数的定义,若有读者知道big()
的相关信息欢迎在评论区指出。
在调用big()
后,lldb终于是输出了我们想看到的内容。
可以看到我们定义的实例方法instanceMethod1
成功地被打印了出来,而method_t::big
的结构就是method_t
定义的经典三段式结构(name-types-imp)
class methods
前文中我们已经找到了属性、实例方法、成员变量(存放在类对象的class_ro_t)中,那么还剩下一个东西,那就是类方法class method。类方法其实存放在元类Meta Class里。我们知道objc_class
继承了objc_object
,也就是说它结构中是隐含了一个ISA
指针的。
在对象中,对象的ISA指针指向了对象所属的类,那类中的ISA指针指向哪里呢?元类。
这张图中的链接关系,现在只剩下class->meta class这条没有被验证了。接下来我们利用lldb找一下元类。
第一步我们需要找到类对象的ISA指针。
在objc_object
结构体中有一个叫ISA()
的函数
查看该函数的实现
发现,将isa.bits & ISA_MASK
可以得到ISA指向的Class,查阅更多资料后发现这个的确是获取到元类的方法。
使用x/4gx
指令读取类对象地址的内容,第一个地址即为类中的ISA指针存放的地址。
将该地址 &
上ISA_MASK, 即0x00007ffffffffff8ULL,用po
打印得到的结果,发现的确是LGPerson类,并且显然地址与[objc2 class]
方法得到的不一样,说明这个就是元类的地址了。
接下来的流程与其他的无异,将该地址加上0x20的偏移量,得到元类的class_data_bits_t。接着调用data()
方法得到相关数据。
可以看到,在这里我们成功找到了类方法,这说明我们关于找元类的方法是正确的,同时类方法也的确是存储在元类中。
其实,类方法最开始的存储位置应该是在元类的class_ro_t中的,通过打印class_ro_t的内容,我们同样可以找到类方法的定义。
写在最后
之前已经多次看过关于类与对象的底层源码,并且也尝试了lldb调试验证理论,但系统地记录并跑通所有验证还是第一次。个人认为理解类与对象的本质和原理非常重要,诸如method swizzling等runtime黑科技也是基于对类与对象的理解而产生的。通过对类与对象结构的学习,像为什么Category不能在运行时添加成员变量等问题也水到渠成地解决了。虽然这篇文章耗时非常长,时间大多耗在走通lldb的验证上,但最后还是收获满满。