什么是消息派发?
消息派发,英文名称 Method Dispatch,是指程序在运行过程中调用某个方法的时候决议使用哪个具体指令的过程。消息派发的行为在我们代码中时时刻刻的在发生。了解消息派发的机制对于我们日常写出相对高效的代码也是有利的,日常 Coding 的时候遇到一些派发相关的问题,也能做到心里有数。
对于编译型语言来讲,有主要三种类型的方法派发方式:Direct Dispatch,Table Dispatch 以及 Message Dispatch,前者也被称作 Static Dispatch,后两个为 Dynamic Dispatch。
方法派发类型
函数派发就是程序判断使用哪种途径去调用一个函数的机制,也就是CPU在内存中找到该函数地址并调用的过程。
Direct Dispatch
直接派发是三种派发方式中最快的。CPU直接按照函数地址调用,使用最少的指令集,办最快的事情。当编译器对程序进行优化的时候,也常常将函数内联,使之成为直接派发方式,优化执行速度。我们熟知的C++默认使用直接派发方式,在Swift中给函数加上final关键字,该函数也会变成直接派发的方式。当然,有利就有弊,直接派发最大的弊病就是没有动态性,不支持继承。
注:函数指针的方式,根据指针地址可直接调用函数。效率最高。
Table Dispatch
这种方式是编译型语言最常见的派发方式,他既保证了动态性也兼顾了执行效率。函数所在的类会维护一个”函数表”,也就是我们熟知的虚函数表,Swift 里称为 “witness table”。该函数表存取了每个函数实现的指针。每个类的vtable在编译时就会被构建,所以与直接派发相比只多出了两个读取的工作: 读取该类的vtable和该函数的指针。理论上说,函数表派发也是一种高效的方式。不过和直接派发相比,编译器对某些含有副作用的函数却无法优化,也是导致函数表派发变慢的原因之一。
查表是一种简单, 易实现, 而且性能可预知的方式. 然而, 这种派发方式比起直接派发还是慢一点. 从字节码角度来看, 多了两次读和一次跳转, 由此带来了性能的损耗. 另一个慢的原因是我们开头也说了,编译器对某些含有副作用的函数却无法优化,也是导致函数表派发变慢的原因之一。 这种基于数组的实现, 缺陷在于函数表无法拓展. 子类会在虚数函数表的最后插入新的函数, 没有位置可以让 extension 安全地插入函数。
V-Table
对于 V-Table 的应用场景下,每一个类都会维护一个函数表,里面记录着该类所有的函数指针,主要包含:
- 由父类继承而来的方法执行地址;
- 如果子类覆写了父类方法的话,表格里面就会保存被重载之后的函数。
- 子类新添加的函数会插入到这个表格的末尾
在程序运行期间会根据这个表去查询真正要调用的函数是哪一个。这种特性就极大的提升了代码的灵活性,也是 Java,C++ 等语言得以支持多态这种语言特性的基石。当然,相对于静态派发而言,表格派发则多了很多的隐式花销,因为函数调用不再是直接调用了,而是需要通过先行查表的形式找到真实的函数指针来执行,编译器也无法再进行诸如 inline 等编译期优化了。
注:相当于通过数组管理一个类的函数地址,子类的copy父类函数地址,并将子类方法覆盖。并通过这个方式管理继承关系。分类不用想了。
PWT
对于 Swift 来说,还有更为重要的 Protocol,对于符合同一协议的对象本身是不一定拥有继承关系的,因此 V-Table 就没法使用了。这里,Swift 使用了 Protocol Witness Table 的数据结构达到动态查询协议方法的目的。如果将上面的例子中的 Drawable 抽象成协议。
简单来说,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 指针遍历完整个继承链的方法。
介绍完这三种方法派发形式之后,大家有了一个概念之后,我们看下几个主流语言目前是什么情况。
直接调用 | 虚拟表 | 消息机制 | |
---|---|---|---|
c | Y | N | N |
java | Y | Y | N |
c++ | Y | Y | N |
OC | Y | N | Y |
SIL
在讲解 Swift 的方法派发之前,我们先了解一下 SIL,SIL 全程是 Swift Intermediate Language,它是为 Swift 编程语言而设计的高级中间语言,它工作在编译器前端,其作为 AST 和 LLVM IR 的中间过程的主要产物,主要针对 Swift 语言做语义分析,代码优化,泛型特化等事情。从 SIL 的生成文件我们能够一窥方法派发的一些门道。
可以使用 swiftc 生成某个 Swift 文件对应的 SIL 文件。swiftc -emit-sil test.swift > test.sil
SIL 几个方法
因为我们在讲述方法派发,因此我们关心几个相关的 SIL 语法,主要有如下四个:
class_method
Swift 语义指令使用 V-Table 进行动态派发。
objc_method
使用 Objective-C 的运行时进行派发。
witness_method
协议目击表(Protocol Witness Table)中查询方法。
function_ref
代表一个函数的引用。
apply
可以将 apply 理解为调用某个函数。
如果想了解完整的其他详细参数,可以到 Swift 的 Github 页面查看。
OK,了解完 SIL 之后,进入正题。
Swift 中的方法派发是什么样子的
Swift 中支持以上三种派发方式。
首先,因为其背负的历史负担,必须支持 Objective-C Runtime,因此一定会支持消息派发方式,又由于值类型和传统类类型的存在,Swift 支持了以上三种。
首先我们需要先知道影响目前 Swift 中方法派发形式的几个要素:
- 声明方法的地方
- 修饰符修改
- 编译器的可见优化
声明位置
首先,不同位置的方法声明,派发的时候各不相同。
-
值类型毫无疑问进行直接派发;
-
存在继承关系可能的类的初始声明下的方法采用虚函数表派发(V-Table);
-
对协议的扩展或者类的扩展进采用直接派发(非
NSObject
下的 extension 的方法均为直接派发); -
协议的初始声明下的方法均采用协议目击表派发(PWT)
在Swift Protocol中,如果该方法在定义中有声明,则采用动态派发,如果在定义中未声明该方法,则默认使用静态派发的方式。
直接定义 | 分类 | |
---|---|---|
值类型 | 静态 | 静态 |
协议 | PWT | 静态 |
类 | 虚表 | 静态 |
案例
这其中有个问题是日常开发过程中也经常会遇到的,如下代码。
我们在某个类型的扩展中定义了方法,协议扩展中也定义了同名方法,在进行调用的时候,因为声明类型的不同,表现完全不一样,通过调用的结果就能知道是静态派发。
而当我们将该同名方法声明在 MyProtocol 中的时候,这也变成了原始的协议表格派发形式了,通过 PWT 来查找该协议方法的具体实现。
协议扩展是严格的静态派发的,因为没有虚函数表可以把方法的实现地址放进去。扩展能够实现协议的默认实现,那是因为符合这个协议的类型会把扩展的实现方法存到自己的协议目击表里(PWT),而且只有在这些类型没有实现这些协议方法的时候。
因为协议目击表中包含定义在 Protocol 中的方法而已,因此协议扩展方法(不应该算作 Protocol 的一部分)并不知道把这些实现置于何处。因此调用协议扩展方法是不经过虚函数表派发的,它们唯一能做的事情就是静态调用扩展中的方法实现。而且因为是静态派发,也就不存在重载这一支持。
唯一能够让协议扩展方法进行虚函数表派发的方式就是为 Extension 增加虚函数表,但是在编译期间,符合该协议的类型并没有必要获知该协议所有扩展中的方法,因此没办法说将所有扩展的方法均添加到自身的 PWT 中。
显式指定派发方式
Swift 语言自身提供了一些修饰符,能够对方法的派发方式做变更,主要有如下几种。
- 默认的类方法出现,意味着默认类的方法采用表格派发;
- 标记了
final
的方法没有出现在虚函数表中; @objc
标记的方法和默认的方法相同;dynamic
标记的方法没有出现在虚函数表中;
那我们那几句执行代码如今都什么样子,我们一个一个来看下。
default class method
默认为表派发
final
final 关键字能够确保方法进行静态派发,添加了该关键字的方法也会对 Objective-C 运行时隐藏该方法,使得 OC 运行时不会为该方法生成 selector.
function_ref 表明直接拿到函数指针,将实例对象传递给该方法,apply 就是执行的意思。
dynamic
dynamic 是确保该方法能够进行消息派发,也就是说该确保该方法是通过 obj_msgSend() 来调用的。在 Swift 4 之前,dynamic 关键字是和 @objc 关键字一起出现的,到了Swift4 之后,官方将这两个关键字的功能拆开了。@objc 只确保 Objective-C 可见。
我们看到 MyClass 的虚函数表中,使用 dynamic 修饰的方法 performDynamicOperation
并不在其中,这也证明了该方法的派发已经不是 Table Dispatch 了。
从这里首先我们能知道 dynamic 修饰的方法不是直接派发,也不是表格派发,是将该实例对象转换成 Objc 运行时的代码进行执行。
@objc
这两个关键字是来确保方法是否能够被 Objective-C 运行时看到。@objc 这个经常是为了暴露 Swift 方法给 Objc 来使用。一定要知道 @objc 并不是改变派发方式,在 SIL 文件中我们看到 performOcOperation 和默认类方法 performOperation 一样均在 V-Table 中出现。
@objc 标记的方法和默认的方法完全一致,都是进行表格派发。
关于内联
内联是自从 C 语言开始,编译器针对函数调用的一种优化措施。一旦编译器判定某个函数能够进行内联优化,则会将一次函数调用直接在当前调用处展开,就如同不存在函数本身,而直接将函数中的代码插入到当前调用处。
因为一次函数调用的代价是存在的,虽然一次函数调用的代价受到多种因素影响,但是有几个关键的因素,比如说函数调用栈的开辟,间接指针跳转以及分配寄存器。
当然,编译器是需要很多多种情况衡量这个方法是否适合进行内联优化的,具体的因素在下面的参考文献里已经列举出来了,我就不详细说了,感兴趣的同学可以看下这篇文章了解下,我这里大致列举了几种:
- 函数体中有循环;
- 函数体过长(想象一下,要是都展开了,无形中增加了包体积,重复代码);
- 递归函数;
内联也不是都是好处,比如说增大二进制体积等。所以针对内联与否,编译器是有一套完整的考量标准的。当然,所以 Swift 而言,有内联参数来主动干预, @inline(__always)
以及 @inline(never)
.
Swift 支持内联,该参数告知编译器进行派发优化,使用直接派发(静态派发)的形式。当然具体能不能做成内联函数还是要看编译器怎么抉择了。
OK,通过修饰符来主动修改派发方式大体是这样,
发送方式 | |
---|---|
final | 直接派发 |
dynamic | 消息发送机制 |
@objc | 不改变发送方式 |
@inline | 指定直接派发 |
可见性带来的优化
和以往大家对编译文件的认识一样,编译器是针对独立的每一个源文件进行编译,优化等一系列操作。当编译器优化当前文件的时候,只会考虑当前的文件,比如 main.swift 文件,编译器只知道有个函数为 getElement,但是不知道其具体实现。而在编译器准备优化 utils.swift 文件时,因为它并不知道具体类型 T 是什么,这个时候它就没法做泛型参数具体化,只能生成一个泛型版本的函数实现,然后会在运行时阶段动态的根据传递具体的类型来确定返回什么。
通过开启 Whole Module Optimizations
开关,使得 Swift 编译器能够获取很多信息,包括类型的继承关系以及 Access Control 的情况来进行优化。Swift 3 的 Whole-module optimization
总结
通过上面的讲解,我们可以列出目前所有的方法派发情况:
直接派发 | 表派发 | 消息派发 | |
---|---|---|---|
值类型 | all | N/A | N/A |
Protocol | 分类 | 直接定义 | @objc 声明 (奇怪的地方,它不改变消息派发类型) |
类 | 分类(final) | 直接定义 | dynamic |
NSObject | final | 直接定义 | 分类(dynamic) |
虽然现在编译器优化方面做的已经足够优秀了,但是对于代码开发者的我们还是要比编译器更能应付业务逻辑的复杂代码,而这些复杂代码导致可以优化的地方被编译器忽略。因此,还是需要 Coder 自己能够主观上有认识到这些可以优化的一些点,从小处着手,写出更加高效的代码。