Swift类与结构体--方法调度

3,047 阅读11分钟

前面讲了类与结构体的本质,以及类和结构体属性的特性,那么这一章则说一下方法如何调度。

方法调度

众所周知,对于OC来说,类的方法调度,都是功过runtime中的objc_mgsend这样的消息机制,来获取IMP来寻找和运行方法的。

我们先来看一下 Swift 中的方法调度。 首先我们看一个例子:

class LGTeacher{ 
    func teach(){
        print("teach") 
    }
}
var t = LGTeacher() 
t.teach()

通过Debug,Always Show Disassembly显示汇编语言,

image.png

汇编中调用代码,一般为blr指令和bl指令。 在LGTeacher alloca和release之间,只有一个方法调用blr指令。我们猜测是不是这条指令就是方法teach()的调用呢?contrl+stepinto image.png 这就是teach方法,那么这个方法如何调度的呢,我们再看另一个例子:

class LGTeacher{
    func teach(){
       print("teach")
    }
    func teach1(){
        print("teach1")
    }
    func teach2(){
        print("teach2")
    }
}

var t = LGTeacher() 
t.teach()
t.teach1()
t.teach2()

这里调用了teach(),teach1(),teach2()但个方法,如果我们猜测正确,汇编中在创建和release之间应该有三个blr指令,我么看一下debug的汇编断点,如下 image.png

在LGTeacher alloca和release之间,确实有三个方法调用blr指令。证实基本成立,那么继续探究,这个方法怎么来的呢?细看下面这几句

image.png

细看这一段,x8这个寄存器到底是干嘛的呢?blr调用的x8到底代表什么,图中代码第一句,mov指令是将x0赋值到x8寄存器,x0里面是什么呢?那么汇编中这里有个固定模式,函数的返回值是放在x0里面的,

那么上面bl指令调用的allcating__init()的返回值,就应该在x0中,很显然他就是个LGTeacher的实例对象。 mov x8 x0 就是将LGTeacher对象结构体放入x8寄存器中, 再看下面的ldr指令,他是将内存中的值读取到寄存器当中,例如: image.png 那么这条ldr指令意思就是,将x0的值读到x8寄存器中,64位寄存器x8取x0前8个字节(64位),是什么呢,LGTeacher的实例对象前8个字节是什么?MedaData(前面章节提到过) 如下打印地址证实

image.png

