如果违反了Liskov替代原则,怎么办?


27

我一直在关注这个可能会违反《里斯科夫换人原则》的极富争议的问题。我知道Liskov替代原则是什么,但是我仍然不清楚的是,如果我作为开发人员在编写面向对象的代码时没有考虑该原则,那可能会出问题。


6
如果不遵循LSP怎么办?最坏的情况:您最终会召唤Code-thulhu!;)
FrustratedWithFormsDesigner 2012年

1
作为原始问题的作者,我必须补充一点,这是一个学术问题。尽管违规可能会导致代码错误,但我从未遇到过严重的错误或维护问题,可以归结为违反LSP。
Paul T Davies

2
@Paul So yo从未遇到过由于复杂的OO层次结构而造成的程序问题(您不是自己设计的,但可能不得不扩展),在这些层次上,合同的左右不确定性导致了人们对基础类目的的不确定首先?我羡慕你!:)
Andres F.

@PaulTDavies后果的严重性取决于用户(使用该库的程序员)是否对库的实现有详细的了解(即可以访问并熟悉库的代码。)最终,用户将进行数十个条件检查或构建包装器在库中解决非LSP(类特定行为)的问题。如果图书馆是开源商业产品,则会发生最坏的情况。
rwong

@Andres和rwong,请用答案说明这些问题。公认的答案在很大程度上支持了保罗·戴维斯(Paul Davies),因为如果您拥有良好的编译器,静态分析器或最小化的单元测试,其后果似乎是很小的(异常),可以迅速注意到并纠正这种后果。
user949300

Answers:


31

我认为在这个问题上讲得很好,这是投票率很高的原因之一。

现在,当在Task上调用Close()时,如果它是具有启动状态的ProjectTask,则调用将失败,而如果它是基本Task则不会失败。

想象一下您是否会:

public void ProcessTaskAndClose(Task taskToProcess)
{
    taskToProcess.Execute();
    taskToProcess.DateProcessed = DateTime.Now;
    taskToProcess.Close();
}

在此方法中,.Close()调用有时会中断,因此,现在基于派生类型的具体实现,必须更改此方法的行为方式,与如果Task没有任何可能的子类型时该方法的编写方式相同移交给这种方法。

由于违反liskov替换,使用您的类型的代码将必须具有派生类型的内部工作的显式知识,以区别对待它们。这紧密耦合了代码,并且通常使实现更难以一致地使用。


这是否意味着子类不能具有未在父类中声明的自己的公共方法?
Songo 2012年

@Songo:不一定:可以,但是这些方法是从基本指针(或引用或变量或您使用的任何语言调用它的方式)“无法访问的”,并且您需要一些运行时类型信息来查询对象具有的类型在调用这些函数之前。但这是与语言语法和语义密切相关的问题。
Emilio Garavaglia '10 October

2
否。这是在引用子类时就好像它是父类的类型一样,在这种情况下,在父类中未声明的成员将不可访问。
2012年

1
@Phil Yep; 这就是紧密耦合的定义:改变一件事会导致其他事情发生改变。松散耦合的类可以更改其实现,而无需您在其外部更改代码。这就是合同良好的原因,它们指导您如何不要求更改对象的使用者:遵守合同,使用者将不需要修改,从而实现了松散耦合。当您的使用者需要对您的实现而不是合同进行编码时,这是紧密耦合的,并且在违反LSP时是必需的。
吉米·霍法

1
@ user949300任何软件完成其工作的成功都无法衡量其质量,长期或短期成本。设计原则是试图带来一些准则,以减少软件的长期成本,而不是使软件“起作用”。人们可以在仍然无法实施有效解决方案的情况下遵循他们想要的所有原则,也可以不遵循任何原则而实施有效解决方案。尽管Java集合可能对许多人有用,但这并不意味着从长远来看使用它们的成本会尽可能便宜。
Jimmy Hoffa 2015年

13

如果您不履行基类中定义的合同,那么当您获得不满意的结果时,事情可能会无声地失败。

维基百科状态中的LSP

  • 前提条件不能在子类型中得到加强。
  • 子条件不能弱化后置条件。
  • 超类型的不变量必须保留在子类型中。

