從 Swift 的面向協議編程說開去

寫在最前

文章標題談到了面向協議編程(下文簡稱 POP),是由於前幾天閱讀了一篇講 Swift 中 POP 的文章。本文會以此爲出發點,聊聊相關的概念,好比接口、mixin、組合模式、多繼承等,同時也會藉助各類語言中的例子來闡述個人思想。python

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

最後囉嗦一句:程序員

沒有銀彈算法

Swift 的 POP

Swift 很是強調 POP 的概念,若是你是一名使用 Objective-C (或者 Java 等某些語言)的老程序員,你可能會以爲這是一種「新」的編程概念。甚至有些文章喊出了:「放棄面向對象,改成面向協議」的口號。這種說法從根本上講就是徹底錯誤的。編程

面向接口

首先,面向協議的思想已經提出不少年了,不少經典書籍中都提出過:「面向接口編程,而不是面向實現編程」的概念。swift

這句話很好理解,假設咱們有一個類——燈泡,還有一個方法,參數類型是燈泡,方法中能夠調用燈泡的「打開」和「關閉」方法。用面向接口的思想來寫,就會把參數類型定義爲某個接口,好比叫 Openable,而且在這個接口中定義了打開和關閉方法。數組

這樣作的好處在於,假設你未來又多了一個類,好比說是電視機,只要它實現了 Openable 接口,就能夠做爲上述方法的參數使用。這就知足了:「對拓展開放,對修改關閉」的思想。ruby

很天然的想法是,爲何我不能定義一個燈泡和電視機的父類,而是恰恰選擇接口?答案很簡單,由於燈泡和電視機極可能已經有父類了,即便沒有,也不能如此草率的爲它們定義父類。markdown

接口的缺點

因此在這個階段,你暫且能夠把接口理解爲一種分類,它能夠把多個毫無關係的類劃分到同一個種類中。可是接口也有一個重大缺陷,由於它只是一種約束,而非一種實現。也就是說,實現了某個接口的類,須要本身實現接口中的方法。dom

有時候你會發現,其實像繼承那樣,擁有默認實現也是一件挺好的事。仍是以燈泡舉例,假設全部電器每一次開、關都要發出聲音,那麼咱們但願 Openable 接口能提供一個默認的 openclose 的方法實現,其中能夠調用發出聲音的函數。再好比個人電器須要統計開關次數,那我就但願 Openable 協議定義了一個 count 變量,而且在每次開關時對它作統計。

顯然使用接口並不能完成上述需求,由於接口對代碼複用的支持很是差,所以除了某些很是大型的項目(好比 JDBC),在客戶端開發中(好比 Objective-C)使用面向接口的場景並不很是多見。

Swift 的改進

Swift 之因此如此強調 POP,首先是由於面向協議編程確實有它的優勢。想象以下的繼承關係:

B、C 繼承自 A,B一、B2繼承自 B,C一、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++ 中,你能夠規定一個類繼承自多個父類,實際上這個類會持有多個父類的實例(虛繼承除外)。當發生函數名衝突時,程序員須要手動指定調用哪一個父類的方法,不然就沒法編譯經過:

#include 
using namespace std;
class A {
public:
    void test() {
        cout << "A\n";
    }
};

class B: public A {
public:
    void test() {
        cout << "B\n";
    }
};

class C: public A {
public:
    void test() {
        cout << "C\n";
    }
};

class D: public B, public C {};

