天天看點

從 Swift 的面向協定程式設計說開去寫在最前Swift 的 POP多繼承繼承與組合再看 POP參考資料

寫在最前

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

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

最後啰嗦一句:

沒有銀彈

Swift 的 POP

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

面向接口

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

這句話很好了解,假設我們有一個類——燈泡,還有一個方法,參數類型是燈泡,方法中可以調用燈泡的“打開”和“關閉”方法。用面向接口的思想來寫,就會把參數類型定義為某個接口,比如叫

Openable

,并且在這個接口中定義了打開和關閉方法。

這樣做的好處在于,假設你将來又多了一個類,比如說是電視機,隻要它實作了

Openable

接口,就可以作為上述方法的參數使用。這就滿足了:“對拓展開放,對修改關閉”的思想。

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

接口的缺點

是以在這個階段,你暫且可以把接口了解為一種分類,它可以把多個毫無關系的類劃分到同一個種類中。但是接口也有一個重大缺陷,因為它隻是一種限制,而非一種實作。也就是說,實作了某個接口的類,需要自己實作接口中的方法。

有時候你會發現,其實像繼承那樣,擁有預設實作也是一件挺好的事。還是以燈泡舉例,假設所有電器每一次開、關都要發出聲音,那麼我們希望

Openable

接口能提供一個預設的

open

close

的方法實作,其中可以調用發出聲音的函數。再比如我的電器需要統計開關次數,那我就希望

Openable

協定定義了一個

count

變量,并且在每次開關時對它做統計。

顯然使用接口并不能完成上述需求,因為接口對代碼複用的支援非常差,是以除了某些非常大型的項目(比如 JDBC),在用戶端開發中(比如 Objective-C)使用面向接口的場景并不非常多見。

Swift 的改進

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

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

如果你發現

B1

C2

具有某些共同特性,完全使用繼承的做法是找到

B1

C2

的最近祖先,也就是 A,然後在 A 中添加一段代碼。于是你還得重寫

B2

C1

,禁用這個方法。這樣做的結果是

A

的代碼越來越龐大臃腫, 變成了一個上帝類(God Class),後續的維護非常困難。

如果使用接口,則又回到了上述問題,你得把方法實作在

B1

C2

中寫兩次。之是以在 Swift 中強調 POP,正是因為 Swift 為協定提供了拓展功能,它能夠為協定中規定的方法提供預設實作。現在讓

B1

C2

實作這個協定,既不影響類的繼承結構,也不需要寫重複代碼。

似乎 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 的繼承鍊其實就是将

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

這個序列扁平化的過程。

我們首先周遊第一個元素

K1

,如果它隻出現在每個數組的首位,就可以被提取出來。在這裡,顯然

K1

隻出現在第一個數組的首位,是以可以提取。同理,

K2

K2

都可以提取。于是上述問題變成了:

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

接下來會周遊到

A

,因為它在第三個數組的末尾出現過,是以不能提取。同理

B

C

也不滿足要求。最後發現

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() { … }
}複制代碼
           

雖然你成功的在數組的基礎上添加了

push

pop

方法,但這樣一來就把數組的其他方法也暴露給外界了,而這些方法并非是 Stack 所需要的。

換個思路考慮問題,什麼時候才能暴露父類的接口呢,答案是:“當你是父類的一種細化時”,這也就是我們強調的

is-a

的概念。隻有當你确實是父類,能在任何父類出現的地方替換父類(裡氏替換原則)時,才應該使用繼承。在這裡的例子中,棧顯然并不是數組的細化,因為數組是随機通路(random-access),而棧是線性通路。

這種情況下,正确的做法是使用組合,即定義一個類

Stack

,并持有數組對象用來存取自身的資料,同時僅對外暴露必要的

push

pop

方法。

另一種可能的破壞封裝的行為是讓業務相關的類繼承自工具類。比如有一個類的内部需要持有多個

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