方法派发是一个术语,是指程序确定应该执行哪个操作的机制(通过操作,这里指的是一组指令)。有时,我们仅希望在运行时确定具体的方法行为。这种机制导致了方法派发机制的不同,每种机制各有其优缺点。
静态派发
有时也被称为直接调用/派发。
如果一个方法是静态派发的,编译器就可以在编译时找到指令所在的位置。这样,当调用这种函数时,系统将直接跳转到此函数 的内存地址以执行操作。这种直接行为导致执行速度非常快,并且还允许编译器执行各种优化,例如内联。实际上,由于性能的巨 大提高,编译管道中存在一个阶段,在此阶段,编译器将在适用的情况下尝试使函数静态化。这种优化称为去虚拟化[1]。
动态派发
使用这种方法,程序直到运行时才知道要选择哪种实现。
尽管静态派发是极为轻量的,但它限制了灵活性,特别是在多态方面。这也是为什么动态派发被 OOP 语言广泛支持的原因
每种语言都有其自己的机制来支持动态调度。Swift提供了两种实现动态性的方法:table 派发(表派发)和message 派发(消息 派发)。
Table 派发 (表派发)
这是编译语言中最常见的选择。通过这种方法,一个类与一个所谓的 virtual table 相关联,虚拟表包含了指向对应于该类的实际 实现的函数指针数组。
请注意,vtable 是在编译时构造的。因此,与静态派发相比,真多了两个附加指令(read和jump)。从理论上讲,表派发应该也 很快。
Message 派发(消息派发)
实际上,正是由 Objective-C 提供的这种机制(有时这被称作消息传递[2]),Swift 代码仅使用了 Objective-C 运行库。每次调用 Objective-C 方法时,调用都会传递给 objc_msgSend ,由后者负责处理查找工作。从技术上讲,运行时从给定类开始,抓取类的层 次结构以便确认调用哪个方法。
与表派发不同的是,message passing dictionary 在运行时可以发生修改,从而使我们能够在运行时调整程序的行为。利用这一特 点,Method swizzling 成为最流行的技术。
消息派发是三种派发(实际上是 4 种)中最具动态性的。作为交换,尽管系统通过实现缓存机制来保障查找的性能,但是其解决 实现的成本可能会稍微高一些。
这种机制是 Cocoa 框架的基石。查看代码 Swift 的源码,你会发现 KVO 就是利用 swizzling 实现的。
关于 Swift 派发的两个问题
对于一个给定函数,它使用了什么样的派发方式?证据是什么?
确定派发机制的方法
作为怀疑者,我对问题的第二部分更感兴趣。提出一个假设很容易,但是要一直进行检验并不是一件容易的事。经过数小时的搜寻,我碰巧知道了SIL文档[3],该文档合理地解释了派发策略的存在。这是一个简短的摘要:
如果函数使用表派发,那么它会出现在 vtable(或用于协议的 witness_table)中
sil_vtable Animal {
#Animal.makeSound!1: (Animal) -> () -> () : main.Animal.makeSound() -> () // Animal.makeSound()
......
}
如果函数是通过消息派发的,那么调用中应该出现关键字 volatile 。此外,你将找到两个标记 foreign 和 objc_method, 表明该函数/方法是使用 Objective-C 运行时调用的。
%14 = class_method [volatile] %13 : $Dog, #Dog.goWild!1.foreign : (Dog) -> () -> (), $@convention(objc_method) (Dog) -> ()
如果并没有出现上面两种情况,则说明该函数/方法是使用静态调度的。
注意
首先,Struct 或 任何值类型的函数必须静态派发。这是有道理的,因为它们永远不会被覆盖。
明确执行
具有 final 关键字的函数也会被静态派发。
具有 dynamic 关键字的函数将通过消息传递派发 —— 从 Swift 4.0 开始
带有 dynamic 关键字的函数对 Objective-C 是隐式可见的。同时,Swift 4 要求你使用 @objc 属性显式声明它。
普通扩展(即没有 final、dynamic、@objc)是直接派发的。现在,回想一下你可能曾经遇到过的编译错误:declarations in extensions cannot override yet. 这是因为这些功能当然是遵循静态派发的。
你可能会问:“如果我想让这些扩展成为动态的呢?”。你明白了!如果扩展名是动态的,那就可以覆盖它。
extension Animal {
func eat() { }
@objc dynamic func getWild() { }
}
class Dog: Animal {
override func eat() { } // Compiled error!
@objc dynamic override func getWild() { } // Ok :)
}
其他情况
protocol Noisy {
func makeNoise() -> Int // TABLE
}
extension Noisy {
func makeNoise() -> Int { return 0 } // TABLE
func isAnnoying() -> Bool { return true } // STATIC
}
class Animal: Noisy {
func makeNoise() -> Int { return 1 } // TABLE
func isAnnoying() -> Bool { return false } // TABLE
@objc func sleep() { } // Still TABLE
}
extension Animal {
func eat() { } // STATIC
@objc func getWild() { } // MESSAGE
}
Noisy.isAnnoying() 和 Animal.getWild() 是静态派发的,因为它们是扩展。
Noisy.makeNoise() 尽管具有默认实现,但仍使用表派发。
我们必须谨慎使用 isAnnoying()。请考虑以下两种用法。
animal2.isAnnoying() 选择协议扩展的实现(因为它是直接方法,不需要查找)。以这种方式使用可能是一个错误的来源
let animal1 = Animal()
print(animal1.isAnnoying()) // Value: false
let animal2: Noisy = Animal()
print(animal2.isAnnoying()) // Value: true
反过来说,animal1.makeNoise() 和 animal2.makeNoise() 产生相同的结果,因为协议是通过查找表来解决的。
被 @objc 关键字修饰的 @objc func sleep() 意味着该函数对 Objective-C 可见。但这并不一定意味着派发时一定选择 Objective-C 方法来执行。从函数调用的 SIL(见下文),我们可以看到 $@convention(method) ,这意味着选择 Swift 方法而不是 objc 方法。
原则是什么?
优先考虑直接调用(静态派发)。
如果需要覆盖,则表派发是下一个候选。
需要对 Objective-C 进行覆盖和可见性吗?然后发送消息。
另一个关键是明确性更好。。隐式推断(如带有 @objc 的扩展)可能会发生变化。
以下是一些常见案例的总结。建议你通过读取生成的 SIL 来进行双重检查。
直接调用 Table Message
明确执行 final, static — dynamic
值类型 所有方法 — —
协议 拓展中的方法 定义的方法 —
类 拓展中的方法 定义的方法 带有 @objc 的扩展
结论
在这篇文章中,我们了解了什么是方法派发,以及 Swift 中的不同派发类型。我们通过一些例子来了解 Swift 是如何解析特定函数 的。另外,通过阅读 SIL,我们收集了关于函数具体遵循哪种派发假设的证据。
静态派发由于其出色的性能而变得越来越重要。这就是为什么 Swift 是 Swift(雨燕)(相对于 Objective-C —— 一种动态语言)。
尽管消息派发的性能似乎比较差,但它提供了极大的灵活性,以至于支持一系列很酷的技术。
理解方法派发至关重要。它不仅可以帮助编写更优的代码,还可以避免一些奇怪的错误。
在上面提到的这些之中,我们抛弃了编译器的优化。编译器优化的代码的能力很大程度上取决于我们编写代码的方式 :)。
网友评论