OCP背后的主要机制是抽象和多态。在静态类型语言中,比如C++和Java,支持抽象和多态的关键机制之一是继承。正是使用了继承,才可以创建实现其基类中抽象方法的派生类。
是什么设计规则在支配着这种特殊的继承用法呢?最佳的继承层次的特征又是什么呢?怎样的情况会使我们创建的类层次结构掉进不符合OCP的陷阱中去呢?这些正是Liskov替换原则(LSP)要解答的问题。
1. 定义
对于LSP可以做如下解释:
子类型(subtype)必须能够替换掉它们的基类型(base type)
Barbara Liskov首次写下这个原则是在1988年:
这里需要如下替换性质:若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P行为功能不变,则S是T的子类型。
想想违反该原则的后果,LSP的重要性不言而喻了。假设有一个函数f,它的参数为指向某个基类B的指针或引用。同样假设有B的某个派生类D,如果把D的对象作为B类型传递给f,会导致f出现错误的行为。
那么D就违反了LSP。
2. 一个违反LSP的简单例子
对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型辨别(RTTI)。这种方式常常是使用一个显式的if语句或if/else链去确定一个对象的类型,以便于可以选择针对该类型的正确行为。
class Point { double x; double y; } enum ShapeType { square, circle } class Shape { ShapeType itsType; public Shape(ShapeType shapeType) { this.itsType = shapeType; } } class Circle extends Shape { Point itsCenter; double itsRadius; public Circle(ShapeType shapeType) { super(shapeType); } void draw() { System.out.println(this); } } class Square extends Shape { Point itsTopLeft; double itsSide; public Square(ShapeType shapeType) { super(shapeType); } void draw() { System.out.println(this); } } public class Lsp { void drawShape(Shape sh) { if (sh.itsType == ShapeType.square) { ((Square)sh).draw(); } else if (sh.itsType == ShapeType.circle) { ((Circle)sh).draw(); } } }
很显然,drawShape函数违反了OCP。它必须知道Shape类所有的派生类,并且每次创建一个从Shape类派生的新类时都必须要更改它。
Square和Circle从Shape类派生,并具有draw函数,但是它们没有覆写Shape类中的函数。因为Circle和Square类不能替换Shape类。
所以drawShape函数必须要检查输入的Shape对象,确定它的类型,接着调用正确的draw函数。
Square和Circle类不能替换Shape类其实违反了LSP,这个违反又迫使drawShape函数违反OCP,因而,对于LSP的违反也潜在违反了OCP。
2.1 正方形和矩形,更微妙的违规
class Rectangle { Point itsTopLeft; double itsWidth; double itsHeight; public double getItsWidth() { return itsWidth; } public void setItsWidth(double itsWidth) { this.itsWidth = itsWidth; } public double getItsHeight() { return itsHeight; } public void setItsHeight(double itsHeight) { this.itsHeight = itsHeight; } }
假设这个引用程序运行得很好,并被安装在许多地方。和任何一个成功的软件一样,用户的需求不时会发生变化。用户不满足仅仅操作矩形,要求添加操作正方形的功能。
经常说继承是IS-A关系,也就是说,如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新对象的类应该从这个已有对象的类派生。
从一般一样来说,一个正方形就是一个矩形。因此,把Square类视为从Rectangle类派生是合乎逻辑的。
IS-A关系的这种用法有时被认为是面向对象分析基本技术之一。
一个正方形是一个矩形,所以Square累就应该派生自Rectangle类。不过,这种想法会带来一些微妙但极为值得重视的问题。一般来说,这些问题是很难预见的,直到编写代码时才会发现它们。
首先Square类并不同时需要成员变量itsHeight和itsWidth。但是Square仍会从Rectangle中继承它们,显然这是浪费。
从Rectangle派生Square也会产生其他一些问题。Square会继承setWidth和setHeight函数,这两个函数对于Square来说是不合适的,因为正方形的长和宽是相等的。
这是表明存在问题的重要标志,不过这个问题是可以避免的。可以覆写setWidth和setHeight:
class Square extends Rectangle { @Override public void setItsHeight(double itsHeight) { super.setItsHeight(itsHeight); super.setItsWidth(itsHeight); } @Override public void setItsWidth(double itsWidth) { super.setItsWidth(itsWidth); super.setItsHeight(itsWidth); } }
现在,当设置Square对象的宽时,它的长也相应地改变。当设置长时,宽也会随之改变。这样就保持了Square要求的不变性。Square对象是具有严格数学意义下的长方形。
但是考虑下面这个函数
void f(Rectangle r) { r.setWidth(5); r.setHeight(4); assert(r.Area() == 20) }
如果向这个函数传递一个Square对象,这个Square对象就会被破坏,因为函数的本意并不是要改变长。这显然违反了LSP。根因是函数f的编写者假设改变Rectangle的宽不会导致长的改变。
函数f的表现说明有一些使用指向Rectangle对象的函数,不能正确操作Square对象。对于这些函数来说,Square不能够替换Rectangle,因此Square和Rectangle之间的关系是违反LSP的。
3. 基于契约设计
使用基于契约设计DBC(Design By Contract),类的编写者显式规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。
4. 一个实际的例子
有一个第三方的类库,包括一些容器类,这些容易中有两个Set的变体(variety)。
一个变体是有限的(bounded),是基于数组实现的,第二个变体是无限的,是基于链表实现的。
我们不希望自己的应用程序代码依赖于这些容器类,以后会有更好的来替代它们,一次,把它们包装在自己的抽象接口中。
如图,创建一个Set的接口,提供add,delete,isMemeber方法。这个结构统一了第三方集合的两个变体:unbounded变体和bounded变体,让我们通过一个公共的接口访问它们。这样,客户就可以接受类型为Set的参数而不用关心实际使用的Set是bounded变体还是unbounded变体。
不用关心所使用的Set的具体类型,这是一个很大的优点。这意味着程序员可以在每个具体的情况中选择所需要的Set种类,而不会影响到客户函数。在内存紧张而速度要求不严格时,程序员可以选择UnboundedSet,或在内存充足而堆速度有严格要求时,可以选择BoundedSet,客户函数时通过基类Set的接口来操纵这些对象,因此也就不必关心使用的是哪种Set。
4.1 问题
在该层次中加入PersistentSet。遗憾的是,能够访问的唯一的、同时也提供了持久化功能的第三方容器类不是一个模板类。相反,只接受PersistentObject的派生对象。
PersistentSet包含了一个第三方持久性集合的实例,它把它的所有方法都委托给该实例。这样,如果调用PersistentSet的add方法,它就简单地把该调用委托给第三方持久性集合中包含的对应方法。
加入到第三方持久性集合中的元素必须得从PersistentObject继承。由于PersistentSet只是把调用委托给第三方持久性集合,所以任何要加入PersistentSet的元素也必须得从PersistentObject派生。可是,Set的接口没有这样的限制。
当客户程序向基类Set中加入元素时,客户程序不能确保该Set实际上是否是一个PersistentSet。因而,客户程序没有办法知道它所加入的元素是否应该从PersistentObject继承。
class PersistentSet implments Set{ void add(Object e) { PersistentObject po = (PersistentObject)e; persistentSet3rd.add(po); } }
从该代码可以明显看出,如果任何客户企图想PersistentSet中添加不是从PersistentObject派生的对象,将会发生运行时错误。但是抽象基类Set的所有现存的客户都不会预计到调用add会抛出异常。由于Set的派生类会导致这些函数出错误,所以对类层次所做的这种改动违反了LSP。
4.2 符合LSP的解决方案
PersistentSet和Set之间不存在IS-A关系,它不应该派生自Set。因此分离这个层次结构,但不是完全的分离。
Set和PersistentSet之间有一些公有的特性。
事实上,仅仅是add方法致使在LSP原则下出了问题,因此,创建一个层次结构,其中Set和PersistentSet是兄弟关系。统一在一个具有测试成员关系,遍历等操作的抽象接口下。这就可以对PersistentSet对象进行遍历以及测试成员关系等操作。但是不能够把不是派生自Persistent的对象加入到PersistentSet中。
5. 总结
OCP是OOD中很多说法的核心,如果这个原则应用得有效,应用程序就会具有更多的可维护性、可重用性以及健壮性。
LSP是使OCP称为可能的主要原则之一。
正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的东西。
术语IS-A的含义过于宽泛以至于不能作为子类型的定义。子类型的正确定义是“可替换性的”,这里的可替换性可以通过显式或隐式的契约来定义。