讓里氏替換原則為你效力

面向?qū)ο蟮幕?/h2>

從事軟件開發(fā)的朋友或多或少都聽過(guò)以下一些原則:比如KiSS、DRY、LKP、COC、DbC、SoC、HP、SOLID等。這些原則已經(jīng)在業(yè)界被證實(shí)了自身的價(jià)值,尤其當(dāng)談到面向?qū)ο笤O(shè)計(jì)的時(shí)候,SOLID則是一個(gè)避不開的主題。

作為面向?qū)ο蟮幕驹瓌t,SOLID本身就是一個(gè)明顯的招牌 - 堅(jiān)固的磐石,撐起了面向?qū)ο笤O(shè)計(jì)大廈。

SOLID由五大原則構(gòu)成:

  1. Single Responsibility Principle【單一職責(zé)原則】
  2. Open Close Principle【開閉原則】
  3. Liskov Substitution Principle【里氏替換原則】
  4. Interface Segregation Principle【接口隔離原則】
  5. Dependency Inversion Principle【依賴倒置原則】

對(duì)于大部分OO程序員,這五大原則的名字可能已經(jīng)耳熟能詳,卻總不能很清晰的描述出SOLID是如何為我們服務(wù),因?yàn)镾OLID從來(lái)也沒(méi)有告訴我們How,它只在說(shuō):"這就是你最終要達(dá)到的目的地"。

本文我將帶著我的思考來(lái)捋一下LSP,LSP可能是一個(gè)很容易被破壞的原則,理解了它將能夠很好地驅(qū)動(dòng)我們?nèi)ニ伎既绾握_地做抽象設(shè)計(jì)。


打破里氏替換原則

In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.) -- Barbara Liskov in 1987

簡(jiǎn)單描述LSP:一個(gè)子類實(shí)例對(duì)象替換掉其父類實(shí)例對(duì)象,不會(huì)引發(fā)程序的任何變化。

如果要保證這點(diǎn),我們?cè)谠O(shè)計(jì)類的繼承關(guān)系的時(shí)候,子類不應(yīng)重寫父類的方法,這樣保證了父類的行為沒(méi)有被修改。來(lái)看個(gè)代碼示例,一個(gè)Square類繼承自Rectangle類,我們計(jì)算它們的面積:


class RectangleTest {
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Rectangle rectangle = new Rectangle();
        rectangle.setHeight(3);
        rectangle.setWidth(5);

        assertThat(rectangle.calculateArea()).isEqualTo(15);
    }
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Rectangle rectangle = new Square();
        rectangle.setHeight(3);
        rectangle.setWidth(5);

        assertThat(rectangle.calculateArea()).isEqualTo(25);
    }
}

前者返回的是15,而后者返回的是25。這兩者的差別在于我們使用了Square替換掉Rectangle,從而導(dǎo)致了程序的行為發(fā)生了改變。

看看RectangleSquare的實(shí)現(xiàn),不難發(fā)現(xiàn)子類Square重寫了setHeightsetWidth方法,修改了父類行為,導(dǎo)致了替換失敗。

public class Rectangle {
    protected double width;
    protected double height;
    public void setWidth(double width) { this.width = width; }
    public void setHeight(double height) { this.height = height; }
    public double calculateArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }
    @Override
    public void setWidth(double width) {
        this.height = width;
        this.width = width;
    }
}

所以按照LSP的觀點(diǎn),這個(gè)繼承關(guān)系被扣上不良的帽子,注意這里我用了不良,而非錯(cuò)誤。因?yàn)槲抑滥憧赡軙?huì)懟我:"我設(shè)計(jì)這個(gè)子類我就想基于父類做一個(gè)擴(kuò)展,不同的子類有不同的實(shí)現(xiàn),沒(méi)讓你在使用的時(shí)候去替換父類呀! " (我敢打賭你在項(xiàng)目中遇到過(guò)這種覆寫父類的實(shí)現(xiàn),并且軟件還能正常Work)。在我懟回去之前,請(qǐng)先跟我來(lái)回顧一下面向?qū)ο筇匦浴?/p>


從復(fù)用來(lái)看繼承

從一踏入職場(chǎng)那一刻,我就在面試中多次被問(wèn)過(guò):請(qǐng)談?wù)勀銓?duì)面向?qū)ο蟮娜筇匦缘睦斫猓?/p>

