softwareArchitecture

深入淺出 Liskov 替換原則 Liskov Substitution Principle

這篇文章介紹軟體架構裡面 S.O.L.I.D 中的L (Liskov Substitution Principle)

這篇文章中大部分的程式碼 參考自SOLID Principles of Object-Oriented Design and Architecture

特別感謝領英Kafka一哥 lmr3796 遠端線上教學

定義

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.)

這意思就是說 如果你程式碼中的類別T的物件 都可以被一個類別S的物件給取代 而且程式碼還運作正常 那你才可以說類別S是類別T的subType

subType vs subClass

這個原則很容易被人誤解 最大的原因是大多數人搞不清楚subtype跟subclass的差別(我甚至不知道中文怎麼翻 為了不搞混大家這篇一律用英文)

subType: 就如同LSP定義所說 如果S是T的subType 那在所有T出現過的地方都可以用S取代 目標是讓你的架構更加彈性

subClass: A extend B, 那你就可以說B是A的subClass 目標是code reuse

Alt text

當然subType也是靠繼承來達成 但只有正確的繼承 才夠格被稱為subType 不正確的繼承 就只是個繼承

Princeton CS441教學的第一段講得不錯

There are important differences between subtypes and subclasses in supporting reuse. Subclasses allow one to reuse the code inside classes - both instance variable declarations and method definitions. Thus they are useful in supporting code reuse inside a class. Subtyping on the other hand is useful in supporting reuse externally, giving rise to a form of polymorphism. That is, once a data type is determined to be a subtype of another, any function or procedure that could be applied to elements of the supertype can also be applied to elements of the subtype.

我來翻譯翻譯

Subtype = Subclass(realization) which can be substituted for the type it extends(implements)

聽完我舉兩個例子你會更加的清楚

鳥跟鴕鳥例子

我們有個鳥類別

class Bird{
  public void fly(){
    // Fly!
  }
}

鴕鳥即使不會飛 但也是鳥的一種

class Ostrich extends Bird{
  @Override
  public void fly(){
    // Cannot fly
    throw new RuntimeException();
  }
}

在這個例子 Ostrich就是Bird的SubClass 因為他使用了繼承

但Ostrich不是Bird的SubType 因為不是所有有Bird出現的地方你都可以用Ostrich取代 而且程式碼正確性一樣

比如說我得到了一隻鳥

Bird b = getBird();
b.fly();

我要是把這個鳥換成鴕鳥 就會有例外噴出來

正方形長方形例子

我們有正方形類別跟長方形類別 因為正方形只是長方形的長寬相同的特例 所以我們讓正方形繼承長方形

class Rectangle{
  int width;
  int height;
  public void setWidth(int w){
    width = w;
  }
  public void setHeight(int h){
    height = h;
  } 
}

當然別忘了在改長寬的時候兩個要一起改

class Square extends Rectangle {
  public void setWidth(int w){
    width = w;
    height = w;
  }
  public void setHeight(int h){
    height = h;
    width = h;
  } 
}

這個例子中 Square 就是 Rectangle 的 SubClass

那Square 是不是 Rectangle 的 SubType 呢 我們來看看這個函式

public void testRectangle(Rectangle r) {
  r.setWidth(10);
  r.setHeight(20);
  assertTrue(r.getArea() == 200);
}

這個函數原本丟長方形的物件進去會對的 但要是丟了正方形的物件進去就會錯

所以 Square 不是 Rectangle 的 SubType

這有什麼了不起

那如果我違反了會怎麼樣呢 Square 不是 Rectangle 的 SubType 世界會塌嗎?

世界不會塌 但是這不是個好架構 想像一下你是個使用Rectangle跟Square類別的人 當你拿到一個Rectangle的時候

1.因為你不確定當初設計Rectangle的人 有沒有保證每個繼承Rectangle的類別都是Rectangle的subType

2.你知道如果一個Rectangle的subClass不是Rectangle的subType的話 你預期會應該會正確的行為有可能會不正確(比如testRectangle函數)

那你只能怎麼辦 你只能

public void testRectangle(Rectangle r) {
  r.setWidth(10);
  r.setHeight(20);
  if(r instanceof Square){
    assertTrue(r.getArea() == 400);
  }else{
    assertTrue(r.getArea() == 200);
  }
}

天知地知你知我知 這程式太醜 而且使用者被強迫了解太多細節

講到這裡 你應該知道我要說什麼了 你要做好心理準備 這可能會讓你的世界崩塌

 

準備好了嗎

 

我要說了喔

 

就是…

 

大多數你使用繼承的地方都不該用繼承

主要原因 是因為

Inheritance是所有依賴關係裡面最強的 而你知道太過依賴總是沒啥好事

                                                                                          - lmr3796

所以LSP這個原則告訴了我們兩件事

1.如何判斷A跟B是不是SubType關係

2.只要不是subType關係 你就不該用繼承

Alt text

如何判斷

那也太麻煩了吧 我每次A繼承B的時候 我都要去想辦法去找出一個例子 把所有的B換成A來看看跑起來對不對 試過所有情況後我才能有信心的認為A是B的SubType嗎?

