深刻理解 Swift 派发机制

原文: Method Dispatch in Swift
做者: Brain King
译者: kemchenjhtml

译者注:

以前看了不少关于 Swift 派发机制的内容, 但感受没有一篇可以完全讲清楚这件事情, 看完了这篇文章以后我对 Swift 的派发机制才创建起了初步的认知.git

正文

clipboard.png

一张表总结引用类型, 修饰符和它们对于 Swift 函数派发方式的影响.github

函数派发就是程序判断使用哪一种途径去调用一个函数的机制. 每次函数被调用时都会被触发, 但你又不会太留意的一个东西. 了解派发机制对于写出高性能的代码来讲颇有必要, 并且也可以解释不少 Swift 里"奇怪"的行为.编程

编译型语言有三种基础的函数派发方式: 直接派发(Direct Dispatch), 函数表派发(Table Dispatch)消息机制派发(Message Dispatch), 下面我会仔细讲解这几种方式. 大多数语言都会支持一到两种, Java 默认使用函数表派发, 但你能够经过 final 修饰符修改为直接派发. C++ 默认使用直接派发, 但能够经过加上 virtual 修饰符来改为函数表派发. 而 Objective-C 则老是使用消息机制派发, 但容许开发者使用 C 直接派发来获取性能的提升. 这样的方式很是好, 但也给不少开发者带来了困扰,swift

译者注: 想要了解 Swift 底层结构的人, 极度推荐这段视频数组

派发方式 (Types of Dispatch )

程序派发的目的是为了告诉 CPU 须要被调用的函数在哪里, 在咱们深刻 Swift 派发机制以前, 先来了解一下这三种派发方式, 以及每种方式在动态性和性能之间的取舍.缓存

直接派发 (Direct Dispatch)

直接派发是最快的, 不止是由于须要调用的指令集会更少, 而且编译器还可以有很大的优化空间, 例如函数内联等, 但这不在这篇博客的讨论范围. 直接派发也有人称为静态调用.安全

然而, 对于编程来讲直接调用也是最大的局限, 并且由于缺少动态性因此没办法支持继承.app

函数表派发 (Table Dispatch)

函数表派发是编译型语言实现动态行为最多见的实现方式. 函数表使用了一个数组来存储类声明的每个函数的指针. 大部分语言把这个称为 "virtual table"(虚函数表), Swift 里称为 "witness table". 每个类都会维护一个函数表, 里面记录着类全部的函数, 若是父类函数被 override 的话, 表里面只会保存被 override 以后的函数. 一个子类新添加的函数, 都会被插入到这个数组的最后. 运行时会根据这一个表去决定实际要被调用的函数.框架

举个例子, 看看下面两个类:

class ParentClass {
    func method1() {}
    func method2() {}
}
class ChildClass: ParentClass {
    override func method2() {}
    func method3() {}
}

在这个状况下, 编译器会建立两个函数表, 一个是 ParentClass 的, 另外一个是 ChildClass的:

clipboard.png

这张表展现了 ParentClass 和 ChildClass 虚数表里 method1, method2, method3 在内存里的布局.

let obj = ChildClass()
obj.method2()

当一个函数被调用时, 会经历下面的几个过程:

  1. 读取对象 0xB00 的函数表.

  2. 读取函数指针的索引. 在这里, method2 的索引是1(偏移量), 也就是 0xB00 + 1.

  3. 跳到 0x222 (函数指针指向 0x222)

查表是一种简单, 易实现, 并且性能可预知的方式. 然而, 这种派发方式比起直接派发仍是慢一点. 从字节码角度来看, 多了两次读和一次跳转, 由此带来了性能的损耗. 另外一个慢的缘由在于编译器可能会因为函数内执行的任务致使没法优化. (若是函数带有反作用的话)

这种基于数组的实现, 缺陷在于函数表没法拓展. 子类会在虚数函数表的最后插入新的函数, 没有位置可让 extension 安全地插入函数. 这篇提案很详细地描述了这么作的局限.

消息机制派发 (Message Dispatch )

消息机制是调用函数最动态的方式. 也是 Cocoa 的基石, 这样的机制催生了 KVO, UIAppearenceCoreData 等功能. 这种运做方式的关键在于开发者能够在运行时改变函数的行为. 不止能够经过 swizzling 来改变, 甚至能够用 isa-swizzling 修改对象的继承关系, 能够在面向对象的基础上实现自定义派发.

举个例子, 看看下面两个类:

class ParentClass {
    dynamic func method1() {}
    dynamic func method2() {}
}
class ChildClass: ParentClass {
    override func method2() {}
    dynamic func method3() {}
}

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

