再探Swift函数的派发方式

初探Swift函数的派发方式
Swift 的函数是怎么派发的呢? 我没能找到一个很简明扼要的答案, 但这里有四个选择具体派发方式的因素存在:

  • 声明的位置
  • 引用类型
  • 特定的行为
  • 显式地优化(Visibility Optimizations)

在解释这些因素之前, 我有必要说清楚, Swift 没有在文档里具体写明什么时候会使用函数表什么时候使用消息机制. 唯一的承诺是使用 dynamic 修饰的时候会通过 Objective-C 的运行时进行消息机制派发.
下面我写的所有东西, 都只是我在 Swift 5.0 里测试出来的结果, 并且很可能在之后的版本更新里进行修改.

声明的位置 (Location Matters)

在 Swift 里, 一个函数有两个可以声明的位置: 类型声明的作用域, 和 extension. 根据声明类型的不同, 也会有不同的派发方式。在Swift中,我们常常在extension里面添加扩展方法。
首先看一个小问题:

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

上面的例子里, mainMethod 会使用函数表派发, 这一点是没有任何异议的。
而 extensionMethod 则会使用直接派发.
当我第一次发现这件事情的时候觉得很意外, 直觉上这两个函数的声明方式并没有那么大的差异.
为了搞清楚extension为什么是直接派发的问题,我们再看一个例子:

//首先声明一个协议
protocol Drawing {
  func render()
}

//定义这个协议中的函数
extension Drawing {
  func circle() { print("protocol")}
  func render() { circle()}
}

//遵循这个协议
class SVG: Drawing {
  func circle(){ print("class") }
}

SVG().render()

// what's the output?

这里会输出什么呢?
根据当时的统计,43%选择了protocol, 57%选择了class。但真理往往掌握在少数人手中,正确答案是protocol。

objc给出的解释是: circle函数声明在protocol的extension里面,所以不是动态派发, 并且类没有实现render函数,所以输出为protocol.
由此可以看出 : extension中声明的函数是直接派发,编译的时候就已经确定了调用地址,类无法重写实现,否则如果是函数表派发的话这里应该输出的是class,而不是protocol。

如果不相信实验的猜测,那么我们可以直接编译一下,看看到底是什么派发方式,使用如下命令将swift代码转换为SIL(中间码)以便查看其函数派发方式:

➜ swiftc -emit-silgen -O main.swift
······
// MyClass.extensionMethod()
sil hidden [ossa] @$s4main7MyClassC15extensionMethodyyF : $@convention(method) (@guaranteed MyClass) -> () {
// %0                                             // user: %1
bb0(%0 : @guaranteed $MyClass):
  debug_value %0 : $MyClass, let, name "self", argno 1 // id: %1
  %2 = tuple ()                                   // user: %3
  return %2 : $()                                 // id: %3
} // end sil function '$s4main7MyClassC15extensionMethodyyF'

sil_vtable MyClass {
  #MyClass.mainMethod!1: (MyClass) -> () -> () : @$s4main7MyClassC0A6MethodyyF	// MyClass.mainMethod()
  #MyClass.init!allocator.1: (MyClass.Type) -> () -> MyClass : @$s4main7MyClassCACycfC	// MyClass.__allocating_init()
  #MyClass.deinit!deallocator.1: @$s4main7MyClassCfD	// MyClass.__deallocating_deinit
}

我们可以很清楚的看到,sil_vtable这张函数表里并没有extensionMethod方法,因此可以断定是直接派发。

这里总结了一张表,展示了默认情况下Swift使用的派发方式:

类型 初始声明 extension
Value Type(值类型) 直接派发 直接派发
Protocol(协议) 函数表派发 直接派发
Class(类) 函数表派发 直接派发
NSObject Subclass(NSObject子类) 函数表派发 消息机制派发

总结起来有这么几点:

  • 值类型总是会使用直接派发, 简单易懂
  • 协议和类的 extension 都会使用直接派发
  • NSObject 的 extension会使用消息机制进行派发
  • NSObject 声明作用域里的函数都会使用函数表进行派发.
  • 协议里声明的,并且带有默认实现的函数会使用函数表进行派发

引用类型 (Reference Type Matters)

引用的类型决定了派发的方式. 这很显而易见, 但也是决定性的差异. 一个比较常见的疑惑, 发生在一个协议拓展和类型拓展同时实现了同一个函数的时候.

protocol MyProtocol {}

struct MyStruct: MyProtocol {}

extension MyStruct {
    func extensionMethod() {
        print("结构体")
    }
}
extension MyProtocol {
    func extensionMethod() {
        print("协议")
    }
}
 
let myStruct = MyStruct()
let proto: MyProtocol = myStruct
 
myStruct.extensionMethod() // -> “结构体”
proto.extensionMethod() // -> “协议”

刚接触 Swift 的人可能会认为 proto.extensionMethod() 调用的是结构体里的实现。
但是,引用的类型决定了派发的方式,协议拓展里的函数会使用直接派发方式调用。
如果把 extensionMethod 的声明移动到协议的声明位置的话,则会使用函数表派发,最终就会调用结构体里的实现。
并且,如果两种声明方式都使用了直接派发的话,基于直接派发的运作方式,我们不可能实现预想的 override 行为。

指定派发方式 (Specifying Dispatch Behavior)

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

final or static

