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

里氏替换原则

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

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

里氏替换原则(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 年提出了这一原则:

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 原则的一个简单示例

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

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 原则的重要方式。只有当衍生类能够完全替代它们的基类时,使用基类的函数才能够被安全的重用,然后衍生类也可以被放心的修改了。

参考资料

原文地址:https://www.cnblogs.com/MessiXiaoMo3334/p/11822956.html

时间: 2024-08-03 03:04:56

设计模式-----里氏替换原则的相关文章

小菜学设计模式——里氏替换原则

背景 本文标题为什么叫小菜学习设计模式,原因是本文内容主要是学习<大话设计模式>时的笔记摘要部分,当然,并不是记录书中小菜的学习过程,这个完全没有意义,而是指本人学习设计模式的成长之旅. 真诚的希望自己能够从一名小菜成长为一名大鸟! 编写的程序应该满足: 1)可维护 2)可扩展 3)可复用 4)够灵活 废话少说,言归正传,设计模式原则之:里氏替换原则 书面理解 里氏替换原则:一个软件实体如果使用的是父类的话,那么一定适用与其子类,而且它察觉不出父类对象和子类对象的区别.也就是说,在软件里面,把

设计模式六大原则之里氏替换原则

一.概念: 里氏替换原则:LSP (Liskov Substitution Principle),如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都换成o2时,程序P的行为没有变化,那么类型T2是类型T1的子类型. 通俗的定义:所有引用基类的地方必须能透明地使用其子类的对象. 二.例子: 以浇水为例.人,拿到工具[水管.水桶.瓶子],装水后都可以浇水.[水管.桶.瓶子]都可以获取水.应该有个loadWater方法.有watering 浇水功能

设计模式六大原则(2):里氏替换原则

里氏替换原则 肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑.其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的. 定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型. 定义2:所有引用基类的地方必须能透明地使用其子类的对象. 问题由来:有一功能P1,由

设计模式六大原则(2):里氏替换原则(转载)

肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑.其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的. 定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型. 定义2:所有引用基类的地方必须能透明地使用其子类的对象. 问题由来:有一功能P1,由类A完成.现需

设计模式六大原则(2):里氏替换原则(Liskov Substitution Principle)

肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑.其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的. 定义1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型. 定义2:所有引用基类的地方必须能透明地使用其子类的对象. 问题由来:有一功能P1,由类A完成.现需

【设计模式】单一职责 开-闭 依赖倒转 里氏替换原则

几个设计模式的原则,简单了解了一下 单一职责 类的功能应该单一,就一个类而言,应该仅有一个引起它变化的原因,否则就要拆分. [大话设计模式]里大鸟和小菜用的DV的摄像功能和手机的摄像功能的比较,DV的功能单一,手机的功能多而复杂,小菜在看到UFO的时候赶紧拿出手机来录像,结果发现录的很不清楚,如果是DV的话,小菜应该来得及摆一个牛逼的姿势,把UFO给录进去. 开闭原则 可以很简单的总结为一句话:对扩展开放,对修改关闭. 对一个功能的修改应该是通过增加代码的方式,而不是通过修改代码的方式.(内心独

&amp;quot;围观&amp;quot;设计模式(2)--里氏替换原则(LSP,Liskov Substitution Principle)

在面向对象的程序设计中.里氏替换原则(Liskov Substitution principle)是对子类型的特别定义.它由芭芭拉·利斯科夫(Barbara Liskov)在1987年在一次会议上名为"数据的抽象与层次"的演说中首先提出. 里氏替换原则的内容能够描写叙述为: "派生类(子类)对象能够替换其基类(超类)对象被使用." 以上内容并不是利斯科夫的原文,而是译自罗伯特·马丁(Robert Martin)对原文的解读. 其原文为: Let be a prope

学习设计模式 - 六大基本原则之里氏替换原则

设计模式总共有六大基本原则,统称为SOLID (稳定)原则,分别是S-单一职责原则(Single Responsibility Principle), O-开闭原则(Open closed Principle),L-里氏替换原则(Liskov Substitution Principle),L-迪米特法则(Law of Demeter),I-接口隔离原则(Interface Segregation Principle),D-依赖倒置原则(Dependence Invension Principl

设计模式的七大原则(4) --里氏替换原则

前言 上一节中我们介绍了,依赖倒置,依赖倒置利用抽象的稳定性来架构我们的系统,是我们经常能遇到的一种原则,比如说面向接口编程. 这一节中,我们来说说里氏替换原则,这个原则其实非常非常的简单,其实与依赖倒置相结合来看,就是希望我们用抽象的方法来构建项目而非具体的实现,里氏替换原则就是推荐我们不要重写父类中具体的实现来构建我们的项目. 我们来深入研究研究. 基本介绍 继承包含这样一层含义:父类中凡是已经实现好的方法,实际上是在设定规范和契约,虽然它不强制要求所有的子类必须遵循这些契约,但是如果子类对