里氏替换原则(Liskov Substitution Principle)

开放封闭原则(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)来根据对象类型选择函数执行。

1 void DrawShape(const Shape& s)
2 {
3     if (typeid(s) == typeid(Square))
4         DrawSquare(static_cast<Square&>(s));
5     else if (typeid(s) == typeid(Circle))
6         DrawCircle(static_cast<Circle&>(s));
7 }    

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

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

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

 1   public class Rectangle
 2   {
 3     private double _width;
 4     private double _height;
 5
 6     public void SetWidth(double w) { _width = w; }
 7     public void SetHeight(double w) { _height = w; }
 8     public double GetWidth() { return _width; }
 9     public double GetHeight() { return _height; }
10   }

试想这个应用程序可以良好地工作,并且已被部署到了多个位置。就像所有成功的软件一样,它的用户提了新的需求。假设某一天用户要求该应用程序除了能够处理长方形(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 方法。如下所示:

 1   public class Square : Rectangle
 2   {
 3     public void SetWidth(double w)
 4     {
 5       base.SetWidth(w);
 6       base.SetHeight(w);
 7     }
 8     public void SetHeight(double w)
 9     {
10       base.SetWidth(w);
11       base.SetHeight(w);
12     }
13   }

现在,无论谁设置 Square 对象的 Width,它的 Height 也会相应跟着变化。而当设置 Height 时,Width 也同样会改变。这样做之后,Square 看起来很完美了。Square 对象仍然是一个看起来很合理的数学中的正方形。

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

但现在看下下面这个方法:

1   void f(Rectangle r)
2   {
3     r.SetWidth(32); // calls Rectangle::SetWidth
4   }

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

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

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

 1   public class Rectangle
 2   {
 3     private double _width;
 4     private double _height;
 5
 6     public virtual void SetWidth(double w) { _width = w; }
 7     public virtual void SetHeight(double w) { _height = w; }
 8     public double GetWidth() { return _width; }
 9     public double GetHeight() { return _height; }
10   }
11
12   public class Square : Rectangle
13   {
14     public override void SetWidth(double w)
15     {
16       base.SetWidth(w);
17       base.SetHeight(w);
18     }
19     public override void SetHeight(double w)
20     {
21       base.SetWidth(w);
22       base.SetHeight(w);
23     }
24   }

问题的根源

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

既然这样,我们可能得出结论了,这个模型现在是自洽的(self-consistent)和正确的。但是,这个结论其实是错误的。一个自洽的模型不一定对它的所有用户都保持一致!

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

试想下面这个方法:

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

这个函数调用了 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<double>(_height))"。所以,Square::SetWidth(double w) 违背了基类定立的契约。

有些编程语言,对前置条件和后置条件有直接的支持。你可以直接定义这些条件,然后在运行时验证系统。如果编程语言不能直接支持条件定义,我们也可以考虑手工定义这些条件。

总结

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

面向对象设计的原则

 SRP
单一职责原则


Single Responsibility Principle

 OCP
开放封闭原则


Open Closed Principle

 LSP
里氏替换原则


Liskov Substitution Principle


ISP


接口隔离原则


Interface Segregation Principle

 DIP
依赖倒置原则


Dependency Inversion Principle

参考资料

本文《里氏替换原则(Liskov Substitution Principle)》由 Dennis Gao 翻译改编自 Robert Martin 的文章《LSP: The Liskov Substitution Principle》,未经作者本人同意禁止任何形式的转载,任何自动或人为的爬虫行为均为耍流氓。

里氏替换原则(Liskov Substitution Principle),布布扣,bubuko.com

时间: 06-16

里氏替换原则(Liskov Substitution Principle)的相关文章

面向对象五大原则_1.单一职责原则&amp;amp;2.里氏替换原则

单一职责原则:Single Responsibility Principle (SRP) 一个类.仅仅有一个引起它变化的原因.应该仅仅有一个职责.每个职责都是变化的一个轴线.假设一个类有一个以上的职责,这些职责就耦合在了一起.这会导致脆弱的设计.当一个职责发生变化时,可能会影响其他的职责.另外,多个职责耦合在一起,会影响复用性. 比如:要实现逻辑和界面的分离. T负责两个不同的职责:职责P1.职责P2.当因为职责P1需求发生改变而须要改动类T时.有可能会导致原本执行正常的职责P2功能发生问题.

Java 设计模式(十一) 里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle) LSP的基本概念 定义: 所有引用基类的地方必须能透明地使用其子类的对象 只要父类能出现的地方子类就可出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类(封装造成的多态性) 规范 子类必须完全实现父类的方法 在类中调用其他类时必然要使用父类或者接口,如果子类中不支持父类中的方法,自然就违背了LSP 子类要有自己的特性 子类是在父类的基础上实现的,有自己的特性 这也就导致了LSP的单向性

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

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

Java设计原则—里氏替换原则(转)

里氏替换原则(Liskov Substitution Principel)是解决继承带来的问题. 继承的优点: 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性: 提高代码的重用性: 子类可以形似父类,但又异于父类: 提高代码的可扩展性: 提高产品或项目的开放性. 继承的缺点: 继承是侵入性的,只要继承就必须拥有父类的所有属性和方法: 降低代码的灵活性,子类必须拥有父类的属性和方法,让子类增加了约束: 增强了耦合性,当父类的常量.变量和方法被修改时,必须考虑子类的修改. 定义: 所有

里氏替换原则(Liskov Substitution Principle) LSP

using System; using System.Collections.Generic; using System.Text; namespace LiskovSubstitutionPrinciple { //里氏替换原则(Liskov Substitution Principle) LSP //If for each object o1 of type S there is an object o2 of type T such that for all programs P defi

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

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

里氏替换原则(Liskov Substitution Principle, LSP)

以此回顾所读<设计模式之禅>与<高校程序员的45个习惯>中Liskov部分 定义: 第一种:If for each object O1 of type S there is an object O2 fo type T such that for all programs P defined in terms of T, the behavior of P is unchanged when O1 is substitueted for O2 then S is a subtype

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

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

设计模式六大原则: 狸猫换太子 -- 里氏替换原则

据野史传说中记载,当年宋哲宗最宠爱的妃子是刘德妃.刘德妃虽然深受皇上宠爱,但是却久久不能生育.刘德妃为竞争皇后之位,提高自己的身价,便想出了"借腹怀孕"的诡计.她打算利用身旁的一个长得有些姿色的姓李的侍女,引诱宋真宗上钩.一次在刘德妃沐浴之时,真宗果真临幸了这个李姓侍女,不久这个侍女就怀孕了,这使刘德妃也装作怀孕的样子,其实侍女怀孕是真,刘德妃只是安排计策假装而已.等到十月分娩的时候,"两个"龙种先后呱呱落地.刘德妃采取了狸猫换太子的卑劣手段,让真宗将李姓侍女打入冷