本文是筆者在 MDCC 16 (移動開發者大會) 上 iOS 專場中的主題演講的文字整理。您可以在這裡找到演講使用的 Keynote,部分示例程式碼可以在 MDCC 2016 的官方 repo 中找到。因為全部內容比較長,所以分成了上下兩個部分,本文 (上) 主要介紹了一些理論方面的內容,包括物件導向程式設計存在的問題,面向協議的基本概念和決策模型等,下半部分主要展示了一些筆者日常使用面向協議思想和 Cocoa 開發結合的示例程式碼,並對其進行了一些解說。
引子
面向協議程式設計 (Protocol Oriented Programming,以下簡稱 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一種程式設計正規化。相比與傳統的物件導向程式設計 (OOP),POP 顯得更加靈活。結合 Swift 的值語義特性和 Swift 標準庫的實現,這一年來大家發現了很多 POP 的應用場景。本次演講希望能在介紹 POP 思想的基礎上,引入一些日常開發中可以使用 POP 的場景,讓與會來賓能夠開始在日常工作中嘗試 POP,並改善程式碼設計。
起・初識 – 什麼是 Swift 協議
Protocol
Swift 標準庫中有 50 多個複雜不一的協議,幾乎所有的實際型別都是滿足若干協議的。protocol 是 Swift 語言的底座,語言的其他部分正是在這個底座上組織和建立起來的。這和我們熟知的物件導向的構建方式很不一樣。
一個最簡單但是有實際用處的 Swift 協議定義如下:
1 2 3 4 5 |
protocol Greetable { var name: String { get } func greet() } |
這幾行程式碼定義了一個名為 Greetable
的協議,其中有一個 name
屬性的定義,以及一個 greet
方法的定義。
所謂協議,就是一組屬性和/或方法的定義,而如果某個具體型別想要遵守一個協議,那它需要實現這個協議所定義的所有這些內容。協議實際上做的事情不過是“關於實現的約定”。
物件導向
在深入 Swift 協議的概念之前,我想先重新讓大家回顧一下物件導向。相信我們不論在教科書或者是部落格等各種地方對這個名詞都十分熟悉了。那麼有一個很有意思,但是其實並不是每個程式設計師都想過的問題,物件導向的核心思想究竟是什麼?
我們先來看一段物件導向的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Animal { var leg: Int { return 2 } func eat() { print("eat food.") } func run() { print("run with \(leg) legs") } } class Tiger: Animal { override var leg: Int { return 4 } override func eat() { print("eat meat.") } } let tiger = Tiger() tiger.eat() // "eat meat" tiger.run() // "run with 4 legs" |
父類 Animal
定義了動物的 leg
(這裡應該使用虛類,但是 Swift 中沒有這個概念,所以先請無視這裡的 return 2
),以及動物的 eat
和 run
方法,併為它們提供了實現。子類的 Tiger
根據自身情況重寫了 leg
(4 條腿)和 eat
(吃肉),而對於 run
,父類的實現已經滿足需求,因此不必重寫。
我們看到 Tiger
和 Animal
共享了一部分程式碼,這部分程式碼被封裝到了父類中,而除了 Tiger
的其他的子類也能夠使用 Animal
的這些程式碼。這其實就是 OOP 的核心思想 – 使用封裝和繼承,將一系列相關的內容放到一起。我們的前輩們為了能夠對真實世界的物件進行建模,發展出了物件導向程式設計的概念,但是這套理念有一些缺陷。雖然我們努力用這套抽象和繼承的方法進行建模,但是實際的事物往往是一系列特質的組合,而不單單是以一脈相承並逐漸擴充套件的方式構建的。所以最近大家越來越發現物件導向很多時候其實不能很好地對事物進行抽象,我們可能需要尋找另一種更好的方式。
物件導向程式設計的困境
橫切關注點
我們再來看一個例子。這次讓我們遠離動物世界,回到 Cocoa,假設我們有一個 ViewController
,它繼承自 UIViewController
,我們向其中新增一個 myMethod
:
1 2 3 4 5 6 7 8 9 10 11 |
class ViewCotroller: UIViewController { // 繼承 // view, isFirstResponder()... // 新加 func myMethod() { } } |
如果這時候我們又有一個繼承自 UITableViewController
的 AnotherViewController
,我們也想向其中新增同樣的 myMethod
:
1 2 3 4 5 6 7 8 9 10 11 |
class AnotherViewController: UITableViewController { // 繼承 // tableView, isFirstResponder()... // 新加 func myMethod() { } } |
這時,我們迎來了 OOP 的第一個大困境,那就是我們很難在不同繼承關係的類裡共用程式碼。這裡的問題用“行話”來說叫做“橫切關注點” (Cross-Cutting Concerns)。我們的關注點 myMethod
位於兩條繼承鏈 (UIViewController
-> ViewCotroller
和 UIViewController
-> UITableViewController
-> AnotherViewController
) 的橫切面上。物件導向是一種不錯的抽象方式,但是肯定不是最好的方式。它無法描述兩個不同事物具有某個相同特性這一點。在這裡,特性的組合要比繼承更貼切事物的本質。
想要解決這個問題,我們有幾個方案:
- Copy & Paste
這是一個比較糟糕的解決方案,但是演講現場還是有不少朋友選擇了這個方案,特別是在工期很緊,無暇優化的情況下。這誠然可以理解,但是這也是壞程式碼的開頭。我們應該儘量避免這種做法。
- 引入 BaseViewController
在一個繼承自
UIViewController
的BaseViewController
上新增需要共享的程式碼,或者乾脆在UIViewController
上新增 extension。看起來這是一個稍微靠譜的做法,但是如果不斷這麼做,會讓所謂的Base
很快變成垃圾堆。職責不明確,任何東西都能扔進Base
,你完全不知道哪些類走了Base
,而這個“超級類”對程式碼的影響也會不可預估。 - 依賴注入
通過外界傳入一個帶有
myMethod
的物件,用新的型別來提供這個功能。這是一個稍好的方式,但是引入額外的依賴關係,可能也是我們不太願意看到的。 - 多繼承
當然,Swift 是不支援多繼承的。不過如果有多繼承的話,我們確實可以從多個父類進行繼承,並將
myMethod
新增到合適的地方。有一些語言選擇了支援多繼承 (比如 C++),但是它會帶來 OOP 中另一個著名的問題:菱形缺陷。
菱形缺陷
上面的例子中,如果我們有多繼承,那麼 ViewController
和 AnotherViewController
的關係可能會是這樣的:
在上面這種拓撲結構中,我們只需要在 ViewController
中實現 myMethod
,在 AnotherViewController
中也就可以繼承並使用它了。看起來很完美,我們避免了重複。但是多繼承有一個無法迴避的問題,就是兩個父類都實現了同樣的方法時,子類該怎麼辦?我們很難確定應該繼承哪一個父類的方法。因為多繼承的拓撲結構是一個菱形,所以這個問題又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 這樣的語言選擇粗暴地將菱形缺陷的問題交給程式設計師處理,這無疑非常複雜,並且增加了人為錯誤的可能性。而絕大多數現代語言對多繼承這個特性選擇避而遠之。
動態派發安全性
Objective-C 恰如其名,是一門典型的 OOP 語言,同時它繼承了 Small Talk 的訊息傳送機制。這套機制十分靈活,是 OC 的基礎思想,但是有時候相對危險。考慮下面的程式碼:
1 2 3 4 5 6 7 8 9 10 11 |
ViewController *v1 = ... [v1 myMethod]; AnotherViewController *v2 = ... [v2 myMethod]; NSArray *array = @[v1, v2]; for (id obj in array) { [obj myMethod]; } |
我們如果在 ViewController
和 AnotherViewController
中都實現了 myMethod
的話,這段程式碼是沒有問題的。myMethod
將會被動態傳送給 array
中的 v1
和 v2
。但是,要是我們有一個沒有實現 myMethod
的型別,會如何呢?
1 2 3 4 5 6 7 8 9 10 11 |
NSObject *v3 = [NSObject new] // v3 沒有實現 `myMethod` NSArray *array = @[v1, v2, v3]; for (id obj in array) { [obj myMethod]; } // Runtime error: // unrecognized selector sent to instance blabla |
編譯依然可以通過,但是顯然,程式將在執行時崩潰。Objective-C 是不安全的,編譯器預設你知道某個方法確實有實現,這是訊息傳送的靈活性所必須付出的代價。而在 app 開發看來,用可能的崩潰來換取靈活性,顯然這個代價太大了。雖然這不是 OOP 正規化的問題,但它確實在 Objective-C 時代給我們帶來了切膚之痛。
三大困境
我們可以總結一下 OOP 面臨的這幾個問題。
- 動態派發安全性
- 橫切關注點
- 菱形缺陷
首先,在 OC 中動態派發讓我們承擔了在執行時才發現錯誤的風險,這很有可能是發生在上線產品中的錯誤。其次,橫切關注點讓我們難以對物件進行完美的建模,程式碼的重用也會更加糟糕。
承・相知 – 協議擴充套件和麵向協議程式設計
使用協議解決 OOP 困境
協議並不是什麼新東西,也不是 Swift 的發明。在 Java 和 C# 裡,它叫做 Interface
。而 Swift 中的 protocol 將這個概念繼承了下來,併發揚光大。讓我們回到一開始定義的那個簡單協議,並嘗試著實現這個協議:
1 2 3 4 5 |
protocol Greetable { var name: String { get } func greet() } |
1 2 3 4 5 6 7 8 |
struct Person: Greetable { let name: String func greet() { print("你好 \(name)") } } Person(name: "Wei Wang").greet() |
實現很簡單,Person
結構體通過實現 name
和 greet
來滿足 Greetable
。在呼叫時,我們就可以使用 Greetable
中定義的方法了。
動態派發安全性
除了 Person
,其他型別也可以實現 Greetable
,比如 Cat
:
1 2 3 4 5 6 7 |
struct Cat: Greetable { let name: String func greet() { print("meow~ \(name)") } } |
現在,我們就可以將協議作為標準型別,來對方法呼叫進行動態派發了:
1 2 3 4 5 6 7 8 9 |
let array: [Greetable] = [ Person(name: "Wei Wang"), Cat(name: "onevcat")] for obj in array { obj.greet() } // 你好 Wei Wang // meow~ onevcat |
對於沒有實現 Greetbale 的型別,編譯器將返回錯誤,因此不存在訊息誤傳送的情況:
1 2 3 4 5 6 7 8 |
struct Bug: Greetable { let name: String } // Compiler Error: // 'Bug' does not conform to protocol 'Greetable' // protocol requires function 'greet()' |
這樣一來,動態派發安全性的問題迎刃而解。如果你保持在 Swift 的世界裡,那這個你的所有程式碼都是安全的。
- ✅ 動態派發安全性
- 橫切關注點
- 菱形缺陷
橫切關注點
使用協議和協議擴充套件,我們可以很好地共享程式碼。回到上一節的 myMethod
方法,我們來看看如何使用協議來搞定它。首先,我們可以定義一個含有 myMethod
的協議:
1 2 3 4 |
protocol P { func myMethod() } |
注意這個協議沒有提供任何的實現。我們依然需要在實際型別遵守這個協議的時候為它提供具體的實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// class ViewController: UIViewController extension ViewController: P { func myMethod() { doWork() } } // class AnotherViewController: UITableViewController extension AnotherViewController: P { func myMethod() { doWork() } } |
你可能不禁要問,這和 Copy & Paste 的解決方式有何不同?沒錯,答案就是 — 沒有不同。不過稍安勿躁,我們還有其他科技可以解決這個問題,那就是協議擴充套件。協議本身並不是很強大,只是靜態型別語言的編譯器保證,在很多靜態語言中也有類似的概念。那到底是什麼讓 Swift 成為了一門協議優先的語言?真正使協議發生質變,並讓大家如此關注的原因,其實是在 WWDC 2015 和 Swift 2 釋出時,Apple 為協議引入了一個新特性,協議擴充套件,它為 Swift 語言帶來了一次革命性的變化。
所謂協議擴充套件,就是我們可以為一個協議提供預設的實現。對於 P
,可以在 extension P
中為 myMethod
新增一個實現:
1 2 3 4 5 6 7 8 9 10 |
protocol P { func myMethod() } extension P { func myMethod() { doWork() } } |
有了這個協議擴充套件後,我們只需要簡單地宣告 ViewController
和 AnotherViewController
遵守 P
,就可以直接使用 myMethod
的實現了:
1 2 3 4 5 6 |
extension ViewController: P { } extension AnotherViewController: P { } viewController.myMethod() anotherViewController.myMethod() |
不僅如此,除了已經定義過的方法,我們甚至可以在擴充套件中新增協議裡沒有定義過的方法。在這些額外的方法中,我們可以依賴協議定義過的方法進行操作。我們之後會看到更多的例子。總結下來:
- 協議定義
- 提供實現的入口
- 遵循協議的型別需要對其進行實現
- 協議擴充套件
- 為入口提供預設實現
- 根據入口提供額外實現
這樣一來,橫切點關注的問題也簡單安全地得到了解決。
- ✅ 動態派發安全性
- ✅ 橫切關注點
- 菱形缺陷
菱形缺陷
最後我們看看多繼承。多繼承中存在的一個重要問題是菱形缺陷,也就是子類無法確定使用哪個父類的方法。在協議的對應方面,這個問題雖然依然存在,但卻是可以唯一安全地確定的。我們來看一個多個協議中出現同名元素的例子:
1 2 3 4 5 6 7 8 9 |
protocol Nameable { var name: String { get } } protocol Identifiable { var name: String { get } var id: Int { get } } |
如果有一個型別,需要同時實現兩個協議的話,它必須提供一個 name
屬性,來同時滿足兩個協議的要求:
1 2 3 4 5 6 7 |
struct Person: Nameable, Identifiable { let name: String let id: Int } // `name` 屬性同時滿足 Nameable 和 Identifiable 的 name |
這裡比較有意思,又有點讓人困惑的是,如果我們為其中的某個協議進行了擴充套件,在其中提供了預設的 name
實現,會如何。考慮下面的程式碼:
1 2 3 4 5 6 7 8 9 10 11 |
extension Nameable { var name: String { return "default name" } } struct Person: Nameable, Identifiable { // let name: String let id: Int } // Identifiable 也將使用 Nameable extension 中的 name |
這樣的編譯是可以通過的,雖然 Person
中沒有定義 name
,但是通過 Nameable
的 name
(因為它是靜態派發的),Person
依然可以遵守 Identifiable
。不過,當 Nameable
和 Identifiable
都有 name
的協議擴充套件的話,就無法編譯了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
extension Nameable { var name: String { return "default name" } } extension Identifiable { var name: String { return "another default name" } } struct Person: Nameable, Identifiable { // let name: String let id: Int } // 無法編譯,name 屬性衝突 |
這種情況下,Person
無法確定要使用哪個協議擴充套件中 name
的定義。在同時實現兩個含有同名元素的協議,並且它們都提供了預設擴充套件時,我們需要在具體的型別中明確地提供實現。這裡我們將 Person
中的 name
進行實現就可以了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
extension Nameable { var name: String { return "default name" } } extension Identifiable { var name: String { return "another default name" } } struct Person: Nameable, Identifiable { let name: String let id: Int } Person(name: "onevcat", id: 123).name // onevcat |
這裡的行為看起來和菱形問題很像,但是有一些本質不同。首先,這個問題出現的前提條件是同名元素以及同時提供了實現,而協議擴充套件對於協議本身來說並不是必須的。其次,我們在具體型別中提供的實現一定是安全和確定的。當然,菱形缺陷沒有被完全解決,Swift 還不能很好地處理多個協議的衝突,這是 Swift 現在的不足。
- ✅ 動態派發安全性
- ✅ 橫切關注點
- ❓菱形缺陷
本文的下半部分將展示一些筆者日常使用面向協議思想和 Cocoa 開發結合的示例程式碼,並對其進行了一些解說。