OCP背后的主要机制是抽象和多态。在静态语言中,支持抽象和多态的关键机制是继承。正是使用了继承,才可以创建实现其基类中抽象方法的抽象类。
那么我们在使用继承的过程中,应该需要注意哪些问题呢?这就正是本文中要介绍的一个敏捷原则——[里氏替换原则]
首先,简单的说明一下什么是LSP。 LSP:子类型必须能够替换掉它们的基类型 。
下面看一个简单微妙的违反LSP的示例:矩形与正方形的关系。从几何角度上来说,两者是完全意义上的IS-A关系。也就是说:正方形是矩形,正方形是矩形的一个特例。这完全没有错,但是把这个问题延伸到软件设计的领域就别有洞天了。
为什么会这样呢?因为矩阵有长和宽两个特性,而正方形只有边长这一个特性。这样在软件设计中,如果正方形继承自矩形就完全不适合了。实际也证明了如果使用继承关系是错误的。LSP让我们得到一个非常重要的结论:一个模型,如果鼓励的看,并不具有真正意义上的有效性。模型的有效性之能通过它的客户程序来表现。也就是说:一个模型,在被使用的过程中体现价值。
在考虑一个特定设计是否恰当时,不能完全孤立的来看这个解决方案,必须要根据该设计的使用者所作出的合理假设来审视它。个人觉得[测试驱动开发]这一方法所上述理论的最佳实践。测试驱动开发要求首先编写测试,也就是客户代码。这样就从使用者的角度来审视一个接口的外部行为。
IS-A是关于行为的!矩阵和正方形这个合理的特例关系为什么会出问题呢?原因在于矩形对象和正方形对象的外在行为方式不相容。矩形可以设置长和宽,而正方形呢?只能设置边长。从行为方式的角度,正方形不是矩形。对象的行为方式所软件真正所关注的问题。LSP清楚的指出:OOD中IS-A关系是就行为方式而言的,行为方式所可以进行合理假设的,是客户程序所依赖的。
关于这个主题,还有一个相关的理论——基于契约设计(DBC Design By Contract)。
使用DBC,类的编写者现实地规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明的前置条件和后置条件来指定的。在方法执行前,前置条件必须为真。在方法执行后,后置条件必须为真。
用比较直白的话来说明一下上述的理论:当通过基类的接口使用对象时,用户只知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。也就是说,他们必须接受基类可以接受的一切。同时,派生类必须和积累的所有后置条件一致。也就是说,他们的行为方式和输出不能违反基类已经确立的任何限制。
用比较专业的方式总结一下,这就是Meyer原则:在重新声明派生类中的例程时,只能使用相等或者更弱的前置条件来替换原始的前置条件,只能使用相等或更强的后置条件来替换原始的后置条件。
如果遇到违反LSP原则的情况,应该如何处理应对呢?方法是:用提取公共部分的方法代替继承。既然子类不能完全替代基类,并且二者有很多相同的部分,只有部分行为不相容。那就提取二者的相同部分,将继承关系修改为继承自公共基类的兄弟关系。
如果一组类都支持一个公共的职责,那么它们应该从一个公共的超类继承该职责,如果公共的超类不存在,那么就创建一个,并把公共职责放入其中。
还有两种隐式的违反LSP原则的情况。第一:派生类中的退化函数,派生类中没有很好的支持父类中的方法。第二:在派生类的方法中添加了其基类不会抛出的异常。如果基类的使用者不期望这些异常,那么把它们添加到派生类的方法中就会导致不可替换性。
LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类型的模块在无需修改的情况下就可以扩展。