Xcodo Version 12.5.1
iOS 14.2
Protocol Oriented Programming
之前分享过C++动态多态的实现,在C++中通过虚函数表的方式实现运行时多态。在Swift中继承对象的动态多态也是类似的实现,通过V-Table的方式实现运行时多态。
在Swift中我们一般通过协议进行行为的抽象,这也是我经常提起的面向协议编程。通过定义Protocol的形式来约束类型遵循某一些规则和实现一些方法,然后在运行时调用各类型自己实现版本的方法来达到运行时多态。
Protocol Witness Table
如下我们定义了一个Drawable
的协议,里面只有一个draw
方法。
swift
protocol Drawable {
func draw()
}
然后我们定义了Point
和Line
类型并遵守Drawable
协议
swift
struct Point: Drawable {
let x: Int
let y: Int
func draw() {
print("Point draw")
}
}
struct Line: Drawable {
let x1, y1: Int
let x2, y2: Int
func draw() {
print("Line draw")
}
}
构造Point
和Line
的实例并将类型声明为Drawable
,最后调用draw
方法。
less
let point: Drawable = Point(
x: 3, y: 3
)
let line: Drawable = Line(
x1: 2, y1: 2,
x2: 6, y2: 6
)
point.draw() // Point draw
line.draw() // Line draw
可以看到在运行时根据真实的类型去调用了对应类型实现版本的draw
方法。那对于Protocol的运行时多态又是如何实现的呢?是否又是通过Table的方式实现运行时查找和调用?这个不妨从汇编角度去看看编译器是如何做的。
首先我们只看point的执行。
arduino
let point: Drawable = Point(
x: 3, y: 3
)
point.draw() // Point draw
对应汇编如下:
这里我们只要关注bl
这个函数调整指令,但是这里有好几个bl
相关的指令,但是运行时多态肯定不是跳转写死的地址,所以我们只需关注blr x10
的指令。然后我们打印一下当前上下文使用到的寄存器的信息,发现x8
存放的是一个 protocol witness table for Point
的东西,而x10
是通过x8
+offset获取了protocol witness for Drawable.draw() -> () in conformance Point
的函数地址,最终通过blr x10
的指令跳转并执行。
这里还打印x20
的内容,发现存放的是指向Point
的指针,并作为隐式参数(self)传递到draw
方法中执行。
所以在Swift中Protocol通过查表的方式来实现运行时多态,而这个表的名字Protocol Witness Table(简称PWT)。
Existential Container
现在我们思考一个问题,平时我们会把Protocol当做一种类型来使用,例如作为数组元素类型的声明或者是函数的形参类型声明:
swift
let shapes: [Drawable] = [point, line]
func drawShape(_ shape: Drawable) {
shape.draw()
}
我们知道数组存放的元素所占的空间大小是固定的,但我们存放的真实类型大小却是不相同的,这个情况编译器会怎么处理呢?首先我们看下Drawable
类型的大小:
arduino
MemoryLayout<Drawable>.size // 40 bytes
发现Drawable
类型的大小是40字节,显然编译器处理后存放在数组中的元素并非原来的元素,而是一个中间类型,并且这个中间类型用来处理所有Protocol作为类型时的包装,苹果官方把这个中间类型叫做Existential Container,大概长这样子:
首先来了解下每个字段的含义和作用:
-
ValueBuffer
- 用于存储真实类型对象的容器。如果类型大小不超过24bytes,则直接存储在ValueBuffer上;如果类型大于24bytes,则把对象存放到堆上,并将指针存储在ValueBuffer上。
-
MetaData
- 用于存储真实类型元信息的指针。通过MetaData信息可以知道真实类型对象应该直接存储在ValueBuffer上还是存放到堆上;并且通过MetaData找到真实类型的Value Witness Table, 用于管理对象在ValueBuffer 上的生命周期。
-
ProtocolWitnessTable
- 用于存放真实类型对应协议表的地址。
通过Existential Container就可以让实现了Protocol的不同大小的类型变成相同大小的中间类型,让Protocol类型可以做到存储和传参。
Value Witness Table
既然刚才提到了不同类型数据存放在Existential Container 的Value Buffer
上的方式不一样,那么就需要一个东西去管理这块内存的创建方式,在Swift中这个东西叫做Value Witness Table (简称VWT),用来管理对象在Value Buffer
上的创建、拷贝和销毁:
这是官方给出的VWT的结构图,很好的说明了是用来管理对象的生命周期, 而VWT则是通过Existential Container 中的MetaType
间接获取的,而最后获取的VWT在内存结构上跟官方给出的有些许不同,但作用都是一致的,大概长这样子:
对于内存中的VWT结构主要关心两个字段:
-
copy
- 用于在拷贝Existential Container 时,决定
ValueBuffer
的内容拷贝的方法地址。 - 如果在对象直接存放在
ValueBuffer
上,则改copy方法为__swift_memcpy
,将内容直接拷贝到新的ValueBuffer
上。 - 如果在对象存放在堆上,则改copy方法为
initializeBufferWithCopyOfBuffer
,将新拷贝的指针存放ValueBuffer
上。
- 用于在拷贝Existential Container 时,决定
-
flag
- 这个flag值决定了对象如何存放在
ValueBuffer
上的标志,在对象类型大小不超过24bytes时为0x7,而超过24bytes时则为0x20007。
- 这个flag值决定了对象如何存放在
然后我们看看这两种情况的Existential Container 的结构图,以Point
类型为例:
ini
let point: Drawable = Point(x: 3, y: 3)
let point2: Drawable = point
因为Point
大小不超过24bytes,而且是个struct
结构,所以Existential Container 创建时Point
的x和y直接存放在ValueBuffer
栈 上。如果这时候拷贝了Existential Container ,对于新的Existential Container 的**ValueBuffer
**通过MetaData
找到VWT后获取copy
方法后把Point
内容拷贝过去,而VWT里存放的copy
方法的地址是__swift_memcpy
的地址,对于MetaData
和PWT则使用同一个引用。
然后以Line
类型为例:
yaml
let line: Drawable = Line(
x1: 2, y1: 2,
x2: 6, y2: 6
)
let line2: Drawable = line
由于Line
类型超过24bytes,即使是struct
结构编译器还是会在堆上创建一个Class
结构的Line
对象,和其他Class
结构一样会多两个字段Type
和RefCount
,最后才是存放Line
的内容,而ValueBuffer
存放的是指向这个堆对象的指针。如果这时候拷贝Existential Container ,VWT里存放的copy
方法的地址是initializeBufferWithCopyOfBuffer
的地址,只是简单的将堆对象引用加一,然后把新指针存放到新的ValueBuffer
上,对于MetaData
和PWT也是使用同一个引用。
从拷贝方式可以看出,苹果为了性能考虑,避免拷贝时频繁创建新的对象,所以才把堆对象变成一个Class
结构,但同样不丢失struct
结构Copy-on-Write的特性,会在修改内容时通过isKnownUniquelyReferenced
方法判断引用数来决定是否产生拷贝。
到这里我们可以思考下Swift中的Any是怎么实现?是否会创建一个AnyContainer的中间类型
汇编分析流程
通过上面的介绍大家基本能够了解Swift中Protocol实现的原理,下面从汇编角度去看看具体的实现细节(毕竟代码不会骗我们),从三行代码来分析具体的汇编逻辑:
arduino
let point = Point(x: 3, y: 3) // 创建Point对象
let shape: Drawable = point // 转成Drawable对象
shape.draw() // 调用draw方法
- 创建
Point
对象
这里看到的是Point
初始化逻辑,x0
、x1
是内容,而x10
则是MetaData
的地址。
- 创建
Drawable
对象,并获取真实对象的地址
这里我停在了跳转命令上,这里是跳转到__swift_project_boxed_opaque_existential_1
的函数地址,因为是写死的地址,表明这是个通用的函数,所有的Protocol类型都会调用这个函数。
跳转前从控制台打印下上面使用过的寄存器信息,可以发现MetaData
和PWT的地址,sp
存的是当前栈底指针,x0
和x1
可能用于传参,而x0
的地址跟sp
的地址很接近,那也是在栈上。
打印一下x0
地址里40bytes的内容,可以发现就是Existential Container 的内容,前24bytes存了Point
的内容(第三个8字节有值是因为内存未初始化),然后是MetaData
的地址,最后是PWT的地址。
简单说明下__swift_project_boxed_opaque_existential_1
的作用是,从Existential Container 中获取真实对象地址的一个通用函数,具体看看怎么做的:
首先看到x0
存放了Existential Container 的地址,x1
是MetaData
的地址,通过MetaData
地址加偏移找到了FullMetaData
地址,最终找到VWT地址。从VWT地址加偏移找到了flag的值(就是上面说到决定ValueBuffer
存储方式的flag),最后比较第0x11位是否为0知道了对象是直接存放在ValueBuffer
上,所以Existential Container 的地址就是对象地址,就直接返回了x0
。
- 调用
draw
方法
这里最终通过PWT调用了对应的函数,并把真实对象通过x20
进行传参(Existential Container的地址就是对象地址)。
- 销毁
Drawable
对象
最后通过__swift_destroy_boxed_opaque_existential_1
函数进行Existential Container 的内存释放。这个函数跟__swift_project_boxed_opaque_existential_1
函数的逻辑很相似,都是获取VWT对应的flag值,发现对象是在ValueBuffer
上直接回收栈内存就好了。这个调用逻辑也就结束了。
把整个调用变成一个流程图如下:
对于通过_swift_project_boxed_opaque_existential_1
获取对象地址的流程图如下:
对于通过_swift_project_boxed_opaque_existential_1
销毁对象地址的流程图如下:
之前用Xcode12.2是通过两个汇编指令判断Flag,先与操作再判断是否为0:
and w10 flag, #0x20000
cbnz w0, xxxxxx
到了Xcode12.5.1就变成一个指令,判断某一位是否为0:
tbnz w0, 0x11
可以看到苹果在性能方面很符合追求极致(狗头
接着我们来看看Line
类型的汇编流程,看跟Point
的差异点,也是对应三行代码逻辑:
less
let line = Line(
x1: 2, y1: 2,
x2: 6, y2: 6
) // 创建Line对象
let shape: Drawable = line // 转成Drawable对象
shape.draw() // 调用draw方法
- 创建
Drawable
对象,并获取真实对象的地址
通过汇编可以发现Line
初始化完后会调用swift_allocObject
函数创建一个堆对象,这也是Line
在堆上的副本。
通过控制台打印相关寄存器和内存的信息,x0
就是Existential Container 的地址,第一个8字节就是指向堆上Line
对象的指针,在堆上的Line
对象是一个Class
结构。
通过PWT调用对应的方法时需要传入真实对象,所以这里还是通过__swift_project_boxed_opaque_existential_1
这个通用函数去获取
这里同样还是通过VWT去获取flag值,发现是0x20007,然后判断第0x11位不为0,跑到下面部分去读取指针地址,从而获取到真实对象的地址,并且再偏移16个字节跳过Class
结构中的Type
和RefCount
字段。
- 销毁
Drawable
对象
__swift_destroy_boxed_opaque_existential_1
函数同样是获取VWT的flag值后,知道需要去回收在堆上的Line
对象
把整个调用变成一个流程图如下:
对于通过_swift_project_boxed_opaque_existential_1
获取对象地址的流程图如下:
对于通过_swift_project_boxed_opaque_existential_1
销毁对象地址的流程图如下:
最后再来看看拷贝一个Protocol对象的流程:
csharp
let point = Point(x: 3, y: 3) // 创建Point对象
let shape1: Drawable = point // 转成Drawable对象
let shape2: Drawable = shape1 // 拷贝Drawable对象
这里x0
就是Existential Container 的地址,通过init with copy of Drawable
函数来拷贝。
进入函数后可以发现是找存储在VWT上的第一函数地址去执行拷贝的,因为Point
是在栈上,所以对应的copy函数就是__swift_memcpy
,对应的Line
是需要放在堆上的,所以对应Line
的VWT的copy函数就是initializeBufferWithCopyOfBuffer
,这个大家自己去验证好了。
Copy调用的流程图如下:
Generic Type
上面我们了解到Protocol的工作原理,接下来我们来看看Protocol配合泛型时编译器会怎么处理,分析一下例子:
swift
protocol Drawable {
func draw()
}
struct Point: Drawable {
let x: Int
let y: Int
func draw() {
add()
}
@inline(never)
func add() {
let z = x + y
print(z)
}
}
func drawShape<T: Drawable>(_ shape: T) {
shape.draw()
}
@inline(never)
func main() {
let point = Point(x: 3, y: 3)
drawShape(point)
}
这里我们声明了一个带泛型的drawShape<T: Drawable>
函数,创建Point
并调用,看下具体的汇编代码:
这里发现跟前面的汇编代码不太一样,这里并没有生成Existential Container。主要原因是泛型函数属于编译期多态,编译时如果编译器有足够信息能推导出真实类型,那就会进行代码优化,直接调用对应类型的PWT函数,所以通过泛型的方式可以减少内存的开销和减少调用的指令,但同时泛型特化可能会伴随代码体积的增大的风险。
当然编译器还能对代码进行更进一步的优化,当我们把编译优化改-Osize时会有更好的收益,例如函数内联:
为了能看到优化效果,把main
和add
函数都标记了@inline(never),让断点能生效,最后生成的汇编代码只有简单的几条指令: