设计模式-----里氏替换原则

里氏替换原则

开放封闭原则(Open Closed Principle)是构建可维护性和可重用性代码的基础。它强调设计良好的代码能够不经过修改而扩展,新的功能经过添加新的代码来实现,而不须要更改已有的可工做的代码。抽象(Abstraction)和多态(Polymorphism)是实现这一原则的主要机制,而继承(Inheritance)则是实现抽象和多态的主要方法。html

那么是什么设计规则在保证对继承的使用呢?优秀的继承层级设计都有哪些特征呢?是什么在诱使咱们构建了不符合开放封闭原则的层级结构呢?这些就是本篇文章将要回答的问题。程序员

里氏替换原则(LSP: The Liskov Substitution Principle)编程

使用基类对象指针或引用的函数必须可以在不了解衍生类的条件下使用衍生类的对象。安全

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.编程语言

Barbara Liskov 在 1988 年提出了这一原则:ide

What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.函数

违背 LSP 原则的一个简单示例post

一个很是明显地违背 LSP原则的示例就是使用 RTTI(Run Time Type Identification)来根据对象类型选择函数执行。.net

void DrawShape(const Shape& s)
{
    if (typeid(s) == typeid(Square))
        DrawSquare(static_cast<Square&>(s)); 
    else if (typeid(s) == typeid(Circle))
        DrawCircle(static_cast<Circle&>(s));
}

显然 DrawShape 函数的设计存在不少问题。它必须知道全部 Shape 基类的衍生子类,而且当有新的子类被建立时就必须修改这个函数。事实上,不少人看到这个函数的结构都认为是在诅咒面向对象设计。设计

正方形和长方形,违背原则的微妙之处

不少状况下对 LSP 原则的违背方式都十分微妙。设想在一个应用程序中使用了 Rectangle 类,描述以下:

public class Rectangle
{
    private double _width;
    private double _height;

    public void SetWidth(double w) { _width = w; }
    public void SetHeight(double w) { _height = w; }
    public double GetWidth() { return _width; }
    public double GetHeight() { return _height; }
}

试想这个应用程序能够良好地工做,而且已被部署到了多个位置。就像全部成功的软件同样,它的用户提了新的需求。假设某一天用户要求该应用程序除了可以处理长方形(Rectangle)以外还要可以处理正方形(Square)。

一般来讲,继承关系是 is-a 的关系。换句话讲,若是一种新的对象与一种已有对象知足 is-a 的关系,那么新的对象的类应该是从已有对象的类继承来的。

很明显一个正方形是一个长方形,能够知足全部常规的目的和用途。所以这就创建了 is-a 的关系,Square 的逻辑模型能够从 Rectangle 衍生。

对 is-a 关系的使用是面向对象分析(Object Oriented Analysis)的基本技术之一。一个正方形是一个(is-a)长方形,全部 Square 类应当从 Rectangle 类衍生。然而这种思考方式将引发一些微妙的却很严重的问题。一般在咱们没有实际使用这些代码以前,这些问题是没法被预见的。

关于这个问题,咱们的第一个线索多是Square 类并不须要 _height 和 _width 成员变量,尽管不管如何它都继承了它们。能够看出这是一种浪费,并且若是咱们持续建立成百上千个 Square 对象,这种浪费就会表现的十分明显。

尽管如此,咱们也能够假设咱们并非十分关心内存的开销。那还有什么问题吗?固然!Square 类将继承 SetWidth 和 SetHeight 方法。这些方法对于 Square 来讲是彻底不适当的,由于一个正方形的长和宽是同样的。这就应该是另外一个显著的线索了。然而,有一种方法能够规避这个问题。咱们能够覆写SetWidth 和 SetHeight 方法。以下所示:

public class Square : Rectangle
{
    public void SetWidth(double w)
    {
        base.SetWidth(w);
        base.SetHeight(w);
    }
    public void SetHeight(double w)
    {
    base.SetWidth(w);
    base.SetHeight(w);
    }
}

