里斯科夫替代原则的一个例子是什么?


Answers:


892

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

在数学中,a Square是a Rectangle。实际上,它是矩形的一种特殊形式。“是”使您想使用继承对其进行建模。但是,如果在您Square从中派生的代码中Rectangle,则a Square应该可以在您期望的任何地方使用Rectangle。这导致一些奇怪的行为。

假设您在基类上有SetWidthSetHeight方法Rectangle;这似乎完全合乎逻辑。但是,如果您的Rectangle引用指向Square,则SetWidthSetHeight没有任何意义,因为设置一个将更改另一个以使其匹配。在这种情况下,Square通过Liskov替代测试失败,Rectangle并且Square从继承的抽象Rectangle是一个不好的选择。

enter image description here

你们应该查看其他无价的SOLID原则励志海报


19
@ m-sharp如果它是一个不可变的Rectangle,而不是SetWidth和SetHeight,而是使用GetWidth和GetHeight方法,该怎么办?
Pacerier

139
故事的寓意:基于行为而不是基于属性为您的课程建模;根据属性而非行为对数据建模。如果表现得像鸭子,那肯定是鸟。
Sklivvz 2012年

193
好吧,正方形显然是现实世界中的一种矩形。是否可以在代码中对此建模,取决于规范。LSP表示子类型行为应与基本类型规范中定义的基本类型行为匹配。如果矩形基本类型说明说高度和宽度可以独立设置,则LSP表示正方形不能是矩形的子类型。如果矩形规范说矩形是不可变的,则正方形可以是矩形的子类型。所有关于子类型的维护基本类型指定的行为。
SteveT 2012年

62
@Pacerier是不可变的,没有问题。真正的问题是,我们不是在建模矩形,而是在建模“可接受的矩形”,即在创建后可以修改宽度或高度的矩形(并且我们仍然将其视为同一对象)。如果我们以这种方式看待矩形类,很明显,正方形不是“可接受的矩形”,因为正方形无法重塑,并且仍然是正方形(通常)。在数学上,我们看不到问题,因为在数学环境中可变性甚至没有意义。
asmeurer 2013年

14
关于这个原则,我有一个问题。为什么会是问题,如果Square.setWidth(int width)是这样实现的:this.width = width; this.height = width;?在这种情况下,可以保证宽度等于高度。
MC皇帝

487

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

使用指针或对基类的引用的函数必须能够使用派生类的对象,而无需对其进行了解。

LSP的核心是关于接口和协定,以及如何决定何时扩展课程与使用诸如组合之类的另一种策略来实现您的目标。

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

他们展示了一个代表板的类,如下所示:

Class Diagram

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

本书继续更改要求,说游戏框架还必须支持3D游戏板以容纳具有飞行功能的游戏。因此ThreeDBoard引入了扩展类Board

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

当您查看从继承的所有其他成员时,它会崩溃Board。对于这些方法AddUnitGetTileGetUnits等等,都以X和Y参数的Board类,但ThreeDBoard需要一个参数Z为好。

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

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

这使我们能够使用良好的面向对象原理,例如封装和重用,并且不会违反LSP。


10
有关类似但更简单的示例,另请参见Wikipedia上的Circle-Ellipse问题
布莱恩(Brian)

从@NotMySelf重新引用:“我认为该示例只是为了说明在ThreeDBoard的上下文中从板继承是没有意义的,并且所有方法签名对于Z轴都是无意义的。”
Contango

1
因此,如果我们向Child类添加另一个方法,但是Parent的所有功能在Child类中仍然有意义,那么它将破坏LSP吗?因为一方面我们修改了使用Child的接口,另一方面,如果我们将Child强制转换为Parent,那么期望Parent的代码会正常工作。
Nickolay Kondratyev

5
这是一个反利斯科夫的例子。里斯科夫让我们从正方形派生矩形。从较少的参数类到更多的参数类。您已经很好地证明了它是不好的。标记为答案,并且对liskov问题的反liskov答案进行了200次投票,这真是一个很好的笑话。里斯科夫原理真的是谬论吗?
Gangnus

3
我看到继承工作方式错误。这是一个例子。基类应为3DBoard,派生类为Board。董事会的Z轴仍为Max(Z)= Min(Z)= 1
Paulustrious

169

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

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

不好的例子

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

鸭子会飞,因为它是鸟。但是这呢:

public class Ostrich extends Bird{}

鸵鸟是鸟,但是它不会飞,鸵鸟类是鸟类的子类型,但是它不能使用fly方法,这意味着我们正在打破LSP原理。

