Xcodo Version 12.5.1
iOS 14.2
Protocol Oriented Programming
之前分享过C++动态多态的完成,在C++中经过虚函数表的办法完成运行时多态。在Swift中承继目标的动态多态也是类似的完成,经过V-Table的办法完成运行时多态。
在Swift中咱们一般经过协议进行行为的抽象,这也是我经常提起的面向协议编程。经过定义Protocol的办法来束缚类型遵从某一些规则和完成一些办法,然后在运行时调用各类型自己完成版别的办法来到达运行时多态。
Protocol Witness Table
如下咱们定义了一个Drawable
的协议,里边只需一个draw
办法。
protocol Drawable {
func draw()
}
然后咱们定义了Point
和Line
类型并遵守Drawable
协议
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
办法。
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的履行。
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作为一种类型来运用,例如作为数组元素类型的声明或者是函数的形参类型声明:
let shapes: [Drawable] = [point, line]
func drawShape(_ shape: Drawable) {
shape.draw()
}
咱们知道数组寄存的元素所占的空间巨细是固定的,但咱们寄存的实在类型巨细却是不相同的,这个状况编译器会怎样处理呢?首要咱们看下Drawable
类型的巨细:
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
类型为例:
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
类型为例:
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完成的原理,下面从汇编角度去看看详细的完成细节(毕竟代码不会骗咱们),从三行代码来剖析详细的汇编逻辑:
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
的差异点,也是对应三行代码逻辑:
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目标的流程:
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合作泛型时编译器会怎样处理,剖析一下比如:
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),让断点能收效,终究生成的汇编代码只需简略的几条指令:
References
Whole-Module Optimization
swift witness table
Understanding Swift Performance