第6章:里氏替换原则(LSP)上——定义与数学基础,子类必须能够替换父类,契约式设计
里氏替换原则,圈内人常简称为LSP。这名字听起来挺学术,其实道理很朴素。
我刚开始接触面向对象时,觉得继承就是"复用代码"的好工具。父类写好的功能,子类直接拿来用,多省事。直到有一次,我写了一个正方形继承自矩形的类,结果把整个图形引擎搞崩了。嗯,那次教训让我记住了LSP的分量。
什么是里氏替换原则?
一句话概括:子类对象必须能够替换掉父类对象,并且程序的行为不会发生变化。
说白了,如果你把代码里所有父类的地方换成子类,程序还能正常运行,那就符合LSP。如果替换后出了问题,说明你的继承关系设计有问题。
这个原则是Barbara Liskov在1987年提出的。她给出的正式定义是这样的:
正式定义:设φ(x)是关于类型T的对象x的一个可证明的性质。那么对于类型S的对象y,如果S是T的子类型,则φ(y)也应当成立。
读起来有点绕,我翻译成人话:父类能做的事,子类也必须能做。父类承诺的功能,子类不能"打折扣"。
数学基础:子类型与替换
从数学角度看,LSP定义了一种子类型关系。这种关系不是简单的"is-a"语义,而是行为上的可替换性。
我们来看一个简单的数学表达:
// 假设有一个函数 f,它接受父类类型 Base 的参数
void f(Base& b) {
b.doSomething();
}
// 如果 Derived 是 Base 的子类
// 那么 f 函数应该能接受 Derived 类型的对象
Derived d;
f(d); // 必须能正常工作
这里的关键是:f函数内部对b的调用,不能因为传入的是子类对象就产生不同的结果。当然,这里的"不同"指的是违背父类契约的行为。
我的经验:判断继承是否合理,我有个简单方法——把代码里所有父类类型换成子类类型,然后问自己:"程序还能跑吗?"如果答案是"不能",那继承关系八成有问题。
契约式设计:LSP的核心保障
要实现LSP,光靠语法检查是不够的。我们需要契约式设计(Design by Contract)。这个概念由Bertrand Meyer提出,核心思想是:每个类都有明确的契约,子类必须遵守这个契约。
契约包含三部分:
- 前置条件:调用方法前必须满足的条件
- 后置条件:方法执行后必须保证的条件
- 不变式:在整个对象生命周期中始终成立的条件
LSP对契约的要求很明确:
| 契约元素 | 子类要求 | 说明 |
|---|---|---|
| 前置条件 | 不能加强 | 子类不能要求比父类更多的条件 |
| 后置条件 | 不能削弱 | 子类必须保证父类承诺的所有结果 |
| 不变式 | 必须保持 | 子类不能破坏父类定义的恒常条件 |
为什么会这样?你想想看,如果子类加强了前置条件,那原来能调用父类的代码,换成子类就可能失败。这不就违背了替换原则吗?
我曾经踩过的坑:写了一个带缓存的数据查询类,父类承诺"每次查询返回最新数据"。子类为了性能,加了缓存,结果返回了过期数据。这就是削弱了后置条件。那次线上事故让我记住了:性能优化不能以破坏契约为代价。
一个经典的违反案例
我们来看一个教科书级的反面教材——矩形与正方形:
class Rectangle {
protected:
int width, height;
public:
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
int getArea() const { return width * height; }
};
class Square : public Rectangle {
public:
void setWidth(int w) override {
width = w;
height = w; // 保持正方形特性
}
void setHeight(int h) override {
width = h;
height = h;
}
};
这段代码有什么问题?我们写个测试看看:
void resize(Rectangle& r) {
r.setWidth(5);
r.setHeight(10);
// 期望面积是 50
assert(r.getArea() == 50);
}
Square s;
resize(s); // 面积变成 100,断言失败!
看到了吗?resize函数期望的是矩形的行为——宽高独立设置。但正方形子类破坏了这一契约。这就是典型的违反LSP。
核心教训:继承不是为了代码复用,而是为了行为复用。如果子类不能完全替代父类的行为,那就不要用继承。
LSP的知识体系
我把LSP的核心逻辑整理成了一张图,方便你理解:
这张图展示了LSP的三个支柱:数学基础、契约式设计和行为兼容性。三者缺一不可。任何一个支柱出了问题,整个继承体系就会崩塌。
如何判断是否违反LSP?
我总结了几个实用的检查点:
- 子类是否削弱了父类的功能?——比如父类支持所有操作,子类却抛出"不支持"异常
- 子类是否增加了额外的限制?——比如父类接受任何整数,子类却要求输入必须为正数
- 子类是否改变了父类的预期行为?——比如父类返回不可变对象,子类却返回了可变对象
- 子类是否破坏了父类的不变式?——比如父类保证"id唯一",子类却允许重复id
我的建议:写继承之前,先问自己三个问题:子类真的是父类的一种吗?子类的行为真的和父类一致吗?换成子类后,调用方会感到意外吗?如果任何一个答案是"不",那就别用继承。
里氏替换原则不是教条,它是经过无数项目验证的实践智慧。我个人习惯在代码审查时,专门检查继承关系是否符合LSP。这个习惯帮我避免了很多设计上的坑。
记住:好的继承关系,子类替换父类后,代码不需要做任何修改。如果做不到,那就重新设计。
公众号:蓝海资料掘金营,微信deep3321