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

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

前言

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

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

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

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


里氏替換原則

是什麼

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

↑ 芭芭拉和周以真

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

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.
維基百科·里氏替換原則ide

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

數學語言雖然凝練、精準,可是抽象、費解。這個定義也是這樣。因此在應用中,咱們會將原定義解讀爲:設計

「派生類(子類)對象能夠在程序中代替其基類(超類)對象。」3d

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

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


爲何

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

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

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

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=一、b=0這組參數去調用父類;可是不能直接用它來調用子類。不然的話,因爲除數爲0,一調用子類方法就會拋出異常。

// 父類能夠正常處理a=一、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類及其子類中,父類的入參取值範圍是全部整數,而子類的入參的取值範圍則是全部非零整數。顯然,子類的取值範圍比父類小。也正由於這個緣故,這兩個類違反了里氏替換原則,於是在使用時會出現問題。

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

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來講,它的業務內涵則被擴大了。

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

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

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

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

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]里氏替換原則(一)

相關文章
相關標籤/搜索