編碼最佳實踐——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
舉一個從倉儲庫中獲取物件的例子幫助理解:
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
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
不變性
除了逆變和協變的行為外,型別本身具有不變性。這裡的不變性是指“不會生成變體”。既不可協變也不可逆變,必定是個非變體。具體到實現層面,定義中沒有對in和out關鍵字的引用,這二者分別用來指定逆變和協變。C#語言的方法引數型別和返回型別都是不可變的,只有在設計泛型時才能將型別定義為可協變的或可逆變的。
Liskov型別系統規則
子型別的方法引數必須是支援逆變的
子型別的返回型別必須是支援協變的
子型別不能引發不屬於已有異常層次結構中的新異常
異常機制的主旨就是將錯誤的彙報和處理環節分隔開。捕獲異常後不做任何處理或只捕獲最通用的Exception基類都是不可取的,二者結合就更糟糕了。從SystemException派生出來的異常基本都是根本無法處理和恢復的情況。好的做法總是從ApplicationException類派生自己的異常。
最後
Liskov替換原則是SOLID原則中最複雜的一個。需要理解契約和變體的概念才可以應用Liskov替換原則編寫具有更高自適應能力的程式碼。理想情況下,不論執行時使用的是哪個具體的子型別,客戶端都可以只引用一個基類或介面而無需擔心行為變化。任何對Liskov替換原則定義規則的違背都應該被看作技術債務,應該儘早的償還掉這些技術債務,否則後患無窮。
參考
《C#敏捷開發實踐》
微信公眾號:
宣告:本文為博主學習感悟總結,水平有限,如果不當,歡迎指正。如果您認為還不錯,不妨點選一下下方的【推薦】按鈕,謝謝支援。轉載與引用請註明作者及出處。