接下来最后一句ldr指令,[x8, #0x50] MedaData的内存地址加0x50放入x8。现在x8就是teach函数的内存地址了。

经过上面的推断和证实,teach函数的调用过程,我们先做一个总结

  • 找到 Metadata,
  • 确定函数地址 = (metaDataAddress + 偏移量(#0x50)),
  • 执行函数 现在读者要记住,我们研究的是函数的调度,metaDataAddress + 偏移量#0x50 那么0x50这个偏移量哪里来的呢?再往下看 image.png 三个方法,teach、teach1、teach2各自是0x50,0x58,0x60 相差8字节,他们是一个连续的内存空间,那么我们大胆猜测,Metadata里面是不是有一个连续的函数表?V-Table来存储函数地址的偏移量呢?我们的方法调度,就是基于这个偏移量的表来调度呢?

我们再从SIL中找找看,发现teach、teach1、teach2都在一个sil_vtable的表结构中,那么上面所说的V-Table确实的存在的。 image.png

那么V-Table在哪里呢,这里我们先提出一个函数调度的概念,便于后面区别于其他方式

基于函数表的调度

之前的章节,我们在类与结构体中,探索了 Metdata 的数据结构,最终汇总得出下面的结构,那么V-Table存放在什么地方? 我们先来回顾一下先前Metadata的数据结构。

struct Metadata{
var kind: Int
var superClass: Any.Type 
var cacheData: (Int, Int) 
var data: Int
var classFlags: Int32
var instanceAddressPoint: UInt32
var instanceSize: UInt32
var instanceAlignmentMask: UInt16
var reserved: UInt16
var classSize: UInt32
var classAddressPoint: UInt32
var typeDescriptor: UnsafeMutableRawPointer 
var iVarDestroyer: UnsafeRawPointer
}

这里有一个东⻄需要关注 typeDescriptor ,不管是Class,Struct,Enum 都有metaData,都有metaData中都有自己的Descriptor,它是对类的一个详细描述,它里面是什么结构呢?

我们先从前面提到的TargetClassMetadata(metaData)看起, image.png 它里面的Descriotion就是我们上面的typeDescriptor,它关联一个TargetClassDescriptor如下 image.png image.png 我们通过源码还原了TargetClassDescriptor的数据结构,如下:

struct TargetClassDescriptor{ 
var flags: UInt32
var parent: UInt32
var name: Int32 //类、结构体名字
var accessFunctionPointer: Int32
var fieldDescriptor: Int32
var superClassType: Int32
var metadataNegativeSizeInWords: UInt32 
var metadataPositiveSizeInWords: UInt32 
var numImmediateMembers: UInt32
var numFields: UInt32
var fieldOffsetVectorOffset: UInt32 
var Offset: UInt32
var size: UInt32
//V-Table
}

这里也没有V-Table啊? 那么怎么确定V-Table就在这里呢? 我们还是得去swift源码中研究一下。 发现TargetClassDescriptor有个别名ClassDescriptor image.png 我们全局找一下这个ClassDescriptor, image.png 我们在GenMeta中,生成Meta的文件中,我们发现一个和ClassDescriptor关联的类ClassContextDescriptorBuilder, image.png ClassContextDescriptorBuilder是不是创建Descriptor或者metadata的地方呢?发现一个方法layout image.png addVTable(),addOverrideTable()..找到组织了吧同学 在看super的layout image.png 是不是一样样,和上面的TargetClassDescriptor结构。 我们看看addVTable()到底怎么建的。进入方法 image.png 通过源码我们最终找到了addVTable()这个函数,

  • 判空
  • 优化判断,不知道干啥的。
  • 计算一个偏移量,将这个偏移量添加到B
  • 将vtable的size添加到B
  • 第三句,for循环添加fn, fn为函数指针 那么如果B关联的就是TargetClassDescriptor,那么这个addVTable就能说明TargetClassDescriptor里面vtable的位置。 但是上面的一系列推测,不是非常直接,很模糊,我们还需通过Mach-o来直接证实一下。

我们开始使用分析swift底层的第三板斧,Mach-o。

Mach-o

Mahco: Mach-O其实是Mach Object文件格式的缩写,是mac 以及iOS上可执行文件的格式,类似于windows上的 PE 格式 (Portable Executable ),linux 上的elf 格式 (Executable and Linking Format)。常⻅的 .o,.a .dylib Framework,dyld .dsym。

Mahoc文件格式:

下面是mach-o文件的具体格式,我们一一介绍 image.png

  • 首先是header文件头,是2进制的一些信息,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排等,如32位还是64位,CPUType,CPUSubType,fileType,加载命令个数,大小,一些Flags等。 image.png
  • Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。 image.png
  • Data区主要就是负责代码和数据记录的。存的就是具体的代码和数据,Mach-O是以 Segment这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment是映射的哪一个Load Command,Segment中section就可以被解读为是代码,常量或者一些其他的数据类型。在装载在内存中时,也是根据Segment做内存映射的。 image.png image.png image.png 比如,Section64(_Text,_text)存放的就是当前的汇编指令, Section64(_Text,_cstring)存放的我们硬编码的字符串 Section64(_Text,_objcclasslist)存放的我们的oc类 image.png

注意:Section64(_Text,_Swift5_Types),这个Section就存放我们struct,enum和类的Descriptor的地址信息。

image.png 我们来证实一下,我们取这里的前四个字节和pfile相加: 0xfffffbf4 + 0xbc68 = 0x10000B85C, 0x10000000是虚拟地址的基础地址,那么0xB85C就是我们Descriptor在整个数据区域的地址,或者偏移量。 我们在Section64(_Text,_const)找到0xB85C,这里是不是就是我们假设的Descriptor的内容呢? image.png 此时此刻,我们反推一下,既然这里是Descriptor的内容的首地址,那我们这个地址加上v-table上面的结构的全部偏移,是不是就是v-table呢,偏移是12个4字节,我们接着数一下,第13个地址: 那么蓝色区域是不是就是我们的teach,teach1,teach2呢? 0xB890就是teach?我们怎么验证呢? 好累! 我们是不是根据这个地址,推算出程序中的运行地址,然后看看是否和teach一样就行了。gogogo Debug程序,image list 找到程序运行的基地址(ASLR 空间配置随机地址) 0x0000000100044000 这个基地址加上0xB85C是不是就是运行的地址呢? 0x100044000 + 0xB890 = 0x10004F890 我们先看看源码中,一个函数是一个什么结构 image.png 此时此刻,0x10004F890应该就是teach的TargetMethodDescriptor地址了。 那偏移掉里面第一个数据flags(4字节),是不是就是函数的IMP了。 0x10004F890 + 0x4 = 0x10004F894 注意这里的impl是一个相对地址,需要加偏移量 0xffffb9d4(mach-o里面) 0x10004F894 + 0xffffc220 = 0x20004BAB4 - 0x100000000 = 0x10004BAB4 通过register read x8 读取到teach()地址也是0x10004BAB4。 所以我们最终也证实了这个结构:

struct TargetClassDescriptor{ 
var flags: UInt32
var parent: UInt32
var name: Int32 //类、结构体名字
var accessFunctionPointer: Int32
var fieldDescriptor: Int32
var superClassType: Int32
var metadataNegativeSizeInWords: UInt32 
var metadataPositiveSizeInWords: UInt32 
var numImmediateMembers: UInt32
var numFields: UInt32
var fieldOffsetVectorOffset: UInt32 
var Offset: UInt32
var size: UInt32
Array V-Table
}

最后说一下为什么是 (metaDataAddress + 偏移量(#0x50)) 我们看源码: image.png

注意一点就是,子类和父类都有自己的metadat和v-table他不会在派发的时候向上寻找。空间换取时间。加快效率。

基于内存的静态派发

上面是类的情况,如果我们把类改成结构体呢? image.png 在结构体情况下,teach等三个函数是直接调用内存的,这里提出一个概念:基于内存的静态派发。或者直接调度。为什么呢?

值类型是没有继承关系的,他没有必要做vtable记录的,直接编译好就行,直接调用,也就是说,当编译完成后,函数的内存地址就已经确定了。

extension也是基于内存的静态派发

对于extension来说,extension一个函数,不管是结构体还是类,都是静态派发。 这是为什么呢? 很简单,extension可以在其他文件创建,swift是编译时的,如果要将extension的函数放到定义的class的地方,就要对v-table表进行插入或者修改,开销是非常昂贵的,这是很不现实的。苹果不会这么设计。 想继承重载的方法,就必须v-table调用,或者走oc的方式,这也是为什么要想重写extension父类的方法,必须加@objc。 如果不重写,就可以静态调用。 image.png

影响函数派发方式

  • final: 添加了final关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可⻅。属性和方法不需要重载的时候。就可以用final关键字,且对objc运行时不可⻅。

  • dynamic: 函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。结构体还是静态派发。用法如下: image.png

  • @objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。

  • @objc + dynamic: 消息派发的方式,也就是具有runtime的消息调度机制,但是objc也还是调用不了,只是可以Method-swizzling, 使用Runtime的API。

image.png

函数内联

函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。

  • 将确保有时内联函数。这是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内联函数作为优化。
  • always 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
  • never 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
  • 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))

如果对象只在声明的文件中可⻅,可以用 private 或 fileprivate 进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private: 定义的声明 中访问)

另外在xcode中setting可以设置函数的优化等级。