我听说利斯科夫替换原则(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得到维护的有价值的工具。
其他回答
A square is a rectangle where the width equals the height. If the square sets two different sizes for the width and height it violates the square invariant. This is worked around by introducing side effects. But if the rectangle had a setSize(height, width) with precondition 0 < height and 0 < width. The derived subtype method requires height == width; a stronger precondition (and that violates lsp). This shows that though square is a rectangle it is not a valid subtype because the precondition is strengthened. The work around (in general a bad thing) cause a side effect and this weakens the post condition (which violates lsp). setWidth on the base has post condition 0 < width. The derived weakens it with height == width.
因此,可调整大小的正方形不是可调整大小的矩形。
简单来说,LSP是指同一超类的对象应该能够在不破坏任何东西的情况下相互交换。
例如,如果我们有一个从Animal类派生的Cat和Dog类,那么任何使用Animal类的函数都应该能够使用Cat或Dog,并且行为正常。
利斯科夫替换原则(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下,用另一个接口的实现替换一个接口的实现。正是这一原则使我们能够解决未来出现的需求,即使我们今天不能预见它们。
If we unplug the computer from the wall (Implementation), neither the wall outlet (Interface) nor the computer (Client) breaks down (in fact, if it’s a laptop computer, it can even run on its batteries for a period of time). With software, however, a client often expects a service to be available. If the service was removed, we get a NullReferenceException. To deal with this type of situation, we can create an implementation of an interface that does “nothing.” This is a design pattern known as Null Object,[4] and it corresponds roughly to unplugging the computer from the wall. Because we’re using loose coupling, we can replace a real implementation with something that does nothing without causing trouble.
我在每个答案中都看到了矩形和正方形,以及如何违反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 !
}
}
现在子类型不能以同样的方式使用,因为它们不再产生相同的结果。
设q(x)是关于类型为T的x的对象的可证明属性,那么q(y)对于类型为S的对象y应该是可证明的,其中S是T的子类型。
实际上,公认的答案并不是利斯科夫原理的反例。正方形自然是一个特定的矩形,因此从类矩形继承是完全有意义的。你只需要以这样的方式实现它:
@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的值。
推荐文章
- 合并两个PHP对象的最佳方法是什么?
- 继承和组合的区别
- 如何在PostgreSQL中查看视图的CREATE VIEW代码?
- 打印Python类的所有属性
- 面向对象编程,函数式编程,过程式编程
- 面向对象的Javascript最佳实践?
- 为什么我更喜欢使用成员初始化列表?
- 如何让PHP类构造函数调用父类的父类构造函数?
- 理解__getattr__和__getattribute__之间的区别
- 让setter返回"this"是不好的做法吗?
- JavaScript中的类与静态方法
- 聚合、组合和依赖之间的区别是什么?
- Javascript是基于原型的语言,这意味着什么?
- 在类方法上使用property()
- 子类继承私有字段吗?