第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 → 继承设计错误 核心原则

这张图展示了LSP的三个支柱:数学基础、契约式设计和行为兼容性。三者缺一不可。任何一个支柱出了问题,整个继承体系就会崩塌。

如何判断是否违反LSP?

我总结了几个实用的检查点:

  1. 子类是否削弱了父类的功能?——比如父类支持所有操作,子类却抛出"不支持"异常
  2. 子类是否增加了额外的限制?——比如父类接受任何整数,子类却要求输入必须为正数
  3. 子类是否改变了父类的预期行为?——比如父类返回不可变对象,子类却返回了可变对象
  4. 子类是否破坏了父类的不变式?——比如父类保证"id唯一",子类却允许重复id

我的建议:写继承之前,先问自己三个问题:子类真的是父类的一种吗?子类的行为真的和父类一致吗?换成子类后,调用方会感到意外吗?如果任何一个答案是"不",那就别用继承。

里氏替换原则不是教条,它是经过无数项目验证的实践智慧。我个人习惯在代码审查时,专门检查继承关系是否符合LSP。这个习惯帮我避免了很多设计上的坑。

记住:好的继承关系,子类替换父类后,代码不需要做任何修改。如果做不到,那就重新设计。


公众号:蓝海资料掘金营,微信deep3321