编码最佳实践——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#敏捷开发实践》
编码最佳实践——Liskov替换原则的更多相关文章
- 敏捷软件开发:原则、模式与实践——第10章 LSP:Liskov替换原则
第10章 LSP:Liskov替换原则 Liskov替换原则:子类型(subtype)必须能够替换掉它们的基类型(base type). 10.1 违反LSP的情形 10.1.1 简单例子 对L ...
- Liskov替换原则(LSP)
OCP背后的主要机制是抽象和多态.在静态类型语言中,比如C++和Java,支持抽象和多态的关键机制之一是继承.正是使用了继承,才可以创建实现其基类中抽象方法的派生类.是什么设计规则在支配着这种特殊的继 ...
- 面向对象世界里转转七(Liskov替换原则)
前言:Liskov替换原则是关于继承机制的应用原则,是实现开放封闭原则的具体规范,违反了Liskov原则必然意味着违反了开放封闭原则.因此,有必要对面向对象的继承机制及其基本原则做以探索,来进一步了解 ...
- 10个精妙的Java编码最佳实践
这是一个比Josh Bloch的Effective Java规则更精妙的10条Java编码实践的列表.和Josh Bloch的列表容易学习并且关注日常情况相比,这个列表将包含涉及API/SPI设计中不 ...
- Windows Azure 安全最佳实践 - 第 7 部分:提示、工具和编码最佳实践
在撰写这一系列文章的过程中,我总结出了很多最佳实践.在这篇文章中,我介绍了在保护您的WindowsAzure应用程序时需要考虑的更多事项. 下面是一些工具和编码提示与最佳实践: · 在操作系统上运行 ...
- 你知道吗?10个精妙的 Java 编码最佳实践
这是一个比Josh Bloch的Effective Java规则更精妙的10条Java编码实践的列表.和Josh Bloch的列表容易学习并且关注日常情况相比,这个列表将包含涉及API/SPI设计中不 ...
- 软件设计----LisKov替换原则(LSP)
LisKov替换原则的定义:一个软件实体如果使用的是一个基类的话,一定适用于其子类,而且根本不能觉察出基类对象和子类对象的区别. 1)怎么理解上面的概念?就是我们程序设计的子类型能够完全替换父类型,而 ...
- 结合异步模型,再次总结Netty多线程编码最佳实践
更多技术分享可关注我 前言 本文重点总结Netty多线程的一些编码最佳实践和注意事项,并且顺便对Netty的线程调度模型,和异步模型做了一个汇总.原文:结合异步模型,再次总结Netty多线程编码最 ...
- 设计模式学习--面向对象的5条设计原则之Liskov替换原则--LSP
一.LSP简介(LSP--Liskov Substitution Principle): 定义:如果对于类型S的每一个对象o1,都有一个类型T的对象o2,使对于任意用类型T定义的程序P,将o2替换为o ...
随机推荐
- 国外青少年最喜爱的聊天 app,竟然是 Google Docs
简评: 这还真不是标题党,Google Docs 的协作中内置了实时聊天的功能,也可以进行 comments,颇有种现代「传纸条」的既视感.其实国内的石墨文档.腾讯文档等协作工具也有类似的效果,本文很 ...
- Python常用模块os & sys & shutil模块
OS模块 import os ''' os.getcwd() 获取当前工作目录,即当前python脚本工作的目录路径 os.chdir("dirname") 改变当前脚本工作目录: ...
- laravel5.5 __construct函数 无法使用session() 解决方法和实例
从网上查询资料得知,在laravel中使用__construct方法的时候,session还没开启,此时就需要用到中间件来解决. $this->middleware(function ($req ...
- 调用 Https WebService 使用程序自动生成代理类
1 商家提供的WebService接口: https://ws.nciic.org.cn/nciic_ws/services/NciicServices?wsdl 2 在浏览器里打开这个地址,会显示 ...
- Java 中三大构建工具:Ant、Maven和Gradle
Java世界中主要有三大构建工具:Ant.Maven和Gradle 目前:Ant已经销声匿迹.Maven也没落了,而Gradle的发展则如日中天. Maven的主要功能主要分为5点,分别是依赖管理系统 ...
- Atom相关资料整理
官网地址 https://atom.io/ Atom 中文社区 https://atom-china.org/ 常用插件 Emmet 这款插件是用来支持zend-coding,Emmet的前身是大名鼎 ...
- 【Java基本功】一文读懂String及其包装类的实现原理
String作为Java中最常用的引用类型,相对来说基本上都比较熟悉,无论在平时的编码过程中还是在笔试面试中,String都很受到青睐,然而,在使用String过程中,又有较多需要注意的细节之处. S ...
- ida 调试 android fork
在使用ida 调试android native代码时经常会碰见fork子进程的情况出现,而运行一个 android_server只能对一个进程进行调试或者attach,而ida 默认端口是23946, ...
- javascript中的iterable
遍历Array可以采用下标循环,遍历Map和Set就无法使用下标.为了统一集合类型,ES6标准引入了新的iterable类型,Array.Map和Set都属于iterable类型. 具有iterabl ...
- Linux的进程线程及调度
本文为宋宝华<Linux的进程.线程以及调度>学习笔记. 1 进程概念 1.1 进程与线程的定义 操作系统中的经典定义: 进程:资源分配单位. 线程:调度单位. 操作系统中用PCB(Pro ...