swift派发机制


原文: Method Dispatch in Swift

派发方式

派发的目的是为了告诉 CPU 在一次方法调用中如何在内存中找到需要执行的代码

直接派发(Direct Dispatch)

直接派发的速度是最快的,不仅是因为用到的指令集少,而且编译器还能进行优化,比如 函数内敛 等。有时候直接派发还被称为 静态调用

然而,从编程的角度来说直接调用也是最大的局限,因为缺乏动态性所以无法支持继承。

函数表派发(Table Dispatch)

函数表派发是编译型语言实现动态行为的最常见的实现方式。函数表使用数组来存储类中声明的每个方法的指针。大多数语言也成为 virtual table(虚函数表),swift 中称为 witness table (目击表)。每个类都维护这一张表,里面记录着类的所有方法,如果父类方法被 override 的话,表里面只会保存被 override 之后的函数的指针。一个类新添加方法会被插入到数组的末尾,在运行时会根据这张表来决定要被调用的实际方法。

1
2
3
4
5
6
7
8
class ParentClass {
func method1() {}
func method2() {}
}
class ChildClass: ParentClass {
override func method2() {}
func method3() {}
}

在这种情况下,编译器会创建两张表,一张是 ParentClass 的,一张是 ChildClass 的。

这张图展示了在 ParentClass、ChildClass 的函数表里 method1、method2、method3 的在内存中布局。

1
2
let obj = ChildClass()
obj.method2()

当一个方法被调用,其过程将是:

  1. 读取对象 0xB00 的方法表
  2. 在函数表中读取函数指针的索引,在这里,method2 的索引是 1,即0xB00 + 1
  3. 跳转到函数指针指向的 0x222

查表是一种简单、易实现, 而且性能可预知的方式,但相对于直接派发来说,还是慢了一些。从字节码的角度来说,多了两次读取和一次跳转,由此带来了性能损耗。另一个慢的原因在于编译器可能会由于方法内执行的任务导致无法进行优化,比如方法有 side effect

这种基于数组的实现,缺陷在于无法扩展 函数表, 子类在表的最后插入新方法,没有位置可以让 extensions 安全地插入方法。

消息派发

消息派发是方法调用最动态的方式,是 Cocoa 的基石。KVOUIApperanceCoreData 也是基于这种机制。这个机制的键关是允许开发者在运行时改变方法的行为。不止可以通过 swizzling来实现。还可以用过 isa-swizzling来修改继承关系,可以在面向对象的基础上实现自定义派发。

1
2
3
4
5
6
7
8
class ParentClass {
dynamic func method1() {}
dynamic func method2() {}
}
class ChildClass: ParentClass {
override func method2() {}
dynamic func method3() {}
}

Swift 会用树来构建这种继承关系:

当一个消息被派发时,运行时会顺着类继承体系向上查找应该被调用的方法,这样做的效率确实会有些低下。但是如果缓存一旦建立了,就会把性能提升到和函数表派发一样。

swift 的派发机制

swift 中的派发机制是什么样的呢?我没找到一个简明扼要的答案。但以下四种因素会影响到具体使用哪种派发方式:

  1. 声明位置(Declaration Location)
  2. 引用类型(Reference Type)
  3. 特定的行为(Specified Behavior)
  4. 显式优化(Visibility Optimizations)

文档中并没有具体写明什么时候会使用什么派发方式,唯一保证的是使用 dynamic 的时候会通过OC 的 runtime 来进行消息派发。

声明位置(Declaration Location)

swift 有两个地方可以声明方法:类型声明的作用域和 extension。声明的位置不同,派发方式也会不同。

1
2
3
4
5
6
class MyClass {
func mainMethod() {}
}
extension MyClass {
func extensionMethod() {}
}

在上面的代码中,mainMethod 会使用 方法表派发, extensionMethod将会使用 直接派发。下面是我根据引用类型和方法声明位置总结的派发方式表:

  • 值类型的方法总是使用直接派发
  • Protocol 和 Class 的 Extension 都会使用直接派发
  • NSObject 的 Extension 会使用 消息派发
  • NSObject De 声明作用域中的方法会使用 方法表派发
  • protocol 中声明的并且带有默认实现的使用 方法表派发

