1. 程式人生 > >從 Swift 的面向協議程式設計說開去

從 Swift 的面向協議程式設計說開去

寫在最前

文章標題談到了面向協議程式設計(下文簡稱 POP),是因為前幾天閱讀了一篇講 Swift 中 POP 的文章。本文會以此為出發點,聊聊相關的概念,比如介面、mixin、組合模式、多繼承等,同時也會藉助各種語言中的例子來闡述我的思想。

那些老生常談的概念,相信每位讀者都耳熟能詳了,我當然不會無聊到浪費時間贅述一遍。我會試圖從更高一層的角度對他們做一個總結,不過由於經驗和水平有限,也難免有所疏漏,歡迎交流討論。

最後囉嗦一句:

沒有銀彈

Swift 的 POP

Swift 非常強調 POP 的概念,如果你是一名使用 Objective-C (或者 Java 等某些語言)的老程式設計師,你可能會覺得這是一種“新”的程式設計概念。甚至有些文章喊出了:“放棄面向物件,改為面向協議”的口號。這種說法從根本上講就是完全錯誤的。

面向介面

首先,面向協議的思想已經提出很多年了,很多經典書籍中都提出過:“面向介面程式設計,而不是面向實現程式設計”的概念。

這句話很好理解,假設我們有一個類——燈泡,還有一個方法,引數型別是燈泡,方法中可以呼叫燈泡的“開啟”和“關閉”方法。用面向介面的思想來寫,就會把引數型別定義為某個介面,比如叫 Openable,並且在這個介面中定義了開啟和關閉方法。

這樣做的好處在於,假設你將來又多了一個類,比如說是電視機,只要它實現了 Openable 介面,就可以作為上述方法的引數使用。這就滿足了:“對拓展開放,對修改關閉”的思想。

很自然的想法是,為什麼我不能定義一個燈泡和電視機的父類,而是偏偏選擇介面?答案很簡單,因為燈泡和電視機很可能已經有父類了,即使沒有,也不能如此草率的為它們定義父類。

介面的缺點

所以在這個階段,你暫且可以把介面理解為一種分類,它可以把多個毫無關係的類劃分到同一個種類中。但是介面也有一個重大缺陷,因為它只是一種約束,而非一種實現。也就是說,實現了某個介面的類,需要自己實現介面中的方法。

有時候你會發現,其實像繼承那樣,擁有預設實現也是一件挺好的事。還是以燈泡舉例,假設所有電器每一次開、關都要發出聲音,那麼我們希望 Openable 介面能提供一個預設的 openclose 的方法實現,其中可以呼叫發出聲音的函式。再比如我的電器需要統計開關次數,那我就希望 Openable 協議定義了一個 count 變數,並且在每次開關時對它做統計。

顯然使用介面並不能完成上述需求,因為介面對程式碼複用的支援非常差,因此除了某些非常大型的專案(比如 JDBC),在客戶端開發中(比如 Objective-C)使用面向介面的場景並不非常多見。

Swift 的改進

Swift 之所以如此強調 POP,首先是因為面向協議程式設計確實有它的優點。想象如下的繼承關係:

B、C 繼承自 A,B1、B2繼承自 B,C1、C2繼承自 C

如果你發現 B1C2 具有某些共同特性,完全使用繼承的做法是找到 B1C2 的最近祖先,也就是 A,然後在 A 中新增一段程式碼。於是你還得重寫 B2C1,禁用這個方法。這樣做的結果是 A 的程式碼越來越龐大臃腫, 變成了一個上帝類(God Class),後續的維護非常困難。

如果使用介面,則又回到了上述問題,你得把方法實現在 B1C2 中寫兩次。之所以在 Swift 中強調 POP,正是因為 Swift 為協議提供了拓展功能,它能夠為協議中規定的方法提供預設實現。現在讓 B1C2 實現這個協議,既不影響類的繼承結構,也不需要寫重複程式碼。

似乎 Swift 的 POP 毫無問題?答案顯然是否定的。

多繼承

