初探里氏替换

1. 内容

『Liskov Substitution Principle』概念来自 1994 年论文 A Behavioral Notion Of Subtyping

If S is a declared subtype of T, objects of type S should behave as objects of type T are expected to behave, if they are treated as objects of type T.
如果 S 是 T 的子类型,对于 S 类型的任意对象,如果将他们看作是 T 类型的对象,则对象的行为也理应与期望的行为一致。

子类可以扩展父类的功能,但不能改变父类原有的功能。或者说,接口的实现必须满足『调用者对接口的所有期望』而不是仅仅是『满足接口的签名』

2. 作用

主要作用如下:

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

3. 要点

3.1 什么是替换?

假设有一个方法可以获取列表的第一个值,方法签名:

1
String getFirst(List<String> list);

从上面的签名中可以知道它做的事情是从 String 列表中获取了第一个值,并且这个返回值是一个 String 对象。我们在实现时需要根据入参的不同做不同的处理。

3.2 多态是否违背 LSP?

不违背。LSP 有两种含义:

  1. 继承是为了代码复用。此时子类与复用公用同一实现,满足『子类对象能够替换父类对象,而程序逻辑不变』。
  2. 继承是为了多态。多态的前提是子类覆盖并重定义父类的方法,此时父类应该是接口类或抽象类,所以不存在父类实例化的情况。

4. 场景

4.1 例一

假设当前实现一个自定义 List:

1
2
3
4
5
6
class CustomList<T> extends ArrayList<T> {
@Override
public T get(int index) {
throw new UnsupportedOperationException();
}
}

同时 Listget 如下定义:

1
2
3
4
5
6
7
8
9
/**
* Returns the element at the specified position in this list.
*
* @param index index of the element to return
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* (<tt>index &lt; 0 || index &gt;= size()</tt>)
*/
E get(int index);

由于 get 只声明了会抛出 IndexOutOfBoundsException 异常,因此这个自定义的 List 不满足 LSP。

4.2 例二

1
2
3
4
5
6
7
8
9
class CustomList<T> extends ArrayList<T> {
@Override
public T get(int index) {
if (index >= size()){
return null;
}
return get(index);
}
}

这个例子中,如果列表查出范围返回了 null,而不是描述的异常,因此也不满足 LSP。

4.3 例三

鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期:

几维鸟类重写了鸟类的 setSpeed(double speed) 方法,这违背了 LSP,更好的做法是取消几维鸟原来的继承关系,定义鸟和几维鸟的更一般的父类,如动物类,它们都有奔跑的能力。几维鸟的飞行速度虽然为 0,但奔跑速度不为 0:

5. 最佳实践

  1. 基于行为设计。比如正方形和长方形计算面积,正方形也是一种长方形,但是面积的计算方式不一致。
  2. 基于接口设计。这个接口包括接口签名、功能描述、参数类型、返回值、异常,在派生时要时刻保持与接口一致。
  3. 子类实现抽象方法,但是尽量不要覆盖父类的非抽象方法
  4. 如果需要覆盖父类的非抽象方法,入参需要比父类宽松,出参要比父类严格。

6. 系统优化

讨论