寫在最前
文章标題談到了面向協定程式設計(下文簡稱 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 的核心其實是不同父類中方法名、變量名的沖突問題。
我選擇了五種常見語言,總結出了四種具有代表性的解決思路:
- 顯式支援多繼承,代表語言 Python、C++
- 利用 Interface,代表語言 Java
- 利用 Trait,代表語言 Swift、Java8
- 利用 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
關系。但它依然是使用了繼承的思想,是以并非銀彈。在使用時依然應該仔細考慮,區分與組合模式的差別,作出合理選擇。
參考資料
- Ruby: How do I access module local variables?
- Swift protocol extension method dispatch
- Composition vs. Inheritance: How to Choose?
- Protocol-Oriented Programming in Swift
- Multiple inheritance
- python c3 linearization