什么是Liskov替代原则?

我听说Liskov替换原则(LSP)是面向对象设计的基本原则。它是什么?它的使用示例是什么?

504821 次浏览

将ThreeDBoard在板的数组方面实现是有用的吗?

也许您可能希望将各种平面上的ThreeDBoard切片视为一个Board。在这种情况下,您可能希望为Board抽象出一个接口(或抽象类)以允许多个实现。

就外部接口而言,您可能希望为TwoDBoard和ThreeDBoard排除一个Board接口(尽管上述方法都不适合)。

Liskov替换原则(LSP,)是面向对象编程中的一个概念,它指出:

使用指针或对基类的引用必须是能够使用派生类的对象我不知道。

LSP的核心是接口和契约,以及如何决定何时扩展类与使用其他策略(例如组合)来实现您的目标。

我见过的说明这一点的最有效的方法是在Head First OOA&D中。他们提出了一个场景,你是一个项目的开发人员,为策略游戏构建框架。

他们展示了一个类,它代表了一个看起来像这样的板:

类图

所有方法都以X和Y坐标为参数来定位Tiles的二维数组中的平铺位置。这将允许游戏开发人员在游戏过程中管理棋盘中的单元。

这本书继续改变了要求,说游戏框架工作也必须支持3D游戏板以适应有飞行的游戏。因此引入了一个扩展BoardThreeDBoard类。

乍一看,这似乎是一个很好的决定。Board提供HeightWidth属性,ThreeDBoard提供Z轴。

当您查看从Board继承的所有其他成员时,它会崩溃。AddUnitGetTileGetUnits等的方法都在Board类中同时采用X和Y参数,但ThreeDBoard也需要Z参数。

所以你必须用Z参数再次实现这些方法。Z参数没有Board类的上下文,从Board类继承的方法失去了它们的意义。试图使用ThreeDBoard类作为其基类Board的代码单元将非常不走运。

也许我们应该找到另一种方法。ThreeDBoard应该由Board对象组成,而不是扩展Board。Z轴的每单位一个Board对象。

这允许我们使用良好的面向对象原则,如封装和重用,并且不违反LSP。

使用指向基类的指针或引用的函数必须能够在不知情的情况下使用派生类的对象。

当我第一次阅读LSP时,我认为这是非常严格意义上的意思,本质上等同于接口实现和类型安全强制转换。这意味着LSP要么由语言本身确保,要么不确保。例如,在这个严格意义上,就编译器而言,ThreeDBoard肯定可以替代Board。

在阅读了更多关于这个概念的内容后,我发现LSP的解释通常比这更广泛。

简而言之,客户端代码“知道”指针后面的对象是派生类型而不是指针类型并不仅限于类型安全。通过探测对象的实际行为也可以测试对LSP的遵守。也就是说,检查对象的状态和方法参数对方法调用结果的影响,或者对象抛出异常的类型。

再次回到示例,理论上可以使Board方法在ThreeDBoard上正常工作。然而,在实践中,很难防止客户端可能无法正确处理的行为差异,而不会阻碍ThreeDBoard打算添加的功能。

有了这些知识,评估LSP依从性可以成为一个很好的工具,可以确定何时组合是扩展现有功能而不是继承的更合适的机制。

罗伯特·马丁有一个优秀的关于Liskov替换原则的论文。它讨论了可能违反原则的微妙和不那么微妙的方式。

本文的一些相关部分(请注意,第二个例子被大量压缩):

违反LSP的一个简单例子

最明显的违反这一原则的行为之一是使用C++运行时类型信息(RTTI)以根据对象的类型。即:

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类的每一个可能的导数,它必须被改变每当创建Shape的新派生时。事实上,许多人认为这个函数的结构是面向对象设计的诅咒。

正方形和矩形,更微妙的违反。

但是,还有其他更微妙的违反LSP的方式。考虑一个使用Rectangle类的应用程序下图:

class Rectangle{public:void SetWidth(double w) {itsWidth=w;}void SetHeight(double h) {itsHeight=w;}double GetHeight() const {return itsHeight;}double GetWidth() const {return itsWidth;}private:double itsWidth;double itsHeight;};

[…]想象有一天用户要求能够操纵除了矩形之外还有正方形[…]

显然,正方形是所有正常意图和目的的矩形。由于ISA关系成立,因此对Square建模是合乎逻辑的类是从Rectangle派生的。[…]

Square将继承SetWidthSetHeight函数。这些函数对于Square完全不合适,因为宽度和正方形的高度是一样的这应该是个重要的线索设计有问题。然而,有一种方法可以避免这个问题。我们可以覆盖SetWidthSetHeight[…]

但考虑以下功能:

void f(Rectangle& r){r.SetWidth(32); // calls Rectangle::SetWidth}

如果我们将对Square对象的引用传递到此函数中,则Square对象将被损坏,因为高度不会改变。这明显违反了LSP。该功能不适用于它的参数的导数。

[…]

LSP涉及不变量。

经典示例由以下伪代码声明给出(省略实现):

class Rectangle {int getHeight()void setHeight(int value) {postcondition: width didn’t change}int getWidth()void setWidth(int value) {postcondition: height didn’t change}}
class Square extends Rectangle { }

现在我们有一个问题,尽管接口匹配。原因是我们违反了源自正方形和矩形的数学定义的不变量。getter和setter的工作方式,Rectangle应该满足以下不变量:

void invariant(Rectangle r) {r.setHeight(200)r.setWidth(100)assert(r.getHeight() == 200 and r.getWidth() == 100)}

然而,这个不变量(以及显式后置条件)必须Square的正确实现所违反,因此它不是Rectangle的有效替代。

LSP是关于类的契约的规则:如果基类满足契约,那么由LSP派生的类也必须满足该契约。

在Pseudo-python中

class Base:def Foo(self, arg):# *... do stuff*
class Derived(Base):def Foo(self, arg):# *... do stuff*

满足LSP,如果你每次调用Foo的派生对象,它给出了完全相同的结果调用Foo的基础对象,只要arg是相同的。

说明LSP的一个很好的例子(由Bob叔叔在我最近听到的播客中给出)是有时在自然语言中听起来正确的东西在代码中不太起作用。

在数学中,SquareRectangle。事实上,它是矩形的特化。“是”让你想用继承来建模。然而,如果在代码中你让SquareRectangle派生,那么Square应该可以在你期望Rectangle的任何地方使用。这会产生一些奇怪的行为。

想象一下,你的Rectangle基类上有SetWidthSetHeight方法;这似乎是完全合乎逻辑的。然而,如果你的Rectangle引用指向Square,那么SetWidthSetHeight就没有意义了,因为设置一个会改变另一个来匹配它。在这种情况下,Square没有通过Rectangle的Liskov替换测试,并且SquareRectangle继承的抽象是不好的。

在此处输入图片描述

你们都应该看看另一个无价的用动机海报解释的坚实原则

LSP的这个提法太强了:

如果对于每个S类型的对象o1,都有一个T类型的对象o2,使得对于所有用T定义的程序P,当o1代替o2时,P的行为不变,则S是T的子类型。

这基本上意味着S是与T完全相同的另一个完全封装的实现,我可以大胆地决定性能是P…

因此,基本上,任何后期绑定的使用都违反了LSP。当我们将一种对象替换为另一种对象时,获得不同的行为是OO的全部意义!

引用的公式由wikipedia更好,因为属性取决于上下文,不一定包括程序的整个行为。

当某些代码认为它正在调用类型T的方法时,LSP是必要的,并且可能在不知不觉中调用类型S的方法,其中S extends T(即S继承、派生自或是超类型T的子类型)。

例如,当具有T类型输入参数的函数以S类型的参数值被调用(即调用)时,就会发生这种情况。或者,在T类型的标识符被分配为S类型的值的情况下。

val id : T = new S() // id thinks it's a T, but is a S

LSP要求类型T(例如Rectangle)的方法的期望(即不变量)在调用类型S(例如Square)的方法时不被违反。

val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Squareval rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation

即使是具有不可变字段的类型仍然具有不变量,例如不可变矩形设置器期望尺寸被独立修改,但不可变方形设置器违反了这一期望。

class Rectangle( val width : Int, val height : Int ){def setWidth( w : Int ) = new Rectangle(w, height)def setHeight( h : Int ) = new Rectangle(width, h)}
class Square( val side : Int ) extends Rectangle(side, side){override def setWidth( s : Int ) = new Square(s)override def setHeight( s : Int ) = new Square(s)}

LSP要求子类型S的每个方法必须具有逆变输入参数和协变输出。

逆变是指方差方向与继承方向相反,即类型Si、子类型S的每个方法的每个输入参数,必须与类型Ti的相应输入参数相同或与类型超型的相应方法的超类型T相同。

协方差是指方差是在同一方向上继承的,即类型So,子类型S的每个方法的输出,必须与类型亚型或类型To的相应方法的相应输出相同的超类型T

这是因为如果调用者认为它有一个类型T,认为它正在调用T的方法,那么它提供类型Ti的参数并将输出分配给类型To。当它实际调用S的相应方法时,然后每个Ti输入参数被分配给Si输入参数,So输出被分配给类型To。因此,如果Si不是逆变的w. r. t.到Ti,那么子类型T1-不会是Si的子类型-可以分配给Ti

此外,对于在类型多态性参数(即泛型)上具有定义站点方差注释的语言(例如Scala或Ceylon),类型T的每个类型参数的方差注释的协方向或反方向必须分别为相反或相同方向,每个输入参数或输出(T的每个方法)具有类型参数的类型。

此外,对于每个具有函数类型的输入参数或输出,所需的方差方向是相反的。这条规则是递归应用的。


分型是合适的,其中可以枚举不变量。

关于如何对不变量建模,以便它们由编译器强制执行,有很多正在进行的研究。

Typestate声明并强制执行与类型正交的状态不变量。或者,不变量可以由将断言转换为类型强制执行。例如,要断言文件在关闭前已打开,那么File.open()可以返回一个OpenFile类型,其中包含File中不可用的关闭()方法。井字游戏API可以是另一个在编译时使用类型来强制执行不变量的例子。类型系统甚至可能是图灵完备的,例如scala。依赖类型的语言和定理证明器形式化了高阶类型的模型。

因为语义学需要扩展上的抽象,我期望用类型来建模不变量,即统一的高阶指称语义学,优于Typestate。“扩展”意味着不协调的模块化开发的无界、置换的组合。因为在我看来,这似乎是统一和自由度的对立面,有两个相互依赖的模型(例如类型和Typestate)来表达共享的语义学,而这两个模型在可扩展组合中不能相互统一。例如,类似表达问题的扩展在子类型、函数重载和参数类型域中是统一的。

我的理论立场是,对于知识存在(参见“中心化是盲目和不合适的”一节),将有从未是一个通用模型,可以在图灵完备的计算机语言中100%覆盖所有可能的不变量。为了知识的存在,存在许多意想不到的可能性,即无序和熵必须总是在增加。这是熵力。要证明潜在扩展的所有可能计算,就是先验计算所有可能的扩展。

这就是为什么存在停止定理,即不能判定图灵完备性编程语言中的每个可能的程序是否终止。可以证明某个特定的程序终止(所有可能性都已经定义和计算了的程序)。但不可能证明该程序的所有可能扩展都终止,除非该程序扩展的可能性不是图灵完备的(例如通过依赖类型)。由于图灵完备性的基本要求是无界递归,理解哥德尔不完备性定理和罗素悖论如何适用于扩展是直观的。

对这些定理的解释将它们纳入对熵力的广义概念理解中:

  • 哥德尔不完备性定理:任何形式理论,其中所有算术真理都可以被证明,是不一致的。
  • 罗素悖论:一个可以包含一个集合的集合的每个成员规则,要么枚举每个成员的特定类型,要么包含它自己。因此集合要么不能扩展,要么它们是无界递归。例如,不是茶壶的一切的集合,包括它自己,它包括它自己,它包括它自己,等等…因此,如果一个规则(可能包含一个集合并且)不枚举特定类型(即允许所有未指定的类型)并且不允许无界扩展,则它是不一致的。这是不是自己成员的集合。这种无法在所有可能的扩展上保持一致和完全枚举的能力,是哥德尔的不完全性定理。
  • 李斯科夫潜质原理:一般来说,任何集合是否是另一个集合的子集是一个不可判定的问题,即继承通常是不可判定的。
  • 林斯基参考:当事物被描述或感知时,它的计算是不可确定的,即感知(现实)没有绝对的参考点。
  • 科斯定理:没有外部参考点,因此任何阻碍无限外部可能性的障碍都会失败。
  • 0:整个宇宙(一个封闭的系统,即万物)趋向于最大无序,即最大的独立可能性。

LSP使用的一个重要例子是软件测试

如果我有一个类A是B的LSP兼容子类,那么我可以重用B的测试套件来测试A。

为了完全测试子类A,我可能需要添加更多的测试用例,但至少我可以重用所有超类B的测试用例。

实现这一点的一种方法是构建McGregor所说的“用于测试的并行层次结构”:我的ATest类将继承自BTest。然后需要某种形式的注入来确保测试用例适用于类型A而不是类型B的对象(简单的模板方法模式就可以了)。

请注意,对所有子类实现重用超级测试套件实际上是测试这些子类实现是否符合LSP的一种方式。因此,人们也可以争辩说,一个应该在任何子类的上下文中运行超类测试套件。

另请参阅Stackoverflow问题“我可以实现一系列可重用的测试来测试接口的实现吗?”的答案

我建议你阅读这篇文章:违反Liskov替换原则(LSP)

你可以在那里找到一个解释什么是Liskov替换原则,帮助你猜测你是否已经违反了它的一般线索,以及一个方法示例,它将帮助你使你的类层次结构更加安全。

一些附录:
我想知道为什么没有人写过派生类必须遵守的基类的不变性、前置条件和后置条件。为了使派生类D完全可以被Base类B继承,类D必须遵守某些条件:

  • 基类的内变体必须由派生类保留
  • 基类的先决条件不能被派生类加强
  • 基类的后置条件不能被派生类削弱。

因此,派生必须意识到基类强加的上述三个条件。因此,子类型的规则是预先决定的。这意味着,“IS A”关系只有在子类型遵守某些规则时才会遵守。这些规则以不变量、预编码和后置的形式出现,应该由形式的“设计合同”决定。

关于这一点的进一步讨论可以在我的博客上找到:Liskov替换原则

正方形是一个长方形,其中宽度等于高度。如果正方形为宽度和高度设置了两个不同的大小,它就违反了正方形不变量。这是通过引入副作用来解决的。但是如果矩形有一个设置大小(高度,宽度),先决条件0<高度和0<宽度。派生的子类型方法需要高度==宽度;一个更强的先决条件(这违反了lsp)。这表明尽管square是一个矩形,但它不是一个有效的子类型,因为先决条件得到了加强。周围的工作(通常是一件坏事)会产生副作用,这会削弱post条件(违反lsp)。基础上的setWidth具有post条件0<宽度。派生的削弱它的高度==宽度。

因此,可调整大小的正方形不是可调整大小的矩形。

到目前为止,我发现对LSP最清晰的解释是从这里开始的“Liskov替换原则说派生类的对象应该能够替换基类的对象,而不会给系统带来任何错误或修改基类的行为”。这篇文章给出了违反LSP和修复它的代码示例。

LISKOV SUBSTITUTION PRINCIPLE(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下将接口的一个实现替换为另一个实现。

如果我们将计算机从墙上拔掉插头(实现),墙上的插座(接口)和计算机(客户端)都不会坏(事实上,如果是笔记本电脑,它甚至可以用电池运行一段时间)。然而,对于软件,客户端通常期望有服务可用。如果服务被删除,我们会得到一个NullReReference ceException。为了处理这种情况,我们可以创建一个“什么都不做”的接口实现。这是一种称为空对象的设计模式,[4]它大致对应于从墙上拔掉计算机。因为我们使用的是松耦合,所以我们可以用什么都不做而不会造成麻烦的东西替换真正的实现。

有一个清单来确定你是否违反了Liskov。

  • 如果你违反了以下项目之一->你违反了Liskov。
  • 如果你不违反任何->不能得出任何结论。

核对清单:

  • 不应在派生类中抛出新的异常:如果你的基类抛出了ArgumentNullException,那么你的子类只允许抛出ArgumentNullException类型的异常或任何从ArgumentNullException派生的异常。

  • 前提条件不能加强:假设你的基类与成员int一起工作。现在你的子类型要求int为正。这是加强的先决条件,现在任何在使用负整数之前工作得很好的代码都被破坏了。

  • 后置条件不能被削弱:假设你的基类要求在方法返回之前关闭与数据库的所有连接。在你的子类中,你覆盖了那个方法并保持连接打开以供进一步重用。你削弱了那个方法的后置条件。

  • 必须保持不变:最困难和最痛苦的约束。不变量有时隐藏在基类中,揭示它们的唯一方法是阅读基类的代码。基本上你必须确保当你覆盖一个方法时,任何不可改变的东西在你覆盖的方法执行后必须保持不变。我能想到的最好的办法是在基类中强制执行这些不变的约束,但这并不容易。

  • 历史约束:重写方法时,不允许修改基类中不可修改的属性。看看这些代码,你可以看到Name被定义为不可修改(私有集),但SubType引入了允许修改它的新方法(通过反射):

     public class SuperType{public string Name { get; private set; }public SuperType(string name, int age){Name = name;Age = age;}}public class SubType : SuperType{public void ChangeName(string newName){var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);}}

还有另外两个项目:方法参数的矛盾返回类型的协方差。但在C#中是不可能的(我是C#开发人员),所以我不在乎它们。

我想每个人都在技术上介绍了LSP是什么:你基本上希望能够抽象出子类型细节并安全地使用超类型。

所以Liskov有三个基本规则:

  1. 签名规则:子类型中超类型的每个操作在语法上都应该有一个有效的实现。编译器将能够为您检查的东西。关于抛出更少的异常并且至少与超类型方法一样可访问,有一条小规则。

  2. 方法规则:这些操作的实现在语义上是合理的。

    • 较弱的先决条件:子类型函数至少应该将超类型作为输入,如果不是更多的话。
    • 更强的后置条件:它们应该产生超类型方法产生的输出的子集。
  3. 属性规则:这超越了单个函数调用。

    • 不变式:永远为真的事物必须保持真。例如,集合的大小永远不会为负。
    • 进化属性:通常与不变性或对象可以处于的状态类型有关。或者可能对象只增长而不收缩,因此子类型方法不应该成功。

所有这些属性都需要保留,额外的子类型功能不应违反超类型属性。

如果处理了这三件事,那么您已经抽象出了底层内容,并且正在编写松散耦合的代码。

来源:Java项目开发-Barbara Liskov

可替代性是面向对象编程中的一个原则,它指出,在计算机程序中,如果S是T的子类型,则T类型的对象可以替换为S类型的对象

让我们在Java做一个简单的例子:

坏榜样

public class Bird{public void fly(){}}public class Duck extends Bird{}

鸭子可以飞,因为它是一只鸟,但是这个呢?

public class Ostrich extends Bird{}

Ostrich类是Bird类,但它不能飞,Ostrich类是Bird类的一个子类,但它不应该使用fly方法,这意味着我们正在打破LSP原则。

好榜样

public class Bird{}public class FlyingBirds extends Bird{public void fly(){}}public class Duck extends FlyingBirds{}public class Ostrich extends Bird{}

在一个简单的句子中,我们可以说:

子类不能违反它的基类特征。它必须能够使用它。我们可以说它与子类型相同。

李可夫的替换原则指出如果程序模块使用Base类,则可以将对Base类的引用替换为Derive类,而不会影响程序模块的功能。

意图-派生类型必须能够完全替代其基类型。

示例-java中的协变返回类型。

假设我们在代码中使用一个矩形

r = new Rectangle();// ...r.setDimensions(1,2);r.fill(colors.red());canvas.draw(r);

在我们的几何课程中,我们了解到正方形是一种特殊类型的矩形,因为它的宽度与其高度相同。让我们根据以下信息创建一个Square类:

class Square extends Rectangle {setDimensions(width, height){assert(width == height);super.setDimensions(width, height);}}

如果我们在第一个代码中将Rectangle替换为Square,那么它将中断:

r = new Square();// ...r.setDimensions(1,2); // assertion width == height failedr.fill(colors.red());canvas.draw(r);

这是因为Square有一个我们在Rectangle类中没有的新先决条件:width == height。根据LSP,Rectangle实例应该可以被Rectangle子类实例替换。这是因为这些实例通过了Rectangle实例的类型检查,所以它们会在你的代码中引起意外错误。

这是wiki文章"前置条件不能在子类型中得到加强"部分的一个示例。因此,总而言之,违反LSP可能会在某些时候导致代码中的错误。

简而言之,让我们留下矩形矩形和正方形正方形,扩展父类时的实际示例,您必须要么PRESERVE确切的父API,要么EXTEND IT。

假设您有一个基地 ItemsReposaku。

class ItemsRepository{/*** @return int Returns number of deleted rows*/public function delete(){// perform a delete query$numberOfDeletedRows = 10;
return $numberOfDeletedRows;}}

还有一个扩展它的子类:

class BadlyExtendedItemsRepository extends ItemsRepository{/*** @return void Was suppose to return an INT like parent, but did not, breaks LSP*/public function delete(){// perform a delete query$numberOfDeletedRows = 10;
// we broke the behaviour of the parent classreturn;}}

然后,您可以让客户使用Base ItemsRepositoryAPI并依赖它。

/*** Class ItemsService is a client for public ItemsRepository "API" (the public delete method).** Technically, I am able to pass into a constructor a sub-class of the ItemsRepository* but if the sub-class won't abide the base class API, the client will get broken.*/class ItemsService{/*** @var ItemsRepository*/private $itemsRepository;
/*** @param ItemsRepository $itemsRepository*/public function __construct(ItemsRepository $itemsRepository){$this->itemsRepository = $itemsRepository;}
/*** !!! Notice how this is suppose to return an int. My clients expect it based on the* ItemsRepository API in the constructor !!!** @return int*/public function delete(){return $this->itemsRepository->delete();}}

取代父母类带有子类破坏了API的契约时,LSP被破坏。

class ItemsController{/*** Valid delete action when using the base class.*/public function validDeleteAction(){$itemsService = new ItemsService(new ItemsRepository());$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is an INT :)}
/*** Invalid delete action when using a subclass.*/public function brokenDeleteAction(){$itemsService = new ItemsService(new BadlyExtendedItemsRepository());$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is a NULL :(}}

您可以在我的课程中了解有关编写可维护软件的更多信息:https://www.udemy.com/enterprise-php/

我在每个答案中都看到矩形和正方形,以及如何违反LSP。

我想用一个真实世界的例子来展示如何符合LSP:

<?php
interface Database{public function selectQuery(string $sql): array;}
class SQLiteDatabase implements Database{public function selectQuery(string $sql): array{// sqlite specific code
return $result;}}
class MySQLDatabase implements Database{public function selectQuery(string $sql): array{// mysql specific code
return $result;}}

这种设计符合LSP,因为无论我们选择使用哪种实现,行为都保持不变。

是的,您可以在此配置中违反LSP,进行一个简单的更改,如下所示:

<?php
interface Database{public function selectQuery(string $sql): array;}
class SQLiteDatabase implements Database{public function selectQuery(string $sql): array{// sqlite specific code
return $result;}}
class MySQLDatabase implements Database{public function selectQuery(string $sql): array{// mysql specific code
return ['result' => $result]; // This violates LSP !}}

现在,子类型不能以相同的方式使用,因为它们不再产生相同的结果。

李斯科夫替换原理(LSP)

我们一直在设计一个程序模块并创建一些类然后我们扩展一些类,创建一些派生的类。

我们必须确保新的派生类只是扩展而没有扩展替换旧类的功能。否则,新类在现有程序中使用时可能会产生不希望的效果模块。

Liskov的替换原则指出,如果程序模块是使用Base类,则对Base类的引用可以是替换为派生类,而不影响程序模块。

示例:

下面是违反Liskov替换原则的经典示例。在该示例中,使用了2个类:Rectangle和Square。让我们假设Rectangle对象在应用程序的某个地方使用。我们扩展了应用程序并添加了Square类。square类由工厂模式返回,基于某些条件,我们不知道将返回的对象的确切类型。但我们知道它是Rectangle。我们得到Rectangle对象,将宽度设置为5,高度设置为10并获得面积。对于宽度5,高度10的矩形,面积应该是50。相反,结果将是100

    // Violation of Likov's Substitution Principleclass Rectangle {protected int m_width;protected int m_height;
public void setWidth(int width) {m_width = width;}
public void setHeight(int height) {m_height = height;}
public int getWidth() {return m_width;}
public int getHeight() {return m_height;}
public int getArea() {return m_width * m_height;}}
class Square extends Rectangle {public void setWidth(int width) {m_width = width;m_height = width;}
public void setHeight(int height) {m_width = height;m_height = height;}
}
class LspTest {private static Rectangle getNewRectangle() {// it can be an object returned by some factory ...return new Square();}
public static void main(String args[]) {Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);r.setHeight(10);// user knows that r it's a rectangle.// It assumes that he's able to set the width and height as for the base// class
System.out.println(r.getArea());// now he's surprised to see that the area is 100 instead of 50.}}

结论:

这个原则只是打开关闭原则的延伸,它意味着我们必须确保新的派生类正在扩展不改变其行为的基类。

另见:打开关闭原则

一些类似的概念以获得更好的结构:约定优于配置

以下是这篇文章的摘录,很好地澄清了事情:

[…]为了理解一些原则,重要的是要意识到它何时被违反。这就是我现在要做的。

违反这个原则是什么意思?它意味着一个对象没有履行用接口表达的抽象所强加的契约。换句话说,这意味着你识别了你的抽象错误。

考虑以下示例:

interface Account{/*** Withdraw $money amount from this account.** @param Money $money* @return mixed*/public function withdraw(Money $money);}class DefaultAccount implements Account{private $balance;public function withdraw(Money $money){if (!$this->enoughMoney($money)) {return;}$this->balance->subtract($money);}}

这是否违反了LSP?是的。这是因为账户的合同告诉我们一个账户将被撤回,但情况并非总是如此。那么,我该怎么做才能修复它?我只是修改合同:

interface Account{/*** Withdraw $money amount from this account if its balance is enough.* Otherwise do nothing.** @param Money $money* @return mixed*/public function withdraw(Money $money);}

瞧,现在合同得到了满足。

这种微妙的违规通常会让客户端有能力区分所使用的具体对象。例如,给定第一个帐户的合同,它可能如下所示:

class Client{public function go(Account $account, Money $money){if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {return;}$account->withdraw($money);}}

而且,这会自动违反开闭原则[也就是说,对于取钱要求。因为你永远不知道如果违反合约的对象没有足够的钱会发生什么。可能它只是什么都不返回,可能会抛出异常。所以你必须检查它是否hasEnoughMoney()--这不是接口的一部分。所以这种强制的具体类依赖检查是对OCP的违反]。

这一点也解决了我经常遇到的关于违反LSP的误解。它说“如果父母的行为在孩子身上发生了变化,那么它就违反了LSP。”然而,只要孩子不违反父母的合同,它就不会。

让我们用Java说明:

class TrasportationDevice{String name;String getName() { ... }void setName(String n) { ... }
double speed;double getSpeed() { ... }void setSpeed(double d) { ... }
Engine engine;Engine getEngine() { ... }void setEngine(Engine e) { ... }
void startEngine() { ... }}
class Car extends TransportationDevice{@Overridevoid startEngine() { ... }}

这里没有问题吧?汽车绝对是一个运输设备,这里我们可以看到它覆盖了其超类的start Engine()方法。

让我们添加另一个运输设备:

class Bicycle extends TransportationDevice{@Overridevoid startEngine() /*problem!*/}

现在一切都没有按计划进行!是的,自行车是一种运输设备,但是,它没有引擎,因此无法实现方法start Engine()。

这些是违反Liskov替换的问题类型原则导致,它们通常可以被一个什么都不做,甚至无法实现的方法。

这些问题的解决方案是正确的继承层次结构,在我们的例子中,我们将通过区分有引擎和没有引擎的运输设备类别来解决问题。即使自行车是一种运输设备,它也没有引擎。在这个例子中,我们对运输设备的定义是错误的。它不应该有引擎。

我们可以重构我们的TransportationDevice类,如下所示:

class TrasportationDevice{String name;String getName() { ... }void setName(String n) { ... }
double speed;double getSpeed() { ... }void setSpeed(double d) { ... }}

现在我们可以为非机动设备扩展TransportationDevice。

class DevicesWithoutEngines extends TransportationDevice{void startMoving() { ... }}

并为电动设备扩展TransportationDevice。这里更适合添加Engine对象。

class DevicesWithEngines extends TransportationDevice{Engine engine;Engine getEngine() { ... }void setEngine(Engine e) { ... }
void startEngine() { ... }}

因此,我们的汽车类变得更加专业化,同时坚持Liskov替代原则。

class Car extends DevicesWithEngines{@Overridevoid startEngine() { ... }}

我们的自行车类也符合Liskov替代原则。

class Bicycle extends DevicesWithoutEngines{@Overridevoid startMoving() { ... }}

LSP说“对象应该可以被它们的子类型替换”。另一方面,这个原则指向

子类不应该破坏父类的类型定义。

下面的例子有助于更好地理解LSP。

没有LSP:

public interface CustomerLayout{
public void render();}

public FreeCustomer implements CustomerLayout {...@Overridepublic void render(){//code}}

public PremiumCustomer implements CustomerLayout{...@Overridepublic void render(){if(!hasSeenAd)return; //it isn`t rendered in this case//code}}
public void renderView(CustomerLayout layout){layout.render();}

通过LSP修复:

public interface CustomerLayout{public void render();}

public FreeCustomer implements CustomerLayout {...@Overridepublic void render(){//code}}

public PremiumCustomer implements CustomerLayout{...@Overridepublic void render(){if(!hasSeenAd)showAd();//it has a specific behavior based on its requirement//code}}
public void renderView(CustomerLayout layout){layout.render();}

让我试一试,考虑一个接口:

interface Planet{}

这是按类实现的:

class Earth implements Planet {public $radius;public function construct($radius) {$this->radius = $radius;}}

您将使用地球作为:

$planet = new Earth(6371);$calc = new SurfaceAreaCalculator($planet);$calc->output();

现在再考虑一个扩展地球的类:

class LiveablePlanet extends Earth{public function color(){}}

现在根据LSP,你应该能够使用LiveablePlanet来代替地球,它不应该破坏你的系统。比如:

$planet = new LiveablePlanet(6371);  // Earlier we were using Earth here$calc = new SurfaceAreaCalculator($planet);$calc->output();

示例来自这里

李斯科夫替换原理

  • 被覆盖的方法不应保持为空
  • 被覆盖的方法不应该抛出错误
  • 由于派生类行为,基类或接口行为不应该进行修改(返工)。

简而言之,LSP表示同一超类的对象应该能够在不破坏任何东西的情况下相互成为交换

例如,如果我们有一个从Animal类派生的CatDog类,任何使用动物类的函数都应该能够使用CatDog并正常运行。

这个原则是由Barbara Liskov在1987年引入的,并通过关注超类及其子类型的行为来扩展开放-封闭原则。

当我们考虑违反它的后果时,它的重要性变得显而易见。考虑一个使用以下类的应用程序。

public class Rectangle{private double width;
private double height;
public double Width{get{return width;}set{width = value;}}
public double Height{get{return height;}set{height = value;}}}

想象一下,有一天,客户端要求除了矩形之外还能操作正方形。由于正方形是矩形,因此正方形类应该派生自Rectangle类。

public class Square : Rectangle{}

但是,这样做会遇到两个问题:

一个正方形不需要从矩形继承的高度和宽度变量,如果我们必须创建数十万个正方形对象,这可能会在内存中造成重大浪费。从矩形继承的宽度和高度设置器属性不适合正方形,因为正方形的宽度和高度是相同的。为了将高度和宽度设置为相同的值,我们可以创建两个新属性,如下所示:

public class Square : Rectangle{public double SetWidth{set{base.Width = value;base.Height = value;}}
public double SetHeight{set{base.Height = value;base.Width = value;}}}

现在,当有人设置正方形物体的宽度时,它的高度会相应地改变,反之亦然。

Square s = new Square();s.SetWidth(1); // Sets width and height to 1.s.SetHeight(2); // sets width and height to 2.

让我们继续前进并考虑另一个函数:

public void A(Rectangle r){r.SetWidth(32); // calls Rectangle.SetWidth}

如果我们将对正方形对象的引用传递给此函数,我们将违反LSP,因为该函数不适用于其参数的导数。属性宽度和高度不是多态的,因为它们在矩形中没有声明为虚拟(正方形对象将被损坏,因为高度不会改变)。

但是,通过将setter属性声明为虚拟,我们将面临另一个违规行为,即OCP。事实上,创建派生类方块会导致基类矩形的更改。

李斯科夫替换原理

[固体]

继承分类

维基Liskov替换原则(LSP)

不能在子类型中加强先决条件。
后置条件不能在子类型中被削弱。
超类型的不变量必须保留在子类型中。

  • 子类型不应该比超类型更需要调用者(前置条件)
  • 子类型不应为小于超类型的调用者公开(后置条件)

*前置条件+后置条件=function (method) types[Swift函数类型。Swift函数与方法]

//Swift functionfunc foo(parameter: Class1) -> Class2
//function type(Class1) -> Class2
//PreconditionClass1
//PostconditionClass2

示例

//C3 -> C2 -> C1
class C1 {}class C2: C1 {}class C3: C2 {}
  • 前置条件(例如函数parameter type)可以相同或(争取->C1)

  • 后置条件(例如函数returned type)可以相同或更强大(争取->C3)

  • 超类型的不变变量关于我们应该保持不变

Swift

class A {func foo(a: C2) -> C2 {return C2()}}
class B: A {override func foo(a: C1) -> C3 {return C3()}}

Java

class A {public C2 foo(C2 a) {return new C2();}}
class B extends A {@Overridepublic C3 foo(C2 a) { //You are available pass only C2 as parameterreturn new C3();}}

行为分型

维基Liskov替换原则(LSP)

子类型中方法参数类型的逆差。
子类型中方法返回类型的协方差。
子类型中的方法不能抛出新的异常,除非它们是超类型方法抛出的异常的子类型。

[方差,协方差,逆差,不变性]

它指出如果C是E的子类型,那么E可以被C类型的对象替换,而不会改变或破坏程序的行为。简单地说,派生类应该可以替代它们的父类。例如,如果0#是法默,那么他可以代替他的父亲工作,但如果0#是板球运动员,那么他不能代替他的父亲工作。

违规示例:

public class Plane{
public void startEngine(){}
}public class FighterJet extends Plane{}    
public class PaperPlane extends Plane{}

在给定的示例中,FighterPlanePaperPlane类都扩展了Plane类,其中包含startEngine()方法。所以很明显FighterPlane可以启动引擎,但PaperPlane不能,所以它破坏了LSP

PaperPlane class虽然扩展了Plane class并且应该可以替换它,但不是Plane实例可以替换的合格实体,因为纸飞机无法启动引擎,因为它没有引擎。所以一个很好的例子是,

尊敬的例子:

public class Plane{}public class RealPlane{
public void startEngine(){}
}public class FighterJet extends RealPlane{}public class PaperPlane extends Plane{}

大画面:

  • Liskov替换原理是关于什么的?它是关于给定类型的亚型(什么不是)。
  • 为什么它如此重要?因为亚型子类之间是有区别的。

示例

与其他答案不同,我不会从违反Liskov替换原则(LSP)开始,而是从符合LSP开始。我使用Java,但在每种OOP语言中几乎都是一样的。

CircleColoredCircle

几何例子在这里似乎很受欢迎。

class Circle {private int radius;
public Circle(int radius) {if (radius < 0) {throw new RuntimeException("Radius should be >= 0");}this.radius = radius;}
public int getRadius() {return this.radius;}}

半径不允许为负。下面是一个suclass:

class ColoredCircle extends Circle {private Color color; // defined elsewhere
public ColoredCircle(int radius, Color color) {super(radius);this.color = color;}
public Color getColor() {return this.color;}}

根据LSP,这个子类是Circle的子类型。

LSP指出:

如果对于每个S类型的对象o1,都有一个T类型的对象o2,使得对于所有用T定义的程序P,当o1代替o2时,P的行为不变,则S是T的子类型(Barbara Liskov,“数据抽象和层次结构”,SIGPLAN通知,23,5(1988年5月))

在这里,对于每个ColoredCircle实例o1,考虑Circle实例具有相同的半径o2。对于每个使用Circle对象的程序,如果你用o1替换o2,任何使用Circle的程序的行为在替换后都将保持不变。(请注意,这是理论上的:使用ColoredCircle实例比使用Circle实例更快地耗尽内存,但这与此无关。)

我们如何根据o1找到o2?我们只需去掉color属性并保留radius属性。我将转换o1->o2称为Circle空间上CircleColor空间的投影

反例

让我们创建另一个示例来说明违反LSP的情况。

CircleSquare

想象一下前一个Circle类的子类:

class Square extends Circle {private int sideSize;
public Square(int sideSize) {super(0);this.sideSize = sideSize;}
@Overridepublic int getRadius() {return -1; // I'm a square, I don't care}
public int getSideSize() {return this.sideSize;}}

违反LSP

现在,看看这个程序:

public class Liskov {public static void program(Circle c) {System.out.println("The radius is "+c.getRadius());}

我们用Circle对象和Square对象测试程序。

    public static void main(String [] args){Liskov.program(new Circle(2)); // prints "The radius is 2"Liskov.program(new Square(2)); // prints "The radius is -1"}}

发生了什么?直观地说,虽然SquareCircle的子类,但Square没有Circle的子类型,因为没有常规的Circle实例的半径为-1。

从形式上讲,这违反了Liskov替代原则。

我们有一个用Circle定义的程序,没有Circle对象可以替换这个程序中的new Square(2)(或任何Square实例)并保持行为不变:记住任何Circle的半径总是正的。

子类和子类型

现在我们知道为什么子类并不总是亚型了。当一个子类不是一个子类型时,即当有LSP冲突时,一些程序(至少一个)的行为不会总是预期的行为。这非常令人沮丧,通常被解释为bug。

在理想的世界中,编译器或解释器将能够检查给定的子类是否是真正的子类型,但我们不在理想的世界中。

静态类型

如果有一些静态类型,您将在编译时受到超类签名的约束。Square.getRadius()不能返回StringList

如果没有静态类型,如果一个参数的类型是错误的(除非类型很弱)或参数的数量不一致(除非语言非常宽松),您将在运行时收到错误。

关于静态类型的注意:存在返回类型的协方差(S的方法可以返回与T的方法相同的返回类型的子类)和参数类型的逆变(S的方法可以接受与T的方法相同的参数的参数的超类)的机制。这是下面解释的先决条件和后置的具体情况。

按合同设计

还有更多。一些语言(我认为是Eiffel)提供了一种强制遵守LSP的机制。

更不用说确定初始对象o1的投影o2了,我们可以期待任何程序的相同行为,如果o1代替o2 if,对于任何参数#4和任何方法#5

  • 如果o2.f(x)是有效呼叫,则o1.f(x)也应该是有效呼叫(1)。
  • o1.f(x)的结果(返回值、控制台上的显示等)应该等于o2.f(x)的结果,或者至少同等有效(2)。
  • o1.f(x)应该让o1处于内部状态,o2.f(x)应该让o2处于内部状态,以便下一次函数调用将确保(1)、(2)和(3)仍然有效(3)。

请注意,如果函数#0是纯函数,则(3)是免费的。这就是为什么我们喜欢拥有不可变对象。

这些条件是关于类的语义学(期望什么),而不仅仅是类的语法。此外,这些条件非常强。但是它们可以通过契约编程在设计中的断言来近似。这些断言是确保类型语义得到维护的一种方式。打破契约会导致运行时错误。

  • 前置条件定义了什么是有效调用。当子类化类时,前置条件只能被削弱(S.f接受超过T.f)(a)。
  • 后置定义了什么是有效的结果。当子类化一个类时,后置只能被加强(S.f提供的比T.f多)(b)。
  • 不变量定义了什么是有效的内部状态。子类化类时,不变量必须保持不变(c)。

我们看到,大致上,(a)保证(1),(b)保证(2),但(c)弱于(3)。此外,断言有时难以表达。

想象一个类Counter有一个唯一的方法Counter.counter(),它返回下一个整数。你如何为此编写后置?想象一个类Random有一个方法Random.gaussian(),它返回一个介于0.0和1.0之间的浮点数。你如何编写后置来检查分布是否是高斯分布?这可能是可能的,但成本会很高,以至于我们将依赖测试而不是后置条件。

结论

不幸的是,子类并不总是子类型。这可能会导致意外的行为bug。

OOP语言提供了避免这种情况的机制。首先在语法层面。在语义层面也是如此,这取决于编程语言:语义学的一部分可以使用断言编码在程序文本中。但要由您来确保子类是一个子类型。

还记得你什么时候开始学习OOP吗?“如果关系是IS-A,那么使用继承”。反过来也是如此:如果使用继承,请确保关系是IS-A

LSP在比断言更高的层次上定义了什么是子类型。断言是确保LSP得到维护的有价值的工具。

设q(x)是关于T类型x的对象的可证明属性,则q(y)对于S类型的对象y应该可证明,其中S是T的子类型。


实际上,公认的答案不是Liskov原理的反例。正方形自然是一个特定的矩形,所以继承类矩形是完全有意义的。你只需要通过这种方式实现它:

@Overridepublic void setHeight(double height) {this.height = height;this.width = height; // since it's a square}
@Overridepublic void setWidth(double width) {setHeight(width);}

所以,在提供了一个很好的例子之后,这是一个反例:

class Family:-- getChildrenCount()
class FamilyWithKids extends Family:-- getChildrenCount() { return childrenCount; } // always > 0
class DeadFamilyWithKids extends FamilyWithKids:-- getChildrenCount() { return 0; }-- getChildrenCountWhenAlive() { return childrenCountWhenAlive; }

在这个实现中,DeadFamilyWithKids不能从FamilyWithKids继承,因为getChildrenCount()返回0,而从FamilyWithKids它应该总是返回更大的0