好的例子

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

3
很好的例子,但是如果客户有,您会怎么做Bird bird。您必须将对象投射到FlyingBirds才能使用fly,这不好吗?
穆迪

16
否。如果客户端拥有Bird bird,则意味着它不能使用fly()。而已。传递a Duck不会更改此事实。如果客户端具有FlyingBirds bird,则即使通过,Duck它也应始终以相同的方式工作。
Steve Chamaillard

9
这还不是接口隔离的一个很好的例子吗?
萨哈什

优秀的例子谢谢男人
阿卜杜勒哈迪·阿卜杜

6
如何使用接口“ Flyable”(想不出更好的名字)。这样,我们就不会陷入僵化的层次结构中。除非我们知道确实需要它。
第三

132

LSP关注不变性。

下面的伪代码声明(省略实现)给出了经典示例:

class Rectangle {
    int getHeight()
    void setHeight(int value)
    int getWidth()
    void setWidth(int value)
}

class Square : Rectangle { }

现在,尽管接口匹配,我们还是有问题。原因是我们违反了基于正方形和矩形的数学定义的不变量。获取器和设置器的工作方式a Rectangle应该满足以下不变式:

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

但是,正确的实现必须违反此不变式Square,因此它不是的有效替代Rectangle


35
因此,很难使用“ OO”来建模我们可能要实际建模的任何事物。
DrPizza

9
@DrPizza:绝对。但是,有两件事。首先,这种关系仍然可以在OOP中建模,尽管不完整,也可以使用更复杂的弯路(选择适合您的问题)。其次,没有更好的选择。其他映射/建模有相同或相似的问题。;-)
Konrad Rudolph,2009年

7
@NickW在某些情况下(而不是上面的情况),您可以简单地反转继承链-从逻辑上讲,2D点是-3D点,其中不考虑三维尺寸(或0-所有点都位于同一平面上) 3D空间)。但这当然是不实际的。通常,这是继承没有真正帮助并且实体之间不存在自然关系的情况之一。分别对它们建模(至少我不知道有更好的方法)。
康拉德·鲁道夫2012年

7
OOP旨在模拟行为而非数据。您的类甚至在违反LSP之前就违反了封装。
Sklivvz 2012年

2
@AustinWBryan Yep; 我在该领域工作的时间越长,我越倾向于将继承仅用于接口和抽象基类,而将组合用于其余部分。有时需要做更多的工作(明智地键入内容),但是它避免了很多问题,并且被其他经验丰富的程序员广泛建议。
康拉德·鲁道夫'18

77

罗伯特·马丁(Robert Martin)撰写一篇有关《里斯科夫替代原理》的出色论文。它讨论了可能违反该原则的细微和不太细微的方式。

本文的一些相关部分(请注意,第二个示例非常简洁):

违反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。该函数不适用于其参数的派生。

[...]


14
太晚了,但是我认为那是那篇论文中的一个有趣的引语: Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one. 如果子类的前提条件比父类的前提条件强,那么您就不能在不违反前提条件的情况下用孩子代替父母。因此LSP。
user2023861 2015年

@ user2023861您完全正确。我将以此为基础写一个答案。
inf3rno

40

如果某些代码认为LSP正在调用类型的方法T,并且在不知不觉中调用类型的方法,则LSP是必需的S,其中S extends T(即,S继承,派生或是超类型的子类型T)。

例如,在输入参数类型T为的函数被参数类型为调用(即调用)的情况下会发生这种情况S。或者,在type的标识符中T,分配了type的值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 Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation

即使具有不可变字段的类型仍然具有不变性,例如,不可变的 Rectangle setter期望尺寸被独立修改,但是不可变的 Square setter违反了这一期望。

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,则它将提供type的参数Ti并将输出分配给type To。当它实际上在调用的相应方法时S,则将每个Ti输入参数分配给一个Si输入参数,并将So输出分配给type To。因此,如果Si与并不是协变的Ti,则可以将子类型Xi—不会是的子类型— Si分配给Ti

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

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


子类型适用于可以枚举不变量的地方。

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

类型状态(请参阅第3页)声明并强制与类型正交的状态不变式。或者,可以通过将断言转换为type来强制执行不变量。例如,要断言在关闭文件之前已打开文件,则File.open()可以返回OpenFile类型,该类型包含File中不可用的close()方法。甲井字棋API可以是采用打字执行在编译时不变的另一个例子。类型系统甚至可以是图灵完备的,例如Scala。相依类型语言和定理证明了高阶类型化模型的形式化。

