[5+1]里氏替換原則(一)

前言

面向對象的SOLID設計原則,外加一個迪米特法則,就是我們常說的5+1設計原則。

↑ 五個,再加一個,就是5+1個。哈哈哈。

這六個設計原則的位置有點不上不下。
論原則性和理論指導意義,它們不如封裝繼承抽象或者高內聚低耦合,所以在寫代碼或者code review的時候,它們很難成爲“應該這樣做”或者“不應該這樣做”的一個有說服力的理由。
論靈活性和實踐操作指南,它們又不如設計模式或者架構模式,所以即使你能說出來某段代碼違反了某項原則,常常也很難明確指出錯在哪兒、要怎麼改。

所以,這裏來討論討論這六條設計原則的“爲什麼”和“怎麼做”。順帶,作爲面向對象設計思想的一環,這裏也想聊聊它們與抽象、高內聚低耦合、封裝繼承多態之間的關係。


里氏替換原則

是什麼

里氏替換原則(Liskov Substitution principle)是一條針對對象繼承關係提出的設計原則。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名爲“數據的抽象與層次”的演講中首次提出這條原則;1994年,芭芭拉與另一位女性計算機科學家周以真(Jeannette Marie Wing)合作發表論文,正式提出了這條面向對象設計原則。

↑ 芭芭拉和周以真

ps,以後再有人說女生不適合做IT,請把里氏替換原則甩Ta臉上:這是由兩位女性提出來計算機理論。其中一位(芭芭拉)獲得過圖靈獎和馮諾依曼獎;另一位(周以真)則是ACM和IEEE的會員。言歸正傳,芭芭拉和周以真是這樣定義里氏替換原則的:

Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T.
維基百科·里氏替換原則

簡單翻譯一下,意思是:已知x是類型T的一個對象、y是類型S的一個對象,且類型S是類型T的子類;令q(x)爲true;那麼,q(y)也應爲true。

數學語言雖然凝練、精準,但是抽象、費解。這個定義也是這樣。所以在應用中,我們會將原定義解讀爲:

“派生類(子類)對象可以在程序中代替其基類(超類)對象。”

↑ “狸”氏替換原則的經典用法(大誤)

把兩種定義綜合一下,里氏替換原則大概就是這樣的:子類只重寫父類中抽象方法,而絕不重寫父類中已有具體實現的方法。


爲什麼

細究起來,只有在我們用父類的上下文——例如入參、出參——來調用子類的方法時,里氏替換原則纔有指導意義。

↑ 拿父類的上下文調子類實例,“好大的官威啊”

例如,我們有這樣兩個類:

public class Calculator{
    public int calculate(int a, int b){return a + b;}
}

public class ClaculatorSub extends Calculator{
    @Override
    public int calculate(int a, int b){return a / b;}
}

顯然,我們可以用a=1、b=0這組參數去調用父類;但是不能直接用它來調用子類。否則的話,由於除數爲0,一調用子類方法就會拋出異常。

// 父類可以正常處理a=1、b=0這組參數。
// 然而對子類來說,雖然編譯期間不會報錯,但是在運行期間會拋出異常。
Calculator calculator = new CalculatorSub();
c = calculator.calculate(1,0);

應對這種問題,我們就要以里氏替換原則爲指導,好好地設計一下類繼承關係了。

=================================

由於場景受限,里氏替換法則很少出現在我們的討論之中。

最常見的原因是,很多人的代碼中根本就不會出現父子類關係,更不會出現子類替換父類這種場景。很多人的代碼中,一個接口下只有一個實現類;很多人的代碼中,連接口都沒有,直接使用public class。

用面嚮對象的技術,寫面向過程的代碼,就像開着殲20跑高速一樣。什麼“眼鏡蛇”、“落葉飄”,根本無從談起。

↑ 殲二零

而在使用了繼承的場景中,當需要用子類來替換父類時,大多數時候,我們都會保證只用父類的上下文去調用父類、只用子類的上下文去調用子類。這樣一來,場景中就不會出現使用父類的上下文去調用子類方法的情況。因而,里氏替換原則也失去了它的用武之地。

=================================

那麼,難道大名鼎鼎的里氏替換原則,到頭來就只能用於紙上談兵了嗎?

