我听说Liskov替代原理(LSP)是面向对象设计的基本原理。它是什么,其用法有哪些示例?
我听说Liskov替代原理(LSP)是面向对象设计的基本原理。它是什么,其用法有哪些示例?
Answers:
一个很好的例子说明了LSP(我最近听到的一个播客中的Bob叔叔给了LSP)是,有时候听起来像自然语言的东西在代码中不太起作用。
在数学中,a Square
是a Rectangle
。实际上,它是矩形的一种特殊形式。“是”使您想使用继承对其进行建模。但是,如果在您Square
从中派生的代码中Rectangle
,则a Square
应该可以在您期望的任何地方使用Rectangle
。这导致一些奇怪的行为。
假设您在基类上有SetWidth
和SetHeight
方法Rectangle
;这似乎完全合乎逻辑。但是,如果您的Rectangle
引用指向Square
,则SetWidth
并SetHeight
没有任何意义,因为设置一个将更改另一个以使其匹配。在这种情况下,Square
通过Liskov替代测试失败,Rectangle
并且Square
从继承的抽象Rectangle
是一个不好的选择。
你们应该查看其他无价的SOLID原则励志海报。
Square.setWidth(int width)
是这样实现的:this.width = width; this.height = width;
?在这种情况下,可以保证宽度等于高度。
Liskov替代原则(LSP, lsp)是面向对象编程中的一个概念,其中指出:
使用指针或对基类的引用的函数必须能够使用派生类的对象,而无需对其进行了解。
LSP的核心是关于接口和协定,以及如何决定何时扩展课程与使用诸如组合之类的另一种策略来实现您的目标。
我看到的最有效的方式来说明这一点是在Head First OOA&D中。他们提出了一个场景,其中您是一个项目的开发人员,旨在为策略游戏构建框架。
他们展示了一个代表板的类,如下所示:
所有方法都将X和Y坐标作为参数来定位的二维数组中的图块位置Tiles
。这将允许游戏开发人员在游戏过程中管理棋盘中的单元。
本书继续更改要求,说游戏框架还必须支持3D游戏板以容纳具有飞行功能的游戏。因此ThreeDBoard
引入了扩展类Board
。
乍一看,这似乎是一个不错的决定。Board
提供Height
和Width
属性,并ThreeDBoard
提供Z轴。
当您查看从继承的所有其他成员时,它会崩溃Board
。对于这些方法AddUnit
,GetTile
,GetUnits
等等,都以X和Y参数的Board
类,但ThreeDBoard
需要一个参数Z为好。
因此,您必须使用Z参数再次实现这些方法。Z参数没有Board
类的上下文,并且从Board
类继承的方法失去了意义。试图将ThreeDBoard
类用作其基类的代码单元Board
将非常不走运。
也许我们应该找到另一种方法。而不是扩展Board
,ThreeDBoard
应该由Board
对象组成。Board
Z轴的每单位一个对象。
这使我们能够使用良好的面向对象原理,例如封装和重用,并且不会违反LSP。
可替换性是面向对象编程中的一项原则,指出在计算机程序中,如果S是T的子类型,则可以用类型S的对象替换类型T的对象。
让我们用Java做一个简单的例子:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
鸭子会飞,因为它是鸟。但是这呢:
public class Ostrich extends Bird{}
鸵鸟是鸟,但是它不会飞,鸵鸟类是鸟类的子类型,但是它不能使用fly方法,这意味着我们正在打破LSP原理。
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
。您必须将对象投射到FlyingBirds才能使用fly,这不好吗?
Bird bird
,则意味着它不能使用fly()
。而已。传递a Duck
不会更改此事实。如果客户端具有FlyingBirds bird
,则即使通过,Duck
它也应始终以相同的方式工作。
LSP关注不变性。
下面的伪代码声明(省略实现)给出了经典示例:
class Rectangle {
int getHeight()
void setHeight(int value)
int getWidth()
void setWidth(int value)
}
class Square : Rectangle { }
现在,尽管接口匹配,我们还是有问题。原因是我们违反了基于正方形和矩形的数学定义的不变量。获取器和设置器的工作方式a Rectangle
应该满足以下不变式:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
但是,正确的实现必须违反此不变式Square
,因此它不是的有效替代Rectangle
。
罗伯特·马丁(Robert Martin)撰写了一篇有关《里斯科夫替代原理》的出色论文。它讨论了可能违反该原则的细微和不太细微的方式。
本文的一些相关部分(请注意,第二个示例非常简洁):
违反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
将继承SetWidth
和SetHeight
功能。这些功能完全不适用于Square
,因为正方形的宽度和高度相同。这应该是设计存在问题的重要线索。但是,有一种方法可以避开该问题。我们可以覆盖SetWidth
和SetHeight
[...]但是请考虑以下功能:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
如果我们
Square
将对对象的引用传递给此函数,Square
将对对象将被破坏,因为高度不会更改。这显然违反了LSP。该函数不适用于其参数的派生。[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
如果子类的前提条件比父类的前提条件强,那么您就不能在不违反前提条件的情况下用孩子代替父母。因此LSP。
如果某些代码认为LSP正在调用类型的方法T
,并且在不知不觉中调用类型的方法,则LSP是必需的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
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
即使具有不可变字段的类型仍然具有不变性,例如,不可变的 Rectangle setter期望尺寸被独立修改,但是不可变的 Square setter违反了这一期望。
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
。
这是因为,如果调用者认为它具有类型T
,认为它正在调用的方法T
,则它将提供type的参数Ti
并将输出分配给type To
。当它实际上在调用的相应方法时S
,则将每个Ti
输入参数分配给一个Si
输入参数,并将So
输出分配给type To
。因此,如果Si
与并不是协变的Ti
,则可以将子类型Xi
—不会是的子类型— Si
分配给Ti
。
另外,对于在类型多态性参数(即泛型)上具有定义站点差异注释的语言(例如Scala或Ceylon),该类型的每个类型参数的差异注释的同向或反方向T
必须相反或相同分别输入T
到具有类型参数类型的每个输入参数或输出(的每种方法)。
此外,对于具有功能类型的每个输入参数或输出,所需的方差方向将相反。该规则是递归应用的。
子类型适用于可以枚举不变量的地方。
关于如何对不变量建模的工作正在进行很多研究,以便由编译器强制执行。
类型状态(请参阅第3页)声明并强制与类型正交的状态不变式。或者,可以通过将断言转换为type来强制执行不变量。例如,要断言在关闭文件之前已打开文件,则File.open()可以返回OpenFile类型,该类型包含File中不可用的close()方法。甲井字棋API可以是采用打字执行在编译时不变的另一个例子。类型系统甚至可以是图灵完备的,例如Scala。相依类型语言和定理证明了高阶类型化模型的形式化。
由于需要语义来对扩展进行抽象,因此我希望使用类型来对不变量进行建模(即统一的高阶指称语义)要优于Typestate。“扩展”是指不协调,模块化的开发的无穷排列组合。因为在我看来,这是统一性的对立面,因此也是自由度的对立面,所以有两个相互依赖的模型(例如,类型和Typestate)来表达共享语义,对于可扩展的组合,它们不能相互统一。例如,类似于表达式问题的扩展在子类型,函数重载和参数类型域中是统一的。
我的理论立场是 知识存在(请参阅“集中化是盲目的和不适当的”),将永远不会有一个通用模型可以强制使用图灵完备的计算机语言对所有可能的不变量进行100%的覆盖。为了存在知识,存在着许多意想不到的可能性,即无序和熵必须一直在增加。这是熵力。证明潜在扩展的所有可能计算,就是先验计算所有可能的扩展。
这就是存在暂停定理的原因,即无法确定图灵完备的编程语言中的每个可能的程序是否终止。可以证明某些特定程序终止(已经定义并计算了所有可能性)。但是不可能证明该程序的所有可能扩展都终止了,除非该程序的扩展可能性不是图灵完成的(例如通过依赖类型)。由于图灵完备性的基本要求是无限制的递归,因此直观地了解Gödel的不完备性定理和Russell的悖论如何适用于扩展。
对这些定理的解释将它们纳入对熵力的广义概念理解中:
有一个检查表来确定您是否违反了Liskov。
检查清单:
历史约束:重写方法时,不允许您在基类中修改不可修改的属性。看一下这些代码,您会看到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#开发人员),所以我不在乎它们。
参考:
我在每个答案中都看到了矩形和正方形,以及如何违反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 !
}
}
现在,子类型不能以相同的方式使用,因为它们不再产生相同的结果。
Database::selectQuery
仅支持所有数据库引擎支持的SQL子集,该示例就不会违反LSP 。这几乎是不实际的...也就是说,该示例仍然比此处使用的大多数其他示例更易于理解。
LSP是有关租约合同的规则:如果基类满足合同,则由LSP派生的类也必须满足该合同。
在伪python中
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
如果每次您在派生对象上调用Foo时,只要arg相同,它就会得到与在基础对象上调用Foo完全相同的结果。
2 + "2"
)。也许您将“强类型”与“静态类型”混淆了?
长延伸的父类时的故事,总之,让我们离开矩形长方形和正方形广场,实际的例子,你必须要么保留确切父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();
}
}
当用子类代替父类破坏了API的合同时,LSP被破坏了。
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的文章时,我认为这是非常严格的含义,实质上等同于接口实现和类型安全的转换。这将意味着语言本身无法保证LSP。例如,从严格意义上讲,就编译器而言,ThreeDBoard当然可以替代Board。
在阅读了有关该概念的更多信息之后,我发现LSP的解释通常比这更广泛。
简而言之,客户端代码“知道”指针后面的对象是派生类型而不是指针类型意味着什么并不仅限于类型安全。还可以通过探测对象的实际行为来测试对LSP的遵守性。也就是说,检查对象的状态和方法参数对方法调用结果或从对象引发的异常类型的影响。
再次回到该示例,理论上可以使Board方法在ThreeDBoard上正常工作。但是,实际上,如果不妨碍ThreeDBoard要添加的功能,很难防止客户端可能无法正确处理的行为差异。
掌握了这些知识之后,评估LSP的遵从性可以成为确定组合何时是扩展现有功能(而不是继承)的更合适机制的好工具。
我猜每个人都从技术上讲过LSP的含义:您基本上希望能够从子类型细节中抽象出来并安全地使用超类型。
因此Liskov具有3条基本规则:
签名规则:语法上应该有效地实现子类型中父类型的每个操作。编译器可以为您进行检查。关于抛出更少的异常并且至少与父类型方法具有相同的可访问性有一条小规则。
方法规则:这些操作的实现在语义上是合理的。
属性规则:这超出了单个函数的调用范围。
所有这些属性都必须保留,并且额外的子类型功能不应违反超类型属性。
如果这三件事都得到了照顾,那么您已经从基础内容中抽象出来了,并且正在编写松耦合的代码。
资料来源:Java程序开发-Barbara Liskov
在软件测试中,使用 LSP的一个重要示例。
如果我有一个类A,它是B的LSP兼容子类,则可以重用B的测试套件来测试A。
为了完全测试子类A,我可能需要添加更多测试用例,但是至少我可以重用所有超类B的测试用例。
一种实现方式是通过构建McGregor所谓的“用于测试的并行层次结构”来实现:我的ATest
类将从继承BTest
。然后需要某种形式的注入,以确保测试用例可以用于类型A的对象而不是类型B的对象(可以使用简单的模板方法模式)。
注意,对所有子类实现重新使用超级测试套件实际上是一种测试这些子类实现是否符合LSP的方法。因此,也可以说一个人应该在任何子类的上下文中运行超类测试套件。
另请参阅Stackoverflow问题的答案“ 我可以实现一系列可重用的测试来测试接口的实现吗? ”
让我们用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()方法。
这些都是违反Liskov替代原理所导致的问题,通常可以通过什么都不做甚至无法实现的方法来识别。
这些问题的解决方案是正确的继承层次结构,在我们的案例中,我们将通过区分带有和不带有引擎的运输设备的类别来解决该问题。即使自行车是运输工具,它也没有引擎。在此示例中,我们对运输设备的定义是错误的。它不应该有引擎。
我们可以如下重构我们的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扩展为电动设备。在这里添加引擎对象更为合适。
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
因此,我们的汽车课在遵循Liskov替代原则的同时变得更加专业。
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
我们的自行车课也符合《里斯科夫换人原则》。
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
LSP的这种公式过于强大:
如果对于类型S的每个对象o1,都存在类型T的对象o2,使得对于用T定义的所有程序P,当用o1代替o2时P的行为不变,则S是T的子类型。
这基本上意味着S是与T完全相同的另一种完全封装的实现。我可以大胆地决定性能是P行为的一部分。
因此,基本上,任何后期绑定的使用都会违反LSP。当我们用一种对象代替另一种对象时,获得不同的行为是OO的重点!
简单地说,我们可以说:
子类不得违反其基类特征。它必须有能力。我们可以说这与子类型化相同。
里斯科夫的替代原理(LSP)
一直以来,我们都设计一个程序模块,并创建一些类层次结构。然后我们扩展一些类,创建一些派生类。
我们必须确保新的派生类可以扩展而不替换旧类的功能。否则,当新类在现有程序模块中使用时,可能会产生不希望的效果。
Liskov的“替换原理”指出,如果程序模块使用Base类,则可以用Derived类替换对Base类的引用,而不会影响程序模块的功能。
例:
以下是违反Liskov替代原则的经典示例。在示例中,使用了2个类:矩形和正方形。假设Rectangle对象在应用程序中的某处使用。我们扩展应用程序并添加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.
}
}
结论:
该原则只是“开放关闭原则”的扩展,它意味着我们必须确保新的派生类在不改变其行为的情况下扩展了基类。
另请参阅:开闭原理
一些类似的概念可以改善结构:约定优于配置
一些附录:
我想知道为什么没有人写过派生类必须遵循的Invariant,前提条件和后置条件。为了使派生类D完全可以被基本类B取代,类D必须遵守以下条件:
因此,派生对象必须了解基类施加的以上三个条件。因此,子类型的规则是预先确定的。这意味着,仅当子类型遵守某些规则时,才应遵守“ IS A”关系。这些规则以不变式,前提条件和后置条件的形式,应由正式的“ 设计合同 ” 决定。
有关此问题的进一步讨论,请访问我的博客:Liskov替代原理
正方形是宽度等于高度的矩形。如果平方为宽度和高度设置了两个不同的大小,则会违反平方不变式。通过引入副作用可以解决此问题。但是如果矩形的setSize(height,width)的前提条件是0 <height和0 <width。派生子类型方法需要height == width; 一个更强的前提条件(并且违反了lsp)。这表明尽管正方形是一个矩形,但由于前提条件得到了加强,因此它不是有效的子类型。变通方法(通常是一件坏事)会引起副作用,这会削弱后期条件(违反lsp)。底座上的setWidth具有发布条件0 <width。派生的高度==宽度削弱了它。
因此,可调整大小的正方形不是可调整大小的矩形。
该原理由Barbara Liskov于1987年提出,并通过关注父类及其子类型的行为扩展了Open-Closed原理。
当我们考虑违反它的后果时,它的重要性变得显而易见。考虑使用以下类的应用程序。
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
{
}
但是,这样做会遇到两个问题:
正方形不需要从矩形继承的高度和宽度变量,如果我们必须创建成千上万的正方形对象,这可能会在内存中造成大量浪费。从矩形继承的width和height setter属性不适用于正方形,因为正方形的宽度和高度相同。为了将height和width设置为相同的值,我们可以创建两个新属性,如下所示:
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,因为该函数不适用于其参数的派生类。属性width和height不是多态的,因为它们不是在矩形中声明为虚拟的(方形对象将被破坏,因为高度不会更改)。
但是,通过将setter属性声明为虚拟,我们将面临另一种违反,即OCP。实际上,派生类正方形的创建导致对基类矩形的更改。
到目前为止,我对LSP的最清晰的解释是“李斯科夫替换原理说,派生类的对象应该能够替换基类的对象,而不会给系统带来任何错误或修改基类的行为。从这里。本文提供了违反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
有了我们在Rectangle
课程中没有的新先决条件:width == height
。根据LSP,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替换原则(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下用另一种实现替换接口的实现,正是这一原则使得即使将来能够满足将来出现的需求,也能做到''今天预见他们。
如果我们从墙壁上拔下计算机的插头(实施),则墙壁插座(接口)或计算机(客户端)都不会发生故障(实际上,如果是便携式计算机,它甚至可以用电池运行一段时间) 。但是,使用软件时,客户通常希望获得服务。如果该服务被删除,我们将收到NullReferenceException。为了处理这种情况,我们可以创建不执行任何操作的接口的实现。这是一种称为Null Object [4]的设计模式,大致相当于将计算机从墙上拔下。因为我们使用的是松散耦合,所以我们可以用什么都不做而不会引起麻烦的东西来代替真正的实现。
Likov的“替代原理”指出,如果程序模块使用Base类,则可以用Derived类替换对Base类的引用,而不会影响程序模块的功能。
目的-派生类型必须能够完全替代其基本类型。
示例-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);
}
Voilà,现在合同已履行。
这种细微的冲突常常使客户有能力分辨所采用的具体对象之间的差异。例如,给定第一个帐户的合同,它可能如下所示:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
并且,这自动违反了开闭原则[即,取款要求。因为您永远不知道如果违反合同的对象没有足够的钱会发生什么。可能它什么也没返回,可能会抛出异常。所以你要检查一下hasEnoughMoney()
是否不是接口的一部分。因此,这种强制依赖于具体类的检查违反了OCP。
这一点也解决了我经常遇到的违反LSP的误解。它说:“如果父母的行为在孩子中改变了,那就违反了LSP。” 但是,只要孩子没有违反父母的合同,它就不会这样做。