理解 Swift 的方法派发

目录

本文转自理解 Swift 的方法派发

派发(dispatch)是一个比较通用的概念,一般是指为了完成某个目的把一个东西发送到某个位置的行为。在计算机科学中,这个术语在很多地方都会用到,比如派发一个调用给某个函数,派发一个事件给一个监听者,派发一个中断给中断处理程序,或者派发一个进程给 CPU。

在这篇文章中,我们主要研究 Swift 中的派发,也就是派发一个调用到某个方法上,Swift 中的方法派发包括类的方法派发和基于协议的派发。

类的方法派发

Swift 中类的方法的派发有以下三种方式:

  • 静态派发(Static Dispatch)
  • 动态派发(Dynamic Dispatch)
  • 消息派发(Messaging Dispatch)

静态派发

静态派发,又叫做早期绑定,是指在编译期将方法调用绑定到方法的实现上,这种派发方式非常快。在编译期,编译器可以看到调用方和被调方的所有信息,直接生成跳转代码,这样在运行期就不会有其它额外的开销。并且编译器可以根据自己知道的信息进行优化,比如内联,可以极大提高程序运行效率。

在 Swift 中,结构体和枚举的方法调用,以及被 final 标记的类和类的方法,都会采用这种派发方式。

动态派发

动态派发是在运行时决定方法调用地址,因此需要有个查找方法地址的机制,在 Swift 中是通过 虚函数表(Virtual Method Table) ,简称 V-Table 实现的,因此动态派发也被称为表派发(Table Dispatch)

在编译期,编译器会给每个包含动态派发方法的类型创建一个虚函数表,这个表会被放在内存的静态区,表中是方法名到方法实现地址的映射。当这个类型的方法被调用时,运行时会去这个类型的虚函数表中寻找这个方法名对应的实现地址,然后再跳转到这个地址执行代码。

动态派发主要是用来实现 继承多态 ,继承多态是多态的一种。例如以下代码:

class Animal {
    func makeNoise() {
        fatalError("此方法必须通过子类调用")
    }
}

class Dog: Animal {
    override func makeNoise() {
        print("Wang Wang!")
    }
}

class Cat: Animal {
    override func makeNoise() {
        print("Miao!")
    }
}
复制代码

这段代码在编译时,编译器会把 makeNoise 方法采用动态派发来处理,会给 Animal、Dog、Cat 这三个类分别生成一个虚函数表,每个表中包含了方法实现地址的列表和方法列表的索引。

我们可以使用一个容器来装一些列 Animal 和其子类,然后统一调用 makeNoise 方法,这样的好处是忽略每个具体类型的信息,提供高级的抽象,这种做法在很多地方都很有用。这种做法在面向对象中也被称为开放递归(Open recursion)。

let animals: [Animal] = [Dog(), Cat()]
for animal in animals {
    animal.makeNoise()
}
// 输出:
// Wang Wang!
// Miao!
复制代码

相对于静态派发的直接跳转,动态派发要经过 3 个步骤,找到虚函数表、找到方法地址、跳转到方法地址,并且编译器无法对动态派发做优化,因此其性能要比静态派发慢得多。

重写扩展中的方法

Swift 中扩展中的方法是不能被子类重写的,可以尝试编写以下代码:

extension Animal {
    func methodInExtension() {}
}

class Dog: Animal {
    ...
    
    override func methodInExtension() {
    
    }
}
复制代码

此时 Xcode 会报告一个编译错误 扩展中的非 @objc 的实例方法不能被重写。这是因为扩展中的方法不会被添加到类的虚函数表中。如果一定想重写方法,只能添加 @objc 修饰符,这样这个方法会拥有完整的 Objective-C 的方法派发能力,编译器会知道这个方法可以在运行时被正确处理,从而允许重写。

默认情况下,如果继承了一个 Objective-C 类,子类中的方法派发是采用动态派发而不是消息派发。

消息派发

关于消息派发,这就是 Objective-C 的知识了,就是 OC 运行时通过 isa 和 super 指针查找方法实现,并包含一系列消息转发流程,在此不表。

在 Swift 类中使用 @objc dynamic 关键字可以强制方法使用消息派发。

协议的派发

类继承是一个很好用的东西,但是它也存在一些问题,比如子类只能继承一个父类,并且子类会被强制包含父类的内存布局。

