1 定义
子类型(subtype)必须能够替换掉他们的基类型(base type)。Barbara Liskov首次写下这个原则是在1988年。她说道:
这里需要如下替换性质:若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P行为功能不变,则S是T的子类型。
2 问题&解决方案
对LSP的违反常常会导致对OCP的违反。遇到这种情况通常的解决方案是在运行时对类型进行鉴别,然后强制转换为子类型从而实现对方法的调用。
2.1 一个违反LSP的简单例子
代码清单
- Point.java
- Shape.java
- Circle.java
- Square.java
- LspDemo1.java
1 Point.java
请参考下文2.1章节代码清单第二个类的编写:
https://blog.csdn.net/weixin_37624828/article/details/111028394
2 Shape.java
public class Shape {
enum ShapeType {
/**
* square
*/
square,
/**
* circle
*/
circle
}
Shape.ShapeType itsType = null;
Shape(Shape.ShapeType t) {
this.itsType = t;
}
}
3 Circle.java
public class Circle extends Shape {
double itsRadius;
Point itsCenter;
Circle(double itsRadius, Point itsCenter) {
super(ShapeType.circle);
this.itsRadius = itsRadius;
this.itsCenter = itsCenter;
}
public void draw() {
System.out.println("绘制圆形");
System.out.println(toString());
}
@Override
public String toString() {
return "Circle{" +
"itsRadius=" + itsRadius +
", itsCenter=" + itsCenter +
", itsType=" + itsType +
'}';
}
}
4 Square.java
public class Square extends Shape {
double itsSide;
Point itsToLeft;
public Square(double itsSide, Point itsToLeft) {
super(ShapeType.square);
this.itsSide = itsSide;
this.itsToLeft = itsToLeft;
}
public void draw(){
System.out.println("绘制方形");
System.out.println(toString());
}
@Override
public String toString() {
return "Square{" +
"itsSide=" + itsSide +
", itsToLeft=" + itsToLeft +
", itsType=" + itsType +
'}';
}
}
5 LspDemo1.java
public class LspDemo1 {
public static void main(String[] args) {
Shape shape1 = new Circle(1, new Point(1, 2));
Shape shape2 = new Square(1, new Point(2, 2));
drawShape(shape1);
drawShape(shape2);
}
/**
* @param shape 图形对象
*/
private static void drawShape(Shape shape) {
if (shape.itsType == Shape.ShapeType.circle) {
((Circle) shape).draw();
} else if (shape.itsType == Shape.ShapeType.square) {
((Square) shape).draw();
}
}
}
促使开发人员编写上述代码的动机可能是因为让程序多态的开销很大,大到让人无法接受,但是这种认知是不正确的。Square和Circle在派生时没有实现对Shape类中的draw方法进行重写。问题在于Shape类中没有draw方法的定义,所以子类型无法对父类进行替换。
解决方案:在Shape类中定义draw方法,子类重写该方法即可遵循LSP原则。
2.2 一个更微妙的违反LSP的例子
这里有一个矩形类,现需要定义一个正方形类,因为正方形是矩形,所以自然会想到使用矩形类来派生正方形类,又根据常识,正方形的类宽高是相等的,所以有可能对设置宽和高的方法加入一些常识逻辑,当针对矩形类编写的方法validArea时,因为重写了矩形的设置宽高的方法,会引发一个更微妙的错误,具体例子参考如下代码。
代码清单:
- Rectangle.java
- Square.java
- LspDemo2.java
1 Rectangle.java
public class Rectangle {
private Point itsTopLeft;
private double itsWidth;
private double itsHeight;
Rectangle(Point itsTopLeft){
this.itsTopLeft = itsTopLeft;
}
public Point getItsTopLeft() {
return itsTopLeft;
}
public void setItsTopLeft(Point itsTopLeft) {
this.itsTopLeft = itsTopLeft;
}
public double getItsWidth() {
return itsWidth;
}
public void setItsWidth(double itsWidth) {
this.itsWidth = itsWidth;
}
public double getItsHeight() {
return itsHeight;
}
public void setItsHeight(double itsHeight) {
this.itsHeight = itsHeight;
}
public double getArea(){
return itsWidth * itsHeight;
}
}
2 Square.java
public class Square extends Rectangle {
Square(Point itsTopLeft) {
super(itsTopLeft);
}
@Override
public void setItsWidth(double itsWidth) {
super.setItsWidth(itsWidth);
super.setItsHeight(itsWidth);
}
@Override
public void setItsHeight(double itsHeight) {
super.setItsHeight(itsHeight);
super.setItsWidth(itsHeight);
}
}
3 LspDemo2.java
public class LspDemo2 {
public static void main(String[] args) {
Square square = new Square(new Point(1,1));
validArea(square);
}
/**
* 校验矩形的面积是否为20
* 输出结果为否
*
* @param rectangle 图形集合
*/
private static void validArea(Rectangle rectangle) {
rectangle.setItsHeight(5);
rectangle.setItsWidth(4);
System.out.println(rectangle.getArea() == 20 ? "是": "否");
}
}
问题就在于重写了设置宽高的方法,引入了先验逻辑,违反了Rectangle的不变性。具体如图所示:
2.3 解决方案
LSP让我们得出了一个非常重要的结论:一个模型,如果孤立地看,并不具有真正意义上的有效性。模型的有效性只能通过它的客户程序来表现。在考虑一个特定设计是否恰当时,不能完全孤立地看这个解决方案。必须要根据该设计地使用者所做的合理假设来审视它。解决这个问题通常最好的方法是只预测那些最明显的对于LSP的违反情况而推迟所有其他的预测,直到出现相关的脆弱性的臭味时,才会处理它们。
那究竟为什么会出现这个问题呢?是因为派生类和父类之间存在IS-A的关系,即子类是父类。有的方法的设计考虑对象是父类对象,子类不是真正的父类,导致了程序发生了上述微妙的错误。如何解决上述问题呢?有以下几种完美或者不完美的解决方法可供参考。
2.3.1 契约设计
使用DBC,类的编写者显式地规定针对该类地契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明的前置条件(preconditions)和后置条件(postconditions)来指定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。
某些语言,比如Eiffel,对前置条件和后置条件有直接的支持。你只需声明它们,运行时系统会去检验它们。C++和Java都没有此项特性。在这些语言中,我们必须自己考虑每个方法的前置条件和后置条件,并确保没有违反Meyer规则。此外,为每个方法都注明它们的前置条件和后置条件是非常有帮助的。
2.3.2 提取公共方法代替继承关系
考虑一个问题,尽管上述矩形和正方形很像,但是它们在getArea方法上真的是“父子关系”吗?如果将两个类中地公有方法提取出来,让这两个类均派生于该类,使之成为“兄弟关系”就可以完美的解决这个问题。
代码清单:
- Pet.java
- Cat.java
- Dog.java
- LspDemo3.java
1 Pet.java
public class Pet {
protected String type;
public Pet() {
this.type = "宠物";
}
void run(){
System.out.println("我是"+ type + ", 跑呀、跑呀、跑呀");
}
void eat(){
System.out.println("不知道吃什么");
}
void doSomething(){
System.out.println("做很多事情");
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}
2 Cat.java
public class Cat extends Pet {
public Cat() {
this.type = "猫";
}
@Override
void eat() {
System.out.println("我是" + type + ", 吃猫粮");
}
@Override
void doSomething() {
System.out.println("失效");;
}
}
3 Dog.java
public class Dog extends Pet {
public Dog() {
this.type = "狗";
}
@Override
public void eat() {
System.out.println("我是" + type + ", 吃狗粮");
}
}
4 LspDemo3.java
public class LspDemo3 {
public static void main(String[] args) {
List<Pet> pets = new ArrayList<>();
pets.add(new Cat());
pets.add(new Dog());
pets.add(new Pet());
petDaily(pets);
}
private static void petDaily(List<Pet> pets){
for(Pet c : pets){
c.run();
c.eat();
}
}
}
注意: 派生类中的退化函数有可能导致基于基类编写的函数失效
在子类里添加退化函数
public class LspDemo4 {
public static void main(String[] args) {
List<Pet> petList = new ArrayList<>();
petList.add(new Cat());
petList.add(new Pet());
// 退化函数
petDoSomething(petList);
}
private static void petDoSomething(List<Pet> pets){
for(Pet c : pets){
c.doSomething();
}
}
}
3 结论
OCP是OOD中很多说法的核心。如果这个原则应用有效,应用程序就会有更多的可维护性、可重用性以及健壮性。LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的东西。因此,如果没有显式地强制基类类型的契约,那么代码就必须良好地并且明显地表达出这一点。
在设计种需要考虑基类和派生类是否真的是“父子关系”,可能因为它们很像,但有可能它们之间是“兄弟关系”。