JavaScript:S.O.L.I.D五大原則之裡氏替換原則LSP
前言
本章我們要講解的是S.O.L.I.D五大原則JavaScript語言實現的第3篇,裡氏替換原則LSP(The Liskov Substitution Principle )。
開閉原則的描述是:
Subtypes must be substitutable for their base types.衍生類別型必須可以替換它的基底類型。
在物件導向編程裡,繼承提供了一個機制讓子類和共用基類的代碼,這是通過在基底類型裡封裝通用的資料和行為來實現的,然後已經及類型來聲明更詳細的子類型,為了應用裡氏替換原則,繼承子類型需要在語義上等價於基底類型裡的期望行為。
為了來更好的理解,請參考如下代碼:
function Vehicle(my) { var my = my || {}; my.speed = 0; my.running = false; this.speed = function() { return my.speed; }; this.start = function() { my.running = true; }; this.stop = function() { my.running = false; }; this.accelerate = function() { my.speed++; }; this.decelerate = function() { my.speed--; }, this.state = function() { if (!my.running) { return parked; } else if (my.running && my.speed) { return moving; } else if (my.running) { return idle; } };}
上述代碼我們定義了一個Vehicle函數,其建構函式為vehicle對象提供了一些基本的操作,我們來想想如果當前函數當前正運行在服務客戶的產品環境上,如果現在需要添加一個新的建構函式來實現加快移動的vehicle。思考以後,我們寫出了如下代碼:
function FastVehicle(my) { var my = my || {}; var that = new Vehicle(my); that.accelerate = function() { my.speed += 3; }; return that;}
在瀏覽器的控制台我們都測試了,所有的功能都是我們的預期,沒有問題,FastVehicle的速度增快了3倍,而且繼承他的方法也是按照我們的預期工作。此後,我們開始部署這個新版本的類庫到產品環境上,可是我們卻接到了新的建構函式導致現有的代碼不能支援執行了,下面的程式碼片段揭示了這個問題:
var maneuver = function(vehicle) { write(vehicle.state()); vehicle.start(); write(vehicle.state()); vehicle.accelerate(); write(vehicle.state()); write(vehicle.speed()); vehicle.decelerate(); write(vehicle.speed()); if (vehicle.state() != idle) { throw The vehicle is still moving!; } vehicle.stop(); write(vehicle.state());};
根據上面的代碼,我們看到拋出的異常是“The vehicle is still moving!”,這是因為寫這段代碼的作者一直認為加速(accelerate)和減速(decelerate)的數字是一樣的。但FastVehicle的代碼和Vehicle的代碼並不是完全能夠替換掉的。因此,FastVehicle違反了裡氏替換原則。
在這點上,你可能會想:“但,用戶端不能老假定vehicle都是按照這樣的規則來做”,裡氏替換原則(LSP)的妨礙(譯者註:就是妨礙實現LSP的代碼)不是基於我們所想的繼承子類應該在行為裡確保更新代碼,而是這樣的更新是否能在當前的期望中得到實現。
上述代碼這個case,解決這個不相容的問題需要在vehicle類庫或者用戶端調用代碼上進行一點重新設計,或者兩者都要改。
減少LSP妨礙
那麼,我們如何避免LSP妨礙?不幸的話,並不是一直都是可以做到的。我們這裡有幾個策略我們處理這個事情。
契約(Contracts)
處理LSP過分妨礙的一個策略是使用契約,契約清單有2種形式:執行說明書(executable specifications)和錯誤處理,在執行說明書裡,一個詳細類庫的契約也包括一組自動化測試,而錯誤處理是在代碼裡直接處理的,例如在前置條件,後置條件,常量檢查等,可以從Bertrand Miller的大作《契約設計》中查看這個技術。雖然自動化測試和契約設計不在本篇文字的範圍內,但當我們用的時候我還是推薦如下內容:
- 檢查使用測試驅動開發(Test-Driven Development)來指導你代碼的設計
- 設計可重用類庫的時候可隨意使用契約設計技術
對於你自己要維護和實現的代碼,使用契約設計趨向於添加很多不必要的代碼,如果你要控制輸入,添加測試是非常有必要的,如果你是類庫作者,使用契約設計,你要注意不正確的使用方法以及讓你的使用者使之作為一個測試載入器。
避免繼承避免LSP妨礙的另外一個測試是:如果可能的話,盡量不用繼承,在Gamma的大作《Design Patterns – Elements of Reusable Object-Orineted Software》中,我們可以看到如下建議:
Favor object composition over class inheritance盡量使用對象組合而不是類繼承
有些書裡討論了組合比繼承好的唯一作用是靜態類型,基於類的語言(例如,在運行時可以改變行為),與JavaScript相關的一個問題是耦合,當使用繼承的時候,繼承子類型和他們的基底類型耦合在一起了,就是說及類型的改變會影響到繼承子類型。組合傾向於對象更小化,更容易想靜態和動態語言語言維護。
與行為有關,而不是繼承到現在,我們討論了和繼承上下文在內的裡氏替換原則,指示出JavaScript的物件導向實。不過,裡氏替換原則(LSP)的本質不是真的和繼承有關,而是行為相容性。JavaScript是一個動態語言,一個對象的契約行為不是對象的類型決定的,而是對象期望的功能決定的。裡氏替換原則的初始構想是作為繼承的一個原則指南,等價於對象設計中的隱式介面。
舉例來說,讓我們來看一下Robert C. Martin的大作《敏捷式軟體開發 (Agile Software Development) 原則、模式與實踐》中的一個矩形類型:
矩形例子考慮我們有一個程式用到下面這樣的一個矩形對象:
var rectangle = { length: 0, width: 0};
過後,程式有需要一個正方形,由於正方形就是一個長(length)和寬(width)都一樣的特殊矩形,所以我們覺得建立一個正方形代替矩形。我們添加了length和width屬性來匹配矩形的聲明,但我們覺得使用屬性的getters/setters一般我們可以讓length和width儲存同步,確保聲明的是一個正方形:
var square = {};(function() { var length = 0, width = 0; // 注意defineProperty方式是262-5版的新特性 Object.defineProperty(square, length, { get: function() { return length; }, set: function(value) { length = width = value; } }); Object.defineProperty(square, width, { get: function() { return width; }, set: function(value) { length = width = value; } });})();
不幸的是,當我們使用正方形代替矩形執行代碼的時候發現了問題,其中一個計算矩形面積的方法如下:
var g = function(rectangle) { rectangle.length = 3; rectangle.width = 4; write(rectangle.length); write(rectangle.width); write(rectangle.length * rectangle.width);};
該方法在調用的時候,結果是16,而不是期望的12,我們的正方形square對象違反了LSP原則,square的長度和寬度屬性暗示著並不是和矩形100%相容,但我們並不總是這樣明確的暗示。解決這個問題,我們可以重新設計一個shape對象來實現程式,依據多邊形的概念,我們聲明rectangle和square,relevant。不管怎麼說,我們的目的是要說裡氏替換原則並不只是繼承,而是任何方法(其中的行為可以另外的行為)。
總結裡氏替換原則(LSP)表達的意思不是繼承的關係,而是任何方法(只要該方法的行為能體會另外的行為就行)。