倒也不是。雖然里氏替換原則的路走得有點窄,但是它卻很適用於CS模式中版本兼容的場景。

在這個場景中,用戶可以用低版本的客戶端來調用最新版本的服務端。這跟“用父類的上下文來調用子類的方法”不是異曲同工的嗎?

當然,版本兼容問題可以有很多種方案。不過,萬變不離其宗,各種各樣的方案中,都有“子類應當可以替代父類”這條基本原則的影子。泛化一點來說,“版本兼容”也並不僅僅是CS模式需要考慮的問題,而是所有需要處理存量數據的系統所必須考慮的問題——老版本客戶端也可以被理解爲一種“存量數據”。

這類問題的本質就是使用存量數據調用新功能的問題,也就是使用父類上下文調用子類方法的問題。顯然的,里氏替換原則就是爲這類問題量身定製的。

=================================

不僅如此,里氏替換原則還爲“如何設計繼承層次”提供了另一個標準。我們知道,只有“is-a”關係才應當使用繼承結構。里氏替換原則提出了一個新的要求:子類不能重寫父類已經實現了的具體方法。反過來說,如果子類必須重寫父類方法才能實現自己的功能,那就說明,這兩個類不構成繼承關係。此時,我們就應該用其它結構改寫這種父子結構。

顯然,這是一個更可行的要求。對於什麼樣的關係是“is-a”、什麼樣的關係是“like-a”,我們沒有一個硬性指標。但是,子類有沒有修改父類的方法、被修改的父類方法有沒有具體實現,這是一望而知、非此即彼的事情。因而,這個標準的可操作性非常高。

同時,這是一個更嚴格的要求。按照這個要求,所有的非抽象類都不能擁有子類。因爲這種子類只能做三件事情:重寫父類的方法,或者修改父類的屬性,或者增加新的方法。
重寫父類非抽象方法是里氏替換原則所禁止的行爲。自然地,我們一般不會這樣做。
如果不重寫父類方法、只修改父類屬性,則完全可以通過多實例來實現,沒必要使用繼承結構。考慮到繼承會帶來高耦合問題,還是能不用就不用吧。
增加新的方法會使得子類“突破”父類的抽象。“突破抽象聲明”這種事情,很容易增加模塊耦合度——原本調用方只需依賴父類,此時不得不依賴子類。

在這種場景下,我更傾向於使用組合,而非繼承。例如這段代碼:

public class Service{
    public void doSth(){
        // 略,父類方法
    }
}
public class Service1 extends Service{
    public void doOtherThing(){
        // 略,子類擴展的新方法,用到了父類的方法
        doSth();
    }
}
public class Service2{
    private Service s = new Service();
    public void doOtherThing(){
        // 通過組合來擴展子類功能
        s.doSth();
    }
}
public class Test{
    public static void main(String... args){
        // 使用繼承來擴展
        // 原代碼:只調用父類方法,使用父類即可
        // Service s = new Service();
        // 需要使用子類方法,所以必須使用子類
        Service1 s = new Service1();
        s.doSth();
        // 使用子類方法
        s.doOtherThing();

        // 使用組合來擴展
        // 原代碼:只調用父類方法,使用父類即可
         Service s1 = new Service();
        s.doSth();
        // 需要使用新方法的地方,增加新的調用代碼
        Service2 s2 = new Service2();
        // 使用子類方法
        s2.doOtherThing();
    }
}

對比Test類中的兩段代碼可以發現,在子類增加新方法的這種場景下,使用組合比使用繼承更符合“開閉”原則。畢竟,在使用組合時,調用父類的代碼沒有做任何改動。而在使用繼承時,調用父類的地方被改爲了調用子類——而這種修改,就是典型的“使用父類上下文調用子類”的場景。在這種場景中,我們需要小心翼翼地遵循里氏替換原則、維護父子類關係,才能避免出現問題。

綜上所述,嚴格遵循里氏替換原則就禁止(至少是不提倡)我們繼承非抽象類。然而,如果禁止繼承非抽象類,類的個數和層級結構都會變得非常複雜,因而,開發工作量也會變得非常大。所以,在實踐中,我們往往會對里氏替換原則做一些“折中”處理。


