Swift探索(二): 类与结构体(下)
在上一篇中我们探讨了类与结构体的本质,以及他们的相同点和不同点。接下来我们将对 Swift 的方法进行深入了解。
1.异变方法
默认情况下,值类型属性不能被自身的实例方法修改。
因为
x
和y
是属于 self
的,修改它们就是修改 self
本身,在自己的方法里修改自己。解决方式:使用
mutating
关键字进行修饰方法
struct Point {
var x = 0.0, y = 0.0
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
//self --- x, y
x += deltaX
y += deltaY
}
}
通过SIL
文件来分析mutating
关键字的作用
struct Point {
var x = 0.0, y = 0.0
func test(){
let tmp = self.x
}
mutating func mutatingTest(){
x = self.x + 1
}
}
通过swiftc main.swift -emit-sil > ./main.sil
生成.sil
文件
struct Point {
@_hasStorage @_hasInitialValue var x: Double { get set }
@_hasStorage @_hasInitialValue var y: Double { get set }
func test()
mutating func mutatingTest()
init()
init(x: Double = 0.0, y: Double = 0.0)
}
结构体Point
中mutatingTest()
函数有mutating
关键字修饰
// 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
···
} // end sil function '$s4main5PointV4testyyF'
// Point.mutatingTest()
sil hidden @$s4main5PointV12mutatingTestyyF : $@convention(method) (@inout Point) -> () {
// %0 "self" // users: %10, %2, %1
bb0(%0 : $*Point):
debug_value_addr %0 : $*Point, var, name "self", argno 1 // id: %1
···
} // end sil function '$s4main5PointV12mutatingTestyyF'
可以发现test()
函数和mutatingTest()
函数在调用是都传入了一个参数Point
,不同的是
mutatingTest()
传入的Point
参数是有@inout
修饰的,在SIL官方文档中@inout
的解释为
An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)
注意看两个函数执行的第一行代码
// test函数
debug_value %0 : $Point, let, name "self", argno 1 // id: %1
// mutatingTest函数
debug_value_addr %0 : $*Point, var, name "self", argno 1 // id: %1
可以看出
- 没有添加
mutating
关键字的函数,传入的是self
的值 相当于let self = Point
- 添加了
mutating
关键字的函数,传入的是self
的地址 相当于var self = &Point
因此对于变异方法,传入的 self
被标记为 inout
参数。无论在mutating
方法内部发生什么,都会影响外部依赖类型的一切。
inout
关键字还有一个用处:
如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为
输入输出形式参数
。在形式参数定义开始的时候在前边 添加一个inout
关键字可以定义一个输入输出形式参数
比如我们想在changeAge
函数中修改age
:
那么我们只需要在参数age1
加上inout
关键字
var age = 18
func changeInoutAge (_ age1 : inout Int) {
age1 += 1
}
changeInoutAge(&age);
print(age)
// 输出结果 19
注意在调用changeInoutAge()
函数的时候传入的一定是age
的地址
2.方法调度
2.1 使用汇编进行分析
在 OC
中,调用一个方法的本质是消息传递,底层通过objc_msgSend
函数去查找方法并调用。那么Swift
中又是怎么调度的呢?这里我们通过汇编代码来分析
ARM64
(真机)常用汇编指令
mov
: 将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器 与常量之间传值,不能用于内存地址),如:
mov x1, x0
//将寄存器x0
的值复制到寄存器x1
中add
: 将某一寄存器的值和另一寄存器的值 相加 并将结果保存在另一寄存器中, 如:
add x0, x1, x2
//将寄存器x1
和x2
的值相加后保存到寄存器x1
中sub
: 将某一寄存器的值和另一寄存器的值 相减 并将结果保存在另一寄存器中:
sub x0, x1, x2
//将寄存器x1
和x2
的值相减后保存到寄存器x1
中and
: 将某一寄存器的值和另一寄存器的值 按位与 并将结果保存到另一寄存器中, 如:
and x0, x0, #0x1
//将寄存器x0
的值和常量1
按位与后保存到寄存器x0
中orr
: 将某一寄存器的值和另一寄存器的值 按位或 并将结果保存到另一寄存器中, 如:
prr 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
: 带返回的跳转指令, 返回地址保存到lr(X30)
blr
: 带返回的跳转指令,跳转到指令后边跟随寄存器中保存的地址
blr x8
//跳转到x8
保存的地址中去执行ret
: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器lr (x30)
中
将如下代码运行,并进入汇编
class Person {
func play(){
print("play")
}
func play1(){
print("play1")
}
func play2(){
print("play2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person()
t.play()
t.play1()
t.play2()
// Do any additional setup after loading the view.
}
}
image.png
通过汇编代码我们可以看到在allocating_init
和relesae
中间有三个blr
指令
// 函数的返回值是放在x0寄存器中的 x0的第一个8字节:metadata
mov x20, x0 // 将x0的值复制到x20中
str x20, [sp, #0x18] // 将寄存器x20中的值保存到栈内存[sp + #0x18]处 #0x18偏移量
str x20, [sp, #0x20] // 将寄存器x20中的值保存到栈内存[x0 + #0x20]处
ldr x8, [x20] // 将寄存器x20的值作为地址,取该内存地址的值放入寄存器x8中
ldr x8, [x8, #0x50] // 将寄存器x8的值+#0x50(偏移量)相加作为地址,取该内存地址的值放入寄存器x8中
blr x8 //跳转到x8保存的地址中去执行
由此可以得出Swift
中函数的调用过程:
- 找到
Metadata
//x0
的第一个8
字节:metadata
- 确定函数地址
(metadata + 偏移量)
- 执行函数
在汇编代码中我们发现三个函数的地址的偏移量(0X50
、#0x58
、0x60
)相差8
个字节(函数指针的大小),并且在内存中是连续的内存空间,因此Swift函数的调度是基于函数表的调度
在上一篇中我们了解到Metdata
的结构那么函数表在什么地方呢?
struct HeapMataData {
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
}
我们知道不管是 Class
,Struct
,Enum
都有自己的 typeDescriptor
,就是对类的一个详细描述。通过对Swift源码的分析最后能够得到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.2 使用Mach-O进行分析
2.2.1 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
是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。
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
做内存映射的。
2.2.2 验证流程
Section64(_TEXT,__swift5_types)
中存放的就是 Descriptor
:
因此得到
Descriptor
的地址为:
0xFFFFFB7C + 0xBBCC = 0x10000B748
image.png
在
Load Commands
的LC_SEGMENT_64(__PAGEZERO)
中可以看到虚拟地址的首地址和大小,因此上一步得到的地址0x10000B748
减去虚拟内存的首地址0x100000000
就是当前Descriptor
在虚拟内存中的偏移量(offset
)。
0x10000B748 - 0x100000000 = 0xB748
定位到0xB748
0xB748
就是TargetClassDescriptor
这个结构体类的首地址,后面存储的就是相应成员变量的内容,根据前面对源码的分析我们得到了TargetClassDescriptor
结构体中有13
个Int32
类型,也就是13
个4
字节,于是我们向后偏移13
个4
字节
因此我们可以得到
play1()
这个函数地址在Mach-O
文件中的偏移量0xB77C
,由于这里得到的只是偏移量,要想得到play1()
的地址我们就还要加上ASLR(随机偏移地址)
此时程序运行时的基地址加上方法在macho
的偏移量,可以得到方法在真机运行时下的首地址通过命令
image list
可以查看到程序运行时的基地址0x0000000104834000
image.png
所以
play1()
函数首地址为基地址0x0000000104834000
+ 偏移量0xB77C
0x0000000104834000 + 0xB77C = 0x10483F77C
0x10483F77C
是函数play1()
在运行内存当中的地址,在Swift源码中我们找到内存对于函数的存储是一个结构体TargetMethodDescriptor
struct TargetMethodDescriptor {
/// Flags describing the method.
MethodDescriptorFlags Flags; // 4字节
/// The method implementation. // offset
TargetRelativeDirectPointer<Runtime, void> Impl;
};
TargetMethodDescriptor.png
所以0x10483F77C
指向的就是结构体TargetMethodDescriptor
的首地址。
因此Impl
的地址则要偏移4
个字节,而Impl
的偏移量是0xFFFFC250
0x10483F77C + 4 = 0x10483F780
0x10483F780 + 0xFFFFC250 = 0x20483B9D0
0x20483B9D0 - 0x100000000 = 0x10483B9D0 // 减去虚拟内存的首地址0x100000000
得到play1()
函数的地址为0x10483B9D0
对比汇编断点读取寄存器x8
中play1()
方法的值,可以得到:
得到最后两个地址是一致的,因此我们可以得出结论
V-Table
就是在 Descriptor
结构的后面。
2.3 函数派发方式
2.3.1 Struct函数
上面我们已经知道了Class
的函数派发方式是函数表,那么Struct
又怎么怎样的呢?
我们将上面的代码中的Class
换成Struct
并在汇编中看一看
struct Person {
func play(){
print("play")
}
func play1(){
print("play1")
}
func play2(){
print("play2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person()
t.play()
t.play1()
t.play2()
}
}
sturct汇编.png
我们可以看到
play()
、play1()
、play2()
函数调用就是直接的地址调用,也就意味着是静态派发
。
2.3.1 Struct 的 extension中的函数
给Person
添加一个扩展
extension Person {
func play3() {
print("play3")
}
}
struct中的extension的函数调用.png
可以看到
extension
中的函数也是静态派发
2.3.2 Class 的 extension中的函数
将struct
改回class
,并在汇编中调试
class Person {
func play(){
print("play")
}
func play1(){
print("play1")
}
func play2(){
print("play2")
}
}
extension Person {
func play3() {
print("play3")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person()
t.play()
t.play1()
t.play2()
t.play3()
}
}
class中的extension的函数调用.png
我们可以看到
class
中的extension
的函数也是静态派发
的方式。那么为什么类里面的extension
也是静态派发呢?我认为应该是这样的:创建类的时候,
V-table
的大小已经确认好了,那么extension
里面的方法需要插入到之前创建V-table
里面,那么V-table
的大小就不够了,并且这样的操作也比较消耗性能,对应用程序的设计这样是不可取的。并且如果这个类作为其他类的父类的话,那么子类就需要记住父类V-Table
的位置,然后插入到父类的V-Table
中,这样的操作是比较复杂的。所以为了优化,直接把
extension
独立于虚函数表之外,采用静态调用的方式。
2.3.3 函数调度方式
由上述我们可以总结出函数的调度方式如下图:
函数调度方式总结.png
2.4 影响函数派发方式
2.4.1 final
class Person {
final func play(){
print("play")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person()
t.play()
}
}
final关键字.png
添加了
final
关键字的函数无法被重写,使用静态派发,不会在 v-table
中出现,且对objc
运行时不可⻅。实际开发过程中当属性,方法,类不需要被重载时使用
final
关键字
2.4.2 dynamic
class Person {
dynamic func play(){
print("play")
}
}
extension Person {
@_dynamicReplacement(for: play) // 用play1()替代play()函数
func play1() {
print("play1")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person()
t.play()
print("end")
t.play1()
}
}
打印结果.png
dynamic关键字.png
函数均可添加 dynamic 关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。
2.4.3 @objc
class Person : NSObject {
@objc func play(){
print("play")
}
}
查看方式.png
Swift类暴露给OC.png
@objc
可以将Swift
函数暴露给Objc
运行时,依旧是函数表派发。
2.4.4@objc + dynamic
class Person {
@objc dynamic func play(){
print("play")
}
}
extension Person {
@objc dynamic func play1() {
print("play1")
}
}
@objc + dynamic.png
添加了@objc + dynamic
关键字,那么这里的函数的派发方式就变成了消息派发objc_msgSend
的方式,并且可以使用OC
中runtime
的api
。
2.4.5 private和fileprivate
class Person {
private var gender: Bool
private func updateGender(){
self.gender = !self.gender
}
init(_ gender: Bool) {
self.gender = gender
}
func play(){
self.updateGender()
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Person(true)
t.play()
}
}
updateGender()函数的调用.png
如果对象只在声明的文件中可⻅,可以用 private
或fileprivate
进行修饰。编译器会对 private
或 fileprivate
对象进行检查,确保没有其他继承关系的情形下,自动打上 final
标记,进而使得对象获得静态派发的特性(fileprivate
: 只允许在定义的源文件中访问,private
: 定义的声明中访问)
3.函数内联
函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
如果开启了编译器优化(Release
模式默认会开启优化),编译器会自动将某些函数变成内联函数-将函数调用展开成函数体。
手动修改的方式如图所示:
// 永远不会被内联(即使开启了编译器优化)
@inline(never) func play() {
print("play")
}
// 开启编译器优化后,即使代码很长,也会被内联(递归调用函数、动态派发的函数除外)
@inline(__always) func play1() {
print("play1")
}
-
always
- 将确保始终内联函数。通过在函数前添加@inline(__always)
来实现此行为 -
never
- 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)
来实现。 - 如果函数很⻓并且想避免增加代码段大小,请使用
@inline(never)
在 Debug
模式下默认不优化代码的,但是内联函数是默认行为,在函数中代码比较少,或者编译器认为代码非常少直接将函数中的代码挪到该位置执行就好
在 Release
模式下,编译器已经开启优化,会自动决定哪些函数需要内联,因此没必要使用@inline
。