引用类型(Reference Type)

引用的类型决定了派发的方式. 这很显而易见, 但也是决定性的差异. 一个比较常见的疑惑是:一个协议拓展和类型拓展同时实现了同一个函数时的派发机制.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protocol MyProtocol {
}
struct MyStruct: MyProtocol {
}
extension MyStruct {
func extensionMethod() {
print("In Struct")
}
}
extension MyProtocol {
func extensionMethod() {
print("In Protocol")
}
}

let myStruct = MyStruct()
let proto: MyProtocol = myStruct

myStruct.extensionMethod() // -> “In Struct”
proto.extensionMethod() // -> “In Protocol”

对 swift 新手来说,可能会觉得 proto.extensionMethod()调用的是结构体中的实现,会输出 In Struct。但是引用类型决定了派发方式,协议扩展里的方法会使用直接派发。如果把 extensionMethod的声明移动到协议的声明位置的话,则会使用方法表派发,最终调用结构体中的实现。需要注意的是,代码中的两个方法调用都是直接派发,所以不能实现 override 行为。

指定派发方式 (Specifying Dispatch Behavior)

Swift 有一些修饰符可以指定派发方式.

final

final 允许类中的方法使用直接派发,会使方法是去动态性。它可以修饰任何方法,即使是 Extension 中原本就已经是直接派发的方法。也会使 Objective-C 的运行时无法获取到这个方法,而不会生成对应的 selector

dynamic

dynamic 可以让类中的方法使用消息机制派发,使用 dynamic 必须导入Foundation 框架,里面包含了 NSObject 和 OC 的运行时。dynamic 可以让声明在 Extension 中的方法能够被 override。可以用在所有NSObject 的子类和swift 的原生类的方法上。

@objc & @nonobjc

@objc@nonobjc 显式地声明了一个方法是否能够被OC的运行时捕获到。使用 @objc 的典型例子是给 selector 一个命名空间 @objc(abc_methodName),让这个函数可以被OC的运行时调用。@nonobjc 会改变派发方式, 可以用来禁止消息机制来派发这个方法,不让这个方法注册到 OC 的运行时

final @objc

可以在使用 final 的时候用使用 @objc, 结果就是会在OC的运行始终注册对应的 selector,但在调用的时候使用直接派发。函数可以响应 perform(selector:) 以及别的OC特性,但在直接调用时又可以有直接派发的性能。

@inline

Swift 也支持 @inline, 告诉编译器可以使用直接派发. 有趣的是,dynamic @inline(__always) func dynamicOrDirect() {} 也可以通过编译! 但这也只是告诉了编译器而已, 实际上这个函数还是会使用消息机制派发. 这样的写法看起来像是一个未定义的行为, 应该避免这么做.

修饰符总结

可见的都会被优化 (Visibility Will Optimize)

1
2
3
4
5
6
7
8
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Sign In", style: .plain, target: nil,
action: #selector(ViewController.signInAction)
)
}
private func signInAction() {}

这里编译器会抛出一个错误: Argument of '#selector' refers to a method that is not exposed to Objective-C (Objective-C 无法获取 #selector 指定的函数). 你如果记得 Swift 会把这个函数优化为直接派发的话, 就能理解这件事情了. 这里修复的方式很简单: 加上 @objc 或者 dynamic 就可以保证 Objective-C 的运行时可以获取到函数了. 这种类型的错误也会发生在 UIAppearance 上, 依赖于 proxyNSInvocation 的代码.

另一个需要注意的是, 如果你没有使用 dynamic 修饰的话, 这个优化会默认让 KVO 失效. 如果一个属性绑定了 KVO 的话, 而这个属性的 gettersetter 会被优化为直接派发, 代码依旧可以通过编译, 不过动态生成的 KVO 函数就不会被触发.

派发总结 (Dispatch Summary)