继Swift类与结构体(上)我们研究了Swift类和结构体的区别和类的生命周期后,这篇我们继续对Swift的方法进行研究
1、变异方法
-
Swift 中 Class 和 Struct 都能定义方法,但稍有区别的是,默认情况下,
值类型属性(struct、enum) 不能被自身实例方法修改; -
如果你确实需要在某个特定的方法中修改struct或者enum的属性,你可以为这个方法选择
可变(mutating)行为,然后就可以从其方法内部改变它的属性;并且这个方法做的任何改变都会在方法执行 结束时写回到原始结构中。方法还可以给它隐含的self属性赋予一个全新的实例,这个 新实例在方法结束时会替换现存实例 -
举例分析添加mutating后,SIL文件中的变化,Swift代码如下:(值类型才能用mutating)
拿到SIL文件后找到两个func,发现添加mutating的func多出1个
@inout来修饰Point- @inout:当前参数类型是间接的,传递的是
已经初始化过的地址&
通过分析我们这回知道了,不加mutating修饰的func里Point代表这个struct的值,而加了mutating修饰的func里Point代表这个struct的地址,能拿到地址当然就可以直接修改属性值了
输入输出参数:如果想函数能够修改一个形式参数的值,并希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数.在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数
- @inout:当前参数类型是间接的,传递的是
总结
-
类型的每一个实例都有一个隐藏属性
self,代表的是实例本身- 使用self的主要场景是,实例方法的某个参数名称与实例的某个属性名同名时,参数名享有优先权,并且在引用属性时必须使用一种更严格的方式;上图中,无参addCount函数中就可以直接使用count进行+1操作,但有参addCount函数中调用属性就不能省略
self.了
- 使用self的主要场景是,实例方法的某个参数名称与实例的某个属性名同名时,参数名享有优先权,并且在引用属性时必须使用一种更严格的方式;上图中,无参addCount函数中就可以直接使用count进行+1操作,但有参addCount函数中调用属性就不能省略
-
使用
inout关键字定义的输入输出参数,底层会改成调用地址,因此传参也需传入地址
2、方法调度
在 OC 中,底层是通过objc_msgSend函数去查找方法并调用,但 Swift是静态语言,没有运行时机制又是如何调度的呢?
2.1、汇编查看方法调度
-
首先,我们准备一段简单的代码定义3个方法,打断点真机运行查看
ARM64下的汇编 -
查看函数调用,我们需要寻找汇编指令
bl和blr图中
#0x50、#0x58、#0x60就是3个自定义方法在内存中存储时,相对Metadata的偏移量,16进制下它们 相互差8字节 ,因为它们在函数表中紧邻(即函数地址 = Metadata + 函数偏移量) -
为了证明
bl和blr下是函数调用,我们按住control键向下步进,可以发现跳入的就是我们定义的方法
常用汇编指令
- 函数的返回值是放在
x0这个寄存器中的,存放函数实例(所以可以知道上边的所有开始x0就是代码中创建的LZPerson实例p)mov: 将某一寄存器的 值复制 到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于内存地址)mov x1, x0 // 将寄存器 x0 的值复制到寄存器 x1 中add: 将某一寄存器的值和另一寄存器的值 相加 ,并将结果保存在另一寄存器中add x0, x1, x2 // 将寄存器 x1 和 x2 的值相加后保存到寄存器 x0 中sub: 将某一寄存器的值和另一寄存器的值 相减 ,并将结果保存在另一寄存器中sub x0, x1, x2 // 将寄存器 x1 和 x2 的值相减后保存到寄存器 x0 中and: 将某一寄存器的值和另一寄存器的值 按位与 ,并将结果保存到另一寄存器中,and x0, x0, #0x1 // 将寄存器 x0 的值和常量 1 按位与后保存到寄存器 x0 中orr: 将某一寄存器的值和另一寄存器的值 按位或 ,并将结果保存到另一寄存器中orr x0, x0, #0x1 // 将寄存器 x0 的值和常量 1 按位或后保存到寄存器 x0 中str: 将寄存器中的值 写入到内存中str x0, [x0, x8] // 将寄存器 x0 中的值保存到栈内存 [x0, x8] 处ldr: 将 内存中的值读取到寄存器中ldr x0, [x1, x2] // 将寄存器 x1 和寄存器 x2 的值相加作为地址,取该内存地址的值放入寄存器 x0 中cbz: 和 0 比较,如果结果为零就转移(只能跳到后面的指令)cbnz: 和非 0 比较,如果结果非零就转移(只能跳到后面的指令)cmp: 比较指令bl: (branch)跳转到某地址(无返回)blr: 跳转到某地址(有返回)ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中
2.2、SIL查看函数表
经过上边的分析,我们可以通过地址偏移成功找到方法函数,并知道函数存在 vtable 中,那么我们在 SIL 中看一下具体代码
2.2.1、生成SIL中的问题
在Swift类与结构体(上)中,介绍了生成SIL的方法,并运行在MAC环境下,这次则是在模拟器下,报出了错误
- 解决方法:将脚本按下边进行修改
swiftc -emit-silgen -Onone -target x86_64-apple-ios14.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/LZPerson/ViewController.swift > ./ViewController.sil && open ViewController.sil
2.2.2、查看SIL虚函数表vtable
- 拖动到 SIL 最后,可以找到虚函数表
vtable
2.3、源码查看方法调度
看过了 vtable ,让我们再来探索一下 vtable 在源码中存在什么地方
- 在上篇中,我们知道了 Metadata 的数据结构,
其中需要特别关注typeDescriptor,不管是 Class , Struct , Enum 都有自己的 Descriptor ,就是对类的一个详细描述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 }
2.3.1、分析Descriptor
-
分析 Descriptor 则需要在源码中找到它,思路是从
HeapObject入手HeapObject-->HeapMetadata-->TargetHeapMetadata(别名) -->TargetHeapMetadata-->Description-->TargetClassDescriptor(父类)- 文件中搜索 TargetClassDescriptor 发现它还被起了个别名
ClassDescriptor,再全局搜索 ClassDescriptor - 在
GenMeta.cpp文件中找到了ClassContextDescriptorBuilder类对 ClassDescriptor 进行使用,是类创建描述信息的地方 - 在这个类中我们在
layout()方法中找到了添加 vtable 的方法addVTable() - 看到
super::layout();可以知道其父类也有 layout 方法进行一些操作,这里不进去看了,让我们看一下关键的 addVTable 方法:
结合父类的layout内容,最后确定 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 }
2.4、Mach-O文件验证vtable
-
Mach-O: 是 mac 以及 iOS 上可执行文件的格式,常见的.o、.a、.dylib、Framework、dyld、.dsym- 首先是文件头
Header,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排 Load commands是一张包含很多内容的表,内容包括 区域的位置、符号表、动态符号表等Data区主要负责代码和数据记录的。 Mach-O 是以Segment这种结构来组织数据的,1个 Segment 可以包含0个或多个Section。根据 Segment 是映射的哪一个 Load Command ,Segment 中 section 就可以被解读为是是代码、常量或者一些其他的数据类型。在装载在内存中时,也是根据 Segment 做内存映射的
- 首先是文件头
2.4.1、MachOView查看可执行文件
-
将黑色的可执行文件拖到 MachOView 软件中
-
Section64(_TEXT,__swift5_types)中存放的就是 Descriptor 地址信息 -
每4个字节一组,前4个字节是LZPerson的 Descriptor 在 Mach-O 的这个Section中的偏移地址(小端模式:从右向左读)
pFile: 每个Section的偏移地址Descriptor 在 Mach-O中的内存地址: 0xBBCC + 0xFFFFFBAC = 0x10000B778 (pFile + DataLO前4个字节)0x10000是虚拟地址的开端,相减后得到0xB778就是 Descriptor在Mach-O 中的偏移量,在Section64(_TEXT,_const)定位如下:- B778比B770偏移8字节,所以得到从 50 00 00 00 这个地址开始,就是 Descriptor存放在Mach-O中的结构体信息
-
找到了 Descriptor,我们再找其中存的 vtable 信息,根据上边2.3.1中分析出的 TargetClassDescriptor 结构,可以知道 vtable前有 13个Int32 类型的各种信息,咱们直接略过,向下数13个4字节(Int32为4字节大小)可得 vtable 的起始地址:(后边
每个函数地址占8个字节) -
有了 vtable 在Mach-O中的偏移地址后,我们还需要拿到并加上一个
ASLR(随机偏移地址),这个地址是为了程序安全设置的,保证不是从0开始而容易被人摸清内存,而我们要拿到 ASLR 则可以通过image list指令拿到第一个加载的地址得到 -
最后一步,我在源码中找到这么一个结构体。
TargetMethodDescriptor是 Swift 方法在内存中的结构,是上边一通找后被地址指向的真正内容,而最终要找的方法存在其中,Impl不是真正的imp,而是相对指针 offset。 -
好了,准备完毕,让我们计算一下MachOView中算的地址和XCode调试拿到的是否一致:
- 0x100a70000[
ASLR基地址] + 0xB7A0[vtable所在的pFile] + 0xC[vtable在pFile行前边多出的字节数] + 0x4[Flags] + 0xFFFFC250[偏移地址offset] - 0x100000000[虚拟地址开端] = 0x100A77A00 与上边图中调试时register read x8中拿到的 run 方法地址一致,验证通过
- 0x100a70000[
-
注意:
- Flags 也就是 10 00 00 00所占用的,所以
Impl的偏移地址是0xFFFFC250 - 在 MachOView 里的一个 Section 中推导的地址是相对地址,也就是偏移量,需要通过
pFile + 前边空出的字节数 + 偏移地址的计算才能拿到在其他Section中的地址
- Flags 也就是 10 00 00 00所占用的,所以
2.4.2、将Method的Descriptor加载到Metadata
3、派发方式
-
类通过虚数表进行函数调用,而值类型的 struct 和 enum 则是通过静态派发( 编译时就确定下来 ),并不创建虚数表,汇编中不会存到寄存器中,而是直接有一个地址
-
extension 也是静态派发;因为假设extension有虚数表,有场景B继承于A,A被extension增加了方法,对B来说,这个extension的方法按道理应该放在继承的A虚数表最后、B虚数表之前的位置,这样整合起来性能消耗大,所以直接把extension设计成静态派发,独立于虚数表性能更好,但占用空间更大一些,属于用空间换取时间
-
总结:
3.1、影响函数派发方式
-
final: 添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,且对 objc 运行时不可见。- 实际开发中如果属性、方法、类不需要被重载,则可使用 final ,这样效率更高
-
dynamic: 函数均可添加 dynamic 关键字,不改变原本的派发方式。@_dynamicReplacement(for:函数名): 将该函数替换成后续的函数
-
@objc: 该关键字可以将Swift函数暴露给Objc运行时。 -
@objc + dynamic: 消息派发的方式,更常用,使Swift能使用OC的runtime的API
4、函数内联
函数内联 是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
-
将确保有时内联函数。这是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内联函数作为优化。
-
always:将确保始终内联函数。通过在函数前添加@inline(__always)来实现此行为 -
never: 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)来实现。 -
如果函数很长并且想避免增加代码段大小,请使用 @inline(never)
-
如果对象只在声明的文件中可见,可以用
private或fileprivate进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private: 定义的声明中访问)