1. 引入
SOLID设计原则的之中的开闭原则(Open/Closed Principle, OCP)主要是基于抽象和多态实现的。而实现抽象和多态的关键机制之一就是继承。
如何设计继承体系才能使得抽象和多态正常的发挥作用,并且不违背开闭原则呢? 这是里氏替换原则(Liskov Substitution Principle, LSP)要解决的问题。
2. 定义
里氏替换原则的定义如下:
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
即: 方法(函数)中使用对基类对象引用的地方,必须可以替换成其子类对象,而方法并不知道发生了这一替换。
自己关于without knowing it
的理解:
- 一方面,表示编写该方法时应该面向超类/接口编程,并不应该面向实现编程,参考下一小节的例①。
- 另一方面,在替换之后,应该确保方法的功能/运行结果符合我们自己约定的预期。
里氏替换原则并没有要求子类不能重写父类方法,有些博客中这样说应该是错误的,参考:
https://stackoverflow.com/questions/1735137/liskov-substitution-principle-no-overriding-virtual-methods
上面的定义有些难以理解,下面结合例子进一步说明:
3. 实例
① 违反LSP的例子1
违反里氏替换原则的一个典型的例子就是试图在运行时期判断对象的实际类型,例如下面的代码中App
类的drawShape()
方法:
class Point {
private double x;
private double y;
}
class Shape { }
class Circle extends Shape {
private Point center; // 圆心
private double radius; // 半径
public Circle(Point center, double radius) {
this.center = center;
this.radius = radius;
}
public void draw() {
// draw circle
}
}
class Square extends Shape {
private Point topLeft; // 左上角点
private double sideLen; // 边长
public Square(Point topLeft, double sideLen) {
this.topLeft = topLeft;
this.sideLen = sideLen;
}
public void draw() {
// draw square
}
}
public class App {
public void drawShape(Shape shape) {
if (shape instanceof Circle)
((Circle) shape).draw();
else if (shape instanceof Square)
((Square) shape).draw();
}
}
你或许会觉得,drawShape()
方法中的shape
用子类Circle
或Square
的对象替换不是完全可以吗,程序还是正常运行? 为什么违背里氏替换原则了?
注意前面提到定义中的without knowing it
,drawShape()
的参数替换为子类Circle
或Square
的对象后确实能够正常工作,但是它是建立在了解子类的基础之上的,也就是我们提前知道了drawShape()
会接受子类对象, 显然违背了里氏替换原则。
进一步理解,里氏替换原则的目的是为了规范继承体系,使得多态能够正常工作,不违背开闭原则。而上面的代码显然违背了开闭原则,因为每增加一个新的Shape
的子类,就要修改drawShape()
的代码,增加一个新的else if
语句来判断新增的类型。
下面是对上面代码的改进,使之符合里氏替换原则:
class Point {
private double x;
private double y;
}
abstract class Shape {
public abstract void draw();
}
class Circle extends Shape {
private Point center; // 圆心
private double radius; // 半径
public Circle(Point center, double radius) {
this.center = center;
this.radius = radius;
}
@Override
public void draw() {
// draw circle
}
}
class Square extends Shape {
private Point topLeft; // 左上角点
private double sideLen; // 边长
public Square(Point topLeft, double sideLen) {
this.topLeft = topLeft;
this.sideLen = sideLen;
}
@Override
public void draw() {
// draw square
}
}
public class App {
public void drawShape(Shape shape) {
shape.draw();
}
}
② 违反LSP的例子2
正方形是特殊的长方形,它们之间存在IS-A
的关系。那么我们是不是可以让正方形继承长方形呢? 假设可以,看如下的代码:
class Rectangle {
private int width;
private int height;
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
/**
* 确保长和宽同时被设置, 避免违反正方形的定义,下同
* @param width 宽度
*/
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}
public class App {
public void foo(Rectangle rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
assert rectangle.getArea() == 20;
}
}
在App
类的foo()
方法中,我们基于长方形的性质(即我们自定义的预期): 面积 = 长 × 宽
断言了rectangle.getArea() == 20
。
而对foo()
方法,如果传入一个Square
类实例,断言就会报错。这显然违背了我们对foo()
方法的预期。
因此在foo()
方法中,子类Square
的对象不能替换超类Rectangle
的对象,说明这个继承关系违背了里氏替换原则。
4. 契约设计和里氏替换原则
上面的第二个例子中,我们对foo()
方法定义了一个预期(面积 = 长 × 宽
)。这个预期是我们脑子里的约定,对于上面的foo()
方法我们也可以有别的预期。
那么有没有一种方法,能将我们的预期写到代码里面,从而约束程序员按照这个预期来编程,防止违背里氏替换原则呢? 当然有了,可以借助**契约设计(Design by Contract)**的方法来实现。
契约设计涉及以下几个术语:
- 前置条件( precondition ): 一个方法要想执行,它的前置条件必须满足。 前置条件是指方法执行之前方法的参数、用到的变量/对象的状态所满足的特定约束。
- 后置条件 (postcondition): 一个方法执行完毕,它的后置条件必须满足。 后置条件是指方法执行之后方法的返回值、用到的变量/对象的状态所满足的特定约束。
- 不变条件 (invariant): 一般是指在public类型方法执行之前和之后都保持不变的条件,对于一个类的所有方法都是这个条件。
关于这三个属于,参考:
我们可以在代码中指定precondition, postcondition, invariant
来对代码进行约束。
用契约设计的方法来看,要使得设计遵循里氏替换原则应该满足:
- 子类的前置条件不能强于父类的前置条件(体现在参数上是参数类型必须与父类一样或者是父类参数类型的超类,但是记住前置条件不仅仅包括参数)
- 子类的后置条件不能弱于父类的后置条件 (体现在参数上是参数类型必须与父类一样或者是父类参数类型的子类,但是记住后置条件不仅仅包括参数)
- 子类的不变条件不能弱于父类的不变条件
(个人理解)并且方法的实现不能和上面3个条件产生冲突
例子
例子中的require代表前置条件,ensures代表后置条件
例1.
下面满足上面三个条件(不变条件更严格,前置条件放宽,后置条件更严格),是符合里氏替换原则的设计
class Car extends Vehicle {
int fuel;
boolean engineOn;
//@ invariant fuel >= 0;
//@ requires fuel > 0 && !engineOn;
//@ ensures engineOn;
void start() { …}
void accelerate() { …}
//@ requires speed != 0;
//@ ensures speed < old(speed)
void brake() { …}
}
class Hybrid extends Car {
int charge;
//@ invariant fuel >= 0 && charge >= 0;
//@ requires (charge > 0 || fuel > 0) &&!engineOn;
//@ ensures engineOn;
void start() { …}
void accelerate() { …}
//@ requires speed != 0;
//@ ensures speed < \old(speed)
//@ ensures charge > \old(charge)
void brake() { …}
}
例2.
下面例子中Rectangle
的setWidth()
方法破坏了Square
类的不变条件h == w
,因此不遵循里氏替换原则。
5. java编译器体现的里氏替换原则
Java编译器内置的一些规则遵循了里氏替换原则:
- 子类可以增加方法,但不能删除方法
- 子类必须实现抽象方法/接口中没有默认实现的方法
- 重写方法必须返回相同类型或是子类型
- 重写方法必须接受相同相同类型的参数
- 重写方法不能抛父类没抛的异常
6.总结
- 里氏替换原则和开闭原则的关系
- 里氏替换原则主要是为了规范继承体系
- 从契约设计的角度看里氏替换原则