Swift —— 类与结构体的方法
1. 异变方法
Swift 中 class 和 struct 都能定义方法。但是有一点区别的是默认情况 下,值类型属性不能被自身的实例方法修改。
如果想要自身的实例方法可以修改struct的属性,那么就需要在方法前面加上mutating的关键字。
那么mutating做了些什么呢?在结构体里添加一个没有mutating的方法和一个有mutating的方法,这样之后运行生成SIL文件方便进行观察。
struct Point {
var x = 0.0, y = 0.0
func test() {
let tmp = self.x
}
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
//self
x += deltaX
y += deltaY
}
}
看到SIL里面声明是没有变化的,那么接下来去看函数的实现。
看到test方法,这里需要一个参数Point,其实这个也就是self。在Swift中,不论是类还是结构体,都有一个默认参数,就是self。而在oc中,则有两个默认参数,一个是self,一个是sel。
看到moveBy方法这里,看到这里有三个参数,前面两个就是方法的参数,最后面就是默认参数self,而这里的默认参数和test方法的不同,Point前面加了@inout,并且这里可以看到在使用point的时候是用$*Point,而在test方法中使用的是 $Point。并且这里一个使用的是let,一个用的是var。也就相当于一个是 let self = Point。 一个是 var self = &Point。
在官方文档中解释到:An @inout parameter is indirect. The address must be of an initialized object.(当前参数 类型是间接的,传递的是已经初始化过的地址),也就是说,moveBy方法这里接受的是地址,而test方法接受的是实例。
所以我们得到:
异变方法的本质:对于变异方法, 传入的 self 被标记为 inout 参数。无论在 mutating 方法 内部发生什么,都会影响外部依赖类型的一切,可以理解为外界将self的地址传入函数内部,所以内部修改self可以改变外部实例。
inout参数(输入输出参数):如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后 依然生效,那么就需要将形式参数定义为 输入输出形式参数 。在形式参数定义开始的时候在前边 添加一个 inout关键字可以定义一个输入输出形式参数
例如:
var age = 10
func changeAge(_ age: inout Int) {
age += 1
}
changeAge(&age)
print(age)
2. 方法调度
在oc中使用的是objc_msgsend来进行方法的调度,接下来看一下swift的方法调度。
创建一个LGTeacher类,然后在viewDidLoad创建实例对象并且调用teach方法。
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = LGTeacher()
t.teach()
}
}
class LGTeacher{
func teach() {
print("teach")
}
}
2.1 汇编
在 t.teach()打下断点然后打开汇编模式后运行 。上篇文章可以知道生成实例调用__allocating_init(),那么teach在生成实例后面,而blr是跳转指令,那么blr 那一行就应该是调用teach方法。
进去之后发现果然到了teach方法里面。
为了探究Swift方法是怎么调度的,在LGTeacher类里面添加两个方法。
class LGTeacher{
func teach() {
print("teach")
}
func teach1() {
print("teach")
}
func teach2() {
print("teach")
}
}
同时在viewDidLoad也调用这两个方法。
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = LGTeacher()
t.teach()
t.teach1()
t.teach2()
}
}
分别打下断点后运行,看到了三个blr,也就是调用函数的地方。
看到这里调用的是x8,那么x8是怎么来的呢?往上看到:
-
mov x20,x0: 把寄存器x0的值复制到寄存器x20,这里的x0就是上面LGTeacher.__allocating_init()返回的实例对象
-
str x20, [sp, #0x18] : 把寄存器x20的值保存到栈内存 [sp + #0x18]
-
str x20, [sp, #0x20] : 把寄存器x20的值保存到栈内存 [sp + #0x20]
-
ldr x8, [x20] : 取寄存器x20地址的值放入到寄存器x8中,根据之前实例对象结构可以得知是metadata
-
ldr x8, [x8, #0x50]: 取寄存器x8和0x50相加的值作为地址,取该内存地址放入到寄存器x8中,根据也就是metadata + 0x50 得到teach的方法的地址,然后放入到寄存器x8中。然后往下就是执行函数了。
同时,我们注意到,三个x8的地址相加的地址是分别是0x50,0x58,0x60,也就是相差8个字节,也就是函数指针的大小,在内存上是连续的内存空间。这种调用方法是基于函数表的调度。
2.2 SIL
将程序编译成SIL文件后打开,那么就看到这里有一个vtable,里面包含着teach,teach1,teach2方法,这个vtable其实就是每个类自己的函数表。
2.3 源码
再来看源码。这里需要关注一个Description的属性。
在看到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
}
看到这里没有vtable,继续搜索发现TargetClassDescriptor有个别名ClassDescriptor。
全局搜索ClassDescriptor。在GenMeta里面就找到了一个类,ClassContextDescriptorBuilder。
往下看到了layout(布局)方法,这里先调用了super.layout方法,这里的super是TypeContextDescriptorBuilderBase。
在TypeContextDescriptorBuilderBase里面的layout调用了一系列方法,添加的和TargetClassDescriptor里面的属性就有点类似了。
而在调用了super.layout之后,ClassContextDescriptorBuilder的layout方法还调用了addVTable(),看到addVTable里面做了一系列操作,还计算了偏移量,把偏移量添加到了B,而这里B就是TargetClassDescriptor。最后遍历数组,添加了数组的指针也就是函数的指针。所以TargetClassDescriptor里面的size和vtable是这里面添加进去的。
2.4 macho
Mach-O 其实是Mach Object文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows 上的 PE 格式 (Portable Executable ), linux 上的 elf 格式 (Executable and Linking Format) 。常⻅的 .o,.a .dylib Framework,dyld .dsym。
将工程的可执行文件拖入到machO打开。
文件头
header是头文件,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信 息,文件头信息影响后续的文件结构安排,记录一些二进制信息。
- Magic number: 标志着是32位还是64位的,这里是64位的。
- CPU Type:cpu类型,这里可以看到是ARM64
- CPU Subtype:cpu分类型,这里是ARM64_ALL
- File Type: 文件类型,这里看到是可执行文件。
- number of load Commands: 需要加载的命令个数,这里是35个
- size of load Commands: 需要加载命令的大小
- flags: 标识
Load Commands
在看到Load Commands,Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表 等。
Data 区
Data 区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据 的,一个 Segment 可以包含 0 个或多个 Section。根据 Segment 是映射的哪一个 Load Command,Segment 中 section 就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据 Segment 做内存映射的。
例如text存指令:
cstring存硬编码的字符串:
对于swift的类,结构体和enum,都存在swift5_types里面,而这里面存的是类,结构体和enum的descriptor的地址信息。
验证一下 ,这里将14 FC FF FF 这四个字节 加上0000BC78这四个字节,得出来的就是descriptor在macho文件的内存地址。那么0XFFFFFC18 加0XBC88 得到 0x10000B88C。
之前在头文件看到虚拟内存地址从0x10000000开始的,那么B88C就是在data区的内存地址。
根据上面得到的TargetClassDescriptor结构可以知道,这里将地址平移13个4字节得到VTable,那么0000D8C0就是teach1方法的地址。
那么如何得到teach方法在程序内存运行时的地址呢?这里就需要将teach方法的地址加上ASLR(随机偏移地址)。接下来来到Xcode运行程序,然后打印image list 获得ASLR地址0x0000000100d1c000,加上teach方法地址B8C0得到0x100D278C0。
方法在底层的结构体是这个样子的,那么要得到impl,就需要位移4个字节,0x100D278C0 + 4 = 0x100D278C4.
impl存储的是相对指针,存储的是offset,所以需要将得到的impl地址加上offset,才能得到函数的地址。
那么 0x100D278C4 + 0XFFFFBAC0 得到0x200D23384, 那么在减去基地址,就得到0x100D23384
在Xcode里面读取寄存器X8,发现地址是正确的。
那么为什么Vtable在descriptor后面呢?在initClassVTable方法里面看到这里先获得了VTable的描述,然后在用vtableOffset + i 进行存储。vtableOffset是在程序运行时就已经确定了的字段。
那么struct的方法调度是什么样的呢?将LGTeacher改成struct后运行,发现直接就拿到了内存地址,所以就是一个直接的地址调用,是静态派发,这也就意味着在编译链接之后,地址就已经确定了。struct是值类型,那么struct没有继承关系,那么struct的函数就是属于自己的,没有必要在开辟一个连续的空间来记录方法,所以编译器直接优化成了地址调用。
既然有ClassContextDescriptorBuilder,那么就应该有StructContextDescriptorBuilder。在源码中找到StructContextDescriptorBuilder,看到这有个addLayoutInfo方法,可以看到这里没有vtable。所以对结构体来说,在编译链接之后,函数的地址就已经确定了
那么在extension里面的函数是怎么样的呢?
extension LGTeacher {
func teach3() {
print("teach3")
}
}
看到了这里extension也是静态派发。
那么类的extension是什么派发呢?将struct改成class后运行发现也是静态派发。
那么为什么类里面的extension也是静态派发呢?猜测一下原因可能是这样的:创建类的时候,Vtable的大小已经确认好了,那么extension里面的方法需要插入到之前创建Vtable里面,那么Vtable的大小就不够了,并且这样的操作也比较消耗性能,所以这里extension就使用静态派发了。并且如果这个类作为其他类的父类的话,那么子类就需要记住父类VTable的位置,然后插入到父类的VTable中,这样的操作是比较复杂的,这也是使用静态派发的原因之一。
方法调度方式总结:
3. 影响函数派发方式
- final: 添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,且 对 objc 运行时不可⻅。在实际开发中不需要被重载的属性和方法可以添加final关键字。
final func teach() {
print("teach")
}
-
dynamic: 函数均可添加 dynamic 关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是原来的派发方式。动态性是可以对函数进行替换。
下面的例子中,调用teach就变成了调用teach3.
-
@objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
@objc func teach() {
print("teach")
}
- @objc + dynamic: 消息派发的方式,这样就可以使用method——swizzling
@objc dynamic func teach() {
print("teach")
}
4. 函数内联
函数内联 是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
- 将确保有时内联函数。这是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内
联函数作为优化。 - always - 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
- never - 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
- 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))
在项目中或多或少都会接触到,看到这里的优化等级(Optimization Level)。这里面OC的是Cland,swift的是 Swift Compiler。
对于函数sum来说
int sum(int a, int b) {
return a+b;
}
如果优化等级是None,那么这里就会调用函数。
如果优化等级是Smallest,Fastest,那么这里就会使用方法的内容替换直接调用该方法,从而优化性能。
如果对象只在声明的文件中可⻅,可以用 private 或 fileprivate 进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得 对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private : 定义的声明 中访问)
将Swift compiler 的优化等级改为for speed 后创建一个LGPerson类,添加下面方法和属性。
class LGPerson {
private var sex: Bool
private func updateSex() {
self.sex = !self.sex
}
init (innerSex: Bool){
self.sex = innerSex
}
func test(){
self.updateSex()
}
}
let t = LGPerson(innerSex: true)
t.test()
这个时候打下断点后运行,发现没有断住。这是因为在调用test的时候,发现没有意义,只是调用updateSex,所以把test符号优化掉了。而检查了LGPerson,发现没有继承,那么在默认就为updateSex添加 final 的标识,所以updateSex也被优化了。