转载自Chen's Blog
什么是消息派发
消息派发,英文名称Method Dispatch, 是指程序在运行过程中调用某个方法的时候决议使用哪个具体指令的过程。消息派发的行为在我们代码中时时刻刻的在发生。了解消息派发的机制对于我们日常写出高效的代码也是有利的,日常Coding时候遇到一些派发相关的问题,也能做到心里有数。
对于编译语言来说,主要有三种类型的方法派发方式: Direct Dispatch,Table Dispatch以及Message Dispatch,前者也被称作为Static Dispatch, 后两个为Dynamic Dispatch
方法派发类型
本质上来讲,派发的目的就是告诉CPU, 在某个特定方法被调用的时候去内存的哪个地址去读执行代码。下面我们先了解下这三种类型的派发形式,这三种可以说各有优劣
Direct Dispatch 直接派发
直接派发也叫静态派发。它是这三种形式中最快速的,不仅仅是底层翻译的汇编指令少,也是因为编译器可以针对这种情况做更多优化的事情,比如函数内联。C语言就是这种的典型代表,然而,对于日渐复杂的编程场景来说,静态派发限制非常大,我们必须在代码运行之前把事情安排的明明白白的,这个极大的限制了代码书写的灵活性。一句话总结,编译器在编译期间就已经确定了的推断路径,运行期间按照既定路线走就行。
Table Dispatch 表派发
函数表派发这种是编译型语言中最常见的动态派发实现形式,编译器层面使用一个表格结构来存储类型声明中的每一个函数指针。C++ 语言把这种表格称作虚函数表(virtual table,简称 V-Table),而在 Swift 中,针对拥有继承的 Class 类型来说,依然采用了 V-Table 这种实现形式达到对多态的支持,而针对值类型由于 Protocol 产生的多态而采用了另外一种函数表派发形式,这个表格被称为协议目击表 (Protocol Witness Table,简称 PWT),这个暂时略去不表。
V-Table 虚函数表派发
对于 V-Table 的应用场景下,每一个类都会维护一个函数表,里面记录着该类所有的函数指针,主要包含:
- 由父类继承而来的方法执行地址;
- 如果子类覆写了父类方法的话,表格里面就会保存被重载之后的函数。
- 子类新添加的函数会插入到这个表格的末尾
在程序运行期间会根据这个表去查询真正要调用的函数是哪一个。这种特性就极大的提升了代码的灵活性,也是 Java,C++ 等语言得以支持多态这种语言特性的基石。当然,相对于静态派发而言,表格派发则多了很多的隐式花销,因为函数调用不再是直接调用了,而是需要通过先行查表的形式找到真实的函数指针来执行,编译器也无法再进行诸如 inline 等编译期优化了。
我们以 WWDC 的某个例子来大概说明下 V-Table 的具体派发过程,如下代码,共有三个 Class,其中 Drawable 是基类,子类化的 Point 和 Line。
class Drawable {
func draw() {
}
}
class Point: Drawable {
var x, y: Double
override func draw() {
}
override init() {
x = 1
y = 1
}
}
class Line: Drawable {
var x1, y1, x2, y2: Double
override init() {
x1 = 1
x2 = 1
y1 = 1
y2 = 1
}
override func draw() {
}
}
var drawables: [Drawable] = [Point(), Line()]
for d in drawables {
d.draw()
}
在drawables真正调用的时候(大家注意下,数组的这个结构中除了 refCount 做引用计数之外,每个元素都是 1 个字长的引用,也就是每一个具体实例的地址 ),先通过指针找到在堆上的实例,通过实例中存储的类型信息中的虚函数表(V-Table)找到具体的函数执行地址。
每一个类的元类型信息中存储着虚函数表,表中就是所有该类型真实的函数地址。
PWT Protocol Witness Table
对于 Swift 来说,还有更为重要的 Protocol,对于符合同一协议的对象本身是不一定拥有继承关系的,因此 V-Table 就没法使用了。这里,Swift 使用了 Protocol Witness Table 的数据结构达到动态查询协议方法的目的。如果将上面的例子中的 Drawable 抽象成协议。
protocol Drawable {
func draw()
}
class Point: Drawable {
var x, y: Double
func draw() {
}
init() {
x = 1
y = 1
}
}
class Line: Drawable {
var x1, y1, x2, y2: Double
init() {
x1 = 1
x2 = 1
y1 = 1
y2 = 1
}
func draw() {
}
}
var drawables: [Drawable] = [Point(), Line()]
for d in drawables {
d.draw()
}
简单来说,Swift 会为每一个实现了该协议的对象生成一个大小一致的结构体,这个结构体被称为 Existential Container,它内部就包含了 PWT,而这个 Table 中的每一个条目指向了符合该协议的类型信息,而除了 PWT,该结构体中还保留了三个字长的 valueBuffer 用以存储数据成员,一个 Value Witness Table 存储着一组函数的执行地址,这些函数都是针对前面数据成员的具体操作方法,细节这里不展开讲了。 PWT 中包含着该实例实现的协议方法实现地址。
所以,本质上来讲,Protocol 的消息派发要比 V-Table 更加复杂,但是还是基于这种表格查询的形式找到真正需要执行的方法地址。
Message Dispatch 消息转发
第三种就是消息派发类型,作为一个使用 Objective-C 这么多年的老同志,想必对 sendMessage 不能更熟悉了,消息派发是目前最为动态的一种方式,这个也是整个 Cocoa 架的基础。像平时我们常用的 KVO ,Target-Action 等都建立在消息派发的基础之上,这也才有了 Objective-C 中常炒不衰的黑魔法 ── method swizzling ,你可以用这个调换函数执行地址。
关于基于 OC 层面运行时库的核心代码估计大家都已经看过。运行时通过查找该类的方法列表,同时通过 super class 回溯一直查找到该方法即可,这部份核心内容是 objc runtime。而我们知道 Objective-C 运行时的核心方法是 obj_msgSend,其会在类的继承链查找所有可能的方法。整个运行时消息的转发过程再发一次。
大家体会一下消息派发模式和之前的表格派发的区别,表格派发查询的表是固定的,子类也会将父类的可见方法继承过来(这也相对安全),而消息派发还可以动态的回溯继承链,子类无需复制父类方法,而是通过 superClass 指针遍历完整个继承链的方法。
介绍完这三种方法派发形式之后,大家有了一个概念之后,我们看下几个主流语言目前是什么情况。
| Direct Dispatch | Table Dispatch | Message Dispatch | |
|---|---|---|---|
| C | x | ||
| Java | x | x | |
| C++ | x | x | |
| Objective-C | x | x |
大家可能会说 Objective-C 不是能和 C++ 混编么?那它也支持 Table Dispatch 吧,这个是误解。 Objc 的历史地位和 C++ 是类似的,都是在 C 语言的基础之上的一门语言,而天然 Objective-C 就是采用的 Message Dispatch 形式,但是因为是基于 C 语言的,包括它的核心运行时都是使用 C 语言构建的,因而能很好的兼容 C,也自然支持 C 的 Direct Dispatch。 而 C++ 语言能够和 Objective-C 混编也算是一厢情愿吧,想想被 Objective-C++ 支配的恐惧。
SIL
在讲解 Swift 的方法派发之前,我们先了解一下 SIL,SIL 全程是 Swift Intermediate Language,它是为 Swift 编程语言而设计的高级中间语言,它工作在编译器前端,其作为 AST 和 LLVM IR 的中间过程的主要产物,主要针对 Swift 语言做语义分析,代码优化,泛型特化等事情。从 SIL 的生成文件我们能够一窥方法派发的一些门道。
下图是一张 WWDC 上讲述 Swift 语言的编译前端的几个阶段。
可以使用swiftc生成某个Swift文件对应的SIL文件。
swift -emit-sil test.swift > test.sil
比如针对下面这份文件会生成对应的SIL如下
class MyClass {
func method() {
var num = 1
}
}
生成的SIL文件如下:
// ....
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
alloc_global @$s4main3objAA7MyClassCvp // id: %2
%3 = global_addr @$s4main3objAA7MyClassCvp : $*MyClass // users: %8, %7
%4 = metatype $@thick MyClass.Type // user: %6
// function_ref MyClass.__allocating_init()
%5 = function_ref @$s4main7MyClassCACycfC : $@convention(method) (@thick MyClass.Type) -> @owned MyClass // user: %6
%6 = apply %5(%4) : $@convention(method) (@thick MyClass.Type) -> @owned MyClass // user: %7
store %6 to %3 : $*MyClass // id: %7
%8 = load %3 : $*MyClass // users: %9, %10
%9 = class_method %8 : $MyClass, #MyClass.method : (MyClass) -> () -> (), $@convention(method) (@guaranteed MyClass) -> () // user: %10
%10 = apply %9(%8) : $@convention(method) (@guaranteed MyClass) -> ()
%11 = integer_literal $Builtin.Int32, 0 // user: %12
%12 = struct $Int32 (%11 : $Builtin.Int32) // user: %13
return %12 : $Int32 // id: %13
} // end sil function 'main'
// ....
sil_vtable MyClass {
#MyClass.method: (MyClass) -> () -> () : @$s4main7MyClassC6methodyyF // MyClass.method()
#MyClass.init!allocator: (MyClass.Type) -> () -> MyClass : @$s4main7MyClassCACycfC // MyClass.__allocating_init()
#MyClass.deinit!deallocator: @$s4main7MyClassCfD // MyClass.__deallocating_deinit
}
SIL 几个方法
因为我们在讲述方法派发,因此我们关心几个相关的 SIL 语法,主要有如下四个:
class_method
关于 class_method,我们可以在VTables 这一 Section 找到说明,证明其表示当前 Swift 语义指令使用 V-Table 进行动态派发。
SIL represents dynamic dispatch for class methods using the class_method, super_method, objc_method, and objc_super_method instructions.
The potential destinations for class_method and super_method are tracked in
sil_vtabledeclarations for every class type.
objc_method
这个字面很明显,使用 Objective-C 的运行时进行派发。在 Github 主页也有说明
Performs Objective-C method dispatch using
objc_msgSend().
witness_method
这个方法的意思表示的语义是在协议目击表(Protocol Witness Table)中查询方法。
function_ref
代表一个函数的引用。
apply
可以将 apply 理解为调用某个函数。
如果想了解完整的其他详细参数,可以到 Swift 的 Github 页面查看。
OK,了解完 SIL 之后,进入正题。
Swift中的方法派发是什么样子的
首先一句话,Swift中支持以上三种派发方法,首先,因为其背负的历史,必须支持Objective-C Runtime,因此一定会支持消息派发方式,Swift 支持了以上三种。
首先我们需要先知道影响目前Swift中方法派发形式的几个要素:
- 声明方法的地方
- 修饰符修改
- 编译器的可见优化
声明位置
首先,不同位置的方法声明,派发的时候各个不相同,先看个例子,我们为类和其扩展分别定义了两个方法。
class MyClass {
func mainMethod() {}
}
extension MyClass {
func extensionMethod(){}
}
针对上面这段代码,实际上MyClass使用的是函数表派发,而其分类中的方法是使用的静态派发。
- 值类型毫无疑问进行直接派发
- 存在继承关系可能的类的初始声明下的方法采用虚函数表派发(V-Table);
- 对协议的扩展或者类的扩展进采用直接派发(非
NSObject下的 extension 的方法均为直接派发); - 协议的初始声明下的方法均采用协议目击表派发(PWT)
| Initial Declaration | Extension | |
|---|---|---|
| Value Type | Static | Static |
| Protocol | Table(PWT) | Static |
| Class | Table(V-Table) | Static |
| NSObject. | Message Send | Message Send |
案例
这其中有个问题是日常开发过程中也经常会遇到的,如下代码。
protocol MyProtocol {
// When declare in protocol. method is public.
// direct dispatch -> table dispatch
func extensionMethod()
}
struct MyStruct: MyProtocol {
}
extension MyStruct {
func extensionMethod() {
print("extensionMethod in MyStruct")
}
}
extension MyProtocol {
func extensionMethod() {
print("extensionMethod in MyProtocol")
}
}
let myStruct = MyStruct()
let myProtocol: MyProtocol = myStruct
myStruct.extensionMethod()
myProtocol.extensionMethod()