美文网首页
2019-03-24 敏捷软件开发 10-12章

2019-03-24 敏捷软件开发 10-12章

作者: ShawnPanCn | 来源:发表于2019-03-24 19:50 被阅读0次

Liskov替换原则:子类型(subtype)必须能够替换掉它们的基类型(basetype)。

违反LSP的情形

  对于LSP的违反常常会导致以明显违反OCP的方式使用运行时类型检查。通常,会使用一个显式的if语句或者if/else链去确定一个对象的类型,以便于可以选择针对该类型的正确行为。

structPoint {double x, y;}publicenum ShapeType { square, circle };publicclass Shape

{

    private ShapeType type;

    publicShape(ShapeType t) { type = t; }

    publicstaticvoid DrawShape(Shape s)

    {

        if(s.type == ShapeType.square)

            (s as Square).Draw();

        elseif(s.type == ShapeType.circle)

            (s as Circle).Draw();

    }

}publicclass Circle : Shape

{

    private Point center;

    privatedouble radius;

    publicCircle() :base(ShapeType.circle) { }

    publicvoidDraw() {/* draws the circle */}

}publicclass Square : Shape

{

    private Point topLeft;

    privatedouble side;

    publicSquare() :base(ShapeType.square) { }

    publicvoidDraw() {/* draws the square */}

}

  很显然,DrawShape函数违反了OCP。它必须知道Shape类每个可能的派生类,并且每次新建一个从Shape类派生的新类时都必须要改变它。

Shape类和Circle类不能替换Shape类其实是违反了LSP。这个违反又迫使DrawShape函数违反了OCP。因而,对于LSP的违反也潜在地违反了OCP。

更微妙的违反情形

publicclass Rectangle

