Liskov替换原则的一个例子是什么?

1222

我听说里氏替换原则(Liskov Substitution Principle,简称LSP)是面向对象设计的一个基本原则。那么它是什么,有哪些使用示例呢?


这里有更多关于LSP遵循和违反的例子:链接 - StuartLC
这是我找到的最好的例子之一:https://www.baeldung.com/java-liskov-substitution-principle - 01000001
Liskov替换原则指出,子类应该可以盲目地替换它们的基类。 - Ziaullhaq Savanur
Liskov替换原则指出,子类应该可以盲目地替换它们的基类。 - Ziaullhaq Savanur
Liskov替换原则指出,子类应该可以盲目地替换它们的基类。 - Ziaullhaq Savanur
35个回答

1223
一个很好的例子说明了LSP(我最近在听到的一篇播客中由Uncle Bob提供):有时候自然语言中听起来正确的东西在代码中并不完全适用。
在数学中,一个“正方形”是一个“矩形”。确实它是矩形的一个特殊情况。这里的“是一个”让您想要使用继承来建模。但是,如果在代码中您让“正方形”派生自“矩形”,那么当您期望一个“矩形”时,一个“正方形”应该是可用的。这会导致一些奇怪的行为。
想象一下,您在您的“矩形”基类上有“设置宽度”和“设置高度”的方法;这似乎是完全合理的。但是,如果您的“矩形”引用指向一个“正方形”,那么“设置宽度”和“设置高度”就没有意义,因为设置其中一个会更改另一个以匹配它。在这种情况下,“正方形”未通过Liskov替换测试与“矩形”相比,而让“正方形”从“矩形”继承的抽象是错误的。

enter image description here

大家应该查看其他无价的用激励海报解释的SOLID原则


32
如果这是一个不可变的矩形,那么我们可以使用 GetWidth 和 GetHeight 方法来代替 SetWidth 和 SetHeight 方法吗?请给出你的看法。 - Pacerier
192
故事寓意:在设计类时,应基于行为而非属性进行建模;在设计数据时,应基于属性而非行为进行建模。如果某物表现出像鸭子一样的行为,那它肯定是一只鸟。 - Sklivvz
280
在现实世界中,正方形显然是一种矩形。我们能否在我们的代码中对此建模取决于规范。LSP表明,子类型的行为应与基类型的行为相匹配,如基类型规范所定义。如果矩形基本类型规范指定高度和宽度可以单独设置,则LSP表明正方形不能是矩形的子类型。如果矩形规范指定了矩形是不可变的,则正方形可以是矩形的子类型。一切都关乎子类型保持基类型指定的行为。 - SteveT
91
@Pacerier,如果它是不可变的话就没有问题。这里真正的问题在于我们正在建模的并不是矩形,而是“可重塑的矩形”,也就是说,在创建后其宽度或高度可以修改(且我们仍然认为它是同一个对象)。如果我们从这个角度看矩形类,很明显正方形不是“可重塑的矩形”,因为一般情况下正方形不能被重塑后仍然是正方形。在数学上,我们并不认为有问题,因为在数学上,可变性甚至没有意义。 - asmeurer
28
关于原则,我有一个问题。如果 Square.setWidth(int width) 这个方法被实现为 this.width = width; this.height = width;,会出现什么问题?在这种情况下,宽度等于高度是被保证的。 - MC Emperor
显示剩余38条评论

558
Liskov替换原则(LSP,)是面向对象编程中的一个概念,它声明:
使用指向基类的指针或引用的函数必须能够在不知道的情况下使用派生类对象。
LSP的核心是接口和合同,以及如何决定何时扩展类而不是使用其他策略(例如组合)来实现目标。
我见过的最有效的阐述这一点的方法是在Head First OOA&D中。他们提出了一个场景,你是一个项目的开发人员,要构建一个策略游戏框架。
他们呈现了一个代表看起来像这样的棋盘的类:

Class Diagram

所有的方法都需要X和Y坐标作为参数,以在二维数组中定位瓦片位置。这将允许游戏开发人员在游戏过程中管理棋盘上的单位。
接着,书中改变了要求,要求游戏框架还必须支持3D游戏板以适应具有飞行功能的游戏。因此,引入了一个扩展Board的ThreeDBoard类。
乍一看,这似乎是一个不错的决定。Board提供了Height和Width属性,而ThreeDBoard则提供了Z轴。
问题在于,当您查看从Board继承的所有其他成员时,情况就会崩溃。 Board类的AddUnit、GetTile、GetUnits等方法都需要在X和Y参数中传递,但ThreeDBoard还需要一个Z参数。
所以你必须使用Z参数重新实现这些方法。Z参数对于Board类没有上下文,从Board类继承的方法失去了它们的意义。试图将ThreeDBoard类用作其基类Board的代码单元将非常不幸。 也许我们应该找到另一种方法。三维棋盘ThreeDBoard应该由Board对象组成。每个Z轴单元一个Board对象。 这使我们可以使用良好的面向对象原则,如封装和重用,并且不违反LSP。

