第5章 里氏替换原则 - LSP (Liskov Substitution Principle)
一、导读
1.1 本章学习目标
通过本章的学习,你将能够:
深入理解里氏替换原则(LSP)的数学定义和实际含义
识别代码中违反 LSP 的模式,特别是继承层次中的行为不一致
掌握设计符合 LSP 的继承关系的方法
理解行为子类型化与名义子类型化的区别
学会使用契约式设计(前置条件、后置条件、不变式)验证 LSP
理解"组合优于继承"的设计哲学
掌握在无法避免 LSP 违反时的替代方案
理解 LSP 与多态、开闭原则的关系
1.2 为什么本章如此重要
里氏替换原则是 SOLID 原则中最微妙也最容易被忽视的一个。它由 Barbara Liskov 在 1987 年提出,最初是一个关于类型理论的数学概念,后来被 Robert C. Martin 引入到面向对象设计中。
LSP 的核心洞察是:继承不仅仅是代码复用机制,它还建立了一种行为契约。当类 B 继承类 A 时,B 承诺在所有情况下都能替代 A 而不改变程序的正确性。如果这个承诺被打破,多态就会成为陷阱而非优势。
在实际开发中,违反 LSP 是导致多态滥用、继承层次脆弱、运行时错误的主要原因之一。经典的"正方形-矩形问题"就是一个典型例子:从几何学角度看,正方形确实是特殊的矩形;但从软件设计角度看,让 Square 继承 Rectangle 会导致行为不一致。
理解 LSP 有助于我们做出更好的继承决策,避免将继承仅仅作为代码共享的手段。它也促使我们思考接口与实现的关系,以及如何使用组合来替代继承。
1.3 核心问题引导
在深入本章内容之前,请思考以下问题:
什么是"子类型必须能够替换基类型"?如何判断是否可以安全替换?
为什么"正方形继承矩形"是 LSP 的经典违反案例?
继承除了代码复用,还建立了什么样的关系?
如何识别继承层次中的 LSP 违反?有哪些信号?
当前置条件、后置条件或不变式被改变时,会发生什么?
什么时候应该使用组合而非继承?
接口继承和实现继承有什么区别?
在函数式编程中,LSP 有对应的概念吗?
二、核心概念详解
2.1 LSP 的正式定义
里氏替换原则的正式表述是:如果对每个类型为 S 的对象 o1,都存在一个类型为 T 的对象 o2,使得对于所有用 T 编写的程序 P,将 o1 替换为 o2 后程序的行为不变,那么 S 就是 T 的子类型。
这个定义看似复杂,但核心思想很简单:子类应该能够在任何父类出现的地方正常工作,而不会让调用者察觉到差异。
更实用的表述是:子类不应该改变父类的契约。这里的"契约"包括:
- 前置条件:方法执行前必须满足的条件
- 后置条件:方法执行后保证成立的条件
- 不变式:对象在整个生命周期中保持不变的属性
2.2 契约式设计
Bertrand Meyer 提出的契约式设计(Design by Contract)为理解 LSP 提供了清晰的框架。
前置条件(Precondition):方法执行前必须为真的条件。子类不能加强前置条件(即要求更严格的输入),因为这会使得原本对父类有效的输入对子类无效。
// 父类
public class Stack {
/**
* 前置条件:无(可以 push 任何元素)
*/
public void push(Object item) {
// ...
}
}
// 违反 LSP 的子类
public class BoundedStack extends Stack {
private int maxSize;
/**
* 前置条件:栈未满
* 这加强了前置条件,违反 LSP
*/
@Override
public void push(Object item) {
if (size() >= maxSize) {
throw new IllegalStateException("Stack is full");
}
super.push(item);
}
}后置条件(Postcondition):方法执行后保证为真的条件。子类不能削弱后置条件(即保证更少),因为这会使得调用者无法依赖父类所保证的结果。
// 父类
public class SearchService {
/**
* 后置条件:返回所有匹配的结果
*/
public List<Result> search(String query) {
// 返回完整结果
}
}
// 违反 LSP 的子类
public class CachedSearchService extends SearchService {
/**
* 后置条件:可能只返回缓存中的部分结果
* 这削弱了后置条件,违反 LSP
*/
@Override
public List<Result> search(String query) {
if (cache.containsKey(query)) {
return cache.get(query); // 可能不是完整结果
}
return super.search(query);
}
}不变式(Invariant):对象在整个生命周期中保持为真的属性。子类不能破坏父类的不变式。
// 父类
public class PositiveCounter {
private int count = 0;
/**
* 不变式:count >= 0
*/
public void increment() {
count++;
}
public void decrement() {
if (count > 0) {
count--;
}
}
public int getCount() {
return count;
}
}
// 违反 LSP 的子类
public class UnboundedCounter extends PositiveCounter {
/**
* 允许 count 变为负数,破坏了父类的不变式
*/
@Override
public void decrement() {
// 移除了 count > 0 的检查
super.decrement(); // 但父类方法仍有检查,所以这里需要直接操作
}
}2.3 经典的 LSP 违反案例
案例一:正方形-矩形问题
这是最著名的 LSP 违反案例。从几何学角度,正方形是特殊的矩形(四边相等的矩形)。但从软件设计角度,让 Square 继承 Rectangle 会导致问题:
// 违反 LSP 的设计
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth() { return width; }
public int getHeight() { return height; }
public int getArea() {
return width * height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // 正方形宽高必须相等
}
@Override
public void setHeight(int height) {
this.width = height; // 正方形宽高必须相等
this.height = height;
}
}
// 测试代码
public void testRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50 : "Expected area 50, got " + r.getArea();
}
// 传入 Rectangle 时测试通过
// 传入 Square 时测试失败(area = 100)问题的根源在于:Rectangle 的契约允许独立设置宽度和高度,但 Square 打破了这个契约。
案例二:只读集合
// 违反 LSP
public class ReadOnlyList<T> extends ArrayList<T> {
@Override
public void add(T element) {
throw new UnsupportedOperationException("Read-only list");
}
@Override
public boolean remove(Object o) {
throw new UnsupportedOperationException("Read-only list");
}
}
// 这段代码在使用 ArrayList 时正常工作,但传入 ReadOnlyList 时会崩溃
public void populateList(List<String> list) {
list.add("item1");
list.add("item2");
}案例三:抛出额外异常
// 违反 LSP
public class BaseService {
public void process() throws IOException {
// 可能抛出 IOException
}
}
public class ExtendedService extends BaseService {
@Override
public void process() throws IOException, SQLException {
// 抛出了父类未声明的额外异常
// 调用者如果只捕获 IOException,可能会漏掉 SQLException
}
}2.4 识别 LSP 违反的信号
以下是代码中可能违反 LSP 的信号:
1. instanceof 检查
if (shape instanceof Square) {
// 特殊处理 Square
} else if (shape instanceof Circle) {
// 特殊处理 Circle
}如果客户端代码需要检查具体类型并特殊处理,说明子类不能完全替代父类。
2. 空实现或抛出异常的方法
@Override
public void someMethod() {
throw new UnsupportedOperationException();
}这通常意味着子类不需要或不支持这个方法,违反了父类的契约。
3. 文档中的警告
/**
* 注意:此实现在某些条件下可能不返回结果
*/
@Override
public List<Result> search(String query) {
// ...
}如果子类需要在文档中警告调用者注意不同的行为,说明契约被改变了。
4. 重写方法改变语义
即使方法签名相同,如果行为语义不同,也违反 LSP。
2.5 符合 LSP 的设计策略
策略一:重新建模继承关系
如果子类不能真正替代父类,考虑重新设计继承层次。
// 不符合 LSP
class Bird {
void fly() { /* ... */ }
}
class Penguin extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
// 符合 LSP:重新建模
interface Animal {
void move();
}
interface Flyable {
void fly();
}
class Bird implements Animal, Flyable {
public void move() { fly(); }
public void fly() { /* ... */ }
}
class Penguin implements Animal {
public void move() { swim(); }
public void swim() { /* ... */ }
}策略二:使用组合而非继承
// 不符合 LSP:继承用于代码复用
class Stack extends ArrayList {
// 继承了 ArrayList 的所有方法,但很多不适用
}
// 符合 LSP:使用组合
class Stack {
private final List<Object> elements = new ArrayList<>();
public void push(Object item) {
elements.add(item);
}
public Object pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
}策略三:使用接口而非抽象类
接口只定义契约,不提供实现,避免了实现继承带来的 LSP 问题。
策略四:使用委托
public class LoggingList<T> implements List<T> {
private final List<T> delegate;
private final Logger logger;
public LoggingList(List<T> delegate, Logger logger) {
this.delegate = delegate;
this.logger = logger;
}
@Override
public void add(T element) {
logger.info("Adding: " + element);
delegate.add(element);
}
// 其他方法委托给 delegate
}2.6 LSP 与多态的关系
LSP 是多态正确性的保证。只有当子类符合 LSP 时,多态才能安全工作。
// 如果所有 Shape 子类都符合 LSP,这段代码可以安全处理任何 Shape
public double calculateTotalArea(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::getArea)
.sum();
}如果某个子类违反 LSP(例如返回错误的面积,或抛出异常),整个多态机制就会失效。
2.7 LSP 与其他原则的关系
LSP 与 OCP:如果子类不能正确替换父类,通过多态扩展就会出问题。LSP 是 OCP 的前提。
LSP 与 DIP:依赖倒置要求高层模块依赖抽象。如果具体实现不能正确替代抽象(违反 LSP),DIP 就失去了意义。
LSP 与 SRP:当一个类承担多个职责时,子类可能只需要其中一部分,导致其他方法成为空实现或抛出异常,违反 LSP。
三、程序员的视角
3.1 在实际项目中的应用
在日常工作中,应用 LSP 可以从以下几个方面入手:
设计继承关系时:
- 问自己:"子类是否真的是父类的一种?"
- 检查子类是否需要禁用或特殊处理父类的某些方法
- 确保子类遵守父类的契约
代码审查时:
- 寻找
instanceof检查 - 注意抛出
UnsupportedOperationException的重写方法 - 检查文档中是否有行为变化的警告
重构遗留代码时:
- 将深层继承层次展平
- 用组合替换继承
- 提取接口代替抽象类
3.2 常见陷阱与误区
陷阱一:将继承视为代码复用机制
继承应该表示"is-a"关系,而不仅仅是共享代码。如果只是需要代码复用,优先考虑组合。
陷阱二:忽视行为语义
即使方法签名相同,如果行为语义不同,也违反 LSP。仔细阅读父类的文档,理解其契约。
陷阱三:过度使用抽象类
抽象类混合了接口定义和实现共享,容易导致 LSP 问题。优先使用接口定义契约,使用组合共享实现。
3.3 与其他层级的联系
LSP 与软件工程的其他方面密切相关:
- 测试:符合 LSP 的代码可以用父类的测试用例测试子类
- 文档:清晰的契约文档有助于确保 LSP
- IDE 支持:现代 IDE 可以检测潜在的 LSP 违反
四、代码实战
4.1 示例一:修复正方形-矩形问题
/**
* 符合 LSP 的形状设计
*/
// === 抽象:形状接口 ===
public interface Shape {
/**
* 计算面积
* @return 形状的面积
*/
double getArea();
/**
* 计算周长
* @return 形状的周长
*/
double getPerimeter();
}
// === 具体实现 ===
public class Rectangle implements Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
this.width = width;
this.height = height;
}
public double getWidth() { return width; }
public double getHeight() { return height; }
@Override
public double getArea() {
return width * height;
}
@Override
public double getPerimeter() {
return 2 * (width + height);
}
}
public class Square implements Shape {
private final double side;
public Square(double side) {
if (side <= 0) {
throw new IllegalArgumentException("Side must be positive");
}
this.side = side;
}
public double getSide() { return side; }
@Override
public double getArea() {
return side * side;
}
@Override
public double getPerimeter() {
return 4 * side;
}
}
public class Circle implements Shape {
private final double radius;
public Circle(double radius) {
if (radius <= 0) {
throw new IllegalArgumentException("Radius must be positive");
}
this.radius = radius;
}
public double getRadius() { return radius; }
@Override
public double getArea() {
return Math.PI * radius * radius;
}
@Override
public double getPerimeter() {
return 2 * Math.PI * radius;
}
}
// === 使用多态的代码 ===
public class ShapeCalculator {
public double calculateTotalArea(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::getArea)
.sum();
}
public double calculateTotalPerimeter(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::getPerimeter)
.sum();
}
public Shape findLargest(List<Shape> shapes) {
return shapes.stream()
.max(Comparator.comparingDouble(Shape::getArea))
.orElseThrow(() -> new IllegalArgumentException("No shapes provided"));
}
}
// === 测试 ===
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class ShapeTest {
@Test
void rectangleArea() {
Rectangle rect = new Rectangle(5, 10);
assertEquals(50, rect.getArea());
}
@Test
void squareArea() {
Square square = new Square(5);
assertEquals(25, square.getArea());
}
@Test
void shapesAreSubstitutable() {
List<Shape> shapes = Arrays.asList(
new Rectangle(5, 10),
new Square(5),
new Circle(3)
);
ShapeCalculator calculator = new ShapeCalculator();
// 这些方法应该能安全处理任何 Shape 组合
double totalArea = calculator.calculateTotalArea(shapes);
assertTrue(totalArea > 0);
Shape largest = calculator.findLargest(shapes);
assertNotNull(largest);
}
}4.2 示例二:使用组合替代继承
"""
展示如何使用组合替代继承来避免 LSP 问题
"""
from abc import ABC, abstractmethod
from typing import List
# === 不好的设计:继承用于代码复用 ===
class Duck:
def quack(self):
print("Quack!")
def swim(self):
print("Swimming")
def fly(self):
print("Flying")
class RubberDuck(Duck):
"""橡皮鸭不会真的叫、游或飞"""
def quack(self):
# 发出 squeak 声音而不是 quack
print("Squeak!")
def swim(self):
# 橡皮鸭只是浮在水上
print("Floating")
def fly(self):
# 橡皮鸭不能飞
raise NotImplementedError("Rubber ducks can't fly")
# === 好的设计:使用组合 ===
class QuackBehavior(ABC):
@abstractmethod
def quack(self):
pass
class RealQuack(QuackBehavior):
def quack(self):
print("Quack!")
class Squeak(QuackBehavior):
def quack(self):
print("Squeak!")
class MuteQuack(QuackBehavior):
def quack(self):
print("<< Silence >>")
class FlyBehavior(ABC):
@abstractmethod
def fly(self):
pass
class RealFly(FlyBehavior):
def fly(self):
print("Flying")
class NoFly(FlyBehavior):
def fly(self):
print("Can't fly")
class SwimBehavior(ABC):
@abstractmethod
def swim(self):
pass
class RealSwim(SwimBehavior):
def swim(self):
print("Swimming")
class Float(SwimBehavior):
def swim(self):
print("Floating")
class DuckV2:
"""使用组合的鸭子类"""
def __init__(self, quack_behavior: QuackBehavior,
fly_behavior: FlyBehavior,
swim_behavior: SwimBehavior):
self.quack_behavior = quack_behavior
self.fly_behavior = fly_behavior
self.swim_behavior = swim_behavior
def perform_quack(self):
self.quack_behavior.quack()
def perform_fly(self):
self.fly_behavior.fly()
def perform_swim(self):
self.swim_behavior.swim()
class MallardDuck(DuckV2):
def __init__(self):
super().__init__(RealQuack(), RealFly(), RealSwim())
class RubberDuckV2(DuckV2):
def __init__(self):
super().__init__(Squeak(), NoFly(), Float())
class DecoyDuck(DuckV2):
def __init__(self):
super().__init__(MuteQuack(), NoFly(), Float())
# === 使用示例 ===
def main():
ducks = [
MallardDuck(),
RubberDuckV2(),
DecoyDuck()
]
for duck in ducks:
duck.perform_quack()
duck.perform_fly()
duck.perform_swim()
print("---")
if __name__ == "__main__":
main()4.3 示例三:契约式设计验证 LSP
/**
* 使用契约式设计确保 LSP 合规
*/
public abstract class ContractBase {
/**
* 检查前置条件
*/
protected void checkPrecondition(boolean condition, String message) {
if (!condition) {
throw new PreconditionViolationException(message);
}
}
/**
* 检查后置条件
*/
protected void checkPostcondition(boolean condition, String message) {
if (!condition) {
throw new PostconditionViolationException(message);
}
}
/**
* 检查不变式
*/
protected void checkInvariant(boolean condition, String message) {
if (!condition) {
throw new InvariantViolationException(message);
}
}
}
public class PreconditionViolationException extends RuntimeException {
public PreconditionViolationException(String message) {
super("Precondition violated: " + message);
}
}
public class PostconditionViolationException extends RuntimeException {
public PostconditionViolationException(String message) {
super("Postcondition violated: " + message);
}
}
public class InvariantViolationException extends RuntimeException {
public InvariantViolationException(String message) {
super("Invariant violated: " + message);
}
}
// === 示例:带契约的栈 ===
public class ContractStack extends ContractBase {
private final List<Object> elements = new ArrayList<>();
private final int maxSize;
public ContractStack(int maxSize) {
checkPrecondition(maxSize > 0, "Max size must be positive");
this.maxSize = maxSize;
}
/**
* 前置条件:栈未满
* 后置条件:栈大小增加1,新元素在栈顶
* 不变式:0 <= size <= maxSize
*/
public void push(Object item) {
int oldSize = elements.size();
checkPrecondition(elements.size() < maxSize, "Stack is full");
checkPrecondition(item != null, "Cannot push null");
elements.add(item);
checkPostcondition(elements.size() == oldSize + 1, "Size should increase by 1");
checkPostcondition(elements.get(elements.size() - 1).equals(item), "New item should be on top");
checkInvariant(elements.size() >= 0 && elements.size() <= maxSize, "Size within bounds");
}
/**
* 前置条件:栈非空
* 后置条件:栈大小减少1,返回原栈顶元素
* 不变式:0 <= size <= maxSize
*/
public Object pop() {
int oldSize = elements.size();
checkPrecondition(!elements.isEmpty(), "Stack is empty");
Object result = elements.remove(elements.size() - 1);
checkPostcondition(elements.size() == oldSize - 1, "Size should decrease by 1");
checkInvariant(elements.size() >= 0 && elements.size() <= maxSize, "Size within bounds");
return result;
}
public int size() {
return elements.size();
}
public boolean isEmpty() {
return elements.isEmpty();
}
}
// === 子类必须遵守相同的契约 ===
public class LoggingStack extends ContractStack {
private final Logger logger;
public LoggingStack(int maxSize, Logger logger) {
super(maxSize);
this.logger = logger;
}
@Override
public void push(Object item) {
logger.info("Pushing: " + item);
super.push(item);
logger.info("Pushed. Size: " + size());
}
@Override
public Object pop() {
logger.info("Popping. Current size: " + size());
Object result = super.pop();
logger.info("Popped: " + result);
return result;
}
}
// LoggingStack 没有改变前置条件、后置条件或不变式,符合 LSP五、深入思考
5.1 历史背景
LSP 由 Barbara Liskov 在 1987 年的论文"Data Abstraction and Hierarchy"中首次提出。1994 年,她与 Jeannette Wing 合作发表了更形式化的定义。
Barbara Liskov 是计算机科学领域的先驱女性,2008 年因在编程语言和系统设计的实践与理论方面的贡献获得图灵奖。
Robert C. Martin 将 LSP 引入到 SOLID 原则体系中,使其成为面向对象设计的重要指导原则。
5.2 前沿方向
在现代软件工程中,LSP 的应用正在演变:
类型系统增强:Kotlin、Rust 等现代语言的类型系统可以更好地表达子类型关系。
契约测试:在微服务架构中,消费者驱动的契约测试确保服务提供者不破坏契约。
形式化验证:使用定理证明工具验证代码是否符合契约。
5.3 延伸阅读
- 《Object-Oriented Software Construction》- Bertrand Meyer:契约式设计的权威著作
- 《Design by Contract》- Eiffel 软件的方法论
- 《Refactoring》- Martin Fowler:用组合替换继承的重构手法
六、实验笔记
实验一:识别和修复 LSP 违反
目标:在一个真实项目中识别违反 LSP 的继承关系并修复。
步骤:
选择一个项目,找出所有继承关系
对每个继承关系,检查子类是否可以完全替代父类
寻找 instanceof 检查、UnsupportedOperationException 等信号
修复违反 LSP 的设计,使用组合或重新建模
验证所有测试通过
实验二:设计符合 LSP 的类型层次
目标:设计一个动物类型层次,确保符合 LSP。
任务:考虑各种动物(鸟、企鹅、鸵鸟、鱼、哺乳动物等)及其能力(飞、游泳、行走等)。
七、本章小结
本章深入探讨了里氏替换原则(LSP),这是关于子类型行为一致性的原则。我们学习了:
LSP 要求子类能够替换父类而不改变程序的正确性。这不仅是类型兼容性问题,更是行为契约问题。
契约式设计提供了验证 LSP 的框架:子类不能加强前置条件、不能削弱后置条件、不能破坏不变式。
经典的"正方形-矩形问题"展示了名义上的"is-a"关系不一定意味着行为上的可替换性。
组合优于继承是避免 LSP 问题的重要策略。通过将行为封装为独立的对象并使用组合,可以更灵活地构建功能。
LSP 是多态正确性的保证,也是 OCP 和 DIP 的前提条件。
八、习题
概念理解:解释为什么 LSP 不仅仅是类型兼容性问题,而是行为契约问题。
案例分析:分析以下代码是否违反 LSP,并说明理由:
class Bird {
void layEggs() { /* ... */ }
}
class Bat extends Bird {
@Override
void layEggs() {
throw new UnsupportedOperationException("Bats don't lay eggs");
}
}设计练习:设计一个交通工具类型层次,包括汽车、自行车、飞机、船等,确保符合 LSP。
重构实践:将一个使用继承的代码重构为使用组合。比较两种设计的优缺点。
契约设计:为一个队列类设计前置条件、后置条件和不变式。
研究题:调研 Kotlin 或 Rust 的类型系统如何处理子类型关系。
批判性思考:在什么情况下,轻微违反 LSP 可能是可以接受的?给出具体场景。
测试关联:如何编写测试来验证子类符合 LSP?
多态与安全:解释 LSP 如何保证多态的安全性。
实际应用:在你熟悉的项目中,找出一个违反 LSP 的例子,并提出改进方案。