iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 5
1
Software Development

從生活中認識Design Pattern系列 第 5

[Day05] 里氏替換原則 | Liskov Substitution Principle

  • 分享至 

  • xImage
  •  

本文同步分享於個人blog

今天來到了第三個原則,里氏替換原則。個人覺得這個原則稍稍複雜些,所以今天篇幅會比較長,範例程式碼也比較多,麻煩耐著性子看完囉XD

  • 定義


If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

翻譯年糕:如果對每一個類型為S的對象o1,都存在類型T的對象o2,使得對於用T定義的所有程序P,在所有對象o1都代換成o2時P的行為不變,那麼S是T的子類別型。

這段話唸起來很饒舌...,以我們世俗的話來說就是子類別可以擴充套件父類別的功能,但不改變父類別原有的功能。那我們可能會想,這樣要怎樣擴充?要如何才叫不改變?首先我們可以先來探討繼承的問題!!

  • 繼承(is-a)


物件導向程式設計中,is-a指的是類別的父子繼承關係。比方說老鷹是鳥的一種,鳥為父類別,老鷹為子類別。而子類別也繼承父類別的行為,比如說鳥會飛,老鷹為鳥的子類別,所以老鷹也會飛。

我們用鳥類飛行速度以及飛行一段距離所需花的時間來舉例子。

範例1

class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }
}

class Eagle extends Bird {}

public class LSP {
    public static void main(String[] args) {
        Bird eagle=new Eagle();
        eagle.setFlySpeed(120);
        System.out.println("路程300公里:");
        System.out.println("老鷹花了" + eagle.getFlyTime(300) + "小時.");
    }
}

output

路程300公里:
老鷹花了2.5小時.

上面的程式碼我們可以了解,老鷹是鳥的子類別,所以老鷹也有setFlySpeed()的功能,藉由setFlySpeed()來設定老鷹的飛行速度,再經由getFlyTime()取得老鷹的飛行時間。但若今天我們也想知道企鵝會花多久時間移動呢?

  • 老鷹 VS 企鵝


範例2

class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }
    
}

class Eagle extends Bird {}

class Penguin extends Bird {
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = 0;
    }
}

public class LSP {
    public static void main(String[] args) {
        Bird eagle=new Eagle();
        Bird penguin=new Penguin();
        eagle.setFlySpeed(120);
        penguin.setFlySpeed(20);
        System.out.println("路程300公里:");
        System.out.println("老鷹花了" + eagle.getFlyTime(300) + "小時");
        System.out.println("企鵝花了" + penguin.getFlyTime(300) + "小時");
    }
}

output

飛行300公里:
老鷹花了2.5小時

Exception in thread "main" java.lang.ArithmeticException: / by zero
	at Bird.getFlyTime(LSP.java:7)
	at LSP.main(LSP.java:28)

LSP1

我們可以發現,沒有辦法計算出企鵝飛行的時間,因為企鵝根本不會飛,所以class Penguin內覆寫掉了class bird的setFlySpeed(),導致原本的getFlyTime()發生了計算上的錯誤,違反了LSP。但在應用LSP之前,我們要在對LSP有更近一步的認識才行。

  • Liskov Substitution Principle 的4個繼承規範


剛剛有提到LSP的定義為子類別可以擴充套件父類別的功能,但不改變父類別原有的功能,其中還包含以下4層含義。

  1. 子類別必須完全實現父類別的方法
  2. 子類別可以有自己的特性
  3. 重載(Overload)或者實現父類別的方法時輸入參數可以被放大
  4. 覆蓋或者實現父類別的方法時輸出結果可以被縮小

接著來一一解釋這四層個表示著什麼。

1. 子類別必須完全實現父類別的方法

若Bird為一個抽象類別,裡面含有抽象方法。Penguin和Eagle繼承了Bird,就需要實現裡面的抽象方法,也就是action()。

public abstract class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }

    public abstract void action();
    
}

public class Eagle extends Bird {
    @Override
    public void action(){
        System.out.println("老鷹用飛的");
    }
}

public class Penguin extends Bird {
    @Override
    public void action(){
        System.out.println("企鵝用走的");
    }
}

2. 子類別可以有自己的特性

子類別除了繼承父類別的方法外,也可以擁有屬於自己的方法。比方說,企鵝會游泳,那在class Penguin內就可以加一個swim()的function。

public class Penguin extends Bird {
    public void swim(){
        System.out.println("企鵝游泳");
    }
}