13
请参见维基百科上的“圆椭圆问题”(Circle-Ellipse Problem),这是一个类似但更简单的例子。 - Brian
1
如果我们向子类添加另一种方法,但是父类的所有功能在子类中仍然有意义,那么这是否会违反LSP原则?因为一方面,我们稍微修改了使用Child的接口,但另一方面,如果我们将Child向上转换为Parent,则期望一个Parent的代码仍能正常工作。 - Nickolay Kondratyev
7
这是一个反Liskov的例子。 Liskov让我们从正方形派生矩形,让参数更多的类从参数较少的类中派生。你已经很好地展示了这是不好的。真的很有趣,将一个反Liskov的答案标记为回答Liskov问题并获得200个赞。 Liskov原则真的是谬论吗? - Gangnus
@Contango,你能否举个例子说明继承是好的吗?就像这里答案中引用的那样。 - Gangnus
4
我曾看到继承被错误地使用了。以下是一个例子。基类应该是3DBoard,派生类为Board。但是Board仍然具有最大(Z) = 最小(Z) = 1的Z轴。 - Paulustrious
显示剩余6条评论

472

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

让我们用Java来举个简单的例子:

不好的例子

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

鸭子之所以能飞,是因为它是一种鸟类。但对于以下问题:

public class Ostrich extends Bird{}

鸵鸟是一种鸟类,但它不能飞行。鸵鸟类是鸟类的子类型,但它不应该能够使用飞行方法,这意味着我们违反了LSP原则。

好的例子

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

8
不错的例子,但如果客户端有 Bird bird,你该怎么办?你需要将对象转换为 FlyingBirds 才能使用 fly,这不是很好,对吧? - Moody
45
不行。如果客户端有“Bird bird”,那意味着它不能使用“fly()”方法。仅此而已。传递一个“Duck”并不能改变这个事实。如果客户端有“FlyingBirds bird”,即使传递一个“Duck”,它也应该始终以同样的方式工作。 - Steve Chamaillard
48
这个例子也不是一个很好的接口隔离的例子吗? - Saharsh
6
优秀的例子 谢谢,伙计 - Abdelhadi Abdo
22
使用接口“Flyable”(想不到更好的名称)如何?这样,我们就不会将自己固定于这种严格的层次结构中,除非我们确实需要它。 - Thirdy
显示剩余5条评论

157

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的工作方式,一个矩形应该满足以下不变量:
void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

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


42
因此,在使用“OO”来建模我们实际想要建模的任何内容时,存在困难。 - DrPizza
10
@DrPizza: 当然可以。不过,需要注意两点。首先,这种关系仍然可以通过面向对象编程来建模,尽管可能无法完全地表达,或者需要使用更复杂的方法(根据你的问题选择其中一个即可)。其次,并没有更好的替代方案。其他映射/建模方法也存在同样或类似的问题。;-) - Konrad Rudolph
8
在某些情况下(但不包括上述情况),你可以简单地反转继承链 - 从逻辑上讲,一个二维点是一个三维点,其中第三个维度被忽略(或为0 - 在三维空间中所有点都位于同一平面上)。但这当然并不实用。一般来说,这是继承并不能真正帮助的案例之一,并且实体之间不存在自然关系。将它们分别建模(至少我不知道更好的方法)。 - Konrad Rudolph
9
面向对象编程旨在建模行为而不是数据。你的类甚至在违反里氏替换原则之前就已经破坏了封装性。 - Sklivvz
4
是的;我在这个领域工作的时间越长,我越倾向于只对接口和抽象基类使用继承,而对其余部分使用组合。虽然这有时会更加繁琐(从输入方面来看),但它避免了许多问题,并且是其他经验丰富的程序员广泛传达的建议。 - Konrad Rudolph
显示剩余31条评论

83

罗伯特·马丁在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的明显违规。该函数不适用于其参数的派生类。


