里氏替换原则 - 没有重写/虚方法?

71

我对里氏替换原则的理解是,基类的某些属性或实现的行为,在派生类中也应该保持正确性。

我想这意味着当一个方法在基类中被定义时,在派生类中就不应该被重写,因为用基类代替派生类将会产生不同的结果。我想这也意味着拥有(非纯)虚方法是不好的事情?

我觉得自己可能对这个原则有一些误解。如果没有,我不明白为什么这个原则是好实践。能否有人向我解释一下?谢谢。


2
感谢所有回答的人。我认为你们所有人都对我理解这个工作原理做出了很大的贡献。我已经给每个人点了赞,但我不确定如何确定正确的答案(每个人的答案都帮助了我!:D)。 - Aishwar
6个回答

70

里氏替换原则允许子类覆盖基类中的方法。

这可能过于简化,但我记得它是“子类不应该要求更多也不应该承诺更少”。

如果客户端正在使用带有方法something(int i)的超类ABC,那么客户端应该能够在没有问题的情况下替换ABC的任何子类。不要考虑变量类型,可以考虑前置条件和后置条件。

如果我们上面的ABC基类中的something()方法具有允许任何整数的放松前提条件,则ABC的所有子类必须也允许任何整数。例如,子类GreenABC不允许向something()方法添加需要参数为正整数的额外前提条件。这将违反里氏替换原则(即要求更多)。因此,如果客户端正在使用子类BlueABC并向something()传递负整数,则在需要切换到GreenABC时客户端不会出错。

相反,如果基类ABCsomething()方法具有后置条件,例如保证其永远不会返回零值,则所有子类也必须遵守相同的后置条件,否则它们就会违反里氏替换原则(即承诺更少)。

希望这可以帮助理解。


2
如果GreenABC返回的值符合ABC提供的规范,则不违反原则。 - Grundlefleck
1
@Grundlefleck:抱歉,如果我似乎不理解。因此,派生类不能更改基类的行为,只能添加到它上面?也就是说,我不能在派生类中覆盖一个方法,以使其实现与基类不同。 - Aishwar
8
一个名为PizzaSharingService接口或抽象类(基类),有一个share(pizza,numSlices)方法,它将返回一系列的薄片。前提条件是,numSlices参数可以是0-12之间的任何数字(包括0),如果数字为0,则会返回一个空列表。于是第一团队创建了一个RoundPizzaSharingService,将比萨切成三角形的薄片,并遵守有关薄片数量为零的规则。第二个团队创建了一个SquarePizzaSharingService,但决定如果薄片数量为零,则抛出异常或返回null,这违反了LSP原则。 - dustmachine
7
@dustmachine,好的例子。在我看来,更多的例子==更好... 假设你有一个Rectangle类,它具有setWidth()setHeight()方法。 一个Square类是Rectangle的子类,并且覆盖了行为,使得当一个维度被改变时,这个类将保持一个正方形的形状。 如果某人拥有一个对Rectangle的引用,并调用setWidth(10); setHeight(5);,那么对于一个矩形而言,它的尺寸应该是10x5,但是如果引用实际上是指向一个Square,那么尺寸会被修改以使其成为一个正方形。 Square类是违反LSP原则的一个例子。 - Grundlefleck
1
我要对第一个声明稍作补充:子类应该向那些可以接受超类或其任何子类实例引用的代码承诺不低于[超类]的内容。 Subclass为调用new Subclass()的代码提供比Superclass为调用new Superclass()的代码提供更少的承诺没有问题,因为调用new Subclass()的代码不能期望收到超类实例。 - supercat
显示剩余2条评论

16

有一个流行的例子说,如果它像鸭子一样游泳,嘎嘎地叫,但需要电池,那么它就违反了Liskov替换原则。

简单来说,你有一个基本的Duck类,被某人使用。然后你通过引入PlasticDuck来添加层次结构,其具有与Duck相同的重写行为(如游泳、叫等),但需要电池来模拟这些行为。这实际上意味着你对子类的行为引入了额外的前提条件,要求使用电池来执行之前由基本Duck类无需电池执行的行为。这可能会让Duck类的使用者感到惊讶,并且可能会破坏围绕基本Duck类预期行为构建的功能。