clipboard.png

这张图很好地展现了 Swift 如何使用树来构建类和子类.

当一个消息被派发, 运行时会顺着类的继承关系向上查找应该被调用的函数. 若是你以为这样作效率很低, 它确实很低! 然而, 只要缓存创建了起来, 这个查找过程就会经过缓存来把性能提升到和函数表派发同样快. 但这只是消息机制的原理, 这里有一篇文章很深刻的讲解了具体的技术细节.

Swift 的派发机制

那么, 到底 Swift 是怎么派发的呢? 我没能找到一个很简明扼要的答案, 但这里有四个选择具体派发方式的因素存在:

  1. 声明的位置

  2. 引用类型

  3. 特定的行为

  4. 显式地优化(Visibility Optimizations)

在解释这些因素以前, 我有必要说清楚, Swift 没有在文档里具体写明何时会使用函数表何时使用消息机制. 惟一的承诺是使用 dynamic 修饰的时候会经过 Objective-C 的运行时进行消息机制派发. 下面我写的全部东西, 都只是我在 Swift 3.0 里测试出来的结果, 而且极可能在以后的版本更新里进行修改.

声明的位置 (Location Matters)

在 Swift 里, 一个函数有两个能够声明的位置: 类型声明的做用域, 和 extension. 根据声明类型的不一样, 也会有不一样的派发方式.

class MyClass {
    func mainMethod() {}
}
extension MyClass {
    func extensionMethod() {}
}

上面的例子里, mainMethod 会使用函数表派发, 而 extensionMethod 则会使用直接派发. 当我第一次发现这件事情的时候以为很意外, 直觉上这两个函数的声明方式并无那么大的差别. 下面是我根据类型, 声明位置总结出来的函数派发方式的表格.

clipboard.png

这张表格展现了默认状况下 Swift 使用的派发方式.

总结起来有这么几点:

  • 值类型老是会使用直接派发, 简单易懂

  • 而协议和类的 extension 都会使用直接派发

  • NSObject 的 extension 会使用消息机制进行派发

  • NSObject 声明做用域里的函数都会使用函数表进行派发.

  • 协议里声明的, 而且带有默认实现的函数会使用函数表进行派发

引用类型 (Reference Type Matters)

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

protocol MyProtocol {
}
struct MyStruct: MyProtocol {
}
extension MyStruct {
    func extensionMethod() {
        print("结构体")
    }
}
extension MyProtocol {
    func extensionMethod() {
        print("协议")
    }
}
 
let myStruct = MyStruct()
let proto: MyProtocol = myStruct
 
myStruct.extensionMethod() // -> “结构体”
proto.extensionMethod() // -> “协议”

刚接触 Swift 的人可能会认为 proto.extensionMethod() 调用的是结构体里的实现. 可是, 引用的类型决定了派发的方式, 协议拓展里的函数会使用直接调用. 若是把 extensionMethod 的声明移动到协议的声明位置的话, 则会使用函数表派发, 最终就会调用结构体里的实现. 而且要记得, 若是两种声明方式都使用了直接派发的话, 基于直接派发的运做方式, 咱们不可能实现预想的 override 行为. 这对于不少从 Objective-C 过渡过来的开发者是反直觉的.

Swift JIRA(缺陷跟踪管理系统) 也发现了几个 bugs, Swfit-Evolution 邮件列表里有一大堆讨论, 也有一大堆博客讨论过这个. 可是, 这好像是故意这么作的, 虽然官方文档没有提过这件事情

指定派发方式 (Specifying Dispatch Behavior)

Swift 有一些修饰符能够指定派发方式.

final

final 容许类里面的函数使用直接派发. 这个修饰符会让函数失去动态性. 任何函数均可以使用这个修饰符, 就算是 extension 里原本就是直接派发的函数. 这也会让 Objective-C 的运行时获取不到这个函数, 不会生成相应的 selector.

dynamic

dynamic 可让类里面的函数使用消息机制派发. 使用 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, 由于意图更加明显.

译者注: 我我的感受, 这这主要是为了跟 Objective-C 兼容用的, final 等原生关键词, 是让 Swift 写服务端之类的代码的时候能够有原生的关键词可使用.

final @objc

能够在标记为 final 的同时, 也使用 @objc 来让函数可使用消息机制派发. 这么作的结果就是, 调用函数的时候会使用直接派发, 但也会在 Objective-C 的运行时里注册响应的 selector. 函数能够响应 perform(selector:) 以及别的 Objective-C 特性, 但在直接调用时又能够有直接派发的性能.

