Mac·iOS开发iOS

Swift 的函数派发机制

2020-04-22  本文已影响0人  6ffd6634d577

介绍

首先全面了解一下,有4种派发机制,而不是两种(静态和动态):

  1. 内联(inline) (最快)
  2. 静态派发 (Static Dispatch)
  3. 函数表派发 (Virtual Dispatch)
  4. 动态派发 (Dynamic Dispatch)(最慢)

函数派发就是程序判断使用哪种途径去调用一个函数的机制. 每次函数被调用时都会被触发, 但你又不会太留意的一个东西. 了解派发机制对于写出高性能的代码来说很有必要, 而且也能够解释很多 Swift 里"奇怪"的行为.

注:编译型语言有三种基础的函数派发方式:

  • 直接派发(Direct Dispatch)
  • 函数表派发(Table Dispatch)
  • 消息机制派发(Message Dispatch)

大多数语言都会支持一到两种, Java 默认使用函数表派发, 但你可以通过 final 修饰符修改成直接派发. C++ 默认使用直接派发, 但可以通过加上 virtual 修饰符来改成函数表派发. 而 Objective-C 则总是使用消息机制派发, 但允许开发者使用 C 直接派发来获取性能的提高. 这样的方式非常好, 但也给很多开发者带来了困扰。

派发方式 (Types of Dispatch )

一个方法会在运行时被唤起调用,是因为编译器有一个计算机制,用来选择正确的方法,然后通过传递参数来唤起它,这个机制通常被成为 派发(dispatch)

直接派发 (Direct Dispatch)

直接派发,又叫 静态派发(static dispatch) 是在编译期就完全确定调用方法的分派方式.

用于在多态情况下,在编译期就实现对于确定的类型,在函数调用表中推断和追溯正确的方法,包括列举泛型的特定版本,在提供的全部函数定义中选择的特定实现.

在编译器确定使用 static dispatch 后,会在生成的可执行文件内,直接指定包含了方法实现内存地址的指针,编译器直接找到相关指令的位置。当函数调用时,系统直接跳转到函数的内存地址执行操作。

直接派发是非常快的,调用指令少,执行快,编译器可以在编译期定位到函数的位置。因此,当函数被调用时,编译器能通过函数的内存地址,直接找到它的函数实现。这极大的提高了性能,可以到达类似inline的编译期优化。同时允许编译器能够执行例如内联等优化,缺点是由于缺少动态性而不支持继承。

事实上,编译期在编译阶段为了能够获取最大的性能提升,都尽量将函数静态化。

动态派发

如前所述, 在这种类型的派发中,在运行时而不是编译时选择实现方法,这会增加一下性能开销。

这里也许你会有这样的疑问?既然动态派发有性能开销,我们为什么还要使用它?

因为它具有灵活性。实际上,大多数的OOP语言都支持动态派发,因为它允许多态。

动态派发有两种形式:

  1. 函数表派发( Table dispatch )
  2. 消息派发( Message dispatch )
函数表派发 (Table Dispatch )

函数表派发是编译型语言实现动态行为最常见的实现方式。
函数表使用了一个数组来存储类声明的每一个函数的指针。大部分语言把这个称为 “virtual table”(虚函数表),Swift 里称为 “witness table”。每一个类都会维护一个函数表,里面记录着类所有的函数,如果父类函数被override,表里面只会保存被 override 之后的函数。一个子类新添加的函数,都会被插入到这个数组的最后。运行时会根据这一个表去决定实际要被调用的函数。

举个例子, 看看下面两个类:

class ParentClass {
    func method1() {}
    func method2() {}
}
class ChildClass: ParentClass {
    override func method2() {}
    func method3() {}
}

在这个情况下, 编译器会创建两个函数表, 一个是 ParentClass 的, 另一个是 ChildClass的:

image.png

这张表展示了 ParentClass 和 ChildClass 虚数表里 method1, method2, method3 在内存里的布局.

let obj = ChildClass()
obj.method2()

当一个函数被调用时, 会经历下面的几个过程:

  1. 读取对象 0xB00 的函数表.
  2. 读取函数指针的索引. 在这里, method2 的索引是1(偏移量), 也就是 0xB00 + 1.
  3. 跳到 0x222 (函数指针指向 0x222)

一个函数被调用时会先去读取对象的函数表,再根据类的地址加上该的函数的偏移量得到函数地址,最后跳到那个地址上去。从编译后的字节码这方面来看就是 两次读取一次跳转, 由此带来了性能的损耗. 另一个慢的原因在于编译器可能会由于函数内执行的任务导致无法优化,比直接派发还是慢了些,但仍比消息分派快。

这种基于数组的实现, 缺陷在于函数表无法拓展. 子类会在虚数函数表的最后插入新的函数, 没有位置可以让 extension 安全地插入函数.

消息机制派发 (Message Dispatch )

消息机制是调用函数最动态的方式. 也是 Cocoa 的基石, 这样的机制催生了 KVO, UIAppearenceCoreData 等功能. 这种运作方式的关键在于开发者可以在运行时改变函数的行为.

Objc 的函数派发都是基于消息派发的。这种机制极具动态性,不止可以通过 swizzling 来改变, 甚至可以用 isa-swizzling 修改对象的继承关系, 可以在面向对象的基础上实现自定义派发.

image.png

通过消息派发执行子类中的函数的步骤:

  1. 到自己的方法列表中去找,如果找到了,执行对应逻辑,如果没找到执行2。
  2. 去它的父类中去找,发现找到了,就执行相应的逻辑。

当一个消息被派发, 运行时会顺着类的继承关系向上查找应该被调用的函数. 如果你觉得这样做效率很低, 它确实很低! 然而, 只要缓存建立了起来, 这个查找过程就会通过缓存来把性能提高到和函数表派发一样快.

