Swift中的函数调用有很多奇怪的行为, 比如:
案例1
protocol Runnable { }
extension Runnable {
func run() {
print("run")
}
}
class Human: Runnable {
func run() {
print("human run")
}
}
let r: Runnable = Human()
r.run() // expected prints "human run", but prints "run" <- Unexpected!
这些反直觉的案例就事实存在于Swift中, 要想清晰的明白它们发生的原因, 就必须缕清它们背后的调用机制--函数派发.
函数派发就是程序判断使用哪种途径去调用一个函数的机制. 了解派发机制对于写出高性能的代码来说很有必要, 而且也能够解释很多Swift里奇怪的行为.
派发方式
编译型语言有三种基础的派发方式, 分别是: 直接派发(Direct Dispatch), 函数表派发(Table Dispatch) 和 消息机制派发(Message Dispatch). 大多数语言都会支持 直接派发和函数表派发, 如Java
和C++
. 而Objective-C
则总是使用消息机制(objc_msgSend
)派发. 而Swift
中三种都支持.
直接派发 (Direct Dispatch)
直接派发是最快的, 不止是因为需要调用的指令集会更少, 并且编译器还能够有很大的优化空间, 例如函数内联等. 直接派发也称为静态调用.
然而, 对于编程来说直接调用也是最大的局限, 而且因为缺乏动态性所以没办法支持继承.
函数表派发 (Table Dispatch)
函数表派发是编译型语言实现动态行为最常见的实现方式. 函数表使用了一个数组来存储类声明的每一个函数的指针. 大部分语言(如C++
)把这个称为虚函数表
, 而在Swift
中将其称为Witness Table(目击表)
, 每一个类都会维护一个函数表, 里面记录着类所有的函数, 如果父类函数被 override
的话, 表里面只会保存被 override
之后的函数. 一个子类新添加的函数, 都会被插入到这个数组的最后. 运行时会根据这一个表去决定实际要被调用的函数.
这种基于数组的实现, 缺陷在于函数表无法拓展. 子类会在虚数函数表的最后插入新的函数, 没有位置可以让 extension
安全地插入函数.
消息机制派发 (Message Dispatch)
消息机制是调用函数最动态的方式. 比如可以通过method swizzling
或者isa swizzling
来动态修改方法的实现和对象的继承关系, 从而实现自定义派发.
而当一个消息被派发, 运行时会顺着类的继承关系向上查找应该被调用的函数.
Swift的派发机制
那么Swift
是如何糅合这三种派发方式的呢? Swift
的文档中并没有具体写明成体系的机制. 而通过测试和了解, Swift的派发机制是通过以下四种因素来制约:
- 函数声明的位置(Location Matters)
- 调用者(引用)的类型(Reference Type Matters)
- 特定的派发行为(Specifying Dispatch Behavior)
- 显式地优化(Visibility Optimizations)
具体的细节这篇文章已经讲述的非常好,就不做赘述.
最终可以通过一张表来总结:
如果在开发过程中,错误的混合了这几种分派方式,就可能出现意料之外的结果甚至是Bug.
拿案例1来说明, Runnable
的run
方法声明在Extension
中, 所以只要使用了Runnable
这一类型的调用者, 都是采用直接派发, 直接调用了Runnable
的run
方法.
同样,如果我们将案例1的run
方法的声明位置变化一下:
protocol Runnable {
func run()
}
...
let r: Runnable = Human()
r.run() // prints "human run"
另外在Swift4.0之前的版本, 还存在这个问题SR-923.
我将其引申为一个较为清晰的版本:
class Person: NSObject {
func sayHi() {
print("Hello")
}
}
func greetings(person: Person) {
person.sayHi()
}
greetings(person: Person()) // prints 'Hello'
class MisunderstoodPerson: Person {}
extension MisunderstoodPerson {
override func sayHi() {
print("No one gets me.")
}
}
greetings(person: MisunderstoodPerson()) // prints 'Hello'
这也是由于NSObject
的extension
是使用的Message dispatch
,而Initial Declaration
使用的是Table dispath
. extension
重写的方法添加在了Message dispatch
内,没有修改函数表,函数表内还是父类的方法,故会执行父类方法. 想在extension
重写方法,需要标明dynamic
来使用Message dispatch
.
不过Swift
已经修复了这个Bug, 现在extension
中不允许重写父类的任何函数了.
其他
我们继续更改案例1:
protocol Runnable {
func run()
}
extension Runnable {
func run() {
print("run")
}
}
class Human: Runnable {
}
class Man: Human {
func run() {
print("man run")
}
}
然后测试
let r: Runnable = Man.init()
r.run() // prints "run"
咦 怎么会这样呢?
断点打在r.run()
上, 进入反汇编进行查看, 发现这个run
是被直接call了一个地址0x100001980
, 而不是读取存在容器中的Protocol Witness Table(协议目击表)
中的某个函数地址, 这是被直接派发调用的...
swift的bug讨论处也有相关的问题提出 SR-103
讨论中大多数人认为 man
的run
函数并没有绑定到他自己的协议目击表中, 于是我对案例测试代码修改:
let r = Man.init()
(r as Runnable).run() // prints "run"
进入反汇编查看, 发现run
确实是通过读取协议目击表相关地址来调用的:
在此断点下 我们读取下 r13
寄存器的内容, 也就是调用__swift_project_boxed_opaque_existential_1
这个函数的返回值:
// 读取r13寄存器内容
(lldb) re r r13
r13 = 0x00007ffeefbff590
// 读取 0x00007ffeefbff590 内存中的值 (5个, 16进制, 8字节)
(lldb) x/5xg 0x00007ffeefbff590
0x7ffeefbff590: 0x000000010217b990 0x0000000000000000
0x7ffeefbff5a0: 0x0000000000000000 0x00000001000022a8
0x7ffeefbff5b0: 0x0000000100002060
可以看出0x00007ffeefbff590
也就是存在容器, 其中前8个字节存储的是我们创建的man
对象的指针地址.
0x7ffeefbff5a8
存储的是man
的metadata
的地址.
(lldb) di -s 0x00000001000022a8
swift_assmbly`type metadata for swift_assmbly.Man:
0x1000022a8 <+0>: jo 0x1000022cc ; type metadata for swift_assmbly.Man + 36
0x1000022aa <+2>: addb %al, (%rax)
0x1000022ac <+4>: addl %eax, (%rax)
0x1000022ae <+6>: addb %al, (%rax)
0x1000022b0 <+8>: sbbb %ah, (%rdx)
0x1000022b2 <+10>: addb %al, (%rax)
0x1000022b4 <+12>: addl %eax, (%rax)
0x1000022b6 <+14>: addb %al, (%rax)
0x1000022b8 <+16>: addb %ah, (%rdi,%rsi,4)
0x1000022bb <+19>: outsb (%rsi), %dx
而最后8个字节, 存储的就是Protocol Witness Table
的地址
(lldb) di -s 0x0000000100002060
swift_assmbly`protocol witness table for swift_assmbly.Human : swift_assmbly.Runnable in swift_assmbly:
而最终调用的是什么呢? 我们读取下rax
寄存器的地址
(lldb) re r rax
rax = 0x0000000100002060 swift_assmbly`protocol witness table for swift_assmbly.Human : swift_assmbly.Runnable in swift_assmbly
发现存储的就是协议目击表的地址.
而 *0x8(%rax)
就是 rax
存储地址+8个字节后所指向的空间的地址, 也就是最后函数调用的地址. 我们顺着打印下:
// %rax + 8 = 0x100002060 + 8 = 0x100002068
(lldb) x/1xg 0x100002068
0x100002068: 0x0000000100001a40
(lldb) di -s 0x0000000100001a40
swift_assmbly`protocol witness for Runnable.run() in conformance Human:
0x100001a40 <+0>: pushq %rbp
0x100001a41 <+1>: movq %rsp, %rbp
0x100001a44 <+4>: callq 0x100001870 ; (extension in swift_assmbly):swift_assmbly.Runnable.run() -> () at main.swift:16
0x100001a49 <+9>: popq %rbp
0x100001a4a <+10>: retq
0x100001a4b <+11>: nopl (%rax,%rax)
swift_assmbly`Man.run():
0x100001a50 <+0>: pushq %rbp
0x100001a51 <+1>: movq %rsp, %rbp
0x100001a54 <+4>: pushq %r13
0x100001a56 <+6>: subq $0x38, %rsp
发现就是定义在Runnable
Extension中的run
函数的地址, 而在它下面我们也发现了Man
自己实现的run
函数地址. 所以Man
的run
并非没有注册进协议目击表, 只是没有被正确的调用.
参考资料
【基本功】深入剖析Swift性能优化
深入理解 Swift 派发机制
Method Dispatch in Swift
swift/lib/IRGen/ProtocolInfo.h