簡(jiǎn)單捋一下面向?qū)ο蟮娜筇匦裕?/p>

  • 封裝:隱藏對(duì)象的屬性和實(shí)現(xiàn)細(xì)節(jié),僅對(duì)外公開接口。比如Rectangle類,首先對(duì)自身屬性widthheight進(jìn)行了隱藏,通過(guò)calculateArea方法提供服務(wù),將依賴自身數(shù)據(jù)的計(jì)算細(xì)節(jié)也進(jìn)行了隱藏
  • 繼承:允許子類在不需要重新編寫父類的前提下,復(fù)用父類的所有功能,并能夠按需進(jìn)行擴(kuò)展。比如Square繼承了Rectangle,就具有了calculateArea的功能。(當(dāng)心:這個(gè)繼承不合理)
  • 多態(tài):允許對(duì)象在運(yùn)行期表現(xiàn)出不同的形體

恰巧LSP中提到了子類和父類的概念,所以不得不說(shuō)說(shuō)繼承。在這之前,我假設(shè):在做面向?qū)ο筌浖O(shè)計(jì)的你認(rèn)同面向?qū)ο笤O(shè)計(jì)的價(jià)值 -- 提升軟件的對(duì)變化的響應(yīng)力。

繼承的最核心的目的之一是為了復(fù)用,很多時(shí)候我們?yōu)榱藦?fù)用采用了繼承。如果我們?cè)O(shè)計(jì)繼承單純?yōu)榱藦?fù)用,你可能會(huì)問(wèn)為啥不用組合?而且很多時(shí)候提倡組合優(yōu)于繼承。這就需要我們思考面向?qū)ο蟮脑O(shè)計(jì)初衷:面向?qū)ο蠼⒃趯?duì)真實(shí)世界的抽象前提上,它很大程度上反映了我們的真實(shí)世界。比如一只鸚鵡是一只鳥,鳥能飛,鸚鵡也能飛,所以讓鸚鵡繼承自鳥,鸚鵡具備了飛的能力。

public class Bird {
    public void fly() {
        System.out.println("I am flying");
    }
}

public class Parrot extends Bird {}

如何決定繼承關(guān)系,你可以用Is-A來(lái)進(jìn)行初步驗(yàn)證,比如A parrot is a bird。當(dāng)你使用Is-A讀起來(lái)就能讓自己發(fā)笑得的時(shí)候,就說(shuō)明這個(gè)繼承就明顯不合理了。比如,你有一架飛機(jī),它也能飛,為了復(fù)用你讓飛機(jī)繼承鳥 -- A Plane is a Bird。當(dāng)關(guān)系不是那么明顯的時(shí)候怎么辦?比如,A Square is a Rectangle,下文我將給出答案。

所以,繼承首先它應(yīng)該體現(xiàn)一種現(xiàn)實(shí)世界的真實(shí)規(guī)則Is-A,復(fù)用是它提供的一個(gè)核心能力,也是我們期望在設(shè)計(jì)上能獲得的好處,而要達(dá)到復(fù)用,就要遵守一個(gè)規(guī)則:子類不去更改父類已有的行為,否則就與復(fù)用不沾邊了(復(fù)用,代表你啥也不用做,直接具備的行為,如果你重新實(shí)現(xiàn)了,那叫新的實(shí)現(xiàn),你付出了新的努力。至于你要添加新的行為,這屬于擴(kuò)展,按需就好)。

我想你已經(jīng)能夠運(yùn)用Is-A來(lái)避免很多明顯恰當(dāng)?shù)睦^承關(guān)系。而當(dāng)你面臨模棱兩可的繼承場(chǎng)景時(shí),從復(fù)用的視角出發(fā),LSP提供了很好的校驗(yàn)規(guī)則。


抽象是為了更好地復(fù)用

回到文章一開始的例子,使用Is-A來(lái)解讀:A Square is a Rectangle,好像還湊合,但有點(diǎn)把握不準(zhǔn)。

Square繼承自Rectangle,Square能夠復(fù)用Rectangle中的所有行為,假如你不對(duì)Square做任何事情就能完美復(fù)用,但這樣子出來(lái)的正方形可能寬和高就不一樣了(無(wú)法滿足客戶真實(shí)需求,這可都是無(wú)用功喲)。為了滿足客戶需求,你就不得不對(duì)setWidthsetHeight進(jìn)行重寫:

public class Square extends Rectangle {
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }
    @Override
    public void setWidth(double width) {
        this.height = width;
        this.width = width;
    }
}

一旦重寫,當(dāng)你將下面代碼的Rectangle替換成Square時(shí)候就掛了:

@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
    // Replace with Square
    Rectangle rectangle = new Rectangle(); 
    rectangle.setHeight(3);
    rectangle.setWidth(5);

    // 25 if using Square
    assertThat(rectangle.calculateArea()).isEqualTo(15); 
}

