Swift的函数派发
前言
对于Swift的学者来说函数派发有很大的误区:就是认为Swift沿用Objective-C的消息派发机制,且认为Swift与Objective-C公用一套Runtime。Objective-C是C语言实现的;Swift是C++实现的。倘若苹果大大真是这样做,那就没必要开发新的语言。
在写Swift代码的时候需要给方法加@objc、dynamic等关键字有什么作用,还有Swift到底函数调用的时候是怎么个方式,下面就和大家介绍Swift的函数派发。
概念介绍
函数派发就是程序判断使用哪种途径去调用一个函数的机制。每次函数被调用时都会被触发,但你又不会太留意的一个东西。了解派发机制对于写出高性能的代码来说很有必要。
编译型语言有三种基础的函数派发方式:直接(静态)派发(Direct Dispatch)、函数表派发(Table Dispatch) 和 消息机制派发(Message Dispatch)。
大多数语言都会支持一到两种,Java 默认使用函数表派发,但你可以通过 final 修饰符修改成直接派发;C++ 默认使用直接派发,但可以通过加上 virtual 修饰符来改成函数表派发;而 Objective-C 则总是使用消息机制派发,但允许开发者使用 C 直接派发来获取性能的提高。这样的方式非常好, 但也给很多开发者带来了困扰,。
程序派发的目的是为了告诉 CPU 需要被调用的函数在哪里, 在我们深入 Swift 派发机制之前, 先来了解一下这三种派发方式, 以及每种方式在动态性和性能之间的取舍.
直接(静态)派发 (Direct Dispatch):
直接派发也叫静态派发。在直接派发中,编译器直接找到相关指令的位置。当函数调用时,系统直接跳转到函数的内存地址执行操作。这样的好处就是执行快,同时允许编译器能够执行例如内联等优化。事实上,编译期在编译阶段为了能够获取最大的性能提升,都尽量将函数静态化。
函数表派发 (Table Dispatch):
函数表派发是编译型语言实现动态行为最常见的实现方式。函数表使用了一个数组来存储类声明的每一个函数的指针。大部分语言把这个称为 “virtual table”(虚函数表),Swift 里称为 “witness table”。每一个类都会维护一个函数表,里面记录着类所有的函数,如果父类函数被 override 的话,表里面只会保存被 override 之后的函数。一个子类新添加的函数,都会被插入到这个数组的最后。运行时会根据这一个表去决定实际要被调用的函数。举个例子:
class Animal {
func eat() {}
func drink() {}
}
class Bird: Animal {
override func eat() {}
func fly () {}
}
当执行Bird类中的eat函数时整个流程如下:
1.读取Bird类的函数表地址Oxb00。
2.读取到eat函数,也就是0xb00+1。
3.跳转到0x330执行具体的操作。
函数表从上面的分析中我们可以知道,要具体执行fly函数,就必须进行两次读取和一次跳转。同时编译器对于函数表派发的函数是无法执行优化的。这样,执行速度必然就变慢了。
这种基于数组的实现,缺陷在于函数表无法拓展。子类会在虚数函数表的最后插入新的函数,没有位置可以让 extension 安全地插入函数。
消息机制派发 (Message Dispatch):
Objc的函数派发都是基于消息派发的。这种机制极具动态性,既可以通过swizzling修改函数的实现,也可以通过isa-swizzling修改对象。
还是上面那段代码,然后看一下通过消息派发执行Bird中的drink函数的步骤:
1.到自己的方法列表中去找,结果没找到。
2.去它的父类Animal中去找,发现找到了,就执行相应的逻辑。
消息派发从中我们可以发现,如果这个方法在NSObject中,那么每次都要找好多次,就会非常慢。解决的方法就是利用方法缓存。这个查找过程就会通过缓存来把性能提高到和函数表派发一样快。
Swift 的派发机制
Swift 没有在文档里具体写明什么时候会使用函数表什么时候使用消息机制。唯一的承诺是使用 dynamic修饰的时候会通过 Objective-C 的运行时进行消息机制派发。
默认情况:
Swift函数声明位置有两种:a.类、结构体、枚举和协议的声明位置;b.它们的extension位置。
默认情况下,Swift 使用的派发方式总结起来有这么几点: 1.值类型、协议的extension、类的extension总是会使用直接派发;2.NSObject 的 extension 会使用消息机制进行派发;3.NSObject 声明作用域里的函数都会使用函数表进行派发;4.协议里声明的, 并且带有默认实现的函数会使用函数表进行派发。
Swift默认派发举个例:
Value Type(值类型):struct、enum等
struct MyStruct {
func structFunc() {} // Static直接派发
}
extension MyStruct {
func structExtensionFunc() {} // Static直接派发
}
大概意思懂就行了。 小试牛刀:
默认派发试炼刚接触 Swift 的人可能会认为 myProtocol.extensionMethod()调用的是结构体里的实现。分析:当myProtocol引用了myStruct且在自己的extension下有协议自己的extensionMethod函数,该函数调用的方式是直接派发static,所以当myProtocol.extensionMethod()找到了这个函数的地址直接执行了函数体。逆向思考:如果两种声明方式都使用了直接派发的话,基于直接派发的运作方式,我们不可能实现预想的 override行为。
关键字指定派发方式:
1.final:允许类里面的函数使用直接派发,这个修饰符会让函数失去动态性。任何函数都可以使用这个修饰符,就算是 extension 里本来就是直接派发的函数。这也会让 Objective-C 的运行时获取不到这个函数,不会生成相应的 selector。
2.dynamic:可以让类里面的函数使用消息机制派发。使用 dynamic必须导入 Foundation 框架,里面包括了 NSObject 和 Objective-C 的运行时。dynamic 可以让声明在 extension 里面的函数能够被 override。dynamic 可以用在所有 NSObject 的子类和 Swift 的原声类。
3.@objc 或 @nonobjc:都可以显式地声明了一个函数是否能被 Objective-C 的运行时捕获到。但使用 @objc 的典型例子就是给 selector 一个命名空间 @objc(abc_methodName),让这个函数可以被 Objective-C 的运行时调用。@nonobjc会改变派发的方式,可以用来禁止消息机制派发这个函数,不让这个函数注册到 Objective-C 的运行时里。我不确定这跟 final 有什么区别,因为从使用场景来说也几乎一样。我个人来说更喜欢 final,因为意图更加明显。
4.final 与 @objc同时使用:可以在标记为 final 的同时,也使用 @objc 来让函数可以使用消息机制派发。这么做的结果就是,调用函数的时候会使用直接派发,但也会在 Objective-C 的运行时里注册响应的 selector。函数可以响应 perform(selector:) 以及别的 Objective-C 特性,但在直接调用时又可以有直接派发的性能。
5. @inline:Swift 也支持 @inline,告诉编译器可以使用直接派发。有趣的是,dynamic @inline(__always) func dynamicOrDirect() {} 也可以通过编译!但这也只是告诉了编译器而已,实际上这个函数还是会使用消息机制派发。这样的写法看起来像是一个未定义的行为,应该避免这么做。
指定Modifiers可见的都会被优化 (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))
}
这里编译器会抛出一个错误: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 函数就不会被触发。
派发总结 (Dispatch Summary):
派发方式总结
NSObject 以及动态性的损失 (NSObject and the Loss of Dynamic Behavior)
NSObject 的函数表派发 (Table Dispatch in NSObject):
上面, 我提到NSObject子类定义里的函数会使用函数表派发。但我觉得很迷惑,很难解释清楚,并且由于下面几个原因,这也只带来了一点点性能的提升:
a.大部分NSObject的子类都是在obj_msgSend的基础上构建的。我很怀疑这些派发方式的优化,实际到底会给 Cocoa 的子类带来多大的提升。
b.大多数 Swift 的NSObject子类都会使用 extension 进行拓展, 都没办法使用这种优化。
最后, 有一些小细节会让派发方式变得很复杂。
派发方式的优化破坏了 NSObject 的功能,性能提升很棒,我很喜欢 Swift 对于派发方式的优化。但是,UIView子类颜色的属性理论上性能的提升破坏了 UIKit 现有的模式。
NSObject 作为一个选择 (NSObject as a Choice)
使用静态派发的话,结构体是个不错的选择;而使用消息机制派发的话则可以考虑 NSObject; 现在,如果你想跟一个刚学 Swift 的开发者解释为什么某个东西是一个 NSObject 的子类,你不得不去介绍 Objective-C 以及这段历史。现在没有任何理由去继承 NSObject 构建类,除非你需要使用 Objective-C 构建的框架。
显式的动态性声明 (Implicit Dynamic Modification)
另一个 Swift 可以改进的地方就是函数动态性的检测。我觉得在检测到一个函数被 #selector 和 #keypath 引用时要自动把这些函数标记为 dynamic,这样的话就会解决大部分 UIAppearance 的动态问题,但也许有别的编译时的处理方式可以标记这些函数。
看一下 Swift 开发者遇到过的 error:
歧义1:
在Swift4.2声明 Man 类并且在其 extension 里 override 继承下来的 eat() 函数,这个函数调用会采用消息机制进行调用,故需要在父类Person的eat函数硬性添加 @objc 和 dynamic 表示 Objective-C注册这一个方法,并使用 Objective-C的动态性。打印结果:Man eat。
倘若在Swift3下歧义1但是,在Swift3下父类 Person 的 eat函数可以不需要 @objc 和 dynamic 修饰并不会报错。打印结果是 Person eat。
当man.eat() 被触发时,eat()会通过函数表被派发到Person对象,而Man重写之后会是用消息机制,而Man的函数表依旧保留了Person的实现,紧接着歧义就产生了。
歧义2:
歧义2 声明协议和类 歧义2 调用协议函数相信大家都看得懂代码,打印结果是Hello。你们发现 Man 实现的函数前面没有 override 修饰,这是一个提示,也许代码不会像我们设想的那样运行。在这个例子里,Man没有在 Greetable 的协议记录表(Protocol Witness Table)里成功注册, 当 sayHi()通过 Greetable 协议派发时,默认的实现就会被调用。
解决的方法就是,1.在类声明的作用域里就要提供所有协议里定义的函数,即使已经有默认实现。2.你可以在类的前面加上一个 final 修饰符,保证这个类不会被继承。
有趣的error:
error上面的代码会触发一个编译错误 Declarations in extensions can not be overridden yet(声明在 extension 里的方法不可以被重写)。这可能是 Swift 团队打算加强函数表派发的一个征兆.。又或者这只是我过度解读, 觉得这门语言可以优化的地方。