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 函數就不會被觸發.
為什么會有這些優化,可以參考這篇文章
派發方式總結
如何選擇派發方式
使用final關鍵字修飾肯定不會被重載的聲明
在上面的文章里,使用 final 可以允許類里面的函數使用直接派發。
而 final 關鍵字可以用在 class, 方法和屬性里來標識此聲明不可以被 override。
這可以讓編譯器安全的將其優化為靜態派發。
將文件中使用private關鍵字修飾的聲明推斷為final。
使用 private 關鍵字修飾的聲明只能在當前文件中進行訪問。
這樣編譯器可以找到所有潛在的重載聲明。
任何沒有被重載的聲明編譯器自動的將它推斷為final類型并且去除間接的方法調用和屬性訪問。
使用全局模塊優化推斷internal聲明為final -> whole module Optimization
使用internal(如果聲明沒有使用關鍵詞修飾,默認是 internal )關鍵字修飾的聲明的作用域僅限于它被聲明的模塊中。
因為Swift通常的將這些文件作為一個獨立的模塊進行編譯,所以編譯器不能確定一個internal聲明有沒有在其他的文件中被重載。
然而如果全局模塊優化(Whole Module Optimization,關于全局模塊優化參看下文的相關名詞解釋)是打開的那么所有的模塊將要在同一時間被一起編譯。
這樣以來編譯器就可以為整個模塊一起做出推斷,將沒有被重載的 internal 修飾的聲明推斷為 final 類型。
轉載自:https://blog.csdn.net/youshaoduo/article/details/103904344