如果其中任何一个都不成立,则呼叫者可能会得到他未预期的结果。


1
您能想到任何具体的例子来证明这一点吗?
Mark Booth 2012年

1
@MarkBooth圆形-椭圆/方形矩形问题可能对演示它很有帮助;Wikipedia文章是一个不错的起点: en.wikipedia.org/wiki/Circle-ellipse_problem
Ed Hastings

7

考虑一下访谈问题记录中的一个经典案例:您从Ellipse派生了Circle。为什么?因为IS-AN椭圆当然是圆的!

除了...椭圆具有两个功能:

Ellipse.set_alpha_radius(d)
Ellipse.set_beta_radius(d)

显然,必须为Circle重新定义这些参数,因为Circle具有统一的半径。您有两种可能性:

  1. 调用set_alpha_radius或set_beta_radius后,两者均设置为相同的数量。
  2. 调用set_alpha_radius或set_beta_radius后,该对象不再是Circle。

大多数OO语言都不支持第二种语言,这是有充分理由的:发现您的Circle不再是Circle将令人惊讶。所以第一个选择是最好的。但是请考虑以下功能:

some_function(Ellipse byref e)

想象一下some_function调用e.set_alpha_radius。但是由于e 确实是一个Circle,因此令人惊讶的是它的beta半径也已设置。

替代原则就在这里:子类必须可以替代超类。否则会发生令人惊讶的事情。


1
我认为如果使用可变对象,可能会遇到麻烦。圆也是椭圆。但是,如果用另一个椭圆替换也是圆形的椭圆(这是使用setter方法执行的操作),则不能保证新的椭圆也将是一个圆形(圆形是椭圆的适当子集)。
乔治

2
在纯粹的功能世界(具有不可变的对象)中,方法set_alpha_radius(d)的返回类型为ellipse(在椭圆和circle类中)。
乔治

@Giorgio是的,我应该提到这个问题仅发生在可变对象上。
卡兹龙

@KazDragon:当我们知道一个椭圆不是一个圆时,为什么有人会用一个椭圆替换一个椭圆呢?如果有人这样做,则他们对他们要建模的实体没有正确的了解。但是通过允许这种替换,我们是否不鼓励对我们要在软件中建模的底层系统的松散理解,从而实际上造成不良的软件?
特立独行

@maverick我相信您已经阅读了我向后描述的关系。提出的is- a关系是相反的:圆形是椭圆形。具体来说,圆是椭圆形,其中alpha半径和beta半径相同。因此,期望可能是任何期望将椭圆作为参数的函数都可以取一个圆。考虑calculate_area(椭圆)。向其传递一个圆圈将产生相同的结果。但是问题是椭圆形的突变函数的行为不能替代圆环中的那些。
卡兹巨龙

6

用外行的话来说:

您的代码到处都有很多CASE / switch子句

这些CASE / switch子句中的每一个子句都需要不时添加新的用例,这意味着代码库没有应有的可伸缩性和可维护性。

LSP允许代码更像硬件一样工作:

您无需修改​​iPod,因为您购买了一对新的外部扬声器,因为旧的和新的外部扬声器都使用同一接口,因此它们可以互换使用而不会失去iPod所需的功能。


2
-1:周围的答案都不好
托马斯·爱丁

3
@托马斯,我不同意。这是一个很好的类比。他谈到了不超出预期,这就是LSP的意义所在。(尽管我同意有关案例/切换的部分内容较弱)
Andres F.

2
然后苹果通过更改连接器打破了LSP。这个答案继续存在。
Magus 2014年

我不知道switch语句与LSP有什么关系。如果您指的是切换typeof(someObject)以决定“允许执行的操作”,那么可以肯定,但这完全是另一种反模式。
萨拉2016年

大幅减少switch语句的数量是LSP的理想副作用。由于对象可以代表扩展同一接口的任何其他对象,因此无需考虑特殊情况。
TulainsCórdova'16

1

Java的UndoManager给出一个真实的例子

它从其AbstractUndoableEdit合同中继承,该合同指定它具有2个状态(撤消和重做),并且可以通过一次调用undo()和进入它们之间redo()

但是UndoManager具有更多的状态,其作用类似于撤消缓冲区(每次调用都undo撤消部分但不是全部编辑操作,从而削弱了后置条件)

