Static vc Dynamic Dispatch in Swift: A decisive choice

avatar

如果您是OOP爱好者,那么这些方法调度技术(特别是动态调度)对您来说可能并不陌生。

方法分发是帮助决定应该执行哪个操作的机制,或者更具体地说,应该使用哪个方法实现。

Basics

首先,值类型和引用类型都支持静态分派。

然而,动态分派仅支持引用类型(即:类)。这样做的原因是,对于动态分派,简而言之,我们需要继承,而我们的值类型不支持继承。

记住那件事,让我们继续前进!

总的来说,调度技术不是2种(静态和动态),而是4种

  1. Inline: 内联 (Fastest)
  2. Static Dispatch: 静态分发
  3. Virtual Dispatch: 虚拟分发
  4. Dynamic Dispatch (Slowest): 动态分发

由编译器决定应该使用哪种分派技术,优先选择内联,然后在需要时继续执行。

Static vs Dynamic or Swift vs Objective-C

默认情况下,Objective-C支持动态调度。这种分派技术以多态性的形式为开发人员提供了灵活性! 子类化和重写现有方法之类的,这很好。但是,这是有代价的。

动态分派以恒定的运行时开销为代价增加了语言的表达能力。这意味着对于每个方法调用,在动态调度的情况下,我们的编译器必须查看我们称为见证表(其他语言中的虚表或调度表)的内部,以检查该特定方法的实现。编译器需要确定您是引用超类的实现,还是引用子类的实现。因为所有对象的内存都是在运行时分配的,所以编译器只能在运行时执行检查

然而,静态分派没有这个问题。通过这种分派技术,编译器在编译时知道要为一个方法调用哪个方法实现。因此,编译器可以执行某些优化,甚至可以将代码转换为内联(Inline),如果可能的话,从而使整体执行速度非常快!

那么在Swift中我们如何同时实现这两种功能呢?

  • 为了实现动态调度,我们使用继承、子类化基类,然后重写基类的现有方法。此外,我们可以利用dynamic关键字,我们需要前缀*@objc*关键字,以便将我们的方法暴露给Objective-C运行时

  • 为了实现静态分派,我们需要使用finalStatic,因为它们都可以确保类和方法不能被重写

Let’s dive deep

Static Dispatch (or Direct Dispatch)

如上所述,与动态分派相比,它们是相当快的,因为编译器能够在编译时定位指令的位置。因此,当函数被调用时,编译器直接跳到函数的内存地址执行操作。这个结果是巨大的性能提高和某些编译器优化,比如内联。

Dynamic Dispatch

如前所述,在这种类型的分派中,实现是在运行时而不是编译时选择的,这会增加一些开销。

现在,你可能会想,如果它这么贵,我们为什么还要用它。

因为它的灵活性。事实上,大多数OOP语言都支持动态调度,因为它允许存在多态性。

现在动态调度有两种类型-

  1. Table Dispatch

这种分派技术利用一个表,它是一个函数指针数组,称为*witness table (or virtual table)*来查找特定方法的实现。

那么,这个见证表是如何工作的呢?

  • 每个子类都有自己的表副本
  • 对于这个类覆盖的每个方法,这个表有不同的函数指针
  • 当子类添加新方法时,这些方法指针会被附加到这个数组的末尾
  • 最后,编译器在运行时利用这个表来查看要为一个方法调用哪个实现

由于编译器必须从表中读取实现的内存地址,然后跳转到该地址,因此它需要额外的两条指令,所以它比静态调度慢,但仍然比消息调度快。

NOTE : 我不太确定,但这个特定的调度技术可以是Virtual dispatch,因为它使用了虚拟表,但我无法找到一个具体的参考

  1. Message Dispatch

这种动态调度技术是最动态的(双关语)。事实上,它非常好(除了优化部分),以至于Cocoa框架在KVO、Core Data等许多大玩家中都使用了它。

此外,它还支持方法混合(method swizling),这通常意味着使用这种技术,我们可以在运行时更改方法的功能。

