再探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 函數就不會被觸發.

為什么會有這些優化,可以參考這篇文章

派發方式總結

屏幕快照 2020-02-29 下午12.17.27.png

如何選擇派發方式

使用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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,763評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,238評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,823評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,604評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,339評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,713評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,712評論 3 445
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,893評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,448評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,201評論 3 357
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,397評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,944評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,631評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,033評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,321評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,128評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,347評論 2 377