异变方法
在上篇文章 Swift 中类与结构体(一)中我们了解到 Swift 中 Class 与 Struct 中都能定义方法,但是有一点区别的是默认情况下,值类型属性不能被自身的实例方法修改。

如上代码所示,会提示 self 不可被修改,如果我们想在结构体自己的方法中修改自己的值的话就需要加关键字 mutating。
struct Point {
var x = 0.0, y = 0.0
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
// 这里就相当于修改 self.x 跟 self.y 的值,这里 self 是值类型,相当于在自己的方法里面修改自己本身
x += deltaX
y += deltaY
}
}
如上代码,当加了 mutating 关键字之后就可以在方法中修改 self 的值了。那么具体原因我们通过以下代码转成 SIL 文件来分析一下。
-
swift代码
struct Point {
var x = 0.0, y = 0.0
func textFunc() {
let temp = self.x;
}
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
// 这里就相当于修改 self.x 跟 self.y 的值,这里 self 是值类型,相当于在自己的方法里面修改自己本身
x += deltaX
y += deltaY
}
}
-
SIL代码
// Point.textFunc()
// 这里 textFunc 函数需要一个参数 Point,也就是 self,swift 函数中有一个默认参数 self
sil hidden @$s4main5PointV8textFuncyyF : $@convention(method) (Point) -> () {
// %0 "self" // users: %2, %1
bb0(%0 : $Point):
debug_value %0 : $Point, let, name "self", argno 1 // id: %1
%2 = struct_extract %0 : $Point, #Point.x // user: %3
debug_value %2 : $Double, let, name "temp" // id: %3
%4 = tuple () // user: %5
return %4 : $() // id: %5
}
// Point.moveBy(x:y:)
// 这里默认参数 Point 前多了 @inout 关键字
sil hidden @$s4main5PointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, @inout Point) -> () {
// %0 "deltaX" // users: %10, %3
// %1 "deltaY" // users: %20, %4
// %2 "self" // users: %16, %6, %5
bb0(%0 : $Double, %1 : $Double, %2 : $*Point):
debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
}
在 SIL 文件中我们找到 textFunc 函数与 moveBy 函数的实现,可以发现在 Swift 代码中函数前用 mutating 修饰的话,在 SIL 中的函数默认参数前会多了 @inout 关键字。以下是官方文档对 @inout 关键字的解释。
-
SIL文档的解释:
An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)。
异变方法的本质:对于变异方法, 传入的self被标记为inout参数。无论在mutating方法内部发生什么,都会影响外部依赖类型的一切。
用伪代码来表示的话就是在 textFunc 函数中是 let self = Point,用的是 let 关键字修饰的,表示不可修改。在 moveBy 函数中的表示是 var self = &Point,用的是 var 关键字修饰,且取得是 Point 的地址。
-
inout关键字介绍:
这里inout关键字就是输入输出参数,如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数 。在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数,示例代码如下。
如以上代码编译的时候会报错,当我们用 inout 关键修饰后就可以在函数内修改 age 的值,不过这里需要注意的是调用 modifyage 函数的时候传参要传 &age。