{

    private Point topLeft;

    privatedouble width;

    privatedouble height;

    publicdouble Width

    {

        get{return width; }

        set{ width = value; }

    }

    publicdouble 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:

publicclass Square : Rectangle

{

    publicnewdouble Width

    {

        set        {

            base.Width = value;

            base.Height = value;

        }

    }

    publicnewdouble 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类

publicclass Rectangle

{

    private Point topLeft;

    privatedouble width;

    privatedouble height;

    publicvirtualdouble Width

    {

        get{return width; }

        set{ width = value; }

    }

    publicvirtualdouble Height

    {

        get{return height; }

        set{ height = value; }

    }

}publicclass Square : Rectangle

{

    publicoverridedouble Width

    {

        set        {

            base.Width = value;

            base.Height = value;

        }

    }

    publicoverridedouble Height

    {

        set        {

            base.Height = value;

            base.Width = value;

        }

    }

}

真正的问题

  现在Square和Rectangle看起来都能够工作。这样看起来该设计似乎是自相容的、正确的。可是,这个结论是错误的。一个自相容的设计未必就和所有的用户程序相容。考虑如下函数: 

void g(Rectangle r)

{

    r.Width =5;

    r.Height =4;

    if(r.Area() !=20)

    {

        thrownewException("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称为可能的主要原则之一。正式子类型的可替换性才使得使用基类型表示的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的。这样,如果没有在代码中显示地支持基类型的契约,那么就必须要很好的、广泛地理解这些契约。

子类型的正确定义是可替换的,这里的可替换性可以通过显示或者隐式的契约来定义。

DIP 依赖倒置原则

高层模块不应该依赖于低层模块。二者都应该依赖于抽象。

抽象不应该依赖于细节。细节应该依赖于抽象。

  依赖于低层模块的高层模块意味着什么?正是高层模块包含了应用程序中重要的策略选择和业务模型。这些高层模块使得其所在的应用程序区别于其他。然而,如果这些高层模块依赖于低层模块,那么对于低层模块的改动会直接影响到高层模块,从而迫使它们依次做出改动。如果高层模块独立于低层模块,那么高层模块就可以非常容易地被重用。该原则是框架设计的核心原则。

层次化

  糟糕的层次关系。

  更为适合的模型。每个较高层都为它所需要的服务声明一个抽象接口。较低的层次实现了这些抽象接口。每个高层类都通过该抽象接口使用下一层。这样高层就不依赖于低层。低层反而依赖与在高层中声明的抽象服务接口。这不仅解除了PolicyLayer对于UtilityLayer的传递依赖关系,甚至也解除了PolicyLayer对于MechanismLayer的依赖关系。

倒置的接口所有权

   倒置不仅仅是依赖关系的倒置,它也是接口所有权的倒置。我们通常会认为工具库应该拥有它们自己的接口。但是当应用了DIP时,我们发现往往是客户拥有抽象接口,而它们的服务者则从这些抽象接口派生。

  这就是著名的Hollywood原则:"Don't call us, we'll call you.(不要调用我们,我们会调用你。)"低层模块实现了在高层模块中声明并被高层模块调用的接口。

  通过这种倒置的接口所有权,对于MechanismLayer和UtilityLayer的任何改动都不会再影响到PoliyLayer。而且,PolicyLayer可以在定义了符合PolicyServiceInterface的任何上下文中重用。这样,通过倒置这些依赖关系,我们创建了一个更灵活、更持久、更易改变的结构。

  这里所说的所有权仅仅是指接口是随拥有它们的客户程序发布的,而非实现它们的服务器程序。接口和客户程序位于同一个包或者库中。这就迫使服务器程序库或者包依赖于客户程序库或者包。

  当然,有时我们会不想让服务器程序依赖于客户程序,特别是当有多分客户程序但是服务器却仅有一份时。在这种情况下,客户程序必须得遵循服务接口,并把它发布到一个独立的包中。

依赖于抽象

  程序中所有的依赖关系都应该终止于抽象类或者接口。

  任何变量都不应该持有一个指向具体类的引用。

  任何类都不应该从具体类派生。

  任何方法都不应该重写它的任何基类中已经实现了的方法。

  当然,每个程序都会有违反该启发规则的情况。有时必须创建具体类的实例,而创建这些实例的模块将会依赖于它们。此外,该启发规则对于那些虽然是具体但却稳定的来来说似乎不大合理。如果一个具体类不太会改变,并且也不会创建其他类似的派生类,那么依赖于它并不会造成损害。

  比如,在大多数系统中,描述字符串的类都是具体的。例如,在C#中的String。该类是稳定的。也就是说,它不太会改变。因此,直接依赖于它不会造成伤害。

  如果一个不稳定的类的接口必须变化是,这个变化一定会影响到表示该类的抽象接口。这种变化破坏了由抽象接口维系的隔离性。

  由此可知,该启发规则对问题的考虑有点儿简单了。另一方面,如果看得更远一点,认为是客户模块或者层来声明它们需要的服务接口,那么仅当客户需要时才会对接口进行改变。这样,改变实现抽象接口的类就不会影响到客户。

找出潜在的抽象

  接口可以被许多不同的客户使用,并被许多不同的服务者实现。这样,接口就需要独立存在而不属于任何一方。在C#中,可以把它放在一个单独的命名控件和库中。

结论

  使用传统的过程化程序设计所创建出来的依赖关系结构、策略是依赖于细节的。这是糟糕的,因为这样会使策略受到细节的改变的影响。面向对象的程序设计倒置了依赖关系结构,使得细节和策略都依赖于抽象,并且常常是客户程序拥有服务接口。

  事实上,这种依赖关系倒置正是好的面向对象设计的标志所在。使用何种语言编程是无关紧要的。如程序的依赖关系是倒置的,它就是面向对象的设计。如果程序的依赖关系不是倒置的,它就是过程化的设计。

  依赖倒置原则是实现许多面向对象技术所宣称的好处的基本低层机制。它的正确应用对于创建可重用的框架来说是必需的。同时它对于构建在变化面前富有弹性的代码也是非常重要的。由于抽象和细节彼此隔离,所以代码也非常容易维护。

ISP接口隔离原则

  如果类的接口不是内聚的,就表示该类具有“胖”接口。换句话说,类的“胖”接口可以分解成多组方法。每一组方法服务于一组不同的客户程序。

  ISP承认有一些对象确实需要有非内聚的接口,但是ISP建议客户程序不应该看到它们作为单一的类存在。相反,客户程序看到的应该是多个具有内聚接口的抽象基类。

接口污染

  考虑一个安全系统。在这个系统中,有一些Door对象,可以被加锁和解锁,并且Door对象知道自己是开着还是关着。这个Door编码成一个接口,这样客户程序就可以使用那些符合Door接口的对象,而不需要依赖于Door的特定实现。

publicinterface Door

{

    void Lock();

    void Unlock();

    bool IsDoorOpen();

}

  现在,考虑一个这样的实现,TimedDoor,如果门开着的时间过长,它就会发出警报声。为了实现这一点,TimedDoor对象需要和另外一个名为Timer的对象交互。

publicclass Timer

{

    publicvoidRegister(int timeout,TimerClient client)

    {}

}publicinterface TimerClient

{

    void TimeOut();

}

  如果一个对象希望得到超时通知,它可以调用Timer的Register函数。该函数有两个参数,一个是超时时间,另一个是指向TimerClient对象的引用,其TimeOut函数会在超市到达时被调用。

  如何把TimerClient类和TimedDoor类联系起来,才能在超时时通知TimedDoor中相应的处理代码呢?

  常见的解决方案是,其中Door继承了TimerClient,因此TimedDoor也就继承了TimeClient。这就保证了TimerClient可以把自己注册到Timer中,并且可以接收TimeOut消息。

这种做法的问题是,现在Door依赖于TimerClient了。可是并不是所有种类的Door都需要定时功能。事实上,最初的Door抽象类和定时功能没有任何关系。如果创建了无需定时功能的Door的派生类,那么在这些派生类中就必须要提供TimeOut方法的退化实现,这就可能违反ISP。此外,使用这些派生类的应用程序及时不使用TimerClient类的定义,也必须要引入它。这样就具有了不必要的复杂性以及不必要的重复性的臭味。

分离客户就是分离接口

  Door接口和TimerClient接口是被完全不同的客户程序使用的。Timer使用TimerClient,而操作门的类使用Door。既然客户程序是分离的,所以接口也应该保持分离。

不应该强迫客户程序依赖并未使用的方法。

  如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些未使用的方法的改变所带来的变更。这无意中导致了所有客户程序之间的耦合。换种说法,如果一个客户程序依赖于一个含有它不使用的方法的类,但是其他客户程序却确实要使用该方法,那么当其他客户要求这个类改变时,就会影响到这个客户程序。我们希望尽可能地避免这种耦合,因此我们希望分离接口。

类接口和对象接口

  再次考虑一下TimedDoor。它具有两个独立的接口,被两个独立的客户-Timer以及Door的使用者-使用。因此实现这两个接口需要操作同样的数据,所以这两个接口必须在同一个对象中实现。那么怎样才能遵循ISP呢?怎样才能分离必须在一起实现的接口呢?

  那就是一个对象的客户不必通过该对象的接口去访问它,也可以通过委托或者通过该对象的基类去访问它。

通过委托(适配器)分离接口

  一个解决方案是创建一个派生自TimerClient的对象,并把对该对象的请求传递给TimedDoor。

  当TimedDoor想要向Timer对象注册一个超时请求时,它就创建一个DoorTimerAdapter并且把它注册给Timer。当Timer对象发送TimeOut消息给DoorTimerAdapter时,DoorTimerAdapter把这个消息传递给TimedDoor。

  这个解决方案遵循了ISP原则,并且避免了Door的客户程序和Timer之间的耦合。即使对Timer进行了改变,也不会影响到任何Door的使用者。此外TimedDoor不必具有和TimerClient一样的接口。DoorTimerAdapter将TimerClient接口转换为TimedDoor接口。因此,这是一个通用的解决方案。

  不过,这个解决方案还是有些不太优雅。每次想去注册一个超时请求时,都要去创建一个新的对象。同时,类型转换会倒置一些很小但仍然存在的运行时间和内存的开销。

使用多重继承分离接口

   在这个模型中,TimedDoor同时继承了Door和TimerClient。尽管这两个基类的客户程序都可以使用TimedDoor,但是实际上却都不再依赖于TimedDoor。这样,它们就通过分离的接口使用同一对象。

  通常都会优先使用这个解决方案。

结论

  胖类会导致它们的客户程序之间产生不正常的并且有害的耦合关系。当一个客户程序要求该胖类进行一个改动时,会影响到所有其他的客户程序。因此,客户程序应该仅仅依赖于它们实际调用的方法。通过把胖类的接口分解为多个特定于客户程序的接口,可以实现这个目标。每个特定于客户程序的接口仅仅声明它的特定客户或者客户组调用的那些函数。接着,该胖类就可以继承所有特定于客户程序的接口,并实现它们。这就解除了客户程序和它们没有调用的方法间的依赖关系,并使客户程序之间互不依赖。

相关文章

网友评论

      本文标题:2019-03-24 敏捷软件开发 10-12章

      本文链接:https://www.haomeiwen.com/subject/zjcpvqtx.html