這是我第一次讀完LSP的結論 我相信也是大多數人的想法

如果你只是看Clean Architecture或是其他網路上文章的話 沒錯 因為這是LSP的定義 可是這篇文章並不是普通的文章 該不該用繼承 有些準則可以遵循 每項準則都遵守了 你就可以用繼承

且聽我娓娓道來

Liskov

先來了解一下 為什麼 S.O.L.I.D 裡面 只有LSP是用人名呢 因為Liskov很猛

Barbara Liskov 他是美國第一位CS女博士 2008年Turing Award得主 在對女性不友好的年代中 還是脫穎而出 絕對是最偉大的程序員之一

Alt text

而我們講的LSP 就是她寫的paper Behavioral Subtyping Using Invariants and Constraints

我們來看一下本篇Paper最重要的一段 這段定義了何為Subtype

Alt text

看得懂嗎? 看不懂沒關係 這篇文章就是為此而生的

Alt text

這段天書 基本上就是在描述7個規則 只要這7個規則都遵守了 那你繼承用下去 就是subType

7個規則

規則一: Covariance of argument

1.當你實作或是繼承一個superClass的方法時 你方法的input argument的數目應該一樣

2.在SubClass的方法裡面的argument 都應該是SuperClass相對應的argument的SuperType

為了方便講解 我們先隨便定義一個SuperType <- Type <- SubType的關係

class SuperType {}
class Type extends SuperType{}
class SubType extends Type{}

上例子 我有一個DemoClass

class DemoClass {
  public int compute();
} 

如果我想要繼承它的話 裡面的方法參數的數目要一樣

class DemoClassSub {
  public int compute();
} 

而且如果compute()有參數的話

class DemoClass {
  public int compute(Type t);
} 

那你override的時候 參數只能是SuperType 不能是SubType

class DemoClassSub {
  public int compute(SuperType st);
} 

這就是第一個規則

腦中的小疑問

誒你在開玩笑嗎 如果要Override的話 所有的argument參數要同一種型態啊 不然compiler不會給你過的吧

Alt text

那是因為我們現在寫的是Java 是個強型別的語言 Liskov這篇paper是可以套用在所有的程式語言 很多語言沒有這種check 那你就必須要自己檢查有沒有違反以上的rule

規則二: Covariance of result

1.當你實作或是繼承一個superClass的方法時 你方法的回傳的數目應該一樣

2.在SubClass的方法裡面的回傳類別 都應該是SuperClass相對應的回傳值的SubType

夠好懂 如果compute()有回傳值的話

class DemoClass {
    public Type compute(Type t){
        return new Type();
    };
}

那你override的時候 回傳值只能是SubType 不能是SuperType

class DemoClassSub extends DemoClass {
    @Override
    public SubType compute(Type t){
        return new SubType();
    }
}

這就是第二個規則

規則三: Exception rule

當你實作或是繼承一個superClass的方法時 拋出的例外 必須是superClass方法拋出的例外的subType

這就是第三個規則

深呼吸喘口氣

恭喜你 你看完前三個原則了 現在問題來了 要不要死背!

做學問要融會貫通 其實如果你真的懂subType的好處 這些都是可以推導的出來

subType的好處是什麼呢 好處就是我們可以放心的相信polymorphism

比如說我得到了一隻鳥

Bird b = getBird();
b.fly();

今天我把這個物件b換成任何一個Bird的subType的物件 我的程式還是會對 不用擔心會來個鴕鳥導致我的fly()噴出例外

我們的目標 是讓使用者(Client)對於這個類別(Bird)了解的越少越好 他不需要去仔細看所有鳥的SubType來決定它的程式怎麼寫

只要 他回傳給我的一定是鳥的subType

只要 他可能拋出的exception是原本預期的exception的subType

那我就不用另外處理 降低了依賴 Client輕鬆愉快

希望這個時候你有一點點融會貫通的感覺 記得這個感覺 讓我們繼續看下去

規則四: Pre-condition rule

先了解什麼是pre-condition

Pre-condition: An assertion about the state of the system before the method is called

比如說 你要執行methodA之前 你必須要先initilize某個類別X 那這就是這個methodA的pre-condition

了解什麼是pre-condition之後 規則四講的是

subType的函式需要的precondition 不能比baseType的還要嚴謹(strict)

你也可以想成 在所有可以執行baseType.methodA的地方 你都應該可以執行subType.methodA

這就是第四個規則

規則五: Post-condition rule

先了解什麼是post-condition

Post-condition: An assertion about the state of the system after method execution completes

比如說執行完methodA之後 某個物件一定不能是null 就是一個methodA的post-condition

了解什麼是post-condition之後 規則五講的是

subType的函式需要的postcondition 不能比baseType的還要鬆散(weak)

比如說baseType的methodA的post-condition是: 某個物件一定不能是null 那如果一個繼承baseType的類別的methodA的post-condition是: 某個物件可能是null

那這個類別就不是baseType的subType 因為這個post-condition比baseType的更加的不嚴謹

