读源码学架构系列:里氏替换原则

里氏替换原则(LSP):子类型必须能够替换掉它们的基类型。

这条原则强调的是正确的使用抽象。

OCP背后的主要机制是抽象和多态。在静态类型语言中(如Java),支持抽象和多态的关键机制之一是继承,正是使用了继承,我们才可以基于抽象来封装公共逻辑,然后创建实现抽象基类中的抽象方法的子类。

先看一个经典的例子,我们从小学开始,数学课上就教我们:正方形是特殊的长方形。那么,如果现在我们要对长方形和正方形建模的话,按照数学上的描述,正方形是继承自长方形的。所以,我们可能会这样来设计:

长方形类:

public class Rectangle {

    private int width;
    private int height;

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int area() {
        return width * height;
    }
}

接着是正方形:

public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

因为正方形的四条边长相同,所以,我们需要在setWidth()方法和setHeight()方法中,分别同时对widthheight进行赋值操作。

好了,我们现在对我们的设计编写测试用例,分别测试计算长方形和正方形的面积:

@Test
    public void testRectangleArea() {
        Rectangle rec = new Rectangle();
        rec.setWidth(5);
        rec.setHeight(4);
        assertEquals("", 20, rec.area());
    }

    @Test
    public void testSquareArea() {
        Rectangle rec = new Square();
        rec.setWidth(5);
        rec.setHeight(4);
        assertEquals("", 20, rec.area());
    }

这两个测试用例,结果:

java.lang.AssertionError: 
Expected :20
Actual   :16
<Click to see difference>

testSquareArea()测试用例没通过,期望值是20,实际值是16

我们来看一下测试用例的内容,这里的Rectangle是使用的Square来实例化的,先是设置了Rectangle的宽,然后再设置了它的长,最后断言面积的值。从数学的定义上来分析:我先设置长方形的长为5,然后设置长方形的宽为4,然后判断长方形的面积为20,没有哪里有问题啊?

这个测试用例不通过的根本原因在于测试用例做了一个假设:改变Rectangle的宽不会导致其长发生变化。

很显然,改变一个长方形的宽不会影响它的长的假设是合理的。但是,并不是所有可以作为Rectangle传递的对象都满足这个假设,例如这里的Square类。所以这里的SquareRectangle之间的关系是违反LSP的。

这个测试用例本身是没有问题的,长方形的宽和长是可以独立变化的,所以以上面的逻辑进行断言没有问题。问题在于Square类的实现,违反了长和宽可以独立变化这条规则。

到这里,LSP让我们明白了一个非常重要的结论:一个模型,如果孤立地看,并不内有真正意义上的有效性,模型的有效性只能通过它的客户程序来表现。在评估一个特定的设计是否恰当时,不能完全孤立地看这个解决方案,必须要根据该设计的使用者所做出的合理假设来审视它。

就像上面的RectangleSquare两个类,孤立地看,两个类都没有问题,也符合数学意义上的长方形和正方形。但是,在使用者使用时(测试用例)却不能按预期正常地工作。

那前面说的正方形是特殊的长方形不对吗?

正方形可以是长方形(IS-A),但是,从测试用例的角度来看,Square对象不是Rectangle对象。因为Square对象的行为方式和测试用例所期望的Rectangle对象的行为方式不相容、不正确。而我们做为软件设计者,对象的行为方式才是我们真正所关注的问题

从上面的例子,我们可以看到LSP强调并指出,OODIS-A的关系是针对行为方式而言的,并且,行为方式是可以进行合理假设的,也是客户端程序(调用方、使用者)所依赖的。

这里提到了合理假设,这个合理假设是对客户需求的合理假设,那怎样才能知道客户真正的要求呢?那就是**基于契约设计(DBC)**了:

使用DBC,类的编写者显式地规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明的前置条件(preconditions)和后置条件(postconditions)来指定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。

