SOLID设计原则:里式替换原则

你好,我是猿java。

前面几篇文章,我们介绍了 SOLID原则的单一职责原则和开闭原则,单一职责描述的模块需要对一类行为负责,开闭原则描述的是对扩展开放,对修改关闭。今天我们就来聊聊SOLID的第三个原则:Liskov替换原则。

什么是里式替换原则?

里式替换原则,Liskov substitution principle(简称LSP),它是以作者 Barbara Liskov(一位美国女性计算机科学家,对编程语言和分布式计算做出了开创性的贡献,于2008年获得图灵奖)的名字命名的,Barbara Liskov 曾在1987年的会议主题演讲“数据抽象”中描述了子类型:

1
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

Liskov替换原则的核心:设Φ(x)是关于 T类型对象 x的可证明性质。那么对于 S类型的对象 y,Φ(y)应该为真,其中 S是 T的子类型。

这种科学的定义是不是过于抽象,太烧脑了?因此,在实际软件开发中的 Liskov替换原则可以这样:

1
2
The principle defines that objects of a superclass shall be replaceable with objects of its subclasses without breaking the application.
That requires the objects of your subclasses to behave in the same way as the objects of your superclass.

该原则定义了在不破坏应用程序的前提下,超类的对象应该可以被其子类的对象替换,这就要求子类对象的行为方式与您的超类对象相同。

Robert C. Martin 对SLP的描述更加直接:

1
Subtypes must be substitutable for their base types.

子类型必须可以替代它们的基本类型。

通过上面几个描述,我们可以把 LSP通俗的表达成:子类型必须能够替换其父类。

如何实现Liskov替换原则?

说起 Liskov替换原则的实现,就不得不先看一个著名的违反 LSP设计案例:正放形/长方形问题。尽管这个 case已经有点老掉牙,但是为了帮助理解,我们还是炒一次剩饭。

数学知识告诉我们:正方形是一种特殊的长方形,因此用 java代码分别定义 Rectangle(长方形) 和 Square(正方形)两个类,并且 Square继承 Rectangle,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Rectangle(长方形)
public class Rectangle {
private int length;
private int width;
public void setLength(double length) {
this.length = length;
}

public void setWidth(double width) {
this.width = width;
}
}

// Square(正方形)
public class Square extends Rectangle {
// 设置边长
@Override
public void setLength(double length) {
super.setLength(length);
super.setWidth(length);
}

@Override
public void setWidth(double width) {
super.setLength(width);
super.setWidth(width);
}
}

假设现在的需求是计算几何图形的面积,因此面积计算代码会如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 计算面积
public int area(){
Rectangle r = new Square();
// 设置长度
r.setLength(3);
// 设置宽度
r.setWidth(4);

r.getLength * r.getWidth = 3 * 4 = 12;

// 正方形
Rectangle r = new Rectangle();
// 设置长度
r.setLength(3); // Length=3, Width=3
// 设置宽度
r.setWidth(4); // Length=4, Width=4

r.getLength * r.getWidth = 4 * 4 = 16;
}

在这个例子中,Square类重写了 setLength和 setWidth方法,以确保正方形的长度和宽度总是相等的。因此:假设 length=3,width=4

  • 对于长方形,面积 = length * width= 3 * 4 = 12,符合预期;
  • 然而,用 Square对象替换 Rectangle对象时,程序的行为发生了变化,本期望矩形的面积为12(3 * 4),但实际输出为 4*4=16,违反了里氏替换原则。

如何解决这个 bad case呢?

可以定义一个几何图形的接口,设定一个计算面积的方法,然后长方形、正方形都实现这个接口,实现各自的面积计算逻辑,整体思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 基类
public interface Geometry{
int area();
}

public class Rectangle implements Geometry{
private int length;
private int width;
public int area(){
return length * width;
}
}

public class Square implements Geometry{
private int side;
public int area(){
return side * side;
}
}

我们再来看一个 LSP使用的例子:

假设有一个股票交易的场景,而且需要支持债券、股票和期权等不同证券类型的多种交易类型,我们就可以考虑使用 LSP来解决这个问题。

首先,我们定义一个交易的基类,并且在基类中定义买入和卖出两个方法实现,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个交易类
public class Transaction{
// 买进操作
public void buy(String stock, int quantity, float price){

}

// 卖出操作
public void sell(String stock, int quantity, float price){

}
}

接着,定义一个子类:股票交易,它和基类具有相同的买入和卖出行为,因此,在股票交易子类中需要重写基类的方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义股票交易子类,定义股票特定的买卖动作逻辑
public class StockTransaction extends Transaction{

@Override
public void buy(String stock, int quantity, float price){

}
@Override
public void sell(String stock, int quantity, float price){

}
}

同样,定义一个子类:基金交易,它和基类具有相同的买入和卖出行为,因此,在基金交易子类中需要重写基类的方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义基金交易子类,定义基金特定的买卖动作逻辑
public class FundTransaction extends Transaction{

@Override
public void buy(String stock, int quantity, float price){

}
@Override
public void sell(String stock, int quantity, float price){

}
}

同样,我们还可以定义了债券交易子类,债券交易和交易基类具有相同的行为:买入和卖出。所以只需要重写基类的方法,实现子类特定的实现就ok了。

1
2
3
4
5
6
7
8
9
10
11
12
// 定义债券交易子类,定义债券特定的买卖动作逻辑
public class BondTransaction extends Transaction{

@Override
public void buy(String stock, int quantity, float price){

}
@Override
public void sell(String stock, int quantity, float price){

}
}

上述交易的案例,股票交易和基金交易子类替换基类之后,并没有破坏基类的买入卖出行为,更具体地说,替换的子类实例仍提供 buy()和 sell(),可以以相同方式调用的功能。这个符合LSP。

经过我们的抽象、分离和改造之后,Stock.updateStock()类就稳定下来了,再也不需要增加一个事件然后增加一个else if分支处理。这种抽象带来的好处也是很明显的:每次有新的库存变更事件,只需要增加一个实现类,其他的逻辑都不需要更改,当库存事件无效时只需要把实现类删除即可。

总结

Liskov替换原则扩展了OCP开闭原则,它描述的子类型必须能够替换其父类型,而不会破坏应用程序。因此,子类需要遵循以下规则:

  • 不要对输入参数实施比父类实施更严格的验证规则。
  • 至少对父类应用的所有输出参数应用相同的规则。

Liskov替换原则相对前面的单一职责和开闭原则稍微晦涩一些,因此在开发中容易误用,因此我们特别要注意类之间是否存在继承关系。

LSP不仅可以用在类关系上,也可以应用在接口设计中。

学习交流

如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。

drawing