17
虽然有些晚了,但我认为这篇论文中的这句话很有趣:“现在Meyer规定导数的前置条件和后置条件是:......在重新定义一个例程[在导数中]时,你只能用一个更弱的前置条件替换它的前置条件,而用一个更强的后置条件替换它的后置条件。”如果一个子类的前置条件比父类的前置条件更强,则不能将子类替换为父类而不违反前置条件。因此需要遵循LSP原则。 - user2023861
我过去实现过图表编辑器。强大的实现可能是 setHeight(x) { this.height = x; this.width = x }setWidth() 等类似方法。它对于水平和垂直调整控件可以很好地工作,但对于角落调整控件需要一些解决方法。 - x'ES
实际上,从绘图应用的角度来看,矩形并不是具有“宽度”和“高度”的东西。相反,它是具有“(x1,y1)”和“(x2,y2)”的东西。甚至“线”和“圆”也可以表示为框线形状。此外,对于绘图应用程序而言,了解如何定义锚点“(x,y)”非常重要,这可能因不同类型的形状而有所不同,并且可以进行透明计算。 - x'ES

44

有一个清单可以确定您是否违反了Liskov原则。

  • 如果您违反以下任何一项 -> 您会违反Liskov原则。
  • 如果您没有违反任何一项 -> 不能得出结论。

检查清单:

  • 派生类中不应抛出新异常:如果您的基类抛出ArgumentNullException,则您的子类只允许抛出类型为ArgumentNullException或任何从ArgumentNullException派生的异常。抛出IndexOutOfRangeException会违反Liskov原则。

  • 不得加强前置条件:假设您的基类使用一个成员变量int。现在您的子类型要求该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#开发人员),所以我不关心它们。


我也是一名C#开发人员,我要告诉你的最后一句话在Visual Studio 2010和.Net 4.0框架中不再正确。返回类型的协变性允许使用比接口定义更派生的返回类型。例如:IEnumerable<T>(T是协变的) IEnumerator<T>(T是协变的) IQueryable<T>(T是协变的) IGrouping<TKey,TElement>(TKey和TElement是协变的) IComparer<T>(T是逆变的) IEqualityComparer<T>(T是逆变的) IComparable<T>(T是逆变的)https://msdn.microsoft.com/en-us/library/dd233059(v=vs.100).aspx - LCarter

44

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

现在,由于亚型不再产生相同的结果,因此不能以相同的方式使用。


10
只要我们限制 Database::selectQuery 的语义仅支持所有数据库引擎都支持的 SQL 子集,这个例子就不会违反 LSP。但这几乎是不实际的... 尽管如此,这个例子比这里使用的大多数其他例子更容易理解。 - Palec
6
在众多答案中,我认为这个回答最容易理解。 - Malcolm Salvador
1
将LSP应用于数据库是否切实可行?我发现大多数,如果不是全部,数据库操作都需要进行封装,并且容易出现错误。好处是API保持相同,无论是SQL还是NoSQL。 - Sean W

41

LSP是必要的,当一些代码认为它在调用类型T的方法时,可能无意中调用类型S的方法,其中S extends T(即S继承、派生或是T的子类型)。

例如,在将类型T的输入参数传递给一个函数时(即调用该函数),使用的实参值为类型S。或者,将类型S的值分配给类型T的标识符。

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

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

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

即使具有不可变字段的类型仍然具有不变量,例如,不可变的矩形设置器期望独立修改尺寸,但不可变的正方形设置器违反了这一期望。
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的每个方法必须具有逆变输入参数和协变输出。

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

协变意味着方差与继承方向相同,即子类型S的每个方法的输出类型So必须与超类型T的相应方法的相应输出类型To相同或是其子类型

这是因为如果调用者认为它具有类型T,认为正在调用T的方法,则它提供类型为Ti的参数并将输出分配给类型To。当实际上调用相应的S方法时,那么每个Ti输入参数都被分配到Si输入参数,并且So输出被分配给类型To。因此,如果Si不是相对于Ti逆变的,那么一个子类型Xi——它不是Si的子类型——可以分配给Ti。

此外,对于具有类型多态参数(即泛型)的定义位置变异注释的语言(例如Scala或Ceylon),必须将类型T的每个类型参数的变异注释的协变或逆变方向分别与具有类型为类型参数的每个输入参数或输出(T的每个方法)相反或相同方向。

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


子类型适用于可以枚举不变量的情况。

如何建模不变量以便编译器可以强制执行,目前仍有许多正在进行的研究。

类型状态(见第3页)声明并强制实施与类型正交的状态不变量。或者,可以通过将断言转换为类型来强制实施不变量。例如,要断言在关闭文件之前文件已打开,则File.open()可能会返回一个OpenFile类型,其中包含一个在File中不可用的close()方法。 井字游戏API 可以是使用类型强制执行编译时不变量的另一个示例。类型系统甚至可以是图灵完备的,例如 Scala。依赖类型语言和定理证明器规范了高阶类型的模型。

