美文网首页
iOS Runtime之类与对象的本质

iOS Runtime之类与对象的本质

作者: tino又想吃肉了 | 来源:发表于2021-11-16 11:06 被阅读0次

    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)就被叫做对象。

    image

    如图,我们在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,我们在第一行就可以看到一段疑似是类的定义的代码。

    image

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

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

    image

    从命名可以得知,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

    image

    自此,类与对象的底层结构都已经找到了。
    这里有很关键的一点: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两个部分。

    继承链

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


    image

    类的结构

    与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的方法,现在我们看看它里面有什么。

    image

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


    image

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

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

    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_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

    image

    从上图可以看到,我们成功取到了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指针指向哪里呢?元类。

    image

    这张图中的链接关系,现在只剩下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

    相关文章

      网友评论

          本文标题:iOS Runtime之类与对象的本质

          本文链接:https://www.haomeiwen.com/subject/izchtrtx.html