面向協(xié)議編程與 Cocoa 的邂逅 (上)
本文轉(zhuǎn)載自微信公眾號(hào)「Swift 社區(qū)」,作者Onevcat。轉(zhuǎn)載本文請(qǐng)聯(lián)系Swift 社區(qū)公眾號(hào)。
本文筆者在 MDCC 16 (移動(dòng)開發(fā)者大會(huì)) 上 iOS 專場(chǎng)中的主題演講的文字整理。發(fā)布于 2016年11月29日 最后更新于 2020年10月22日
您可以在這里[1]找到演講使用的 Keynote,部分示例代碼可以在 MDCC 2016 的 官方 repo[2]中找到。因?yàn)槿績(jī)?nèi)容比較長(zhǎng),所以分成了上下兩個(gè)部分,本文 (上) 主要介紹了一些理論方面的內(nèi)容,包括面向?qū)ο缶幊檀嬖诘膯栴},面向協(xié)議的基本概念和決策模型等,下半部分主要展示了一些筆者日常使用面向協(xié)議思想和 Cocoa 開發(fā)結(jié)合的示例代碼,并對(duì)其進(jìn)行了一些解說。
1. 引子
面向協(xié)議編程 (Protocol Oriented Programming,以下簡(jiǎn)稱 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一種編程范式。相比與傳統(tǒng)的面向?qū)ο缶幊?(OOP),POP 顯得更加靈活。結(jié)合 Swift 的值語(yǔ)義特性和 Swift 標(biāo)準(zhǔn)庫(kù)的實(shí)現(xiàn),這一年來大家發(fā)現(xiàn)了很多 POP 的應(yīng)用場(chǎng)景。
本次演講希望能在介紹 POP 思想的基礎(chǔ)上,引入一些日常開發(fā)中可以使用 POP 的場(chǎng)景,讓與會(huì)來賓能夠開始在日常工作中嘗試 POP,并改善代碼設(shè)計(jì)。
2. 什么是 Swift 協(xié)議
2.1 Protocol
Swift 標(biāo)準(zhǔn)庫(kù)中有 50 多個(gè)復(fù)雜不一的協(xié)議,幾乎所有的實(shí)際類型都是滿足若干協(xié)議的。protocol 是 Swift 語(yǔ)言的底座,語(yǔ)言的其他部分正是在這個(gè)底座上組織和建立起來的。這和我們熟知的面向?qū)ο蟮臉?gòu)建方式很不一樣。
一個(gè)最簡(jiǎn)單但是有實(shí)際用處的 Swift 協(xié)議定義如下:
- protocol Greetable {
- var name: String { get }
- func greet()
- }
這幾行代碼定義了一個(gè)名為 Greetable 的協(xié)議,其中有一個(gè) name 屬性的定義,以及一個(gè) greet 方法的定義。
所謂協(xié)議,就是一組屬性和/或方法的定義,而如果某個(gè)具體類型想要遵守一個(gè)協(xié)議,那它需要實(shí)現(xiàn)這個(gè)協(xié)議所定義的所有這些內(nèi)容。協(xié)議實(shí)際上做的事情不過是“關(guān)于實(shí)現(xiàn)的約定”。
2.2 面向?qū)ο?/strong>
在深入 Swift 協(xié)議的概念之前,我想先重新讓大家回顧一下面向?qū)ο?。相信我們不論在教科書或者是博客等各種地方對(duì)這個(gè)名詞都十分熟悉了。那么有一個(gè)很有意思,但是其實(shí)并不是每個(gè)程序員都想過的問題,面向?qū)ο蟮暮诵乃枷刖烤故鞘裁?
我們先來看一段面向?qū)ο蟮拇a:
- 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 定義了動(dòng)物的 leg (這里應(yīng)該使用虛類,但是 Swift 中沒有這個(gè)概念,所以先請(qǐng)無(wú)視這里的 return 2),以及動(dòng)物的 eat 和 run 方法,并為它們提供了實(shí)現(xiàn)。子類的 Tiger 根據(jù)自身情況重寫了 leg (4 條腿)和 eat (吃肉),而對(duì)于 run,父類的實(shí)現(xiàn)已經(jīng)滿足需求,因此不必重寫。
我們看到 Tiger 和 Animal 共享了一部分代碼,這部分代碼被封裝到了父類中,而除了 Tiger 的其他的子類也能夠使用 Animal 的這些代碼。這其實(shí)就是 OOP 的核心思想 - 使用封裝和繼承,將一系列相關(guān)的內(nèi)容放到一起。
我們的前輩們?yōu)榱四軌驅(qū)φ鎸?shí)世界的對(duì)象進(jìn)行建模,發(fā)展出了面向?qū)ο缶幊痰母拍?,但是這套理念有一些缺陷。雖然我們努力用這套抽象和繼承的方法進(jìn)行建模,但是實(shí)際的事物往往是一系列特質(zhì)的組合,而不單單是以一脈相承并逐漸擴(kuò)展的方式構(gòu)建的。
所以最近大家越來越發(fā)現(xiàn)面向?qū)ο蠛芏鄷r(shí)候其實(shí)不能很好地對(duì)事物進(jìn)行抽象,我們可能需要尋找另一種更好的方式。
2.3 面向?qū)ο缶幊痰睦Ь?/strong>
2.3.1 橫切關(guān)注點(diǎn)
我們?cè)賮砜匆粋€(gè)例子。這次讓我們遠(yuǎn)離動(dòng)物世界,回到 Cocoa,假設(shè)我們有一個(gè) ViewController,它繼承自 UIViewController,我們向其中添加一個(gè) myMethod:
- class ViewCotroller: UIViewController
- {
- // 繼承
- // view, isFirstResponder()...
- // 新加
- func myMethod() {
- }
- }
如果這時(shí)候我們又有一個(gè)繼承自 UITableViewController 的 AnotherViewController,我們也想向其中添加同樣的 myMethod:
- class AnotherViewController: UITableViewController
- {
- // 繼承
- // tableView, isFirstResponder()...
- // 新加
- func myMethod() {
- }
- }
這時(shí),我們迎來了 OOP 的第一個(gè)大困境,那就是我們很難在不同繼承關(guān)系的類里共用代碼。這里的問題用“行話”來說叫做“橫切關(guān)注點(diǎn)” (Cross-Cutting Concerns)。我們的關(guān)注點(diǎn) myMethod 位于兩條繼承鏈 (UIViewController -> ViewCotroller 和 UIViewController -> UITableViewController -> AnotherViewController) 的橫切面上。
面向?qū)ο笫且环N不錯(cuò)的抽象方式,但是肯定不是最好的方式。它無(wú)法描述兩個(gè)不同事物具有某個(gè)相同特性這一點(diǎn)。在這里,特性的組合要比繼承更貼切事物的本質(zhì)。
想要解決這個(gè)問題,我們有幾個(gè)方案:
- Copy & Paste
這是一個(gè)比較糟糕的解決方案,但是演講現(xiàn)場(chǎng)還是有不少朋友選擇了這個(gè)方案,特別是在工期很緊,無(wú)暇優(yōu)化的情況下。這誠(chéng)然可以理解,但是這也是壞代碼的開頭。我們應(yīng)該盡量避免這種做法。
- 引入 BaseViewController
在一個(gè)繼承自 UIViewController 的 BaseViewController 上添加需要共享的代碼,或者干脆在 UIViewController 上添加 extension??雌饋磉@是一個(gè)稍微靠譜的做法,但是如果不斷這么做,會(huì)讓所謂的 Base 很快變成垃圾堆。職責(zé)不明確,任何東西都能扔進(jìn) Base,你完全不知道哪些類走了 Base,而這個(gè)“超級(jí)類”對(duì)代碼的影響也會(huì)不可預(yù)估。
- 依賴注入
通過外界傳入一個(gè)帶有 myMethod 的對(duì)象,用新的類型來提供這個(gè)功能。這是一個(gè)稍好的方式,但是引入額外的依賴關(guān)系,可能也是我們不太愿意看到的。
- 多繼承
當(dāng)然,Swift 是不支持多繼承的。不過如果有多繼承的話,我們確實(shí)可以從多個(gè)父類進(jìn)行繼承,并將 myMethod 添加到合適的地方。有一些語(yǔ)言選擇了支持多繼承 (比如 C++),但是它會(huì)帶來 OOP 中另一個(gè)著名的問題:菱形缺陷。
2.3.2 菱形缺陷
上面的例子中,如果我們有多繼承,那么 ViewController 和 AnotherViewController 的關(guān)系可能會(huì)是這樣的:
此處有圖片
在上面這種拓?fù)浣Y(jié)構(gòu)中,我們只需要在 ViewController 中實(shí)現(xiàn) myMethod,在 AnotherViewController 中也就可以繼承并使用它了??雌饋砗芡昝溃覀儽苊饬酥貜?fù)。
但是多繼承有一個(gè)無(wú)法回避的問題,就是兩個(gè)父類都實(shí)現(xiàn)了同樣的方法時(shí),子類該怎么辦?我們很難確定應(yīng)該繼承哪一個(gè)父類的方法。因?yàn)槎嗬^承的拓?fù)浣Y(jié)構(gòu)是一個(gè)菱形,所以這個(gè)問題又被叫做菱形缺陷 (Diamond Problem)。
像是 C++ 這樣的語(yǔ)言選擇粗暴地將菱形缺陷的問題交給程序員處理,這無(wú)疑非常復(fù)雜,并且增加了人為錯(cuò)誤的可能性。而絕大多數(shù)現(xiàn)代語(yǔ)言對(duì)多繼承這個(gè)特性選擇避而遠(yuǎn)之。
2.3.3 動(dòng)態(tài)派發(fā)安全性
Objective-C 恰如其名,是一門典型的 OOP 語(yǔ)言,同時(shí)它繼承了 Small Talk 的消息發(fā)送機(jī)制。這套機(jī)制十分靈活,是 OC 的基礎(chǔ)思想,但是有時(shí)候相對(duì)危險(xiǎn)??紤]下面的代碼:
- ViewController *v1 = ...
- [v1 myMethod];
- AnotherViewController *v2 = ...
- [v2 myMethod];
- NSArray *array = @[v1, v2];
- for (id obj in array) {
- [obj myMethod];
- }
我們?nèi)绻?ViewController 和 AnotherViewController 中都實(shí)現(xiàn)了 myMethod 的話,這段代碼是沒有問題的。myMethod 將會(huì)被動(dòng)態(tài)發(fā)送給 array 中的 v1 和 v2。但是,要是我們有一個(gè)沒有實(shí)現(xiàn) myMethod 的類型,會(huì)如何呢?
- NSObject *v3 = [NSObject new]
- // v3 沒有實(shí)現(xiàn) `myMethod`
- NSArray *array = @[v1, v2, v3];
- for (id obj in array) {
- [obj myMethod];
- }
- // Runtime error:
- // unrecognized selector sent to instance blabla
編譯依然可以通過,但是顯然,程序?qū)⒃谶\(yùn)行時(shí)崩潰。Objective-C 是不安全的,編譯器默認(rèn)你知道某個(gè)方法確實(shí)有實(shí)現(xiàn),這是消息發(fā)送的靈活性所必須付出的代價(jià)。
而在 app 開發(fā)看來,用可能的崩潰來?yè)Q取靈活性,顯然這個(gè)代價(jià)太大了。雖然這不是 OOP 范式的問題,但它確實(shí)在 Objective-C 時(shí)代給我們帶來了切膚之痛。
2.3.4 三大困境
我們可以總結(jié)一下 OOP 面臨的這幾個(gè)問題:
- 動(dòng)態(tài)派發(fā)安全性
- 橫切關(guān)注點(diǎn)
- 菱形缺陷
首先,在 OC 中動(dòng)態(tài)派發(fā)讓我們承擔(dān)了在運(yùn)行時(shí)才發(fā)現(xiàn)錯(cuò)誤的風(fēng)險(xiǎn),這很有可能是發(fā)生在上線產(chǎn)品中的錯(cuò)誤。其次,橫切關(guān)注點(diǎn)讓我們難以對(duì)對(duì)象進(jìn)行完美的建模,代碼的重用也會(huì)更加糟糕。
3. 協(xié)議擴(kuò)展和面向協(xié)議編程
3.1 使用協(xié)議解決 OOP 困境
協(xié)議并不是什么新東西,也不是 Swift 的發(fā)明。在 Java 和 C# 里,它叫做 Interface。而 Swift 中的 protocol 將這個(gè)概念繼承了下來,并發(fā)揚(yáng)光大。讓我們回到一開始定義的那個(gè)簡(jiǎn)單協(xié)議,并嘗試著實(shí)現(xiàn)這個(gè)協(xié)議:
- protocol Greetable {
- var name: String { get }
- func greet()
- }
- struct Person: Greetable {
- let name: String
- func greet() {
- print("你好 \(name)")
- }
- }
- Person(name: "Wei Wang").greet()
實(shí)現(xiàn)很簡(jiǎn)單,Person 結(jié)構(gòu)體通過實(shí)現(xiàn) name 和 greet 來滿足 Greetable。在調(diào)用時(shí),我們就可以使用 Greetable 中定義的方法了。
3.1.1 動(dòng)態(tài)派發(fā)安全性
除了 Person,其他類型也可以實(shí)現(xiàn) Greetable,比如 Cat:
- struct Cat: Greetable {
- let name: String
- func greet() {
- print("meow~ \(name)")
- }
- }
現(xiàn)在,我們就可以將協(xié)議作為標(biāo)準(zhǔn)類型,來對(duì)方法調(diào)用進(jìn)行動(dòng)態(tài)派發(fā)了:
- let array: [Greetable] = [
- Person(name: "Wei Wang"),
- Cat(name: "onevcat")]
- for obj in array {
- obj.greet()
- }
- // 你好 Wei Wang
- // meow~ onevcat
對(duì)于沒有實(shí)現(xiàn) Greetbale 的類型,編譯器將返回錯(cuò)誤,因此不存在消息誤發(fā)送的情況:
- struct Bug: Greetable {
- let name: String
- }
- // Compiler Error:
- // 'Bug' does not conform to protocol 'Greetable'
- // protocol requires function 'greet()'
這樣一來,動(dòng)態(tài)派發(fā)安全性的問題迎刃而解。如果你保持在 Swift 的世界里,那這個(gè)你的所有代碼都是安全的。
- 動(dòng)態(tài)派發(fā)安全性
- 橫切關(guān)注點(diǎn)
- 菱形缺陷
3.1.2 橫切關(guān)注點(diǎn)
使用協(xié)議和協(xié)議擴(kuò)展,我們可以很好地共享代碼。回到上一節(jié)的 myMethod 方法,我們來看看如何使用協(xié)議來搞定它。首先,我們可以定義一個(gè)含有 myMethod 的協(xié)議:
- protocol P {
- func myMethod()
- }
注意這個(gè)協(xié)議沒有提供任何的實(shí)現(xiàn)。我們依然需要在實(shí)際類型遵守這個(gè)協(xié)議的時(shí)候?yàn)樗峁┚唧w的實(shí)現(xiàn):
- // class ViewController: UIViewController
- extension ViewController: P {
- func myMethod() {
- doWork()
- }
- }
- // class AnotherViewController: UITableViewController
- extension AnotherViewController: P {
- func myMethod() {
- doWork()
- }
- }
你可能不禁要問,這和 Copy & Paste 的解決方式有何不同?沒錯(cuò),答案就是 – 沒有不同。不過稍安勿躁,我們還有其他科技可以解決這個(gè)問題,那就是協(xié)議擴(kuò)展。協(xié)議本身并不是很強(qiáng)大,只是靜態(tài)類型語(yǔ)言的編譯器保證,在很多靜態(tài)語(yǔ)言中也有類似的概念。
那到底是什么讓 Swift 成為了一門協(xié)議優(yōu)先的語(yǔ)言?真正使協(xié)議發(fā)生質(zhì)變,并讓大家如此關(guān)注的原因,其實(shí)是在 WWDC 2015 和 Swift 2 發(fā)布時(shí),Apple 為協(xié)議引入了一個(gè)新特性,協(xié)議擴(kuò)展,它為 Swift 語(yǔ)言帶來了一次革命性的變化。
所謂協(xié)議擴(kuò)展,就是我們可以為一個(gè)協(xié)議提供默認(rèn)的實(shí)現(xiàn)。對(duì)于 P,可以在 extension P 中為 myMethod 添加一個(gè)實(shí)現(xiàn):
- protocol P {
- func myMethod()
- }
- extension P {
- func myMethod() {
- doWork()
- }
- }
有了這個(gè)協(xié)議擴(kuò)展后,我們只需要簡(jiǎn)單地聲明 ViewController 和 AnotherViewController 遵守 P,就可以直接使用 myMethod 的實(shí)現(xiàn)了:
- extension ViewController: P { }
- extension AnotherViewController: P { }
- viewController.myMethod()
- anotherViewController.myMethod()
不僅如此,除了已經(jīng)定義過的方法,我們甚至可以在擴(kuò)展中添加協(xié)議里沒有定義過的方法。在這些額外的方法中,我們可以依賴協(xié)議定義過的方法進(jìn)行操作。我們之后會(huì)看到更多的例子??偨Y(jié)下來:
- 協(xié)議定義
- 提供實(shí)現(xiàn)的入口
- 遵循協(xié)議的類型需要對(duì)其進(jìn)行實(shí)現(xiàn)
- 協(xié)議擴(kuò)展
- 為入口提供默認(rèn)實(shí)現(xiàn)
- 根據(jù)入口提供額外實(shí)現(xiàn)
這樣一來,橫切點(diǎn)關(guān)注的問題也簡(jiǎn)單安全地得到了解決。
- 動(dòng)態(tài)派發(fā)安全性
- 橫切關(guān)注點(diǎn)
- 菱形缺陷
3.1.3 菱形缺陷
最后我們看看多繼承。多繼承中存在的一個(gè)重要問題是菱形缺陷,也就是子類無(wú)法確定使用哪個(gè)父類的方法。在協(xié)議的對(duì)應(yīng)方面,這個(gè)問題雖然依然存在,但卻是可以唯一安全地確定的。我們來看一個(gè)多個(gè)協(xié)議中出現(xiàn)同名元素的例子:
- protocol Nameable {
- var name: String { get }
- }
- protocol Identifiable {
- var name: String { get }
- var id: Int { get }
- }
如果有一個(gè)類型,需要同時(shí)實(shí)現(xiàn)兩個(gè)協(xié)議的話,它必須提供一個(gè) name 屬性,來同時(shí)滿足兩個(gè)協(xié)議的要求:
- struct Person: Nameable, Identifiable {
- let name: String
- let id: Int
- }
- // `name` 屬性同時(shí)滿足 Nameable 和 Identifiable 的 name
這里比較有意思,又有點(diǎn)讓人困惑的是,如果我們?yōu)槠渲械哪硞€(gè)協(xié)議進(jìn)行了擴(kuò)展,在其中提供了默認(rèn)的 name 實(shí)現(xiàn),會(huì)如何??紤]下面的代碼:
- 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 (因?yàn)樗庆o態(tài)派發(fā)的),Person 依然可以遵守 Identifiable。不過,當(dāng) Nameable 和 Identifiable 都有 name的協(xié)議擴(kuò)展的話,就無(wú)法編譯了:
- 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
- }
- // 無(wú)法編譯,name 屬性沖突
這種情況下,Person 無(wú)法確定要使用哪個(gè)協(xié)議擴(kuò)展中 name 的定義。在同時(shí)實(shí)現(xiàn)兩個(gè)含有同名元素的協(xié)議,并且它們都提供了默認(rèn)擴(kuò)展時(shí),我們需要在具體的類型中明確地提供實(shí)現(xiàn)。這里我們將 Person 中的 name 進(jìn)行實(shí)現(xiàn)就可以了:
- 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
這里的行為看起來和菱形問題很像,但是有一些本質(zhì)不同。首先,這個(gè)問題出現(xiàn)的前提條件是同名元素以及同時(shí)提供了實(shí)現(xiàn),而協(xié)議擴(kuò)展對(duì)于協(xié)議本身來說并不是必須的。
其次,我們?cè)诰唧w類型中提供的實(shí)現(xiàn)一定是安全和確定的。當(dāng)然,菱形缺陷沒有被完全解決,Swift 還不能很好地處理多個(gè)協(xié)議的沖突,這是 Swift 現(xiàn)在的不足。
- 動(dòng)態(tài)派發(fā)安全性
- 橫切關(guān)注點(diǎn)
- 菱形缺陷
作者:王巍(onevcat),江湖人稱 "喵神",他是 ObjC 中國(guó)組織的發(fā)起人和領(lǐng)導(dǎo)者,也是著名開源框架 Kingfisher 的作者。