Bienvenue à tous pour prêter attention à mon compte public WeChat : Kobayashi Izakaya
TLDR
Dans Swift, l'utilisation de génériques sans qu'il soit nécessaire d'effacer le type se traduit par de meilleures performances d'exécution que de protocol
les .
protocol Foo { }
// ❌
struct Hello {
let foo: Foo
}
// ⭕️
struct World<T: Foo> {
let foo: T
}
复制代码
SE-0335
Swift 5.6 a publié un tas de fonctionnalités fraîches et chaudes, dont SE-0335 est la plus importante.
SE-0335 apporte un tout nouveau mot-clé any
(remarquez le Any
, AnyObject
):
protocol Foo { }
struct Hello {
let foo: any Foo
}
func foo(_: any Foo) { }
复制代码
Tous les types existentiels sont décorés avec any
ce mot-clé. Dans Swift 5.6, any
cela peut être utilisé, et protocol
les objets apparaîtront comme avertissement. Dans Swift 6.0, où (yao) ajoute (yao) à (wu) à (qi), ce any
sera obligatoire.
Du point de vue de l'implémentation de PR-40282 , des vérifications au moment de la compilation sont principalement ajoutées et aucun changement majeur n'a été trouvé après la compilation/l'exécution.
Les langues sont généralement itérées dans une direction plus concise et abstraite, mais le seul travail de cette proposition est de forcer un nouveau mot-clé dans l' protocol
objet . Si vous n'êtes pas saturé de travail, vous voulez toujours saturer !
Mais si vous regardez attentivement la Motivation de la proposition, elle détaille la motivation :
Le coût de l'utilisation de types existentiels ne doit pas être caché et les programmeurs doivent explicitement opter pour ces sémantiques.
Existential types 的运行时成本不可被忽略,通过显著提高直接使用 Existential types 的成本的方式,让开发者只有在必要时才使用。
Existential Type
Existential Type 在类型系统中,描述了一个抽象类型的接口。这个名称源于 Existential Quantification (存在量词, 数学符号 ∃ )这个概念。既存在一种特定的类型,但在上下文中并不能理解其的真正具体类型。例如:
T = ∃X { a: X; f: (X → int); }
复制代码
这表示了一种类型 T
,描述了一个模块接口,它表明了存在一种类型 X
,有一个名为a
的 X
类型的数据成员和一个名为 f
的函数,该函数接收一个相同类型的 X
的参数并返回一个整数。X
的具体类型可以有不同的实现,如
intT = { a: int; f: (int → int); }
floatT = { a: float; f: (float → int); }
复制代码
在 Swift 中,这种概念由 protocol
对象进行具像化(在一些别的语言中,对象被限定为 class
的一个实例,但 Swift 的对象概念非常广泛,可以是 struct
, enum
, class
, protocol
或者一个 closures
。这又是另外一个话题了)。
Swift 引入了 Existential Container, Protocol Witness Table, Value Witness Table 三种技术来解决 Reference type, Value type 的类型抹除和多态问题。
protocol runtime
当开发者存储或者传递一个 protocol
时,其遵循该 protocol
的特定真实类型在编译器时还无法确定,只有在运行时才能获取真实类型。那么我们需要一些额外的运行时手段来解决存储,调用约定函数,和复制销毁等行为。
内存布局
Swift 有两种形式的类型,值类型和引用类型。它们的内存布局是不一样的,一般来说,值类型会直接保存在栈上,引用类型则保存在堆上。假设有一个 protocol
,三个内存布局各不相同的对象:
protocol Drawable { func draw() }
struct Point : Drawable {
var x, y: Double
func draw() { ... }
}
struct Line : Drawable {
var x1, y1, x2, y2: Double
func draw() { ... }
}
class SharedLine : Drawable {
var x1, y1, x2, y2: Double
func draw() { ... }
}
复制代码
这三种类型内存布局分别是,2 * 8 字节的栈空间,4 * 8 字节的栈空间,8 字节栈空间和 (2 + 4) * 8 字节的堆空间。显然,protocol
对象在存储和传递时,无法直接使用类型本身的内存布局。
Swift 使用了 Existential Containers 来装载原本类型的 property 和其相关联的必要信息,以确保他们在运行时能有一个统一的内存布局。
Existential Container 是一个五个词大小的容器,前三个词为 value buffer. 第四个词存储真实类型所对应 Value Witness Table 的指针, 第五个词是真实类型遵循当前 protocol
的函数表 protocol
Witness Table 的指针
如果 protocol
的真实类型为值类型,且其足够小能被装进三个词大小的容器中,则其会被直接装载到 value buffer 中。
如果真实类型的大小超过三个词,或者真实类型是引用类型,则会在堆上开辟空间存储,并将指针存储到 value buffer 中。(示意图中忽略了堆对象的 0x0 和 0x8 偏移的内容)
伪代码如下:
protocol Foo { ... }
struct ExistentialContainer {
var valueBuffer: (Int, Int, Int)
var vwt: ValueWitnessTable
var pwt: ProtocolWitnessTable
init<T: Foo>(_ value: T) {
self.vwt = T.vwt
self.pwt = T.pwt
if value is class { // reference type
self.valueBuffer = (value.ptr, nil, nil)
}
if value is struct { // value type
if MemoryLayout<T>.size > 3 * MemoryLayout<Int>.size { // larger then 3 word
let newValue = heapAlloc(value)
self.valueBuffer = (value.ptr, nil, nil) // alloc heap object to storage large value
} else {
self.valueBuffer = value.copy() // copy the value to inline value buffer
}
}
}
}
复制代码
函数调用
一个遵从了特定 protocol
的类型需要包含其约定的所有函数,但由于无法在编译期确定类型,自然也无法确定所调用的函数和其地址。这与 non-final class 是类似的,因此,protocol
对象也采用了和 non-final class 的 v-table 相似的技术手段,来解决约定函数地址的问题。每一个函数的地址都会按次序被保存在一张表中,这被称为 protocol
Witness Table.
每个遵循了特定 protocol
的具体类型,都会根据 protocol
生成对应的 protocol
Witness Table. 对于同一个约定函数,它们的相对 protocol
Witness Table 开始地址的偏移都是一样的。那么对于调用方而言,真实类型可以不被关注。调用时获取 protocol
Witness Table,根据编译时的方法偏移量从表中取出方法的地址,跳到方法的地址执行。
这一段代码:
protocol Drawable { func draw() }
struct Point: Drawable {
func draw() { ... }
}
func foo(_ d: Drawable) {
d.draw()
}
foo(Point())
复制代码
其汇编的伪代码如下
_main:
push rbp
mov rbp, rsp
mov rdi, qword [_protocol_witness_table_for_Point]
call _foo
pop rbp
ret
_foo:
push rbp
mov rbp, rsp
call [rdi + 0x8] # rdi is Drawable `protocol` Witness Table for Foo
pop rbp
ret
_protocol_witness_table_for_Point:
dq 0x1230
dq 0x1238 # Point.draw() address
复制代码
实例管理
Existential Containers 采用 value buffer 的方式装载原类型,来抹平各个类型内存布局的不同。因此 Existential Containers 也需要去管理这些不同类型的生命周期,其采用了与 protocol
Witness Table 一样的跳表机制,称为 Value Witness Table. 每一个具体的类型都有一个 Value Witness Table, 它提供了如何布置和管理该类型的信息和相关函数的地址,如初始化,赋值,复制,销毁,大小等。具体存储的信息可以参见 ValueWitness.def.
假设我们有一个函数,其参数为一个 protocol
对象,并在其中对这个对象进行修改 (mutating),最后再消费这个对象。
protocol Changeable { mutating func change() }
struct Hello {
mutating func change() { ... }
}
func foo(_ value: Changeable) {
var copiedValue = value
copiedValue.change()
print(copiedValue)
}
复制代码
那么这会经历以下几个步骤。
- 在本地保存 Existential Container.
- 通过 Value Witness Table 获取原类型对应的大小,开辟临时栈空间。
- 通过 Value Witness Table 获取原类型的 copy 函数,复制一份原类型。
- 对复制后的对象进行修改。
- 消费复制后的对象。
- 销毁复制后的对象,并归还栈空间。
伪代码如下:
func foo(_ value: ExistentialContainer) {
// Stored existential container
let valueBuffer = value.valueBuffer
let vwt = value.vwt
let pwt = value.pwt
// alloc stack space for this functions
let size = value.vwt.size
addRSP(size)
// Keep a local copy
let initializeWithCopy = vwt.initializeWithCopy
var localValue = initializeWithCopy(valueBuffer)
// call change() function
let change = pwt.change
change(&localValue) // passed self as parameters
print(localValue)
// destroy localValue
let destroy = vwt.destroy
destroy(&localValue)
// move RSP to raw state
resetRSP()
}
复制代码
从上文伪代码中我们可以发现,在运行时直接使用 protocol
对象相比直接使用固定类型和静态函数而言,每一步都会有一个额外的消耗。但如果需要多态性,这些代价可能都是你所需要付出的。而且相比直接使用 class
来实现多态性,还省去了引用计数的管理。
泛型
那么,我们有没有一种方式,既拥有多态性,又能减少运行时的动态消耗呢?
可以通过泛型这种更静态的多态来解决运行时的损耗。
对于泛型函数或泛型结构,编译器会编译生成一个通用实现。与直接使用 protocol
对象相比,虽不再使用 Existential Container, 但其内存布局,函数调用和实例管理都十分类似,运行时的动态消耗和性能表现自然也是相似的,有一定的损耗。真正能减少运行时动态消耗的是编译器的泛型特化的优化。
对于下面的例子 foo(_:)
, Swift 编译时在当前 context 下将泛型 D
绑定至 foo 中
protocol Drawable { func draw() }
func foo<D: Drawable>(_ value: D)
struct Point: Drawable { }
foo(Point()) // Is foo<Point>(Point())
复制代码
当开发者去存储或者传递 protocol
对象时,总是以一个具体类型起始,如果根据这个 context,将整条调用链中所有未确定类型替换成当前具体类型,这便是泛型的类型调用链替换。
对于下面的例子 foo(_:)
:
protocol Drawable { func draw() }
func foo<D: Drawable>(_ value: D)
struct Point: Drawable { }
struct Line: Drawable { }
foo(Point())
foo(Line())
// generic specialization <Point> of foo<D>(_:D)
func foo(_ value: Point)
// generic specialization <Line> of foo<D>(_:D)
func foo(_ value: Line)
复制代码
除了生成通用版本的二进制外,还会生成入参为 Point
和 Line
特化版本的 foo(_:)
,并使用其来替代调用链中 foo(_:)
通用版本。此时 protocol
对象中内存布局,函数调用和实例管理这几个需要运行时决议的部分,已全部可以在编译时确定。
泛型特化后与 protocol
对象的运行时对比
泛型特化后的函数和结构,其优化行为与静态函数和固定结构一样。 这里以两个类型 Point
和 Line
为例子,对比泛型特化后与直接使用 protocol
对象。下列所有情况都基于 64 位系统。
protocol Drawable { func draw() }
struct Point: Drawable {
var x, y: Double
}
struct Line: Drawable {
var x1, y1, x2, y2: Double
}
复制代码
内存布局
以 A,B 两种类型为例子,都存储一个 Point 和一个 Line 实例:
struct A {
var first, second: Drawable
}
struct B<First: Drawable, Second: Drawable> {
var first: Frist
var second: Second
}
A(Point(), Line())
B(Point(), Line())
复制代码
A 在栈上存储了两个 Existential Container 和在堆上的一个 Line
空间,总共 2 * 5 * 8 + (2 + 4) * 8 字节。
B 直接在栈上存储了 Point 和 Line,总共 (2 + 4) * 8 字节。
在这个 case 下,内存结构的变化除了可以节省内存空间外,还可以通过减少单次复制成本和堆空间开辟次数来提高速度。
栈空间占用由原来 (2 * 5 * 8) 字节下降到了 (6 * 8) 字节, 堆空间原来 (6 * 8) 字节下降至 0 字节。单个结构体节省了 40% 的栈空间和 100% 的堆空间。
结构内存占用下降和无需开辟堆空间后,单次复制的成本显然会随之降低(在 Swift 中,值复制的操作十分常见,也可以通过 copy on write 来解决频繁复制的问题)。
在调用函数时,按照 Swift 的调用约定,结构体在小于一定大小的情况下,可以直接使用约定寄存器传递参数,而超过一定尺寸的结构体则需要开辟堆空间,传递一个指针作为参数。
函数调用/实例管理
上文提到,调用 protocol
对象的函数,需要在运行时进行查表动态派发。而被泛型特化的函数,由于有了更充分的 context,编译器直接会将上下文中使用动态派发调用的函数替换成静态派发的函数。
在汇编角度则是从
call [rbx + 0x8]
复制代码
转换成了
call _Line_Draw
复制代码
但单单从动态派发转换成静态派发,其性能提升是有限的(减少了一次查表的操作),性能提升的大部分,在于编译器对静态函数的优化。
动态派发的 protocol
对象函数,在编译期的优化是极其有限的,如内联等优化就几乎没有。泛型特化后的静态函数,编译器有了更多的 context 和机会对其进行优化,如裁减特化函数中的死分支(如在某种特定类型下确定不会执行的分支),删除原函数泛型通用版本,内联展开,完全展开后删除原函数等操作。
这也是使用泛型后,虽然单个函数特化出了多个不同的版本,但包体积可能没有较大变化的原因。
protocol
对象对实例的管理会需要依靠 Value Witness Table. 其优化方式与函数派发相近,实例生命周期的操作大部分情况下都会被内联展开,如值类型被回收时会直接移动栈顶指针,无需再调用 destory
函数。
总结
Swift is a Protocol-Oriented Programming Language.
Swift 使用 protocol
来解决了无继承或引用语义的多态性。由于其简洁隐蔽与其他类型完全一致的语法,开发者们非常容易在代码中直接存储或传递一个 protocol, 但它的成本并不是可忽略的。
La proposition SE- any
0335 met en protocol
évidence ce qui était auparavant caché dans le code au moyen de mots-clés, et résout le problème d' protocol
une trop . Ce que les développeurs doivent faire, en any
plus , peut également nécessiter plus de réflexion, s'ils peuvent utiliser plus de génériques pour remplacer les protocol
objets, et faire bon usage des mots- where
clés Limites pour réduire pertes d'exécution.