由于需要语义来对扩展进行抽象,因此我希望使用类型来对不变量进行建模(即统一的高阶指称语义)要优于Typestate。“扩展”是指不协调,模块化的开发的无穷排列组合。因为在我看来,这是统一性的对立面,因此也是自由度的对立面,所以有两个相互依赖的模型(例如,类型和Typestate)来表达共享语义,对于可扩展的组合,它们不能相互统一。例如,类似于表达式问题的扩展在子类型,函数重载和参数类型域中是统一的。

我的理论立场是 知识存在(请参阅“集中化是盲目的和不适当的”),将永远不会有一个通用模型可以强制使用图灵完备的计算机语言对所有可能的不变量进行100%的覆盖。为了存在知识,存在着许多意想不到的可能性,即无序和熵必须一直在增加。这是熵力。证明潜在扩展的所有可能计算,就是先验计算所有可能的扩展。

这就是存在暂停定理的原因,即无法确定图灵完备的编程语言中的每个可能的程序是否终止。可以证明某些特定程序终止(已经定义并计算了所有可能性)。但是不可能证明该程序的所有可能扩展都终止了,除非该程序的扩展可能性不是图灵完成的(例如通过依赖类型)。由于图灵完备性的基本要求是无限制的递归,因此直观地了解Gödel的不完备性定理和Russell的悖论如何适用于扩展。

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

  • 哥德尔不完备定理:任何可以证明所有算术真理的形式理论都是不一致的。
  • 罗素悖论:集合中的每个成员资格规则都可以包含一个集合,它枚举每个成员的特定类型或包含自身。因此,集要么不能扩展,要么是无限制的递归。例如,不是茶壶的所有事物的集合都包括自己,包括茶壶,包括茶壶,等等……。因此,如果规则(可能包含一个集合并且)不枚举特定类型(即,允许所有未指定的类型)并且不允许无限扩展,则它是不一致的。这是一组不属于其自身的集合。哥德尔的不完备性定理表明,这种无法兼顾所有可能扩展的一致性。
  • Liskov替换原理:通常,任何集合是否是另一个集合的子集都是不确定的问题,即,继承通常是不确定的。
  • Linsky参考:当描述或感知某事物时,它的不确定性是什么,即感知(现实)没有绝对的参考点。
  • 科斯定理:没有外部参考点,因此对无限外部可能性的任何障碍都会失败。
  • 热力学第二定律:整个宇宙(一个封闭的系统,即所有事物)趋向于最大的无序状态,即最大的独立可能性。

17
@Shelyby:您混合了太多东西。事情并没有像您陈述的那样令人困惑。您的许多理论断言都立足于脆弱的基础,例如“对于知识的存在,意料之外的可能性非常存在,............”和“通常来说,任何集合是否是另一个集合的子集,这都是一个不确定的问题,即继承通常是不确定的”。您可以为这些要点中的每一个创建一个单独的博客。无论如何,您的主张和假设都值得怀疑。禁止使用别人不知道的东西!
aknon 2013年

1
@aknon我有一个博客更深入地解释了这些问题。我的无限时空的TOE模型是无限频率。对我来说,递归归纳函数的起始值已知且具有无限的终止边界,或者对共归函数具有未知的终止值和已知的起始边界,这对我来说并不令人困惑。引入递归后,相对性便成为问题。这就是图灵完成等同于无限制递归的原因
谢尔比·摩尔

4
@ShelbyMooreIII您的方向太多了。这不是答案。
Soldalma

1
@Soldalma,这是一个答案。您没有在“答案”部分中看到它。您的评论是因为它在评论部分。
谢尔比·摩尔三世

1
就像您与Scala世界混合!
Ehsan M. Kermani

23

有一个检查表来确定您是否违反了Liskov。

  • 如果您违反以下条件之一->您违反了Liskov。
  • 如果您没有违反任何规则->无法得出任何结论。

检查清单:

  • 派生类中不应抛出新的异常:如果基类抛出ArgumentNullException,则子类只允许抛出ArgumentNullException类型的异常或任何从ArgumentNullException派生的异常。抛出IndexOutOfRangeException违反Liskov。
  • 前提条件不能得到加强:假设您的基类与成员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);
        }
    }
    

有2人项目:方法参数逆变返回类型的协方差。但是在C#中是不可能的(我是C#开发人员),所以我不在乎它们。

