案例
众所周知,在数学领域中,人们把正方形看成是一种特殊的矩形(长和宽相等),这是一种典型的一般和特殊的关系。那么,对于软件设计师来说,这种一般和特殊的关系能否用泛化来表示呢?这是一个经典的设计案例,我们来看看如何构建这个设计方案。
按照常识,这种一般和特殊的关系可以利用泛化关系来表示,即设计矩形类(Rectangle),它包含的私有属性有长(length)和宽(width),并提供公有的get和set操作来操纵这些属性;为了简化起见,假定它们的数据类型均为整型(int)。而作为矩形的特例正方形(Square),通过泛化关系继承矩形的属性和操作。当然,对于正方形来说,必须保证长和宽完全相等。因此,在正方形中必须重新定义set函数,保证在修改长或宽的同时修改对方,以保持两者完全相等。此外,考虑到在正方形中,人们更多的是使用边长(Side)的概念,因此设计人员可能会为正方形类提供setSide()和getSide()操作,以便用户按照更通用的方式操作正方形。
一切非常顺利,所构建的方案很简单,也很直观,看起来没什么问题。那么这个方案到底好不好呢?换句话说,这个设计方案的质量怎么样?如何评价?这里又涉及一个新的话题,即如何评价一个设计的质量,其实这也是软件设计面临的又一个问题。对于软件,很难找到一个直接的衡量标准去评价其好坏,也不可能通过推理或运算来计算其质量。在设计过程中,保证设计的质量是一个非常关键的问题,因为糟糕的设计可能会给软件带来灾难性的后果。
现实中,很难证明某个设计方案是出色的,但反之,证明它存在问题很简单,只需要采用反证法给出一个反例便能说明问题:现假设某用户按照下面代码所示方式去使用该方案,它的目标是增加一个矩形的长度,直到长度超过其宽度
使用矩形运行这行代码没有问题,但使用正方形就会出现问题,陷入死循环
目标
泛化关系是面向对象系统中的一种重要关系,大多数静态类型语言中的抽象、多态等机制都需要通过类之间的泛化关系来支持,即通过泛化才可以创建抽象基类和实现抽象方法的派生类。然而,在设计泛化关系的继承层次时,是什么设计规则支配着这种设计方案?又是什么样的原则保证基类和派生类之间的多态特性能够正确地发挥?这就是Liskov替换原则(The Liskov Substitution Principle, LSP)所要解答的问题。
概念(什么是LSP)
子类型(subtype) 必须能够替换它们的基类型(base type)。 换一个角度来理解,对于继承层次的设计,要求在任何情况下,子类型与基类型都是可以互换的,那么该继承的使用就是合适的,否则就可能出现问题。
即子类型不能添加任何基类型没有的附加约束。
为了避免子类型针对基类型的行为添加附加的约束(即违背LSP),基类型中应该只提供尽量少的必需的行为,而且不针对这些行为进行任何实现。此时,那些基类型往往就是抽象类(行为没有任何实现),甚至是接口。
由LSP可以引申出一条新的规则,即只要有可能,不要从具体类继承,而应该由抽象类继承或由接口实现。
由LSP引发的思考
- 从LSP的判定规则可以看出,判断继承层次是否合适并不是从参与继承的类本身来判定的,而是从使用该继承层次的程序P入手。由此可见,评价一个设计模型的质量,并不是孤立地看待设计模型本身的好坏,而应该从使用该模型的客户程序来衡量,根据客户的需求做出合理的假设来进行评价。
- “is a”关系并不一定是按照人们的常识去理解的“是”的关系,而是从使用者的行为角度去评价的。对象对外所展现的行为是否存在“is a”才是设计系统时应该考虑的。
- “is a”关系等都需要从使用者的角度去做合理假设。
契约式设计
- 契约主要分为两类:一类是为类定义不变式(invariants),对于该类的所有对象,不变式一直为真;另一类是为类的方法声明前置条件(preconditions)和后置条件(postconditions),只有前置条件为真时,该方法才可以执行,而方法执行完成后,必须保证后置条件为真。
- 当通过基类的接口使用对象时,类客户只知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。这就意味着,派生类必须接受基类可以接受的一切。
从实现继承到接口继承
- 实现继承其实就是子类继承父类
实现继承能直接简化代码,不用维护父类已经维护了的代码,从而可以让代码得到更大的复用。而它的缺点也很明显:首先就是过于依赖父类的实现,因此对父类的组织结构和扩展性要求非常高;其次就是由于破坏了类间的可替换性,会为外部应用埋下隐患。
这种继承唯一的目的就是代码复用。然而,在当今程序设计领域,很多其他技术(如聚合、类库等)也提供了代码复用的手段,但应尽量避免因代码复用而引入实现继承。
- 接口继承
与实现继承对应的就是接口继承。在这种继承层次中,派生类继承基类的属性和操作声明,并为这些操作声明提供实现;而基类一般通过抽象类或接口来声明,并不为派生类提供实现。在这种继承层次中,由于派生了只涉及契约部分的继承,因此在类间是可替换的,是一种安全的继承机制。
接口继承并不定义对象间内部关系,因此耦合度更低,扩展性更好,在有可能的情况下应尽量使用接口继承。当然,相比实现继承而言,接口继承的设计和实现难度相对较大,如何设计合理的接口(或抽象类)将是面向对象设计中所面临的关键问题。