final和static 允许类里面的函数使用直接派发. 这个修饰符会让函数失去动态性.
任何函数都可以使用这个修饰符, 即使是 extension 里本来就是直接派发的函数.
这也会让 Objective-C 的运行时获取不到这个函数, 不会生成相应的 selector.
总之一句话:添加了final关键字的函数无法被重写(static可以被重写),使用直接派发,不会在函数表中出现,并且对Objc runtime不可见

dynamic

dynamic 可以让类里面的函数使用消息机制派发. 使用 dynamic, 必须导入 Foundation 框架, 里面包括了 NSObject 和 Objective-C 的运行时.
dynamic 可以让声明在 extension 里面的函数能够被 override.
dynamic 可以用在所有 NSObject 的子类和 Swift 的原声类.
在Swift5中,给函数添加dynamic的作用是为了赋予非objc类和值类型(struct和enum)动态性。
这里举一个例子:

struct Test {
    dynamic func test() {}
}

转换成SIL中间码之后:

// main
sil [ossa] @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  %2 = integer_literal $Builtin.Int32, 0          // user: %3
  %3 = struct $Int32 (%2 : $Builtin.Int32)        // user: %4
  return %3 : $Int32                              // id: %4
} // end sil function 'main'

// Test.test()
sil hidden [dynamically_replacable] [ossa] @$s4main4TestV4testyyF : $@convention(method) (Test) -> () {
// %0                                             // user: %1
bb0(%0 : $Test):
  debug_value %0 : $Test, let, name "self", argno 1 // id: %1
  %2 = tuple ()                                   // user: %3
  return %2 : $()                                 // id: %3
} // end sil function '$s4main4TestV4testyyF'

// Test.init()
sil hidden [ossa] @$s4main4TestVACycfC : $@convention(method) (@thin Test.Type) -> Test {
bb0(%0 : $@thin Test.Type):
  %1 = alloc_box ${ var Test }, var, name "self"  // user: %2
  %2 = mark_uninitialized [rootself] %1 : ${ var Test } // users: %5, %3
  %3 = project_box %2 : ${ var Test }, 0          // user: %4
  %4 = load [trivial] %3 : $*Test                 // user: %6
  destroy_value %2 : ${ var Test }                // id: %5
  return %4 : $Test                               // id: %6
} // end sil function '$s4main4TestVACycfC'

我们可以看到Test.test()函数多了一个dynamically_replacable关键字, 也就是说添加dynamic关键字就是赋予函数动态替换的能力。关于这个关键字,感兴趣的可以看一下这一篇文章

@objc & @nonobjc

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

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

final @objc

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

@inline

Swift 也支持 @inline, 告诉编译器可以使用直接派发. 但其实转换成SIL代码后,依然是函数表派发。
有趣的是, dynamic @inline(__always) func dynamicOrDirect() {} 也可以通过编译!
但这也只是告诉了编译器而已, 实际上这个函数还是会使用消息机制派发.
这样的写法看起来像是一个未定义的行为, 应该避免这么做.

修饰符总结 (Modifier Overview)

关键字 派发方式
final 直接派发
static 直接派发
dynamic 消息机制派发
@objc 函数表派发
@inline 函数表派发

显式的优化 (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 a method that is not exposed to Objective-C (Objective-C 无法获取 #selector 指定的函数).
你如果记得 Swift 会把这个函数优化为直接派发的话, 就能理解这件事情了.
这里修复的方式很简单: 加上 @objc 或者 dynamic 就可以保证 Objective-C 的运行时可以获取到函数.
这种类型的错误也会发生在UIAppearance 上, 依赖于 proxy 和 NSInvocation 的代码.

另一个需要注意的是, 如果你没有使用 dynamic 修饰的话, 这个优化会默认让 KVO 失效. 如果一个属性绑定了 KVO 的话, 而这个属性的 getter 和 setter 会被优化为直接派发, 代码依旧可以通过编译, 不过动态生成的 KVO 函数就不会被触发.

为什么会有这些优化,可以参考这篇文章

派发方式总结

直接派发 函数表派发 消息机制派发
Value Type(struct) 所有方法
Protocol extension 正常初始化 @objc
Class extension or final 正常初始化 dynamic
NSObject extension or final 正常初始化 extension or dynamic

如何选择派发方式

使用final关键字修饰肯定不会被重载的声明

在上面的文章里,使用 final 可以允许类里面的函数使用直接派发。
而 final 关键字可以用在 class, 方法和属性里来标识此声明不可以被 override。
这可以让编译器安全的将其优化为静态派发。

将文件中使用private关键字修饰的声明推断为final。

使用 private 关键字修饰的声明只能在当前文件中进行访问。
这样编译器可以找到所有潜在的重载声明。
任何没有被重载的声明编译器自动的将它推断为final类型并且去除间接的方法调用和属性访问。

使用全局模块优化推断internal声明为final -> whole module Optimization

使用internal(如果声明没有使用关键词修饰,默认是 internal )关键字修饰的声明的作用域仅限于它被声明的模块中。
因为Swift通常的将这些文件作为一个独立的模块进行编译,所以编译器不能确定一个internal声明有没有在其他的文件中被重载。
然而如果全局模块优化(Whole Module Optimization,关于全局模块优化参看下文的相关名词解释)是打开的那么所有的模块将要在同一时间被一起编译。
这样以来编译器就可以为整个模块一起做出推断,将没有被重载的 internal 修饰的声明推断为 final 类型。

发布了249 篇原创文章 · 获赞 926 · 访问量 149万+

猜你喜欢

转载自blog.csdn.net/youshaoduo/article/details/103904344
今日推荐