这里是一个好的链接-http://lassala.net/2010/11/04/a-good-example-of-liskov-substitution-principle/


8
不,它告诉你应该能够像使用基类一样使用派生类。有很多方法可以重写方法而不破坏这个规则。一个简单的例子是,在C#中,GetHashCode()在所有类的基类中,仍然可以将所有类用作“object”来计算哈希码。我记得的一个经典的打破规则的例子是从矩形派生正方形,因为正方形不能同时有宽度和高度——因为设置一个属性会改变另一个属性,所以它不再符合矩形的规则。但是,你仍然可以有基类Shape并且具有.getSize()方法,因为所有形状都可以执行此操作,因此任何派生形状都可以被替换并用作Shape。

嘿,你是说在C#中对于任何对象都可以调用GetHashCode()方法,并且可以将其转换为对象,仍然会返回相同的值。或者我误解了这个问题?我猜测GetHashCode()方法是在Object中实现的,派生对象没有覆盖它。我不明白这与正方形/矩形示例有什么关系。你能解释一下吗? - Aishwar
2
我认为queen3的意思是许多类将需要重写GetHashCode(),但调用它的客户端(如哈希表)不会知道他们没有调用为Object定义的方法。由于合同仍然得到满足,因此没有违规行为发生。然而,在正方形/矩形示例中,客户端可能能够确定它们被传递的实例的类是什么,或者可能会失败,因此违反了规定。 - quamrana
5
不,不是相同的值,而是满足基类关于这个值的契约的某个值。至于形状和哈希,如果针对派生的 SomeObject 只有在调用 ToString() 后才能调用 GetHashCode()(由于某些原因),那么你就违反了契约 - 现在调用者必须知道它是对象还是 SomeObject。现在你不能把 SomeObject 传递给期望对象的调用者。形状也是一样 - 不能把正方形传递给期望矩形的调用者,因为他们会尝试设置宽度,而不期待它改变高度。你不能用一个类替换另一个类。因此,LSP 被破坏了。 - queen3

7
覆盖会破坏里氏替换原则,如果更改基本方法定义的任何行为。这意味着:
1. 子方法的最弱前提条件不应比基本方法更强。 2. 子方法的后置条件暗示父方法的后置条件。其中后置条件由以下组成:a) 方法执行引起的所有副作用和b) 返回表达式的类型和值。
从这两个要求可以推断出,在子方法中任何不影响超级方法所期望的内容的新功能都不会违反该原则。这些条件允许您在需要超类实例的地方使用子类实例。
如果不遵守这些规则,则类将违反LSP。一个经典的例子是以下层次结构:class Point(x,y),class ColoredPoint(x,y,color)扩展了Point(x,y)并覆盖了ColoredPoint中的equals(obj)方法,该方法通过颜色反映相等性。现在,如果有Set的实例,他可以假设具有相同坐标的两个点在此集合中是相等的。但是对于重写的equals方法来说并非如此,并且通常没有办法扩展可实例化的类并添加在equals方法中使用的方面而不违反LSP。
因此,每次违反此原则时,都会隐式引入潜在的错误,当代码所期望的父类不变量未满足时,该错误就会显现出来。但是,在实际世界中,通常没有明显的设计解决方案,不违反LSP,因此可以使用例如@ViolatesLSP类注释来警告客户端,在多态集合或任何其他依赖于里氏替换原则的情况下使用类实例是不安全的。