运行输出之后可以看到 age 的值确实被修改了,函数中的 age 跟外部 age 指向的其实是同一块内存地址。
方法调度
在 OC 中我们是通过 obj_msgsend 这种消息机制来进行方法的调度的,那么在 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: 比较指令 -
bl: (branch)跳转到某地址(无返回) -
blr: 跳转到某地址(有返回) -
ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器lr (x30)中
汇编调试
class CXPerson {
func eat() {
print("eat")
}
func eat1() {
print("eat1")
}
func eat2() {
print("eat2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
p.eat1()
p.eat2()
}
}

以上代码我们在 CXPerson 类中定义了 eat 、eat1 、eat2 三个方法,并在三个方法调用的地方打上断点并开启汇编调试,结合上面的汇编指令可以看到在汇编代码中 33行、37行、41行分别对应的就是 eat 、eat1 、eat2 这三个方法的调用指令,以下是汇编代码的注释。
- 汇编代码的注释:
// 这里返回的就是 CXPerson 对象实例,且返回结果保存在 x0 寄存器中
0x100ef2764 <+100>: bl 0x100ef26b0 ; swiftDemo.CXPerson.__allocating_init() -> swiftDemo.CXPerson at ViewController.swift:10
// 将寄存器 x0 的值复制到寄存器 x20
0x100ef2768 <+104>: mov x20, x0
// 将寄存器 x20 中的值保存到栈内存 [sp, #0x18] 处
0x100ef276c <+108>: str x20, [sp, #0x18]
// 将寄存器 x20 中的值保存到栈内存 [sp, #0x20] 处
0x100ef2770 <+112>: str x20, [sp, #0x20]
// 取 x20 实例对象的地址的值放入寄存器 x8 中,地址就是实例对象的前 8 个字节,x20 第一个8字节就是 metedata
0x100ef2774 <+116>: ldr x8, [x20]
// 将寄存器 x8(也就是 metedata 的值) 值加上 0x50 并放入寄存器 x8 中
0x100ef2778 <+120>: ldr x8, [x8, #0x50]
// 此时 x8 就是 eat 函数的地址,这里跳转到函数地址,也就是执行 eat 函数
-> 0x100ef277c <+124>: blr x8
0x100ef2780 <+128>: ldr x20, [sp, #0x18]
0x100ef2784 <+132>: ldr x8, [x20]
0x100ef2788 <+136>: ldr x8, [x8, #0x58]
// 此处为 eat1 函数执行,函数地址为 x8 + 0x58
0x100ef278c <+140>: blr x8
0x100ef2790 <+144>: ldr x20, [sp, #0x18]
0x100ef2794 <+148>: ldr x8, [x20]
// 此处为 eat2 函数执行,函数地址为 x8 + 0x60
0x100ef2798 <+152>: ldr x8, [x8, #0x60]
0x100ef279c <+156>: blr x8
eat 函数的调用过程: 找到 Metadata 基于函数表的调度
,确定函数地址(metadata + 偏移量), 执行函数
通过以上汇编分析可以看到
eat、eat1、eat2三个函数都是相差 8 个字节,也就是函数地址的大小,且是连续的内存空间,有点类似数组,所以可以猜测swift中的方法调度是基于函数表的调度。例如eat函数的调用过程就是找到Metadata,基于函数表的调度,确定函数地址(metadata+ 偏移量), 然后执行函数。下面我们再来通过SIL文件来验证一下。
通过 SIL 验证函数表
sil_vtable CXPerson {
#CXPerson.eat: (CXPerson) -> () -> () : @$s4main8CXPersonC3eatyyF // CXPerson.eat()
#CXPerson.eat1: (CXPerson) -> () -> () : @$s4main8CXPersonC4eat1yyF // CXPerson.eat1()
#CXPerson.eat2: (CXPerson) -> () -> () : @$s4main8CXPersonC4eat2yyF // CXPerson.eat2()
#CXPerson.init!allocator: (CXPerson.Type) -> () -> CXPerson : @$s4main8CXPersonCACycfC // CXPerson.__allocating_init()
#CXPerson.deinit!deallocator: @$s4main8CXPersonCfD // CXPerson.__deallocating_deinit
}
将 swift 代码生成 SIL 文件之后,可以看到 sil_vtable,里面包含了 CXPerson 类的所有函数,所以函数表在 SIL 文件中的表现形式就是 sil_vtable,sil_vtable 是每个类自己的函数表。
通过源码分析函数表
以上我们通过汇编跟 SIL 文件证明了函数表的调度形式,但是源码更有说服力,下面我们通过源码来验证一下。
在上篇文章 Swift 中类与结构体(一)中我们讲到了 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 都有自己的 Descriptor,就是对类或者结构体等类型的一个详细描述。下面我们打开源码来分析一下。
- 打开源码之后在
Metadata.h文件中找到TargetClassMetadata类,在TargetClassMetadata类中找到描述属性Description。
TargetSignedPointer<Runtime, const TargetClassDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;
- 这里可以看到
Description是TargetClassDescriptor类型,所以我们进入到TargetClassDescriptor类中,然后通过继承关系我们可以还原出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
}
- 以上要注意的是我们在
TargetClassDescriptor类中可能看不到v-table,这里我们在当前文件中搜索TargetClassDescriptor,可以看到它有一个别名ClassDescriptor。
using ClassDescriptor = TargetClassDescriptor<InProcess>;
- 然后在全局文件中搜索
ClassDescriptor,在GenMeta.cpp文件中找到ClassContextDescriptorBuilder类,该类是负责metadata与ClassDescriptor的构建。

- 在
ClassContextDescriptorBuilder类中我们找到layout方法,该方法负责布局
void layout() {
assert(!getType()->isForeignReferenceType());
super::layout();
addVTable();
addOverrideTable();
addObjCResilientClassStubInfo();
maybeAddCanonicalMetadataPrespecializations();
}
- 在
layout方法中会先调用super.layout,所以这里我们先到父类中查看,父类是TypeContextDescriptorBuilderBase。
void layout() {
asImpl().computeIdentity();
super::layout();
asImpl().addName();
asImpl().addAccessFunction();
asImpl().addReflectionFieldDescriptor();
asImpl().addLayoutInfo();
asImpl().addGenericSignature();
asImpl().maybeAddResilientSuperclass();
asImpl().maybeAddMetadataInitialization();
}
父类的 layout 方法中我们可以看到 addName 、addAccessFunction 等方法调用跟我们上面分析的 TargetClassDescriptor 类的结构中的属性存在对应关系,这里其实就是在创建 Descriptor,layout 中是做一些赋值的操作。
- 在上面
ClassContextDescriptorBuilder类的layout方法中我们可以看到addVTable函数的调用,所以我们进到addVTable函数的实现中。
void addVTable() {
// 计算偏移量
auto offset = MetadataLayout->hasResilientSuperclass()
? MetadataLayout->getRelativeVTableOffset()
: MetadataLayout->getStaticVTableOffset();
// 将偏移量添加到 B 中,这里 B 是一个结构体 ,就是 Descriptor
B.addInt32(offset / IGM.getPointerSize());
B.addInt32(VTableEntries.size());
// 遍历数组,添加函数的指针
for (auto fn : VTableEntries)
emitMethodDescriptor(fn);
}
所以向 B 中添加就是向 Descriptor 结构体中添加 method,在 Descriptor 中的最后一个属性是 v-table,下面我们通过 Mach-O 文件来验证一下。
通过 Mach-O 文件验证源码分析结果
Mach-O 介绍
Mach-O 其实是 Mach Object 文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows 上的 PE 格式 (Portable Executable ), linux 上的 elf 格式 (Executable and Linking Format) 。常⻅的 .o、.a、.dylib 、Framework、dyld 、.dsym。

- 首先是文件头,表明该文件是
Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排。 -
Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。

-
Data区主要就是负责代码和数据记录的。Mach-O是以Segment这种结构来组织数据 的,一个Segment可以包含 0 个或多个Section。根据Segment是映射的哪一个Load Command,Segment中section就可以被解读为是是代码,常量或者一些其他的数据类型。在装载在内存中时,也是根据Segment做内存映射的。
可执行文件分析
class CXPerson {
func eat() {
print("eat")
}
func eat1() {
print("eat1")
}
func eat2() {
print("eat2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
p.eat1()
p.eat2()
}
}
以上代码生成可执行文件之后我们借助 MachOView 工具来查看一下。

通过 MachOView 工具打开代码运行后的可执行文件如上所示,其中 swift5_types 中存储的是 Class、Struct、Enum 等数据类型的 Descriptor 信息,且是以 4 字节为单位,这里 0XFFFFFBA8 + BBCC = 0X10000B774 就是 CXPerson 类的 Descriptor 在 Mach-O 中的地址。

在 PAGEZERO 中可以看到虚拟内存的基地址是从 0000000100000000,开始, 这里 0X10000B774 - 0000000100000000 = B774 就是 Descriptor 在 Data 区的地址。

如上图所示,红色圈选的部分就是 CXPerson 类的 Descriptor 的首地址,而圈选部分后面的数据应该是跟 TargetClassDescriptor 中的属性一一对应的,前面我们推断 v-table 应该在所有的属性后面,所以 Descriptor 的首地址加上所有属性占用的内存大小,就是 v-table 的内容位置,在上图中我们也标记出了 size 属性的位置,在 size 之后就分别是 eat 、eat1 、eat2 三个函数的内容。而 B7A8 就是 eat 函数在 MachO 中的偏移量。那么我们如果要找到 eat 函数在运行程序内存中的真实地址的话就需要用 B7A8 加上程序的随机偏移地址(ASLR)。

我们通过 LLDB 命令可以看到,0x00000001000ac000 就是当前程序的基地址,0x102238000 + B7A8 = 0x1000B77A8 就是 eat 函数对应的数据结构 TargetMethodDescriptor 在内存中的首地址,下面我们再来看下函数的数据结构。
struct TargetMethodDescriptor {
// Flags 为 4 字节
MethodDescriptorFlags Flags;
// 这里存储的是相对指针,就是 offset
TargetRelativeDirectPointer<Runtime, void> Impl;
};
所以 eat 函数的指针地址需要用 0x1000B77A8 + 4字节 + offset(FFFFC028)- 虚拟内存的起始地址(0000000100000000) = 0x1000B37D4,那么我们算的对不对呢,我们通过断点调试来验证一下。

通过断点调试,输出 eat 函数的地址跟我们计算出的一样。
结构体的方法调度
以上我们分析了类的方法调度方式,那么结构体的方法调度方式又是什么样的呢?下面我们继续来分析一下。
struct CXPerson {
func eat() {
print("eat")
}
func eat1() {
print("eat1")
}
func eat2() {
print("eat2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
p.eat1()
p.eat2()
}
}

通过汇编调试可以看到,结构体的方法调用就是 bl 指令直接拿到地址进行跳转,也就是静态派发, 意味着在编译链接之后,结构体中的方法地址就已经确定了, 这是因为结构体是值类型,没有继承关系,所以没必要再开辟连续的空间来记录结构体中的方法, 编译器会把方法执行直接优化成地址调用,类似于静态函数。

通过源码也可以看到,在建立结构体的描述的时候也没有 v-table 。
extension 下方法的调度
struct CXPerson {
func eat() {
print("eat")
}
func eat1() {
print("eat1")
}
func eat2() {
print("eat2")
}
}
extension CXPerson {
func eat3() {
print("eat3")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
p.eat1()
p.eat2()
p.eat3()
}
}

当 CXPerson 为结构体的时候,给 CXPerson 的 extension 中添加方法,新添加的方法调度方式不变,也是静态派发。
class CXPerson {
func eat() {
print("eat")
}
func eat1() {
print("eat1")
}
func eat2() {
print("eat2")
}
}
extension CXPerson {
func eat3() {
print("eat3")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
p.eat1()
p.eat2()
p.eat3()
}
}

当 CXPerson 为 class 的时候,给 CXPerson 的 extension 中添加方法,类中的方法调度依然是通过函数表派发,但是 extension 中的方法是通过静态派发的方式调用的。个人理解的原因是,如果 extension 中的方法依然通过函数表派发的话,会影响类中 v-table 的内存结构,需要插入新的方法到函数表中,对内存的开销是非常大的,所以编译器对 extension 中的方法调用会直接优化成静态调用。
方法调度方式的总结

影响函数派发方式的因素
final: 添加了final关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可⻅。实际开发过程中属性、方法、类不需要被重载的时候可以使用final。dynamic: 函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。

如上图可以看到 eat 函数被替换成了 eat3。
@objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。@objc+dynamic: 消息派发的方式。
class CXPerson {
@objc dynamic func eat() {
print("eat")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let p = CXPerson()
p.eat()
}
}

这里 eat 会通过消息派发的方式进行调用,可以使用 runtime 的 api,也可以进行方法的交换,但是 eat 不能被 NSObject 使用,如果想让 NSObject 使用 eat 函数的话需要让 CXPerson 继承于 NSObject。
函数内联
函数内联 是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
将确保有时内联函数。这是默认行为,我们无需执行任何操作,
Swift编译器可能会自动内
联函数作为优化。always- 将确保始终内联函数。通过在函数前添加@inline(__always)来实现此行为never- 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)来实现。如果函数很⻓并且想避免增加代码段大小,请使用
@inline(never)。
如果对象只在声明的文件中可⻅,可以用 private 或 fileprivate 进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private : 定义的声明中访问)。
class LGPerson{
private var sex: Bool
private func unpdateSex(){
self.sex = !self.sex
}
init(sex innerSex: Bool) {
self.sex = innerSex
}
}