如今,不管谁设置 Square 对象的 Width,它的 Height 也会相应跟着变化。而当设置 Height 时,Width 也一样会改变。这样作以后,Square 看起来很完美了。Square 对象仍然是一个看起来很合理的数学中的正方形。

public void TestCase1()
{
    Square s = new Square();
    s.SetWidth(1); // Fortunately sets the height to 1 too.
    s.SetHeight(2); // sets width and heigt to 2, good thing.
}

但如今看下下面这个方法:

void f(Rectangle r)
{
    r.SetWidth(32); // calls Rectangle::SetWidth
}

若是咱们传递一个 Square 对象的引用到这个方法中,则 Square 对象将被损坏,由于它的 Height 将不会被更改。这里明确地违背了 LSP 原则,此函数在衍生对象为参数的条件下没法正常工做。而失败的缘由是由于在父类 Rectangle 中没有将 SetWidth 和 SetHeight 设置为 virtual 函数。

咱们也能很容易的解决这个问题。但尽管这样,当建立一个衍生类将致使对父类作出修改,一般意味着这个设计是有缺陷的,具体的说就是它违背了 OCP 原则。咱们可能会认为真正的设计瑕疵是忘记了将SetWidth 和 SetHeight 设置为 virtual 函数,并且咱们已经修正了这个问题。可是,其实也很难自圆其说,由于设置 Rectangle 的 Height 和 Width 已经再也不是一个原子操做。不管是何种缘由咱们将它们设置为 virtual,咱们都将没法预期 Square 的存在。

还有,假设咱们接收了这个参数,而且解决了这些问题。咱们最终获得了下面这段代码:

public class Rectangle
{
    private double _width;
    private double _height;

    public virtual void SetWidth(double w) { _width = w; }
    public virtual void SetHeight(double w) { _height = w; }
    public double GetWidth() { return _width; }
    public double GetHeight() { return _height; }
}

public class Square : Rectangle
{
    public override void SetWidth(double w)
    {
      base.SetWidth(w);
      base.SetHeight(w);
    }
    public override void SetHeight(double w)
    {
      base.SetWidth(w);
      base.SetHeight(w);
    }
}

问题的根源

此时此刻咱们有了两个类,Square 和 Rectangle,并且看起来能够工做。不管你对 Square 作什么,它仍能够保持与数学中的正方形定义一致。并且也无论你对 Rectangle 对象作什么,它也将符合数学中长方形的定义。而且当你传递一个 Square 对象到一个能够接收 Rectangle 指针或引用的函数中时,Square 仍然能够保证正方形的一致性。

既然这样,咱们可能得出结论了,这个模型如今是自洽的(self-consistent)和正确的。可是,这个结论实际上是错误的。一个自洽的模型不必定对它的全部用户都保持一致!

(注:自洽性即逻辑自洽性和概念、观点等的先后一向性。首先是指建构一个科学理论的若干个基本假设之间,基本假设和由这些基本假设逻辑地导出的一系列结论之间,各个结论之间必须是相容的,不相互矛盾的。逻辑自洽性也要求构建理论过程当中的全部逻辑推理和数学演算正确无误。逻辑自洽性是一个理论可以成立的必备条件。)

试想下面这个方法:

void g(Rectangle r)
{
    r.SetWidth(5);
    r.SetHeight(4);
    Assert.AreEqual(r.GetWidth() * r.GetHeight(), 20);
}

这个函数调用了 SetWidth 和 SetHeight 方法,而且认为这些函数都是属于同一个 Rectangle。这个函数对 Rectangle 是能够工做的,可是若是传递一个 Square 参数进去则会发生断言错误。

因此这才是真正的问题所在:写这个函数的程序员是否彻底能够假设更改一个 Rectangle 的 Width 将不会改变 Height 的值?

