一、异变方法
1.1 值类型添加/不添加mutating关键字的区别
Swift语言中的类型有值类型
与引用类型
之分,对于引用类型
,在实例方法中对实例属性进行修改是没有问题的,但是对于值类型
,读者需要格外注意,默认情况下,值类型属性不能被修改。示例代码如下:
///创建一个结构体
struct Point {
var x: Double
var y: Double
func move(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
编译上面的代码会报如下图所示错误:
对于值类型
,使用mutating
关键字修饰实例方法才能对属性进行修改,示例代码如下:
///创建一个结构体
struct Point {
var x: Double
var y: Double
///将点进行移动,因为修改了属性的值,需要用mutating修饰方法
mutating func move(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)
实际上,在值类型实例方法中修改值类型属性的值就相当于创建了一个新的实例,上面的代码和下面的代码原理是一致的:
///创建一个结构体
struct Point {
var x: Double
var y: Double
///将点进行移动,直接创建新的实例
mutating func move(x deltaX: Double, y deltaY: Double) {
self = Point(x: self.x + x, y: self.y + y)
}
}
var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)
1.2 SIL文档探究异变方法本质
下面我们通过SIL
来对比一下,不添加mutating
和添加mutating
两者有什么区别:
///创建一个结构体
struct Point {
var x: Double
var y: Double
///没有用mutating修饰,和下面的move函数进行对比
func test() {
let tmp = self.x
print(tmp)
}
///将点进行移动,直接创建新的实例
mutating func move(x deltaX: Double, y deltaY: Double) {
self = Point(x: self.x + x, y: self.y + y)
}
}
var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)
// Point.test()
sil hidden @$s4main5PointV4testyyF : $@convention(method) (Point) -> () {
// %0 "self" // users: %2, %1
bb0(%0 : $Point):
debug_value %0 : $Point, let, name "self", argno 1 // id: %1
分析上面的代码,可以知道test函数
,有一个默认参数self
,类型是Point类型
。这里的test函数
实际就是let self = Point
,是直接取值。
// Point.move(x:y:)
sil hidden @$s4main5PointV4move1x1yySd_SdtF : $@convention(method) (Double, Double, @inout Point) -> () {
// %0 "deltaX" // user: %3
// %1 "deltaY" // user: %4
// %2 "self" // users: %33, %23, %19, %11, %7, %5
bb0(%0 : $Double, %1 : $Double, %2 : $*Point):
debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5
分析上面的代码,可以知道move函数
,有两个参数x,y
,有一个默认参数self
,类型是Point类型
,一个是一个@inout
关键字,那么我们先来看一下inout
在官方文档的解释是什么。
SIL 文档的解释 - inout
An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)
这里的move函数
实际就是var self = *Point
,由原来的直接取值改变为了变量取地址。
由此我们可以得出异变方法的本质: 对于异变方法, 传入的 self
被标记为 inout
参数。无论在mutating
方法内部发生什么,都会影响外部依赖类型的一切。
如果在开发中真的需要在函数内部修改传递参数的变量的值,可以将此参数声明为inout
类型。
1.3 输入输出参数inout
输入输出参数: 如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后 依然生效,那么就需要将形式参数定义为 输入输出形式参数 。在形式参数定义开始的时候在前边添加一个inout
关键字可以定义一个输入输出形式参数。
///在函数内部修改参数变量的值
func myFunc(a: inout Int) {
a += 1
}
var a = 15;
myFunc(a: &a)
///将打印16
print(a)
上面的代码中将参数a声明为inout
类型,在传参时需要使用‘&’
符号,这个符号将传递参数变量的内存地址。
二、方法调度
2.1 函数调用过程
在OC中,方法调度是通过消息发送机制
,也就是objc_msgsend
。那么在Swift中的方法调度又是怎样的一种形式哪?我们一起通过下面的代码来求证分析一下。
import UIKit
class ZGTeacher {
func teach() {
print("teach")
}
func teach1() {
print("teach1")
}
func teach2() {
print("teach2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = ZGTeacher()
t.teach()
t.teach1()
t.teach2()
}
}
给三个函数方法打上断点,编译时选择Xcode
->Debug
-> Debug Workflow
->Always Show Disassembly
,我们来看一下对应的汇编打印
0x109299e8c <+108>: callq 0x109299dd0 ; ZGSwiftAPPTest.ZGTeacher.__allocating_init() -> ZGSwiftAPPTest.ZGTeacher at ViewController.swift:10
0x109299e91 <+113>: movq %rax, %r13
0x109299e94 <+116>: movq %r13, -0x30(%rbp)
0x109299e98 <+120>: movq %r13, -0x28(%rbp)
-> 0x109299e9c <+124>: movq (%r13), %rax
0x109299ea0 <+128>: movq 0x50(%rax), %rax
0x109299ea4 <+132>: callq *%rax
0x109299ea6 <+134>: movq -0x30(%rbp), %r13
0x109299eaa <+138>: movq (%r13), %rax
0x109299eae <+142>: movq 0x58(%rax), %rax
0x109299eb2 <+146>: callq *%rax
0x109299eb4 <+148>: movq -0x30(%rbp), %r13
0x109299eb8 <+152>: movq (%r13), %rax
0x109299ebc <+156>: movq 0x60(%rax), %rax
0x109299ec0 <+160>: callq *%rax
0x109299ec2 <+162>: movq -0x30(%rbp), %rdi
0x109299ec6 <+166>: callq 0x10929bac6 ; symbol stub for: swift_release
我们看到关键字__allocating_init()
,这很明显是在开辟空间,而关键字swift_release
告知我们这里是在销毁空间,而断点停在第124行,0x109299e9c <+124>: movq (%r13), %rax
,很明显这里是我们的teach函数
开始执行的地方,同样的,第138行和152行分别代表了teach1函数
和teach2
函数执行。
第128行 0x50(%rax)
第142行 0x58(%rax)
第156行 0x60(%rax)
这三行地址的值,每一个相差8
个字节,说明他们函数地址的值在内存里是连续的一块内存空间。
通过上面汇编指令的对应分析,可以知道函数teach
的调用过程
- Metadata
- 确定函数地址(metadata + 偏移量)
- 执行函数xxx
它们是基于函数表的调度,下面我们通过SIL文件的角度来看一下它的调度
2.2 基于函数表V-table的调度
下面我们去掉Xcode ->Debug -> Debug Workflow ->Always Show Disassembly,汇编指令打印,在项目中添加如下路径的sh文件
选择
other
->Aggregate
,创建一个Script的Target选择
New Run Script Phase
添加一个新的sh文件,并在Run Script
添加以下sh代码
swiftc -emit-silgen -Onone -target x86_64-apple-ios15.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/ZGSwiftAPPTest/ViewController.swift > ./ViewController.sil && open ViewController.sil
编译运行这个Script Target
,就可以生成并打开对应的sil文件。下面我们来分析一下这份sil文件。
sil_vtable ZGTeacher {
#ZGTeacher.teach: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC5teachyyF // ZGTeacher.teach()
#ZGTeacher.teach1: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC6teach1yyF // ZGTeacher.teach1()
#ZGTeacher.teach2: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC6teach2yyF // ZGTeacher.teach2()
#ZGTeacher.init!allocator: (ZGTeacher.Type) -> () -> ZGTeacher : @$s14ViewController9ZGTeacherCACycfC // ZGTeacher.__allocating_init()
#ZGTeacher.deinit!deallocator: @$s14ViewController9ZGTeacherCfD // ZGTeacher.__deallocating_deinit
}
这里就罗列了我们的 ZGTeacher
函数里都有哪些函数,是以vtable
存放并罗列对应函数的函数表。
2.3 typeDescriptor源码分析
之前我们在第一节课讲到了 Metdata
的数据结构,那么 V-Table
是存放在什么地方那? 我们先来回顾一下当前的数据结构。
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
,就是对类的一个详细描述。
我们通过查看Swift源码,找到这个Metadata.h
文件
ConstTargetMetadataPointer<Runtime, TargetClassDescriptor>
getDescription() const {
return Description;
}
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文件读取分析
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文件格式:
- 首先是
文件头
,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信 息,文件头信息影响后续的文件结构安排 -
Load commands
是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表 等。
-
Data
区主要就是负责代码和数据记录的。Mach-O 是以Segment
这种结构来组织数据的,一个Segment
可以包含 0 个或多个Section
。根据Segment
是映射的哪一个Load Command
,Segment
中Section
就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据Segment
做内存映射的。
在新建项目的时候,Xcode 不会自动生成Products
文件,可以参考这篇文章:Xcode13 新建项目 Products 目录显示方法
用 MachOView 工具打开 Mach-O 文件的格式大概长这样:
前面的四个字节 80 FB FF FF
就是 ZGTeacher
的 Descriptor
信息,那用 80 FB FF FF
加上前面的 00007CAC
得到的就是 Descriptor
在当前 Mach-O 文件的内存地址。
它们怎么相加呢,iOS 属于小端模式,所以 80 FB FF FF
要从右边往左读。也就是:
0xFFFFFB80 + 0x00007CAC = 0x10000782C
0x10000782C
这个值是我拿计算器算的,那么 0x100000000
就是 Mach-O 文件中虚拟内存的基地址,如下图所示:
我们用0x10000782C - 0x100000000 = 0x782C
就是 ZGTeacher
在整个 Data
区的内存地址。我们找到 TEXT, const
。
如图所示,这个0x7820
是首地址,偏移12个字节就是0x782c
,也是意味着,它后面的数据是 TargetClassDescriptor
的数据,所以我们可以在这里拿到 ZGTeacher 的虚函数表 - ZGTeacher 方法的地址
。
计算 TargetClassDescriptor
中 VTable
前面的数据大小,求得偏移量。一共 12 个 4 字节(48字节)的成员变量,12 个四字节的成员变量再加上 size(4字节)得到 52 字节,再往后的 24 字节就是teach,teach1,teach2
方法的结构地址(一个函数地址占 8 字节)。如图所示:
如图中所示,0x7860 - 0x7867
是 teach
结构在 Mach-O 文件的地址。那么在程序中如何找到该地址呢。
ASLR
是一个随机偏移地址
,这个随机偏移地址的目的是为了给应用程序一个随机内存地址。
image list
是列出应用程序运行的模块,我们找到第一个,其内存地址为 0x000000010eaeb000
,这个地址就是当前应用程序的基地址。
接下来我在Swift源码中找到这么一个结构体TargetMethodDescriptor
。
struct TargetMethodDescriptor {
/// 4字节
MethodDescriptorFlags Flags;
/// 这里存储的是相对指针,offset
TargetRelativeDirectPointer<Runtime, void> Impl;
};
到这里,TargetMethodDescriptor
结构体的地址就可以确定了,那么要找到函数地址,还需要偏移 Flags + Impl
,得到的就是函数的地址。 综合以上的逻辑开始计算:
// 应用程序的基地址:0x000000010eaeb000,teach 结构地址:0x7860,Flags:0x4,offset:1C C2 FF FF
// 注意!小端模式要从右往左,所以为 FFFFC21C
0x000000010C493000 + 7860 + 0x4 + FFFFC21C = 0x20EAEEA80
// 接下来需要减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址。
0x20EAEEA80 - 0x100000000 = 0x10EAEEA80
打开汇编调试,读取汇编中 teach 的地址,验证 0x10EAEEA80
就是否就是 teach 的地址。到这里就完全验证了 Swift 类的方法确实是存放在 VTable - 虚函数表
里面的。
2.5 方法调度方式总结
三、影响函数派发方式
3.1 final
添加了final
关键字的函数无法被重写,使用静态派发
,不会在vtable
中出现,且对objc运行时
不可见。
示例如下:
class Shape {
final var center:(Double, Double)
init() {
center = (0, 0)
}
}
3.2 dynamic
函数均可添加 dynamic
关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发
。
3.3 @objc
该关键字可以将Swift函数暴露给Objc运行时
,依旧是函数表派发
。
3.4 @objc + dynamic
消息派发的方式
四、函数内联
4.1 什么是函数内联
函数内联
是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优 化性能。
4.2 @inline(__always)
将确保始终内联函数。通过在函数前添加 @inline(__always)
来实现此行为。
4.3 @inline(never)
将确保永远不会内联函数。这可以通过在函数前添加 @inline(never)
来实现。 如果函数很长并且想避免增加代码段大小,请使用@inline(never)