里氏替换原则(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理清思路
下面这张图展示了矩形-正方形问题的两种设计对比:
7. 什么时候该用继承?
经过这个案例,你可能想问:那继承到底什么时候用?我个人的判断标准很简单:
- 子类完全不需要修改父类的行为。 只需要添加新方法,或者重写虚方法但不改变原有约定。
- 子类可以安全地替代父类。 把子类对象传给任何接受父类引用的代码,都不会出问题。
- 子类不会抛出新的异常类型。 这个在C++里尤其重要,因为异常规范虽然不强制了,但调用方可能只catch了父类声明的异常。
如果以上条件不满足,优先考虑组合、接口抽象,或者干脆让两个类平级。
一句话总结: 继承不是为了复用代码,而是为了复用行为。如果行为不一致,就别硬凑。
好了,矩形与正方形的案例就聊到这里。这个例子虽然简单,但它背后揭示的LSP原则,能帮你避免很多设计上的坑。下次写继承时,多问自己一句:这个子类真的能替代父类吗?
公众号:蓝海资料掘金营,微信deep3321