《敏捷软件开发 原则、模式与实践(c#版)》
第10章 LSP:LisKov替换原则
OCP背后的主要机制是抽象和多态。在静态类型语言中,比如C#,支持抽象和多态的关键机制之一是继承。正是使用了继承,我们才可以创建实现其基类中抽象方法的派生类。
是什么设计规则在支配着这种特殊的继承用法呢?最佳的继承层次的特征又是什么呢? 怎样的情况会使我们创建的类层次结构掉进不符合OCP的陷阱中去呢?这些正是LisKov替换原则(LSP)要解答的问题。
定义
“子类型(subtype)必须能够替换掉它们的基类型(base type)”。
Barbara LisKov首次写下这个原则是在1988年。她说道:
“这里需要如下的替换性质:若对类型S的每一个对象O1,都存在一个类型T的对象O2,使得在所有针对T编写的程序P中,用O1替换O2后,程序P的行为功能不变,则S是T的子类型。”
违反LSP的情形
对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型检查。通常,会使用一个显式的if语句或者if/else链去确定一个对象的类型,以便于可以选择针对该类型的正确行为。请看代码1-1
using System.Collections.Generic;
using System.Text;
namespace Lsp
{
struct Point { double x, y;}
public enum ShapeType { square, circle };
public class Shape
{
private ShapeType type;
public Shape(ShapeType t) { type = t; }
public static void DrawShape(Shape s)
{
if (s.type == ShapeType.square)
{
(s as Square).Draw();
}
else if (s.type == ShapeType.circle)
{
(s as Circle).Draw();
}
}
}
public class Circle : Shape
{
private Point center;
private double radius;
public Circle() : base(ShapeType.circle) { }
public void Draw() { /* draws the circle */ }
}
public class Square : Shape
{
private Point topLeft;
private double radius;
public Square() : base(ShapeType.square) { }
public void Draw() { /* draws the circle */ }
}
}
代码1-1
很显然,代码1-1中的DrawShape函数违反了OCP。它必须知道Shape类每个可能的派生类,并且每次创建一个从Shape类派生的新类时都必须要更改它。事实上,很多人肯定的认为这种函数结构简直是对良好设计的诅咒。那么,是什么促使程序员编写出类似这样的函数呢?
假设Joe是一个工程师。他学过面向对象技术,并前认为多态的开销大得难以忍受(在一个具有相当速度的计算机中,每个方法调用的开销是lns的数量级,所以Joe的观点是不正确的)。因此,他定义了一个没有任何抽象方法的Shape类。类Square和Circle从Shape类派生,并具有Draw()函数,但是它们没有重写Shape类中的函数。因为Circle类和Square类不能替换Shape类,所以DrawShape函数必须要仔细检查传入的Shape对象,确定它的类型,接着调用正确的Draw函数。
Square类和Circle类不能替换Shape类其实是违反了LSP。这个违反又迫使DrawShape函数违反了OCP。因而,对于LSP的违反也潜在地违反了OCP。
更微妙的违反情形
当然存在更微妙的违反LSP的情形。考虑一个使用了代码1-2中描述的Rectangle类的应用程序。
{
private Point topLeft;
private double width;
private double height;
public double Width
{
get { return width; }
set { width = value; }
}
public double Height
{
get { return height; }
set { height = value; }
}
}
代码1-2
假设这个应用程序运行得很好,并被安装在许多地方。和任何一个成功的软件一样,用户的需求不是会发生变化。某一天,用户不满足仅仅操作矩形,要求添加操作正方形的功能。
我们经常说继承是IS-A关系。也就是说,如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新对象的类应该从这个已用对象的类派生。
从一般意义上讲,一个正方形就是一个矩形。因此,把Square类视为从Rectangle类派生是合乎逻辑的。
IS-A关系的这种用法有时被认为是面向对象分析(一个被频繁使用却很少定义的术语)的基本技术之一。一个正方形是一个矩形,所以Square类就应该派生自Rectangle类。不过,这种想法会带来一个微妙但极为值得我们重视的问题。一般来说,这些问题是很难预见的,知道我们编写代码才会发现。
我们首先注意到的出问题的地方是,Square类并不同时需要成员变量height和width。但是Square仍会从Rectangle中继承它们。显然这是浪费。在许多情况下,这种浪费是无关紧要的。但是,如果我们必须要创建成百上千Square对象(比如,在CAD/CAM中复杂的电路的每个原件的管脚引线都作为正方形进行绘制),浪费的程序是巨大的。
假设目前我们并不十分关心内存效率。从Rectangle派生Square也会产生其它一些问题。Square会继承Width和Height的设置方法属性。这些属性对于Square来说是不合适的,因为正方形长和宽是相等的。这是表明存在问题的重要标志。不过这个问题是可以避免的。我们可以按照如下方式重写Width和Height:
{
set
{
base.Width = value;
base.Height = value;
}
}
public new double Height
{
set
{
base.Width = value;
base.Height = value;
}
}
现在,当设置Square对象的宽时,它的长会相应地改变。当设置长时,宽也会随之改变。这样,就保持了Square要求的不变性。Square对象是具有严格意义下的正方形。
s.Width=1;
s.Height=2;
但是考虑下面这个函数:
{
r.Width=32; //calls Rectangle.SetWidth
}
如果我们向这个函数传递一个指向Square对象的引用,这个Square对象就会被破坏,因为它的长并不会改变。这显然违反了LSP。以Rectangle的派生类的对象作为参数传入时,函数f不能正确运行。错误的原因是在Rectangle中没有把SetWidth和SetHeight声明为Virtual;因此它们不是多态的。
这个错误很容易修正,只要把设置方法属性声明为Virtual即可。然而,如果派生类的创建会导致我们改变基类,这就常常意味着设计是有缺陷的。当然也违反了OCP。也许有人会反驳说, 真正的设计缺陷是忘记把Width和Height声明为Virtual的,而我们已经做了修正。可是,这很难让人信服,因为设置一个长方形的长和宽是非常基本的操作。如果不是预见到Square的存在,我们凭什么要把它们声明为Virtual的呢?
尽管如此,假设我们接受这个理由并修正这些类。修正后的代码1-3如下:
{
private Point topLeft;
private double width;
private double height;
public virtual double Width
{
get { return width; }
set { width = value; }
}
public virtual double Height
{
get { return height; }
set { height = value; }
}
}
public class Square : Rectangle
{
public override double Width
{
set
{
base.Width = value;
base.Height = value;
}
}
public override double Height
{
set
{
base.Width = value;
base.Height = value;
}
}
}
代码1-3
真正的问题
现在 Square和Rectangle看起来都能够工作。无论对Square对象进行什么样的操作,它都和数学意义上的正方形保存一致。无论我们对Rectangle对象进行什么样的操作,它都和数学意义上的长方形保存一致。此外,可以向接受Rectangle的函数传递Square,而Square依然保存正方形的特征,与数学意义上的正方形一致。
这样看来该设计似乎是自相容的,正确的。可是,这个结论是错误的。一个自相容的设计未必就和所有的用户程序相容。考虑下面的函数g:
{
r.Width=5;
r.Height=4;
if(r.Area() !=20) { throw new Exception("Bad area!");}
}
这个函数认为所传递进来的一定是Rectangle,并调用其成员Width和Height。对于Rectangle来说,吃函数运行正确,但是如果传递进来的是Square对象就会抛出异常。所以,真正的问题是:函数g的编写者假设Rectangle的宽不会导致其长的改变。
很显然,改变一个长方形的宽不会影响它的长的假设是合理的!然而,并不是所有可以作为Rectangle传递的对象都满足这个这个假设。如果把一个Square类的实例传递给像g这样做了该假设的函数,那么这个函数就会出现错误的行为。函数g对于Square/Rectangle层次结构来说是脆弱的。
函数g的表现说明:存在有使用Rectangle对象的函数,它们不能正确地操作Square对象。对于这些函数来说,Square不能够替换Rectangle,因此Square和Rectangle之间的关系违反LSP的。
有人会对函数g中存在的问题进行争辩,他们认为函数g的编写者不能假设宽和长是独立的。g的编写者不会同意这种说法的。函数g以Rectangle作为参数。并且确实有一些不变性质和原理说明明显适用于Rectangle类,其中一个不变性质就是长和宽是独立的。g的编写者完成可以对这个不变性质进行断言。倒是Square的编写者违反了这个不变性。
真正有趣的是,Square的编写者没有违反正方形的不变性。由于把Square从Rectangle派生,Square的编写者违反了Rectangle的不变性!
有效性并非本质属性
LSP让我们得出了一个非常重要的结论:一个模型,如果孤立的看,并不具有真正意义上的有效性。模型的有效率性只能通过它的客户程序来表现。例如,如果孤立的看,最后那个版本的Rectangle和Square是自相容的且有效的。但是如果从对基类作出了一些合理假设的程序员的角度来看,这个模型就是有问题的。
在考虑一个特定设计是否恰当时,不能完全孤立地看这个解决方案。必须要根据该设计的使用者所作出的合理假设来审视它。(这些合理的假设常常会以断言的形式出现在为基类编写的单元测试中。这是又一个要实践测试驱动开发的好理由)
有谁知道设计的使用者会作出什么样的合理假设呢?大多数这样的假设都很难预测。事实上,如果试图去预测所有这些假设,我们所得到的系统很可能会充满不必要的复杂性的臭味。因此,像所有其它原则一样,通常最好的方法只是预测那些最明显的对于LSP的违反情况而推迟所有其它的预测,直到出现相关的脆弱性的臭味时,才去处理它们。
ISA是关于行为的
那么究竟是怎么回事?Sauare和Rectangle这个显然合理的模型为什么会有问题呢?毕竟,Square不也是Rectangle吗?难道它们之间不存在IS-A关系吗?
对于那些不是g的编写者而言,正方形可以是长方形,但是从g的角度来看,Square对象绝对不是说Rectangle对象。为什么?因为Square对象的行为方式和函数g所期望的Rectangle对象的行为方式不相容。从行为方式的角度来看,Square不是Rectangle,对象的行为方式是可以进行合理假设的,是客户程序所依赖的。
基于契约设计
许多开发人员可能会对“合理假设”行为方式的概念感到不安。怎样才能知道客户真正的要求呢?有一项技术可以使这些合理的假设明确化,从而支持了LSP。这项技术称为基于契约设计(Design By Contract,DBC),Bertrand Meyer对此进行过详细的介绍。
使用DBC,类的编写者显示地规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明前置条件(Precondition) 和后置条件(Postcondition)来指定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。
设置Rectangle.Width的方法的后置条件可看做是:
在这个例子中,old是Width被调用前Rectangle的值。按照Meyer所述,派生类的前置条件和后置条件规则是:“在重新声明派生类中的例程时,只能使用相等或者更弱的前置条件来替换原始的前置条件,只能使用相等或者更强的后置条件来替换原始的后置条件。”
换句话说,当通过基类的接口使用对象时,用户只知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。也就是说,它们必须接受基类可以接受的一切。同时,派生类必须和基类的所有后置条件一致。也就是说,它们的行为方式和输出不能违反基类已经确立的任何限制。基类的用户不应被派生类的输出扰乱。
显然,Square.Width setter的后置条件比Rectangle.Width setter的后置条件弱(弱是一个容易混淆的概念。如果X没有遵从Y的所有约束,那么X就比Y弱。X所遵从的新约束的数目是无关紧要的)。因为它不服从(height == old.height)这个约束。因而,Square的Width属性违反了基类订下的契约。
某些语言,比如Eiffel,对前置条件和后置条件有直接的支持。你只需声明它们,运行时系统会去检查它们。C#中没有此项特征。在C#中,我们必须自己考虑每个方法的前置条件和后置条件,并确保没有违反Meyer规则。此外,为每个方法的注释中注明它们的前置条件和后置条件是非常与欧帮助的。
在单元测试中指定契约
也可以通过编写单元测试的方式来指定契约。单元测试通过彻底的测试一个类的行为来使该类的行为更加清晰。客户代码的编写者会去查看这些单元测试,这样他们就可以知道对于要使用的类,应该作出什么合理的假设。
后记:
在这之后,还有一个实际的例子,介于精力,就不在这里写出来了。
End.