怎麼做

如果不繼承非抽象類,類的繼承結構會變得非常複雜。並且,在繼承層次由簡單變複雜的過程中,我們要付出的工作量也會增加。例如,我們原有這樣一個服務類:

↑ 一個服務類

這個類只是簡單地把接口定義的方法interfaceMthod拆分爲四個步驟:valid()/prepare()/doMethod()和triggerEvent()。這四個方法都只需要提供給ServiceImp類自己調用,因此它們全都被聲明爲私有方法。

隨着業務需求的發展,我們需要一個新的子類。與ServiceImpl相比,這個子類的prepare()和doMethod()邏輯有所不同,valid()和triggerEvent()則一模一樣。我們有三種方式來實現這個新的子類:直接繼承ServiceImpl、爲ServiceImpl和新的子類抽取公共父類,以及使用組合。這幾種方式的類圖如下所示:

[5+1]里氏替換原則(一)

相信大家看得出來:第一種方式的開發工作量最少。但是,第一種方式恰恰就違反了里氏替換原則:子類ServiceImplA重寫了父類ServiceImpl中的非抽象方法prepare()和doMethod()。

如果使用第二種方式,我們首先要新增一個父類ServiceTemplate,然後改寫原有的ServiceImpl類;最後纔可以開發新的類ServiceImplA。顯然,與第一種方式相比,新增ServiceTemplate和修改ServiceImpl都需要付出額外的開發工作量。

如果不使用繼承、而使用組合,開發工作量與第一種方式相似。但是,它會帶來一個新的問題:ServiceImplA與ServiceImpl之間,不再是“面向接口編程”,而是“面向具體類編程”了。這問題恐怕比違反歷史替換原則還要嚴重些。
如果要“面向接口編程”,那麼我們需要爲ServiceImpl增加一個輔助接口——也就是上圖中的第四種方式,使用組合並面向接口編程。但是,第四種方式也需要付出額外的工作量。

質量與工作量(以及藏在工作量背後的工期和成本),這是一對矛盾。一味追求質量而忽視工作量,不僅不符合項目管理的目標,甚至有違人的天性。人們把完美主義稱爲“龜毛”,把偷懶稱爲“第一動力”,這不是沒有道理的。

↑ 偷懶是人類進步的電梯

在這場由里氏替換原則引起的質量與工作量的取捨之間,選擇哪一項都有道理。就我個人而言,我比較傾向於採用第一種方式。這種方式不僅工作量小,而且代碼複用率高、重複率低。此外,這種方式還很好地遵循了開閉原則:在新增一個子類的同時,我們對父類只做了非常少的修改。

當然,質量要求也不能太低。雖然已經違反了里氏替換原則,但我們還是會要求子類不能重寫父類的public方法,而只能重寫諸如protected或者default方法——private方法是無法重寫的,也就不用額外約束了。

這個要求是從使用場景中提煉出來的。大多數情況下,我們只在模板模式下會使用狹義的繼承。這種場景中,父類會在public方法中定義若干個步驟。如果子類需要重寫這個public方法,說明子類不需要按照父類定義的步驟、順序來處理。這時,這兩個類之間無法構成“is-a”關係,連繼承關係都不應使用,更別提重寫public方法了。

↑ 模板模式的典型類圖

誠然,子類繼承父類這種做法不僅僅出現在模板模式中。同樣的,子類不重寫父類的public方法這條約束也不僅限於模板模式。試想,如果連父類的主要方法,子類都要重新實現一遍,那麼,這兩個是否構成“is-a”的關係、是否真的適用繼承結構呢?

↑ “to be or not to be, that is a question”

=================================

除了把里氏替換原則中的“禁止子類重寫父類的非抽象方法”轉換爲“禁止子類重寫父類的public方法”這種折中處理之外,在實踐中,我們還有這四條“里氏替換原則實踐指南”:

  1. 禁止子類重寫父類的非抽象方法。
  2. 子類可以增加自己的方法。
  3. 子類實現父類的方法時,對入參的要求比父類更寬鬆。
  4. 子類實現父類的方法時,對返回值的要求比父類更嚴格。

其中,只有第一條是直接源自里氏替換原則的“定理”,這裏就不再贅述了。其它三條都是從里氏替換原則中衍生出來的“推論”。