3. 重載(Overload)或者實現父類別的方法時輸入參數可以被放大

我們將getFlyTime()的傳入參數改為Map。Map為HashMap的父類別,所以若Eagle要重載Bird的function,那傳入的參數範圍一定要比Brid大才行。

子類別參數範圍大於父類別

範例3

class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(HashMap<String, Integer> distance) {
        System.out.println("父類別執行");
        return(distance.get("distance")/flySpeed);
    }
}

class Eagle extends Bird {
    public int getFlyTime(Map<String, Integer> distance) {
        System.out.println("子類別執行");
        return(distance.get("distance")/flySpeed);
    }
}

public class LSP {
    public static void main(String[] args) {
        Bird bird = new Bird();
        bird.setFlySpeed(120);
        Eagle eagle = new Eagle();
        eagle.setFlySpeed(120);
        
        HashMap map = new HashMap();
        map.put("distance", 300);
        System.out.println("路程300公里:");
        System.out.println("鳥花了" + bird.getFlyTime(map) + "小時.");
        System.out.println("老鷹花了" + eagle.getFlyTime(map) + "小時.");
    }
}

output

路程300公里:
父類別執行
鳥花了2.5小時.
父類別執行
老鷹花了2.5小時.
子類別參數範圍小於父類別

範例4

public class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(Map<String, Integer> distance) {
        System.out.println("父類別執行");
        return(distance.get("distance")/flySpeed);
    }
}

public class Eagle extends Bird {
    public int getFlyTime(HashMap<String, Integer> distance) {
        System.out.println("子類別執行");
        return(distance.get("distance")/flySpeed);
    }
}

public class LSP {
    public static void main(String[] args) {
        Bird bird = new Bird();
        bird.setFlySpeed(120);
        Eagle eagle = new Eagle();
        eagle.setFlySpeed(120);
        
        HashMap map = new HashMap();
        map.put("distance", 300);
        System.out.println("路程300公里:");
        System.out.println("鳥花了" + bird.getFlyTime(map) + "小時.");
        System.out.println("老鷹花了" + eagle.getFlyTime(map) + "小時.");
    }
}

output

路程300公里:
父類別執行
鳥花了2.5小時.
子類別執行
老鷹花了2.5小時.

可以看到第二個範例,子類別在沒有複寫父類別的情況下而被執行,因為傳入的參數HashMap是Map的子類別,這樣就會造成邏輯的論換(上面企鵝的例子)。所以子類別中方法的前置條件必須與父類別中被覆寫的前置條件相同或者更寬。

4. 覆蓋或者實現父類別的方法時輸出結果可以被縮小

這其實與第三點類似,父類別能出現的地方子類別就可以出現,而且替換為子類別不會產生任何錯誤或者異常。但是反過來就不行了,有子類別出現的地方,父類別未必就適應。

子類別替換父類別

範例5

class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }
}

class Eagle extends Bird {}
// 父類別
public class LSP {
    public static void main(String[] args) { 
        Bird bird = new Bird();
        bird.setFlySpeed(120);
        System.out.println("路程300公里:");
        System.out.println("老鷹花了" + bird.getFlyTime(300) + "小時.");
    }
}

範例6

// 子類別(Eagle)替換父類別(Bird) 
public class LSP {
    public static void main(String[] args) {
        Eagle bird = new Eagle();
        bird.setFlySpeed(120);
        System.out.println("路程300公里:");
        System.out.println("老鷹花了" + bird.getFlyTime(300) + "小時.");
    }
}

output

// 父類別
路程300公里:
老鷹花了2.5小時.

// 子類別替換父類別
路程300公里:
老鷹飛行2.5小時.
父類別替換子類別

範例7

class Bird {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }
}

class Eagle extends Bird {
    public void action(){
        System.out.println("老鷹用飛的");
    }
}

// 子類別
public class LSP {
    public static void main(String[] args) {
        Eagle bird = new Eagle();
        bird.setFlySpeed(120);
        System.out.println("路程300公里:");
        bird.action();
        System.out.println("老鷹花了" + bird.getFlyTime(300) + "小時.");
    }
}

範例8

// 父類別替換子類別 
public class LSP {
    public static void main(String[] args) {
        Bird bird = new Bird();
        bird.setFlySpeed(120);
        System.out.println("路程300公里:");
        bird.action();
        System.out.println("老鷹花了" + bird.getFlyTime(300) + "小時.");
    }
}
// 子類別
路程300公里:
老鷹用飛的
老鷹花了2.5小時.