现在,Swift编译器没有提供这种开箱即用的功能。它使用Objective-C运行时来实现这种调度技术。

要显式地使用这个分派,我们需要使用dynamic关键字。在Swift 4.0之前,无论何时我们使用dynamic@objc都是隐式添加的,但在Swift 4.0开始时,我们需要显式地用*@objc*标记它,以使我们的方法暴露在Objective-C运行时中,从而进行消息分发。

由于我们使用的是Objective-C运行时,当消息被分派时,运行时将爬行类层次结构,以确定调用哪个方法。这真的很慢。为了弥补它的性能,它提供了一个缓存,这有点不同

编译器总是尝试将分派技术升级为静态分派,除非我们显式地用dynamic关键字标记它

Examples

Value Types

struct Person {
    func isIrritating() -> Bool { } // Static
}
extension Person {
    func canBeEasilyPissedOff() -> Bool { } // Static
}

由于structenum是值类型,并且不支持继承,编译器将其置于静态分派下,因为它知道它永远不能被子类化。

Protocol

protocol Animal {
    func isCute() -> Bool { } // Table
}
extension Animal {
    func canGetAngry() -> Bool { } // Static
}

这里需要注意的关键点是,在扩展内部定义的任何方法都使用静态调度

Class

class Dog: Animal {
    func isCute() -> Bool { } // Table
    @objc dynamic func hoursSleep() -> Int { } // Message
}
extension Dog {
    func canBite() -> Bool { } // Static
    @objc func goWild() { } // Message
}
final class Employee {
    func canCode() -> Bool { } // Static 
}
  • 普通的方法声明遵循与协议相同的原则
  • 当我们使用@objc向Objective-C运行时公开一个方法时,该方法使用Message Dispatch
  • 但是,如果我们将一个类标记为final,则该类不能被子类化,因此它的方法使用Static Dispatch
avatar

好吧,这就是我说的而你相信我说的,对吧?

现在如何证明这些方法实际上使用了我上面解释的分派技术呢?

为此,我们必须看看Swift中间件语言(SIL)。通过我在网上的研究,我发现了一种方法——

  1. 如果一个函数使用了Table dispatch,它将出现在虚函数表(vtable)(或witness_table)中。
sil_vtable Animal {
#Animal.isCute!1: (Animal) -> () -> () : main.Animal.isCute() -> () // Animal.isCute()
......
}
  1. 如果一个函数使用Message Dispatch,关键字volatile应该是。出现在调用中。此外,您将发现两个标记为外部和objc_method,这表明该函数是使用Objective-C运行时调用的。
%14 = class_method [volatile] %13 : $Dog, #Dog.goWild!1.foreign : (Dog) -> () -> (), $@convention(objc_method) (Dog) -> () 
  1. 如果没有上述两种情况的证据,那么答案是Static dispatch

我这边就是这样! 接下来的文章将是关于通过测试用例进行静态和动态分派之间的性能比较。


Static Dispatch Over Dynamic Dispatch

在文章上半部中,我解释了 Swift 编程语言中可用的各种派发技术。每当我们听到派发技术这个术语时,脑海中立即浮现的两种技术是静态派发和动态派发。

我强烈建议你阅读我之前的文章(如果还没有的话),以便你可以了解一些调度技术。

但是,如果你想知道它们究竟是什么,那就继续吧!

How It All Started

很久以前,苹果有一篇文章说,静态派发优于动态派发。因此,你应该始终默认使用静态派发,并且仅在必要时切换为动态派发。

在 Swift 项目中有这样一篇文章,《Writing High-Performance Swift Code》 也说明要「Reducing Dynamic Dispatch」

之所以选择静态派发而不是动态派发来提高性能的根本原因是,在静态派发的情况下,编译器在编译时就能够知道要调用某个类的哪种方法实现。这样,编译器可以使用一些优化技术,例如设置一些标志,或者可能的话,将调用转换为内联派发(这是最快的)!

