里氏替换原则(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)来根据对象类型选择函数执行。
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(); // Fortunately sets the height to 1 too.
s.SetHeight(); // sets width and heigt to 2, good thing.
}
但现在看下下面这个方法:
void f(Rectangle r)
{
r.SetWidth(); // 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();
r.SetHeight();
Assert.AreEqual(r.GetWidth() * r.GetHeight(), );
}
这个函数调用了 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 方法的后置条件是:
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 | ||
OCP | ||
LSP | ||
ISP |
||
DIP | ||
LKP |
参考资料
- LSP:The Liskov Substitution Principle by Robert C. Martin “Uncle Bob”
- The SOLID Principles, Explained with Motivational Posters
- Dangers of Violating SOLID Principles in C#
- An introduction to the SOLID principles of OO design
本文《里氏替换原则(Liskov Substitution Principle)》由 Dennis Gao 翻译改编自 Robert Martin 的文章《LSP: The Liskov Substitution Principle》,未经作者本人同意禁止任何形式的转载,任何自动或人为的爬虫行为均为耍流氓。
里氏替换原则(Liskov Substitution Principle)的更多相关文章
- 里氏替换原则(Liskov Substitution Principle,LSP)
肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑.其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的. 定义1:如果对每一 ...
- 设计模式六大原则(二):里氏替换原则(Liskov Substitution Principle)
里氏替换原则(LSP)由来: 最早是在 妖久八八 年, 由麻神理工学院得一个女士所提出来的. 定义: 1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 ...
- 里氏代换原则(Liskov Substitution Principle,LSP)
第一种定义: 如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换为o2,程序P的行为没有发生变化,那么类型S是类型T的子类型. 第二种定义: 所有引 ...
- 面向对象设计原则 里氏替换原则(Liskov Substitution Principle)
里氏替换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一. 里氏替换原则中说,任何基类可以出现的地方,子类一定可以出现. LSP是继承复用的基石,只 ...
- 【面向对象设计原则】之里氏替换原则(LSP)
里氏代换原则由2008年图灵奖得主.美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing 教授于1994年提出,所以使用的是这位女博士的性命名的一个 ...
- 面向对象五大原则_1.单一职责原则&2.里氏替换原则
单一职责原则:Single Responsibility Principle (SRP) 一个类.仅仅有一个引起它变化的原因.应该仅仅有一个职责.每个职责都是变化的一个轴线.假设一个类有一个以上的职责 ...
- 面象对象设计原则之三:里氏替换原则(The Liskov Substitution Principle,LSP)
里氏代换原则由2008年图灵奖得主.美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出.其严格表述如下:如果对每一个类型为S的 ...
- Java设计原则之里氏替换原则
里氏代换原则由2008年图灵奖得主.美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出.其严格表述如下:如果对每一个类型为S的 ...
- 设计模式值六大原则——里氏替换原则(LSP)
里氏替换原则(Liskov Substitution Principel)是解决继承带来的问题. 继承的优点: 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性: 提高代码的重用性: 子类 ...
随机推荐
- 关于KB905474正版验证补丁破解办法 KB905474是个微软操作系统正版/盗版监测间谍软件。更新安装后,右下角有个提示说“系统监测到你的操作系统是盗版”。 如果没有安装的: 在系统提示更新的时候注意看一下,如果包含有“更新KB905474”就去掉“更新KB905474”方框前的勾,点击关闭(注意如果没有去掉那个勾得话,会找不到“关闭”,而是“确定”),在不在提示我该消息前打勾。 如果已经安装
关于KB905474正版验证补丁破解办法 KB905474是个微软操作系统正版/盗版监测间谍软件.更新安装后,右下角有个提示说“系统监测到你的操作系统是盗版”. 如果没有安装的: 在系统提示更新的时候 ...
- 搭建selenium自动化环境步骤
1.下载pythonhttps://www.python.org/downloads/2.安装2.X或者3.X3.添加环境变量python和pip(与python一起安装)4.下载setuptools ...
- mysql将一张表中多条记录按联系整合成一条
现有表如下:id time is_login 3 2012-07-03 11:20:20 13 2012-07-03 11:25:20 04 2012-07-03 12:30:20 14 2012-0 ...
- Config文件的读写
using System; using System.Configuration; using System.Xml; namespace COMMON { public class ConfigHe ...
- 通过实现Countable接口来调用count函数
周六我一大早就来到公司,还有些客户工作没有收尾,还有写文档没写,还有写计划需要完善,我得抓紧.到了下午我发现大家陆陆续续的都到公司来了,有几个兄弟一来就开始工作了,每当有人自愿投入某一项工作时,我基本 ...
- 一行css代码调试中学到的javascript知识,很有意思
现在到处都是JavaScript,每天都能知道点新东西.一旦你入了门,你总能从这里或是那里领悟到很多知识.今天我想分享Addy Osmani的一行代码 ,这行代码对于你调试你的CSS是很有用的.为了可 ...
- iOS 取绝对值函数
1.abs(a) 处理int类型a的取绝对值 2.fabsf(a) 处理float类型a的取绝对值 3.fabs(a) 处理double类型a的取绝对值
- 原生js-焦点图轮播
首先是html实现页面结构及主程序 <!doctype html> <html> <head> <meta charset="utf-8" ...
- php面向对象学习
近期跟着别人开发一套php程序,深感自己面向对象很白痴,于是再次巩固了一下面向对象的学习,自己整理了一下这几天面向对象的东西,给大家分享!! 面向对象的三大特性: 封装 -- 隐藏内部实现,稳定外部接 ...
- MATLAB常用操作
1.点乘,点除,点乘方 点乘(对应元素相乘),必须同维或者其中一个是标量,a.*b 点除,a.\b表示矩阵b的每个元素除以a中对应元素或者除以常数a,a./b表示常数a除以矩阵b中每个元素或者矩阵a除 ...