所以,Liskov就開始吶喊了:"說(shuō)好的Square只是復(fù)用Rectangle的呢,為啥把Rectangle的行為改了,程序掛了,你不守規(guī)矩,怎么回事!"

那規(guī)矩又是什么呢?此時(shí)你必須重寫setWidthsetHeight,畢竟?jié)M足客戶才是你首要目的。到這個(gè)時(shí)候,已經(jīng)說(shuō)明了該繼承關(guān)系出了點(diǎn)問(wèn)題。你需要做的是跳出來(lái),重新審視一下你的設(shè)計(jì):

SquareRectangle都有寬和高,并且計(jì)算面積的方式一樣,不同的是setWidthsetHeight。是否可以將共同的特征進(jìn)一步抽象提煉。就這樣逼著自己去思考,你可能很快就抽象出一個(gè)四邊形,因?yàn)?code>setWidth和setHeight行為不確定,先將它們抽象化。

你很快用Java代碼實(shí)現(xiàn):

public abstract class Quads {
    protected int width;
    protected int height;
    
    public abstract void setWidth(int width);
    public abstract void setHeight(int height);
    
    public int calculateArea() { return width * height; }
}

然后你讓RectangleSquare分別繼承自Quads,各自在自己的類中實(shí)現(xiàn)setWidthsetHeight。這時(shí)候你使用RectangleSquare的方式也改了:

class QuadsTest {
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Quads quads = new Rectangle();
        quads.setHeight(3);
        quads.setWidth(5);

        assertThat(quads.calculateArea()).isEqualTo(15);
    }
    @Test
    void should_return_area_when_calculate_given_width_and_height_valid() {
        Quads quads = new Square();
        quads.setWidth(5);
        assertThat(quads.calculateArea()).isEqualTo(25);
    }
}

通過(guò)進(jìn)一步抽象,你改變了繼承關(guān)系,Rectangle is A Quads,Square is A Quads,此時(shí)這種繼承關(guān)系就更加清楚明顯了,并且沒(méi)有違背LSP(Quads是一個(gè)抽象類,不能實(shí)例化對(duì)象,所以不會(huì)出現(xiàn)子類實(shí)例對(duì)象替換父類實(shí)例對(duì)象的場(chǎng)景)。

到這里,你已經(jīng)成功通過(guò)了進(jìn)一步抽象拯救了這個(gè)繼承關(guān)系,而且新的繼承關(guān)系更加合理,更加符合面向?qū)ο笤O(shè)計(jì),也最大化發(fā)揮了繼承的核心能力 -- 復(fù)用

很多時(shí)候我們遇到這種,可能是因?yàn)槲覀冞^(guò)于著急寫代碼或是疏忽大意,那些貌似像Is-A的關(guān)系也被我們用上了繼承,這也促成了繼承被濫用。而解決辦法也很簡(jiǎn)單,LSP這個(gè)工具提供了很大的幫助,最終你會(huì)發(fā)現(xiàn)大多是由于恰當(dāng)抽象的缺失。


總結(jié)

如果用一句話來(lái)形容LSP,我覺得是:當(dāng)你無(wú)法根據(jù)Is-A 來(lái)判斷繼承關(guān)系是否合理時(shí),你應(yīng)該思考如何進(jìn)行下一步抽象,從而避免讓繼承產(chǎn)生二義性

借用極限編程的理念來(lái)講:將我們認(rèn)同的有效軟件開發(fā)原理和實(shí)踐應(yīng)用到極限。我們?cè)谧雒嫦驅(qū)ο笤O(shè)計(jì)時(shí),不妨拿起LSP這個(gè)現(xiàn)成的工具,幫助我們有效地減少繼承的濫用、模糊意圖等設(shè)計(jì)缺陷,提升軟件設(shè)計(jì)。

當(dāng)然,很多不符合LSP的軟件也能工作,這就像很多軟件充斥著壞味道照樣能工作一樣(比如代碼注釋)。而它背后隱含的邏輯應(yīng)該是:

我們應(yīng)該積極去思考更好的設(shè)計(jì),而不是過(guò)早放棄思考的機(jī)會(huì)


注釋

  • KiSS: Keep it simple, stupid
  • DRY: Don't Repeat Yourself
  • LKP: Least Knowledge Principle (LOD: Law of Demeter)
  • CoC: Convention over Configuration
  • DbC: Design by Contract
  • SoC: Segregation of Concerns
  • HP: Hollywood Principle

參考閱讀


Posted by 袁慎建@ThoughtWorks

版權(quán)聲明:自由轉(zhuǎn)載?非商用?非衍生?保持署名 | Creative Commons BY-NC-ND 4.0

原文鏈接:https://sjyuan.cc/make-lsp-working-for-you/

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容