方法派发
在计算机领域中,有两种类型的方法派发方式,并且它们有着明显的区别:
- 静态派发(Static dispatch):速度快不灵活。
- 动态派发(Dynamic dispatch):速度慢但更加灵活。
这两大类还能根据速度与灵活性的不同,细分成下面的四小类:
- 内联方法:速度最快,灵活性最差。
- 静态派发
- 表派发
- 消息派发:速度最慢,灵活性最好。
这种层次结构是由间接层次决定的。通俗地说,这意味着"找到并执行一个函数所需的跳转次数":
- 内联方法:无需跳转。
- 静态派发:只需跳转一次即可找到执行函数。
- 表派发:需要跳转两次,一次跳转到函数指针表,一次是跳转到函数本身。
- 消息派发:根据代码的数据结构可能会跳转很多次。
大多数语言仅支持上述的某几种方法派发,而 Swift 则支持上述所有的方法派发方式。这是一把双刃剑:它使开发者能够对其代码的性能特征进行细粒度的控制;但如果使用不当,也会导致许多问题。
静态派发
内联方法
这是最快的方法派发机制,实际上它也算不上是方法派发。内联是一种编译器优化,它实际上用函数中的代码替换函数的调用点。
一般来说,我们无法控制这一点:Swift 编译器在其优化阶段做出有关内联函数调用的决定。
代码示例如下:
swift
func addOne(to num: Int) -> Int {
return num + 1
}
let twoPlusOne = addOne(to: 2)
如果编译器决定内联它,编译后的 Swift 可能相当于这样:
ini
let twoPlusOne = 2 + 1
由于上面示例使用硬编码数字,因此编译器实际上拥有在编译时计算 addOne
结果所需的所有信息。这意味着编译器可以执行进一步的优化:返回值可以预先计算,优化完代码如下:
ini
let twoPlusOne = 3
预计算是最终的优化,因为我们此时甚至没有执行代码。也就是说,我们的函数调用的结果在编译时就已知,因此在运行此段代码时,用户的设备实际上不需要进行任何工作。
Swift Intermediate Language
在将代码编译为机器语言之前,Swift 编译器会将其转换为 Swift 中间语言 (SIL),并在其中运行许多优化过程。
下面这些神秘的象形文字让我们能够亲眼看到优化:
perl
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
// 1
alloc_global @$s4main10twoPlusOneSivp
// 2
%3 = global_addr @$s4main10twoPlusOneSivp
// 3
%4 = integer_literal $Builtin.Int64, 3
// 4
%5 = struct $Int (%4 : $Builtin.Int64)
// 5
store %5 to %3 : $*Int
// 6
%7 = integer_literal $Builtin.Int32, 0
%8 = struct $Int32 (%7 : $Builtin.Int32)
return %8 : $Int32
为了简洁起见,我省略了大部分代码,但我们可以看到内联的实际效果:
- 内存被分配给
twoPlusOne
属性。 - 分配
twoPlusOne
属性的指针地址。 - 这就是神奇的地方:3 的整数文字被预先计算并内联,完全避免了方法调用。
- 该值从标准库转换为
Int
结构体。 - 该
Int
存储在%3
处,即twoPlusOne
的内存地址。 - 通常会在
main()
函数的末尾看到这些行, 这只是以代码 0 退出程序。
如果你想亲自查看 SIL,可以使用命令 swiftc -emit-sil -O main.swift > sil.txt
进行转换。
-O
告诉编译器运行速度优化,其中包括内联。 -Osize
相反使编译器不太可能内联代码,因为在多个位置内联函数会增加二进制大小。
静态派发
Swift 中的静态函数以及枚举和结构上的函数始终使用静态派发。当 Swift 程序运行时,这些函数编译后的机器代码存储在内存中的已知地址处。
静态派发的这种确定性使得编译器能够运行内联和预计算等优化。
比如下面的示例代码:
swift
struct Adder {
func addOne(to int: Int) -> Int {
return int + 1
}
}
let threePlusOne = Adder().addOne(to: 3)
让我们为这段代码生成 Swift 中间语言,看看编译器的底层发生了什么:
kotlin
// 此代码为简化后的代码
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
// 1
alloc_global @$s4main12threePlusOneSivp
%3 = global_addr @$s4main12threePlusOneSivp : $*Int
// 2
%4 = metatype $@thin Adder.Type
%5 = function_ref @$s4main5AdderVACycfC : $@convention(method) (@thin Adder.Type) -> Adder
%6 = apply %5(%4) : $@convention(method) (@thin Adder.Type) -> Adder
// 3
%7 = integer_literal $Builtin.Int64, 3
%8 = struct $Int (%7 : $Builtin.Int64)
// 4
%9 = function_ref @$s4main5AdderV6addOne2toS2i_tF : $@convention(method) (Int, Adder) -> Int
%10 = apply %9(%8, %6) : $@convention(method) (Int, Adder) -> Int
store %10 to %3 : $*Int
- 给
ThreePlusOne
属性分配内存。 Adder
结构的init
函数被调用。apply
是用于调用函数的 SIL 指令,将 %4(类型)作为 %5(函数)的参数。- 接下来,函数参数的整数字面量即数字 3 被实例化。首先调用
Builtin Literal
,然后初始化Int
。 - 最后,
addOne
函数被调用;创建函数指针function_ref
,并传递之前创建的参数:Int
和Adder
。
SIL 的调用与 Python 的调用非常相似,其中 self
(实例)显式传递到其方法的调用站点。这是因为类型上的方法在内存中的所有实例之间共享。因此,需要对实例的引用才能访问或更改任何属性。
Swift 编译器内联地折叠整个静态派发函数调用链,以一次性消除许多昂贵的函数调用。这就是静态派发速度快的原因。