很显然,写这个函数 g 的程序员作了一个很是合理的假设。而传递一个 Square 到这样的函数中才会引起问题。所以,那些已存在的接收 Rectangle 对象指针或引用的函数也一样是不能对 Square 对象正常操做的。这些函数揭示了对 LSP 原则的违背。此外,Square 从 Rectangle 衍生也破坏了这些函数,因此也违背了 OCP 原则。

有效性不是内在的

这引出了一个很是重要的结论。从孤立的角度看,一个模型没法本身进行有意义地验证。模型的正确性仅能经过它的使用者来表达。例如,孤立地看 Square 和 Rectangle,咱们发现它们是自洽的而且是有效的。但当咱们从一个对基类作出合理假设的程序员的角度来看待它们时,这个模型就被打破了。

所以,当考虑一个特定的设计是否合理时,决不能简单的从孤立的角度来看待它,而必须从该设计的使用者的合理假设的角度来分析

到底哪错了?

那么到底发生了什么呢?为何看起来很合理的 Square 和 Rectangle模型变坏了呢?难道说一个 Square 是一个 Rectangle 不对吗?is-a 的关系不存在吗?

不!一个正方形能够是一个长方形,但一个 Square 对象绝对不是一个 Rectangle 对象。为何呢?由于一个 Square 对象的行为与一个 Rectangle 对象的行为是不一致的。从行为的角度来看,一个 Square 不是一个 Rectangle !而软件设计真正关注的就是行为(behavior)。

LSP 原则使咱们了解了 OOD 中 is-a 关系是与行为有关的。不是内在的私有的行为,而是外在的公共的行为,是使用者依赖的行为。例如,上述函数 g 的做者依赖了一个基本事实,那就是 Rectangle 的 Width 和 Height 彼此之间的变化是无依赖关系的。而这种无依赖的关系就是一种外在的公共的行为,而且其余程序员有可能也会这么想。

为了仍然遵照 LSP 原则,并同时符合 OCP 原则,全部的衍生类必须符合使用者所期待的基类的行为

契约式设计(Design by Contract)

Bertrand Meyer 在 1988 年阐述了 LSP 原则与契约式设计之间的关系。使用契约式设计,类中的方法须要声明前置条件和后置条件。前置条件为真,则方法才能被执行。而在方法调用完成以前,方法自己将确保后置条件也成立。

咱们能够看到 Rectangle 的 SetWidth 方法的后置条件是:

1 Contract.Ensures((_width == w) && (_height == Contract.OldValue<double>(_height)));

为衍生类设置前置条件和后置条件的规则是,Meyer 描述的是:

…when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.

换句话说,当经过基类接口使用对象时,客户类仅知道基类的前置条件和后置条件。所以,衍生类对象不能期待客户类服从强于基类中的前置条件。也就是说,它们必须接受任何基类能够接受的条件。并且,衍生类必须符合基类中所定义的后置条件。也就是说,它们的行为和输出不能违背任何已经与基类创建的限制。基类的客户类毫不能对衍生类的输出产生任何疑惑。

显然,后置条件 Square::SetWidth(double w) 要弱于 Rectangle::SetWidth(double w),由于它不符合基类的中的条件子句 "(_height == Contract.OldValue (_height))"。因此,Square::SetWidth(double w) 违背了基类定立的契约。

有些编程语言,对前置条件和后置条件有直接的支持。你能够直接定义这些条件,而后在运行时验证系统。若是编程语言不能直接支持条件定义,咱们也能够考虑手工定义这些条件。

总结

开放封闭原则(Open Closed Principle)是许多面向对象设计启示思想的核心。符合该原则的应用程序在可维护性、可重用性和鲁棒性等方面会表现的更好。里氏替换原则 则是实现 OCP 原则的重要方式。只有当衍生类可以彻底替代它们的基类时,使用基类的函数才可以被安全的重用,而后衍生类也能够被放心的修改了。

参考资料

相关文章
相关标签/搜索