里氏替换原则

First Post:

Last Update:

里氏替换原则

一个软件实体如果使用的是一个父类的话,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别。

也就是说,在软件里面,把父类都替换成他的子类,程序的行为没有变化。

更加简单地:父类引用可以装载子类实例。

当子类可以替换掉父类,软件单位的功能不受到影响时,父类才能被真正被复用,而子类也能在父类的基础上面增加新的行为。

由于子类型的可替换性才使得父类的模块在无需修改的情况下可以拓展。

里氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。里氏替换原是继承复用的基础,它反映了基类与子类之间的关系,是对开闭原则的补充,是对实现抽象化的具体步骤的规范。

里氏替换原则的作用

里氏替换原则的主要作用如下。

  1. 里氏替换原则是实现开闭原则的重要方式之一。
  2. 它克服了继承中重写父类造成的可复用性变差的缺点。
  3. 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
  4. 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。

里氏替换原则的实现方法

里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

根据上述理解,对里氏替换原则的定义可以总结如下:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  • 子类中可以增加自己特有的方法
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
  • 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

通过重写父类的方法来完成新的功能写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。

如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

关于里氏替换原则的例子,最有名的是“正方形不是长方形”。当然,生活中也有很多类似的例子,例如,企鹅、鸵鸟和几维鸟从生物学的角度来划分,它们属于鸟类;但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。同样,由于“气球鱼”不会游泳,所以不能定义成“鱼”的子类;“玩具炮”炸不了敌人,所以不能定义成“炮”的子类等。

什么是替换?

替换的前提是面向对象语言所支持的多态特性,同一个行为具有多个不同表现形式或形态的能力。以JDK的集合框架为例,List接口的定义为有序集合,List接口有多个派生类,比如大家耳熟能详的ArrayList, LinkedList。那当某个方法参数或变量是List接口类型时,既可以是ArrayList的实现, 也可以是LinkedList的实现,这就是替换。

举个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public String getFirst(List<String> values) {
return values.get(0);
}
//对于getFirst方法,接受一个List接口类型的参数,
//那既可以传递一个ArrayList类型的参数:
List<String> values = new ArrayList<>();
values.add("a");
values.add("b");
String firstValue = getFirst(values);

//也可以接收一个LinkedList参数:
List<String> values = new LinkedList<>();
values.add("a");
values.add("b");
String firstValue = getFirst(values);

什么是与期望行为一致的替换?

在不了解派生类的情况下,仅通过接口或基类的方法,即可清楚的知道方法的行为,而不管哪种派生类的实现,都与接口或基类方法的期望行为一致。或者说接口或基类的方法是一种契约,使用方按照这个契约来使用,派生类也按照这个契约来实现。这就是与期望行为一致的替换。继续以上节中的例子说明:

1
2
3
public String getFirst(List<String> values) {
    return values.get(0);
}

对于getFirst方法,接收List类型的参数,而List类型的get方法返回特定位置的元素,对于本例即为第一个元素。这些是不依赖派生类的知识的。所以对于上节中的示例,不管是ArrayList类型的实现,还是LinkedList的实现,getFirst方法最终的返回值是一样的。这就是与期望行为一致的替换。

违反里氏替换原则的场景:

  1. 子类种抛出了基类中未定义的异常。

  2. 子类改变了基类定义的方法的语义(引入了副作用)。

违反里氏替换原则的危害

反直觉。期望所有的子类行为均一致,但若非一致则需要文档记录。

不可读。如果子类行为不一致,可能需要不同的逻辑分支来适配不同的行为,徒增代码复杂度。

不可用。可能出错的地方终将出错。

如何避免违反里氏替换原则

谈到如何避免,当然要基于里氏替换原则的定义,与期望行为一致的替换。

  • 从行为出发来设计。在做抽象或设计时,不只是要从模型概念出发,还要从行为出发,比如一个经典的例子,正方形和长方形,从现实的概念中正方形是一个长方形,但是在计算其面积的行为上是不一致的。
  • 基于契约设计。这个契约即是基类方法签名、功能描述、参数类型、返回值等。在派生类的实现时,时刻保持派生类与基类的契约不被破坏。