关于OOP:什么是Liskov替换原理的一个例子?

What is an example of the Liskov Substitution Principle?

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


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

在数学中,SquareRectangle。实际上,它是一个矩形的特化。"is a"使您希望用继承来建立这个模型。但是,如果在代码中,您使Square派生自Rectangle,那么Square应该可以在您期望Rectangle的任何地方使用。这会导致一些奇怪的行为。

假设你在你的Rectangle基类上有SetWidthSetHeight方法,这看起来完全合乎逻辑。但是,如果您的Rectangle引用指向Square,那么SetWidthSetHeight就没有意义,因为设置一个会改变另一个来匹配它。在这种情况下,Square未能通过用Rectangle进行的liskov替换试验,而从Rectangle继承Square的抽象是一个糟糕的抽象。

你们都应该看看其他无价的坚实原则激励海报。


Liskov替换原则(lsp,lsp)是面向对象编程中的一个概念,它说明:

Functions that use pointers or
references to base classes must be
able to use objects of derived classes
without knowing it.

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

我看到的最有效的说明这一点的方法是在Head First OOA&D中。它们提供了一个场景,其中您是一个项目的开发人员,以构建战略游戏的框架。

它们提供了一个类,该类表示一个如下所示的板:

Class Diagram

所有的方法都以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涉及不变量。

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

1
2
3
4
5
6
7
8
class Rectangle {
    int getHeight()
    void setHeight(int value)
    int getWidth()
    void setWidth(int value)
}

class Square : Rectangle { }

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

1
2
3
4
5
void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

然而,正确实现Square必须违背这个不变量,因此它不是Rectangle的有效替代。


Substitutability is a principle in object-oriented programming stating that, in a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S

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

坏榜样

1
2
3
4
public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

鸭子会飞,因为它是一只鸟,但是这怎么办呢:

1
public class Ostrich extends Bird{}

鸵鸟是一种鸟类,但它不能飞,鸵鸟类是一种亚类鸟类,但它不能使用飞行方法,这意味着我们正在打破LSP原理。

好例子

1
2
3
4
5
6
7
public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}


罗伯特马丁有一篇关于里斯科夫替代原理的优秀论文。它讨论了可能违反原则的微妙而不是如此微妙的方式。

本文的一些相关部分(请注意,第二个示例是高度浓缩的):

A Simple Example of a Violation of LSP

One of the most glaring violations of this principle is the use of C++
Run-Time Type Information (RTTI) to select a function based upon the
type of an object. i.e.:

1
2
3
4
5
6
7
void DrawShape(const Shape& s)
{
  if (typeid(s) == typeid(Square))
    DrawSquare(static_cast<Square&amp;>(s));
  else if (typeid(s) == typeid(Circle))
    DrawCircle(static_cast<Circle&amp;>(s));
}

Clearly the DrawShape function is badly formed. It must know about
every possible derivative of the Shape class, and it must be changed
whenever new derivatives of Shape are created. Indeed, many view the structure of this function as anathema to Object Oriented Design.

Square and Rectangle, a More Subtle Violation.

However, there are other, far more subtle, ways of violating the LSP.
Consider an application which uses the Rectangle class as described
below:

1
2
3
4
5
6
7
8
9
10
11
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;
};

[...] Imagine that one day the users demand the ability to manipulate
squares in addition to rectangles. [...]

Clearly, a square is a rectangle for all normal intents and purposes.
Since the ISA relationship holds, it is logical to model the Square
class as being derived from Rectangle. [...]

Square will inherit the SetWidth and SetHeight functions. These
functions are utterly inappropriate for a Square, since the width and
height of a square are identical. This should be a significant clue
that there is a problem with the design. However, there is a way to
sidestep the problem. We could override SetWidth and SetHeight [...]

But consider the following function:

1
2
3
4
void f(Rectangle&amp; r)
{
  r.SetWidth(32); // calls Rectangle::SetWidth
}