而在动态派发的情况下,编译器只能在运行时才能确定为某个类调用具体哪个方法实现,即基类方法还是子类方法。

这使得使用静态派发的性能优于动态派发。

How to Achieve Static Dispatch

现在,按照本文所述,有三种方法可以实现静态派发或减少动态派发:

  • final 关键字:这确保了特定的类永远不会被子类化,特定的方法永远不会被重写,因此永远不会有动态调度。

  • private 关键字:这限制了方法或变量对类本身的可见性。根据文章:

这使编译器可以找到所有可能覆盖的声明。缺少任何此类覆盖的声明,可使编译器自动推断final关键字,并删除对方法和属性访问的间接调用。

这意味着,只要我们将该方法或变量标记为 private,编译器都会执行搜索来判断该方法或变量在其他任何地方是否被重写,如果发生重写,就将生成一个编译时错误。如果找不到任何替代行为,则会将其隐式标记为 final

  • Whole Module优化技术:这是一个编译器标志 -whole-module-optimization,默认情况下,Xcode 8 以后创建的新项目都会启用该标志。要点是,当我们不使用该标志(-wmo)时,Swift 编译器将分别编译属于一个模块的所有 .swift 文件。这限制了编译器添加某些优化(如内联),因为它将分别编译所有文件,因此编译器不知道不同的类及其方法之间的关系。当我们使用 -wmo 时,Swift 编译器会将所有这些 .swift 文件编译在一起,从而可以添加优化。如果你想更多地了解 -wmo,这里有很好的详细文档

And Finally

当我读完这篇文章后,脑海中只有一件事——证据在哪里?

我知道到目前为止,我们已经研究了许多理论概念,但是我们是程序员。我们喜欢代码。还有什么比用代码来证明这些概念更好的证明呢?

A small test

我创建了一个小型的性能测试项目,它不过是一堆 Swift 类和一些单元测试。这是我能找到的测试代码性能/速度的唯一方法。你可以克隆仓库并亲自查看。

我只使用了 final 关键字,但是你也可以使用其他两种方法。我将简要介绍一下项目的组成部分以及如何运行测试用例。

在这个项目中,只有两个文件对我们很重要:

  1. StaticDispatch.swift ——它包含我们将用于测试用例的所有类。
  2. PerformanceTesterTests.swift —— 它位于 PerformanceTesterTests 下,PerformanceTesterTests 是我们单元测试的文件组,其中包含我们的测试用例。

我在这两个文件中都添加了注释,使其更易于解释。

运行测试用例——

  • PerformanceTesterTests.swift 文件中取消第 36 行的注释,这个方法是静态派发。

  • 单击第 33 行上的菱形图标以运行测试用例。
    avatar
    这将启动 iPhone 模拟器。一旦模拟器加载完成,它将运行测试用例。

  • 测试用例完成后,你可以在右侧看到运行该测试用例所花费的时间
    avatar

  • 它可能报 no baseline fixed 或类似的内容,这时你需要单击图像中的灰色勾号(你可能需要首先取消灰色勾号然后再次选中),这将打开一个弹出窗口。像这样:
    avatar

  • 现在点击 Edit -> Accept -> Save。这将以此时间为基准,并且所有比较都将在我们保存的该基准时间上进行。

  • 就这样!注释当前行(第 36 行),取消注释下一行(记住,一次只取消注释一行),使用第 33 行再次运行测试用例,然后自己查看性能差异。它将向你显示我们设置的基线(静态调度基线)和当前测试用例之间的差异的百分比。

Some Observations

avatar
既不是 final 类也不是子类的类
avatar
类不是 final,而是子类
avatar
派生类(或子类)

因此,总结一下,作为一个良好的实践,你应该首先将类标记为 final,如果需要继承,请移除 final关键字。这确保了编译器将执行优化,从而提高了代码的性能。

内容的灵感 : Method dispatch in Swift — Thuyen’s corner