原文: Method Dispatch in Swift
做者: Brain King
譯者: kemchenjhtml
以前看了不少關於 Swift 派發機制的內容, 但感受沒有一篇可以完全講清楚這件事情, 看完了這篇文章以後我對 Swift 的派發機制才創建起了初步的認知.git
一張表總結引用類型, 修飾符和它們對於 Swift 函數派發方式的影響.github
函數派發就是程序判斷使用哪一種途徑去調用一個函數的機制. 每次函數被調用時都會被觸發, 但你又不會太留意的一個東西. 瞭解派發機制對於寫出高性能的代碼來講頗有必要, 並且也可以解釋不少 Swift 裏"奇怪"的行爲.編程
編譯型語言有三種基礎的函數派發方式: 直接派發(Direct Dispatch), 函數表派發(Table Dispatch) 和 消息機制派發(Message Dispatch), 下面我會仔細講解這幾種方式. 大多數語言都會支持一到兩種, Java 默認使用函數表派發, 但你能夠經過 final
修飾符修改爲直接派發. C++ 默認使用直接派發, 但能夠經過加上 virtual
修飾符來改爲函數表派發. 而 Objective-C 則老是使用消息機制派發, 但容許開發者使用 C 直接派發來獲取性能的提升. 這樣的方式很是好, 但也給不少開發者帶來了困擾,swift
譯者注: 想要了解 Swift 底層結構的人, 極度推薦這段視頻數組
程序派發的目的是爲了告訴 CPU 須要被調用的函數在哪裏, 在咱們深刻 Swift 派發機制以前, 先來了解一下這三種派發方式, 以及每種方式在動態性和性能之間的取捨.緩存
直接派發是最快的, 不止是由於須要調用的指令集會更少, 而且編譯器還可以有很大的優化空間, 例如函數內聯等, 但這不在這篇博客的討論範圍. 直接派發也有人稱爲靜態調用.安全
然而, 對於編程來講直接調用也是最大的侷限, 並且由於缺少動態性因此沒辦法支持繼承.app
函數表派發是編譯型語言實現動態行爲最多見的實現方式. 函數表使用了一個數組來存儲類聲明的每個函數的指針. 大部分語言把這個稱爲 "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 安全地插入函數. 這篇提案很詳細地描述了這麼作的侷限.
消息機制是調用函數最動態的方式. 也是 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 是怎麼派發的呢? 我沒能找到一個很簡明扼要的答案, 但這裏有四個選擇具體派發方式的因素存在:
聲明的位置
引用類型
特定的行爲
顯式地優化(Visibility Optimizations)
在解釋這些因素以前, 我有必要說清楚, Swift 沒有在文檔裏具體寫明何時會使用函數表何時使用消息機制. 惟一的承諾是使用 dynamic
修飾的時候會經過 Objective-C 的運行時進行消息機制派發. 下面我寫的全部東西, 都只是我在 Swift 3.0 裏測試出來的結果, 而且極可能在以後的版本更新裏進行修改.
在 Swift 裏, 一個函數有兩個能夠聲明的位置: 類型聲明的做用域, 和 extension. 根據聲明類型的不一樣, 也會有不一樣的派發方式.
class MyClass { func mainMethod() {} } extension MyClass { func extensionMethod() {} }
上面的例子裏, mainMethod
會使用函數表派發, 而 extensionMethod
則會使用直接派發. 當我第一次發現這件事情的時候以爲很意外, 直覺上這兩個函數的聲明方式並無那麼大的差別. 下面是我根據類型, 聲明位置總結出來的函數派發方式的表格.
這張表格展現了默認狀況下 Swift 使用的派發方式.
總結起來有這麼幾點:
值類型老是會使用直接派發, 簡單易懂
而協議和類的 extension 都會使用直接派發
NSObject
的 extension 會使用消息機制進行派發
NSObject
聲明做用域裏的函數都會使用函數表進行派發.
協議裏聲明的, 而且帶有默認實現的函數會使用函數表進行派發
引用的類型決定了派發的方式. 這很顯而易見, 但也是決定性的差別. 一個比較常見的疑惑, 發生在一個協議拓展和類型拓展同時實現了同一個函數的時候.
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 郵件列表裏有一大堆討論, 也有一大堆博客討論過這個. 可是, 這好像是故意這麼作的, 雖然官方文檔沒有提過這件事情
Swift 有一些修飾符能夠指定派發方式.
final
容許類裏面的函數使用直接派發. 這個修飾符會讓函數失去動態性. 任何函數均可以使用這個修飾符, 就算是 extension 裏原本就是直接派發的函數. 這也會讓 Objective-C 的運行時獲取不到這個函數, 不會生成相應的 selector.
dynamic
可讓類裏面的函數使用消息機制派發. 使用 dynamic
, 必須導入 Foundation
框架, 裏面包括了 NSObject
和 Objective-C 的運行時. dynamic
可讓聲明在 extension 裏面的函數可以被 override. dynamic
能夠用在全部 NSObject
的子類和 Swift 的原聲類.
@objc
和 @nonobjc
顯式地聲明瞭一個函數是否能被 Objective-C 的運行時捕獲到. 使用 @objc
的典型例子就是給 selector 一個命名空間 @objc(abc_methodName)
, 讓這個函數能夠被 Objective-C 的運行時調用. @nonobjc
會改變派發的方式, 能夠用來禁止消息機制派發這個函數, 不讓這個函數註冊到 Objective-C 的運行時裏. 我不肯定這跟 final
有什麼區別, 由於從使用場景來講也幾乎同樣. 我我的來講更喜歡 final
, 由於意圖更加明顯.
譯者注: 我我的感受, 這這主要是爲了跟 Objective-C 兼容用的,
final
等原生關鍵詞, 是讓 Swift 寫服務端之類的代碼的時候能夠有原生的關鍵詞可使用.
能夠在標記爲 final
的同時, 也使用 @objc
來讓函數可使用消息機制派發. 這麼作的結果就是, 調用函數的時候會使用直接派發, 但也會在 Objective-C 的運行時裏註冊響應的 selector. 函數能夠響應 perform(selector:)
以及別的 Objective-C 特性, 但在直接調用時又能夠有直接派發的性能.
Swift 也支持 @inline
, 告訴編譯器可使用直接派發. 有趣的是, dynamic @inline(__always) func dynamicOrDirect() {}
也能夠經過編譯! 但這也只是告訴了編譯器而已, 實際上這個函數仍是會使用消息機制派發. 這樣的寫法看起來像是一個未定義的行爲, 應該避免這麼作.
這張圖總結這些修飾符對於 Swift 派發方式的影響.
若是你想查看上面全部例子的話, 請看這裏.
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 函數就不會被觸發.
Swift 的博客有一篇很讚的文章描述了相關的細節, 和這些優化背後的考慮.
這裏有一大堆規則要記住, 因此我整理了一個表格:
這張表總結引用類型, 修飾符和它們對於 Swift 函數派發的影響
不久以前還有一羣 Cocoa 開發者討論動態行爲帶來的問題. 這段討論頗有趣, 提了一大堆不一樣的觀點. 我但願能夠在這裏繼續探討一下, 有幾個 Swift 的派發方式我以爲損害了動態性, 順便說一下個人解決方案.
上面, 我提到 NSObject
子類定義裏的函數會使用函數表派發. 但我以爲很迷惑, 很難解釋清楚, 而且因爲下面幾個緣由, 這也只帶來了一點點性能的提高:
大部分 NSObject
的子類都是在 obj_msgSend
的基礎上構建的. 我很懷疑這些派發方式的優化, 實際到底會給 Cocoa 的子類帶來多大的提高.
大多數 Swift 的 NSObject
子類都會使用 extension 進行拓展, 都沒辦法使用這種優化.
最後, 有一些小細節會讓派發方式變得很複雜.
性能提高很棒, 我很喜歡 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
. 如今, 若是你想跟一個剛學 Swift 的開發者解釋爲何某個東西是一個 NSObject
的子類, 你不得不去介紹 Objective-C 以及這段歷史. 如今沒有任何理由去繼承 NSObject
構建類, 除非你須要使用 Objective-C 構建的框架.
目前, NSObject
在 Swift 裏的派發方式, 一句話總結就是複雜, 跟理想仍是有差距. 我比較想看到這個修改: 當你繼承 NSObject
的時候, 這是一個你想要徹底使用動態消息機制的表現.
另外一個 Swift 能夠改進的地方就是函數動態性的檢測. 我以爲在檢測到一個函數被 #selector
和 #keypath
引用時要自動把這些函數標記爲 dynamic
, 這樣的話就會解決大部分 UIAppearance
的動態問題, 但也許有別的編譯時的處理方式能夠標記這些函數.
爲了讓咱們對 Swift 的派發方式有更多瞭解, 讓咱們來看一下 Swift 開發者遇到過的 error.
這個 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 不該該讓咱們遇到這個問題.
這個 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 郵件列表裏提到, 經過顯式地從新把函數聲明爲類的函數, 就能夠解決這個問題, 而且不會偏離咱們的設想.
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 的話, @一下我我就會更新到這篇博客裏.
有一個很好玩的編譯錯誤, 能夠窺見到 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 提供了高性能的可能性, 我只是但願能夠有足夠簡單的方式, 讓這篇博客沒有存在的必要.