文章系翻译转载,版权归原作者所有,原文连接在此。
Method Dispatch(方法派遣)就是指当程序的method(方法)被调用时,它如何去选择将要被执行的指令。这是一件方法每次被调用时都会发生,但是,我们可能并不会太关注的事情。当你想要编写高效的代码时,了解Method Dispatch(方法派遣)是如何工作的就显得非常重要。同时,它还有助于阐释很多Swift看起来费解的行为。
Types of Dispatch
Dispatch(派遣)的目标就是让程序可以告诉CPU在内存的什么地方可有查找到对应的method call(方法调用)的可执行代码。在我们钻研Swift如何做之前,我们先看看了解下三种消息派遣的不同方式。每一种消息派遣方式都在执行效率和动态性之间做出了权衡和妥协。
Direct Dispatch
Direct Dispatch(直接派发)是最快的一种派发方式。它不仅产生的汇编代码更少,而且使得编译器可以做各种各样的优化,比如inlining code(代码内嵌)和更多超出这篇文章讨论范围的事情。这种方式也经常被称为static dispatch (静态派发)。
然而,直接派发从编程的角度来看也是最受限制的,它根本就不具备足够的动态性来支持继承。
Table Dispatch
Table Dispatch (表派发)对于编译型语言,是最常见的动态性实现方式。表派发在每个声明的类中使用一个此类包含的所有方法的函数指针数组。大部分语言都习惯称它为virtual table(虚函数表),但是,Swift使用了witness table(见证表)这个术语。每个子类都有一份该函数表的拷贝,只是被重写的方法的入口函数指针不同。当子类添加新的方法时,这些新方法会被添加到这个数组的末尾。然后在程序运行时,将会查询这个表来决定方法调用所要执行的指令。
表查询非常简单,容易实现,且性能特征是可预测的。然而,这种方式与直接派发相比,仍然很慢。从生成字节码的角度看,仍然有完全多余的两次读取和跳转,增加了一些开销和复杂。而且,还有一点被认为缓慢的原因是编译器无法,基于发生在函数内部的操作,进行任何优化。
这种基于数组的实现的一个弊端是extensions(扩展)无法扩充派发表。因为子类会向派发表的末尾添加新的方法,所以,对于扩展来说,没有可以安全的向派发表添加函数指针的索引。swift-evolution post更加详尽的阐明了这些限制。
Message Dispatch
Message Dispatch(消息派发)是最动态化的可用调用方式。它是Cocoa框架下开发的基石,是实现诸如KVO,UIAppearance,以及Core Data等等这些特性的底层机制。这个功能的关键部分就是它允许开发者在运行时编辑方法派发行为。不仅方法调用可以通过swizzling(方法替换)的方式被改变,而且可以允许基于实例的个性化派发。
当一条消息被派发时,运行时将会遍历整个类结构层次来决定哪个方法被调用。如果这听起来很慢,那么它确实如此!但是,这种查找方式通过一个加速的缓冲层来保证,只要它的缓冲预热完成,它就可以基本和表派发一样快。
Swift Method Dispatch
那么,Swift的方法派发时什么样子的呢?对于这个问题,我没有找到一个简单明了的答案,但是,有四个方面的要素影响派发的选择:
- Declaration Location (声明的位置)
- Reference Type (引用的类型)
- Specified Behavior (指定的行为)
- Visibility Optimizations (可见性优化)
在我定义这些之前,必须要指出的是Swift并没有文档明确指出了什么时候使用表派发,或者什么时候使用消息派发。唯一可以确定的就是,dynamic
关键字将会通过Objective-C运行时,启用消息派发。下面我提到的一切,我是通过观察Swift 3.0的行为得出的,并且在以后的版本中可能会有所改变。
Location Matters
Swift有两个位置可以做方法声明:在类型的初始声明内部,和扩展内部。根据类型声明的不同,这将会改变派发的执行。
class MyClass {
func mainMethod() {}
}
extension MyClass {
func extensionMethod() {}
}
在上面的例子中,mainMethod
将会使用表派发,extensionMethod
将会使用直接派发。当我发现这些的时候,我十分惊讶。这些方法的行为如此不同,这是不清楚的,也不是凭直觉就能理解的。下面是一个依据引用类型和声明位置来决定选用派发类型的对照表:
Initial Declaration | Extension | |
---|---|---|
Value Type | Static | Static |
Protocol | Table | Static |
Class | Table | Static |
NSObject Subclass | Table | Message |
这里有几个问题需要注意:
- 值类型总是使用直接派发。简单高效!
- 协议扩展和类扩展使用直接派发
- NSObject的类扩展使用消息派发
- NSObject的初始类声明使用表派发
- 初始协议声明中方法的默认实现使用表派发
Reference Type Matters
调用方法的引用的类型决定了派发的规则。这似乎很浅显,但,其实这是一个重要区别。造成迷惑的一个常见原因是协议扩展和对象扩展都实现了相同的方法。
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()
会调用结构体的实现。然而,引用的类型决定了派发方式的选择,对于协议来说唯一可见的方法是使用直接派发的。如果extensionMethod
的声明被移动到协议声明里,表派发就会被使用,就会导致结构体的实现被调用。还要注意的是这两种声明都是使用直接派发的,鉴于直接派发的语法,所以,期望的“override”(重载)行为是不可能的。这让许多新的Swift开发人员措手不及,因为这似乎是来自Objective-C背景的预期行为。
有一些SWift JIRA的问题,和很多swift-evolution邮件列表里的讨论,以及一篇优秀的博客与此相关。然而,它是预期的行为,即使它没有很好的文档记录。
Specifying Dispatch Behavior
SWift 还有一些声明修饰符可以改变派发行为。
final
final
使类中定义的方法采用直接派发。这个关键字移除了任何动态行为的可能性。它可以用来修饰方法,甚至是在本来已经采用直接派发的扩展中也可以。它还可屏蔽来自Objective-C runtime运行时的所有方法,因此也不会产生任何的selector(方法选择器)。
dynamic
dynamic
使类中定义的方法采用消息派发。它还会将方法暴露给Objective-C runtime运行时。d ynamic可以用来使扩展中声明的方法能够被重载。dynamic
关键字同时适用于NSObject
子类和Swift类。
@objc
& @nonobjc
@objc
和@nonobjc
修饰可以改变方法对于Objective-C运行时的可见性。@objc
最常见的用法就是为selector(方法选择器)建立名称空间,比如@objc(abc_methodName)
。@objc
不会改变方法的派发方式,它只是将方法暴露给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() {}
可以编译。它确实只是一种暗示,因为汇编代码显示方法仍然是使用消息派发。这将会导致不可预知的行为,所以,应该避免这样的声明。
Modifier Overview
Modifier | Dispatch |
---|---|
final | Static |
dynamic | Message |
@objc | Modify Objective-C Visibility |
@inline | Code generation hit for direct dispatch |
如果你对查看以上的例子的汇编代码感兴趣的话,你可以去这里查看。
Visibility Will Optimize
SWift将会尝试尽可能的优化消息派发。比如,如果你有一个方法,从来都没有被重载过,Swift就会注意到这些,并且将会尽可能的使用直接派发。这种优化绝大多数时候都是好的,target/action模式的Cocoa程序员吃不开。比如:
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
。这时候记得Swift将会优化方法使用直接派遣就很有意义。处理的方法非常简单:只需要添加@objc
或dynamic
修饰符到方法声明前面,保证方法对于Objective-C运行时是可见的。这种类型的错误也会发生在使用UIAppearance
的时候,因为它是依赖代理对象和NSInvocation
的。
另一个,在使用更动态化的Foundation
特性时,必须要认识到的问题是,这种优化会悄无声息的破坏KVO
,如果你不使用dynamic
关键字的话。如果一个属性在被监听,并且它被优化为直接派发,代码仍然能够编译,但是,动态生成的KVO
方法并不会被执行。
Swift的官方博客有一篇优秀的文章描述了更多的细节和这些优化背后的原因。
Dispatch Summary
有很多规则需要记忆,所以,下面是对以上派发规则的总结:
Direct | Table | Message | |
---|---|---|---|
NSObject | @nonobjc or fianl | Initial Declaration | Extensions dynamic |
Class | Extensions final | Initial Declaration | dynamic |
Protocol | Extensions | Initial Declaration | Obj-C Declaration @objc declaration modifier |
Value Type | All Methods | n/a | n/a |
NSObject and the Loss of Dynamic Behavior
许多Cocoa开发人员对动态行为的消失发表了评论。这些讨论非常有趣,并且提出了很多观点。我希望继续这些讨论,并且能够指出一些Swift派发行为损害它的动态行为的几个方面,并提出解决方案。
Table Dispatch in NSObject
上面我提到了定义在NSObject
子类初始声明内部的方法使用的是表派发。我发现这很令人困惑,很难解释,最终,这只是一个微小的性能改进。除此之外:
- 大部分的
NSObject
子类都是基于大量的obj_msgSend
。我强烈怀疑任何的这些派发升级能够实质性地引发任何Cocoa子类的性能提升。 - 大部分的Swift
NSObject
子类都广泛使用了扩展,从而避免了这次升级。
最后,这只是另一个使派发故事复杂化的小细节。
Dispatch Upgrades Breaking NSObject Features
可见的性能提升非常棒,我喜欢Swift在可能的情况下升级派发方面的智慧。然而,在我的UIView子类color属性中有一个理论上的性能提升,却打破了UIKit中建立的模式,这对语言是有害的。
NSObject as a Choice
正如结构是静态分派的一个选择,NSObject
是消息派发的一个选择将是非常棒的。现在,如果你要向一个新的Swift开发人员解释为什么某个东西是NSObject
子类,你必须解释Objective-C和这门语言的历史。除了沿用一个Objective-C代码基础之外,没有理由选择继承NSObject
。
目前,Swift中NSObject
的派发行为可以用“复杂”来形容,这并不理想。我希望看到这个变化:当你子类化NSObject
时,它应该是你想要完全动态的消息派发的一个信号。
Implicit Dynamic Modification
另一种可能性是,Swift可以更好地检测何时动态使用方法。我相信应该可以检测哪些方法在#selector
和#keypath
中被引用,并自动将它们标记为动态的。这将消除这里记录的大多数动态问题,除了UIAppearance
,但可能还有另一种编译技巧,可以标记这些方法。
Errors and Bugs
我们对Swift的派发规则有了更深的理解之后,让我一起来再来看一些Swift开发者遇到的错误场景。
SR-584
这个Swift的问题是Siwft派发规则的一个“特性”。它围绕着这样一个事实:在NSObject
子类的初始声明中定义的方法使用表派发,而在扩展中定义的方法使用消息派发。为了描述该行为,让我们创建一个带有简单方法的对象:
class Person: NSObject {
func sayHi() {
print("Hello")
}
}
func greetings(person: Person) {
person.sayHi()
}
greetings(person: Person()) // prints 'Hello'
greetings(person:)
表派发的方式调用sayHi()
。 如预期的那样解析, “Hello”被打印出来。这没什么值得高兴的. 接下来, 让我子类化 Person
class MisunderstoodPerson: Person {}
extension MisunderstoodPerson {
override func sayHi() {
print("No one gets me.")
}
}
greetings(person: MisunderstoodPerson()) // prints 'Hello'
注意,sayHi()
是在扩展中声明的,这意味着该方法将使用消息派发来调用。当调用greeting (person:)
时,sayHi()
通过表派发分派给person
对象。由于MisunderstoodPerson
重写是通过消息派发添加的,所以MisunderstoodPerson
的派发表表中仍然是Person实现,因此会产生混淆。
这里的解决方法是确保方法使用相同的派发机制。您可以添加dynamic
关键字,或者将方法实现从扩展移到初始声明中。
在这里,理解Swift的调度规则有助于我们理解这一点,尽管Swift应该足够聪明,可以为我们解决这个问题。
⚠️注意: 以上的问题在Swift5中已经不存在。编译器会提示无法在extension中重写方法。必须将Person
类的sayHi()
方法标记为@objc dynamic
方可编译。
代码如下:
import Foundation
class Person: NSObject {
@objc dynamic func sayHi() {
print("Hello")
}
}
func greetings(person: Person) {
person.sayHi()
}
greetings(person: Person()) // prints 'Hello'
class MisunderstoodPerson: Person {}
extension MisunderstoodPerson {
override func sayHi() {
print("No one gets me.")
}
}
greetings(person: MisunderstoodPerson()) // prints 'No one gets me.'
SR-103
这个Swift问题涉及协议中定义的方法的默认实现和子类。为了说明这个问题,让我们定义一个带有该方法默认实现的协议。
protocol Greetable {
func sayHi()
}
extension Greetable {
func sayHi() {
print("Hello")
}
}
func greetings(greeter: Greetable) {
greeter.sayHi()
}
现在,让我们定义一个符合这个协议的类层次结构。让我们创建一个符合Greetable协议的Person类,以及一个覆盖sayHi()函数的LoudPerson子类。
class Person: Greetable {
}
class LoudPerson: Person {
func sayHi() {
print("HELLO")
}
}
greetings(greeter: LoudPerson()) // prints 'Hello'
注意,在LoudPerson
中没有覆盖方法。这是唯一的明显警告,这里的事情可能不会像预期的那样工作。在本例中,LoudPerson
类没有在Greetable
witness表中正确注册sayHi()函数,当通过Greetable
协议分派sayHi()
时,将使用默认实现。
解决方案是记住为协议的初始声明中定义的所有方法提供实现,即使提供了默认实现。或者,您可以将类声明为final
,以确保不存在子类。
有人提到Doug Gregor正在做的工作,它将隐式地将默认协议方法重新声明为类方法。这将修复上面的问题,并启用预期的覆盖行为。
⚠️注意: 以上的问题在Swift5中仍然存在,似乎并未被修复。
Other bugs
我想我要提到的另一个漏洞是SR-435。它涉及两个协议扩展,其中一个扩展比另一个更具体。问题中的示例显示了一个未受约束的扩展和一个被约束为Equatable
类型的扩展。当在协议中调用方法时,不会调用更具体的方法。我不确定这种情况是否经常发生,但似乎需要密切关注。
如果你知道任何其他Swift调度漏洞,请给我留言,我将更新这篇博文。
Interesting Error
这里有一个有趣的编译器错误消息,可以让我们一窥Swift的抱负。如上所述,类扩展使用直接分派。那么,当您试图重写扩展中声明的方法时,会发生什么呢?
class MyClass {
}
extension MyClass {
func extensionMethod() {}
}
class SubClass: MyClass {
override func extensionMethod() {}
}
上面的代码编译失败,错误: Declarations in extensions can not be overridden yet
。显然,Swift团队计划扩展基本的表派发机制。或者也许我是在试图解读未来,而这只是一种乐观的语言选择!
⚠️注意: 以上的问题在Swift5中仍然存在,但显示的错误如下:
向扩展的函数声明前添加@objc
修饰后可以修复,代码如下:
import Foundation
class MyClass {
}
extension MyClass {
@objc func extensionMethod() {}
}
class SubClass: MyClass {
override func extensionMethod() {}
}
Thanks
我希望这是一次有趣的方法派发之旅,我希望它能帮助您更多地了解Swift。尽管我对NSObject
有些不满,但我认为Swift提供了一个很棒的性能故事。我只是希望它足够简单,以至于不需要这篇博文。