理解 Swift 中的方法派发机制 - 静态派发

1,056 阅读3分钟

方法派发

在计算机领域中,有两种类型的方法派发方式,并且它们有着明显的区别:

  • 静态派发(Static dispatch):速度快不灵活。
  • 动态派发(Dynamic dispatch):速度慢但更加灵活。

这两大类还能根据速度与灵活性的不同,细分成下面的四小类:

  • 内联方法:速度最快,灵活性最差。
  • 静态派发
  • 表派发
  • 消息派发:速度最慢,灵活性最好。

这种层次结构是由间接层次决定的。通俗地说,这意味着“找到并执行一个函数所需的跳转次数”:

  • 内联方法:无需跳转。
  • 静态派发:只需跳转一次即可找到执行函数。
  • 表派发:需要跳转两次,一次跳转到函数指针表,一次是跳转到函数本身。
  • 消息派发:根据代码的数据结构可能会跳转很多次。

大多数语言仅支持上述的某几种方法派发,而 Swift 则支持上述所有的方法派发方式。这是一把双刃剑:它使开发者能够对其代码的性能特征进行细粒度的控制;但如果使用不当,也会导致许多问题。

静态派发

内联方法

这是最快的方法派发机制,实际上它也算不上是方法派发。内联是一种编译器优化,它实际上用函数中的代码替换函数的调用点。

一般来说,我们无法控制这一点:Swift 编译器在其优化阶段做出有关内联函数调用的决定。

代码示例如下:

func addOne(to num: Int) -> Int {
    return num + 1
}

let twoPlusOne = addOne(to: 2)

如果编译器决定内联它,编译后的 Swift 可能相当于这样:

let twoPlusOne = 2 + 1

由于上面示例使用硬编码数字,因此编译器实际上拥有在编译时计算 addOne 结果所需的所有信息。这意味着编译器可以执行进一步的优化:返回值可以预先计算,优化完代码如下:

let twoPlusOne = 3

预计算是最终的优化,因为我们此时甚至没有执行代码。也就是说,我们的函数调用的结果在编译时就已知,因此在运行此段代码时,用户的设备实际上不需要进行任何工作。

Swift Intermediate Language

在将代码编译为机器语言之前,Swift 编译器会将其转换为 Swift 中间语言 (SIL),并在其中运行许多优化过程。

下面这些神秘的象形文字让我们能够亲眼看到优化:

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 程序运行时,这些函数编译后的机器代码存储在内存中的已知地址处。

静态派发的这种确定性使得编译器能够运行内联和预计算等优化。

比如下面的示例代码:

struct Adder {
    func addOne(to int: Int) -> Int {
        return int + 1
    }
}

let threePlusOne = Adder().addOne(to: 3)

让我们为这段代码生成 Swift 中间语言,看看编译器的底层发生了什么:

// 此代码为简化后的代码
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,并传递之前创建的参数:IntAdder

SIL 的调用与 Python 的调用非常相似,其中 self(实例)显式传递到其方法的调用站点。这是因为类型上的方法在内存中的所有实例之间共享。因此,需要对实例的引用才能访问或更改任何属性。

Swift 编译器内联地折叠整个静态派发函数调用链,以一次性消除许多昂贵的函数调用。这就是静态派发速度快的原因。