If we pass a reference to a Square object into this function, the
Square object will be corrupted because the height won’t be changed.
This is a clear violation of LSP. The function does not work for
derivatives of its arguments.

[...]


当某些代码认为它在调用T类型的方法时,LSP是必需的,并且可能在不知情的情况下调用S类型的方法,其中S extends T类型(即S继承、派生或是父类型T的子类型)。好的。

例如,当使用S类型的参数值调用(即调用)输入参数为T类型的函数时,就会发生这种情况。或者,如果为T类型的标识符分配了S类型的值。好的。

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

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

1
2
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

即使具有不可变字段的类型仍然具有不变量,例如,不可变矩形设置器希望单独修改维度,但不可变方形设置器违反了此期望。好的。

1
2
3
4
5
6
7
8
9
10
11
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相反的w.r.t.,那么可以将Xi亚型(不是Si的亚型)分配给Ti。好的。

此外,对于在类型多态性参数(如泛型)上具有定义站点方差注释的语言(如scala或ceylon),T类型的每个类型参数的方差注释的同向或逆向必须分别与每个输入参数或输出(每个输入参数或输出)的方向相反或相同。具有类型参数类型的T的thod。好的。

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

在可以枚举不变量的地方,子类型是合适的。好的。

关于如何对不变量建模,目前有很多研究正在进行中,以便由编译器强制执行这些不变量。好的。

type state(参见第3页)声明并强制执行与类型正交的状态不变量。或者,可以通过将断言转换为类型来强制实施不变量。例如,若要在关闭文件之前断言该文件已打开,则file.open()可以返回open file类型,该类型包含文件中不可用的close()方法。tic-tac-toe API可以是另一个在编译时使用类型来强制不变量的例子。类型系统甚至可能是图灵完备的,例如scala。依赖类型语言和定理证明者将高阶类型的模型形式化。好的。

由于需要对语义进行抽象而不是扩展,我希望使用类型来建模不变量,即统一的高阶表示语义,比类型状态更好。"扩展"是指不协调的模块化开发的无限排列组合。因为在我看来,有两个相互依赖的模型(例如类型和类型状态)来表达共享的语义,这两个模型在可扩展的组合中是不可能统一的,这似乎是统一的对立面,因而也是自由度的对立面。例如,表达式问题(如扩展)在子类型、函数重载和参数类型域中是统一的。好的。

我的理论立场是,对于知识的存在(见"集中是盲目的和不适合的"),永远不会有一个通用的模型能够在图灵完整的计算机语言中强制100%地覆盖所有可能的不变量。为了知识的存在,许多意想不到的可能性都存在,即无序和熵必须不断增加。这是熵力。证明一个潜在扩展的所有可能的计算,就是计算一个先验的所有可能的扩展。好的。

这就是中止定理存在的原因,也就是说,在一个图灵完整编程语言中,是否每个可能的程序都终止是不确定的。可以证明,某些特定的程序终止(所有可能性都已定义和计算出来的程序)。但不可能证明该程序的所有可能扩展都终止,除非该程序的扩展可能不是图灵完成的(例如通过依赖类型)。既然图灵完整性的基本要求是无限递归,那么如何理解g是直观的?del不完全性定理和Russell悖论适用于推广。好的。

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

  • G?del的不完全性定理:任何形式的理论,其中所有的算术真理都可以被证明,是不一致的。
  • 罗素悖论:集合的每个成员规则都可以包含一个集合,或者枚举每个成员的特定类型,或者包含它自己。因此,集合要么不能扩展,要么是无边界递归。例如,一套不是茶壶的东西,包括它自己,包括它自己,包括它自己,等等。因此,如果规则(可能包含集合和)不枚举特定类型(即允许所有未指定的类型),并且不允许无边界扩展,则该规则是不一致的。这是一组不是自己成员的集合。这种不能在所有可能的扩展中都是一致的和完全枚举的,是吗?德尔不完全性定理。
  • Liskov子状态原则:一般来说,一个集合是否是另一个集合的子集是一个不可决定的问题,即继承通常是不可决定的。
  • 林斯基参照:当某事物被描述或感知时,即感知(现实)没有绝对参照点,它的计算是不确定的。
  • 科斯定理:没有外部参照点,因此任何对无限外部可能性的障碍都将失败。
  • 热力学第二定律:整个宇宙(一个封闭的系统,即一切)趋向于最大的无序,即最大的独立可能性。

