Liskov 替换原则






4.94/5 (43投票s)
对该原则的看法,包括 Rectangle/Square 示例的可能解决方案,同时解释了为什么它不是真实编程场景的良好示例,并提出了一个在编程时可能遇到的更现实的示例。
引言
我最近看到很多关于 SOLID 原则的文章,其中一个因为糟糕的示例而真正引起我注意的是 Liskov 替换原则(也称为 LSP)。
该原则规定,父类型的实例应能被子类型的实例替换,而不会改变应用程序的正确性。乍一看这似乎很愚蠢,因为任何子类型实例都可以作为参数传递给接收父类型实例的方法,但随后出现了用于说明该原则如何被违反的示例:Rectangle
(矩形)和Square
(正方形)。
考虑到继承意味着“is a”关系,该示例说:Square
是Rectangle
(因此,它是Rectangle
的子类),然后它展示了我们如何破坏 Liskov 替换原则,因为对于Rectangle
来说,可以独立更改Width
(宽度)和Height
(高度),这对于Square
来说是不成立的。
糟糕的解决方案
我最近看到的大多数解决方案都建议创建一个基类,以便Rectangle
和Square
都继承自它,但它们保留了可修改的独立Width
和Height
属性。
这根本不是一个解决方案。首先,Square
不再是Rectangle
,然后基类继续拥有独立的Width
和Height
属性,因此这种“解决方案”只是增加了复杂性(一个新的基类型),破坏了Square
和Rectangle
之间的“is a”关系(因此与几何学相比似乎不合逻辑),并保留了Square
不应具有独立Width
和Height
属性的问题。
事实上,我认为整个Rectangle
和Square
示例并不是为了展示解决方案,而是为了展示问题。
对 Liskov 替换原则的其他解释
对 Liskov 替换原则还有其他解释。与其说父类型的实例应能被子类型的实例替换而不改变应用程序的正确性,不如说子类型不能改变父类型的行为。
好吧,我应该说,虽然我理解这句话的意思,但它并不精确。当然,我们不能改变预期的逻辑(因为这会使程序功能不正确),但子类型,特别是方法重写,正是为了改变行为而存在的。如果我们根本不能改变行为,那就好比说我们只能继承来添加新属性或方法,但我们永远不能重写任何方法,这显然不是目的。
但是,对 Liskov 替换原则还有一个我个人认为更好的解释。这种解释说,子类型绝不能比基类型具有更强的先决条件或更弱的后置条件。好吧,也许“先决条件”和“后置条件”这两个词不是最容易理解的,但它的意思是这样的:
- 如果基类型上的方法接受
null
值作为输入,那么子类型上重写的方法也应接受null
值。它不能简单地开始抛出异常说null
参数无效,因为这将是一个更强的先决条件; - 另一方面,如果基类型的方法保证永远不会返回
null
,那么子类型就不能重写该方法并返回null
。毕竟,任何将实例视为父类型的人都认为null
永远不会被返回。继承的类型仍然是父类型的一种,因此它仍然必须保证相同的后置条件(这种后置条件在返回集合时非常常见,因为在许多情况下,期望收到一个空集合而不是null
)。
Rectangle 和 Square 与该原则的关系?
Rectangle
给我们两个属性:Width
和Height
,如果我们没有被告知其他任何信息,我们可以假设它们是独立的属性。在这里,我们可以说没有明确告知的保证,但根据简单的逻辑,我们可以想象诸如TotalArea
(总面积)之类的属性是根据其他东西(如Width
和Height
)计算的,而Width
和Height
彼此之间似乎完全独立。然而,没有被告知的是,负值是否受支持,因为在现实世界中,没有负尺寸(甚至绝对零值也是无效的,因为它意味着对象不存在),而在编程世界中,这些值是有效的,但这不是目前的重点。
在我看来,Rectangle
/Square
的情况是“现实世界”的比较,在 OOP 世界中失败了。特别是,“is a”这个词导致了问题。在几何学中,Square
是Rectangle
。这种“is a”表达用于表示子类是基类的一种。但是,如果我们(在 C# 这是一个主要是static
类型语言)有这两个方法:DrawRectangle(Rectangle)
和DrawSquare(Square)
呢?
考虑到Square
是Rectangle
,我们可以调用DrawRectangle()
并传入一个Square
对象作为参数,并且在静态类型的 OOP 语言中,这工作得很好。
但现在我问你:一个Width
和Height
相等的Rectangle
是Square
吗?
如果你的答案是肯定的(在几何学中确实是),那么DrawSquare()
可以与一个Rectangle
实例一起调用,其中Width
和Height
相等。但在静态类型语言中,情况并非如此。Rectangle
类型的实例只是一个Rectangle
,而不是一个Square
,即使它拥有Square
的所有相同属性(即,相等大小的Width
和Height
)。
那么,我们如何解决这个问题?
我个人解决这个问题的方法是:应该只有一个Rectangle
类型。Square
只是对该Rectangle
属性的一种观察。因此,像IsSquare
这样的属性将根据它在几何学中的工作方式来解决问题。
也就是说:您可以创建一个Width
为100
,Height
为200
的Rectangle
。IsSquare
将返回false
。然后,您可以将Width
更改为也为200
。如果您获取IsSquare
的值,它将是true
。太好了!
所以代码可以是
public class Rectangle
{
public int Width { get; set; }
public int Height { get; set; }
public bool IsSquare
{
get
{
return Width == Height;
}
}
}
但是,只有Rectangle
类型,我们无法创建像DrawSquare(Square)
这样的方法。
因此,如果我们做一个DrawSquare(Rectangle)
,它应该只接受Square
实例,在编译时它仍然会接受任何Rectangle
。当然,我们可以在运行时进行验证(就像我们可以在运行时验证任何参数是否为null
一样),但是如果我们想使用真实类型来解决Square
和Rectangle
的问题呢?
使用真实类型解决问题
在提出可能的解决方案之前,我想明确一点,我认为这些解决方案是糟糕的设计。正如前面解释的,任何width
和height
相等的矩形都是一个square
,所以我认为有一个属性可以告诉实际的Rectangle
是否也是一个Square
要简单得多、合乎逻辑得多。
但是在静态类型语言中,我们可能希望在编译时提供保证,即参数将具有某些特征(在这种情况下,它将是一个Square
而不是任何类型的Rectangle
)。即使我知道 OOP 情况需要类似的编译时约束,我实际上也无法说我看到了Rectangle
/Square
情况的真实场景,特别是因为这似乎是对现实世界几何的比较,而在现实世界中,我们永远不会要求一个Square
而不是一个Rectangle
而不加上其他要求。例如:如果我们购买瓷砖来更换损坏的瓷砖,我们会要求特定尺寸(例如 10x10cm)且具有特定颜色或纹理的瓷砖。是的,如果尺寸是 10x10,它就是一个正方形,但如果我收到一块颜色/纹理相同、尺寸为 12x12 的瓷砖,它将不起作用,因此在现实世界的情况下,即使收到的对象是square
,我们也会验证尺寸。如果我们已经在验证正确的尺寸,那么验证它是一个square
就是多余的。
但是,如果我们应该让它在 OOP 中工作(也许是为了表明我们找到了解决方案并真正理解了问题),好的,让我们这样做。但首先,请回答两个问题:
- 类型(
Square
和Rectangle
)是否需要可修改? - 每个
Rectangle
(100, 100)都是一个真正的Square
吗?
根据答案,事情可以有不同的解决方式(或者根本无法解决)。
因此,对于答案的每种组合,我们有:
-
需要可修改?否。
Rectangle(100, 100)
是Square
吗?否这是最简单的一个:使类型不可变(也就是说,属性仅在创建时设置)。
Square
继承自Rectangle
,在其构造函数中,它只接收一个参数,该参数将用于填充Rectangle
类型的Width
和Height
。由于之后无法更改Width
或Height
,一切都会好起来,但是如果您执行new Rectangle(100, 100)
而不是new Square(100)
,您将得到一个可能是Square
的Rectangle
,但它将没有真正的Square
类型,因此它将无法像在几何学中那样工作。所以,代码可以是
public class Rectangle { public Rectangle(int width, int height) { // Validating if width and height are > 0 // is optional for this example, but the // real classes will probably do such verification. Width = width; Height = height; } public int Width { get; private set; } public int Height { get; private set; } } public class Square: Rectangle { public Square(int size): base(size, size) { } }
在这种情况下,以下情况可以工作
DrawRectangle(new Rectangle(100, 200)); DrawRectangle(new Square(100)); DrawSquare(new Square(100));
而这会返回
false
Rectangle rectangle = new Rectangle(100, 100); return rectangle is Square;
-
需要可修改?否。
Rectangle(100, 100)
是Square
吗?是。我们仍然需要类型是不可变的,但我们不能将
Rectangle
类型的构造函数设置为public
。我们应该始终使用一个构造方法,该方法能够创建Rectangle
(如果尺寸不同)或Square
(如果尺寸相同)。Square
的构造函数可以是public
,因为它不会有被视为其他类型的风险。但是请注意,即使Rectangle.Create(100, 100)
返回一个Square
的实例,它也会以Rectangle
的形式返回,所以如果您想将这样一个Square
传递给静态要求Square
的方法,则需要将其转换为Square
(在这种情况下,我确实认为IsSquare
属性比转换更清晰)。所以,代码可以是
public class Rectangle { public static Rectangle Create(int width, int height) { if (width == height) return new Square(width); return new Rectangle(width, height); } internal Rectangle(int width, int height) { Width = width; Height = height; } public int Width { get; private set; } public int Height { get; private set; } } public class Square: Rectangle { public Square(int size): base(size, size) { } }
然后,以下所有内容都将正确工作
DrawRectangle(Rectangle.Create(100, 200)); DrawRectangle(new Square(100)); DrawSquare(new Square(100)); Rectangle rectangle = Rectangle.Create(100, 100); bool isSquare = rectangle is Square; // this will be true. // But if we want to use this "square" as a real Square // we will need to cast it. DrawSquare((Square)rectangle);
-
需要可修改?是。
Rectangle(100, 100)
是Square
吗?是。不可能,因为如果用户尝试创建
Width
和Height
为100
的Rectangle
,他将收到一个Square
,然后我们将引入一个 bug,因为用户将无法单独更改属性,即使他要求创建的是Rectangle
而不是Square
。此外,如果他创建了一个Width
为200
,Height
为100
的Rectangle
,然后稍后将Width
调整为100
,该对象将继续是Rectangle
类型,因为在对象创建后,static
类型无法更改。 -
需要可修改?是。
Rectangle(100, 100)
是Square
吗?否。在这种情况下,我们需要使
Rectangle
类型某种程度上意识到它可能是一个Square
(这已经违反了单一职责原则,但没有违反 Liskov 替换原则)。一种可能的解决方案是拥有一个TrySetWidthAndHeight()
方法,而不是让两个属性独立修改。这样,当我们拥有一个Width
和Height
均为100
的Square
时,我们可以将两个值都更改为200
,只需一步操作,因此不会有仅更改Width
时收到异常的风险(在这种情况下,setter 不知道您是否会更改Height
)并且没有Height
在未要求更改时被更改的风险。我们可以说TrySetWidthAndHeight()
解决了问题而没有违反 Liskov 替换原则,因为:- 如果我们设置相等大小的
Width
和Height
值进行重置,Square
可以在更改两个属性之前验证它将继续成为一个有效的square
(因此它永远不会损坏); - 由于该方法是一个
Try
方法,它清楚地表明更改值可能会失败。有些人可能仍然认为行为发生了变化,但如前所述,行为的变化是有效的,只要先决条件不更强或后置条件不更弱。调用TrySetWidthAndHeight()
可能会失败,这一点始终很清楚,所以如果它失败了,那么它是可以预期的。
所以,代码可以是
public class Rectangle { public int Width { get; protected set; } public int Height { get; protected set; } public virtual bool TrySetWidthAndHeight(int width, int height) { Width = width; Height = height; return true; } } public class Square: Rectangle { public override bool TrySetWidthAndHeight(int width, int height) { if (width != height) return false; Width = width; Height = height; return true; } }
- 如果我们设置相等大小的
注意:还有其他可能的示例,例如使Rectangle
和Square
完全独立,或者使Square
成为基类(仅带有一个Size
属性),然后使Rectangle
成为一个“更通用”的Square
,但在这种情况下,我们将不保留Square
是Rectangle
这个想法,因此,即使它解决了编程问题,我认为展示它们也不值得,因为整个想法是使类代表几何形状。
一个更现实的例子
我希望到目前为止的解释足够清楚。我真心希望我能帮助任何因Rectangle
/Square
示例而感到困惑的人,让他们对问题和可能的解决方案有一个真实的认识,但我认为整个情况与我们在实际应用程序中遇到的问题相去甚远,所以我认为最好展示一个在实际编程应用程序时发生的情况。
因此,让我们看一个经常发生的真实编程示例:允许多个子控件的控件。
在 WPF 中,允许这种情况的内置控件是布局控件。它们没有特殊的视觉效果,但它们负责以不同的方式呈现其他控件。Grid
允许您指定行和列的大小(固定大小、比例大小或“最佳匹配”大小),StackPanel
只是将一个控件放在另一个控件之后,水平或垂直方向,而Canvas
允许您指定每个控件的精确位置。
这三个布局控件都基于Panel
类。您可以发现它们的行为完全不同,如果您坚持子类不能改变父类的行为的观点,您可能会认为这是违反了原则,但我应该说事实并非如此。Panel
类对项目如何在屏幕上显示、控件需要填充哪些依赖属性才能正确放置等没有任何保证。一个面板唯一保证的是它可以包含多个控件。我们可以说,如果一个方法期望Panel
作为参数,而不是期望更具体的类,那么它只期望访问已有的控件(例如,计数控件、搜索具有特定名称的子控件等),而不是期望添加一个新控件以正确显示,因为Panel
根本不保证控件会显示出来。
但是,如果我决定创建一个新控件,基于Grid
或Canvas
,并且我完全实现自己的逻辑来显示内部控件,完全忽略已有的逻辑会怎样?
这种情况经常发生,因为开发人员没有正确理解控件的层次结构,所以他们不是直接继承自Panel
类,而是继承自Grid
或Canvas
类(我不知道为什么,但这两个是我看到人们在生成布局控件时最常使用的基类),并完全替换了布局逻辑。
因此,一个期望填充Canvas
(添加许多控件并设置它们的Canvas.Left
和Canvas.Top
属性)的方法可能会收到该替代布局控件的实例,并且所有生成的布局将完全错误,因为子控件不会使用Canvas.Left
和Canvas.Top
属性进行定位(甚至可能使用另一个未设置的附加属性进行定位)。
这是 Liskov 替换原则的一个真正违反。在这种情况下,我们通过直接继承自Panel
来解决它。因此,任何需要Canvas
的方法都不会接受这种新的布局控件(这是可以的),任何只关心列出内部控件的方法仍然可以将新的布局控件作为Panel
接收(这也是可以的),如果有一个方法只是向任何Panel
添加新的子项,那么我们可以说该方法必须被修正为期望一个“最小保证”(即:保证所有控件将通过仅添加它们而不设置任何额外属性来正确显示 [这就是StackPanel
的情况])。
结论
我个人的结论是,该原则并不难遵循,而且许多程序员自然地遵循它,直到他们看到该原则的一个糟糕的例子并感到迷茫。
理解它的许多问题源于这样一个事实:一个句子(“A Square
is a Rectangle
”)被直接翻译为Square
和Rectangle
需要是类,而“is a”意味着继承,但事实并非如此。事实上,将现实世界中的对象翻译到编程世界是非常罕见的,因为总会有不匹配和双重解释,这会在 OOP 中造成问题,而人们通常会试图通过保留这句话来解决问题,而实际上,解决问题更容易从不同角度看待问题,并且为了理解问题,如果展示并正确解决了真实的编程情况,可能会更简单。
最后说明
那些已经看过我高级文章的人有时会给我的基本文章投反对票,说我已经做过更好的了,我应该谈论高级主题。好吧,我期望一篇基本文章被当作一篇基本文章来评判,因为我并没有说它是一篇高级文章。现实情况是,我从灵感出发写作,并且我认为关于这个特定主题的错误信息太多,我真的很想谈谈它。