如果站在更高的角度來看 Protocol Extension,它並不神奇,僅僅是多繼承的一種實現方式而已。理論上的多繼承是有問題的,最常見的就是 Diamond Problem。它描述的是這種情況:

B、C 繼承自 A,D 繼承自 B 和 C

如下圖所示(圖片摘自維基百科):

Diamond Problem

如果類 A、B、C 都定義了方法 test,那麼 D 的例項物件呼叫 test 方法會是什麼結果呢?

可以認為幾乎所有主流語言都支援多繼承的思想,但並不都像 C++ 那樣支援顯式的定義多繼承。儘管如此,他們都提供了各種解決方案來規避 Diamond Problem,而 Diamond Problem 的核心其實是不同父類中方法名、變數名的衝突問題。

我選擇了五種常見語言,總結出了四種具有代表性的解決思路:

  1. 顯式支援多繼承,代表語言 Python、C++
  2. 利用 Interface,代表語言 Java
  3. 利用 Trait,代表語言 Swift、Java8
  4. 利用 Mixin,代表語言 Ruby

顯式支援多繼承

最簡單方式就是直接支援多繼承,具有代表性的是 C++ 和 Python。

C++

在 C++ 中,你可以規定一個類繼承自多個父類,實際上這個類會持有多個父類的例項(虛繼承除外)。當發生函式名衝突時,程式設計師需要手動指定呼叫哪個父類的方法,否則就無法編譯通過:

12345678910111213141516171819202122232425262728293031 #include <iostream>using namespacestd;classA{public:voidtest(){cout<<"A\n";}};classB:publicA{public:voidtest(){cout<<"B\n";}};classC:publicA{public:voidtest(){cout<<"C\n";}};classD:publicB,publicC{};intmain(intargc,char*argv[]){D *d=newD();//    d->test(); // 編譯失敗,必須指定呼叫哪個父類的方法。d->B::test();d->C::test();}

可見,C++ 給予程式設計師手動管理的權利,代價就是實現比較複雜。

Python

Python 解決函式名衝突問題的思路是: 把複雜的繼承樹簡化為繼承鏈。為此,它採用了 C3 Linearization 演算法,這種演算法的結果與繼承順序有密切關係,以下圖為例:

繼承樹

假設繼承的順序如下:

  • class K1 extends A, B, C
  • class K2 extends D, B, E
  • class K3 extends D, A
  • class Z extends K1, K2, K3

求 Z 的繼承鏈其實就是將 [[K1、A、B、C]、[K2、D、B、E]、[K3、D、A]] 這個序列扁平化的過程。

我們首先遍歷第一個元素 K1,如果它只出現在每個陣列的首位,就可以被提取出來。在這裡,顯然 K1 只出現在第一個陣列的首位,所以可以提取。同理,K2K2 都可以提取。於是上述問題變成了:

[K1、K2、K3、[A、B、C]、[D、B、E]、[D、A]]

接下來會遍歷到 A,因為它在第三個陣列的末尾出現過,所以不能提取。同理 BC 也不滿足要求。最後發現 D 滿足要求,可以提取。以此類推……完整的文件可以參考 WikiPedia

最終的繼承鏈是: [K1, K2, K3, D, A, B, C, E],這樣多繼承就被轉化為了單繼承,自然也就不存在方法名衝突問題。

可見,Python 沒有給程式設計師選擇的權利,它自動計算了繼承關係,我們也可以利用 __mro__ 來檢視繼承關係:

1234567891011121314 classA(object):passclassB(A):passclassC(A):passclassD(B,C):passclassE(C,B):passprint(D.__mro__)print(E.__mro__)# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <type 'object'>)# (<class '__main__.E'>, <class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <type 'object'>)

Interface

Java 的 Interface 採用了一種截然不同的思路,雖然它也是一種多繼承,但僅僅是“規格繼承”,也就是說只繼承自己能做什麼,但不繼承怎麼做。這種方法的缺點已經提過了,這裡僅僅解釋一下它是如何處理衝突問題的。

