Swift 的消息派发
什么是消息派发?所谓的消息派发就是当调用一个方法时程序如何选择指令去执行。
编译型语言有三种主要的派发方法。
直接派发
直接派发是最快速的派发方式,这不仅是因为它产生的汇编指令少,更是因为编译器会执行各种技巧,比如说内联代码。
但是,从编码的角度来看,直接派发也是最受限制的,它不够动态,无法支持子类化。
Swift 的一大优势是支持值类型,而值类型的方法都是通过直接派发的方式进行调用的。另外,添加了 final
关键字的方法和在 extension
中实现的方法也是直接派发的。如果要归纳总结一下的话,那就是无法被继承重写的方法都是直接派发的。
函数表派发
函数表派发是编译语言中最常见的动态行为实现。动态派发会为类中定义的每一个可重写方法创建一个函数指针数组。大多数语言将其称为虚拟表(Virtual Method Table,即VTable),但 Swift 使用另一个术语“见证表”。对于被重写的方法,每个子类都会持有一份不同于父类的函数指针。
1 | class ClassA { |
如上面的代码,在 ClassA
中,创建了一个数组来存储函数指针,此处假设函数指针排列如下:
1 | 方法名 地址 |
再来看看ClassB
中的函数指针排列:
1 | 方法名 地址 |
因为ClassB
中的重写了method2,所以method2的指针地址被重新生成,而method1还是保持不变,新增的方法地址被添加到数组的末尾。
这种派发方式相比直接派发依然很慢。从字节码的角度来看,这里存在两次额外的读取和一次跳转造成一些开销。而被认为很慢的另一个原因是编译器无法根据方法内部的情况执行任何优化。
这种基于数组的实现的一个缺点就是 extension
不能向函数表中继续添加指针。由于子类将新方法到函数表的末尾,因此也就没有位置让扩展安全地将函数指针添加进来。
而在 protocol 中定义的方法,则会另外生成一个 witness table,即 WTable
。需要注意的是,遵循协议的对象必须明确指定协议类型才会从 WTable
查找方法调用,否则会由于类型推导而使用对象本身的派发方式。
消息机制派发
消息派发是最动态的类型,也是最慢的一种。动态派发中函数表在编译期间生成,而消息派发则只能在运行时才能确定具体调用的是哪个方法,比如说可能存在的方法交换和动态添加等。Cocoa 框架中大量使用了消息机制来做派发,比如说KVO、Core Data等等。
当消息被分发时,runtime 会在类的层次结构中去寻找确定哪个方法被调用。这并不是特别慢,因为其是由高性能缓存实现的。
编译器总是尝试将派发方式升级为静态派发,除非手动指定了
dynamic
和@objc
关键字。
那么 Swift 到底是按照什么规则进行消息派发的呢?
由上图可以总结出如下规则:
- 值类型始终使用直接派发
- 类和协议的扩展都使用直接派发
- NSObject 的声明作用域里的方法使用函数表派发
- 协议中声明的拥有默认实现的方法使用函数表派发
- NSObject 扩展中的方法使用消息机制进行派发
手动指定派发方式
Swift 中有一些关键字可以指定方法的派发方式。
final
使用直接派发,不会生成对应的 selector,因此使用runtime会获取不到使用 final 修饰的方法。
dynamic
可以让方法具有动态性,runtime可以获取到,使用消息机制派发。另外一点就是使用 dynamic 可以让 extension 里面的方法也能够被 override。
@objc & @nonobjc
标记方法是否能被runtime获取到,@objc使用消息机制派发,@nonobjc则是禁止使用消息机制派发。
final @objc
这两个关键字同时使用时,既可以使用直接派发,也可以生成对应的 selector,在OC中可以使用runtime获取到对应的方法。兼具了静态与动态特性。
inline
该关键字会告诉编译器使用直接派发。
关于关键字和对应的派发方法可以参考如下表格:
可以通过 SIL 来查看测试代码具体的派发方式,生成 SIL 的方式如下:
1 | swiftc -emit-silgen -Onone DispatchTest.swift | xcrun swift-demangle >> result.sil |
参考文章