1、异变方法
Swift中 Class(引用类型)和 Struct(值类型)都可以定义方法,但是有一点区别的是默认情况下,值类型属性不能被自身的实例方法修改。
如上图所示,提示我们Self是不能被修改,修改Self就相当于修改结构体本身,所以值类型属性不能被自身的实例方法修改。
那么我们要如何在我们自身的实例方法中修改我们自身的属性呢?
解决办法: 在实例方法的前面用mutating关键字进行修饰。
1.1、SIL分析Mutating
接下来,我们创建两个实例方法,通过SIL来对比一下,不添加mutating关键字的方法与添加了mutating关键字的方法,两者存在哪些本质的区别?
SIL文件的创建以及解析SIL的一些关键注意事项,请查看Swift类与结构体(上)
SIL分析两个方法
不添加mutating关键字的test方法:
添加mutating关键字的moveBy方法:
通过上面两张图,可以发现,添加了mutating关键字的实例方法,参数里面的隐藏参数Point的类型前面增加了一个@inout,而且里面的self是$*Point,也就是一个地址。没有添加mutating关键字的实例方法,里面的self是$Point,也就是一个Point类型的实例对象。所以我们可以通过伪代码的形式理解为:
//没有添加mutating关键字的方法
let self = Point
//添加了mutating关键字的方法
var self = &Point
综上所述,添加了mutating关键字的方法,方法的隐藏参数self会被标记为inout参数,这样self中存储的就是一个实例对象的指针地址,实例对象的指针地址不会发生改变,这样我们可以在自身的实例方法中修改自身的属性了。而没有添加mutating关键字的方法,方法的隐藏参数self存储的就是一个实例对象,我们修改这个实例对象的属性,也就修改这个实例对象,这样就会造成报错;
关于SIL文档中的@inout,官方的解释为:An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)
输入输出参数(inout参数):如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数,也就是inout参数。在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数。
2、方法调度
2.1、常用的汇编指令
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: 比较指令b: (branch)跳转到某地址(无返回)bl: 跳转到某地址(有返回)ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中
2.2、汇编分析Swift方法调度
在OC中我们是通过runtime的objc_msgSend()消息机制来对方法进行调度的。接下来我们来分析一下Swift中的方法是如何调度的?
Swift类与结构体(上) 中我们讲到,在创建实例对象的时候,会调用到__allocating_init 方法,同时在销毁实例对象的时候,也会调用到release方法,所以实例对象t的teach方法,就会在__allocating_init 方法与release方法之间调用。
如上图所示,我们会发现,在
__allocating_init 方法与release方法之间有一条blr指令,我们进入到到这个函数方法中,就会发现,这个函数方法正是teach方法。
我们来逐条分析一下__allocating_init 方法与release方法之间的汇编指令。
mov x20, x0也就是将x0复制到x20中,所以x20里面存储的也是实例对象的metadata;
通过读取register read x0,我们可以发现,x0中存储的是实例对象的metadata
- 两条
str指令,就是将实例对象x20保存到内存中; - 接下来的第一个
ldr x8,[x20]指令,取实例对象x20的值放入x8中; - 第二个
ldr x8,[x8, #0x50]指令,将x8的值加上#0x50的偏移量得到的值,最后存入x8中; - 最后是
bl x8指令,跳转到x8的地址,也就是调用teach方法。
根据上面流程,我们可以总结出,Swift函数的调度过程就是:通过metadata,然后在加上一个偏移量获取到函数地址(metadata + 偏移量),进行调用。
接下来我们在分析一下方法的存储结构:
如上图的汇编分析,三条
blr指令,分别对应teach()、teach1()、teach2()三个方法。通过三条blr指令上面的ldr指令,我们可以得出,经过三次 0x50,0x58,0x60偏移量,最后得到三个函数方法地址。而且这三个偏移量之间都是依次相差8个字节,所以我们可以间接的猜测,实例方法都是存储在一个函数表的结构中。
2.3、通过SIL分析,验证实例方法的存储结构(虚函数表)
由于是需要将 UIKit 相关内容编译成 sil文件, 所以需要用下面的命令行:
swiftc -emit-sil -target x86_64-apple-ios15.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/ZLSwift/ViewController.swift > ViewController.sil
我们打开SIL文件,然后拉到文件最后的部分,会发现sil_vtable的函数表,里面存储了ZLTeacher类的实例方法。通过这个文件就验证了,类的实例方法的存储结构就是一个虚函数表。
2.4、源码分析vtable虚函数表
Swift类与结构体(上) 中我们了解到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
}
在metadata类里面有一个属性typeDescriptor,不管是class,struct,enum都有自己的Descriptor,它就是对类型的一个详细描述。
我们打开源码,在metadata.h中,我们找到TargetClassDescriptor类型的属性Description
根据上图可以发现,ClassDescriptor是TargetClassDescriptor的别名,然后全局搜索ClassDescriptor,找到GenMeta.cpp文件,然后在这个文件中,找到一个 ClassContextDescriptorBuilder 类的结构体,这个类就是创建 metadata 和 Descriptor 的类。
然后在这个类中,找到layout方法:
然后进入这个super.layout()方法中:
这里我们在进入上面的addVTable()方法中:
这个 B 就是
ClassContextDescriptorBuilder 的类型本身,上面红框部分就是获取到偏移量,for循环添加函数指针。
在layout()中还有addOverrideTable 这里就是把父类的方法加入到了子类的vtable中
综上所述,我们就可以还原出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.5、Mach-O分析实例方法的存储结构
Macho:Mach-O 其实是Mach Object文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows 上的 PE 格式 (Portable Executable ), linux 上的 elf 格式 (Executable and Linking Format) 。常⻅的 .o,.a .dylib Framework,dyld .dsym。
Mach-O的文件格式:
- 首先是文件头,表明该文件是Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排;
- Load commands是一张包含很多内容的表,内容包含区域的位置、符号表、动态符号表等。
- Data 区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据 的,一个 Segment 可以包含 0 个或多个 Section。根据 Segment 是映射的哪一个 Load Command,Segment 中 section 就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据 Segment 做内存映射的。
Mach-O字段说明
2.5.1、Mach-O 文件查找方法内存结构地址
首先在Xcode的Produts文件夹下面找到ZLSwift.app文件
然后显示ZLSwift.app文件的包内容
最后将查找到的ZLSwift文件,用MachOView工具打开
Mach_O的文件格式:
Section64(_TEXT,__swift5_types) 这里存放的是Swift 类、结构体、枚举的 Descriptor,那么我们可以在这里找到类的 Descriptor 的地址信息。
当前的代码中只有一个 ZLTeacher 类,所以 这里的前四个字节就是我们的 ZLTeacher 的 Descriptor信息。那么用 前面的 BC58 + 14 FC FF FF 就是Descriptor 在当前 Mach-O 文件的内存地址。
0xFFFFFC14 + 0x0000BC58 = 0x10000B86C
在每个Mach-O文件中有虚拟内存的基地址,当前得到的地址
0x10000B86C还需要减去这个基地址0x100000000才是ZLTeacher的 Descriptor在Data区的首地址 0xB86C。
我们找到Section64(__TEXT,__const)文件,我们可以发现第一行的末尾,就是我们要找的0xB86C地址。也是意味着,它后面的数据是 TargetClassDescriptor 的数据,所以我们可以在这里拿到 ZLTeacher 的虚函数表。
上面的源码分析得到了 Descriptor 的结构,我们知道了,vtable 的位置在 Descriptor结构的底部,Descriptor 中有 13 个 UInt32,也就是需要从首地址0xB86C后移13个四字节,所以我们的 vtable位置就应该在红框位置往后数 13个四字节,因为方法的指针应该是8个字节,所以后面的三个红框标记的部分,就是 teach、teach1、teach2方法在Mach-O文件中的偏移量。
2.5.2、验证Mach-O文件查找的方法内存结构地址
ASLR是一个随机偏移地址,这个随机偏移地址的目的是为了给应用程序一个随机内存地址,在iOS中每个应用程序都有一个ASLR(随机偏移地址)。我们打一个断点,程序运行起来后,输入lldb命令: image list。如图所示:
image list 是列出应用程序运行的模块,我们找到第一个,其内存地址为 0x00000001020c8000,那我们可以把这个地址当作应用程序的基地址。
接下来我在源码中找到这么一个结构体。TargetMethodDescriptor 是 Swift 的方法在内存中的结构,Impl 不是真正的 imp,而是相对指针 offset。
TargetMethodDescriptor 结构体的地址确定了,那么要找到函数地址,还需要偏移 Flags + Impl,得到的就是函数的地址。 综合以上的逻辑开始计算:
//应用程序的基地址:0x00000001020c8000,teach函数结构地址:B8A0,Flags:0x4,offset:88 B7 FF FF
0x00000001020c8000 + 0xB8A0 + 0x4 + 0xFFFFB788 = 0x2020CF02C
//减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址
0x2020CF02C - 0x100000000 = 0x1020CF02C
0x1020CF02C 就是 teach()方法的函数地址。这样我们就验证了Swift类的方法确实是存放在 VTable虚函数表里面的。
2.6、结构体的方法调度方式
将类class改为结构体struct
然后进入汇编调试
如上图可以发现,在 Swift 中,调用一个结构体的方法是拿到函数直接调用,包括初始化方法,Swift 是一门静态语言,许多东西在运行的时候就可以确定了,所以才可以直接拿到函数进行调用,这个调用的形式也可以称作静态派发。
2.7、extension的方法调度方式
创建一个结构体ZLTeacher以及它的extension扩展,再创建一个类ZLPerson以及它的extension扩展,然后分别创建他们的实例对象,并调用方法。
然后进入汇编调试:
如上图可以发现,无论是class或者是struct 在extension中的的方法都是通过静态派发的调度方式。
2.8、继承NSobject的子类中方法的调度方式
创建一个继承NSobject的子类ZLPerson
然后进入汇编调试:
然后进入SIL文件:
通过汇编调试以及sil文件,可以发现,在Swift中继承NSobject的子类中方法的调度方式仍然是函数表派发,而extension中的方法的调度方式也仍然是静态派发。
综上所述,方法调度方式的总结为
3、影响函数派发方式的关键字
进入SIL文件:
进入汇编调试:
3.1、final
添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,切对objc运行时不可见。
3.2、static
添加了static 关键字的方法不会存在vTable中,也是通过静态派发。
3.3、dynamic
函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。
3.4、@objc
@objc关键字可以将swift函数暴露给Objc运行时,与OC进行交互,方法会存在于 vtable 函数表中,是函数表派发。
3.5、@objc dynamic
@objc dynamic关键字修饰的方法是消息派发的方式,也就是OC的objc_msgSend消息派发方式。
4、内联函数
内联函数是一种编译器优化技术,inline关键字修饰的函数,它通过使用方法的内容替换直接调用该方法,从而优化性能。
4.1、Swift内联函数
- 将确保有时内联函数,这是默认行为。我们无需执行任何操作、Swift编译器可能会自动内联函数作为优化。
always- 将确保始终内联函数。通过在函数前添加@inline(_always)来实现此行为。never- 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)来实现。- 如果函数很长并且想避免增加代码段大小,请使用
@inline(never)。
在iOS的一些框架中,static inline是经常出现的关键字组合,主要是为了提高函数调用的效率。
4.1.1、inline内联函数的优点
优点相比于函数:
- inline函数避免了普通函数的在汇编时必须调用call的缺点,取消了函数的参数压栈,减少了调用的开销,提高效率,所以执行速度比一般函数要执行的快。
- 集成了宏的优点,使用时直接用代码替换。 优点相比于宏:
- 避免了宏的缺点:需要预编译。因为内联函数inline也是函数,不需要预编译。
- 编译器在调用一个内联函数时,会首先检查它的参数类型,保证调用正确。然后进行一系列的相关检查,就像对待任何一个真正的函数一样。这样就消除了它的隐患和局限性。
- 可以使用所在类的保护成员及私有成员。
4.1.2、内联函数的说明
- 内联函数只是我们向编译器提供的申请,编译器不一定采取
inline形式调用函数。 - 内联函数不宜承载大量的代码,如果内联函数的函数体过大,编译器就回自动放弃内联。
- 如果函数体出现循环,那么执行函数体内的代码的时间要比函数调用的开销大。
- 内联函数的定义需在调用前。
4.2、private 的优化操作
如果对象只在声明的文件中可⻅,可以用 private 或 fileprivate 进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private : 定义的声明中访问)