深入理解 Swift 派發機制

ZY_FlyWay發表於2018-06-30

譯者注:

之前看了很多關於 Swift 派發機制的內容, 但感覺沒有一篇能夠徹底講清楚這件事情, 看完了這篇文章之後我對 Swift 的派發機制才建立起了初步的認知.

正文

22.png

一張表總結引用型別, 修飾符和它們對於 Swift 函式派發方式的影響.

函式派發就是程式判斷使用哪種途徑去呼叫一個函式的機制. 每次函式被呼叫時都會被觸發, 但你又不會太留意的一個東西. 瞭解派發機制對於寫出高效能的程式碼來說很有必要, 而且也能夠解釋很多 Swift 裡”奇怪”的行為.

編譯型語言有三種基礎的函式派發方式: 直接派發(Direct Dispatch), 函式表派發(Table Dispatch) 和 訊息機制派發(Message Dispatch), 下面我會仔細講解這幾種方式. 大多數語言都會支援一到兩種, Java 預設使用函式表派發, 但你可以通過 final 修飾符修改成直接派發. C++ 預設使用直接派發, 但可以通過加上 virtual 修飾符來改成函式表派發. 而 Objective-C 則總是使用訊息機制派發, 但允許開發者使用 C 直接派發來獲取效能的提高. 這樣的方式非常好, 但也給很多開發者帶來了困擾,

譯者注: 想要了解 Swift 底層結構的人, 極度推薦這段視訊

派發方式 (Types of Dispatch )

程式派發的目的是為了告訴 CPU 需要被呼叫的函式在哪裡, 在我們深入 Swift 派發機制之前, 先來了解一下這三種派發方式, 以及每種方式在動態性和效能之間的取捨.

直接派發 (Direct Dispatch)

直接派發是最快的, 不止是因為需要呼叫的指令集會更少, 並且編譯器還能夠有很大的優化空間, 例如函式內聯等, 但這不在這篇部落格的討論範圍. 直接派發也有人稱為靜態呼叫.

然而, 對於程式設計來說直接呼叫也是最大的侷限, 而且因為缺乏動態性所以沒辦法支援繼承.

函式表派發 (Table Dispatch )

函式表派發是編譯型語言實現動態行為最常見的實現方式. 函式表使用了一個陣列來儲存類宣告的每一個函式的指標. 大部分語言把這個稱為 “virtual table”(虛擬函式表), Swift 裡稱為 “witness table”. 每一個類都會維護一個函式表, 裡面記錄著類所有的函式, 如果父類函式被 override 的話, 表裡面只會儲存被 override 之後的函式. 一個子類新新增的函式, 都會被插入到這個陣列的最後. 執行時會根據這一個表去決定實際要被呼叫的函式.

舉個例子, 看看下面兩個類:

1
2
3
4
5
6
7
8
class ParentClass {
    func method1() {}
    func method2() {}
}
class ChildClass: ParentClass {
    override func method2() {}
    func method3() {}
}

在這個情況下, 編譯器會建立兩個函式表, 一個是 ParentClass 的, 另一個是 ChildClass的:

23.png

這張表展示了 ParentClass 和 ChildClass 虛數表裡 method1, method2, method3 在記憶體裡的佈局.

1
2
let obj = ChildClass()
obj.method2()

當一個函式被呼叫時, 會經歷下面的幾個過程:

  1. 讀取物件 0xB00 的函式表.

  2. 讀取函式指標的索引. 在這裡, method2 的索引是1(偏移量), 也就是 0xB00 + 1.

  3. 跳到 0x222 (函式指標指向 0x222)

查表是一種簡單, 易實現, 而且效能可預知的方式. 然而, 這種派發方式比起直接派發還是慢一點. 從位元組碼角度來看, 多了兩次讀和一次跳轉, 由此帶來了效能的損耗. 另一個慢的原因在於編譯器可能會由於函式內執行的任務導致無法優化. (如果函式帶有副作用的話)

這種基於陣列的實現, 缺陷在於函式表無法擴充. 子類會在虛數函式表的最後插入新的函式, 沒有位置可以讓 extension 安全地插入函式. 這篇提案很詳細地描述了這麼做的侷限.

訊息機制派發 (Message Dispatch )