好啊。


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

在伪python中

1
2
3
4
5
6
7
class Base:
   def Foo(self, arg):
       # *... do stuff*

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

满足lsp如果每次对派生对象调用foo时,它给出的结果与对基对象调用foo的结果完全相同,只要arg相同。


Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

当我第一次读到LSP时,我假设这是一个非常严格的意义,本质上等同于接口实现和类型安全转换。这意味着LSP要么是由语言本身保证的,要么不是由语言本身保证的。例如,在这个严格意义上,就编译器而言,线程板当然可以替代电路板。

在阅读了更多关于这个概念的文章之后,我发现LSP的解释比这更广泛。

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

再次回到这个例子,理论上,可以使板方法在三向板上工作得很好。然而,在实践中,要防止客户无法正确处理行为上的差异是非常困难的,而不影响ThreedBoard要添加的功能。

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


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

  • 如果你违反了下列条款之一->你违反了里斯科夫。
  • 如果你没有违反任何->就不能得出结论。

检查表:

  • 派生类中不应引发新的异常:如果基类引发ArgumentNullException,则只允许子类引发ArgumentNullException类型的异常或从ArgumentNullException派生的任何异常。引发indexoutofrangeexception违反了liskov。
  • 前提条件不能得到加强:假设您的基类使用一个成员int。现在您的子类型要求该int为正。这是增强的前提条件,现在任何以前使用负整数的代码都会被破坏。
  • 不能减弱post条件:假定您的基类需要在返回方法之前关闭所有到数据库的连接。在您的子类中,您超越了该方法,并使连接保持开放状态以供进一步重用。你已经削弱了那个方法的后置条件。
  • 不变量必须被保留:要实现的最困难和最痛苦的约束。不变量是隐藏在基类中的一段时间,揭示它们的唯一方法是读取基类的代码。基本上,您必须确保当您重写一个方法时,任何不可更改的内容在被重写的方法执行后都必须保持不变。我能想到的最好的事情是在基类中强制执行这个不变的约束,但这并不容易。
  • 历史约束:重写方法时,不允许修改基类中不可修改的属性。看看这些代码,您可以看到名称被定义为不可修改(私有集),但是子类型引入了新的方法,允许修改它(通过反射):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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开发人员),所以我不在乎它们。

参考文献:

  • http://www.ckode.dk/programming/solid-principles-part-3-liskovs-substitution-principle/
  • https://softwarengineering.stackexchange.com/questions/187613/how-do-enhanding-of-pre-conditions-and-weaking-of-post-conditions-violat
  • https://softwarengineering.stackexchange.com/questions/170189/how-to-verify-the-liskov-substitution-principle-in-an-inheritance-hierarchy


奇怪的是,没有人张贴过描述LSP的原稿。读起来不像罗伯特·马丁的书那么容易,但值得一读。


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

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

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

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

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

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


我想每个人都在技术上涵盖了LSP:您基本上希望能够从子类型细节中抽象出来并安全地使用父类型。

