我听说里氏替换原则(Liskov Substitution Principle,简称LSP)是面向对象设计的一个基本原则。那么它是什么,有哪些使用示例呢?
我听说里氏替换原则(Liskov Substitution Principle,简称LSP)是面向对象设计的一个基本原则。那么它是什么,有哪些使用示例呢?
大家应该查看其他无价的用激励海报解释的SOLID原则。
Square.setWidth(int width)
这个方法被实现为 this.width = width; this.height = width;
,会出现什么问题?在这种情况下,宽度等于高度是被保证的。 - MC Emperor可替换性是面向对象编程中的一个原则,它规定在计算机程序中,如果S是T的子类型,则可以用S类型的对象替换T类型的对象。
让我们用Java来举个简单的例子:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
鸭子之所以能飞,是因为它是一种鸟类。但对于以下问题:
public class Ostrich extends Bird{}
鸵鸟是一种鸟类,但它不能飞行。鸵鸟类是鸟类的子类型,但它不应该能够使用飞行方法,这意味着我们违反了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
,这不是很好,对吧? - MoodyLSP涉及不变量。
经典示例由以下伪代码声明给出(省略实现):
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 { }
矩形
应该满足以下不变量:void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
然而,这个不变量(以及明确的后置条件)必须被正确实现的Square
所违反,因此它不能成为Rectangle
的有效替代品。
罗伯特·马丁在Liskov替换原则论文中提供了优秀的阐述。该论文探讨了可能违反该原则的微妙和不那么微妙的方式。
文中的一些相关部分(请注意,第二个例子被大幅缩短):
违反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的明显违规。该函数不适用于其参数的派生类。
setHeight(x) { this.height = x; this.width = x }
,setWidth()
等类似方法。它对于水平和垂直调整控件可以很好地工作,但对于角落调整控件需要一些解决方法。 - x'ES有一个清单可以确定您是否违反了Liskov原则。
检查清单:
派生类中不应抛出新异常:如果您的基类抛出ArgumentNullException,则您的子类只允许抛出类型为ArgumentNullException或任何从ArgumentNullException派生的异常。抛出IndexOutOfRangeException会违反Liskov原则。
不得加强前置条件:假设您的基类使用一个成员变量int。现在您的子类型要求该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);
}
}
这里还有两个项目: 方法参数的逆变性 和 返回类型的协变性。 但在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。但这几乎是不实际的... 尽管如此,这个例子比这里使用的大多数其他例子更容易理解。 - PalecLSP是必要的,当一些代码认为它在调用类型T
的方法时,可能无意中调用类型S
的方法,其中S extends T
(即S
继承、派生或是T
的子类型)。
例如,在将类型T
的输入参数传递给一个函数时(即调用该函数),使用的实参值为类型S
。或者,将类型S
的值分配给类型T
的标识符。
val id : T = new S() // id thinks it's a T, but is a S
LSP要求在调用类型S
(例如Square
)的方法时,不违反类型T
(例如Rectangle
)方法的期望(即不变量)。
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逆变的,那么一个子类型Xi——它不是Si的子类型——可以分配给Ti。
此外,对于具有类型多态参数(即泛型)的定义位置变异注释的语言(例如Scala或Ceylon),必须将类型T的每个类型参数的变异注释的协变或逆变方向分别与具有类型为类型参数的每个输入参数或输出(T
的每个方法)相反或相同方向。
另外,对于每个具有函数类型的输入参数或输出,所需的变异方向是相反的。这个规则会递归地应用。
子类型适用于可以枚举不变量的情况。
如何建模不变量以便编译器可以强制执行,目前仍有许多正在进行的研究。
类型状态(见第3页)声明并强制实施与类型正交的状态不变量。或者,可以通过将断言转换为类型来强制实施不变量。例如,要断言在关闭文件之前文件已打开,则File.open()可能会返回一个OpenFile类型,其中包含一个在File中不可用的close()方法。 井字游戏API 可以是使用类型强制执行编译时不变量的另一个示例。类型系统甚至可以是图灵完备的,例如 Scala。依赖类型语言和定理证明器规范了高阶类型的模型。
因为需要在抽象扩展上进行语义,所以我认为采用类型来建模不变量,即统一的高阶表意语义,优于Typestate。 "扩展" 意味着不协调的模块化开发的无限排列组合。 因为对我来说,这似乎是统一的反面,因此自由度非常大,有两个相互依赖的模型(例如类型和Typestate)用于表示共享语义,不能相互统一以实现可扩展的组成。例如,在子类型,函数重载和参数化键入领域中,类似Expression Problem的扩展已经统一了。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;
}
}
接下来,您可以使用客户端与基础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(Liskov Substitution Principle)就会被破坏。
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() { ... }
}
我们的自行车类也符合Liskov替换原则。
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}