编码最佳实践——Liskov替换原则
Liskov替換原則(Liskov Substitution Principle)是一組用于創建繼承層次結構的指導原則。按照Liskov替換原則創建的繼承層次結構中,客戶端代碼能夠放心的使用它的任意類或子類而不擔心所期望的行為。
Liskov替換原則定義
如果S是T的子類型,那么所有的T類型的對象都可以在不破壞程序的情況下被S類型的對象替換。
- 基類型:客戶端引用的類型(T)。子類型可以重寫(或部分定制)客戶端所調用的基類的任意方法。
- 子類型:繼承自基類型(T)的一組類(S)中的任意一個。客戶端不應該,也不需要知道它們實際調用哪個具體的子類型。無論使用的是哪個子類型實例,客戶端代碼所表現的行為都是一樣的。
Liskov替換原則的規則
要應用Liskov替換原則就必須遵守兩類規則:
1.契約規則(與類的期望有關)
- 子類型不能加強前置條件
- 子類型不能削弱后置條件
- 子類型必須保持超類型中的數據不變式
2.變體規則(與代碼中能被替換的類型有關)
- 子類型的方法參數必須是支持逆變的
- 子類型的返回類型必須是支持協變的
- 子類型不能引發不屬于已有異常層次結構中的新異常
契約
我們經常會說,要面向接口編程或面向契約編程。然后,除了表面上的方法簽名,接口所表達的只是一個不夠嚴謹的契約概念。
作為方法編寫者,要確保方法名稱能反應出它的真實目的,同時參數名稱要盡可能使描述性的。
public decimal CalculateShippingCost(int count,decimal price) {return count * price; } 復制代碼然而,方法簽名并沒有包含方法的契約信息。比如price參數是decimal類型的,這就表明任何decimal類型的值都是有限的。但是price參數的意義是價格,顯然價格不能是負數。為了做到這一點,要在方法內部實現一個前置條件。
前置條件
前置條件(precondition)是一個能保障方法穩定無錯運行的先決條件。所有方法在被調用錢都要求某些前置條件為真。
引發異常是一種強制履行契約的高效方式:
public class ShippingStrategy {public decimal CalculateShippingCost(int count,decimal price){if(price <= Decimal.Zero){throw new Exception();}return count * price;} } 復制代碼更好的方式是提供詳盡的前置條件校驗失敗原因,便于客戶端快速排查問題。此處拋出參數超出了有效范圍,并且明確指出了是哪一個參數。
public class ShippingStrategy {public decimal CalculateShippingCost(int count, decimal price){if (price <= Decimal.Zero){throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero");}return count * price;} } 復制代碼有了這些前置條件,客戶端代碼就必須在調用方法錢確保它們傳遞的參數值要處于有效范圍內。當然,所有在前置條件中檢查的狀態必須是公開可訪問的。私有狀態不應該是前置條件檢查的目標,只有方法參數和類的公共屬性才應該有前置條件。
后置條件
后置條件會在方法退出時檢測一個對象是否處于一個無效的狀態。只要方法內改動了狀態,就用可能因為方法邏輯錯誤導致狀態無效。
方法的尾部臨界子句是一個后置條件,它能確保返回值處于有效范圍內。該方法的簽名無法保證返回值必須大于零,要達到這個目的,必須通過客戶端履行方法的契約來保證。
public class ShippingStrategy {public decimal CalculateShippingCost(int count, decimal price){if (price <= Decimal.Zero){throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero");}decimal cost = count * price;if (cost <= Decimal.Zero){throw new ArgumentOutOfRangeException("cost", "cost must be positive and non-zero");}return cost;} } 復制代碼數據不變式
數據不變式(data invariant)是一個在對象生命周期內始終保持為真的一個謂詞;該謂詞條件在對象構造后一直超出其作用范圍前的這段時間都為真。
數據不變式都是與期望的對象內部狀態有關,例如稅率為正值且不為零。在構造函數中設置稅率,只需要在構造函數中增加一個防衛子句就可以防止將其設置為無效值。
public class ShippingStrategy {protected decimal flatRate;public ShippingStrategy(decimal flatRate){if(flatRate <= Decimal.Zero){throw new ArgumentOutOfRangeException("flatRate", "flatRate must be positive and non-zero");}this.flatRate = flatRate;} } 復制代碼因為flatRate是一個受保護的成員變量,所以客戶端只能通過構造函數來設置它。如果傳入構造函數的值是有效的,就保證了ShippingStrategy對象在整個生命周期內的flatRate值都是有效的,因為客戶沒有地方可以修改它。但是,如果把flatRate定義為公共并且可設置的屬性,為了保證數據不變式,就必須將防衛子句布置到屬性設置器內。
public class ShippingStrategy {private decimal flatRate;public decimal FlatRate{get{return flatRate;}set{if (value <= Decimal.Zero){throw new ArgumentOutOfRangeException("flatRate", "flatRate must be positive and non-zero");}flatRate = value;}}public ShippingStrategy(decimal flatRate){this.FlatRate = flatRate;} } 復制代碼Liskov契約規則
在適當的時候,子類被允許重寫父類的方法實現,此時才有機會修改其中的契約。Liskov替換原則明確規定一些變更是被禁止的,因為它們會導致原來使用超類實例的客戶端代碼在切換至子類時必須要做更改。
1.子類型不能加強前置條件
當子類重寫包含前置條件的超類方法時,絕不應該加強現有的前置條件,這樣做會影響到那些已經假設超類為所有方法定義了最嚴格的前置條件契約的客戶端代碼。
public class WorldWideShippingStrategy : ShippingStrategy {public override decimal CalculateShippingCost(int count, decimal price){if (price <= Decimal.Zero){throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero");}if (count <= 0){throw new ArgumentOutOfRangeException("count", "count must be positive and non-zero");}return count * price;} } 復制代碼2.子類型不能削弱后置條件
與前置條件相反,不能削弱后置條件。因為已有的客戶端代碼在原有的超類切換至新的子類時很可能會出錯。
原有的方法后置條件是方法的返回值必須大于零,映射到現實場景就是購物金額不能為負數。
public class WorldWideShippingStrategy : ShippingStrategy {public override decimal CalculateShippingCost(int count, decimal price){if (price <= Decimal.Zero){throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero");}decimal cost = count * price;return cost;} } 復制代碼3.子類型必須保持超類型中的數據不變式
在創建新的子類時,它必須繼續遵守基類中的所有數據不變式。這里是很容易出問題的,因為子類有很多機會來改變基類中的私有數據。
public class ShippingStrategy {public ShippingStrategy(decimal flatRate){if (flatRate < Decimal.Zero){throw new ArgumentOutOfRangeException("flatRate", "flatRate must be positive and non-zero");}this.flatRate = flatRate;}protected decimal flatRate; }public class WorldWideShippingStrategy : ShippingStrategy {public WorldWideShippingStrategy(decimal flatRate) : base(flatRate){}public decimal FlatRate{get{return base.flatRate;}set{base.flatRate = value;}} } 復制代碼一種普遍的模式是,私有的字段有對應的受保護的或者公共的屬性,屬性的設置器中包含的防衛子句用來保護屬性相關的數據不變式。更好的方式是,在基類中控制字段的可見性并只允許引入防衛子句的屬性設置器訪問該字段,將來所有的子類都不再需要防衛子句檢查。
public class ShippingStrategy {public ShippingStrategy(decimal flatRate){this.FlatRate = flatRate;}private decimal flatRate;protected decimal FlatRate{get{return flatRate;}set{if (value < Decimal.Zero){throw new ArgumentOutOfRangeException("flatRate", "flatRate must be positive and non-zero");}flatRate = value;}} }public class WorldWideShippingStrategy : ShippingStrategy {public WorldWideShippingStrategy(decimal flatRate) :base(flatRate){}public new decimal FlatRate{get{return base.FlatRate;}set{base.FlatRate = value;}} } 復制代碼協變和逆變
Liskov替換原則的剩余原則都與協變和逆變相關。首先要明確變體(variance)這個概念,變體這個術語主要應用于復雜層次類型結構中以定義子類型的期望類型,有點類似于多態。在C#語言中,變體的實現有協變和逆變兩種。
協變
下圖展示了一個非常小的類層次結構,包含了基(超)類Supertype和子類Subtype。
多態是一種子類型被看做基類型實例的能力。任何能夠接受Supertype類型實例的方法也可以接受Subtype類型實例,客戶端不需要做類型轉換,也不需要知道任何子類相關的信息。
如果我們引入一個通過泛型參數使用Supertype和Subtype的類型時,就進入了變體(variance)的主題。因為有了協變,一樣可以用到多態這個強大的特性。當有方法需要ICovariant的實例時,完全可以使用ICovariant的實例替代之。
舉一個從倉儲庫中獲取對象的例子幫助理解:
public class Entity {public Guid ID { get; set; }public string Name { get; set; } }public class User:Entity {public string Email { get; set; }public DateTime DateOfBirth { get; set; } } 復制代碼因為User類和Entity類之間是繼承關系,所以我們也想在倉儲實現上存在繼承層次結構,通過重寫基類方法返回不同具體類型對象。
public class EntityRepository {public virtual Entity GetByID(Guid ID){return new Entity();} }public class UserRepository : EntityRepository {public override User GetByID(Guid ID){return new User();} } 復制代碼結果就會發現編譯不通過。**因為不使用泛型類型,C#方法的返回類型就不是協變的。**換句話說,這種情況下(普通類)的繼承是不具備協變能力的。
有兩種方案可以解決此問題:
1.可以將UserRepository類的GetByID方法的返回類型修改回Entity類型,然后在該方法返回的地方應用多態將Entity類型的實例裝換為User類型的實例。這種方式雖然客戶解決問題,但是對于客戶端并不友好,因為客戶端必須自己做實例類型轉換。
public class UserRepository : EntityRepository {public override Entity GetByID(Guid ID){return new User();} } 復制代碼2.可以把EntityRepository重新定義為一個需要泛型的類型,把Entity類型作為泛型參數傳入。這個泛型參數是可以協變的,UserRepository子類可以為User類指定超類型。
public interface IEntityRepository<out T> where T:Entity {T GetByID(Guid ID); }public class EntityRepository : IEntityRepository<Entity> {public Entity GetByID(Guid ID){return new Entity();} }public class UserRepository : IEntityRepository<User> {public User GetByID(Guid ID){return new User();} } 復制代碼新的UserRepository類的客戶端無需再做向下的類型轉換,因為直接得到就是User類型對象,而不是Entity類型對象。EntityRepository和UserRepository兩個類的父子繼承關系也得以保留。
逆變
協變是與方法返回類型的處理有關,而逆變是與方法參數類型的處理有關。
如圖所示,泛型參數由關鍵字in標記,表示它是可逆變的。這表明層析結構已經被顛倒了:IContravariant成為了超類,IContravariant則變成了子類。
public interface IEqualityComparer<in T> where T:Entity{bool Equals(T left, T right);}public class EntityEqualityComparer : IEqualityComparer<Entity>{public bool Equals(Entity left, Entity right){return left.ID == right.ID;}} 復制代碼IEqualityComparer<User> userComparer = new EntityEqualityComparer(); User user1 = new User(); User user2 = new User(); userComparer.Equals(user1, user2); 復制代碼如果沒有逆變(接口定義中泛型參數前的in 關鍵字),編譯時會直接報錯。
錯誤信息告訴我們,無法將EntityEqualityComparer轉換為IEqualityComparer類型。直覺就是這樣,因為Entity是基類,User是子類型。而如果IEqualityComparer支持逆變,現有的繼承層次結構會被顛倒。此時可以向需要具體類型參數的地方傳入更通用的類型。
不變性
除了逆變和協變的行為外,類型本身具有不變性。這里的不變性是指“不會生成變體”。既不可協變也不可逆變,必定是個非變體。具體到實現層面,定義中沒有對in和out關鍵字的引用,這二者分別用來指定逆變和協變。C#語言的方法參數類型和返回類型都是不可變的,只有在設計泛型時才能將類型定義為可協變的或可逆變的。
Liskov類型系統規則
-
子類型的方法參數必須是支持逆變的
-
子類型的返回類型必須是支持協變的
-
子類型不能引發不屬于已有異常層次結構中的新異常
異常機制的主旨就是將錯誤的匯報和處理環節分隔開。捕獲異常后不做任何處理或只捕獲最通用的Exception基類都是不可取的,二者結合就更糟糕了。從SystemException派生出來的異常基本都是根本無法處理和恢復的情況。好的做法總是從ApplicationException類派生自己的異常。
最后
Liskov替換原則是SOLID原則中最復雜的一個。需要理解契約和變體的概念才可以應用Liskov替換原則編寫具有更高自適應能力的代碼。**理想情況下,不論運行時使用的是哪個具體的子類型,客戶端都可以只引用一個基類或接口而無需擔心行為變化。**任何對Liskov替換原則定義規則的違背都應該被看作技術債務,應該盡早的償還掉這些技術債務,否則后患無窮。
參考
《C#敏捷開發實踐》
作者:CoderFocus 微信公眾號:總結
以上是生活随笔為你收集整理的编码最佳实践——Liskov替换原则的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: WPF PRISM开发入门一( 初始化P
- 下一篇: 采用简单的对比度和亮度来去除水印