我听说利斯科夫替换原则(LSP)是面向对象设计的基本原则。它是什么?它的一些使用例子是什么?
当前回答
大局:
利斯科夫替换原理是什么?它是关于什么是(什么不是)给定类型的子类型。 为什么它如此重要?因为子类型和子类之间是有区别的。
例子
与其他答案不同的是,我不会从违反Liskov替换原则(LSP)开始,而是从符合LSP开始。我使用Java,但在每种OOP语言中几乎都是一样的。
圆和彩色圆
几何例子在这里似乎很流行。
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;
}
}
半径不允许为负。这是一个子类:
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对象的程序,如果您将o2替换为o1,则任何使用Circle的程序的行为在替换之后都将保持不变。(注意,这只是理论上的:使用ColoredCircle实例会比使用Circle实例更快地耗尽内存,但这与本文无关。)
我们如何根据o1求出o2 ?我们只是去掉color属性,保留radius属性。我称这个变换为o1 - >o2是CircleColor空间在Circle空间上的投影。
反例
让我们再创建一个例子来说明LSP的违反。
圆形和方形
想象一下前面Circle类的子类:
class Square extends Circle {
private int sideSize;
public Square(int sideSize) {
super(0);
this.sideSize = sideSize;
}
@Override
public 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"
}
}
发生了什么事?直观地说,虽然Square是Circle的一个子类,但Square不是Circle的子类型,因为没有一个常规的Circle实例的半径是-1。
形式上,这违反了利斯科夫替换原则。
我们有一个用Circle定义的程序,在这个程序中没有Circle对象可以替换新的Square(2)(顺便说一下,也没有任何Square实例),并且保持行为不变:记住,任何Circle的半径总是正的。
子类和子类型
现在我们知道为什么子类并不总是子类型。当子类不是子类型时,即存在LSP违反时,某些程序(至少有一个)的行为并不总是预期的行为。这是非常令人沮丧的,通常被解释为一个错误。
在理想的情况下,编译器或解释器将能够检查给定的子类是否是真正的子类型,但我们并不是在理想的情况下。
静态类型
如果存在一些静态类型,则在编译时被父类签名绑定。Square.getRadius()不能返回String或List。
如果没有静态类型,如果一个参数的类型是错误的(除非类型是弱的)或参数的数量不一致(除非语言是非常允许的),您将在运行时得到一个错误。
关于静态类型的注意:有返回类型的协方差(S的方法可以返回T的相同方法的返回类型的子类)和参数类型的逆变性(S的方法可以接受T的相同方法的相同参数的超类)的机制,这是下面解释的先决条件和后置条件的具体情况。
合同设计
有更多的。有些语言(我想到了Eiffel)提供了一种机制来强制执行LSP。
先不说确定初始对象o1的投影o2,如果用o1代替o2 if,对于任何参数x和任何方法f,我们可以期望任何程序都有相同的行为:
如果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)。
(注意,如果函数f是纯函数,则(3)是免费给出的。这就是为什么我们喜欢使用不可变对象。)
这些条件是关于类的语义(期望什么),而不仅仅是类的语法。而且,这些条件非常强。但是它们可以用契约式编程设计中的断言来近似。这些断言是确保支持类型语义的一种方法。破坏契约会导致运行时错误。
前提条件定义了什么是有效的调用。当子类化一个类时,前提条件只能被削弱(S.f接受的比T.f多)(a)。 后置条件定义了什么是有效结果。当子类化一个类时,后置条件只能被加强(S.f比T.f提供更多)(b)。 不变量定义了什么是有效的内部状态。当子类化一个类时,不变量必须保持不变(c)。
我们可以大致看到,(a)保证了(1),(b)保证了(2),但是(c)比(3)弱。此外,断言有时难以表达。
假设一个类Counter有一个唯一的方法Counter. Counter(),该方法返回下一个整数。怎么写后置条件呢?假设一个类Random有一个方法Random.高斯(),该方法返回一个介于0.0和1.0之间的浮点数。如何编写后置条件来检查分布是否为高斯分布?这也许是可能的,但成本太高,我们将依赖于测试而不是后置条件。
结论
不幸的是,子类并不总是子类型。这可能会导致意想不到的行为——bug。
面向对象语言提供了避免这种情况的机制。首先在语法层面。在语义层面上也是如此,这取决于编程语言:可以使用断言在程序文本中编码一部分语义。但是,由您来确保子类是子类型。
还记得你什么时候开始学习OOP吗?“如果关系是is - a,那么使用继承”。另一种方式也是如此:如果使用继承,请确保关系是is - a。
LSP在比断言更高的级别上定义了什么是子类型。断言是确保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.
我在每个答案中都看到了矩形和正方形,以及如何违反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 !
}
}
现在子类型不能以同样的方式使用,因为它们不再产生相同的结果。
以Board数组的形式实现ThreeDBoard会有用吗?
也许你想把不同平面上的ThreeDBoard切片作为一个板。在这种情况下,您可能希望为Board抽象出一个接口(或抽象类),以允许多种实现。
就外部接口而言,您可能希望为TwoDBoard和ThreeDBoard提取一个Board接口(尽管上述方法都不适合)。
它指出,如果C是E的子类型,则E可以替换为C类型的对象,而不会改变或破坏程序的行为。简单地说,派生类应该可以替代它们的父类。例如,如果一个农民的儿子是农民,那么他可以代替他的父亲工作,但如果一个农民的儿子是板球运动员,那么他就不能代替他的父亲工作。
违反的例子:
public class Plane{
public void startEngine(){}
}
public class FighterJet extends Plane{}
public class PaperPlane extends Plane{}
在给定的例子中,fighter和PaperPlane类都扩展了包含startEngine()方法的Plane类。所以很明显,战斗机可以启动引擎,但纸飞机不能,所以它破坏LSP。
PaperPlane类虽然扩展了Plane类,但应该可以替代Plane类,但它不是Plane实例可以被替换的合格实体,因为纸飞机不能启动引擎,因为它没有引擎。好的例子是,
受人尊敬的例子:
public class Plane{
}
public class RealPlane{
public void startEngine(){}
}
public class FighterJet extends RealPlane{}
public class PaperPlane extends Plane{}
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。