在重新声明派生类中的例程(routine)时,只能使用相等或者更弱的前置条件来替换原始的前置条件,只能使用相等或者更强的后置条件来替换原始的后置条件。

也就是说,当通过基类的接口使用对象时,用户只知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。也即是,派生类必须接受基类可以接受的一切。同时,派生类必须和基类的所有后置条件一致。也即是,派生类的行为方式和输出不能违反基类已经确立的任何限制。基类的用户不应该被派生类的输出扰乱。

再看回上面的例子,Rectangle#setWidth(int w)的后置条件可以看作是:assertTrue(width == w && height == old.height),这里的oldsetWidth被调用之前Rectangle的值。

显然,Square#setWidth(int w)的后置条件比Rectangle#setWidth(int w)的后置条件弱,因为它不满足height == old.height这条约束。因此,SquaresetWidth方法违反了基类订下的契约。

此外,我们也可以通过编写单元测试的方式来指定契约,单元测试通过彻底的测试一个类的行为来使该类的行为更加清晰。

对于LSP,提取公共部分是个很好的设计工具(基于继承关系时)。关于提取公共部分,大师们提出:

如果一组类都支持一个公共的职责,那么它们应该从一个公共的超类继承该职责。如果公共的超类还不存在,那么就创建一个,并把公共的职责放入其中。毕竟,这样一个类的有用性是确定无疑的--你已经展示了一些类会继承这些职责。然后稍后对系统的扩展也许会加入一个新的子类,该子类很可能会以新的方式来支持同样的职责。此时,这个新创建的超类可能会是一个抽象类。

总是保证子类可以代替它的基类是一个有效的管理复杂性的方法。一旦放弃了这一点,就必须要单独的来考虑每个类。

在实际的开发中,我们常常使用如下两种方式来违反LSP

  • 派生类中的退化函数:派生类的编写者认为某个函数在该派生类中没有用处,所以就将该函数的实现退化(空实现)。在派生类中存在退化函数并不一定表示违反了LSP,但是当出现这种情况时,还是值得注意一下。
  • 从派生类中抛出异常:在派生类的方法中添加了其基类不会抛出的异常。如果基类的使用者不期望这些异常,那么把它们添加到派生类的方法中就会导致不可替换性。此时要遵循LSP,要么就必须 改变使用者的期望,要么派生类就不应该抛出这些异常。

OCPOOD中很多原则的核心,如果这个原则应用得有效,应用程序就会具有更好的可维护性、可重用性及健壮性。LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的东西。因此,如果没有显式地强制基类类型的契约,那么代码就必须良好并且明显地表达出这一点。

我们通常所说的IS-A的含义可于宽泛,不能作为子类型的定义。子类型的正确定义是可替换性的,这里的可替换性可以通过显式或者隐式的契约来定义。

LSP引出了基于契约设计,这里的契约我们通常是通过单元测试来实现的,基于单元测试来实现我们的设计意图,以确保满足用户(使用者)的真正的需求。用户可以通过验收测试来验证设计的有效性。

LSPSpring框架和Dubbo框架的使用随处可见,比如Spring框架中的AbstractXmlApplicationContext,又比如Dubbo框架中的AbstractLoadBalance等等,太多了,毕竟,LSPOOD原则实现的基石。

题外话:

其实,再次学习LSP之后,结合软件开发设计的发展,现在才明白以前学习的TDD中的测试其实和单元测试并不能完全等同。

单元测试可以在写完代码之后再写测试用例,测试用例的目的是测试设计的意图和目标,每个测试用例是以能被隔离测试的最小独立行为的单元;

TDD中的测试是先于代码的,即所谓的代码未写测试先行,这里的测试本质上是设计的契约,测试驱动开发本质上是契约在驱动,以确保我们的设计符合预期。TDD中的测试可能有单元测试,还可以有功能测试等,其核心在于驱动,也就是说,通过一个测试来告诉你下一步该做什么。

References:
  1. 敏捷软件开发
  2. LSP
  3. Preconditions, Postconditions, and Class Invariants