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

方法派发

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

  • 静态派发(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,并传递之前创建的参数:IntAdder

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

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

相关推荐
光影少年2 天前
Promise.all实现其中有一个接口失败其他结果正常返回,如何实现?
前端·promise·掘金·金石计划
光影少年3 天前
react16中的hooks的底层实现原理
前端·react.js·掘金·金石计划
光影少年12 天前
vite打包优化有哪些
前端·vite·掘金·金石计划
光影少年14 天前
webpack打包优化
webpack·掘金·金石计划·前端工程化
光影少年15 天前
Typescript工具类型
前端·typescript·掘金·金石计划
光影少年21 天前
Promise状态和方法都有哪些,以及实现原理
javascript·promise·掘金·金石计划
光影少年21 天前
next.js和nuxt与普通csr区别
nuxt.js·掘金·金石计划·next.js
光影少年21 天前
js异步解决方案以及实现原理
前端·javascript·掘金·金石计划
光影少年25 天前
前端上传切片优化以及实现
前端·javascript·掘金·金石计划
ZTStory1 个月前
JS 处理生僻字字符 sm4 加密后 Java 解密字符乱码问题
javascript·掘金·金石计划