=================================

子類可以增加自己的方法,其實跟里氏替換原則沒有什麼直接關係。二者之所以會關聯在一起,我覺得,純粹就是因爲“法無禁令即可行”。當然,把話挑明也有好處。“法無禁令”是一個開區間,不僅會讓人無所適從,而且可操作空間太大。對操作規範來說,閉區間比開區間更加可行、也更加安全。白名單比黑名單更安全,也是一樣的道理。

=================================

子類實現父類方法時,入參約束更寬鬆、出參約束更嚴格,這兩條推論討論的主要是參數的業務涵義,即子類入參的內涵應當比父類更廣、而出參的內涵則應當比父類更窄。例如,子類入參的取值範圍應當比父類更大、而出參的範圍則應當比父類小。在前面例舉的那個Calculator類及其子類中,父類的入參取值範圍是所有整數,而子類的入參的取值範圍則是所有非零整數。顯然,子類的取值範圍比父類小。也正因爲這個緣故,這兩個類違反了里氏替換原則,因而在使用時會出現問題。

如果從技術的角度來理解第三、第四條約束的話,一般我們會他們和泛型結合起來分析。結合泛型以及邊界條件來看,第三、第四條約束可以簡單理解爲:子類的入參和出參,都應該是父類入參和出參的子類。說起來有點繞,看一眼代碼就清楚了。例如,我們有這樣兩個類:

abstract class ServiceTemplate<I extends Input,O extends  Output> {
    public abstract O service(I i);
}

class Service1 extends ServiceTemplate<Input1,Output1> {
    @Override
    public Output1 service(Input1 input1) {
        return new Output1();
    }
}

父類ServiceTemplate中,方法的入參出參,都是通過泛型來聲明的。而這兩個參數,則都通過extends參數,聲明瞭泛型的上界。對入參來說,類型上界是Input;對出參來說則是Output。這樣一來,子類Service1重寫的父類方法中,方法入參和出參就必須是Input和Output的子類。在上面的例子中,子類Service1的方法入參和出參,分別是Input1和Output1。雖然沒有沒有列出它們的定義,但是顯然,它們分別繼承了Input和Output類。

根據“子類不能重寫父類的非抽象方法”以及“子類可以增加自己的方法”,Input1和Output1所包含的信息量都比它們的父類更大。對入參Input1來說,這意味着業務內涵被縮小了。而對出參Output1來說,它的業務內涵則被擴大了。

↑ 同是子類,內涵咋就不一樣呢。

因此,上面這兩個類是符合第三、第四條約束的:子類的入參約束比父類更嚴格;而出參約束比父類更寬鬆。它們是符合那四條“里氏替換原則實踐指南”的。

=================================

然而,弔詭的是,這兩個類其實並不符合里氏替換原則。我們來看下面這段代碼:

public class Test {
    public static void main(String... args) {
        // 父類的調用上下文
        Input i = new Input();
        // 使用父類ServiceTemplate的地方
        ServiceTemplate s = new Service1();
        // 下面這行會有一個warning
        Output o = s.service(i);
        System.out.println(o);
    }
}

根據前面的分析,ServiceTemplate和Service1這兩個類是符合里氏替換原則的。按里氏替換原則來分析,這段代碼似乎並沒有問題:使用父類ServiceTemplate的地方,都可以安全地替換爲子類Service1。事實上,這段代碼也的確可以通過編譯——儘管會有一個warning。

然而,這個編譯期的warning會在運行期轉變成一個ClassCastException:父類並不能安全地替換爲子類。有沒有感覺像是鑽進了一個莫比烏斯環——從環的正面出發,走着走着,就走到了自己的反面。

↑ 莫比烏斯環

=================================
是里氏替換原則失靈了嗎?我覺得不是。

一種解釋是,ServiceTemplate中的service()是一個抽象方法。用原始的定義來理解的話,也就是對類型T的實例x來說,q(x)是無解的。這就使得里氏替換原則的前提不成立。前提不成立,自然結論也不成立。

儘管這個解釋還算說得通,但是它卻帶來了另一個問題。如果接受了這個解釋,就意味着我們不能繼承抽象類、也不能實現抽象類中的抽象方法了。否則,這對父子類必定違反了里氏替換原則。

