初探里氏替换
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. 作用
主要作用如下:
- LSP 是实现『开闭原则』的重要方式之一。
- LSP 克服了继承中重写父类造成的可复用性变差的缺点。
- LSP 是动作正确性的保证,类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
- LSP 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
3. 要点
3.1 什么是替换?
假设有一个方法可以获取列表的第一个值,方法签名:
1 | String getFirst(List<String> list); |
从上面的签名中可以知道它做的事情是从 String
列表中获取了第一个值,并且这个返回值是一个 String
对象。我们在实现时需要根据入参的不同做不同的处理。
3.2 多态是否违背 LSP?
不违背。LSP 有两种含义:
- 继承是为了代码复用。此时子类与复用公用同一实现,满足『子类对象能够替换父类对象,而程序逻辑不变』。
- 继承是为了多态。多态的前提是子类覆盖并重定义父类的方法,此时父类应该是接口类或抽象类,所以不存在父类实例化的情况。
4. 场景
4.1 例一
假设当前实现一个自定义 List
:
1 | class CustomList<T> extends ArrayList<T> { |
同时 List
的 get
如下定义:
1 | /** |
由于 get
只声明了会抛出 IndexOutOfBoundsException
异常,因此这个自定义的 List
不满足 LSP。
4.2 例二
1 | class CustomList<T> extends ArrayList<T> { |
这个例子中,如果列表查出范围返回了 null
,而不是描述的异常,因此也不满足 LSP。
4.3 例三
鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期:
几维鸟类重写了鸟类的 setSpeed(double speed)
方法,这违背了 LSP,更好的做法是取消几维鸟原来的继承关系,定义鸟和几维鸟的更一般的父类,如动物类,它们都有奔跑的能力。几维鸟的飞行速度虽然为 0,但奔跑速度不为 0:
5. 最佳实践
- 基于行为设计。比如正方形和长方形计算面积,正方形也是一种长方形,但是面积的计算方式不一致。
- 基于接口设计。这个接口包括接口签名、功能描述、参数类型、返回值、异常,在派生时要时刻保持与接口一致。
- 子类实现抽象方法,但是尽量不要覆盖父类的非抽象方法。
- 如果需要覆盖父类的非抽象方法,入参需要比父类宽松,出参要比父类严格。
6. 系统优化
讨论。