里氏替换原则(LSP)下:实际案例——矩形与正方形的经典问题,LSP与继承的正确使用

上一讲我们聊了LSP的理论定义。今天,咱们来解剖一个经典案例——矩形与正方形。这个例子几乎每个C++程序员都遇到过,或者至少听说过。我当年刚入行时,就在这个坑里摔过一跤。

1. 矩形与正方形:看似完美的继承

先看一个直观的设计。矩形有宽和高,正方形是特殊的矩形——四条边相等。于是很多人会写出这样的代码:

class Rectangle {
protected:
    int width, height;
public:
    virtual void setWidth(int w) { width = w; }
    virtual void setHeight(int h) { height = h; }
    int getWidth() const { return width; }
    int getHeight() const { return height; }
    int area() const { return width * height; }
};

class Square : public Rectangle {
public:
    void setWidth(int w) override {
        width = w;
        height = w;  // 保持正方形特性
    }
    void setHeight(int h) override {
        height = h;
        width = h;   // 保持正方形特性
    }
};

嗯,看起来挺合理对吧?正方形重写了setWidth和setHeight,保证宽高始终相等。但问题就出在这里。

2. 哪里违反了LSP?

我们来写一段测试代码。假设你有一个函数,它接受Rectangle的引用:

void resize(Rectangle& rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    // 期望:宽=5,高=10,面积=50
    assert(rect.getWidth() == 5);
    assert(rect.getHeight() == 10);
    assert(rect.area() == 50);
}

传入Rectangle对象时,一切正常。但传入Square对象呢?

setWidth(5)把宽和高都设成了5。setHeight(10)又把宽和高都设成了10。最终宽=10,高=10,面积=100。断言全部失败。

这就是典型的LSP违反。 子类Square改变了父类Rectangle的行为约定。调用者以为自己在操作一个矩形,结果被正方形“骗”了。

核心问题: 正方形并不满足矩形的行为契约。矩形允许宽高独立变化,而正方形不允许。这不是“is-a”关系,而是“ behaves-like-a ”关系出了问题。

3. 为什么会出现这种设计?

我记得有一次在代码评审中,一位同事坚持说:“正方形就是矩形啊,数学上就是这么定义的。” 没错,数学上正方形是矩形的子集。但面向对象中的继承,看的不是数学分类,而是行为契约

数学里的矩形有“宽高可独立设置”这个属性吗?没有。数学只关心形状本身,不关心你怎么修改它。但面向对象中,继承意味着子类要能完全替代父类,包括所有可修改的行为。

说白了,继承不是用来表达“是什么”的,而是用来表达“能做什么”的。

4. 正确的设计思路

那该怎么改?我建议从两个方向考虑。

方案一:放弃继承,使用组合

让Rectangle和Square成为平级关系,都继承自一个抽象基类Shape:

class Shape {
public:
    virtual int area() const = 0;
    virtual ~Shape() = default;
};

class Rectangle : public Shape {
    int width, height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int area() const override { return width * height; }
};

class Square : public Shape {
    int side;
public:
    explicit Square(int s) : side(s) {}
    void setSide(int s) { side = s; }
    int area() const override { return side * side; }
};

这样,resize函数只接受Rectangle,不接受Square。Square有自己的setSide方法,互不干扰。调用者不会产生误解。

我的经验: 当你在设计时发现子类需要“禁用”父类的某些方法,或者需要“特殊处理”父类的行为时,这通常意味着继承关系选错了。停下来,重新思考。

方案二:使用不可变对象

如果矩形和正方形都是不可变的(构造时确定宽高,之后不能修改),那LSP问题就消失了:

class Rectangle {
    int width, height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    int getWidth() const { return width; }
    int getHeight() const { return height; }
    int area() const { return width * height; }
};

class Square : public Rectangle {
public:
    explicit Square(int s) : Rectangle(s, s) {}
    // 没有setter,无法破坏约束
};

这种情况下,Square可以安全地替代Rectangle。因为没有任何修改行为,只有查询行为。查询行为不会破坏任何约定。

注意: 不可变对象虽然安全,但适用场景有限。如果你的业务逻辑需要频繁修改对象的属性,这个方案就不太合适。

5. LSP与继承的正确使用

经过这个案例,我想总结几条实用原则:

  • 子类不能强化父类的前置条件。 比如父类允许宽为0,子类不能要求宽必须大于0。
  • 子类不能弱化父类的后置条件。 比如父类保证setWidth后宽度等于传入值,子类不能偷偷改成别的值。
  • 子类不能改变父类的不变量。 矩形的不变量是宽高独立,正方形破坏了它。
  • 子类不能抛出父类未声明的异常。 这个在实际项目中很常见,我曾经见过一个子类在重写方法里抛出了std::runtime_error,而父类只声明了std::logic_error,结果调用方完全没捕获到。

我曾经在一个金融交易系统中遇到过类似问题。当时有个基类叫Order,子类有MarketOrder和LimitOrder。LimitOrder重写了execute方法,加了一个“价格检查”的前置条件。结果上层调用代码在批量处理订单时,MarketOrder执行得好好的,一到LimitOrder就崩溃。排查了半天,才发现是LSP被违反了。

6. 用SVG理清思路

下面这张图展示了矩形-正方形问题的两种设计对比:

错误设计(违反LSP) Rectangle Square : Rectangle ❌ setWidth/setHeight 行为不一致 ❌ 调用者无法安全替换 正确设计(符合LSP) Shape(抽象基类) Rectangle : Shape Square : Shape ✅ 各自实现 area() ✅ 各自拥有独立的 setter ✅ 调用者明确知道类型

7. 什么时候该用继承?

经过这个案例,你可能想问:那继承到底什么时候用?我个人的判断标准很简单:

  • 子类完全不需要修改父类的行为。 只需要添加新方法,或者重写虚方法但不改变原有约定。
  • 子类可以安全地替代父类。 把子类对象传给任何接受父类引用的代码,都不会出问题。
  • 子类不会抛出新的异常类型。 这个在C++里尤其重要,因为异常规范虽然不强制了,但调用方可能只catch了父类声明的异常。

如果以上条件不满足,优先考虑组合、接口抽象,或者干脆让两个类平级。

一句话总结: 继承不是为了复用代码,而是为了复用行为。如果行为不一致,就别硬凑。

好了,矩形与正方形的案例就聊到这里。这个例子虽然简单,但它背后揭示的LSP原则,能帮你避免很多设计上的坑。下次写继承时,多问自己一句:这个子类真的能替代父类吗?


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