我听说利斯科夫替换原则(LSP)是面向对象设计的基本原则。它是什么?它的一些使用例子是什么?


当前回答

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

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

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.: 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)); } 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: 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: void f(Rectangle& 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. [...]

其他回答

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

函数使用指针或 基类的引用必须是 能够使用派生类的对象 在不知不觉中。

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

我所见过的说明这一点的最有效的方法是《Head First OOA&D》。它们呈现的场景是,你是一名致力于为策略游戏构建框架的项目开发者。

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

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

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

乍一看,这似乎是个不错的决定。Board提供了高度和宽度属性,ThreeDBoard提供了Z轴。

当你看到从董事会继承的所有其他成员时,它就失效了。AddUnit, GetTile, GetUnits等方法在Board类中都采用X和Y参数,但ThreeDBoard也需要Z参数。

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

也许我们应该另想办法。ThreeDBoard应该由Board对象组成,而不是扩展Board。Z轴上每单位一个板子对象。

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

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

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

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.: 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)); } 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: 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: void f(Rectangle& 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. [...]

利斯科夫替换原理

被重写的方法不应该保持为空 被重写的方法不应该抛出错误 基类或接口行为不应该因为派生类行为而进行修改(重做)。

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

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

如果我们在第一个代码中将矩形替换为正方形,那么它将会中断:

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

这是因为正方形有一个我们在矩形类中没有的新前提条件:width == height。根据LSP,矩形实例应该被矩形子类实例替代。这是因为这些实例通过了矩形实例的类型检查,因此它们将在代码中导致意外错误。

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

当一些代码认为它正在调用类型T的方法时,LSP是必要的,并且可能在不知情的情况下调用类型S的方法,其中S扩展了T(即S继承、派生于超类型T,或者是超类型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 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的w.r.t.不是逆变的,那么就可以将Si的子类型xi赋给Ti,而它不是Si的子类型。

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

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


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

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

Typestate (see page 3) declares and enforces state invariants orthogonal to type. Alternatively, invariants can be enforced by converting assertions to types. For example, to assert that a file is open before closing it, then File.open() could return an OpenFile type, which contains a close() method that is not available in File. A tic-tac-toe API can be another example of employing typing to enforce invariants at compile-time. The type system may even be Turing-complete, e.g. Scala. Dependently-typed languages and theorem provers formalize the models of higher-order typing.

Because of the need for semantics to abstract over extension, I expect that employing typing to model invariants, i.e. unified higher-order denotational semantics, is superior to the Typestate. ‘Extension’ means the unbounded, permuted composition of uncoordinated, modular development. Because it seems to me to be the antithesis of unification and thus degrees-of-freedom, to have two mutually-dependent models (e.g. types and Typestate) for expressing the shared semantics, which can't be unified with each other for extensible composition. For example, Expression Problem-like extension was unified in the subtyping, function overloading, and parametric typing domains.

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

This is why the Halting Theorem exists, i.e. it is undecidable whether every possible program in a Turing-complete programming language terminates. It can be proven that some specific program terminates (one which all possibilities have been defined and computed). But it is impossible to prove that all possible extension of that program terminates, unless the possibilities for extension of that program is not Turing complete (e.g. via dependent-typing). Since the fundamental requirement for Turing-completeness is unbounded recursion, it is intuitive to understand how Gödel's incompleteness theorems and Russell's paradox apply to extension.

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

Gödel's incompleteness theorems: any formal theory, in which all arithmetic truths can be proved, is inconsistent. Russell's paradox: every membership rule for a set that can contain a set, either enumerates the specific type of each member or contains itself. Thus sets either cannot be extended or they are unbounded recursion. For example, the set of everything that is not a teapot, includes itself, which includes itself, which includes itself, etc…. Thus a rule is inconsistent if it (may contain a set and) does not enumerate the specific types (i.e. allows all unspecified types) and does not allow unbounded extension. This is the set of sets that are not members of themselves. This inability to be both consistent and completely enumerated over all possible extension, is Gödel's incompleteness theorems. Liskov Substition Principle: generally it is an undecidable problem whether any set is the subset of another, i.e. inheritance is generally undecidable. Linsky Referencing: it is undecidable what the computation of something is, when it is described or perceived, i.e. perception (reality) has no absolute point of reference. Coase's theorem: there is no external reference point, thus any barrier to unbounded external possibilities will fail. Second law of thermodynamics: the entire universe (a closed system, i.e. everything) trends to maximum disorder, i.e. maximum independent possibilities.