Liskov替换原则:子类型(subtype)必须能够替换掉它们的基类型(basetype)。
违反LSP的情形
对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型检查。通常,会使用一个显式的if语句或者if/else链去确定一个对象的类型,以便于可以选择针对该类型的正确行为。
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 side; public Square() : base(ShapeType.square) { } public void Draw() {/* draws the square */} }
很显然,DrawShape函数违反了OCP。它必须知道Shape类每个可能的派生类,并且每次新建一个从Shape类派生的新类时都必须要改变它。
Shape类和Circle类不能替换Shape类其实是违反了LSP。这个违反又迫使DrawShape函数违反了OCP。因而,对于LSP的违反也潜在地违反了OCP。
更微妙的违反情形
public class 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; } } }
假设这个应用程序运行的很好,并且被安装在许多地方。和任何一个成功的软件一样,用户的需求不时会发生变化。某一天,用户不满足与仅仅操作矩形,要求添加操作正方形的功能。
我们经常说继承是IS-A(是一个)关系。也就是说,如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A关系,那么这个新的对象的类应该从这个已有对象的类派生。
从一般意义上讲,一个正方形就是一个矩形。因此把Square类视为从Rectangle类派生是合乎逻辑的。不过,这种想法会带来一些微妙但几位值得重视的问题。一般来说,这些问题是很难遇见的,直到我们编写代码时才会发现。
Square类并不同时需要height和width。但是Square仍会从Rectangle中继承它们。显然这是浪费。假设我们不十分关心内存效率。从Rectangle派生Square也会产生其他一些问题,Square会继承Width和Height的设置方法属性。这些属性对于Square来说是不合适的,因为正方形的长和宽是相等的。这是表明存在问题的重要标志。不过这个问题是可以避免的。我们可以按照如下方式重写Width和Height:
public class Square : Rectangle { public new double Width { set { base.Width = value; base.Height = value; } } public new double Height { set { base.Height = value; base.Width = value; } } }
但是考虑下面这个函数:
void f(Rectangle r) { r.Width=32;//调用Rectangle.SetWidth }
如果我们向这个函数传递一个Square对象的引用,这个Square对象就会被破坏,因为它的长并不会改变。这显然违反了LSP。以Rectangle的派生类的对象作为参数传入时,函数f不能正确运行。错误的原因是在Rectangle中没有把SetWidth和SetHeight声明为virtual,因此它们不是多态的。
自相容的Rectangle类和Square类
public class Rectangle { 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.Height = value; base.Width = value; } } }
真正的问题
现在Square和Rectangle看起来都能够工作。这样看起来该设计似乎是自相容的、正确的。可是,这个结论是错误的。一个自相容的设计未必就和所有的用户程序相容。考虑如下函数:
void g(Rectangle r) { 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让我们得出一个非常重要的结论:一个模型,如果孤立的看,并不具有真正意义上的有效性。模型的有效性只能通过它的客户程序来表现。
在考虑一个特定的设计是否恰当时,不能完全孤立地来看这个解决方案。必须要根据该设计的使用者所作出的合理假设来审视它(这些合理的假设常常以断言的形式出现在为基类编写的单元测试中。这是有一个要实践测试驱动开发的好理由)。
有谁知道设计的使用者会作出什么样的合理假设呢?大多数这样的假设都很难预测。事实上,如果视图去预测所有这些假设,我们所得到的系统很可能充满不必要的复杂性的臭味。因此,向所有其他原则一样,通常最好的办法是只预测那些最明显的对于LSP的违反情况而推迟所有其他的预测,直到出现相关的脆弱性的臭味时,才去处理他们。
基于契约设计
许多开发人员可能会对“合理假设”行为方式的概念感到不安。怎样才能知道客户真正的要求呢?有一项技术可以使这些河里的假设明确化,从而支持了LSP。这项技术被称之为基于契约设计(Design By Contract,DBC)
使用DBC,类的编写者显式的规定针对该类的契约。客户代码的编写者可以通过该契约获悉可以依赖的行为方式。契约是通过为每个方法声明前置条件(precondition)和后置条件(postcondition)来指定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。
当通过基类的接口使用对象时,用户只知道基类的前置条件和后置条件。因此,派生类对象不能期望这些用户遵从比基类更强的前置条件。也就是说,他们必须接受基类可以接受的一切。同时,派生类必须和基类的所有后置条件一致。也就是说,它们的行为方式和输出不能违反基类已经确定的任何限制。基类的用户不应被派生类的输出扰乱。
在单元测试中指定契约
可以通过编写单元测试的方式来指定契约。单元测试通过彻底的测试一个类的行为来使该类的行为更加清晰。客户代码的编写者回去查看这些单元测试,这样他们就可以知道对于要使用的类,应该做出什么合理的假设。
用提取公共部分的方法代替继承
提取公共部分是一个有效的工具。如果两个子类中具有一些公共的特性,那么很可能稍后出现的其他类也会需要这些特性。
如果一组类都支持一个公共的职责,那么它们应该从一个公共的超类继承该指责。
如果公共的超类还不存在,那么就创建一个,并把公共的职责放入其中。毕竟,这样一个类的有用性是确定无疑的 - 你已经展示了一些类会继承这些职责。然而稍后对系统的扩展也许会加入一个新的子类,该子类很可能会以新的方式来支持同样的职责。此时,这个新创建的超类可能会是一个抽象类。
OCP是OOD中很多说法的核心。如果这个原则应用得有效,应用程序就会具有更强的可维护性、可重用性以及健壮性。LSP是使OCP称为可能的主要原则之一。正式子类型的可替换性才使得使用基类型表示的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的。这样,如果没有在代码中显示地支持基类型的契约,那么就必须要很好的、广泛地理解这些契约。
子类型的正确定义是可替换的,这里的可替换性可以通过显示或者隐式的契约来定义。
摘录自:[美]RobertC.Martin、MicahMartin著,邓辉、孙鸣译 敏捷软件开发原则、模式与实践(C#版修订版) [M]、人民邮电出版社,2013、135-151、