里式替換

1 如何理解“里式替換原則”?

里式替換原則的英文翻譯是:Liskov Substitution Principle,縮寫為 LSP。

  • 原則最早是在 1986 年由 Barbara Liskov 提出,他是這么描述這條原則的:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
  • 在 1996 年,Robert Martin 在他的 SOLID 原則中,重新描述了這個原則,英文原話是這樣的:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。

子類對象(object of subtype/derived class)能夠替換程序(program)中父類對象(object of base/parent class)出現(xiàn)的任何地方,并且保證原來程序的邏輯行為(behavior)不變及正確性不被破壞。

1.1 舉例

  • 父類 Transporter 使用 org.apache.http 庫中的 HttpClient 類來傳輸網(wǎng)絡(luò)數(shù)據(jù)。
  • 子類 SecurityTransporter 繼承父類 Transporter,增加了額外的功能,支持傳輸 appId 和 appToken 安全認(rèn)證信息。

public class Transporter {
  private HttpClient httpClient;
  
  public Transporter(HttpClient httpClient) {
    this.httpClient = httpClient;
  }

  public Response sendRequest(Request request) {
    // ...use httpClient to send request
  }
}

public class SecurityTransporter extends Transporter {
  private String appId;
  private String appToken;

  public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
    super(httpClient);
    this.appId = appId;
    this.appToken = appToken;
  }

  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
      request.addPayload("app-id", appId);
      request.addPayload("app-token", appToken);
    }
    return super.sendRequest(request);
  }
}

public class Demo {    
  public void demoFunction(Transporter transporter) {    
    Reuqest request = new Request();
    //...省略設(shè)置request中數(shù)據(jù)值的代碼...
    Response response = transporter.sendRequest(request);
    //...省略其他邏輯...
  }
}

// 里式替換原則
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略參數(shù)*/););

子類 SecurityTransporter 的設(shè)計完全符合里式替換原則,*可以替換父類出現(xiàn)的任何位置,并且原來代碼的邏輯行為不變且正確性也沒有被破壞。

  • 剛剛的代碼設(shè)計不就是簡單利用了面向?qū)ο蟮亩鄳B(tài)特性嗎?

多態(tài)和里式替換原則說的是不是一回事呢?從剛剛的例子和定義描述來看,里式替換原則跟多態(tài)看起來確實有點類似

實際上它們完全是兩回事

1.2 舉例2

我們需要對 SecurityTransporter 類中 sendRequest() 函數(shù)稍加改造一下。改造前,如果 appId 或者 appToken 沒有設(shè)置,我們就不做校驗;

改造后,如果 appId 或者 appToken 沒有設(shè)置,則直接拋出 NoAuthorizationRuntimeException 未授權(quán)異常。

改造前后的代碼對比如下所示:


// 改造前:
public class SecurityTransporter extends Transporter {
  //...省略其他代碼..
  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
      request.addPayload("app-id", appId);
      request.addPayload("app-token", appToken);
    }
    return super.sendRequest(request);
  }
}

// 改造后:
public class SecurityTransporter extends Transporter {
  //...省略其他代碼..
  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
      throw new NoAuthorizationRuntimeException(...);
    }
    request.addPayload("app-id", appId);
    request.addPayload("app-token", appToken);
    return super.sendRequest(request);
  }
}

在改造之后的代碼中,如果傳遞進(jìn) demoFunction() 函數(shù)的是父類 Transporter 對象,那 demoFunction() 函數(shù)并不會有異常拋出,但如果傳遞給 demoFunction() 函數(shù)的是子類 SecurityTransporter 對象,那 demoFunction() 有可能會有異常拋出。

盡管代碼中拋出的是運行時異常(Runtime Exception),我們可以不在代碼中顯式地捕獲處理,但子類替換父類傳遞進(jìn) demoFunction 函數(shù)之后,整個程序的邏輯行為有了改變。

雖然改造之后的代碼仍然可以通過 Java 的多態(tài)語法,動態(tài)地用子類 SecurityTransporter 來替換父類 Transporter,也并不會導(dǎo)致程序編譯或者運行報錯。但是,從設(shè)計思路上來講,SecurityTransporter 的設(shè)計是不符合里式替換原則的。

1.3 總結(jié)

雖然從定義描述和代碼實現(xiàn)上來看,多態(tài)和里式替換有點類似,但它們關(guān)注的角度是不一樣的。

  • 多態(tài)是面向?qū)ο缶幊痰囊淮筇匦?,也是面向?qū)ο缶幊陶Z言的一種語法。它是一種代碼實現(xiàn)的思路。
  • 里式替換是一種設(shè)計原則,是用來指導(dǎo)繼承關(guān)系中子類該如何設(shè)計的,子類的設(shè)計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性

2 哪些代碼明顯違背了 LSP?

里式替換原則還有另外一個更加能落地、更有指導(dǎo)意義的描述,那就是“Design By Contract”,中文翻譯就是“按照協(xié)議來設(shè)計”

子類在設(shè)計的時候,要遵守父類的行為約定(或者叫協(xié)議)。

父類定義了函數(shù)的行為約定,那子類可以改變函數(shù)的內(nèi)部實現(xiàn)邏輯,但不能改變函數(shù)原有的行為約定。

這里的行為約定包括:函數(shù)聲明要實現(xiàn)的功能;對輸入、輸出、異常的約定;甚至包括注釋中所羅列的任何特殊說明。

2.1 子類違背父類聲明要實現(xiàn)的功能

父類中提供的 sortOrdersByAmount() 訂單排序函數(shù),是按照金額從小到大來給訂單排序的,而子類重寫這個 sortOrdersByAmount() 訂單排序函數(shù)之后,是按照創(chuàng)建日期來給訂單排序的。
那子類的設(shè)計就違背里式替換原則。

2.2 子類違背父類對輸入、輸出、異常的約定

  • 在父類中,某個函數(shù)約定:運行出錯的時候返回 null;獲取數(shù)據(jù)為空的時候返回空集合(empty collection)。

  • 而子類重載函數(shù)之后,實現(xiàn)變了,運行出錯返回異常(exception),獲取不到數(shù)據(jù)返回 null。那子類的設(shè)計就違背里式替換原則。

  • 在父類中,某個函數(shù)約定,輸入數(shù)據(jù)可以是任意整數(shù),但子類實現(xiàn)的時候,只允許輸入數(shù)據(jù)是正整數(shù),負(fù)數(shù)就拋出,也就是說,子類對輸入的數(shù)據(jù)的校驗比父類更加嚴(yán)格,那子類的設(shè)計就違背了里式替換原則。

  • 在父類中,某個函數(shù)約定,只會拋出 ArgumentNullException 異常,那子類的設(shè)計實現(xiàn)中只允許拋出 ArgumentNullException 異常,任何其他異常的拋出,都會導(dǎo)致子類違背里式替換原則。

2.3 子類違背父類注釋中所羅列的任何特殊說明

父類中定義的 withdraw() 提現(xiàn)函數(shù)的注釋是這么寫的:“用戶的提現(xiàn)金額不得超過賬戶余額……”,
而子類重寫 withdraw() 函數(shù)之后,針對 VIP 賬號實現(xiàn)了透支提現(xiàn)的功能,也就是提現(xiàn)金額可以大于賬戶余額,那這個子類的設(shè)計也是不符合里式替換原則的。

  • 里式替換這個原則是非常寬松的。一般情況下,我們寫的代碼都不怎么會違背它。

參考

17 | 理論三:里式替換(LSP)跟多態(tài)有何區(qū)別?哪些代碼違背了LSP?

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

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

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