這其實也很直覺 當你的使用者在用baseType的methodA 他會預期跑完後會有些預期的狀況會發生 但要是因為你隨便繼承 導致subClass的post-condition更不嚴謹 那使用者的程式可能就會報錯 比如他沒預期會有null 結果你給null 那就噴NPE

這就是第五個規則

規則六: Invariant rule

先了解什麼是invariant

Some assertion about a specific class property which is always true

就是對於一個類別的永恆不變的法則 比如說對於一個Queue來說

number of elements in the queue <= capacity

這就可以說是一個invariant

了解什麼是invariant之後 規則六講的是

一個subtype的類別的invariant 必須包含所有baseType的invariant

比如說我們今天想寫一個Queue的subType 假設是BoundedQueue好了 那BoundedQueue必須要有所有Queue的invariant

這就是第六個規則

規則七: Constraint rule

先了解什麼是Constraint 他跟invariant的差別非常的細微

Some assertion about how class property evolves over time

就是隨著時間變化 某些class property應該怎麼改變(或是不改變)

比如說對於一個Queue來說

capacity never changes after initialized 這就是一個queue的constraint

Constraint vs Invariant

請容許我再特別說明一下這兩個的差別 因為我一開始也是搞不清楚

Invariant 指的是無論何時何地 你單看一個類別的instance 都應該要遵守的 就叫做invariant

Constraint 指的是對於某一個物件來說 在歷史上的兩個不同時間 應該要遵循什麼事情

來個例子 假設我有個針對Message找error的類別MessageErrorDetector

class MessageErrorDetector {
    boolean errorFound = false;
    public void processMessage(Message m){
      if(m.findError()) {
        errorFound = true;
      }
    }
    public boolean isErrorDetected() {
      return errorFound;
    }
}

MessageErrorDetector類別 只要曾經找到過error 那麼之後每次呼叫isErrorDetected就只會回傳true

那MessageErrorDetector類別的constraint就是: 只要曾經偵測到錯誤訊息 那這個物件就永遠停留在這個error狀態

希望這個例子有讓你更明白何謂constraint

再看回規則七

了解什麼是constraint之後 規則七講的是

一個subtype的類別的constraint 必須包含所有baseType的constraint

其實就是 你要respect你要繼承的類別的constraint

假設你要繼承MessageErrorDetector 寫了ResetableMessageErrorDetector

class ResetableMessageErrorDetector extends MessageErrorDetector {
    public void reset(){ 
      errorFound = false;
    }
}

感覺很合理啊 我想要一個功能性更強的MessageErrorDetector 繼承一下 多加個功能錯了嗎

ㄟ 還真的錯了 Liskov告訴你說 這樣子違反了LSP的規則七

只要曾經偵測到錯誤訊息 那這個物件就永遠停留在這個error狀態 這個constraint在子類中不成立了

所以ResetableMessageErrorDetector並不是MessageErrorDetector的SubType

出生到現在 第一次讀懂Liskov

如果你讀到了這裡 那我先恭喜你了 你已經徹底了解LSP 而我相信大多數的人從來就沒了解過

現在我們已經是專業人士了 讓我們用專業人士的語言來說明 為什麼正方形不能是長方形的subType

Checklist

之前我們要說服一個人說 正方形不能繼承長方形 我們必須寫一段程式 把長方形的物件換成正方形 然後藉由發現錯誤來說服別人

現在我們有了七個準則 我們來看看正方形能不能繼承長方形

class Rectangle{
  int width;
  int height;
  public void setWidth(int w){
    width = w;
  }
  public void setHeight(int h){
    height = h;
  } 
}

class Square extends Rectangle {
  public void setWidth(int w){
    width = w;
    height = w;
  }
  public void setHeight(int h){
    height = h;
    width = h;
  } 
}

1.Covariance of argument: 函式argument一樣 遵守

2.Covariance of result: 函式都回傳void 遵守

3.Exception rule: 都沒有拋出例外 遵守

4.Pre-condition rule: 都沒有Pre-condition 遵守

5.Post-condition rule: 都沒有Post-condition 遵守

6.Invariant rule: 長方形沒有Invariant 正方形的Invariant就是長寬一樣 但在我們正方形的實作中我們有遵守這個Invariant(所以如果我們setWidth只改width 那這條就沒遵守了) 所以這條有遵守

7.Constraint rule: 有趣了 這條沒遵守 因為在Rectangle的Contraint包含了

i. setWidth時不可以動到height

ii. setHeight時不可以動到width

可是我們的正方形違反了這個Constraint

所以 我們可以有信心的說 正方形不是長方形的subType

總結

這可能是S.O.L.I.D裡面最難的一個Principle

要用一句話簡短的總結的話 就是

當你要使用繼承的時候要非常小心 大多數的情況你都不該用繼承 如果你一定要用的話 這裡的七項規則是檢驗的方法 只要你繼承完的類別符合這七項規則 那代表說這個subclass是baseType的subType 那才可以使用繼承

看回我們討論過的Effective Java Item18 你會發現各個大神提倡的概念都是萬變不離其宗