函数派发就是程序判断使用哪种途径去调用一个函数的机制。了解派发机制对于写出高性能代码来说很有必要,而且也能解释很多 Swift 里”奇怪”的行为。
编译型语言有三种基础的函数派发方式:
- 直接派发 Direct Dispatch
- 函数表派发 Table Dispatch
- 消息机制派发 Message Dispatch
大多数语言都会支持一到两种,Java 默认使用函数表派发,但你可以通过 final
修饰符修改成直接派发。 C++ 默认使用直接派发,但可以加上 virtual
修饰符来改成函数表派发。而 Objective-C 则总是使用消息机制派发,但允许开发者使用C直接派发来获取性能的提高。这样的方式非常好,但也给很多开发者带来了困扰。
1. 派发方式
程序派发的目的是为了告诉 CPU 需要被调用的函数在哪里,在我们深入 Swift 派发机制之前,先来了解一下这三种派发方式,以及每种方式在动态性和性能之间的取舍。
直接派发
直接派发是最快的,由编译器来决定调用的函数,并且编译器还能够有很大的优化空间,例如函数内联等。直接派发也被称为静态调用。
然而,对于编程来说直接调用也是最大的局限,而且因为缺乏动态性所有没办法支持继承。
函数表派发
函数表派发是编译型语言实现动态行为最常见的实现形式。函数表使用了一个数组来存储声明的每一个函数的指针。大部分语言把这个称为 virtual table
(虚函数表), Swift 里称为 witness table
.每一个类都会维护一个函数表,里面记录着类所有的函数。如果父类函数被 override 的话,表里面只会保存被 override 之后的函数。一个子类新添加的函数,都会被插入到这个数组的最后。运行时会根据这一个表去决定实际要被调用的函数。
查表是一种简单,易实现,而且性能可预知的方式。然而,这种派发方式比起直接派发还是慢一点。从字节码角度来看,多了两次读和一次跳转,由此带来了性能的损耗。另一个慢的原因在于编译器可能会由于函数内执行的任务导致无法优化。
函数表基于数组实现,缺点在于函数表无法拓展。子类会在虚函数表的最后插入新的函数,但没有位置可以让 extension 安全地插入函数。
消息机制派发
消息机制是调用函数最动态的方式。这种运作方式的关键在于开发者可以在运行时改变函数的行为,不止可以通过 swizzling
来改变,甚至可以用 isa-swizzling
修改对象的继承关系,可以在面向对象的基础上实现自定义派发。
当一个消息被派发,运行时会顺着类的继承关系向上查找应该被调用的函数。如果你觉得这样做效率很低,它确实很低。然而,只要缓存建立了起来,这个查找过程就会通过缓存来把性能提高到和函数表派发一样快。
Swift 的派发机制
那么,到底 Swift 是怎么派发的呢?有四个选择具体派发方式的因素存在:
- 声明的位置
- 引用类型
- 特定的行为
- 显式的优化(Visibilty Optimizations)
在解释这些因素之前,我有必要说清楚,Swift 没有在文档里具体写明什么时候会使用函数表什么时候使用消息机制。唯一的承诺是使用 dynamic
修饰的时候会通过 Objective-C 的运行时进行消息机制派发。
声明的位置
在 Swift 里,一个函数有两个可以声明的位置: 类型声明的作用域和 extension。 根据声明类型的不同,也会有不同的派发方式。
1 | class MyClass { |
上面的例子中,mainMethod
会使用函数表派发,而 extensionMethod
则会使用直接派发。
Initial Declaration | Extension | |
---|---|---|
Value Type | Static | Static |
Protocol | Table | Static |
Class | Table | Static |
NSObject subclass | Table | Message |
这张表格展示了默认情况下 Swift 使用的派发方式。
总结起来这么几点:
- 值类型总是会使用直接派发,简单易懂
- 而协议和类的
extension
都会使用直接派发 NSObject
的extension
会使用消息机制进行派发NSObject
声明作用域里的函数都会使用函数表进行派发- 协议里声明的,并且带有默认实现的函数都会使用函数表进行派发
引用类型
引用的类型决定了派发的方式。这很显而易见,但也是决定性的差异,一个比较常见的疑惑,发生在一个协议拓展和类型拓展同时实现了同一个函数的时候。
1 | protocol MyProtocol { |
引用的类型决定了派发的方式,协议拓展里的函数会使用直接派发
方式调用。如果把 extensionMethod
的声明移动到协议的声明位置的话,则会使用函数表派发,最终就会调用结构体里的实现。并且,如果两种声明方式都使用了直接派发的话,基于直接派发的运作方式,我们不可能实现预想的 override
行为。
指定派发方式
Swift 有一些修饰符可以指定派发方式。
final
final
允许类里面的函数使用直接派发。这个修饰符会让函数失去动态性。任何函数都可以使用这个修饰符,就算是 extension
里本来就是直接派发的函数。这也会让 Objective-C 的运行时获取不到这个函数,不会生成相应的 selector。
dynamic
dynamic
可以让类里面的函数使用消息机制派发。 dynamic
可以让声明在 extension 里面的函数能够被 override。 dynamic
可以用在所有 NSObject
的子类和 Swift 的原生类。
@objc
& @nonobjc
@objc
和 @nonobjc
显式的声明了一个函数是否能被 Objective-C 的运行时捕获到。使用 @objc
的典型例子就是给 selector 一个命名空间 @objc(methodName)
,让这个函数可以被 Objective-C 的运行时调用。 @nonobjc
会改变派发的方式,可以用来禁止消息机制派发这个函数,不让这个函数注册到 Objective-C 的运行时里。我不确定这跟 final
有什么区别,因为从使用场景来说也几乎一样,我个人更喜欢final
,因为意图更明显。
final @objc
可以在标记为 final
的同时, 也使用 @objc
来让函数可以使用消息机制派发. 这么做的结果就是, 调用函数的时候会使用直接派发, 但也会在 Objective-C 的运行时里注册响应的 selector. 函数可以响应 perform(selector:)
以及别的 Objective-C 特性, 但在直接调用时又可以有直接派发的性能.
@inline
Swift 也支持 @inline
, 告诉编译器可以使用直接派发. 有趣的是, dynamic @inline(__always) func dynamicOrDirect() {}
也可以通过编译! 但这也只是告诉了编译器而已, 实际上这个函数还是会使用消息机制派发. 这样的写法看起来像是一个未定义的行为, 应该避免这么做.
显式的优化
Swift 会尽最大能力去优化函数派发的方式。例如,如果你有一个函数从来没有 override,Swift 就会检查并且在可能得情况下使用直接派发。这个优化大多数情况下都表现得很好,但对于使用了 target/action 模式的 Cocoa 开发者就不那么友好了。
1 | override func viewDidLoad() { |
这里编译器会抛出一个错误: Argument of '#selector' refers to a method that is not exposed to Objective-C (Objective-C 无法获取 #selector 指定的函数)
. 你如果记得 Swift 会把这个函数优化为直接派发的话, 就能理解这件事情了. 这里修复的方式很简单: 加上 @objc
或者 dynamic
就可以保证 Objective-C 的运行时可以获取到函数了. 这种类型的错误也会发生在 UIAppearance
上, 依赖于 proxy
和 NSInvocation
的代码.
另一个需要注意的是, 如果你没有使用 dynamic
修饰的话, 这个优化会默认让 KVO 失效. 如果一个属性绑定了 KVO 的话, 而这个属性的 getter 和 setter 会被优化为直接派发, 代码依旧可以通过编译, 不过动态生成的 KVO 函数就不会被触发.
2. 如何通过减少动态派发来提高性能
使用final
关键字修饰肯定不会被重载的声明
在上面的文章里,使用 final
可以允许类里面的函数使用直接派发。而 final
关键字可以用在 class, 方法和属性里来标识此声明不可以被 override。 这可以让编译器安全的将其优化为静态派发。
将文件中使用private
关键字修饰的声明推断为final
。
使用 private
关键字修饰的声明只能在当前文件中进行访问。这样编译器可以找到所有潜在的重载声明。任何没有被重载的声明编译器自动的将它推断为final
类型并且去除间接的方法调用和属性访问。
使用全局模块优化推断internal
声明为final
-> whole module Optimization
使用internal
(如果声明没有使用关键词修饰,默认是 internal
)关键字修饰的声明的作用域仅限于它被声明的模块中。因为Swift通常的将这些文件作为一个独立的模块进行编译,所以编译器不能确定一个internal声明有没有在其他的文件中被重载。然而如果全局模块优化(Whole Module Optimization,关于全局模块优化参看下文的相关名词解释)是打开的那么所有的模块将要在同一时间被一起编译。这样以来编译器就可以为整个模块一起做出推断,将没有被重载的 internal
修饰的声明推断为 final 类型。