訊息機制是呼叫函式最動態的方式. 也是 Cocoa 的基石, 這樣的機制催生了 KVOUIAppearence 和 CoreData 等功能. 這種運作方式的關鍵在於開發者可以在執行時改變函式的行為. 不止可以通過 swizzling 來改變, 甚至可以用 isa-swizzling 修改物件的繼承關係, 可以在物件導向的基礎上實現自定義派發.

舉個例子, 看看下面兩個類:

1
2
3
4
5
6
7
8
class ParentClass {
    dynamic func method1() {}
    dynamic func method2() {}
}
class ChildClass: ParentClass {
    override func method2() {}
    dynamic func method3() {}
}

Swift 會用樹來構建這種繼承關係:

24.png

這張圖很好地展示了 Swift 如何使用樹來構建類和子類.

當一個訊息被派發, 執行時會順著類的繼承關係向上查詢應該被呼叫的函式. 如果你覺得這樣做效率很低, 它確實很低! 然而, 只要快取建立了起來, 這個查詢過程就會通過快取來把效能提高到和函式表派發一樣快. 但這只是訊息機制的原理, 這裡有一篇文章很深入的講解了具體的技術細節.

Swift 的派發機制

那麼, 到底 Swift 是怎麼派發的呢? 我沒能找到一個很簡明扼要的答案, 但這裡有四個選擇具體派發方式的因素存在:

  • 宣告的位置

  • 引用型別

  • 特定的行為

  • 顯式地優化(Visibility Optimizations)

在解釋這些因素之前, 我有必要說清楚, Swift 沒有在文件裡具體寫明什麼時候會使用函式表什麼時候使用訊息機制. 唯一的承諾是使用 dynamic 修飾的時候會通過 Objective-C 的執行時進行訊息機制派發. 下面我寫的所有東西, 都只是我在 Swift 3.0 裡測試出來的結果, 並且很可能在之後的版本更新裡進行修改.

宣告的位置 (Location Matters)

在 Swift 裡, 一個函式有兩個可以宣告的位置: 型別宣告的作用域, 和 extension. 根據宣告型別的不同, 也會有不同的派發方式.

1
2
3
4
5
6
class MyClass {
    func mainMethod() {}
}
extension MyClass {
    func extensionMethod() {}
}

上面的例子裡, mainMethod 會使用函式表派發, 而 extensionMethod 則會使用直接派發. 當我第一次發現這件事情的時候覺得很意外, 直覺上這兩個函式的宣告方式並沒有那麼大的差異. 下面是我根據型別, 宣告位置總結出來的函式派發方式的表格.

25.png

這張表格展示了預設情況下 Swift 使用的派發方式.

總結起來有這麼幾點:

  • 值型別總是會使用直接派發, 簡單易懂

  • 而協議和類的 extension 都會使用直接派發

  • NSObject 的 extension 會使用訊息機制進行派發

  • NSObject 宣告作用域裡的函式都會使用函式表進行派發.

  • 協議裡宣告的, 並且帶有預設實現的函式會使用函式表進行派發

引用型別 (Reference Type Matters)

引用的型別決定了派發的方式. 這很顯而易見, 但也是決定性的差異. 一個比較常見的疑惑, 發生在一個協議擴充和型別擴充同時實現了同一個函式的時候.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 行為. 這對於很多從 Objective-C 過渡過來的開發者是反直覺的.

Swift JIRA(缺陷跟蹤管理系統) 也發現了幾個 bugs, Swfit-Evolution 郵件列表裡有一大堆討論, 也有一大堆部落格討論過這個. 但是, 這好像是故意這麼做的, 雖然官方文件沒有提過這件事情

