设计原则:里式替换原则(LSP)
系列文章
1 定义
里氏原则的英文是Liskov Substitution Principle,缩写就是LSP。其定义有两种
定义1:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
(如果S是T的子类型,则类型T的对象可以替换为类型S的对象,而不会破坏程序。)
定义2:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
(所有引用其父类对象方法的地方,都可以透明的使用其子类对象)
这两种定义方式其实都是一个意思,即:应用程序中任何父类对象出现的地方,我们都可以用其子类的对象来替换,并且可以保证原有程序的逻辑行为和正确性。
如何理解里氏替换与继承多态
很多人(包括我自己)乍一看,总觉得这个原则和继承多态的思想差不多。但其实里氏替换和继承多态有关系,但并不是一回事,我们可以通过一个例子来看一下
public class Cache {
public void set(String key,String value){
// 使用内存Cache
}
}
public class Redis extends Cache {
@Override
public void set(String key,String value){
// 使用Redis
}
}
public class Memcache extends Cache {
@Override
public void set(String key,String value){
// 使用Memcache
}
}
class CacheTest {
@Test
public void set() {
Cache cache = new Cache();
assertTrue(cache.set("testKey", "testValue"));
cache = new Redis();
assertTrue(cache.set("testKey", "testValue"));
cache = new Memcache();
assertTrue(cache.set("testKey", "testValue"));
}
}
我们定义了一个Cache类来实现程序中写缓存的逻辑,它有两个子类Redis和Memcache来实现不同的缓存工具,看到这个例子很多人可能会有疑问这不就是利用了继承和多态的思想吗?
不错,的确是这样的,而且在这个例子中两个子类的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
但如果这时我们需要对Redis子类方法中增加对Key长度的验证。
public class Redis extends Cache {
public void set(String key,String value){
// 使用Redis
if(key==null||key.length<10){
throw new IllegalArgumentException("key长度不能小于10");
}
}
}
class CacheTest {
@Test
public void set() {
Cache cache = new Cache();
assertTrue(cache.set("testKey", "testValue"));
cache = new Redis();
assertTrue(cache.set("testKey", "testValue"));
}
}
此时如果我们在使用父类对象的时候替换成子类对象,那set方法就会有异常抛出。程序的逻辑行为就发生了变化,虽然改造之后的代码仍然可以通过子类来替换父类 ,但是,从设计思路上来讲,Redis子类的设计是不符合里式替换原则的。
继承和多态是面向对象语言所提供的一种语法,一种代码实现的思路,而里式替换则是一种思想,是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
2 规则
其实里氏替换原则的核心就是“约定”,父类与子类的约定。里氏替换原则要求子类在进行设计的时候要遵守父类的一些行为约定。这里的行为约定包括:函数所要实现的功能,对输入、输出、异常的约定,甚至包括注释中一些特殊说明等。
2.1 子类方法不能违背父类方法对输入输出异常的约定
-
前置条件不能被加强
前置条件即输入参数是不能被加强的,就像上面Cache的示例,Redis子类对输入参数Key的要求进行了加强,此时在调用处替换父类对象为子类对象就可能引发异常。
也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
-
后置条件不能被削弱
后置条件即输出,假设我们的父类方法约定输出参数要大于0,调用父类方法的程序根据约定对输出参数进行了大于0的验证。而子类在实现的时候却输出了小于等于0的值。此时子类的涉及就违背了里氏替换原则
public void calculatePrice() { Strategy strategy= new Strategy(); BigDecimal price= strategy.getPrice(); if (price <= Decimal.Zero) { throw new ArgumentOutOfRangeException("price", "price must be positive and non-zero"); } // do something }
-
不能违背对异常的约定
在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
2.2 子类方法不能违背父类方法定义的功能
public class Product {
private BigDecimal amount;
private Calendar createTime;
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public Calendar getCreateTime() {
return createTime;
}
public void setCreateTime(Calendar createTime) {
this.createTime = createTime;
}
}
public class ProductSort extends Sort<Product> {
public void sortByAmount(List<Product> list) {
//根据时间进行排序
list.sort((h1, h2)->h1.getCreateTime().compareTo(h2.getCreateTime()));
}
}
父类中提供的 sortByAmount() 排序函数,是按照金额从小到大来进行排序的,而子类重写这个 sortByAmount() 排序函数之后,却是是按照创建日期来进行排序的。那子类的设计就违背里式替换原则。
实际上对于如何验证子类设计是否符合里氏替换原则其实有一个小技巧,那就是你可以使用父类的单测来运行子类的代码,如果不可以正常运行,那么你就要考虑一下自己的设计是否合理了!
2.3 子类必须完全实现父类的抽象方法
如果你设计的子类不能完全实现父类的抽象方法那么你的设计就不满足里式替换原则。
// 定义抽象类枪
public abstract class AbstractGun{
// 射击
public abstract void shoot();
// 杀人
public abstract void kill();
}
比如我们定义了一个抽象的枪类,可以射击,也可以杀人。无论是步枪还是手枪都可以射击和啥人,我们可以定义子类来继承这个父类
// 定义手枪,步枪,机枪
public class Handgun extends AbstractGun{
public void shoot(){
// 手枪射击
}
public void kill(){
// 手枪杀人
}
}
public class Rifle extends AbstractGun{
public void shoot(){
// 步枪射击
}
public void kill(){
// 步枪杀人
}
}
但是如果我们在这个继承体系内加入一个玩具枪,就会有问题了,因为玩具枪只能射击,不能杀人。但是我经常看到很多人写代码会有这种套路。
public class ToyGun extends AbstractGun{
public void shoot(){
// 玩具枪射击
}
public void kill(){
// 因为玩具枪不能杀人,就返回空,或者直接throw一个异常出去
throw new Exception("我是个玩具枪,惊不惊喜,意不意外,刺不刺激?");
}
}
这时,我们如果把使用父类对象的地方替换为子类对象,显然是会有问题的(士兵上战场结果发现自己拿的是个玩具)。
而这种情况不仅仅不满足里氏替换原则,也不满足接口隔离原则,对于这种场景可以通过接口隔离+委托的方式来解决。
3 小结
面向对象的编程思想中提供了继承和多态是我们可以很好的实现代码的复用性和可扩展性,但继承并非没有缺点,因为继承的本身就是具有侵入性的,如果使用不当就会大大增加代码的耦合性,而降低代码的灵活性,增加我们的维护成本,然而在实际使用过程中却往往会出现滥用继承的现象,而里式替换原则可以很好的帮助我们在继承关系中进行父子类的设计。
系列文章
关注下方公众号,回复“代码的艺术”,可免费获取重构、设计模式、代码整洁之道等提升代码质量等相关学习资料