编码最佳实践——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
微信公众号:

相关文章
相关标签/搜索