Swift 提供了一个解决方案来解决上述类继承的不足,这个解决方案提供了良好的封装,支持多态,不会和某个特定的内存布局绑定,并且可以基于值类型工作,这就是利用 面向协议编程(POP)

协议定义了一个类型具备的能力,和继承不同,我们可以给让一个类型符合任意多个协议,可以让不是自己写的类型去符合一个协议,可以给协议提供默认实现。在 Swift 中,类、结构体、枚举都可以去符合协议。

用面向协议的思想来编程,我们就会摒弃类继承,而是从设计一个协议开始,比如上面的代码,我们会将 Animal 设计为一个协议:

protocol Animal {
    func makeNoise()
}
复制代码

然后可以用一个协议类型的变量来保存一个对象:

let animal: Animal = ...
复制代码

在类继承中,由于 Animal 是一个类,编译器知道 Animal 占用多大的内存空间,因此知道 animal 对象应该占用多大空间,但是如果 Animal 是一个协议类型,编译器怎样知道 animal 应该占用多大空间呢?

class Dog: Animal {
    let name: String
    func makeNoise() { ... }
}

class Cat: Animal {
    let age: Int
    func makeNoise() { ... }
}
复制代码

协议并不限制符合协议的类型的内存布局,上面代码中,Dog 占 3 个字的大小,Cat 占 1 个字的大小。

Swift 引入了 存在容器(Existential Container) 来解决这个问题。每个存在容器由以下几个部分组成:

  1. Value Buffer ValueBuffer 占 3 个字的长度,如果符合协议的对象是值类型且小于等于 3 个字,则直接放入 ValueBuffer 中,如果对象是引用类型或者大于 3 个字的值类型,则将对象放在堆上,在 ValueBuffer 中保存一个指向堆上对象的引用。
  2. 一个指向 值目击表(Value Witness Table, VWT) 的指针,用来创建、拷贝和销毁值,表中保存了创建、拷贝、销毁等函数的地址,其中创建、销毁函数的地址仅在当对象分配在堆上时才会有。
  3. 一个指向 协议目击表(Protocol Witness Table, PWT) 的指针,每个符合了某个协议的类型都有自己的协议目击表,保存了实现协议中方法的方法地址。
  4. 如果类型符合了多个协议,后面还会有第二个协议的协议目击表指针,以及第三个,第四个等。符合的协议越多,存在容器占用内存空间就越大。

这样对于某个协议类型,它的存在容器的大小总是相同的,编译器即可确定它的大小。

let animal: Animal = Dog()
animal.makeNoise()
复制代码

上面的代码,animal 会被处理成一个存在容器,占用 5 个字大小的空间,由于 Dog 的大小小于等于 3 个字,它被直接放入存在容器的 ValueBuffer 中,也就是头 3 个字的空间。第 4 个字的位置是 VWT,保存了对象拷贝等函数的地址。在 PWT 中保存了 makeNoise 方法的实现地址,用存在容器第 5 个字的位置指向 PWT。

当调用 makeNoise 时,运行时会去 PWT 中寻找方法的地址,然后跳转指令,这其实和虚函数表差不多。

值得一提的是,使用协议类型的开销可能会很大,尤其是实现协议的对象是比较大的对象的时候,这会导致在堆上进行分配和引用技术操作。这种情况下使用泛型约束可能是更好的选择。

总结

理解了 Swift 中的方法派发方式后,可以知道,应该优先使用静态派发,可以获得最佳的性能,只有在需要和 Objective-C 代码交互时才应该使用消息派发。在需要动态派发的地方,应该优先使用面向协议设计使用基于协议的派发,然后根据具体情况使用类本身的动态派发。

参考资料

  • developer.apple.com/wwdc16/416
  • [Swift. Method Dispatch by Maxim Krylov Medium](https://link.juejin.cn?target=https%3A%2F%2Fmaxim-kryloff.medium.com%2Fswift-method-dispatch-4ac7efab0388 “https://maxim-kryloff.medium.com/swift-method-dispatch-4ac7efab0388”)
  • [Understanding method dispatch in Swift by Navdeep Singh Heartbeat (comet.ml)](https://link.juejin.cn?target=https%3A%2F%2Fheartbeat.comet.ml%2Funderstanding-method-dispatch-in-swift-684801e718bc “https://heartbeat.comet.ml/understanding-method-dispatch-in-swift-684801e718bc”)

打赏一个呗

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