@inline

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

修饰符总结 (Modifier Overview)

clipboard.png

这张图总结这些修饰符对于 Swift 派发方式的影响.

若是你想查看上面全部例子的话, 请看这里.

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

Swift 会尽最大能力去优化函数派发的方式. 例如, 若是你有一个函数历来没有 override, Swift 就会检车而且在可能的状况下使用直接派发. 这个优化大多数状况下都表现得很好, 但对于使用了 target / action 模式的 Cocoa 开发者就不那么友好了. 例如:

override func viewDidLoad() {
    super.viewDidLoad()
    navigationItem.rightBarButtonItem = UIBarButtonItem(
        title: "登陆", 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 上, 依赖于 proxy 和 NSInvocation 的代码.

另外一个须要注意的是, 若是你没有使用 dynamic 修饰的话, 这个优化会默认让 KVO 失效. 若是一个属性绑定了 KVO 的话, 而这个属性的 getter 和 setter 会被优化为直接派发, 代码依旧能够经过编译, 不过动态生成的 KVO 函数就不会被触发.

Swift 的博客有一篇很赞的文章描述了相关的细节, 和这些优化背后的考虑.

派发总结 (Dispatch Summary)

这里有一大堆规则要记住, 因此我整理了一个表格:

clipboard.png

这张表总结引用类型, 修饰符和它们对于 Swift 函数派发的影响

NSObject 以及动态性的损失 (NSObject and the Loss of Dynamic Behavior)

不久以前还有一群 Cocoa 开发者讨论动态行为带来的问题. 这段讨论颇有趣, 提了一大堆不一样的观点. 我但愿能够在这里继续探讨一下, 有几个 Swift 的派发方式我以为损害了动态性, 顺便说一下个人解决方案.

NSObject 的函数表派发 (Table Dispatch in NSObject)

上面, 我提到 NSObject 子类定义里的函数会使用函数表派发. 但我以为很迷惑, 很难解释清楚, 而且因为下面几个缘由, 这也只带来了一点点性能的提高:

  • 大部分 NSObject 的子类都是在 obj_msgSend 的基础上构建的. 我很怀疑这些派发方式的优化, 实际到底会给 Cocoa 的子类带来多大的提高.

  • 大多数 Swift 的 NSObject 子类都会使用 extension 进行拓展, 都没办法使用这种优化.

最后, 有一些小细节会让派发方式变得很复杂.

派发方式的优化破坏了 NSObject 的功能 (Dispatch Upgrades Breaking NSObject Features)

性能提高很棒, 我很喜欢 Swift 对于派发方式的优化. 可是, UIView 子类颜色的属性理论上性能的提高破坏了 UIKit 现有的模式.

原文: However, having a theoretical performance boost in my UIView subclass color property breaking an established pattern in UIKit is damaging to the language.

NSObject 做为一个选择 (NSObject as a Choice)

使用静态派发的话结构体是个不错的选择, 而使用消息机制派发的话则能够考虑 NSObject. 如今, 若是你想跟一个刚学 Swift 的开发者解释为何某个东西是一个 NSObject 的子类, 你不得不去介绍 Objective-C 以及这段历史. 如今没有任何理由去继承 NSObject 构建类, 除非你须要使用 Objective-C 构建的框架.

目前, NSObject 在 Swift 里的派发方式, 一句话总结就是复杂, 跟理想仍是有差距. 我比较想看到这个修改: 当你继承 NSObject 的时候, 这是一个你想要彻底使用动态消息机制的表现.

显式的动态性声明 (Implicit Dynamic Modification)

另外一个 Swift 能够改进的地方就是函数动态性的检测. 我以为在检测到一个函数被 #selector#keypath 引用时要自动把这些函数标记为 dynamic, 这样的话就会解决大部分 UIAppearance 的动态问题, 但也许有别的编译时的处理方式能够标记这些函数.

Error 以及 Bug (Errors and Bugs)

为了让咱们对 Swift 的派发方式有更多了解, 让咱们来看一下 Swift 开发者遇到过的 error.

SR-584

这个 Swift bug 是 Swift 函数派发的一个功能. 存在于 NSObject 子类声明的函数(函数表派发), 以及声明在 extension 的函数(消息机制派发)中. 为了更好地描述这个状况, 咱们先来建立一个类:

class Person: NSObject {
    func sayHi() {
        print("Hello")
    }
}
func greetings(person: Person) {
    person.sayHi()
}
greetings(person: Person()) // prints 'Hello'

greetings(person:) 函数使用函数表派发来调用 sayHi(). 就像咱们看到的, 指望的, "Hello" 会被打印. 没什么好讲的地方, 那如今让咱们继承 Persion:

class MisunderstoodPerson: Person {}
extension MisunderstoodPerson {
    override func sayHi() {
        print("No one gets me.")
    }
}

greetings(person: MisunderstoodPerson()) // prints 'Hello'

能够看到, sayHi() 函数是在 extension 里声明的, 会使用消息机制进行调用. 当greetings(person:) 被触发时, sayHi() 会经过函数表被派发到 Person 对象, 而misunderstoodPerson 重写以后会是用消息机制, 而 MisunderstoodPerson 的函数表依旧保留了 Person 的实现, 紧接着歧义就产生了.

在这里的解决方法是保证函数使用相同的消息派发机制. 你能够给函数加上 dynamic 修饰符, 或者是把函数的实现从 extension 移动到类最初声明的做用域里.

理解了 Swift 的派发方式, 就可以理解这个行为产生的缘由了, 虽然 Swift 不该该让咱们遇到这个问题.

SR-103

这个 Swift bug 触发了定义在协议拓展的默认实现, 即便是子类已经实现这个函数的状况下. 为了说明这个问题, 咱们先定义一个协议, 而且给里面的函数一个默认实现:

protocol Greetable {
    func sayHi()
}
extension Greetable {
    func sayHi() {
        print("Hello")
    }
}
func greetings(greeter: Greetable) {
    greeter.sayHi()
}

如今, 让咱们定义一个遵照了这个协议的类. 先定义一个 Person 类, 遵照 Greetable 协议, 而后定义一个子类 LoudPerson, 重写 sayHi() 方法.

class Person: Greetable {
}
class LoudPerson: Person {
    func sayHi() {
        print("HELLO")
    }
}

大家发现 LoudPerson 实现的函数前面没有 override 修饰, 这是一个提示, 也许代码不会像咱们设想的那样运行. 在这个例子里, LoudPerson 没有在 Greetable 的协议记录表(Protocol Witness Table)里成功注册, 当 sayHi() 经过 Greetable 协议派发时, 默认的实现就会被调用.

解决的方法就是, 在类声明的做用域里就要提供全部协议里定义的函数, 即便已经有默认实现. 或者, 你能够在类的前面加上一个 final 修饰符, 保证这个类不会被继承.

Doug Gregor 在 Swift-Evolution 邮件列表里提到, 经过显式地从新把函数声明为类的函数, 就能够解决这个问题, 而且不会偏离咱们的设想.

其它 bug (Other bugs)

Another bug that I thought I’d mention is SR-435. It involves two protocol extensions, where one extension is more specific than the other. The example in the bug shows one un-constrained extension, and one extension that is constrained to Equatable types. When the method is invoked inside a protocol, the more specific method is not called. I’m not sure if this always occurs or not, but seems important to keep an eye on.

另一个 bug 我在 SR-435 里已经提过了. 当有两个协议拓展, 而其中一个更加具体时就会触发. 例如, 有一个不受约束的 extension, 而另外一个被 Equatable 约束, 当这个方法经过协议派发, 约束比较多的那个 extension 的实现则不会被调用. 我不太肯定这是否是百分之百能复现, 但有必要留个心眼.

If you are aware of any other Swift dispatch bugs, drop me a line and I’ll update this blog post.

若是你发现了其它 Swift 派发的 bug 的话, @一下我我就会更新到这篇博客里.

有趣的 Error (Interesting Error)

有一个很好玩的编译错误, 能够窥见到 Swift 的计划. 就像以前说的, 类拓展使用直接派发, 因此你试图 override 一个声明在 extension 里的函数的时候会发生什么?

class MyClass {
}
extension MyClass {
    func extensionMethod() {}
}
 
class SubClass: MyClass {
    override func extensionMethod() {}
}

上面的代码会触发一个编译错误 Declarations in extensions can not be overridden yet(声明在 extension 里的方法不能够被重写). 这多是 Swift 团队打算增强函数表派发的一个征兆. 又或者这只是我过分解读, 以为这门语言能够优化的地方.

致谢 Thanks

我但愿了解函数派发机制的过程当中你感觉到了乐趣, 而且能够帮助你更好的理解 Swift. 虽然我抱怨了 NSObject 相关的一些东西, 但我仍是以为 Swift 提供了高性能的可能性, 我只是但愿能够有足够简单的方式, 让这篇博客没有存在的必要.

相关文章
相关标签/搜索