Method Dispatch in Swift
前言
已经使用Swift这门新兴的OO语言已经有很久了,作为面向对象语言在没有特殊声明的时候系统会默认对属性和方法进行重载,虽说Swift不完全算一种动态语言,但是由于和OC依然藕断丝连Swift里面动态派发依然会损耗不小的性能,那么动态派发是什么,Swift的派发机制是什么?我们来深入了(zhuang)解(bi)一下。
开始装逼
滑腻腻的分割线
不同的引用类型和修饰符对于函数派发的影响
函数派发就是当调用一个方法时候程序判断以何种调用方法去调用的机制。每次的函数调用都会触发这种机制,但是你又不会注意太多。在编码时候,了解函数的派发机制是至关重要,同时能解释其中的一些令人困惑的问题。
编译型语言有三种主要的函数派发机制:**直接派发(Direct Dispatch)、函数表派发(Table Dispatch)、消息机制派发(Message Dispatch)**,下面我会详细解释这几种机制。大多数语言支持一种或两种。Java默认使用的是函数表派发,但是可以通过 final
关键字来进行直接派发。C++默认使用的是直接派发,但是可以通过 virtual
关键字来进行函数表派发。Objective-C 只能使用消息机制派发, 但允许开发者使用 C 直接派发来获取性能的提高。WTF但是Swift都支持,这很好,同时这个也是大多数开发人员混淆的源头,并且是大多数开发人员遇到过的问题。
派发的方法(Types of Dispatch )
派发的目的是告诉CPU找到特定方法的可执行的代码搁哪呢。我们在深入了解之前先了解一下这三种派发方法,以及每种方式在动态性和性能之间的取舍。
直接派发 (Direct Dispatch)
直接派发是最快的一种函数派发方法。不但因为在汇编的层面调用的指令集最少,而且编译器还能够有很大的优化空间, 例如函数内联等, 但这不在这篇博客的讨论范围. 直接派发也有人称为静态调用。然而, 对于编程来说直接调用也是最大的局限, 而且因为缺乏动态性所以没办法支持继承。
函数表派发 (Table Dispatch )
函数表派发是编译型语言实现动态化最普遍的实现方式。 函数表派发机制使用了一个数组来存储类声明的每一个函数的指针。 大部分语言把这个称为 “virtual table”(虚函数表),而在Swift 里称为 “witness table”。每一个子类都有函数表的副本,里面记录着父类所有的函数,重写之后的函数会替代父类对应的函数。 一个子类新添加的函数, 都会被插入到这个数组的最后。在运行时会去查表调用响应的函数。
举个🌰
1 | class ParentClass { |
在这个情况下, 编译器会创建两个函数表, 一个是ParentClass
的, 另一个是 ChildClass
的:
1 | let obj = ChildClass() |
####当一个函数被调用时, 会经历下面的几个过程:
- 读取函数表中地址为
0xB00
的对象。 - 读取函数指针的索引。在例子里, method2 的索引是1(偏移量), 也就是 0xB00 + 1。
- 跳转到
0x222
表查找非常简单,易实现的,性能特征是可预知的。然而,与直接调度相比,这种调度方法仍然很慢。从byte-code
的角度来看,有两个额外的读取和一个跳转,这同时增加了不少性能开销。另一个慢的原因在于编译器可能会由于函数内执行的任务导致无法优化。
这种基于数组的实现, 缺陷在于函数表无法拓展。子类会在虚数函数表的最后插入新的函数,没有位置可以让 extension 安全地插入函数。这篇 swift-evolution post 很详细地描述了这么做的局限性。
消息派发 (Message Dispatch )
消息派发是最广泛的动态调用函数的方法。也是Cocoa
的中流砥柱,这种机制也为 KVO, UIAppearence 和 CoreData 打下了基础。这种机制的关键是允许开发者在运行时改变函数的实现。不仅可使用 swizzling 来改变,还可以用 isa-swizzling 修改对象的继承关系,可以在面向对象的基础上实现自定义派发。
再举个🌰
1 | class ParentClass { |
Swift 会用树来构建这种继承关系:
当一个消息被转发, 运行时会顺着类的继承关系向上查找应该被调用的函数。如果你觉得这样做效率很低, 它确实很低。 然而, 只要缓存建立了起来。这个查找过程就会通过缓存来把性能提高到和函数表派发一样快。但这只是消息机制的原理, 这里有一篇文章一篇文章很深入的讲解了具体的技术细节。
Swift 的派发机制
那么,Swift是啥派发机制的呢?没有一个固定的的机制,有几种环境因素来影响使用哪种派发机制:
- 声明的位置
- 引用的类型
- 特定的行为
- 可见的优化
在说明之前, 我有必要说清楚, Swift 没有在文档里具体写明什么时候使用函数表什么时候使用消息派发机制。唯一的承诺是使用 dynamic 修饰的时候会通过 Objective-C 的运行时进行消息机制派发。以下我写的所有东西,都只是我在 Swift 3.0 里测试出来的结果,并且很可能在之后的版本更新里进行修改。博主会在近期使用Swift 4.0进行测试。
声明的位置(Location Matters)
Swift有两个地方可以声明一个方法:在类声明的作用域,在extension中。根据声明的类型,这将改变调度的执行方式。
1 | class MyClass { |
在上面的例子中,mainMethod
将会使用函数表派发,extensionMethod
将使用静态派发。当我第一次发现时候,我很意外。我不能确认这些方法有什么不同。下面是我根据类型,声明位置总结出来的函数派发方式的表格:
下面有几条需要注意的:
- 值类型总是使用直接派发的。这很易懂!
- Protocol 和 Classes 的 Extensions 使用的是直接派发。
NSObject
和NSObject
子类的 Extensions 使用的消息派发。NSObject
和NSObject
子类的声明作用域里面函数都会使用函数表派发。- 协议里的声明以及带有默认实现的函数会使用函数表派发机制。
引用类型 (Reference Type Matters)
引用的类型同时也决定了函数派发的机制。这显而易见,这是一个重要的差异。当一个协议扩展和类型扩展同时实现了同一个函数,这种情况让我们很疑惑。
1 | protocol MyProtocol { |
大多数刚刚接触Swift的人会认为proto.extensionMethod()
调用的是结构体里面的实现。然而,引用的类型决定了派发的方式, 协议拓展里的函数会使用直接调用。如果把 extensionMethod
的声明移动到协议的声明位置的话,则会使用函数表派发,最终就会调用结构体里的实现。
而且,如果两种声明都使用的是直接派发,基于直接派发的机制,我们不可能实现重写Protocol里面的实现。这让很多新的Swift开发者会措手不及,这与Objective-C的机制背道而驰。
Swift JIRA(缺陷跟踪管理系统) 也发现了几个 bugs,Swfit-Evolution 邮件列表里有一大堆讨论,也有一大堆博客讨论过这个。但是,这好像是故意这么做的, 虽然官方文档没有提过这件事情😒😒
指定派发方式 (Specifying Dispatch Behavior)
Swift有一些修饰符可以指定派发方法。
finalfinal
允许类里面声明的函数使用直接派发。这个关键字会移除函数的动态性。任何函数都可以使用这个关键字,甚至是extension里面已经 使用的直接派发的函数。这会让 Objective-C 在运行时获取不到这个函数,同时不会生成函数的selector
。
dynamicdynamic
可以让类里面的函数使用消息机制派发。使用 dynamic
, 必须导入 Foundation 框架,里面包括了 NSObject 和 Objective-C 的运行时。dynamic
可以让声明在 extension 里面的函数能够被override。dynamic
可以用在所有NSObject的子类和 Swift 的原生类。
@objc & @nonobjc@objc
和 @nonobjc
声明了一个函数是否能被 Objective-C 的运行时捕获到。 使用 @objc
的典型例子就是给 selector
一个命名空间 @objc(abc_methodName)
,让这个函数可以被 Objective-C 的运行时调用。 @nonobjc
会改变派发的方式,可以用来禁止消息机制派发这个函数,不让这个函数注册到 Objective-C 的运行时里。 我不确定这跟 final
有什么区别, 因为从使用场景来说也几乎一样。我个人来说更喜欢 final
,因为意图更加明显。个人感觉@objc
@nonobjc
这两个是为了与OC混编时候更加方便,而final
是为Swift准备的原生关键字。
final @objc
可以在标记为 final
的同时,也使用 @objc
来让函数可以使用消息机制派发。这么做的结果就是,调用函数的时候会使用直接派发,但也会在 Objective-C 的运行时里注册响应的 selector。函数可以响应 perform(selector:) 以及别的 Objective-C 特性,但在直接调用时又可以有直接派发的性能。
@inline
Swift 也支持 @inline
,告诉编译器可以使用直接派发。有趣的是dynamic @inline(__always) func dynamicOrDirect() {}
也可以通过编译!它仅仅是一个声明,开发者大会表示函数依然是使用消息派发。这就像一个未声明的行为,应该避免这样使用。
修饰符总结 (Modifier Overview)
这张图总结这些关键字对于 Swift 派发方式的影响,想看范例的话可以看这里。
显示优化 (Visibility Will Optimize)
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 函数就不会被触发。
Swift 的博客有一篇很赞的文章描述了相关的细节,和这些优化背后的考虑。
派发总结 (Dispatch Summary)
这里有很多需要记住的规则,所以我整理了一个表格:
NSObject 以及动态性的损失 (NSObject and the Loss of Dynamic Behavior)
之前还有一些 Cocoa 开发者讨论动态行为带来的问题。 这段讨论很有趣,提了一大堆不同的观点。我希望可以在这里继续探讨一下,有几个 Swift 的派发方式我觉得损害了动态性,顺便说一下我的解决方案。
NSObject 的函数表派发 (Table Dispatch in NSObject)
之前,我说明了一个NSObject
子类作用域里面定义的函数会使用函数表派发。但我觉得很迷惑,很难解释清楚,并且由于下面几个原因,这也只带来了一点点性能的提升:
- 大部分
NSObject
的子类都是在obj_msgSend
的基础上构建的. 我很怀疑这些派发方式的优化,实际到底会给 Cocoa 的子类带来多大的提升. - 大多数 Swift 的
NSObject
子类都会使用 extension 进行拓展, 都没办法使用这种优化。
最后, 有一些小细节会让派发方式变得很复杂。
NSObject 作为一个选择 (NSObject as a Choice)
使用静态派发的话结构体是不错的选择,而使用消息机制派发的话则可以考虑 NSObject
。 现在,如果你想跟一个刚学 Swift 的开发者解释为什么某个东西是一个 NSObject
的子类,你不得不去介绍 Objective-C。现在没有任何理由去继承 NSObject
构建类,除非你需要使用 Objective-C 构建的框架。
目前来说,NSObject
在 Swift 里的派发方式,一个字总结就是TM复杂,跟理想还是有差距。我比较想看到这个修改:当你继承 NSObject
的时候,这是一个你想要完全使用动态消息机制的表现。
显式动态性声明 (Implicit Dynamic Modification)
另一个 Swift 可以改进的地方就是函数动态性的检测。我感觉检测到一个函数被 #selector
和 #keypath
引用时要自动把这些函数标记为 dynamic
,这样的话就会解决大部分 UIAppearance
的动态问题, 但也许有别的编译时的处理方式可以标记这些函数。
Error 以及 Bug (Errors and Bugs)
为了让我们对 Swift 的派发方式有更多了解,让我们来看一下 Swift 开发者遇到过的 error。
SR-584
这个 Swift bug 是 Swift 函数派发的一个功能。存在于 NSObject 子类声明的函数(函数表派发),以及声明在 extension 的函数(消息机制派发)中. 为了更好地描述这个情况, 我们先来创建一个类:
1 | class Person: NSObject { |
reetings(person:)
函数使用函数表派发来调用 sayHi()
。 就像我们期望的, “Hello” 会被打印。没什么好讲的地方, 那现在让我们继承 Person
。
1 | class MisunderstoodPerson: Person {} |
可以看到去sayHi()
函数是在 extension 里声明的, 会使用消息机制进行调用. 当greetings(person:)
被触发时, sayHi()
会通过函数表被派发到 Person
对象,而misunderstoodPerson
重写之后会使用消息机制,而 MisunderstoodPerson
的函数表依旧保留了 Person
的实现,紧接着歧义就产生了。
在这里的解决方法是保证函数使用相同的消息派发机制。你可以给函数加上 dynamic
修饰符,或者是把函数的实现从 extension 移动到类最初声明的作用域里。
理解了 Swift 的派发方式,就能够理解这个行为产生的原因了,虽然 Swift 不应该让我们遇到这问题。
####SR-103
这个 Swift bug 触发了定义在协议拓展的默认实现,即使是子类已经实现这个函数的情况下。为了说明这个问题,我们先定义一个协议,并且给里面的函数一个默认实现。
1 | protocol Greetable { |
现在, 让我们定义一个遵守了这个协议的类。先定义一个 Person
类, 遵守 Greetable
协议,然后定义一个子类 LoudPerson
,重写sayHi()
方法。
1 | class Person: Greetable { |
你们发现 LoudPerson
实现的函数前面没有 override
修饰,这是一个提示,也许代码不会像我们设想的那样运行。在这个例子里,LoudPerson
没有在 Greetable
的协议记录表里成功注册,当 sayHi()
通过 Greetable
协议派发时,默认的实现就会被调用。
解决的方法就是,在类声明的作用域里就要提供所有协议里定义的函数,即使已经有默认实现。或者,你可以在类的前面加上一个final
修饰符,保证这个类不会被继承。
Doug Gregor 在 Swift-Evolution 邮件列表里提到,通过显式地重新把函数声明为类的函数,就可以解决这个问题,并且不会偏离我们的设想。
因吹斯汀 Error (Interesting Error)
有一个很好玩的编译错误, 可以窥见到 Swift 的计划. 就像之前说的, 类拓展使用直接派发, 所以你试图 override 一个声明在 extension 里的函数的时候会发生什么?
1 | class MyClass { |
上面的代码会触发一个编译错误 Declarations in extensions can not be overridden yet
(声明在 extension 里的方法不可以被重写)。这可能是 Swift 团队打算加强函数表派发的一个征兆。又或者这只是我过度解读,觉得这门语言可以优化的地方。
致谢 Thanks
我希望了解函数派发机制的过程中你感受到了乐趣,并且可以帮助你更好的理解 Swift。虽然我抱怨了 NSObject
相关的一些东西,但我还是觉得 Swift 提供了高性能的可能性,我只是希望可以有足够简单的方式,让这篇博客没有存在的必要。
原文:
- 原文: Method Dispatch in Swift
- 作者: Brain King