指定派發方式 (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, 告訴編譯器可以使用直接派發. 有趣的是, dynamic @inline(__always) func dynamicOrDirect() {} 也可以通過編譯! 但這也只是告訴了編譯器而已, 實際上這個函式還是會使用訊息機制派發. 這樣的寫法看起來像是一個未定義的行為, 應該避免這麼做.

修飾符總結 (Modifier Overview)

26.png

這張圖總結這些修飾符對於 Swift 派發方式的影響.

如果你想檢視上面所有例子的話, 請看這裡.

可見的都會被優化 (Visibility Will Optimize)

Swift 會盡最大能力去優化函式派發的方式. 例如, 如果你有一個函式從來沒有 override, Swift 就會檢車並且在可能的情況下使用直接派發. 這個優化大多數情況下都表現得很好, 但對於使用了 target / action 模式的 Cocoa 開發者就不那麼友好了. 例如:

1
2
3
4
5
6
7
8
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 函式就不會被觸發.

Swift 的部落格有一篇很讚的文章描述了相關的細節, 和這些優化背後的考慮.

派發總結 (Dispatch Summary)

這裡有一大堆規則要記住, 所以我整理了一個表格:

27.png

這張表總結引用型別, 修飾符和它們對於 Swift 函式派發的影響

NSObject 以及動態性的損失 (NSObject and the Loss of Dynamic Behavior)

不久之前還有一群 Cocoa 開發者討論動態行為帶來的問題. 這段討論很有趣, 提了一大堆不同的觀點. 我希望可以在這裡繼續探討一下, 有幾個 Swift 的派發方式我覺得損害了動態性, 順便說一下我的解決方案.

NSObject 的函式表派發 (Table Dispatch in NSObject)

上面, 我提到 NSObject 子類定義裡的函式會使用函式表派發. 但我覺得很迷惑, 很難解釋清楚, 並且由於下面幾個原因, 這也只帶來了一點點效能的提升:

  • 大部分 NSObject 的子類都是在 obj_msgSend 的基礎上構建的. 我很懷疑這些派發方式的優化, 實際到底會給 Cocoa 的子類帶來多大的提升.

  • 大多數 Swift 的 NSObject 子類都會使用 extension 進行擴充, 都沒辦法使用這種優化.

最後, 有一些小細節會讓派發方式變得很複雜.

派發方式的優化破壞了 NSObject 的功能 (Dispatch Upgrades Breaking NSObject Features)

效能提升很棒, 我很喜歡 Swift 對於派發方式的優化. 但是, UIView 子類顏色的屬性理論上效能的提升破壞了 UIKit 現有的模式.

原文: However, having a theoretical performance boost in my UIView subclass color property breaking an established pattern in UIKit is damaging to the language.

NSObject 作為一個選擇 (NSObject as a Choice)

使用靜態派發的話結構體是個不錯的選擇, 而使用訊息機制派發的話則可以考慮 NSObject. 現在, 如果你想跟一個剛學 Swift 的開發者解釋為什麼某個東西是一個 NSObject 的子類, 你不得不去介紹 Objective-C 以及這段歷史. 現在沒有任何理由去繼承 NSObject 構建類, 除非你需要使用 Objective-C 構建的框架.

目前, NSObject 在 Swift 裡的派發方式, 一句話總結就是複雜, 跟理想還是有差距. 我比較想看到這個修改: 當你繼承 NSObject 的時候, 這是一個你想要完全使用動態訊息機制的表現.

顯式的動態性宣告 (Implicit Dynamic Modification)

另一個 Swift 可以改進的地方就是函式動態性的檢測. 我覺得在檢測到一個函式被 #selector 和 #keypath 引用時要自動把這些函式標記為 dynamic, 這樣的話就會解決大部分 UIAppearance 的動態問題, 但也許有別的編譯時的處理方式可以標記這些函式.

Error 以及 Bug (Errors and Bugs)

為了讓我們對 Swift 的派發方式有更多瞭解, 讓我們來看一下 Swift 開發者遇到過的 error.

SR-584

這個 Swift bug 是 Swift 函式派發的一個功能. 存在於 NSObject 子類宣告的函式(函式表派發), 以及宣告在 extension 的函式(訊息機制派發)中. 為了更好地描述這個情況, 我們先來建立一個類:

1
2
3
4
5
6
7
8
9
class Person: NSObject {
    func sayHi() {
        print("Hello")
    }
}
func greetings(person: Person) {
    person.sayHi()
}
greetings(person: Person()) // prints 'Hello'

greetings(person:) 函式使用函式表派發來呼叫 sayHi(). 就像我們看到的, 期望的, “Hello” 會被列印. 沒什麼好講的地方, 那現在讓我們繼承 Persion:

1
2
3
4
5
6
7
class MisunderstoodPerson: Person {}
extension MisunderstoodPerson {
    override func sayHi() {
        print("No one gets me.")
    }
}
greetings(person: MisunderstoodPerson()) // prints 'Hello'

可以看到, sayHi() 函式是在 extension 裡宣告的, 會使用訊息機制進行呼叫. 當greetings(person:) 被觸發時, sayHi() 會通過函式表被派發到 Person 物件, 而misunderstoodPerson 重寫之後會是用訊息機制, 而 MisunderstoodPerson 的函式表依舊保留了 Person 的實現, 緊接著歧義就產生了.

在這裡的解決方法是保證函式使用相同的訊息派發機制. 你可以給函式加上 dynamic 修飾符, 或者是把函式的實現從 extension 移動到類最初宣告的作用域裡.

理解了 Swift 的派發方式, 就能夠理解這個行為產生的原因了, 雖然 Swift 不應該讓我們遇到這個問題.

SR-103

這個 Swift bug 觸發了定義在協議擴充的預設實現, 即使是子類已經實現這個函式的情況下. 為了說明這個問題, 我們先定義一個協議, 並且給裡面的函式一個預設實現:

1
2
3
4
5
6
7
8
9
10
11
protocol Greetable {
    func sayHi()
}
extension Greetable {
    func sayHi() {
        print("Hello")
    }
}
func greetings(greeter: Greetable) {
    greeter.sayHi()
}

現在, 讓我們定義一個遵守了這個協議的類. 先定義一個 Person 類, 遵守 Greetable 協議, 然後定義一個子類 LoudPerson, 重寫sayHi() 方法.

1
2
3
4
5
6
7
class Person: Greetable {
}
class LoudPerson: Person {
    func sayHi() {
        print("HELLO")
    }
}

你們發現 LoudPerson 實現的函式前面沒有 override 修飾, 這是一個提示, 也許程式碼不會像我們設想的那樣執行. 在這個例子裡,LoudPerson 沒有在 Greetable 的協議記錄表(Protocol Witness Table)裡成功註冊, 當 sayHi() 通過 Greetable 協議派發時, 預設的實現就會被呼叫.

解決的方法就是, 在類宣告的作用域裡就要提供所有協議裡定義的函式, 即使已經有預設實現. 或者, 你可以在類的前面加上一個final 修飾符, 保證這個類不會被繼承.

Doug Gregor 在 Swift-Evolution 郵件列表裡提到, 通過顯式地重新把函式宣告為類的函式, 就可以解決這個問題, 並且不會偏離我們的設想.

其它 bug (Other bugs)

Another bug that I thought I’d mention is SR-435. It involves two protocol extensions, where one extension is more specific than the other. The example in the bug shows one un-constrained extension, and one extension that is constrained to Equatable types. When the method is invoked inside a protocol, the more specific method is not called. I’m not sure if this always occurs or not, but seems important to keep an eye on.

另外一個 bug 我在 SR-435 裡已經提過了. 當有兩個協議擴充, 而其中一個更加具體時就會觸發. 例如, 有一個不受約束的 extension, 而另一個被 Equatable 約束, 當這個方法通過協議派發, 約束比較多的那個 extension 的實現則不會被呼叫. 我不太確定這是不是百分之百能復現, 但有必要留個心眼.

If you are aware of any other Swift dispatch bugs, drop me a line and I’ll update this blog post.

如果你發現了其它 Swift 派發的 bug 的話, @一下我我就會更新到這篇部落格裡.

有趣的 Error (Interesting Error)

有一個很好玩的編譯錯誤, 可以窺見到 Swift 的計劃. 就像之前說的, 類擴充使用直接派發, 所以你試圖 override 一個宣告在 extension 裡的函式的時候會發生什麼?

1
2
3
4
5
6
7
8
9
class MyClass {
}
extension MyClass {
    func extensionMethod() {}
}
  
class SubClass: MyClass {
    override func extensionMethod() {}
}

上面的程式碼會觸發一個編譯錯誤 Declarations in extensions can not be overridden yet(宣告在 extension 裡的方法不可以被重寫). 這可能是 Swift 團隊打算加強函式表派發的一個徵兆. 又或者這只是我過度解讀, 覺得這門語言可以優化的地方.

致謝 Thanks

我希望瞭解函式派發機制的過程中你感受到了樂趣, 並且可以幫助你更好的理解 Swift. 雖然我抱怨了 NSObject 相關的一些東西, 但我還是覺得 Swift 提供了高效能的可能性, 我只是希望可以有足夠簡單的方式, 讓這篇部落格沒有存在的必要.

相關文章