另一種解釋是,子類Service1在把方法入參約束爲<I extends Input>時,實際上就違反了里氏替換原則。父類不能安全地轉化爲子類,這是里氏替換原則在Java在語言層面的一種實現。然而Service1在約束了入參的上界時,實際上已經偷偷摸摸的越過了雷池:它的入參已經悄悄地把父類Input轉換爲子類Input1了。Service1的那段代碼,本質上等價於:

class Service1 extends ServiceTemplate<Input,Output1> {
    @Override
    public Output1 service(Input input) {
        // 約定泛型上界,等價於做了個強制類型轉換
        Input1 actualParam = (Input1)input1;
        return new Output1();
    }
}

所以,從這個解釋出發,我們只需要處理好泛型邊界帶來的類型轉換問題即可。例如,我們可以這樣:

public class Test {
    public static void main1(String... args) {
        // 注意下面這一行,從new Input()改成了new Input1()
        Input i = new Input1();
        ServiceTemplate s = new Service1();
        Output o = s.service(i);
        System.out.println(o);
    }
}

=================================

網上很多文章把這個問題被歸入泛型上界與下界的討論中,也就是所謂“入參限定下界,出參限定上界”。例如上面那段調用代碼,就可以這樣處理:

public static void main(String... args) {
    // 注意下面這兩行
    Input1 i = new Input1();
    ServiceTemplate<? super Input1, ? extends Output> s =
                                            new Service1();
    Output o = s.service(i);
    System.out.println(o);
}

在上面的代碼中,“? super Input1”爲入參限定了下界,即要求入參必須是Input1的某個父類;而“? extends Output”則爲出參限定了上界,即要求出參必須是Output的某個子類。這樣也可以解決問題。然而,這樣寫的話,入參i必須聲明爲Input1類型——亦即必須聲明爲入參的下界,而不能按“?super Input1”所表示的那樣,可以使用一個Input1的父類,如Input類。如果我們非要聲明“Input i = new Input1();”的話,Java在編譯期就會報錯(是error不是warning):

service(capture<? super Input1) in ServcieTemplate cannot be applied
to (Input)

繞到這裏,和里氏替換原則的關係已經有點遠了。

關於泛型及其邊界的使用,我們以後再聊。總之,對里氏替換原則來說,在實踐中,我一般只要求子類不重寫父類的public方法,而不要求不重寫非抽象方法。此外,對子類方法入參和出參的約束,主要在於業務內涵上。如果要結合泛型邊界來定義約束,務必小心:這很可能是一個莫比烏斯環。


往期索引

《面向對象是什麼》

從具體的語言和實現中抽離出來,面向對象思想究竟是什麼?公衆號:景昕的花園面向對象是什麼

抽象

抽象這個東西,說起來很抽象,其實很簡單。

花園的景昕,公衆號:景昕的花園抽象

高內聚與低耦合

細說幾種內聚

細說幾種耦合

"高內聚"與"低耦合"是軟件設計和開發中經常出現的一對概念。它們既是做好設計的途徑,也是評價設計好壞的標準。

花園的景昕,公衆號:景昕的花園高內聚與低耦合

封裝

繼承

多態》

——“面向對象的三大特性是什麼?”——“封裝、繼承、多態。”

[5+1]單一職責原則

單一職責原則非常好理解:一個類應當只承擔一種職責。因爲只承擔一種職責,所以,一個類應該只有一個發生變化的原因。花園的景昕,公衆號:景昕的花園[5+1]單一職責原則

[5+1]開閉原則(一)

[5+1]開閉原則(二)

什麼是擴展?就Java而言,實現接口(implements SomeInterface)、繼承父類(extends SuperClass),甚至重載方法(Overload),都可以稱作是“擴展”。什麼是修改?在Java中,嚴格來說,凡是會導致一個類重新編譯、生成不同的class文件的操作,都是對這個類做的修改。實踐中我們會放寬一點,只有改變了業務邏輯的修改,纔會歸入開閉原則所說的“修改”之中。花園的景昕,公衆號:景昕的花園[5+1]開閉原則(一)

[5+1]里氏替換原則(一)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章