这导致了一种假设情况,即您在调用之前将UndoManager添加到CompoundEdit,end()然后在该CompoundEdit上调用undo,则undo()一旦部分撤消了编辑,它将导致它在每次编辑时都进行调用

UndoManager为了避免这种情况,我滚动了自己(我可能应该将其重命名为UndoBuffer


1

示例:您正在使用UI框架,并且通过子类化Control基类来创建自己的自定义UI控件。所述Control基类定义的方法getSubControls(),其返回嵌套控件的集合(如果有的话)。但是,您将覆盖该方法以实际返回美国总统的生日列表。

那么,这有什么问题呢?显然,控件的呈现将失败,因为您没有按预期返回控件列表。UI很可能会崩溃。您违反了 Control的子类应该遵守的合同


0

您也可以从建模的角度来看它。当你说类的实例A也是类的实例,B你意味着“类的一个实例的可观察的行为A也可以被归类为类的实例可观察的行为B”(这是唯一可能的,如果类B是不是具体的少类A。)

因此,违反LSP意味着您的设计中存在一些矛盾:您为对象定义了一些类别,然后在实现中不尊重它们,那一定是错误的。

就像制作一个带有标签的盒子一样:“此盒子只包含蓝色的球”,然后向其中扔一个红色的球。如果标签显示错误信息,该标签有什么用?


0

我最近继承了一个代码库,其中包含一些主要的Liskov违反者。在重要的班级。这给我带来了极大的痛苦。让我解释一下原因。

我有Class A,它来自Class BClass AClass B共享一堆Class A用其自己的实现覆盖的属性。设置或获取Class A属性与从中设置或获取完全相同的属性有不同的效果Class B

public Class A
{
    public virtual string Name
    {
        get; set;
    }
}

Class B : A
{
    public override string Name
    {
        get
        {
            return TranslateName(base.Name);
        }
        set
        {
            base.Name = value;
            FunctionWithSideEffects();
        }
    }
}

撇开这是在.NET中进行转换的一种非常糟糕的方式这一事实来看,此代码还有许多其他问题。

在这种情况下,Name它在许多地方用作索引和流量控制变量。上面的类在它们的原始代码和派生形式中乱七八糟。在这种情况下,违反Liskov替换原则意味着我需要知道对采用基类的每个函数的每次调用的上下文。

该代码使用两个对象 Class AClass B,所以我不能只是让Class A抽象的,迫使人们使用Class B

有一些非常有用的实用工具功能在运行,Class A还有一些非常有用的实用工具功能在运行Class B。理想情况下,我希望能够使用可以Class AClass B。如果不是因为违反LSP ,采取a的许多功能Class B很容易采用a Class A

最糟糕的是,这种特殊情况确实很难重构,因为整个应用程序取决于这两个类,始终在这两个类上运行,如果我更改此设置,它将以一百种方式中断(我将要做的事情)无论如何)。

为了解决这个问题,我要做的就是创建一个NameTranslated属性,该属性将是Class BName属性的版本,并且非常非常小心地更改对派生对象的每个引用Name属性的以使用我的新NameTranslated属性。但是,即使这些引用之一出错,整个应用程序也可能崩溃。

考虑到代码库周围没有单元测试,这几乎是开发人员可能面临的最危险的情况。如果我不更改违规,则我必须花费大量的精力跟踪每种方法正在处理哪种类型的对象,如果我解决了违规,我可能会使整个产品在不适当的时候爆炸。


如果在派生类中用另一种名称相同的事物(例如嵌套类)遮盖继承的属性,并创建新的标识符BaseNameTranslatedName同时访问A类样式Name和B类含义,会发生什么情况?然后,任何尝试访问Name类型变量的操作B都会被编译器错误拒绝,因此您可以确保将所有引用转换为其他形式之一。
2013年

我不再在那个地方工作。修复起来会很尴尬。:-)
斯蒂芬

-4

如果您想解决违反LSP的问题,请考虑一下,如果只有基类的.dll / .jar(无源代码),并且必须构建新的派生类,该怎么办。您永远无法完成此任务。


1
这只会带来更多问题,而不是一个答案。
弗兰克(Frank)
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.