我听说 Liskov 替换原则 (LSP) 是面向对象设计的基本原则。它是什么,有哪些使用示例?
35 回答
说明 LSP 的一个很好的例子(鲍勃叔叔在我最近听到的一个播客中给出)是有时在自然语言中听起来正确的东西在代码中并不完全有效。
在数学中,aSquare
是 a Rectangle
。实际上,它是矩形的特化。“is a”让你想用继承来建模。但是,如果在您制作的代码中Square
派生自Rectangle
,那么 aSquare
应该可以在您期望 a 的任何地方使用Rectangle
。这导致了一些奇怪的行为。
想象一下,你的基类上SetWidth
有SetHeight
方法;Rectangle
这似乎完全合乎逻辑。但是,如果您的Rectangle
参考指向 a Square
,那么SetWidth
并且SetHeight
没有意义,因为设置一个会更改另一个以匹配它。在这种情况下Square
,里氏替换测试失败了,继承自Rectangle
的抽象是一个糟糕的抽象。Square
Rectangle
你们都应该看看其他无价的SOLID 原则励志海报。
Liskov 替换原则(LSP,lsp)是面向对象编程中的一个概念,它指出:
使用指向基类的指针或引用的函数必须能够在不知情的情况下使用派生类的对象。
LSP 的核心是关于接口和契约,以及如何决定何时扩展类与使用组合等其他策略来实现目标。
我见过的最有效的说明这一点的方法是Head First OOA&D。他们提出了一个场景,您是一个项目的开发人员,为策略游戏构建框架。
他们提供了一个类,该类代表一个如下所示的板:
所有方法都以 X 和 Y 坐标为参数,在 的二维数组中定位图块位置Tiles
。这将允许游戏开发者在游戏过程中管理棋盘中的单元。
该书继续更改要求,说游戏框架还必须支持 3D 游戏板以适应具有飞行的游戏。因此ThreeDBoard
引入了一个扩展类Board
。
乍一看,这似乎是一个不错的决定。Board
提供Height
和Width
属性并ThreeDBoard
提供 Z 轴。
当您查看所有其他成员继承自Board
. AddUnit
、GetTile
等的方法GetUnits
都采用Board
类中的 X 和 Y 参数,但也ThreeDBoard
需要 Z 参数。
因此,您必须使用 Z 参数再次实现这些方法。Z 参数对类没有上下文,从Board
类继承的方法Board
失去了意义。尝试使用ThreeDBoard
该类作为其基类的代码单元Board
将非常不走运。
也许我们应该找到另一种方法。应该由对象组成Board
,而不是扩展。Z 轴每单位一个对象。ThreeDBoard
Board
Board
这允许我们使用良好的面向对象原则,如封装和重用,并且不违反 LSP。
可替换性是面向对象编程中的一个原则,它指出,在计算机程序中,如果 S 是 T 的子类型,则 T 类型的对象可以被 S 类型的对象替换
让我们用Java做一个简单的例子:
不好的例子
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
鸭子可以飞,因为它是一只鸟,但是这个呢:
public class Ostrich extends Bird{}
Ostrich 是鸟,但它不会飞,Ostrich 类是 Bird 类的子类型,但它应该不能使用 fly 方法,这意味着我们违反了 LSP 原则。
好例子
public class Bird{}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
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 的工作方式, aRectangle
应该满足以下不变量:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
然而,这个不变量(以及显式的后置条件)必须被 的正确实现所违反Square
,因此它不是 的有效替代品Rectangle
。
Robert Martin 有一篇关于 Liskov Substitution Principle的优秀论文。它讨论了可能违反该原则的微妙和不那么微妙的方式。
论文的一些相关部分(请注意,第二个示例非常精简):
违反 LSP 的简单示例
最明显违反此原则的行为之一是使用 C++ 运行时类型信息 (RTTI) 根据对象的类型选择函数。IE:
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
将继承SetWidth
andSetHeight
函数。这些函数完全不适合 aSquare
,因为正方形的宽度和高度是相同的。这应该是设计存在问题的重要线索。但是,有一种方法可以回避这个问题。我们可以覆盖SetWidth
并SetHeight
[...]但请考虑以下功能:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
如果我们将一个
Square
对象的引用传递给这个函数,该Square
对象将被破坏,因为高度不会改变。这明显违反了 LSP。该函数不适用于其参数的导数。[...]
LSP 在某些代码认为它正在调用某个类型的方法时是必要的T
,并且可能会在不知不觉中调用某个类型的方法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
T
LSP 要求类型(例如)方法的期望(即不变量) ,而不是在调用类型(例如)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 设置器期望维度被独立修改,但不可变的Square 设置器违反了这种期望。
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
这是因为如果调用者认为它有一个 type T
,认为它正在调用 的一个方法T
,那么它会提供 type 的参数Ti
并将输出分配给 type To
。当它实际调用 的对应方法时S
,则将每个Ti
输入参数分配给一个Si
输入参数,并将So
输出分配给 type To
。因此,如果Si
与 不逆变Ti
,则Xi
可以将一个子类型(它不是的子类型)Si
分配给Ti
。
此外,对于在类型多态性参数(即泛型)上具有定义站点方差注释的语言(例如 Scala 或 Ceylon),该类型的每个类型参数的方差注释的同向或相反方向T
必须相反或相同方向分别用于T
具有类型参数类型的每个输入参数或输出(的每个方法)。
此外,对于具有函数类型的每个输入参数或输出,所需的方差方向是相反的。此规则以递归方式应用。
子类型适用于可以枚举不变量的情况。
关于如何对不变量进行建模的研究正在进行中,以便它们由编译器强制执行。
Typestate(参见第 3 页)声明并强制执行与类型正交的状态不变量。或者,可以通过将断言转换为类型来强制执行不变量。例如,要断言文件在关闭之前已打开,则 File.open() 可以返回 OpenFile 类型,该类型包含 File 中不可用的 close() 方法。tic-tac-toe API可以是另一个在编译时使用类型来强制执行不变量的示例。类型系统甚至可能是图灵完备的,例如Scala。依赖类型语言和定理证明形式化了高阶类型的模型。
由于语义需要抽象而不是扩展,我希望使用类型来建模不变量,即统一的高阶指称语义,优于 Typestate。“扩展”是指不协调的、模块化的开发的无限的、置换的组合。因为在我看来,统一和自由度的对立面,有两个相互依赖的模型(例如类型和类型状态)来表达共享语义,它们不能相互统一以实现可扩展的组合. 例如,类似表达式问题的扩展在子类型化、函数重载和参数类型化域中被统一起来。
我的理论立场是,为了让知识存在(参见“集中化是盲目的和不合适的”部分),永远不会有一个通用模型可以在图灵完备的计算机语言中强制 100% 覆盖所有可能的不变量。知识要存在,就存在很多意想不到的可能性,即无序和熵必须总是在增加。这就是熵力。证明潜在扩展的所有可能计算,就是先验地计算所有可能的扩展。
这就是Halting Theorem 存在的原因,也就是说,在图灵完备的编程语言中是否每个可能的程序都终止是不可判定的。可以证明某些特定程序终止(已定义和计算了所有可能性的程序)。但是不可能证明该程序的所有可能扩展都终止,除非该程序扩展的可能性不是图灵完备的(例如,通过依赖类型)。由于图灵完备性的基本要求是无限递归,因此可以直观地理解哥德尔不完备性定理和罗素悖论如何应用于扩展。
对这些定理的解释将它们纳入对熵力的广义概念理解中:
- 哥德尔不完备定理:任何可以证明所有算术真理的形式理论都是不一致的。
- 罗素悖论:可以包含集合的集合的每个成员规则,要么枚举每个成员的特定类型,要么包含自身。因此,集合要么不能扩展,要么是无限递归。例如,所有不是茶壶的东西的集合,包括它自己,它包括它自己,它包括它自己,等等……。因此,如果规则(可能包含一个集合并且)不枚举特定类型(即允许所有未指定的类型)并且不允许无界扩展,则该规则是不一致的。这是不属于自身成员的集合。这种无法在所有可能的扩展上保持一致和完全枚举的能力,就是哥德尔的不完备性定理。
- Liskov Substition Principle:一般来说,任何集合是否是另一个集合的子集是一个不可判定的问题,即继承一般是不可判定的。
- 林斯基参考:当事物被描述或感知时,无法确定计算的内容是什么,即感知(现实)没有绝对的参考点。
- 科斯定理:没有外部参考点,因此任何无界外部可能性的障碍都将失效。
- 热力学第二定律:整个宇宙(一个封闭系统,即万物)趋向于最大无序,即最大独立可能性。
我在每个答案中都看到了矩形和正方形,以及如何违反 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 !
}
}
现在不能以相同的方式使用子类型,因为它们不再产生相同的结果。
有一个清单可以确定您是否违反了 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# 开发人员),所以我不关心它们。
长话短说,让我们留下矩形矩形和正方形正方形,扩展父类时的实际示例,您必须保留确切的父 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();
}
}
当用子类替换父类时, LSP会破坏API 的合同。
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/
让我们用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() { ... }
}
而且我们的 Bicycle 类也符合 Liskov 替换原则。
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
LSP 是关于类契约的规则:如果基类满足契约,那么 LSP 的派生类也必须满足该契约。
在伪python中
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
如果每次在 Derived 对象上调用 Foo 时,只要 arg 相同,它就满足 LSP 与在 Base 对象上调用 Foo 完全相同的结果。
我想每个人都在技术上涵盖了 LSP 是什么:您基本上希望能够从子类型细节中抽象出来并安全地使用超类型。
所以 Liskov 有 3 个基本规则:
签名规则:在语法上,子类型中父类型的每个操作都应该有一个有效的实现。编译器将能够为您检查的东西。关于抛出更少的异常并至少与超类型方法一样可访问,有一个小规则。
方法规则:这些操作的实现在语义上是合理的。
- 较弱的前提条件:子类型函数应该至少采用超类型作为输入的内容,如果不是更多的话。
- 更强的后置条件:它们应该产生超类型方法产生的输出的一个子集。
属性规则:这超出了单个函数调用。
- 不变量:总是真实的事物必须保持真实。例如。Set 的大小永远不会是负数。
- 进化属性:通常与不变性或对象可以处于的状态类型有关。或者对象只会增长而不会缩小,因此子类型方法不应该这样做。
所有这些属性都需要保留,并且额外的子类型功能不应违反超类型属性。
如果这三件事都得到了照顾,那么您就已经从底层的东西中抽象出来了,并且您正在编写松散耦合的代码。
资料来源:Java 程序开发 - Barbara Liskov
使用指向基类的指针或引用的函数必须能够在不知情的情况下使用派生类的对象。
当我第一次阅读有关 LSP 的内容时,我认为这是非常严格的意思,本质上将其等同于接口实现和类型安全的强制转换。这意味着语言本身可以保证或不保证 LSP。例如,在这个严格意义上,就编译器而言,ThreeDBoard 肯定可以替代 Board。
在阅读了更多关于这个概念的内容后,我发现 LSP 的解释通常比这更广泛。
简而言之,客户端代码“知道”指针背后的对象是派生类型而不是指针类型意味着什么,并不局限于类型安全。对 LSP 的遵守也可以通过探测对象的实际行为来测试。也就是说,检查对象的状态和方法参数对方法调用结果或对象抛出的异常类型的影响。
再次回到这个例子,理论上Board 方法可以在 ThreeDBoard 上正常工作。然而,在实践中,很难防止客户端可能无法正确处理的行为差异,而不妨碍 ThreeDBoard 打算添加的功能。
掌握了这些知识,评估 LSP 的依从性可以成为一个很好的工具,用于确定何时组合是扩展现有功能而不是继承更合适的机制。
使用LSP 的一个重要例子是在软件测试中。
如果我有一个类 A 是 B 的符合 LSP 的子类,那么我可以重用 B 的测试套件来测试 A。
要完全测试子类 A,我可能需要添加更多测试用例,但至少我可以重用所有超类 B 的测试用例。
一种实现方式是通过构建 McGregor 所说的“用于测试的并行层次结构”:我的ATest
类将继承自BTest
. 然后需要某种形式的注入来确保测试用例与 A 类型的对象而不是 B 类型的对象一起工作(一个简单的模板方法模式就可以了)。
请注意,为所有子类实现重用超级测试套件实际上是一种测试这些子类实现是否符合 LSP 的方法。因此,人们也可以争辩说应该在任何子类的上下文中运行超类测试套件。
另请参阅 Stackoverflow 问题的答案“我可以实现一系列可重用的测试来测试接口的实现吗? ”
里氏替换原则
- 被覆盖的方法不应为空
- 被覆盖的方法不应该抛出错误
- 基类或接口行为不应因为派生类行为而进行修改(返工)。
LSP 的这个公式太强了:
如果对于每个 S 类型的对象 o1 都有一个 T 类型的对象 o2 使得对于所有以 T 定义的程序 P,当 o1 替换 o2 时 P 的行为不变,则 S 是 T 的子类型。
这基本上意味着 S 是与 T 完全相同的东西的另一个完全封装的实现。我可以大胆地决定性能是 P 行为的一部分...
因此,基本上,任何后期绑定的使用都违反了 LSP。当我们用一种对象替换另一种对象时,获得不同行为是 OO 的全部要点!
维基百科引用的公式更好,因为属性取决于上下文,不一定包括程序的整个行为。
用一个非常简单的句子,我们可以说:
子类不得违反其基类特征。它必须有能力。我们可以说它与子类型相同。
里氏替换原则(LSP)
我们一直在设计程序模块并创建一些类层次结构。然后我们扩展一些类,创建一些派生类。
我们必须确保新的派生类只是扩展而不替换旧类的功能。否则,新类在现有程序模块中使用时会产生不良影响。
Liskov 的替换原则指出,如果程序模块正在使用 Base 类,那么对 Base 类的引用可以替换为 Derived 类,而不会影响程序模块的功能。
例子:
以下是违反 Liskov 替换原则的经典示例。在示例中,使用了 2 个类:Rectangle 和 Square。假设在应用程序的某处使用了 Rectangle 对象。我们扩展应用程序并添加 Square 类。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.
}
}
结论:
这个原则只是 Open Close 原则的扩展,它意味着我们必须确保新的派生类在不改变其行为的情况下扩展基类。
参见:开闭原则
一些类似的概念以获得更好的结构:约定优于配置
LSP 简单地说就是同一个超类的对象应该能够在不破坏任何东西的情况下相互交换。
例如,如果我们有一个Cat
和一个Dog
从类派生的Animal
类,那么任何使用 Animal 类的函数都应该能够正常使用Cat
或Dog
运行。
该原则由Barbara Liskov在 1987 年引入,并通过关注超类及其子类型的行为扩展了开闭原则。
当我们考虑违反它的后果时,它的重要性就变得显而易见了。考虑一个使用以下类的应用程序。
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
{
}
但是,这样做我们会遇到两个问题:
正方形不需要从矩形继承的高度和宽度变量,如果我们必须创建数十万个正方形对象,这可能会在内存中造成重大浪费。从矩形继承的宽度和高度设置器属性不适用于正方形,因为正方形的宽度和高度是相同的。为了将高度和宽度设置为相同的值,我们可以创建两个新属性,如下所示:
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,因为该函数不适用于其参数的导数。属性宽度和高度不是多态的,因为它们没有在矩形中声明为虚拟(方形对象将被破坏,因为高度不会改变)。
但是,通过将 setter 属性声明为虚拟,我们将面临另一个违规行为,即 OCP。事实上,派生类正方形的创建导致基类矩形的变化。
一些附录:
我想知道为什么没有人写关于派生类必须遵守的基类的 Invariant 、前置条件和后置条件。对于派生类 D 完全可以被基类 B 替代,类 D 必须遵守某些条件:
- 基类的变量必须由派生类保留
- 派生类不能强化基类的前置条件
- 派生类不能削弱基类的后置条件。
所以派生必须知道基类强加的上述三个条件。因此,子类型的规则是预先确定的。这意味着,只有当子类型遵守某些规则时,才应遵守“IS A”关系。这些规则,以不变量、前置条件和后置条件的形式,应该由正式的“设计合同”来决定。
可以在我的博客上对此进行进一步讨论:Liskov Substitution principle
正方形是宽度等于高度的矩形。如果正方形为宽度和高度设置了两种不同的大小,则它违反了正方形不变量。这是通过引入副作用来解决的。但是如果矩形有一个 setSize(height, width),前提是 0 < height 和 0 < width。派生的子类型方法需要 height == width; 一个更强的前提条件(这违反了 lsp)。这表明虽然正方形是一个矩形,但它不是一个有效的子类型,因为前提条件被加强了。解决方法(通常是一件坏事)会导致副作用,这会削弱后置条件(违反 lsp)。基础上的 setWidth 具有后置条件 0 < 宽度。派生用高度==宽度削弱它。
因此,可调整大小的正方形不是可调整大小的矩形。
它指出如果 C 是 E 的子类型,则可以用 C 类型的对象替换 E,而不会改变或破坏程序的行为。简而言之,派生类应该可以替代它们的父类。例如,如果农民的儿子是农民,那么他可以代替父亲工作,但如果农民的儿子是板球运动员,那么他不能代替父亲工作。
违规示例:
public class Plane{
public void startEngine(){}
}
public class FighterJet extends Plane{}
public class PaperPlane extends Plane{}
在给定的示例FighterPlane
和PaperPlane
类中,都扩展了Plane
包含startEngine()
方法的类。所以很明显FighterPlane
可以启动引擎但PaperPlane
不能所以它正在破坏LSP
。
PaperPlane
class 虽然扩展Plane
了 class 并且应该可以替代它,但它不是 Plane 实例可以替换的合格实体,因为纸飞机无法启动引擎,因为它没有引擎。所以一个很好的例子是,
尊敬的例子:
public class Plane{
}
public class RealPlane{
public void startEngine(){}
}
public class FighterJet extends RealPlane{}
public class PaperPlane extends Plane{}
大图:
- 什么是里氏替换原则?它是关于什么是(什么不是)给定类型的子类型。
- 为什么如此重要?因为subtype和subclass之间是有区别的。
例子
与其他答案不同,我不会从违反 Liskov 替换原则 (LSP) 开始,而是从 LSP 合规开始。我使用 Java,但在每种 OOP 语言中几乎都是一样的。
Circle
和ColoredCircle
几何示例在这里似乎很受欢迎。
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;
}
}
Circle
根据 LSP,这个子类是 的子类型。
LSP 指出:
如果对于每个 S 类型的对象 o1 都有一个 T 类型的对象 o2 使得对于所有以 T 定义的程序 P,当 o1 替换 o2 时 P 的行为不变,则 S 是 T 的子类型。 Barbara Liskov,“数据抽象和层次结构”,SIGPLAN Notices,23,5(1988 年 5 月))
在这里,对于每个ColoredCircle
实例o1
,考虑Circle
具有相同半径的实例o2
。对于每个使用Circle
对象的程序,如果您替换o2
为o1
,则任何使用对象的程序的行为Circle
在替换后都将保持不变。(请注意,这是理论上的:使用ColoredCircle
实例比使用Circle
实例更快地耗尽内存,但这与此处无关。)
我们如何找到o2
依赖o1
?我们只是剥离color
属性并保留radius
属性。我称变换o1
->空间上o2
的空间投影。CircleColor
Circle
反例
让我们创建另一个示例来说明违反 LSP 的情况。
Circle
和Square
想象一下前一个类的这个子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
的子类型,因为没有任何常规实例的半径为 -1。Circle
Circle
形式上,这违反了里氏替换原则。
我们有一个程序定义为,Circle
并且在该程序中没有Circle
可以替换new Square(2)
(或任何Square
实例)的对象并且保持行为不变:记住 any 的半径Circle
始终为正。
子类和子类型
现在我们知道为什么子类并不总是子类型。当子类不是子类型时,即存在 LSP 违规时,某些程序(至少一个)的行为将不会始终是预期的行为。这非常令人沮丧,通常被解释为错误。
在理想世界中,编译器或解释器将能够检查给定的子类是否是真正的子类型,但我们不在理想世界中。
静态类型
如果有一些静态类型,你会在编译时受到超类签名的约束。Square.getRadius()
不能返回 aString
或 a List
。
如果没有静态类型,如果一个参数的类型错误(除非类型很弱)或参数的数量不一致(除非语言非常宽松),您将在运行时收到错误。
关于静态类型的注意事项:存在返回类型的协变(S 的方法可以返回 T 的相同方法的返回类型的子类)和参数类型的逆变(S 的方法可以接受T 的相同方法的相同参数的参数的超类)。这是下面解释的前置条件和后置条件的特定情况。
合同设计
还有更多。某些语言(我认为是 Eiffel)提供了一种强制遵守 LSP 的机制。
更不用说确定o2
初始对象的投影了o1
,我们可以期待任何程序的相同行为 ifo1
被替换为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.gaussian()
返回 0.0 和 1.0 之间的浮点数的方法。您如何编写后置条件来检查分布是否为高斯分布?这可能是可能的,但成本会很高,以至于我们将依赖测试而不是后置条件。
结论
不幸的是,子类并不总是子类型。这可能会导致意外的行为——一个错误。
OOP 语言提供了避免这种情况的机制。首先在句法层面。在语义级别也是如此,这取决于编程语言:一部分语义可以使用断言编码在程序的文本中。但是确保子类是子类型取决于您。
还记得你是从什么时候开始学习 OOP 的吗?“如果关系是 IS-A,则使用继承”。反之亦然:如果您使用继承,请确保关系是 IS-A。
LSP 在比断言更高的级别上定义了什么是子类型。断言是确保 LSP 得到维护的宝贵工具。
用一组 Board 来实现 ThreeDBoard 会有用吗?
也许您可能希望将各个平面上的 ThreeDBoard 切片视为 Board。在这种情况下,您可能希望为 Board 抽象出一个接口(或抽象类)以允许多个实现。
在外部接口方面,您可能需要为 TwoDBoard 和 ThreeDBoard 考虑一个 Board 接口(尽管上述方法都不适合)。
到目前为止,我发现的 LSP 最清晰的解释是“Liskov 替换原则说,派生类的对象应该能够替换基类的对象,而不会在系统中带来任何错误或修改基类的行为“从这里。本文给出了违反 LSP 并修复它的代码示例。
假设我们在代码中使用了一个矩形
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);
}
}
如果我们在第一个代码中替换为Rectangle
,Square
那么它将中断:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
这是因为有一个我们在课堂Square
上没有的新前提: . 根据 LSP,实例应该可以用子类实例替代。这是因为这些实例通过了实例的类型检查,因此它们会导致代码中出现意外错误。Rectangle
width == height
Rectangle
Rectangle
Rectangle
这是wiki 文章中“无法在子类型中加强先决条件”部分的示例。所以总而言之,违反 LSP 可能会在某些时候导致代码中的错误。
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();
}
我鼓励您阅读这篇文章:违反 Liskov 替换原则 (LSP)。
您可以在其中找到什么是 Liskov 替换原则的解释、帮助您猜测是否已经违反它的一般线索以及帮助您使类层次结构更安全的方法示例。
LISKOV SUBSTITUTION PRINCIPLE(来自 Mark Seemann 的书)指出,我们应该能够在不破坏客户端或实现的情况下将接口的一个实现替换为另一个实现。正是这一原则能够解决未来出现的需求,即使我们可以。今天无法预见它们。
如果我们把电脑从墙上拔下来(实现),墙上的插座(接口)和电脑(客户端)都不会坏(事实上,如果是笔记本电脑,它甚至可以用电池运行一段时间) . 然而,对于软件,客户通常希望服务可用。如果服务被删除,我们会收到 NullReferenceException。为了处理这种情况,我们可以创建一个“什么都不做”的接口实现。这是一种称为 Null Object [4] 的设计模式,它大致对应于将计算机从墙上拔下。因为我们使用的是松散耦合,所以我们可以用什么都不做而不会造成麻烦的东西代替真正的实现。
Likov 的替换原则指出,如果程序模块正在使用 Base 类,那么对 Base 类的引用可以替换为 Derived 类,而不会影响程序模块的功能。
意图 - 派生类型必须完全替代它们的基本类型。
示例 - java 中的协变返回类型。
这是这篇文章的摘录,很好地澄清了事情:
[..] 为了理解一些原则,重要的是要意识到它何时被违反。这就是我现在要做的。
违反这个原则是什么意思?这意味着一个对象不履行由接口表达的抽象所强加的契约。换句话说,这意味着您错误地识别了您的抽象。
考虑以下示例:
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);
}
瞧,现在合同已经完成了。
这种微妙的违反常常使客户有能力分辨所使用的具体对象之间的区别。例如,给定第一个 Account 的合约,它可能如下所示:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
而且,这自动违反了开闭原则[即对于提款要求。因为你永远不知道如果违反合同的对象没有足够的钱会发生什么。可能它什么都不返回,可能会抛出异常。所以你必须检查它hasEnoughMoney()
是否不是接口的一部分。因此,这种强制的依赖于具体类的检查是违反 OCP 的]。
这一点也解决了我经常遇到的关于 LSP 违规的误解。它说“如果父母的行为改变了孩子,那么,它违反了 LSP。” 然而,它不会——只要孩子不违反父母的合同。
类(子类型)
先决条件不能在子类型中得到加强。
后置条件不能在子类型中被削弱。
超类型的不变量必须保留在子类型中。
*前置条件和后置条件是function (method) types
[Swift Function 类型。Swift 函数 vs 方法]
- Sybtype 对调用者的要求不应超过超类型
- Sybtype 不应该暴露给调用者少于超类型
//C1 <- C2 <- C3
class C1 {}
class C2: C1 {}
class C3: C2 {}
前提条件(例如 function
parameter type
)可以相同或更弱。(争取-> C1)后置条件(例如 function
returned type
)可以相同或更强(争取 -> C3)超类型的不变变量[About]应该保持不变
迅速
class A {
func foo(a: C2) -> C2 {
return C2()
}
}
class B: A {
override func foo(a: C1) -> C3 {
return C3()
}
}
爪哇
class A {
public C2 foo(C2 a) {
return new C2();
}
}
class B extends A {
@Override
public C3 foo(C2 a) { //You are available pass only C2 as parameter
return new C3();
}
}
函数(覆盖) 方法签名)
子类型中方法参数的逆变。
子类型中返回类型的协方差。
子类型的方法不应抛出新的异常,除非这些异常本身是超类型方法抛出的异常的子类型。
让我试试,考虑一个接口:
interface Planet{
}
这是由类实现的:
class Earth implements Planet {
public $radius;
public function construct($radius) {
$this->radius = $radius;
}
}
您将地球用作:
$planet = new Earth(6371);
$calc = new SurfaceAreaCalculator($planet);
$calc->output();
现在考虑另一个扩展地球的类:
class LiveablePlanet extends Earth{
public function color(){
}
}
现在根据 LSP,您应该能够使用 LiveablePlanet 代替 Earth,它不应该破坏您的系统。像:
$planet = new LiveablePlanet(6371); // Earlier we were using Earth here
$calc = new SurfaceAreaCalculator($planet);
$calc->output();
取自此处的示例
令 q(x) 是关于类型 T 的 x 对象可证明的属性。那么 q(y) 应该对于类型 S 的对象 y 是可证明的,其中 S 是 T 的子类型。
实际上,公认的答案并不是 Liskov 原则的反例。正方形自然是特定的矩形,因此继承自类 rectangle 是完全合理的。您只需要以这种方式实现它:
@Override
public void setHeight(double height) {
this.height = height;
this.width = height; // since it's a square
}
@Override
public void setWidth(double width) {
setHeight(width);
}
因此,在提供了一个很好的例子之后,这是一个反例:
class Family:
-- getChildrenCount()
class FamilyWithKids extends Family:
-- getChildrenCount() { return childrenCount; } // always > 0
class DeadFamilyWithKids extends FamilyWithKids:
-- getChildrenCount() { return 0; }
-- getChildrenCountWhenAlive() { return childrenCountWhenAlive; }
在这个实现中,DeadFamilyWithKids
不能继承FamilyWithKids
自getChildrenCount()
返回0
,而从FamilyWithKids
它应该总是返回更大的东西0
。