// 父類別替換子類別
/LSP.java:25: error: cannot find symbol
        bird.action();
            ^
  symbol:   method action()
  location: variable bird of type Bird
1 error

由上面兩個例子可以發現,當原本是Bird要替換成Eagle,是可以的,因為Eagle擁有Bird的所有行為。但反過來Bird要替換Eagle卻是不行的,原因是Bird並沒有action()的function可以用。

  • 應用 Liskov Substitution Principle

回到剛剛老鷹與企鵝的問題裡。因為企鵝更改了父類別的function,以至於其他的function無法正常的運行。我們可以用比較詳細的分類來區分老鷹以及企鵝。鳥不一定就會飛,企鵝、鴕鳥...這類的基本上都不會飛。所以我們可以將父類別分成會飛的鳥以及用跑的鳥(這樣比較好分類XD),再分別去繼承鳥類。然後讓老鷹繼承會飛的鳥,企鵝繼承會跑的鳥。

範例9

class Bird {
    public void eat(){
        System.out.println("鳥吃東西!!")
    }
}


class BirdFly {
    int flySpeed;
    public void setFlySpeed(int flySpeed) {
        this.flySpeed = flySpeed;
    }
    public int getFlyTime(int distance) {
        return(distance/flySpeed);
    }
}

class BirdRun {
    int runSpeed;
    public void setRunSpeed(int runSpeed) {
        this.runSpeed = runSpeed;
    }
    public int getRunTime(int distance) {
        return(distance/runSpeed);
    }
}

class Eagle extends BirdFly {}

class Penguin extends BirdRun {}

public class LSP {
    public static void main(String[] args) {
        BirdFly eagle = new Eagle();
        BirdRun penguin = new Penguin();
        eagle.setFlySpeed(120);
        penguin.setRunSpeed(20);
        System.out.println("路程300公里");
        System.out.println("老鷹花了" + eagle.getFlyTime(300) + "小時");
        System.out.println("企鵝花了" + penguin.getRunTime(300) + "小時");
    }
}
路程300公里
老鷹花了2.5小時
企鵝花了15.0小時

LSP2

經由上面的更改,企鵝就不需要Override鳥類方法,如此一來就不會影響到父類別其他的方法邏輯。

  • 小結


LSP的優缺點
優點
1. 提高程式碼的重用性
2. 提高類別的擴充性
缺點
1. 因為繼承關係,耦合性增高(修改父類別常數時需要思考是否會影響到其他繼承的子類別)
2. 降低程式碼靈活性(必須實作父類別所有方法)
LSP的目標

子類別可以擴充套件父類別的功能,但不改變父類別原有的功能

LSP 4個繼承規範
1. 子類別必須完全實現父類別的方法:鳥類移動行為,老鷹為飛翔,企鵝為走路。
2. 子類別可以有自己的特性:企鵝會游泳,但鳥並沒有預設此技能。
3. 重載(Overload)或者實現父類別的方法時輸入參數可以被放大:Map and HashMap。
4. 覆蓋或者實現父類別的方法時輸出結果可以被縮小:父類別可以被子類別替換,但子類別不一定能被父類別替換。
  • 範例程式碼


範例1:繼承
範例2:繼承(老鷹 VS 企鵝)
範例3:第三個規範(子類別參數範圍大於父類別)
範例4:第三個規範(子類別參數範圍小於父類別)
範例5:第四個規範(子類別替換父類別:未替換)
範例6:第四個規範(子類別替換父類別:替換)
範例7:第四個規範(父類別替換子類別:未替換)
範例8:第四個規範(父類別替換子類別:替換)
範例9:應用LSP

  • References


设计模式原则
里氏替換原則(LSP)
Java設計模式六大原則(2):里氏替換原則


上一篇
[Day04] 開閉原則 | Open/Closed Principle
下一篇
[Day06] 介面隔離原則 | Interface Segregation Principle
系列文
從生活中認識Design Pattern30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
kodyds
iT邦新手 5 級 ‧ 2022-07-06 17:01:15

請問這些原則是只挑一個來遵守,還是全部都要遵守?

TimChen iT邦新手 5 級 ‧ 2023-08-31 11:35:01 檢舉

這沒有一定,程式設計都希望類別擁有高內聚力+低耦合力,但兩者本來就互斥:

  • Ex: 內聚力越高意味著需要更多類別去各別做其他事而產生,而讓耦合度增加
  • Ex: 耦合力越低意味著帶來更多低內聚力的問題

我要留言

立即登入留言