Swift探索(二): 类与结构体(下)
在上一篇中我们探讨了类与结构体的本质,以及他们的相同点和不同点。接下来我们将对 Swift 的方法进行深入了解。
1.异变方法
默认情况下,值类型属性不能被自身的实例方法修改。
结构体在方法中修改属性.png
因为
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:
函数的形式参数都是let类型的.png
那么我们只需要在参数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文件格式.png
必备工具显示的Mach-O文件格式.png
- 首先是文件头,表明该文件是
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的地址.png
因此得到
Descriptor的地址为:
0xFFFFFB7C + 0xBBCC = 0x10000B748
image.png
在
Load Commands的LC_SEGMENT_64(__PAGEZERO)中可以看到虚拟地址的首地址和大小,因此上一步得到的地址0x10000B748减去虚拟内存的首地址0x100000000就是当前Descriptor在虚拟内存中的偏移量(offset)。
0x10000B748 - 0x100000000 = 0xB748
定位到0xB748
0xB748.png
0xB748就是TargetClassDescriptor这个结构体类的首地址,后面存储的就是相应成员变量的内容,根据前面对源码的分析我们得到了TargetClassDescriptor结构体中有13个Int32类型,也就是13个4字节,于是我们向后偏移13个4字节
向后偏移13个4字节.png
因此我们可以得到
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()方法的值,可以得到:
汇编play1()函数的地址.png
得到最后两个地址是一致的,因此我们可以得出结论
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模式默认会开启优化),编译器会自动将某些函数变成内联函数-将函数调用展开成函数体。
手动修改的方式如图所示:
编译器优化.png
// 永远不会被内联(即使开启了编译器优化)
@inline(never) func play() {
print("play")
}
// 开启编译器优化后,即使代码很长,也会被内联(递归调用函数、动态派发的函数除外)
@inline(__always) func play1() {
print("play1")
}
-
always- 将确保始终内联函数。通过在函数前添加@inline(__always)来实现此行为 -
never- 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)来实现。 - 如果函数很⻓并且想避免增加代码段大小,请使用
@inline(never)
在 Debug 模式下默认不优化代码的,但是内联函数是默认行为,在函数中代码比较少,或者编译器认为代码非常少直接将函数中的代码挪到该位置执行就好
在 Release 模式下,编译器已经开启优化,会自动决定哪些函数需要内联,因此没必要使用@inline。