Swift 的派发机制

四个选择具体派发方式的因素存在:

  1. 声明的位置
  2. 引用类型
  3. 特定的行为
  4. 显式地优化 (Visibility Optimizations)

Swift 没有在文档里具体写明什么时候会使用函数表什么时候使用消息机制. 唯一的承诺是使用 dynamic 修饰的时候会通过 Objective-C 的运行时进行消息机制派发. 下面都只是 Swift 3.0 里的结果, 并且很可能在之后的版本更新里进行修改.

声明的位置 (Location Matters)

在 Swift 里, 一个函数有两个可以声明的位置: 类型声明的作用域extension. 根据声明类型的不同, 也会有不同的派发方式.

class MyClass {
    func mainMethod() {}
}
extension MyClass {
    func extensionMethod() {}
}

上面的例子里, mainMethod 会使用函数表派发, 而 extensionMethod 则会使用直接派发. 直觉上这两个函数的声明方式并没有那么大的差异. 下面是根据类型, 声明位置总结出来的函数派发方式的表格.

image.png

这张表格展示了默认情况下 Swift 使用的派发方式.

总结起来有这么几点:

引用类型 (Reference Type Matters)

指定派发方式 (Specifying Dispatch Behavior)

Swift 有一些修饰符可以指定派发方式.

final

final 允许类里面的函数使用直接派发. 这个修饰符会让函数失去动态性. 任何函数都可以使用这个修饰符, 就算是 extension 里本来就是直接派发的函数. 这也会让 Objective-C 的运行时获取不到这个函数, 不会生成相应的 selector.

dynamic

dynamic 可以让类里面的函数使用消息机制派发. 使用 dynamic, 必须导入 Foundation 框架, 里面包括了 NSObject 和 Objective-C 的运行时. dynamic 可以让声明在 extension 里面的函数能够被 override. dynamic 可以用在所有 NSObject 的子类和 Swift 的原声类.

@objc & @nonobjc

@objc@nonobjc 显式地声明了一个函数是否能被 Objective-C 的运行时捕获到. 使用 @objc 的典型例子就是给 selector 一个命名空间 @objc(abc_methodName), 让这个函数可以被 Objective-C 的运行时调用. @nonobjc 会改变派发的方式, 可以用来禁止消息机制派发这个函数, 不让这个函数注册到 Objective-C 的运行时里. 我不确定这跟 final 有什么区别, 因为从使用场景来说也几乎一样. 我个人来说更喜欢 final, 因为意图更加明显.

译者注: 我个人感觉, 这这主要是为了跟 Objective-C 兼容用的, final 等原生关键词, 是让 Swift 写服务端之类的代码的时候可以有原生的关键词可以使用

final @objc

可以在标记为 final 的同时, 也使用 @objc 来让函数可以使用消息机制派发. 这么做的结果就是, 调用函数的时候会使用直接派发, 但也会在 Objective-C 的运行时里注册响应的 selector. 函数可以响应 perform(selector:) 以及别的 Objective-C 特性, 但在直接调用时又可以有直接派发的性能.

@inline

Swift 也支持 @inline, 告诉编译器可以使用直接派发.

修饰符总结 (Modifier Overview)

image.png

可见的都会被优化 (Visibility Will Optimize)

Swift 会尽最大能力去优化函数派发的方式. 例如, 如果你有一个函数从来没有 override, Swift 就会检车并且在可能的情况下使用直接派发. 这个优化大多数情况下都表现得很好, 但对于使用了 target / action 模式的 Cocoa 开发者就不那么友好了. 例如:

override func viewDidLoad() {
    super.viewDidLoad()
    navigationItem.rightBarButtonItem = UIBarButtonItem(
        title: "登录", style: .plain, target: nil,
        action: #selector(ViewController.signInAction)
    )
}
private func signInAction() {}

这里编译器会抛出一个错误:

Argument of '#selector' refers to instance method 'btnDidClick(sender:)' that is not exposed to Objective-C

Add '@objc' to expose this instance method to Objective-C

Objective-C 无法获取 #selector 指定的函数. Swift 会把这个函数优化为直接派发的话, 就能理解这件事情了. 这里修复的方式很简单:
要使用动态性,我们需要使用 dynamic 关键字。Swift4.0之前,我们需要一起使用 dynamic@objc. Swift4.0之后,我们需要表明 @objc 让我们的方法支持Objective-C的调用,以支持消息派发。

派发总结 (Dispatch Summary)

image.png image.png

这张表总结引用类型, 修饰符和它们对于 Swift 函数派发的影响

现在如何证明这些方法是哪种派发技术?

为此,我们必须看一下Swift中间语言(SIL)。通过在网上可以进行的研究,发现有一种方法:

1. 如果函数使用Table派发,则它会出现在vtable(或witness_table)中

sil_vtable Animal { 
#Animal.isCute!1:(Animal)->()->():main.Animal.isCute()->()// Animal.isCute()
…… 
}

2. 如果函数使用 Message Dispatch,则关键字volatile应该存在于调用中。另外,您将找到两个标记foreign和objc_method,指示使用Objective-C运行时调用了该函数。

%14 = class_method [volatile]%13:$ Dog,#Dog.goWild!1.foreign:(Dog)->()->(),$ @ convention(objc_method)(Dog)->() 

3. 如果没有以上两种情况的证据,答案是静态派发。

参考文章

深入理解 Swift 派发机制
Swift的静态派发和动态派发机制
Static Dispatch Over Dynamic Dispatch

上一篇下一篇

猜你喜欢

热点阅读