在 Java 中,即使一個類實現了多個協議,且這些協議中規定了同名方法,這個類也僅能實現一次,於是多個協議共享同一套實現,筆者認為這不是一種好的解決思路。

在 Java 8 中,協議中的方法可以新增預設實現。當多個協議中有方法衝突時,子類必須重寫方法(否則就報錯), 並且按需呼叫某個協議中的預設實現(這一點很像 C++):

1234567891011121314151617181920212223242526 interfaceHowEat{publicabstractStringhoweat();defaultpublicvoidtest(){System.out.println("tttt");}}interfaceHowToEat{publicabstractStringhoweat();defaultpublicvoidtest(){System.out.println("yyyy");}}classUntitled implementsHowEat,HowToEat{publicvoidtest(){HowEat.super.test();// 選擇 HowEat 協議中的實現,輸出 ttttSystem.out.println("ssss");}publicstaticvoidmain(String[]args){Untitledt=newUntitled();System.out.println(t.howeat());t.test();}}

Trait

儘管提供協議方法的預設實現在不同語言中有不同的稱謂,一般我們將其稱為 Trait,可以簡單理解為 Trait = Interface + Implementation

Trait 是一種相對優雅的多繼承解決方案,它既提供了多繼承的概念,也不改變原有繼承結構,一個類還是隻能擁有一個父類。在不同語言中,Trait 的實現細節也不盡相同,比如 Swift 中,我們在重寫方法時,只能呼叫沒有定義在 Protocol 中的方法,否則就會產生段錯誤:

12345678910111213141516171819 protocolAddable{//    func add(); // 這裡必須註釋掉,否則就報錯}extensionAddable{func add(){print("Addable add");}}classCustomCollection{}extension CustomCollection:Addable{func add(){(selfasAddable).add()print("CustomCollection add");}}varc=CustomCollection()c.addAll()

查閱相關資料後發現,這和 Swift 方法的靜態派發與動態派發有關。

Mixin

另一種與 Trait 類似的解決方案叫做 Mixin,它被 Ruby 所採用,可以理解為 mixin = trait + local_variable。在 Ruby 中,多繼承的層次結構更加扁平,可以這麼理解:“一旦某個模組被 mixin 進來,它的宿主模組立刻就擁有了 mixin 模組的所有屬性和方法”,就像 OC 中的 runtime 一樣,這更像是一種超程式設計的思想:

123456789101112 module MixinSs="mixin"define_method(:print){puts Ss}endclassAinclude Mixinputs Ssenda=A.new()a.print# 輸出 mixin

總結

相比於完全允許多繼承(C++/Python)和幾乎完全不允許多繼承(Java)而言,使用 Trait 或者 Mixin 顯得更加優雅。雖然它們有時候並不能很方便的指定呼叫某一個“父類”中的方法, 但這種利用單繼承來模擬多繼承的的思想有它獨特的有點: “不改變繼承樹”,稍後會做分析。

繼承與組合

文章的開頭我曾經說過,Swift 的 POP 並不是一件多麼了不起的事,除了面向介面的思想早就被提出以外, 它的本質還是繼承,也就無法擺脫繼承關係的天然缺陷。至於說 POP 取代 OOP,那就更是無稽之談了,多繼承也是 OOP,一種略優雅的實現方式如何稱得上是取代呢?

繼承的缺點

有人說繼承的本質不是自下而上的抽象,而是自上而下的細化,我自認沒有領悟到這一層,不過使用繼承的主要目的之一就是實現程式碼複用。在 OOP 中,使用繼承關係,我們享受了封裝、多型的優點,但不正確的使用繼承往往會自食其果。

封裝

一旦你繼承了父類,就會立刻擁有父類所有的方法和屬性,如果這些方法和屬性並非你本來就希望對外暴露的,那麼使用繼承就會破壞原有良好的封裝性。比如,你在定義 Stack 時可能會繼承自陣列:

1234 classStackextendsArrayList{publicvoidpush(Objectvalue){}publicObjectpop(){}}

雖然你成功的在陣列的基礎上添加了 pushpop 方法,但這樣一來就把陣列的其他方法也暴露給外界了,而這些方法並非是 Stack 所需要的。

換個思路考慮問題,什麼時候才能暴露父類的介面呢,答案是:“當你是父類的一種細化時”,這也就是我們強調的 is-a 的概念。只有當你確實是父類,能在任何父類出現的地方替換父類(里氏替換原則)時,才應該使用繼承。在這裡的例子中,棧顯然並不是陣列的細化,因為陣列是隨機訪問(random-access),而棧是線性訪問。

這種情況下,正確的做法是使用組合,即定義一個類 Stack,並持有陣列物件用來存取自身的資料,同時僅對外暴露必要的 pushpop 方法。

另一種可能的破壞封裝的行為是讓業務相關的類繼承自工具類。比如有一個類的內部需要持有多個 Customer 物件,我們應該選擇組合模式,持有一個數組而不是直接繼承自陣列。理由也很類似,業務模組應該對外遮蔽實現細節。

這個概念同樣適用於 Stack 的例子,相比於陣列實現而言,棧是一種具備了特殊規則的業務實現,它不應該對外暴露陣列的實現介面。

多型

多型是 OOP 中一種強有力的武器,由於 is-a 關係的存在,子類可以直接被當成父類使用。這樣子類就與父類具備了強耦合關係,任何父類的修改都會影響子類,這樣的修改會影響子類對外暴露的介面,從而造成所有子類例項都需要修改。與之相對應的組合模式,在“父類”發生變動時,僅僅影響子類的實現,但不影響子類的介面,因此所有子類的例項都無需修改。

除此以外,多型還有可能造成非常嚴重的 bug:

123456789101112131415 publicclassCountingList<T>extendsArrayList<T>{privateintcounter=0;@Overridepublicvoidadd(Telem){super.add(elem);counter++;}@OverridepublicvoidaddAll(Collection<T>other){super.addAll(other);counter+=other.size();}}

這裡的子類重寫了 add 方法的實現,會將 count 計數加一。但是問題在於,子類的 addAll 方法已經加了計數,並且它會呼叫父類的 addAll 方法,父類的方法中會依次呼叫 add 方法。注意,由於多型的存在,呼叫的其實是子類的 add 方法,也就是說最終的結果 count 比預期值擴大了一倍。

更加嚴重的是, 如果父類由 SDK 提供,子類完全不知道父類的實現細節, 根本不可能意識到導致這個錯誤的原因。想要避免上述錯誤,除了多積累經驗外,還要在每次使用繼承前反覆詢問自己,子類是否是父類的細化,具備 is-a 關係,而不是僅僅為了複用程式碼。

同時還應該檢查,子類與父類是否具備業務與實現的關係,如果答案是肯定的,那麼應該考慮使用複合。比如在這個例子中,子類的作用是為父類新增計數邏輯,偏向於業務實現,而非父類(偏向於實現)的細化,所以不適合使用繼承。

組合

儘管我們常說優先使用組合,組合模式也不是毫無缺點。首先組合模式破壞了原來父類和子類之間的聯絡。多個使用組合模式的“子類”不再具有共同點,也就無法享受面向介面程式設計或者多型帶來的優勢。

使用組合模式更像是一種代理,如果你發現被持有的類有大量方法需要外層的類進行代理,那麼就應該考慮使用繼承關係。

再看 POP

對於使用 Trait 或 Mixin 模式的語言來說,雖然本質上還是繼承,但由於堅持單繼承模型,不存在 is-a 的關係,自然就沒有上述多型的問題。

有興趣的讀者可以選擇 Swift 或者 Java 來嘗試實現。

從這個角度來看,Swift 的 POP 模擬了多繼承關係,實現了程式碼的跨父類複用,同時也不存在 is-a 關係。但它依然是使用了繼承的思想,所以並非銀彈。在使用時依然應該仔細考慮,區分與組合模式的區別,作出合理選擇。

參考資料