类与结构体方法上的区别
一. 异变方法
- Swift中类(class)和结构体(struct)都能定义方法.但是有一点区别的是默认情况下,值类型属性不能被自身的实例方法修改.
struct Point {
var x = 0.0, y = 0.0
func moveBy(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
-
上面代码编译会报错, 报错原因是:Left side of mutating operator isn't mutable: 'self' is immutable
在moveBy()函数中修改x,y, 其实就是修改self.x 和 self.y, 而此时的self是结构体,属于值类型, self里面存的就是x,y的值.此时修改x,y,即使不加self.来调用x,y, 其实就是在修改self本身. 因为当前的self指向的就是x,y. 此时就是在自己的方法里修改自己,
var p = Point() p.moveBy(x: , y: )如上代码, 创建实例变量p, 当p调用了moveBy()函数的时候, 相当于p把自己给修改了! 因为moveBy()函数内部的修改,影响了外部的p.这是不被允许的.
对于最上面的代码,在func关键字之前加 mutating 修饰符, 编译就不会报错, 也就是可以在函数内部对值属性进行修改.
struct Point {
var x = 0.0, y = 0.0
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
- 下面通过方法举例说明方法前加mutating和不加的区别
struct Point {
var x = 0.0, y = 0.0
func test() {
let tmp = self.x
}
mutating func moveBy(x deltaX: Double, y deltaY: Double) {
x += deltaX
y += deltaY
}
}
通过把该代码编译成SIL的语句:
WechatIMG9.jpeg/** * 参数: self,类型为Point类型 * 对比过SIL官方文档可以了解到,此时的self,也就是Point接受的是一个结构体的实例,也就是值. */ sil hidden @$s4main5PointV4testyyF : $@convention(method) (Point) -> () // test() 有一个参数self, 类型为Point类型 /** * test()函数中的第一行代码,声明了self的类型为let * 也就是此时此刻当前Point的值. * let self = Point */ debug_value %0 : $Point, let, name " ", argno 1 // id: %1 // ===================================================================================== /** * 第一个参数:x * 第二个参数:y * 第三个参数:也是self, 相比较上面的self多了一个关键字@inout, 看过SIL官方文档对inout的解释可知,inout Point(默认的参数)接收的是一个地址.而上面的Point接收的是一个结构体的实例,也就是self,也就是值. */ sil hidden @$s4main5PointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, > @inout Point) // 第一个参数是x,第二个参数是y, 第三个参数是self,多了inout的关键字! /** * var self = &Point */ debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5 //SIL 官方文档对inout的解释:
- An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)
// 通过代码说明:对于没有加mutating的self其实就是值 // 而加了mutating的self就是当前值的地址,也就是指针.添加了mutating之后,SIL就多了一个inout的关键字,而这个关键字的目的就是传入的是当前值的地址.传地址也就意味着此时此刻修改self是合法的. struct Point { var x = 0.0, y = 0.0 func test() { let tmp = self.x } mutating func moveBy(x deltaX: Double, y deltaY: Double) { x += deltaX y += deltaY } } var p = Point() let x1 = p // x1 是let修饰 , 无法修改 var x2 = withUnsafePointer(to: &p){return $0} // 取地址方法 var x3 = p// x3 和 p 是两个独立的结构体实例,互不影响,所以修改了p.x对x3没有影响,x3.x = 0.0 p.x = 30.0// 此时对p.x进行修改 print(x1.x) print(x2.pointee.x) // x2是p的指针 要通过这种方式打印值 print(x3.x) // 此时控制台输出 0.0 30.0 0.0重要结论:
- 异变方法的本质: 对于异变方法,传入的 self 被标记为 inout 参数. 无论在 mutating 方法内部发生什么,都会影响外部依赖类型的一切.(相当于直接把当前的self以地址的方式传入函数内部,所以修改self.x对于外部变量是能产生影响的)
- inout : 输入输出参数 , 如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数.在形式参数定义开始的时候再前边添加一个inout 关键字可以定义一个输入输出形式参数.
var age = 10 // 函数的形式参数都是let类型的 age是let 类型 所以age+=1 报错Left side of mutating operator isn't mutable: 'age' is a 'let' constant func modifyAge(_ age: Int) { age += 1 } // 如果想在函数内部修改age, 并且在函数外部产生影响, 需要添加 inout 关键字 func modifyAge(_ age: inout Int) { age += 1 } // 函数调用, 注意此时就要传入age的地址 modifyAge(&age)同理, 在SIL中实现为下图:
WechatIMG10.jpeg
二.方法调度
对于OC来说都是通过objc_msgSend来进行消息发送来调度方法.
项目跑真机就是arm64的汇编.
常见汇编指令:
-
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) 中
汇编中,判断方法的调用一般就是看bl,blr这两个指令. 这是一个小技巧
class Teacher {
func teach() {
print("teach")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Teacher()
t.teach()
}
}
// Xcode --> DEBUG --> DEBUG WorkFlow --> Always Show Disassembly 可以查看汇编过程代码

通过上图猜测, teach函数就在blr x8 这句代码上调用!因为在_allocating_init() 和 release 中 没有其他的bl或者blr了.
验证: 先在 blr x8 打一个断点 , 然后按住ctrl , 点击 step into.

可以看到, 正是teach()函数的调用.
探究swift中方法的调用, 首先对Teacher类中的函数进行扩充,如下:
class Teacher {
func teach() {
print("teach")
}
func teach1() {
print("teach1")
}
func teach2() {
print("teach2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = Teacher()
t.teach()
t.teach1()
t.teach2()
}
}
查看汇编代码如下:

x8汇编代码里面,我们称之为寄存器.函数调用的参数,包括一些运算都是放在寄存器中完成的.
分析:
Teacher.__allocating_init()返回的是实例对象
函数的返回值是放在 x0 这个寄存器中的, 所以 x0 中放的就是allocating_init()生成的对象.
例: ldr x8, [x0] 取x0这个地址里面的值放入寄存器x8中. 取地址取的是x0这个对象的前8个字节.因为寄存器是64位的,存放8个字节.x0地址的第一个8字节: metadata!(isa也是一样的).所以此时此刻,x8寄存器存放的实际上是metadata.
此时键入 register read x8 可以读出寄存器 x8 的内容, 如下图:

ldr x8, [x8, #0x50] 的意思就是: metadataAddress + 0x50 , 所得到的结果再赋值给 x8 寄存器.
总结:teach函数的调用过程, step1.找到Metadata,step2.确定函数地址(metadata + 偏移量),step3.执行函数.

由上图,可知swift中,函数的第一种调用方式,就是基于函数表的调度.
通过查看文件SIL

vtable就是所谓的函数表.这个函数表里面罗列了当前Teacher都有哪些函数.所以在SIL中, 它的表现形式就是sil_vtable.这个sil_vtable就是每个类自己的函数表.
之前在类与结构体(上)中讲到了MetaData的数据结构,那么,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 code源码中可以从Metadata文件中找到关于Descriptor的类型:

从源码可以得到TargetClassDescriptor的数据结构:
struct TargetClassDescriptor{
var flags: UInt32
var parent: UInt32
var name: Int32 // name即为当前类/结构体/Enum的名称
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
}
TargetClassDescriptor 有个别名叫: ClassDescriptor

源码中全局搜索ClassDescriptor,可以看到

打开该文件, 可以定位到一个类 ClassContextDescriptorBuilder:

ClassContextDescriptorBuilder:就是Metadata和Descriptor的创建者.
接下来看layout()方法

首先调用了 super.layout() , 按照继承关系找到父类,下图可以看到父类:

点击进入父类, 可以看到父类中的layout:

上图中的各种创建与TargetClassDescriptor的数据结构比较即可知就是在创建name,Descriptor,Metadata等.
再回头看子类ClassContextDescriptorBuilder.在layout()方法中,有一个addVTable();VTable();
分析addVTable()函数:

上图中的B其实就是TargetClassDescriptor.所以如果我们把TargetClassDescriptor数据结构补全, 就是:
struct TargetClassDescriptor{
var flags: UInt32
var parent: UInt32
var name: Int32 // name即为当前类/结构体/Enum的名称
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 (methods)
}
验证上面的结论:(此时需要引入 Mach-O 文件)
Mach-O:Mach-O其实是Mach Object文件格式的缩写,是macOS以及iOS上可执行文件的格式,类似于Windows上的PE格式(Portable Executable),linux 上的elf格式(Executable and Linking Format).常见的.o, .a, .dylib, Framework, dyld, .dsym.
Mach-O文件格式:

首先是头文件,表明该文件是
Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排.-
Load commands 是一张包含很多内容的表. 内容包括区域的位置,符号表,动态符号表等.WechatIMG32.jpeg
-
Data区主要就是负责代码和数据记录的.Mach-O 是以 Segment 这种结构来组织数据的,一个 Segment 可以包含0个或多个 Section. 根据 Segment 是映射的哪一个 Load Command, Segment 中 section 就可以解读为是代码, 常量或者一些其他的数据类型. 在装载在内存中时, 也是根据 Segment 做内存映射的.
利用MachOView软件打开项目的可执行文件,可以看到如下图:
WechatIMG33.jpeg对于swift, 上图中的 __TEXT,__swift5_types存放的就是(结构体,Enum,或者类)的TargetClassDescriptor. types存放的就是TargetClassDescriptor的地址信息. 右边的地址信息是以每4个字节作为一个区分.
0xFFFFFBA4 + BBCC = 0x10000B770
0x10000B770 - 0x100000000 = 0xB770 // 就是当前TargetClassDescriptor在整个Data数据区的内存地址.在内存中的偏移量.
下图中,在 TEXT,const 中找到0xB770的地址:

解析:0xB770即为TargetClassDescriptor的地址, 它的数据结构应该月TargetClassDescriptor的数据结构相对应,其实它就是TargetClassDescriptor. 在size之前前面有包括 flags , parent, name, 等12个4字节的成员变量. 所以从 [50 00 00 80] 开始数12个4字节, 即[04 00 00 00]就是size的内存地址, [10 00 00 00 0C C0 FF FF]就是Teacher类第一个teach()函数的地址,紧接着就是teach1(), teach2()的地址.
已知函数在Mach-O的偏移量是0xB7A0, 我们如何找到函数在运行内存中的地址呢 ?
答 : 加上 ASLR (随机偏移地址)
那么什么是 ASLR 呢?
解析: 运行程序, 在t.teach()函数调用出断点, 程序走到断点处, 在控制台用命令 image list命令,列出当前运行程序的模块.只需要找到它最开始的模块

0x0000000102698000 即程序运行的基地址,可以理解为当前SwiftMethod程序加载进内存的起始地址就是0x0000000102698000.
此时 函数的基地址 + 函数地址的偏移量 就是 函数teach(TargetMethodDescriptor数据结构)的首地址.
0x0000000102698000 + 0xB7A0 = 0x1026A37A0
0x1026A37A0 地址 指向的就是teach()函数. 那么,swift中, 函数的数据结构是什么呢? 看下图:
0x1026A37A0 就是指向 teach(TargetMethodDescriptor) 数据结构的首地址.所以,如果要找到 Impl 还需要进一步偏移, 偏移Flags所占的内存大小. 这个Flags其实就是一个4字节. 注意 TargetRelativeDirectPointer 存储的不是真正的Imp, 它存储的其实是相对指针, 存储的是一个offset . 所以 首地址+flags(4字节)+offset 最终,减去虚拟的基地址, 得到的就是当前函数的地址.
0x1026A37A0 + 4 + 0xFFFFC00C = 0x20269F7B0
得到0x20269F7B0 这个地址, 还需要减去 0x100000000 = 0x10269F7B0
0x10269F7B0 即为当前teach()函数的地址!
方法调度方式总结:

三.影响函数派发方式
- final: 添加了 final 关键字的函数无法被重写, 使用静态派发, 不会在vtable中出现, 且对objc运行时不可见. 如果在实际开发过程中, 属性,方法, 类不需要被重载的时候, 可以添加final关键字.
- dynamic: 函数均可添加 dynamic 关键字, 为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发.
- @objc: 该关键字可以将swift函数暴露给Objc运行时, 依旧是函数表派发.
- @objc + dynamic: 消息派发的方式.
四.函数内联
函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用方法,从而优化性能.
- 将确保有时内联函数.这是默认行为,我们无需执行任何操作.Swift编译器可能会自动内联函数作为优化.
- always - 将确保始终内联函数.通过在函数前添加 @inline(_always)来实现此行为
- never - 将确保永远不会内联函数. 这可以通过在函数前添加 @inline(never)来实现.
- 如果函数很长并且想避免增加代码段大小,请使用 @inline(never) (使用@inline(never))