int main(int argc, char *argv[]) {
    D *d = new D();
//    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 的繼承鏈其實就是將 [[K一、A、B、C]、[K二、D、B、E]、[K三、D、A]] 這個序列扁平化的過程。

咱們首先遍歷第一個元素 K1,若是它只出如今每一個數組的首位,就能夠被提取出來。在這裏,顯然 K1 只出如今第一個數組的首位,因此能夠提取。同理,K2K2 均可以提取。因而上述問題變成了:

[K一、K二、K三、[A、B、C]、[D、B、E]、[D、A]]

接下來會遍歷到 A,由於它在第三個數組的末尾出現過,因此不能提取。同理 BC 也不知足要求。最後發現 D 知足要求,能夠提取。以此類推……完整的文檔能夠參考 WikiPedia

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

可見,Python 沒有給程序員選擇的權利,它自動計算了繼承關係,咱們也能夠利用 __mro__ 來查看繼承關係:

class A(object):
    pass
class B(A):
    pass
class C(A):
    pass
class D(B, C):
    pass
class E(C, B):
    pass
print(D.__mro__)
print(E.__mro__)
# (, , , , )
# (, , , , )複製代碼

Interface

Java 的 Interface 採用了一種大相徑庭的思路,雖然它也是一種多繼承,但僅僅是「規格繼承」,也就是說只繼承本身能作什麼,但不繼承怎麼作。這種方法的缺點已經提過了,這裏僅僅解釋一下它是如何處理衝突問題的。

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

在 Java 8 中,協議中的方法能夠添加默認實現。當多個協議中有方法衝突時,子類必須重寫方法(不然就報錯), 而且按需調用某個協議中的默認實現(這一點很像 C++):

interface HowEat{
    public abstract String howeat();
    default public  void test() {
        System.out.println("tttt");
    }
}

interface HowToEat {
    public abstract String howeat();
    default public void test() {
        System.out.println("yyyy");
    }
}

class Untitled implements HowEat, HowToEat {
    public void test() {
        HowEat.super.test(); // 選擇 HowEat 協議中的實現,輸出 tttt
        System.out.println("ssss");
    }

    public static void main(String[] args) {
        Untitled t = new Untitled();
        System.out.println(t.howeat());
        t.test();
    }
}複製代碼

Trait

儘管提供協議方法的默認實如今不一樣語言中有不一樣的稱謂,通常咱們將其稱爲 Trait,能夠簡單理解爲 Trait = Interface + Implementation

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

protocol Addable {
//    func add(); // 這裏必須註釋掉,不然就報錯
}

extension Addable {
    func add() { print ("Addable add"); }
}

class CustomCollection {}

extension CustomCollection: Addable {
    func add() {
        (self as Addable).add()
        print("CustomCollection add");
    }
}

var c = CustomCollection()
c.addAll()複製代碼

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

Mixin

另外一種與 Trait 相似的解決方案叫作 Mixin,它被 Ruby 所採用,能夠理解爲 mixin = trait + local_variable。在 Ruby 中,多繼承的層次結構更加扁平,能夠這麼理解:「一旦某個模塊被 mixin 進來,它的宿主模塊馬上就擁有了 mixin 模塊的全部屬性和方法」,就像 OC 中的 runtime 同樣,這更像是一種元編程的思想:

module Mixin
    Ss = "mixin"
    define_method(:print) { puts Ss }
end

class A
    include Mixin
    puts Ss
end

a = A.new()
a.print  # 輸出 mixin複製代碼

總結

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

繼承與組合

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

繼承的缺點

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

封裝

一旦你繼承了父類,就會馬上擁有父類全部的方法和屬性,若是這些方法和屬性並不是你原本就但願對外暴露的,那麼使用繼承就會破壞原有良好的封裝性。好比,你在定義 Stack 時可能會繼承自數組:

class Stack extends ArrayList {
    public void push(Object value) { … }
    public Object pop() { … }
}複製代碼

雖然你成功的在數組的基礎上添加了 pushpop 方法,但這樣一來就把數組的其餘方法也暴露給外界了,而這些方法並不是是 Stack 所須要的。

換個思路考慮問題,何時才能暴露父類的接口呢,答案是:「當你是父類的一種細化時」,這也就是咱們強調的 is-a 的概念。只有當你確實是父類,能在任何父類出現的地方替換父類(里氏替換原則)時,才應該使用繼承。在這裏的例子中,棧顯然並非數組的細化,由於數組是隨機訪問(random-access),而棧是線性訪問。

這種狀況下,正確的作法是使用組合,即定義一個類 Stack,並持有數組對象用來存取自身的數據,同時僅對外暴露必要的 pushpop 方法。

另外一種可能的破壞封裝的行爲是讓業務相關的類繼承自工具類。好比有一個類的內部須要持有多個 Customer 對象,咱們應該選擇組合模式,持有一個數組而不是直接繼承自數組。理由也很相似,業務模塊應該對外屏蔽實現細節。

這個概念一樣適用於 Stack 的例子,相比於數組實現而言,棧是一種具有了特殊規則的業務實現,它不該該對外暴露數組的實現接口。

多態

多態是 OOP 中一種強有力的武器,因爲 is-a 關係的存在,子類能夠直接被當成父類使用。這樣子類就與父類具有了強耦合關係,任何父類的修改都會影響子類,這樣的修改會影響子類對外暴露的接口,從而形成全部子類實例都須要修改。與之相對應的組合模式,在「父類」發生變更時,僅僅影響子類的實現,但不影響子類的接口,所以全部子類的實例都無需修改。

除此之外,多態還有可能形成很是嚴重的 bug:

public class CountingList extends ArrayList {
  private int counter = 0;

  @Override
  public void add(T elem) {
    super.add(elem);
    counter++;
  }

  @Override
  public void addAll(Collection 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 關係。但它依然是使用了繼承的思想,因此並不是銀彈。在使用時依然應該仔細考慮,區分與組合模式的區別,做出合理選擇。

參考資料

  1. Ruby: How do I access module local variables?
  2. Swift protocol extension method dispatch
  3. Composition vs. Inheritance: How to Choose?
  4. Protocol-Oriented Programming in Swift
  5. Multiple inheritance
  6. python c3 linearization
相關文章
相關標籤/搜索