如果指定X.Equals(Y)必须返回true,如果X和Y是同一实例,则会出现什么问题?对于任何特定的完全构造的实例X和Y对,必须始终返回相同的值。如果X和Y将作为不同的对象实例进行操作,则必须返回false,并且如果可以替换对X的任何引用组合而不影响程序行为,则可能返回true。确实,很多.NET Equals的实现并不是这样工作的,但这样定义Equals将是有用的,并且将完全符合LSP。 - supercat
2
问题在于,LSP并不意味着如果X和Y将作为不同的对象实例行事,则equals必须返回false,而仅意味着子类必须像其父类一样行事。例如,如果您将超类的成员放入哈希表中作为键,然后使用子类实例进行查找,您将无法找到它,因为在您的情况下它们不相等。 - Vitalii Fedorenko
通常情况下,定义一个比Object.Equals所暗示的更不具体的等价关系是很有用的。这样的等价关系可能只能用于从某种类型派生的对象。如果定义了一个等价关系,指定具有相同名称的两个动物应被视为相等,则命名为“Fred”的暹罗猫如果与命名为“Fred”的猫或者名为“Fred”的土豚不相等,那么它将违反LSP。除非明确指定等价关系... - supercat
默认的等价关系通常不应将任何对象视为与另一个类的任何对象等价,无论类之间的关系如何。仅当可以安全地将一个对象替换为另一个对象时,才将对象视为相等的等价关系可以有用地应用(例如用于缓存或内部化),即使对于所涉及的对象一无所知。其他类型的等价关系(例如匹配“名称”字段)只应在具有特定语义意义的情况下使用。 - supercat

2
我认为你在描述原则时非常准确,只有覆盖纯虚函数或抽象方法才能确保不违反该原则。
然而,如果从客户端的角度来看这个原则,也就是一个接受基类引用的方法。如果这个方法无法判断(当然也不需要尝试或查找)传入的任何实例的类,则也不会违反该原则。因此,覆盖基类方法可能并不重要(某些装饰器可能会这样做,在此过程中调用基类方法)。
如果客户端似乎需要找出传入实例的类,则维护将成为一场噩梦,因为您真正应该做的是将新类添加为维护工作的一部分,而不是修改现有程序。(另见OCP

因此,在常用的正方形和矩形示例中,Square继承自Rectangle并不违反规定。如果矩形的setWidth属性试图识别它的实际类型(如果它是一个传递给接受Rectangle的函数的Square),并试图为该情况实现特殊逻辑,则会违反规定。这正确吗? - Aishwar
2
@aip.cd.aish:如果客户无法辨别,那就不算违规;如果客户需要使用特殊逻辑来修复问题,那才算是违规。最好的情况是类的方法要么不关心自己的类型是什么,要么可以安全地假设其类型与方法最初定义时的类型一致。 - quamrana
@quamrana:根据您对queen3答案评论的回复,派生类不能改变基类的行为,只能添加到它上面?也就是说,我不能在派生类中覆盖一个方法,使其与基类有不同的实现。这是否符合原则(除了关于方法不应尝试识别其实际类别的部分)? - Aishwar
1
@aip.cd.aish:如果你从实现的角度去考虑,这个问题不会那么容易解决。最好从客户的期望出发去思考。在实现中有很多可以做到而不违反客户期望的事情,尤其是当客户的期望比较低或者没有期望时。例如GetHasCode()或者Shape::GetArea(),在这些方法中很难检测出错误。比较困难的例子是在正方形和矩形中,为了符合期望,你可能只能让矩形拥有setHeightsetWidth方法,而让正方形只拥有setSize方法。 - quamrana

1
原始原则:
“这里需要的是类似于以下替换属性:如果对于类型S的每个对象o1,存在一个类型为T的对象o2,使得对于所有以T为基础定义的程序P,当o1被替换为o2时,P的行为不变,则S是T的子类型。”。
Barbara Liskov, 1987

这是关于“行为”的词汇。对于良好的设计而言,“前置条件和后置条件”的理解很有用,但与LSP无关。
让我们来看一下“前置条件和后置条件”理论的总结:
- 不要在输入参数上实施比父类实现的更严格的验证规则。 - 对所有输出参数应用至少与父类应用的相同规则。
表明它与LSP无关的迹象是:那么VOID方法呢?VOID没有输出参数。如何将此规则应用于VOID方法?根据此规则,我们如何保证在VOID方法中遵守LSP?
LSP涉及行为。当子类继承自超类并且您必须使用一些技巧使其工作,并且结果改变了程序的行为时,就会破坏LSP。
LSP是关于“行为”的,Square x Rectangle的经典示例可以帮助我们理解。事实上,这是Uncle Bob使用的示例。 然后,您从Rectangle继承Square并覆盖SetHeight和SetWidth以强制Square作为正方形而不是矩形(通过继承)。 当用户调用SetHeight时,不希望Width发生变化... 但是它会发生变化,这会改变预期的行为并破坏LSP。
这是关于Virtuals x LSP的问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接