敏捷软件开发 – LSP Liskov替换原则

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、

时间: 2024-11-05 12:07:58

敏捷软件开发 – LSP Liskov替换原则的相关文章

敏捷软件开发 – OCP 开放-封闭原则

软件实体(类.模块.函数等)应该是可以扩展的,但是不可修改的. 如果程序中的一处改动就会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味.OCP建议我们应该对系统进行重构,这样以后对系统再进行这样那样的改动时,就不会导致更多的修改.如果正确地应用OCP,那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码. OCP概述 遵循开放-封闭原则设计出的模块具有两个主要的特征.它们是: 对于扩展是开放的(open for extension).这意味着模块的行

敏捷软件开发 – SRP 单一职责原则

SRP:单一职责原则  一个类应该只有一个发生变化的原因. 为何把两个职责分离到单独的类中很重要呢?因为每一个职责都有变化的一个轴线.当需求变化时,该变化会反映为类的职责的变化.如果一个类承担了多于一个的职责,那么引起它变化的原因就会有多个. 如果一个类承担的职责过多,就等于把这些职责耦合在了一起.一个职责发生变化可能会削弱或抑制这个类完成其他职责的能力.这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏. 有两个不同的应用程序使用Rectangle类.一个应用程序是有关计算几何

敏捷软件开发的12个原则

作为一个软件工程师,软件设计和开发是最重要的技能,但是,从整个产品的角度上讲,项目管理能力比开发能力更重要,本文摘自Robert大叔的<敏捷软件开发>,粗体是Robert大叔的话,细体是我的理解. 1.持续.尽早交付有价值的软件以满足客户,是我们优先要做的首要任务. 以逐渐增加功能的方式经常性地交付系统和最终质量之间有非常强的相关性.交付得越频繁,最终产品的质量就越高. 自顶向下地设计软件,按照功能优先级逐步开发,定期交付可运行的版本.这个原则看起来简单,但是对软件设计有非常高的要求,因为随着

敏捷软件开发 – ISP 接口隔离原则

如果类的接口不是内聚的,就表示该类具有“胖”接口.换句话说,类的“胖”接口可以分解成多组方法.每一组方法服务于一组不同的客户程序. ISP承认有一些对象确实需要有非内聚的接口,但是ISP建议客户程序不应该看到它们作为单一的类存在.相反,客户程序看到的应该是多个具有内聚接口的抽象基类. 接口污染 考虑一个安全系统.在这个系统中,有一些Door对象,可以被加锁和解锁,并且Door对象知道自己是开着还是关着.这个Door编码成一个接口,这样客户程序就可以使用那些符合Door接口的对象,而不需要依赖于D

敏捷软件开发 – DIP 依赖倒置原则

DIP 依赖倒置原则 高层模块不应该依赖于低层模块.二者都应该依赖于抽象. 抽象不应该依赖于细节.细节应该依赖于抽象. 依赖于低层模块的高层模块意味着什么?正是高层模块包含了应用程序中重要的策略选择和业务模型.这些高层模块使得其所在的应用程序区别于其他.然而,如果这些高层模块依赖于低层模块,那么对于低层模块的改动会直接影响到高层模块,从而迫使它们依次做出改动.如果高层模块独立于低层模块,那么高层模块就可以非常容易地被重用.该原则是框架设计的核心原则. 层次化 糟糕的层次关系. 更为适合的模型.每

敏捷软件开发要点【转载】

下面的文字来自于<敏捷软件开发 原则.模式和实践>一书,作者是Robert C. Martin.我把这些文字发布在这里,希望对敏捷软件开发还不是很了解的朋友所有帮助.我推崇这本书,是因为它提出了许多有价值的软件项目管理的理念,以及软件设计思想和方法,其中,很多可以直接用在我们的工作中,或用来指导我们的工作----敏捷软件开发是务实的. 一.敏捷软件开发宣言 我们正在通过亲身的实践以及帮助他人实践,揭示更好的软件开发方法.通过这项工作,我们认为: 个体和交互 胜过 过程和工具 可以工作的软件 胜

敏捷软件开发:原则、模式与实践——第10章 LSP:Liskov替换原则

第10章 LSP:Liskov替换原则    Liskov替换原则:子类型(subtype)必须能够替换掉它们的基类型(base type). 10.1 违反LSP的情形 10.1.1 简单例子 对LSP的违反导致了OCP的违反: struct Point { double x, y;} public enum ShapeType { square, circle }; public class Shape { private ShapeType type; public Shape(Shape

软件设计----LisKov替换原则(LSP)

LisKov替换原则的定义:一个软件实体如果使用的是一个基类的话,一定适用于其子类,而且根本不能觉察出基类对象和子类对象的区别. 1)怎么理解上面的概念?就是我们程序设计的子类型能够完全替换父类型,而不会让调用父类型的客户程序从行为上有任何改变. 2)这条原则的意义是什么?这条原则主要是为了保证代码对扩展开放,只要做到子类可以完全替代基类的行为,那么新增的具体子类在重载父类时,就不会对客户代码带来任何不良影响,因而实现了对扩展开放. 在设计的时候,我们就可以使用这个原则,来判断我们设计的子类是否

[书摘]《敏捷软件开发: 原则、模式与实践》第一部分:敏捷开发

面向对象设计的原则 单一职责 开放-封闭 Liskov替换原则 依赖倒置原则 接口隔离原则 重用发布等价原则 共同封闭原则 共同重用原则 无环依赖原则 稳定以来原则 稳定抽象原则 人的重要性 交付产品的关键因素是人,而不是过程.(敏捷 Agile) 人与人之间的交互式复杂的,并且其效果从来都是难以预期,但却是工作中最为重要的方面. ------ Tom DeMacro 和 Timothy Lister<人件> 有凝聚力的团队将具有最强大的软件开发力量. 敏捷软件开发宣言 我们一直在实践中探寻更