深入理解 Swift 派發機制
之前看了很多關于 Swift 派發機制的內容, 但感覺沒有一篇能夠徹底講清楚這件事情, 看完了這篇文章之后我對 Swift 的派發機制才建立起了初步的認知.
正文
一張表總結引用類型, 修飾符和它們對于 Swift 函數派發方式的影響.
函數派發就是程序判斷使用哪種途徑去調用一個函數的機制. 每次函數被調用時都會被觸發, 但你又不會太留意的一個東西. 了解派發機制對于寫出高性能的代碼來說很有必要, 而且也能夠解釋很多 Swift 里”奇怪”的行為.
編譯型語言有三種基礎的函數派發方式: 直接派發(Direct Dispatch), 函數表派發(Table Dispatch) 和 消息機制派發(Message Dispatch) , 下面我會仔細講解這幾種方式. 大多數語言都會支持一到兩種, Java 默認使用函數表派發, 但你可以通過 final 修飾符修改成直接派發. C++ 默認使用直接派發, 但可以通過加上 virtual 修飾符來改成函數表派發. 而 Objective-C 則總是使用消息機制派發, 但允許開發者使用 C 直接派發來獲取性能的提高. 這樣的方式非常好, 但也給很多開發者帶來了困擾,
派發方式 (Types of Dispatch )
程序派發的目的是為了告訴 CPU 需要被調用的函數在哪里, 在我們深入 Swift 派發機制之前, 先來了解一下這三種派發方式, 以及每種方式在動態性和性能之間的取舍.
直接派發 (Direct 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的:
這張表展示了 ParentClass 和 ChildClass 虛數表里 method1, method2, method3 在內存里的布局.
let obj = ChildClass()
obj.method2()
當一個函數被調用時, 會經歷下面的幾個過程:
-
讀取對象 0xB00 的函數表.
-
讀取函數指針的索引. 在這里, method2 的索引是1(偏移量), 也就是 0xB00 + 1.
-
跳到 0x222 (函數指針指向 0x222)
查表是一種簡單, 易實現, 而且性能可預知的方式. 然而, 這種派發方式比起直接派發還是慢一點. 從字節碼角度來看, 多了兩次讀和一次跳轉, 由此帶來了性能的損耗. 另一個慢的原因在于編譯器可能會由于函數內執行的任務導致無法優化. (如果函數帶有副作用的話)
這種基于數組的實現, 缺陷在于函數表無法拓展. 子類會在虛數函數表的最后插入新的函數, 沒有位置可以讓 extension 安全地插入函數. 這篇 提案 很詳細地描述了這么做的局限.
消息機制派發 (Message Dispatch )
消息機制是調用函數最動態的方式. 也是 Cocoa 的基石, 這樣的機制催生了 KVO , UIAppearence 和 CoreData 等功能. 這種運作方式的關鍵在于開發者可以在運行時改變函數的行為. 不止可以通過 swizzling 來改變, 甚至可以用 isa-swizzling 修改對象的繼承關系, 可以在面向對象的基礎上實現自定義派發.
舉個例子, 看看下面兩個類:
class ParentClass {
dynamic func method1() {}
dynamic func method2() {}
}
class ChildClass: ParentClass {
override func method2() {}
dynamic func method3() {}
}
Swift 會用樹來構建這種繼承關系:
這張圖很好地展示了 Swift 如何使用樹來構建類和子類.
當一個消息被派發, 運行時會順著類的繼承關系向上查找應該被調用的函數. 如果你覺得這樣做效率很低, 它確實很低! 然而, 只要緩存建立了起來, 這個查找過程就會通過緩存來把性能提高到和函數表派發一樣快. 但這只是消息機制的原理, 這里有一篇文章很深入的講解了具體的技術細節.
Swift 的派發機制
那么, 到底 Swift 是怎么派發的呢? 我沒能找到一個很簡明扼要的答案, 但這里有四個選擇具體派發方式的因素存在:
-
聲明的位置
-
引用類型
-
特定的行為
-
顯式地優化(Visibility Optimizations)
在解釋這些因素之前, 我有必要說清楚, Swift 沒有在文檔里具體寫明什么時候會使用函數表什么時候使用消息機制. 唯一的承諾是使用 dynamic 修飾的時候會通過 Objective-C 的運行時進行消息機制派發. 下面我寫的所有東西, 都只是我在 Swift 3.0 里測試出來的結果, 并且很可能在之后的版本更新里進行修改.
聲明的位置 (Location Matters)
在 Swift 里, 一個函數有兩個可以聲明的位置: 類型聲明的作用域, 和 extension. 根據聲明類型的不同, 也會有不同的派發方式.
class MyClass {
func mainMethod() {}
}
extension MyClass {
func extensionMethod() {}
}
上面的例子里, mainMethod 會使用函數表派發, 而 extensionMethod 則會使用直接派發. 當我第一次發現這件事情的時候覺得很意外, 直覺上這兩個函數的聲明方式并沒有那么大的差異. 下面是我根據類型, 聲明位置總結出來的函數派發方式的表格.
這張表格展示了默認情況下 Swift 使用的派發方式.
總結起來有這么幾點:
-
值類型總是會使用直接派發, 簡單易懂
-
而協議和類的 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 行為. 這對于很多從 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)
這張圖總結這些修飾符對于 Swift 派發方式的影響.
可見的都會被優化 (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 函數就不會被觸發.
派發總結 (Dispatch Summary)
這里有一大堆規則要記住, 所以我整理了一個表格:
這張表總結引用類型, 修飾符和它們對于 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 的函數(消息機制派發)中. 為了更好地描述這個情況, 我們先來創建一個類:
class Person: NSObject {
func sayHi() {
print("Hello")
}
}
func greetings(person: Person) {
person.sayHi()
}
greetings(person: Person()) // prints 'Hello'
greetings(person:) 函數使用函數表派發來調用 sayHi(). 就像我們看到的, 期望的, “Hello” 會被打印. 沒什么好講的地方, 那現在讓我們繼承 Persion:
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 觸發了定義在協議拓展的默認實現, 即使是子類已經實現這個函數的情況下. 為了說明這個問題, 我們先定義一個協議, 并且給里面的函數一個默認實現:
protocol Greetable {
func sayHi()
}
extension Greetable {
func sayHi() {
print("Hello")
}
}
func greetings(greeter: Greetable) {
greeter.sayHi()
}
現在, 讓我們定義一個遵守了這個協議的類. 先定義一個 Person 類, 遵守 Greetable 協議, 然后定義一個子類 LoudPerson, 重寫sayHi() 方法.
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.
有趣的 Error (Interesting Error)
有一個很好玩的編譯錯誤, 可以窺見到 Swift 的計劃. 就像之前說的, 類拓展使用直接派發, 所以你試圖 override 一個聲明在 extension 里的函數的時候會發生什么?
class MyClass {
}
extension MyClass {
func extensionMethod() {}
}
class SubClass: MyClass {
override func extensionMethod() {}
}
上面的代碼會觸發一個編譯錯誤 Declarations in extensions can not be overridden yet(聲明在 extension 里的方法不可以被重寫). 這可能是 Swift 團隊打算加強函數表派發的一個征兆. 又或者這只是我過度解讀, 覺得這門語言可以優化的地方.
我希望了解函數派發機制的過程中你感受到了樂趣, 并且可以幫助你更好的理解 Swift. 雖然我抱怨了 NSObject 相關的一些東西, 但我還是覺得 Swift 提供了高性能的可能性, 我只是希望可以有足夠簡單的方式, 讓這篇博客沒有存在的必要.
來自:http://www.cocoachina.com/swift/20170112/18574.html