一、异变方法mutating
Swift相对OC有一个巨大的改变,那就是结构体(struct
)和枚举(enum
)里面可以定义方法。但是有一点要注意,由于结构体和枚举属于值类型,因此在默认情况下,结构体和枚举里面的属性不能被自身的实例方法所修改。
如上代码所示,当在结构体中定义一个修改属性的实例方法时,系统编译器就会提示自身
self
无法进行修改。
Swift提供了mutating
关键字来解决这个问题,在func
前面加上mutating
修饰,就可以把这个方法变成异变方法,然后就可以对属性值进行修改。这里面的原理是什么,我们通过代码分析来探寻原理。
1.SIL文件分析
我们先通过生成SIL文件,然后查看一下这两个函数之间有什么不同
moveBy1函数
moveBy函数
通过查看这两个函数的中间语言,我们可以发现方法里面默认带了Point
参数,也可以说是self
而这里也与OC有所不同,在OC中,如果方法里面访问或者修改属性值,需要在属性名前带上self
,而在Swift里面可以不用带self
我们比较一下这两个函数的参数,发现moveBy方法在添加了mutating
关键字后,Point后面加了inout
的关键字。
//moveBy1
debug_value %0 : $Point, let, name "self", argno 1 // id: %1
//moveBy
debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5
同时,我们对上面两个声明语言进行伪代码转换,可以得到下面两个伪代码
//moveBy1
let self = Point
//moveBy
var self = &Point
我们可以看到,值类型的属性都是存在实例本身中,因此在方法内部修改属性值相当于在修改实例本身。在moveBy1
函数里面,传入self
参数的是值本身,而且是用let
声明成一个常量,因此无法对方法属性进行修改。而使用mutating
修饰的moveBy
函数里面,self
传入的是实例对象的内存地址,而且是用var
声明成一个变量,因此我们可以对方法里面的属性进行修改。
2.inout关键字
在Swift中,函数参数默认是常量。试图在函数体中更改参数值将会导致编译错误。这意味着你不能错误地更改参数值。如果你想要一个函数可以修改参数的值,并且想要在这些修改在函数调用结束后仍然存在,那么就应该把这个参数定义为输入输出参数(In-Out Parameters) 。
定义一个输入输出参数时,在参数定义前加 inout
关键字。一个 输入输出参数
有传入函数的值,这个值被函数修改,然后被传出函数,替换原来的值。
你只能传递变量给输入输出参数。你不能传入常量或者字面量,因为这些量是不能被修改的。当传入的参数作为输入输出参数时,需要在参数名前加 &
符,表示这个值可以被函数修改。
二、方法调度
1、常用的一些汇编指令
分析Swift方法调度需要看汇编代码,我这里先准备一些常用到的汇编指令,有助于我们更好地理解。
- 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: 比较指令
- br: (branch)跳转到某地址(无返回)
- blr: 跳转到某地址(有返回)
- ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中
2.类的实例方法
首先,我们先定义一个类和类的实例方法。
class LGStudent {
func study() {
print("study")
}
}
class ViewController: UIViewController{
override func viewDidLoad() {
let t = LGStudent()
t.study()
}
}
然后,通过真机调试,拿到这个类的arm64的汇编源码
经过分析,我们可以看到LGStudent
类创建的时候会调用到__allocating_init
方法,以及释放LGStudent
类的swift_release
方法。所以我们可以推测study
方法应该在这两个方法中间。
通过上面的汇编指令,我们可以猜测,方法调用可能通过blr指令来调用,我们可以看到,在__allocating_init
方法和swift_release
方法之间,刚好有一个blr汇编指令,我们进入到这个指令的函数,结果证实是study()
方法。
- swift函数的调用
在OC中,函数的调用通过
objc_mgsend
来实现,那么在swift中函数是如何调用的呢?
我们通过对上面的汇编源码分析,发现blr是对
x8
寄存器里面的地址进行调用,而x8
先是读取x20
的地址,然后再加上0x50
偏移量再存入到x8
寄存器。往上我们可以看到x20
寄存器的地址是由x0
寄存器赋值的。我们可以看出x8
最终是通过x0
存入x20
,然后存入x8
,之后再通过x8
加上一个0x50
的偏移量获取的。
由于
x0
寄存器一般是用来存放函数的返回值,我们可以知道x0
寄存器里面存放的是LGStudent
的实例对象。通过register read
方法读取x0寄存器的值,我们得到了LGStudent
实例的metadata
,所以我们可以得出一个结论:
类的实例方法是先找到实例的metadata地址,然后通过一个偏移量找到方法地址进行调用
- swift函数的存储结构 由于类的函数地址是通过metadata地址加上偏移量得到的,由此可以猜测函数是否会存放到一个函数表里面。我们可以通过新增多个函数方法,然后对汇编源码进行分分析。
汇编源码情况如下
图里面三个方框标识出来的blr方法,就是三个实例方法的调用,我们可以看到,这三个方法的偏移量分别是
0x50
、0x58
、0x60
,三个连续的内存空间,各自相差8个字节,因此我们可以证实这个猜测,函数是存放到一个函数表里面的,函数的调用是基于函数表的调用。
另一个方面,我们通过sil文件分析,也可以发现函数储存在一个函数表内。
源码分析函数表
我们知道,实例函数是通过Metadata加上偏移量获取v_table,那么v_table可能放在Metadata的数据结构中。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
}
这里我们有一个东西需要关注typeDescriptor
,不管是Class
,Struct
,Enum
都有自己的Descriptor,就是对类的一个详细描述。
在TargetClassMetadata
中有一个TargetClassDescriptor
类的私有属性Description
我们可以通过 TargetClassDescriptor
结构的源码得知其继承关系 TargetClassDescriptor
:TargetTypeContextDescriptor
:TargetContextDescriptor
根据继承关系可以获取 Descriptor
的大致结构如下:
class 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
在Descriptor
的结构中,我们没有找到v_Table的结构,所以我们接着寻找,在Metadata.h
文件中全局搜索TargetClassDescriptor
后,发现它有一个别名ClassDescriptor
我们全局文件查找
ClassDescriptor
,然后发现跟Metadata相关文件里面还有一个GenMata.cpp
文件使用到,进入这个文件,我们可以看到有一个ClassContextDescriptorBuilder
的类,这个就是创建Metadata
和Descriptor
的类。
在
ClassContextDescriptorBuilder
类中,我们找到了一个layout()
的方法
查看
super::layout()
,可以看到它的父类方法实现
可以看到,这个layout()
方法就是在创建TargetClassDescriptor
类,当调用完super::layout()
后,就开始进行addVTable()
方法,而这个就是添加函数表的方法。我们看下这个方法的具体实现
在代码中,变量offset
就是计算出偏移量,然后把这个偏移量加入到变量B
中,把VTableEntries
的size
也加入到变量B
中。最后去遍历这个VTableEntries
,添加VTableEntries
的指针,也就是当前函数的指针。
我们再去查找这个变量B,它定义在这个
CotextDescriptorBuildBase
类中,而这个类是TargetClassDescriptor
的最终父类,因此我们可以确定,这个变量B
应该是TargetClassDescriptor
类。
在
layout()
方法中,我们还看到了addOverrideTable
方法,这个是把父类中可以继承的实例方法加入到子类的函数表中。
类的实例方法的数据结构
在Swift源码Metadata.h
的文件中,我们找到了TargetMethodDescriptor
结构体,这个就是Swift的实例方法的内存结构。
- Flags 用来标识方法类型,比如setter方法、getter方法、init方法等
- Impl 用来指向相对位置的指针。
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。
Mach-o文件格式:
- 首先是文件头,表明该文件是Mach-O格式,指定目标架构,还有一些其他的文件属性信息文件头信息影响后续的文件结构安排
- Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。 Load commands相关命令如下
LC_SEGMENT_64 //将文件中(32位或64位)的段映射到进程地 址空间中
LC_DYLD_INFO_ONLY //动态链接相关信息
LC_SYMTAB //符号地址
LC_DYSYMTAB //动态符号表地址
LC_LOAD_DYLINKER //dyld加载
LC_UUID //文件的UUID
LC_VERSION_MIN_MACOSX //支持最低的操作系统版本
LC_SOURCE_VERSION //源代码版本
LC_MAIN //设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB //依赖库的路径,包含三方库
LC_FUNCTION_STARTS //函数起始地址表
LC_CODE_SIGNATURE //代码签名
- Data区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment是映射的哪一个Load Command,Segment中section就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据Segment做内存映射的。
读取Mach-o文件
首先,在Xcode->Product->Show Build Folder in Finder,获取这个工程的Build文件夹,然后找到.app文件,显示包内容,可以看到有一个以工程名命名的可执行文件,把这个可执行文件拖入到MachoView程序中,就可以读取到Mach-o文件。
打开Mach-o文件后,我们先去寻找__swift5_types的文件,这里存放的是Swift 结构体、枚举、类的 Descriptor
,那么我们可以在这里找到类的 Descriptor
的地址信息
因为我们的代码里面只有一个
LGStudent
类,所以这里的前四个字节就是我们的LGStudent
的Descriptor
信息。
那么用前面的
BC58
+ F4 FB FF FF
就是Descriptor
在当前 Mach-O 文件的内存地址。
//由于iOS属于小端模式,所以 前四个字节是 FFFFFBF4
0xFFFFFBF4 + 0x0000BC58 = 0x10000B84C
在每个Mach-O 文件中有虚拟内存的基地址,当前得到的地址还需要减去这个虚拟内存的基地址才是LGStudent
的 Descriptor
在Data区的首地址0xB698
0x10000B84C - 0x10000000000 = 0xB84C
通过这个地址 我们找到了 Section64(_TEXT,__const)
这个区
这里也就是
LGStudent
的 Descriptor
类的内存首地址。从上面的Descriptor
类的内存结构我们可以知道,vtable
的位置是在Descriptor
类里面,Descriptor
中有13个UInt32,也就是需要后移 13个四字节,所以我们的study
方法就应该在红框位置往后数13个四字节,且方法的指针应该是8个字节,所以后面红框标记的的8个字节,B880
就是study
方法 Mach-O 文件中的偏移量。
在上面的源码分析中我们知道了方法在swift底层中的结构是TargetMethodDescriptor
,所以这里存储的值前四个字节0x1
就是flag
的地址,后面四个存储的 FFFFB9D4
就是impl
的地址。
在iOS中每个应用程序都有一个ASLR(随机偏移地址)
,study
的真实调用地址应该是study
方法在 Mach-O 文件中的偏移量加上ASLR
,然后偏移TargetMethodDescriptor
中flag
的4个字节,加上impl
中的偏移量,最后再减去 Mach-O 的虚拟基地址。
我们可以在真机调试工程的时候,在lldb打上image list 命令,来获取这个ASLR
。通过image list命令得到ASLR程序运行的基地址 0x0000000100194000
//应用程序的基地址:0x0000000100194000,study函数结构地址:B880,Flags:0x4,offset:FFFFB9D4
0x0000000100194000 + B880 + 4 + FFFFB9D4 = 0x20019B258
//减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址
0x20019B258 - 0x100000000 = 0x10019B258
前面我们通过汇编分析知道实例方法如何被调用的,我们只需要在汇编代码中找到 调用该方法的位置查看寄存器的内容是否和计算结果相同,就可以验证Mach-O分析是否正确
由此,我们Mach-o文件分析函数地址无误。
结构体的函数调用
我们把类改成结构体,然后查看结构体的函数如何调用。
真机调试后,查看它的汇编代码:
我们可以看到,在Swift中,调用一个结构体的方法是直接拿到函数的地址直接调用,包括初始化方法,Swift 是一门静态语言,许多东西在运行的时候就可以确定了,所以才可以直接拿到函数的地址进行调用,这个调用的形式也可以称作
静态派发
。
类和结构体的extension扩展
我们对类和结构体使用extension
关键字扩展,去了解一下在extension
里面的函数如何调用。
然后查看汇编源码
我们可以看到,无论是
class
或者是struct
在extension 中的的方法都是通过静态调用的方式。
继承自NSObject的子类
我们可以看到,继承自NSObject的子类和Swift的类一样,都是通过函数表派发,扩展方法也是直接调用,静态派发。
方法调度方式总结
关键字影响函数的派发方式
final
关键字
通过对sil文件进行分析,我们发现在这两个类的sil_vtable中,都没有找到
final
关键字修饰的study3方法。
通过对汇编源码的分析,我们发现直接调用了study3的函数地址。
因此,我们可以得出结论:添加了
final
关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可见。
-
dynamic
关键字 函数均可添加dynamic
关键字,为非objc类和值类型的函数赋予动态性,可以进行一些方法替换,如果是非objc类,派发方式还是函数表派发。如果是值类型,派发方式是静态调用。 -
@objc
关键字@objc
关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
-
@objc + dynamic
我们可以看到,使用@objc + dynamic 修饰,函数调用方式就变成了消息派发方式,我们就可以使用run-time里面的API对这个方法进行交换等操作。
-
static关键字
static
方法不会存在vTable中,也是通过静态派发
函数内联
函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
- Swift 中的内联函数是默认行为,我们无需执行任何操作. Swift编译器可能会自动内联函数作为优化
- always - 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
- never - 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
- 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))
private 的优化操作
如果对象只在声明的文件中可⻅,可以用private或fileprivate进行修饰。编译器会对private或fileprivate对象进行检查,确保没有其他继承关系的情形下,自动打上final标记,进而使得对象获得静态派发的特性(fileprivate:只允许在定义的源文件中访问,private:定义的声明中访问)