参考:


我也是C#开发人员,我将告诉您最后一个声明在使用.Net 4.0框架的Visual Studio 2010中是不正确的。返回类型的协方差允许比接口定义更多的派生返回类型。示例:示例:IEnumerable <T>(T是协变量)IEnumerator <T>(T是协变量)IQueryable <T>(T是协变量)IGrouping <TKey,TElement>(TKey和TElement是协变量)IComparer <T>(T是对立的)IEqualityComparer <T>(T是对立的)IComparable <T>(T是
对立的

1
伟大而集中的答案(尽管最初的问题是关于示例而不是规则)。
Mike Mike

23

我在每个答案中都看到了矩形和正方形,以及如何违反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 !
    }
}

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


6
只要我们限制的语义Database::selectQuery仅支持所有数据库引擎支持的SQL子集,该示例就不会违反LSP 。这几乎是不实际的...也就是说,该示例仍然比此处使用的大多数其他示例更易于理解。
Palec

5
我发现此答案是最容易掌握的答案。
马尔科姆·萨尔瓦多

22

LSP是有关租约合同的规则:如果基类满足合同,则由LSP派生的类也必须满足该合同。

在伪python中

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

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

如果每次您在派生对象上调用Foo时,只要arg相同,它就会得到与在基础对象上调用Foo完全相同的结果。


9
但是……如果您总是得到相同的行为,那么拥有派生类的意义何在?
Leonid

2
您遗漏了一点:观察到的行为是相同的。例如,您可以用功能上等效的O(n)性能代替O(n)性能,但用O(lg n)性能代替某些性能。或者,您可以替换一些访问用MySQL实现的数据的东西,然后将其替换为内存数据库。
查理·马丁

@Charlie Martin,编码为接口而不是实现-我对此进行了解释。这不是OOP独有的。诸如Clojure之类的功能语言也促进了这一点。甚至就Java或C#而言,我认为对于您提供的示例,使用接口而不是使用抽象类加类层次结构也是很自然的。Python不是强类型的,并且实际上没有接口,至少没有明确地。我的困难是我几年来一直在不遵守SOLID的原则下进行OOP。既然我遇到了它,它似乎是有限的并且几乎是自相矛盾的。
Hamish Grubijan

好吧,您需要返回并查看Barbara的原始论文。report-archive.adm.cs.cmu.edu/anon/1999/CMU-CS-99-156.ps并没有真正用接口来表述,它是一种逻辑关系,在任何情况下都可以保持(或不保持)具有某种形式的继承的编程语言。
查理·马丁

1
@HamishGrubijan我不知道谁告诉您Python不是强类型的,但是他们在骗您(如果您不相信我,请启动Python解释器并尝试2 + "2")。也许您将“强类型”与“静态类型”混淆了?
asmeurer 2013年

21

延伸的父类时的故事,总之,让我们离开矩形长方形和正方形广场,实际的例子,你必须要么保留确切父API或把它扩大。

假设您有一个基本 ItemsRepository。

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 class
        return;
    }
}

然后,您可以让一个客户端使用Base ItemsRepository API并依靠它。

