前言
今天來學習 SOLID 中的 L:里氏替換原則。它的英文翻譯是 Liskov Substitution Principle,縮寫為 LSP。
英文原話是: Functions that use points of references of base classes must be able to use objects of derived classes without knowing it。
用中文描述,是這樣的:子類對象能夠替換程序中父類對象出現的任何地方,并且保證原來程序的邏輯行為不變及正確性不被破壞。
如何理解“里氏替換原則”
開頭對里氏替換原則的解釋比較抽象,通過一個例子來解釋下。父類 Transporter
使用 org.apach.http
來傳輸網絡數據。子類 SecurityTransporter
繼承父類 Transporter
,增加了額外的功能,支持傳輸 appId
和 appToken
安全認證信息。
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;}@Overridepublic 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) {Request request = new Request();// 省略設置 request中數據的代碼...Response response = transporter.sendRequest(request);// 省略其他邏輯}
}// 里氏替換原則
Demo demo = new Demo();
demo.demoFunction(new SecurityTransporter(/*省略參數*/));
在上面代碼中,子類 SecurityTransporter
的設計符合里氏替換原則,可以替換父類出現的任何位置,并且原來代碼的邏輯行為不變且正確性也沒有被破壞。
你可能會有疑問,剛剛的代碼就是利用了多態,多態和里氏替換原則是不是一回事呢?
其實它們完全是兩回事。
我們還是通過剛剛的例子來說明下。對 SecurityTransporter
類中 sendRequest()
函數稍微改造下。對 appId
、appToken
進行校驗,若沒有設置,則拋出異常。改造后的代碼如下所示:
// 改造前
public class SecurityTransporter extends Transporter {// 其他代碼忽略...@Overridepublic 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 {// 其他代碼忽略...@Overridepublic 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);}
}
改造之后,如果傳遞進 demoFunction()
函數的是父類 Transporter
對象,那不會有溢出拋出,但是如果傳遞的是 SecurityTransporter
對象有可能會拋出異常,子類替換父類傳遞進 demoFunction()
函數之后,整個程序的邏輯行為有了改變。
雖然改造之后的代碼仍然可以通過 Java
的多態語法,動態地用子類來替換父類,也不會導致程序編譯或運行出錯,但是,從設計思路上來講,SecurityTransporter
不符合里氏替換原則。
總結一下,雖然從定義描述和代碼實現上來看,多態和里氏替換原則有點類似,但它們關注的角度不同。
- 多態是面向對象編程的一大特性,也是面向對象編程語言的一種語法。是一種代碼實現的思路。
- 里氏替換原則是一種設計原則,用來指導繼承關系中子類該如何設計,子類的設計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性。
哪些代碼明顯違背了 LSP?
子類在設計的時候,要遵守父類的行為約定(或者叫協議)。行為約定包括:
- 函數聲明要實現的功能
- 對輸入、輸出、異常的約定
- 甚至包括注釋中所羅列的任何特殊說明。
為了更好的說明,我們舉幾個反例來解釋下。
1.子類違背父類聲明要實現的功能
父類中提供的 sortOrdersByAmount()
訂單排序函數,是按照金額從小到大排序,而子類重寫之后,按照創建日期來給訂單排序。那子類的設計就違背里氏替換原則。
2.子類違背父類對輸入、輸出、異常的約定
在父類中,某個函數約定:運行出錯的時候返回 null
;獲取數據為空的時候返回空集合(empty collection)。而子類重載后,運行出錯返回異常,獲取不到數據返回 null
。那子類的設計就違背里氏替換原則。
在父類中,某個函數約定,輸入可以是任意整數,但子類實現的時候,只允許輸入正整數,負數就拋出異常,即子類對輸入數據的校驗比父類嚴格,那子類的設計就違背里氏替換原則。
在父類中,某個函數約定,只會拋出 ArgumentNullException
異常,那子類的實現中,只允許排拋出 ArgumentNullException
異常,任何其他異常的拋出,都會導致子類違背里氏替換原則。
3.子類違背父類注釋中所羅列的任何特殊說明
父類定義的 withdraw()
提現函數的注釋是這么寫的:“用戶的提現金額不得超過賬戶余額…”,而之類重寫 withdraw()
函數后,針對 VIP 賬號實現了透支提現的功能,也就是提現金額可以大于賬戶余額,那這個子類的設計也是不符合里氏替換原則的。
以上三種典型的違背歷史替換原則的情況。此外,判斷子類的設計實現是否違背里氏替換原則,還有一個小竅門,就是拿父類的單元測試去驗證子類的代碼。如果某些單元測試運行失敗,就有可能說明,子類的設計實現沒有完全地遵守父類的約定,子類有可能違背了里氏替換原則。