一、一句话理解
里氏替换原则(LSP) 的意思是:
如果你写了一段代码,是基于一个父类/接口 来操作的。那么,当你把这段代码中的父类对象,替换成它的任何一个子类对象时,程序的行为都不应该出问题(比如崩溃、报错、产生意外结果)。
换句话说,子类必须完全遵守父类定下的"契约"或"承诺" 。父类能做的事,子类必须都能做,而且不能做出格的事。
二、一个经典的"反面教材"(违反LSP的例子)
假设我们有一个表示矩形的类,和一个表示正方形的类。从数学上说,正方形"是一个"矩形,所以直觉会让我们用继承。
java
arduino
// 父类:矩形
class Rectangle {
protected int width;
protected 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;
}
}
// 子类:正方形 (数学上 is-a 关系,所以继承?)
class Square extends Rectangle {
// 正方形要求长宽永远相等,所以重写setter方法
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // 设置宽的同时,把高也改了
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height); // 设置高的同时,把宽也改了
}
}
现在,我们写一个客户端方法,它的逻辑是基于父类 Rectangle
的承诺:"我可以独立地修改宽和高"。
java
arduino
public class Test {
// 这个方法接收一个Rectangle参数,这是基于父类的"契约"在编程
public static void testArea(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
// 根据矩形的定义,这里面积应该是 5 * 4 = 20
System.out.println("Expected area: 20, Got area: " + rect.getArea());
// 断言结果应该是20,否则逻辑错误
assert rect.getArea() == 20 : "Oops! Area is not 20!";
}
public static void main(String[] args) {
Rectangle rect = new Rectangle();
testArea(rect); // 输出:Expected area: 20, Got area: 20 ✅ 正确
// 现在,我们用子类Square来替换父类Rectangle
Rectangle square = new Square(); // 多态,父类引用指向子类对象
testArea(square); // 输出:Expected area: 20, Got area: 16 ❌ 错误!
}
}
发生了什么?
-
testArea
方法认为它操作的是一个Rectangle
,所以先设宽为5,再设高为4。 -
但当传入的是
Square
时:setWidth(5)
被调用,Square
的重写方法将宽和高都设为了5。setHeight(4)
被调用,Square
的重写方法又将宽和高都设为了4。
-
最终,这个正方形的长宽都是4,面积是16,而不是预期的20。
这就是违反了里氏替换原则!
Square
类虽然语法上成功继承了 Rectangle
,但它的行为却破坏了父类 Rectangle
的契约 (父类的契约是:宽和高可以独立设置)。导致所有基于 Rectangle
契约编写的代码(如 testArea
方法),在接收到 Square
时都产生了错误的结果。
程序没有崩溃,但逻辑的正确性被破坏了。
三、如何遵守LSP?正确的设计应该是怎样的?
这个例子的根源在于, "行为"上的 is-a 关系 比 "数学定义"上的 is-a 关系 更重要。
正方形在数学上是矩形,但在行为上,它并不能替代一个允许独立修改长宽的矩形。因此,它们不应该用继承关系。
解决方案:多用组合,少用继承!
我们可以创建一个共同的抽象,让两者都去实现,或者干脆不要建立继承关系。
java
csharp
// 定义一个形状接口,只包含计算面积的行为
interface Shape {
int getArea();
}
// 矩形类实现Shape接口
class Rectangle implements Shape {
private int width;
private int height;
// ... getters and setters ...
@Override
public int getArea() {
return width * height;
}
}
// 正方形类也实现Shape接口
class Square implements Shape {
private int side; // 正方形只有一个边长属性
public void setSide(int side) { this.side = side; }
public int getSide() { return side; }
@Override
public int getArea() {
return side * side;
}
}
现在,我们有一个方法,它只关心计算面积,它基于 Shape
接口的契约:"你一定能告诉我你的面积"。
java
csharp
public static void printArea(Shape shape) {
System.out.println("The area is: " + shape.getArea());
}
这个方法接收任何 Shape
(Rectangle
或 Square
),都能正常工作,输出正确的面积。它们互相可以替换,而不破坏 printArea
方法的正确性。这就是遵守了LSP。
四、给Java程序员的实践指南
- 子类不应该比父类更严格:父类的方法如果允许传入null,子类重写时就不应该禁止null(可以更宽松,但不能更严格)。
- 子类不应该改变父类的预期行为:比如父类的一个方法是排序,子类重写后不能变成打乱顺序。
- 谨慎重写父类的方法 :如果你发现需要大量重写父类的方法,或者重写后完全改变了其初衷,这很可能意味着继承关系不合理,应该考虑使用组合(比如策略模式)来代替继承。
- 用抽象(接口)来定义契约 :就像上面的
Shape
例子,客户端代码基于最抽象的接口编程,具体替换成哪个实现,都不会影响正确性。这是LSP的最高级应用,也是Spring等框架的核心思想。
总结一下:
里氏替换原则(LSP)就是一把"尺子",用来衡量你的继承关系是否设计得合理。 它要求子类必须乖乖遵守父类立下的所有"规矩",不能搞特殊、搞破坏。只有这样,面向对象的多态特性才能真正发挥威力,让你写出可靠、可扩展的代码。