設計原則之【里式替換原則】
設計原則是指導我們程式碼設計的一些經驗總結,也就是「心法」;面向對象就是我們的「武器」;設計模式就是「招式」。
以心法為基礎,以武器運用招式應對複雜的編程問題。
實習生表妹上班又闖禍了
表妹:今天上班又闖禍了😔
我:發生什麼事情啦?
表妹:我不小心改了後端介面名的大小寫,前端頁面報錯了
你看,這不就類似我們軟體開發中的里式替換原則嘛。
子類對象能夠替換程式中父類對象出現的任何地方,並且保證原來程式的邏輯行為不變及正確性不被破壞。
如何理解「里式替換原則」?
實際上,里式替換原則還有一個更加能落地、更有指導意義的描述,那就是「按照協議來設計」。
子類在設計的時候,要遵守父類的行為約定(或者叫協議)。父類定義了函數的行為約定,那子類可以改變函數內部實現邏輯(重寫),但不能改變函數原有的行為約定。
這裡的行為約定包括:函數聲明要實現的功能;對輸入、輸出、異常的約定;甚至包含注釋中所羅列的任何特殊說明。
前後端協商好的介面文檔,就相當於「協議」。前端和後端都分別按照這個協議獨立開發,具體的實現邏輯,是遞歸、動態規劃還是貪心,由開發者決定。
實際上,定義中父類和子類之間的關係,也可以替換成介面和實現類之間的關係。
比如,父類Transporter使用org.apache.http庫中的HttpClient類傳輸網路數據。子類SecurityTransporter繼承父類Transporter,增加了額外的功能,支援傳輸appID和appToken安全認證資訊。
1 public class Transporter { 2 private HttpClient httpClient; 3 4 public Transporter(HttpClient httpClient) { 5 this.httpClient = httpClient; 6 } 7 8 public Response sendRequest(Request request) { 9 // ...use httpClient to send request 10 } 11 } 12 13 public class SecurityTransporter extends Transporter { 14 private String appID; 15 private String appToken; 16 17 public SecurityTransporter(HttpClient httpClient, String appID, String appToken) { 18 super(httpClient); 19 this.appID = appID; 20 this.appToken = appToken; 21 } 22 23 @Override 24 public Response sendRequest(Request request) { 25 if (StringUtils.isNotBlank(appID) && StringUtils.isNotBlank(appToken)) { 26 request.addPayload("app-id", appID); 27 request.addPayload("app-token", appToken); 28 } 29 return super.sendRequest(request); 30 } 31 } 32 33 public class Demo { 34 public void demoFunction(Transporter transporter) { 35 Request request = new Request(); 36 // ...省略設置request中數據值的程式碼... 37 Response response = transporter.sendRequest(request); 38 // ...省略其他邏輯... 39 } 40 } 41 42 // 里式替換原則 43 Demo demo = new Demo(); 44 demo.demoFunction(new SecurityTransporter(/*省略參數*/););
在上面的程式碼中,子類SecurityTransporter的設計完全符合里式替換原則,可以替換父類出現的任何位置,並且原來程式碼的邏輯行為(傳輸網路數據)不變且正確性也沒有被破壞。
你可能會問,上面的程式碼設計,不就是簡單利用了面向對象的多態特性嗎?
「里式替換原則」就是多態嗎?
里式替換原則,是實現開閉原則的重要方式之一,由於使用父類對象的地方可以使用子類對象,因此,在程式中盡量使用父類類型來對對象進行定義,而在運行時再確定其子類類型,用子類對象來替換父類對象。
那麼,「里式替換原則」就是多態嗎?
還是剛才那個例子,不過需要對SecurityTransporter類中sendRequest()函數稍加改造一下。改造前,我們不校驗appID或者appToken是否設置;改造後,如果appID和appToken沒有設置,則直接拋出NoAuthorizationRuntimeException未授權異常。改造前後的程式碼對比如下:
1 // 改造前: 2 public class SecurityTransporter extends Transporter { 3 // ...省略其他程式碼... 4 @override 5 public Response sendRequest(Request request) { 6 if (StringUtils.isNotBlank(appID) && StringUtils.isNotBlank(appToken)) { 7 request.addPayload("app-id", appID); 8 request.addPayload("app-token", appToken); 9 } 10 return super.sendRequest(request); 11 } 12 } 13 14 // 改造後: 15 public class SecurityTransporter extends Transporter { 16 // ...省略其他程式碼... 17 @override 18 public Response sendRequest(Request request) { 19 if (StringUtils.isBlank(appID) || StringUtils.isBlank(appToken)) { 20 throw new NoAuthorizationRuntimeException(...); 21 } 22 request.addPayload("add-id", appID); 23 request.addPayload("app-token", appToken); 24 return super.sendRequest(request); 25 } 26 }
你看,使用改造後的程式碼後,如果傳進demoFunction()函數的是父類Transporter對象,那demoFunction()函數並不會有異常拋出,但如果傳遞給demoFunction()函數的是子類SecurityTransporter對象,那demoFunction()就有可能有異常拋出。
儘管程式碼中拋出的是運行時異常,我們可以不在程式碼中顯式地捕獲處理,但子類替換父類傳遞進demoFunction函數之後,整個程式的邏輯行為就發生了改變。
雖然從定義描述和程式碼實現上看,多態和里式替換有點類似,但是它們關注的角度是不一樣的。
多態是面向對象編程的一大特性,也是面向對象程式語言的一種語法,是一種程式碼實現的思路。
而里式替換是一種設計原則,是用來指導繼承關係中,子類該如何設計,子類的設計要保證在替換父類的時候,不改變原有程式的邏輯以及不破壞原有程式的正確性。
哪些程式碼明顯違背了「里式替換原則」?
-
子類違背父類聲明要實現的功能
父類中提供的sortOrderByAmount()訂單排序函數,是按照金額從小到大來給訂單排序的,而子類重寫這個sortOrderByAmount()訂單排序函數之後,是按照創建日期來給訂單排序的。
那麼,這個子類的設計就違背了里式替換原則。
-
子類違背父類對輸入、輸出、異常的約定
在父類中,某個函數約定:運行出錯的時候返回null;獲取數據為空的時候返回空集合。而子類重載函數之後,實現變了,運行出錯返回異常,獲取不到數據返回null。
那麼,這個子類的設計就違背了里式替換原則。
-
子類違背父類注釋中所羅列的任何特殊說明
父類中定義的withdraw()提現函數的注釋是這麼寫的:「用戶的提現金額不得超過賬戶餘額…」,而子類重寫withdraw()函數之後,針對VIP帳號實現了透支提現的功能,也就是提現金額可以大於賬戶餘額。
那麼,這個子類的設計就違背了里式替換原則。
實際上,你發現沒有,里式替換原則是非常寬鬆的。判斷子類的設計實現是否違背了里式替換原則,可以拿父類的單元測試去驗證子類的程式碼。
如果某些單元測試運行失敗,就有可能說明,子類的設計實現沒有完全遵守父類的約定,子類有可能違背了里式替換原則。
總結
里式替換原則就是子類完美繼承父類的設計初衷,並做了增強(增加自己特有的方法)。
大白話就是,可以青出於藍勝於藍,但是祖傳的東西不能變。
好啦,每個設計原則是否應用得當,應該根據具體的業務場景,具體分析。
參考
極客時間專欄《設計模式之美》