因为需要在抽象扩展上进行语义,所以我认为采用类型来建模不变量,即统一的高阶表意语义,优于Typestate。 "扩展" 意味着不协调的模块化开发的无限排列组合。 因为对我来说,这似乎是统一的反面,因此自由度非常大,有两个相互依赖的模型(例如类型和Typestate)用于表示共享语义,不能相互统一以实现可扩展的组成。例如,在子类型,函数重载和参数化键入领域中,类似Expression Problem的扩展已经统一了。
我的理论立场是,为了使知识存在(请参见“中心化是盲目和不适当”的部分),将永远不会有一个普遍的模型,可以强制执行所有可能的不变量在Turing完全计算机语言中覆盖100%。为了使知识存在,必须存在意外的可能性,即熵和混乱必须始终增加。 这是热力学力。 证明潜在扩展的所有可能计算就是先验计算所有可能的扩展。
这就是停机问题存在的原因,即无法确定图灵完备编程语言中的每个可能程序是否终止。可以证明某些特定程序会终止(其中所有可能性都已定义和计算),但除非该程序的扩展不是图灵完备的(例如通过依赖类型),否则不可能证明该程序的所有可能扩展都终止。由于图灵完备性的基本要求是无限递归,因此可以直观地理解哥德尔不完备定理和罗素悖论如何应用于扩展。
这些定理的一种解释将它们纳入熵力的广义概念理解中:
  • 哥德尔不完备定理:任何一个形式理论,其中可以证明所有算术真理的理论都是不一致的。
  • 罗素悖论:对于可以包含集合的集合的每个成员规则,要么枚举每个成员的特定类型,要么包含自身。因此,集合要么无法扩展,要么是无限递归的。例如,所有不是茶壶的东西组成的集合包括它自己,这又包括它自己,依此类推......因此,如果一个规则(可能包含集合并且)不枚举特定类型(即允许所有未指定类型),也不允许无限扩展,则该规则是不一致的。这就是那些不是它们自己成员的集合。无法同时保持一致和完全枚举所有可能的扩展,这就是哥德尔不完备定理。
  • Liskov替换原则:通常情况下,确定一个集合是否为另一个集合的子集是一个不可判定的问题,即继承通常是不可判定的。
  • Linsky引用:当描述或感知某物时,计算它是什么是不可判定的,即感知(现实)没有绝对的参照点。
  • 科斯定理:没有外部参考点,因此任何阻碍无限外部可能性的障碍都将失败。
  • 热力学第二定律:整个宇宙(一个封闭系统,即所有东西)趋向于最大的混乱,即最大的独立可能性。

20
您混淆了很多事情。事实并不像您所陈述的那样令人困惑。您的许多理论断言基础薄弱,比如“为了存在知识,必须存在意料之外的可能性...”和“通常确定任何集合是否是另一个集合的子集是一个未决问题,即继承通常是未决的”。您可以为每个观点开设单独的博客。总之,您的断言和假设是高度值得质疑的。一个人不应该使用自己不了解的东西! - aknon
1
@aknon 我有一个博客,更深入地解释了这些问题。我的TOE无限时空模型是无界频率。对我来说,递归归纳函数具有已知的起始值和无限的结束边界,或者共归函数具有未知的结束值和已知的起始边界并不令人困惑。相对论是引入递归后的问题。这就是为什么Turing完备等同于无限递归 - Shelby Moore III
6
@ShelbyMooreIII,你的回答涉及太多方向了,这不是一个答案。 - Soldalma
1
@Soldalma 这是一个答案。你没有在答案部分看到它吗?你的是评论,因为它在评论部分。 - Shelby Moore III
@aknon 关于你所谓“不严谨”的指控,自从2014年回复你以来,我已经写了博客撰写了文章 - Shelby Moore III
1
喜欢你与Scala世界的交融! - Ehsan M. Kermani

36
简言之,让我们保持矩形是矩形,正方形是正方形。在扩展父类时,你必须要么保留完全相同的父级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;
    }
}

接下来,您可以使用客户端与基础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(Liskov Substitution Principle)就会被破坏。

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/


这是一个更好的例子。谢谢! - Kurt Campher

29

让我们用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()方法。

违反里式替换原则会导致这些问题,最常见的是一个什么也不做甚至无法实现的方法。

解决这些问题的方法是正确的继承层次结构,在我们的例子中,我们可以通过区分有引擎和无引擎的交通工具类来解决这个问题。尽管自行车是一种交通工具,但它没有引擎。在这个例子中,我们对交通工具的定义是错误的,它不应该有引擎。

我们可以将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() { ... }
}
因此,我们的Car类变得更加专业化,同时遵循Liskov替换原则。
class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

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

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

你的例子更多关于接口隔离原则。 - Eugene Maysyuk

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