里氏替换原则(LSP)
里氏替换原则可以描述为:子类型必须能够替换掉它们的基类型。或者描述为:使用基类对象指针或引用的函数必须能够在不了解衍生类的条件下使用衍生类的对象。
下面是一个违背 LSP 的一个简单示例;很显然,DrawShape
函数的设计使得它必须知道所有Shape
基类的衍生类,这个函数更明显的是违背了 OCP。
void DrawShape(const Shape& s)
{
if (s.itsType == Shape::square)
static_cast<const Square&>(s).Draw();
else if (s.itsType == Shape::circle)
static_cast<const Circle&>(s).Draw();
}
我们再来看一个更加微妙的违背 LSP 的方式,现有如下有一个矩形类。
class Rectangle {
public:
void setWidth(double w) {width=w;}
void setHeight(double h) {height=h;}
double getWidth() const {return width;}
double getHeight() const {return height;}
private:
double width;
double height;
}
试想一下,这个程序可以良好的工作,并且它被部署到许多地方。但是用户的需求总是会不时的发生变化,某一天,用户不满足于矩形的操作,想要一个添加一个正方形的操作,正方形是一个特殊的矩形,很明显新建一个 Rectangle 的衍生类就可以了。
但是正方形的长和宽相等,所以基类的方法对正方形来说并不适用,自然而然就能想到下面的规避方式。
void Square::setWidth(double w) {
Rectangle::setWidth(w);
Rectangle::setHeight(w);
}
void Square::setHeight(double h) {
Rectangle::setWidth(h);
Rectangle::setHeight(h);
}
现在,当设置 Square 对象的宽时,它的长也会相应地改变;当改变长时,宽也会随之改变。但是看下面的这个方法
void f(Rectangle& r) {
r.setWidth(32);
}
如果我们传递一个 Square 对象的引用到这个方法中,则 Square 对象将会被损坏,因为他的 hight 将不会被更改,这就很明确地违背了 LSP 原则,此函数在衍生对象为参数的条件下无法正常工作,因为基类 Rectangle 没有将 setWidth 和 setHeight 设为虚函数。这个问题也能很容易的解决,但是尽管这样,当创建一个衍生类将导致对父类做出修改,通常意味着这个设计是有缺陷的。
现在我们有了两个类,Square 和 Rectangle,而且看起来可以工作,无论你对 Square 做什么操作,它都能保持与数学中的正方形定义一致,而且不管你对 Rectangle 对象做什么,它也将符合数学中长方形的定义。
但是总有意外,试想一下下面这个方法,它调用了 SetWidth 和 SetHeight 方法,并且认为这些函数都是属于同一个 Rectangle。这个函数对 Rectangle 是可以工作的,但是如果传递一个 Square 参数进去则会发生断言错误。
void g(Rectangle& r) {
r.setWidth(5);
r.setHeight(4);
assert(r.area() == 20);
}
所以到底是哪里错了呢?很明显,正方形可以是一个长方形,但是 Square 对象绝对不是一个 Rectangle 对象,因为 Square 对象的行为与 Rectangle 对象的行为是不一致的。
LSP 原则告诉了我们一个重要的信息,IS-A
是就行为方式而言的 。
为了解决上面的问题,我们可以显示的规定针对一个类的契约。契约是通过为每个方法声明的前置条件和后置条件来指定的。
我个人把前置条件和后置条件当做钩子的概念来理解,在 Vue、Angular 等前端框架中,钩子的概念很普遍,简单来说就是事件触发,一个方法执行前会触发一个 A 事件,执行完后会触发一个 B 事件,前置条件就是在 A 发生时执行,后置条件就是在 B 发生时执行。
之所以使用前后置条件,是因为子类方法的行为与父类方法的行为不一致,可以使用闭包来实现前后置条件,水平有限,我也不知道其它的实现方式,在某些语言中是对前后置条件有直接支持的。
参考资料:《敏捷软件开发 原则、模式与实践》