編碼最佳實踐——Liskov替換原則

mark

Liskov替換原則(Liskov Substitution Principle)是一組用於建立繼承層次結構的指導原則。按照Liskov替換原則建立的繼承層次結構中,客戶端代碼可以放心的使用它的任意類或子類而不擔憂所指望的行爲。編程

Liskov替換原則定義

若是S是T的子類型,那麼全部的T類型的對象均可以在不破壞程序的狀況下被S類型的對象替換。c#

  • 基類型:客戶端引用的類型(T)。子類型能夠重寫(或部分定製)客戶端所調用的基類的任意方法。
  • 子類型:繼承自基類型(T)的一組類(S)中的任意一個。客戶端不該該,也不須要知道它們實際調用哪一個具體的子類型。不管使用的是哪一個子類型實例,客戶端代碼所表現的行爲都是同樣的。

Liskov替換原則的規則

要應用Liskov替換原則就必須遵照兩類規則:微信

1.契約規則(與類的指望有關)ide

  • 子類型不能增強前置條件
  • 子類型不能削弱後置條件
  • 子類型必須保持超類型中的數據不變式

2.變體規則(與代碼中能被替換的類型有關)函數

  • 子類型的方法參數必須是支持逆變的
  • 子類型的返回類型必須是支持協變的
  • 子類型不能引起不屬於已有異常層次結構中的新異常

契約

咱們常常會說,要面向接口編程或面向契約編程。而後,除了表面上的方法簽名,接口所表達的只是一個不夠嚴謹的契約概念ui

做爲方法編寫者,要確保方法名稱能反應出它的真實目的,同時參數名稱要儘量使描述性的。this

public decimal CalculateShippingCost(int count,decimal price)
{
    return count * price;
}
複製代碼

然而,方法簽名並無包含方法的契約信息。好比price參數是decimal類型的,這就代表任何decimal類型的值都是有限的。可是price參數的意義是價格,顯然價格不能是負數。爲了作到這一點,要在方法內部實現一個前置條件。spa

前置條件

前置條件(precondition)是一個能保障方法穩定無錯運行的先決條件。全部方法在被調用錢都要求某些前置條件爲真。設計

引起異常是一種強制履行契約的高效方式:code

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.子類型不能增強前置條件

當子類重寫包含前置條件的超類方法時,毫不應該增強現有的前置條件,這樣作會影響到那些已經假設超類爲全部方法定義了最嚴格的前置條件契約的客戶端代碼

mark

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.子類型不能削弱後置條件

與前置條件相反,不能削弱後置條件。由於已有的客戶端代碼在原有的超類切換至新的子類時極可能會出錯。

原有的方法後置條件是方法的返回值必須大於零,映射到現實場景就是購物金額不能爲負數。

mark

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.子類型必須保持超類型中的數據不變式

在建立新的子類時,它必須繼續遵照基類中的全部數據不變式。這裏是很容易出問題的,由於子類有不少機會來改變基類中的私有數據。

mark

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;
        }
    }
}
複製代碼

一種廣泛的模式是,私有的字段有對應的受保護的或者公共的屬性,屬性的設置器中包含的防衛子句用來保護屬性相關的數據不變式。更好的方式是,在基類中控制字段的可見性並只容許引入防衛子句的屬性設置器訪問該字段,未來全部的子類都再也不須要防衛子句檢查

mark

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。

mark

多態是一種子類型被看作基類型實例的能力。任何可以接受Supertype類型實例的方法也能夠接受Subtype類型實例,客戶端不須要作類型轉換,也不須要知道任何子類相關的信息。

若是咱們引入一個經過泛型參數使用Supertype和Subtype的類型時,就進入了變體(variance)的主題。由於有了協變,同樣能夠用到多態這個強大的特性。當有方法須要ICovariant的實例時,徹底可使用ICovariant的實例替代之。

mark

舉一個從倉儲庫中獲取對象的例子幫助理解:

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();
    }
}
複製代碼

mark

結果就會發現編譯不經過。**由於不使用泛型類型,C#方法的返回類型就不是協變的。**換句話說,這種狀況下(普通類)的繼承是不具有協變能力的。

mark

mark

有兩種方案能夠解決此問題:

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兩個類的父子繼承關係也得以保留。

逆變

協變是與方法返回類型的處理有關,而逆變是與方法參數類型的處理有關。

mark

如圖所示,泛型參數由關鍵字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);
複製代碼

mark

若是沒有逆變(接口定義中泛型參數前的in 關鍵字),編譯時會直接報錯。

mark

錯誤信息告訴咱們,沒法將EntityEqualityComparer轉換爲IEqualityComparer類型。直覺就是這樣,由於Entity是基類,User是子類型。而若是IEqualityComparer支持逆變,現有的繼承層次結構會被顛倒。此時能夠向須要具體類型參數的地方傳入更通用的類型

不變性

除了逆變和協變的行爲外,類型自己具備不變性。這裏的不變性是指「不會生成變體」。既不可協變也不可逆變,一定是個非變體。具體到實現層面,定義中沒有對in和out關鍵字的引用,這兩者分別用來指定逆變和協變。C#語言的方法參數類型和返回類型都是不可變的,只有在設計泛型時才能將類型定義爲可協變的或可逆變的

Liskov類型系統規則

  • 子類型的方法參數必須是支持逆變的

  • 子類型的返回類型必須是支持協變的

  • 子類型不能引起不屬於已有異常層次結構中的新異常

    異常機制的主旨就是將錯誤的彙報和處理環節分隔開。捕獲異常後不作任何處理或只捕獲最通用的Exception基類都是不可取的,兩者結合就更糟糕了。從SystemException派生出來的異常基本都是根本沒法處理和恢復的狀況。好的作法老是從ApplicationException類派生本身的異常。

最後

Liskov替換原則是SOLID原則中最複雜的一個。須要理解契約和變體的概念才能夠應用Liskov替換原則編寫具備更高自適應能力的代碼。**理想狀況下,不論運行時使用的是哪一個具體的子類型,客戶端均可以只引用一個基類或接口而無需擔憂行爲變化。**任何對Liskov替換原則定義規則的違背都應該被看做技術債務,應該儘早的償還掉這些技術債務,不然後患無窮。

參考

《C#敏捷開發實踐》

做者: CoderFocus
微信公衆號:

相關文章
相關標籤/搜索