所以Liskov有三条基本规则:

  • 签名规则:子类型中父类型的每个操作在语法上都应该有一个有效的实现。编译器可以为您检查的内容。有一个小规则可以减少抛出异常的次数,并且至少与父类型方法一样可访问。

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

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

    • 不变量:总是正确的事物必须保持正确。一套的大小从不为负数。
    • 进化属性:通常与不可变性或对象所处的状态有关。或者,对象只会增长而不会收缩,因此子类型方法不应该这样做。
  • 所有这些属性都需要保留,额外的子类型功能不应该违反父类型属性。

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

    来源:Java中的程序开发——Barbara Liskov


    长话短说,让我们留下矩形、矩形和方形,这是扩展父类时的一个实际示例,您必须保留精确的父API或对其进行扩展。

    假设你有一个基本项目报告。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class ItemsRepository
    {
        /**
        * @return int Returns number of deleted rows
        */
        public function delete()
        {
            // perform a delete query
            $numberOfDeletedRows = 10;

            return $numberOfDeletedRows;
        }
    }

    以及一个扩展它的子类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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并依赖它。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
     * 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();
        }
    }

    当用子类替换父类时,LSP被破坏,从而破坏了API的约定。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    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的配方太强了:

    If for each object o1 of type S there is an object o2 of type T such that for all programs P de?ned in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.

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

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

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


    一些附录:
    我想知道为什么没有人写关于派生类必须遵守的基类的不变量、前置条件和后置条件的文章。为了使派生类D完全可以被基类B所支持,类D必须遵守某些条件:

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

    因此派生类必须知道基类所施加的上述三个条件。因此,子类型的规则是预先决定的。也就是说,只有当子类型遵守某些规则时,才应遵守"是"关系。这些规则,以不变量、预条件和后置条件的形式,应该通过正式的"设计合同"来决定。

    关于这一点的进一步讨论可在我的博客上找到:李斯科夫替代原则


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

    我想展示一下LSP是如何与现实世界中的一个例子相一致的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <?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,执行一个简单的更改,比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <?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 !
        }
    }

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


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

    子类不能违反其基类特性。它必须能用它。我们可以说它和子类型相同。


    Liskov's Substitution Principle(LSP)

    All the time we design a program module and we create some class
    hierarchies. Then we extend some classes creating some derived
    classes.

    We must make sure that the new derived classes just extend without
    replacing the functionality of old classes. Otherwise, the new classes
    can produce undesired effects when they are used in existing program
    modules.

    Liskov's Substitution Principle states that if a program module is
    using a Base class, then the reference to the Base class can be
    replaced with a Derived class without affecting the functionality of
    the program module.

    例子:

    下面是一个典型的例子,它违背了里斯科夫的替代原则。在这个例子中,使用了两个类:矩形和正方形。假设矩形对象在应用程序的某个地方使用。我们扩展了应用程序并添加了Square类。Square类是由工厂模式返回的,基于某些条件,我们不知道将返回什么类型的对象。但我们知道它是一个矩形。我们得到矩形对象,将宽度设置为5,高度设置为10,然后得到面积。对于宽度为5、高度为10的矩形,面积应为50。相反,结果将是100

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
        // 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.
        }
    }

    Conclusion:

    This principle is just an extension of the Open Close Principle and it
    means that we must make sure that new derived classes are extending
    the base classes without changing their behavior.

    另见:开闭原理

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


    按照一组板的形式实现三板会有用吗?

    也许你想把不同平面上的三块木板当作木板。在这种情况下,您可能希望为Board抽象出一个接口(或抽象类),以允许多个实现。

    在外部接口方面,您可能需要为twodboard和threedboard考虑一个Board接口(尽管上述方法都不适用)。


    正方形是一个宽度等于高度的矩形。如果平方为宽度和高度设置了两个不同的大小,则违反平方不变量。这是通过引入副作用来解决的。但如果矩形有一个设置尺寸(高度、宽度),前提是0<高度和0<宽度。派生的子类型方法需要height==width;一个更强大的前提条件(这违反了LSP)。这表明,尽管正方形是一个矩形,但它不是有效的子类型,因为前提条件得到了加强。周围的工作(通常是一件坏事)会产生副作用,这会削弱后置条件(违反LSP)。底座上的setwidth具有post条件0

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


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

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

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

    1
    2
    3
    4
    5
    6
    class Square extends Rectangle {
        setDimensions(width, height){
            assert(width == height);
            super.setDimensions(width, height);
        }
    }

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

    1
    2
    3
    4
    5
    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可能会在某个时刻导致代码出错。


    让我们用Java来说明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    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()方法。

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

    1
    2
    3
    4
    5
    class Bicycle extends TransportationDevice
    {
       @Override
       void startEngine() /*problem!*/
    }

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

    These are the kinds of problems that violation of Liskov Substitution
    Principle leads to, and they can most usually be recognized by a
    method that does nothing, or even can’t be implemented.

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

    我们可以将TransportionDevice类重构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class TrasportationDevice
    {
       String name;
       String getName() { ... }
       void setName(String n) { ... }

       double speed;
       double getSpeed() { ... }
       void setSpeed(double d) { ... }
    }

    现在我们可以扩展非机动设备的运输设备。

    1
    2
    3
    4
    class DevicesWithoutEngines extends TransportationDevice
    {  
       void startMoving() { ... }
    }

    扩大机动设备运输设备。这里更适合添加引擎对象。

    1
    2
    3
    4
    5
    6
    7
    8
    class DevicesWithEngines extends TransportationDevice
    {  
       Engine engine;
       Engine getEngine() { ... }
       void setEngine(Engine e) { ... }

       void startEngine() { ... }
    }

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

    1
    2
    3
    4
    5
    class Car extends DevicesWithEngines
    {
       @Override
       void startEngine() { ... }
    }

    我们的自行车课也符合里斯科夫替代原则。

    1
    2
    3
    4
    5
    class Bicycle extends DevicesWithoutEngines
    {
       @Override
       void startMoving() { ... }
    }

    我鼓励你阅读这篇文章:违反了李斯科夫替代原则(LSP)。

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


    Liskov替换原则(摘自Mark Seemann的书)指出,我们应该能够在不破坏客户机或实现的情况下,用另一个接口替换一个接口的实现。正是这个原则,我们能够解决未来出现的需求,即使我们今天不能预见到这些需求。

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


    Likov的替换原则指出,如果程序模块正在使用基类,则可以用派生类替换对基类的引用,而不会影响程序模块的功能。

    意向派生类型必须完全可以替换其基类型。

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


    迄今为止,我发现对lsp最清楚的解释是"liskov替换原则认为派生类的对象应该能够替换基类的对象,而不会在系统中带来任何错误或修改基类的行为"。本文给出了违反LSP并对其进行修复的代码示例。


    LSP说"对象应该由其子类型替换"。另一方面,这一原则指出

    Child classes should never break the parent class`s type definitions.

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

    没有LSP:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    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的固定:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    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();
    }

    下面是这篇文章的一个摘录,它很好地澄清了问题:

    为了理解一些原则,重要的是要认识到什么时候违反了原则。这就是我现在要做的。

    违反这一原则意味着什么?它意味着一个对象不满足由一个接口表示的抽象所强加的契约。换句话说,这意味着你把你的抽象识别错了。

    请考虑以下示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    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吗?对。这是因为帐户的合同告诉我们帐户将被撤销,但情况并非总是如此。那么,我该怎么做才能修好它呢?我只是修改了合同:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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);
    }

    喂,现在合同已经履行了。

    这种微妙的违规行为常常使客户能够分辨所使用的具体对象之间的差异。例如,考虑到第一个帐户的合同,它可能如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Client
    {
        public function go(Account $account, Money $money)
        {
            if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
                return;
            }
            $account->withdraw($money);
        }
    }

    而且,这会自动违反开放-关闭原则(即,对于提款要求)。因为你永远不知道如果违反合同的对象没有足够的钱会发生什么。可能它只是不返回任何内容,可能会引发异常。因此,您必须检查它是否是hasEnoughMoney()--它不是接口的一部分。因此,这种强制的具体类相关检查是一种OCP冲突]。

    这一点还解决了我经常遇到的关于LSP违规的一个误解。它说"如果一个父母的行为在一个孩子身上发生了变化,那么它就违反了LSP。"但是,它并没有——只要一个孩子没有违反父母的合同。