/**
 * 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/


20

使用指针或对基类的引用的函数必须能够使用派生类的对象,而无需了解它。

当我第一次阅读有关LSP的文章时,我认为这是非常严格的含义,实质上等同于接口实现和类型安全的转换。这将意味着语言本身无法保证LSP。例如,从严格意义上讲,就编译器而言,ThreeDBoard当然可以替代Board。

在阅读了有关该概念的更多信息之后,我发现LSP的解释通常比这更广泛。

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

再次回到该示例,理论上可以使Board方法在ThreeDBoard上正常工作。但是,实际上,如果不妨碍ThreeDBoard要添加的功能,很难防止客户端可能无法正确处理的行为差异。

掌握了这些知识之后,评估LSP的遵从性可以成为确定组合何时是扩展现有功能(而不是继承)的更合适机制的好工具。


19

我猜每个人都从技术上讲过LSP的含义:您基本上希望能够从子类型细节中抽象出来并安全地使用超类型。

因此Liskov具有3条基本规则:

  1. 签名规则:语法上应该有效地实现子类型中父类型的每个操作。编译器可以为您进行检查。关于抛出更少的异常并且至少与父类型方法具有相同的可访问性有一条小规则。

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

    • 较弱的前提:子类型函数应该至少接受超类型作为输入的内容,如果不能更多。
    • 更强的后置条件:它们应产生超类型方法产生的输出的子集。
  3. 属性规则:这超出了单个函数的调用范围。

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

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

如果这三件事都得到了照顾,那么您已经从基础内容中抽象出来了,并且正在编写松耦合的代码。

资料来源:Java程序开发-Barbara Liskov


18

软件测试中,使用 LSP的一个重要示例。

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

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

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

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

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


13

让我们用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
{
   @Override
   void startEngine() { ... }
}

这里没问题吧?汽车绝对是一种运输工具,在这里我们可以看到它覆盖了其超类的startEngine()方法。

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

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

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

这些都是违反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扩展为电动设备。在这里添加引擎对象更为合适。

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

   void startEngine() { ... }
}

因此,我们的汽车课在遵循Liskov替代原则的同时变得更加专业。

class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

我们的自行车课也符合《里斯科夫换人原则》。

class Bicycle extends DevicesWithoutEngines
{
   @Override
   void startMoving() { ... }
}

9

LSP的这种公式过于强大:

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

这基本上意味着S是与T完全相同的另一种完全封装的实现。我可以大胆地决定性能是P行为的一部分。

因此,基本上,任何后期绑定的使用都会违反LSP。当我们用一种对象代替另一种对象时,获得不同的行为是OO的重点!

维基百科引用的格式更好,因为属性取决于上下文,并且不一定包含程序的整个行为。


2
恩,那是Barbara Liskov自己的说法。Barbara Liskov,“数据抽象和层次结构”,SIGPLAN Notices,23,5(1988年5月)。它不是“过于强大”,而是“完全正确”,并且没有您认为的含义。它很坚固,但是强度恰到好处。
DrPizza

然后,现实生活中的子类型很少:)
Damien Pollet

3
“行为不变”并不意味着子类型将为您提供完全相同的具体结果值。这意味着子类型的行为与基本类型所期望的行为匹配。示例:基本类型Shape可以具有draw()方法,并规定此方法应呈现形状。Shape的两个子类型(例如Square和Circle)都将实现draw()方法,并且结果看起来会有所不同。但是,只要行为(渲染形状)与Shape的指定行为匹配,则根据LSP,Square和Circle将成为Shape的子类型。
2012年


9

里斯科夫的替代原理(LSP)

一直以来,我们都设计一个程序模块,并创建一些类层次结构。然后我们扩展一些类,创建一些派生类。

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

Liskov的“替换原理”指出,如果程序模块使用Base类,则可以用Derived类替换对Base类的引用,而不会影响程序模块的功能。

例:

以下是违反Liskov替代原则的经典示例。在示例中,使用了2个类:矩形和正方形。假设Rectangle对象在应用程序中的某处使用。我们扩展应用程序并添加Square类。根据某些条件,正方形类是通过工厂模式返回的,我们不知道将返回哪种类型的对象。但是我们知道这是一个矩形。我们得到矩形对象,将宽度设置为5,将高度设置为10,并获得面积。对于宽度为5,高度为10的矩形,面积应为50。结果将为100

    // Violation of Likov's Substitution Principle
class 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.
    }
}

结论:

该原则只是“开放关闭原则”的扩展,它意味着我们必须确保新的派生类在不改变其行为的情况下扩展了基类。

另请参阅:开闭原理

一些类似的概念可以改善结构:约定优于配置


8

里斯科夫替代原则

  • 覆盖的方法不应为空
  • 重写的方法不应引发错误
  • 由于派生类的行为,基类或接口的行为不应进行修改(返工)。

7

一些附录:
我想知道为什么没有人写过派生类必须遵循的Invariant,前提条件和后置条件。为了使派生类D完全可以被基本类B取代,类D必须遵守以下条件:

  • 基类的变量必须由派生类保留
  • 派生类不能加强基类的前提条件
  • 派生类不能削弱基类的后置条件。

因此,派生对象必须了解基类施加的以上三个条件。因此,子类型的规则是预先确定的。这意味着,仅当子类型遵守某些规则时,才应遵守“ IS A”关系。这些规则以不变式,前提条件和后置条件的形式,应由正式的“ 设计合同 ” 决定。

有关此问题的进一步讨论,请访问我的博客:Liskov替代原理


6

简单来说LSP指出相同超类的对象应该能够彼此交换而不会破坏任何对象。

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


4

以董事会的形式实施ThreeDBoard会有用吗?

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

在外部接口方面,您可能希望针对TwoDBoard和ThreeDBoard都考虑一个Board接口(尽管以上方法都不适合)。


1
我认为该示例只是为了说明在ThreeDBoard的上下文中从板继承是没有意义的,并且所有方法签名对于Z轴都是无意义的。
NotMyself

4

正方形是宽度等于高度的矩形。如果平方为宽度和高度设置了两个不同的大小,则会违反平方不变式。通过引入副作用可以解决此问题。但是如果矩形的setSize(height,width)的前提条件是0 <height和0 <width。派生子类型方法需要height == width; 一个更强的前提条件(并且违反了lsp)。这表明尽管正方形是一个矩形,但由于前提条件得到了加强,因此它不是有效的子类型。变通方法(通常是一件坏事)会引起副作用,这会削弱后期条件(违反lsp)。底座上的setWidth具有发布条件0 <width。派生的高度==宽度削弱了它。

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


4

该原理由Barbara Liskov于1987年提出,并通过关注父类及其子类型的行为扩展了Open-Closed原理。

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

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
{
} 

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

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

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,因为该函数不适用于其参数的派生类。属性width和height不是多态的,因为它们不是在矩形中声明为虚拟的(方形对象将被破坏,因为高度不会更改)。

但是,通过将setter属性声明为虚拟,我们将面临另一种违反,即OCP。实际上,派生类正方形的创建导致对基类矩形的更改。


3

到目前为止,我对LSP的最清晰的解释是“李斯科夫替换原理说,派生类的对象应该能够替换基类的对象,而不会给系统带来任何错误或修改基类的行为。从这里。本文提供了违反LSP并修复它的代码示例。


1
请提供有关stackoverflow的代码示例。
sebenalern

3

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

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);
    }
} 

如果我们更换RectangleSquare我们的第一个代码,然后将打破:

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

这是因为Square有了我们在Rectangle课程中没有的新先决条件:width == height。根据LSP,Rectangle实例应可替换为Rectangle子类实例。这是因为这些实例通过了实例的类型检查Rectangle,因此将在您的代码中导致意外错误。

这是Wiki文章中“不能在子类型中增强先决条件”部分的示例。综上所述,违反LSP可能会在某些时候导致代码错误。


3

LSP表示“对象应该可以用其子类型替换”。另一方面,该原则指出

子类永远不要破坏父类的类型定义。

以下示例有助于更好地理解LSP。

没有LSP:

public interface CustomerLayout{

    public void render();
}


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


public PremiumCustomer implements CustomerLayout{
    ...
    @Override
    public 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 {
     ...
    @Override
    public void render(){
        //code
    }
}


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

public void renderView(CustomerLayout layout){
    layout.render();
}

2

我鼓励您阅读以下文章:违反Liskov替代原则(LSP)

您可以在其中找到什么是Liskov替代原理的解释,可以帮助您猜测是否已违反该原理的一般线索,以及可以帮助您使类层次结构更安全的方法示例。


2

LISKOV替换原则(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下用另一种实现替换接口的实现,正是这一原则使得即使将来能够满足将来出现的需求,也能做到''今天预见他们。

如果我们从墙壁上拔下计算机的插头(实施),则墙壁插座(接口)或计算机(客户端)都不会发生故障(实际上,如果是便携式计算机,它甚至可以用电池运行一段时间) 。但是,使用软件时,客户通常希望获得服务。如果该服务被删除,我们将收到NullReferenceException。为了处理这种情况,我们可以创建不执行任何操作的接口的实现。这是一种称为Null Object [4]的设计模式,大致相当于将计算机从墙上拔下。因为我们使用的是松散耦合,所以我们可以用什么都不做而不会引起麻烦的东西来代替真正的实现。


2

Likov的“替代原理”指出,如果程序模块使用Base类,则可以用Derived类替换对Base类的引用,而不会影响程序模块的功能。

目的-派生类型必须能够完全替代其基本类型。

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


1

这是这篇文章的摘录,很好地阐明了一点:

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

违反这一原则意味着什么?这意味着一个对象不满足由接口表示的抽象所施加的约束。换句话说,这意味着您错误地识别了抽象。

考虑以下示例:

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);
}

Voilà,现在合同已履行。

这种细微的冲突常常使客户有能力分辨所采用的具体对象之间的差异。例如,给定第一个帐户的合同,它可能如下所示:

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

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

这一点也解决了我经常遇到的违反LSP的误解。它说:“如果父母的行为在孩子中改变了,那就违反了LSP。” 但是,只要孩子没有违反父母的合同,它就不会这样做。

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.