You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

198 lines
11 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 13丨软件设计的里氏替换原则正方形可以继承长方形吗
我们都知道,面向对象编程语言有三大特性:封装、继承、多态。这几个特性也许可以很快就学会,但是如果想要用好,可能要花非常多的时间。
通俗地说,接口(抽象类)的多个实现就是多态。多态可以让程序在编程时面向接口进行编程,在运行期绑定具体类,从而使得类之间不需要直接耦合,就可以关联组合,构成一个更强大的整体对外服务。绝大多数设计模式其实都是利用多态的特性玩的把戏,前面两篇学习的开闭原则和依赖倒置原则也是利用多态的特性。正是多态使得编程有时候像变魔术,如果能用好多态,可以说掌握了大多数的面向对象编程技巧。
封装是面向对象语言提供的特性将属性和方法封装在类里面。用好封装的关键是知道应该将哪些属性和方法封装在某个类里。一个方法应该封装进A类里还是B类里这个问题其实就是如何进行对象的设计。深入研究进去里面也有大量的学问。
继承似乎比多态和封装要简单一些,但实践中,继承的误用也很常见。
## 里氏替换原则
关于如何设计类的继承关系怎样使继承不违反开闭原则实际上有一个关于继承的设计原则叫里氏替换原则。这个原则说若对每个类型T1的对象o1都存在一个类型T2的对象o2使得在所有针对T2编写的程序P中用o1替换o2后程序P的行为功能不变则T1是T2的子类型。
上面这句话比较学术,通俗地说就是:**子类型必须能够替换掉它们的基类型**。
再稍微详细点说,就是:程序中,所有使用基类的地方,都应该可以用子类代替。
语法上,任何类都可以被继承。但是一个继承是否合理,从继承关系本身是看不出来的,需要把继承放在应用场景的上下文中去判断,使用基类的地方,是否可以用子类代替?
这里有一个马的继承设计:
![](https://static001.geekbang.org/resource/image/98/e7/984d7307bb1af311ba1bbcd92dee15e7.png)
白马和小马驹都是马,所以都继承了马。这样的继承是不是合理呢?我们需要放到应用场景中:
![](https://static001.geekbang.org/resource/image/4e/e6/4ec36b118e23d98732a68d26bb5a2de6.png)
在这个场景中,是人骑马。根据这里的关系,继承了马的白马和小马驹,应该都可以代替马。白马代替马当然没有问题,人可以骑白马,但是小马驹代替马可能就不合适了,因为小马驹还没长好,无法被人骑。
那么很显然,作为子类的白马可以替换掉基类马,但是小马不能替换马,因此小马继承马就不太合适了,违反了里氏替换原则。
## 一个违反里氏替换规则的例子
我们再看这样一段代码:
```
void drawShape(Shape shape) {
if (shape.type == Shape.Circle ) {
drawCircle((Circle) shape);
} else if (shape.type == Shape.Square) {
drawSquare((Square) shape);
} else {
……
}
}
```
这里Circle和Square继承了基类Shape然后在应用的方法中根据输入Shape对象类型进行判断根据对象类型选择不同的绘图函数将图形画出来。这种写法的代码既常见又糟糕它同时违反了开闭原则和里氏替换原则。
首先看到这样的if/else代码就可以判断违反了开闭原则当增加新的Shape类型的时候必须修改这个方法增加else if代码。
其次也因为同样的原因违反了里氏替换原则当增加新的Shape类型的时候如果没有修改这个方法没有增加else if代码那么这个新类型就无法替换基类Shape。
要解决这个问题其实也很简单只需要在基类Shape中定义draw方法所有Shape的子类Circle、Square都实现这个方法就可以了
```
public abstract Shape{
public abstract void draw();
}
```
上面那段drawShape()代码也就可以变得更简单:
```
void drawShape(Shape shape) {
shape.draw();
}
```
这段代码既满足开闭原则:增加新的类型不需要修改任何代码。也满足里氏替换原则:在使用基类的这个方法中,可以用子类替换,程序正常运行。
## 正方形可以继承长方形吗?
一个继承设计是否违反里氏替换原则,需要在具体场景中考察。我们再看一个例子,假设我们现在有一个长方形的类,类定义如下:
```
public class Rectangle {
private double width;
private double height;
public void setWidth(double w) { width = w; }
public void setHeight(double h) { height = h; }
public double getWidth() { return width; }
public double getHeight() { return height; }
public double calculateArea() {return width * height;}
}
```
这个类满足我们的应用场景,在程序中多个地方被使用,一切良好。但是现在,我们有个新需求,我们还需要一个正方形。
通常我们判断一个继承是否合理会使用“IS A”进行判断类B可以继承类A我们就说类B IS A 类A比如白马IS A 马,轿车 IS A 车。
那正方形是不是IS A长方形呢通常我们会说正方形是一种特殊的长方形是长和宽相等的长方形从这个角度讲那么正方形IS A长方形也就是可以继承长方形。
具体实现上,我们只需要在设置长方形的长或宽的时候,同时设置长和宽就可以了,如下:
```
public class Square extends Rectangle {
public void setWidth(double w) {
width = height = w;
}
public void setHeight(double h) {
height = width = w;
}
}
```
这个正方形类设计看起来很正常,用起来似乎也没有问题。但是,真的没有问题吗?
继承是否合理我们需要用里氏替换原则来判断。之前也说过,是否合理并不是从继承的设计本身看,而是从应用场景的角度看。如果在应用场景中,也就是在程序中,子类可以替换父类,那么继承就是合理的,如果不能替换,那么继承就是不合理的。
这个长方形的使用场景是什么样的呢,我们看使用代码:
```
void testArea(Rectangle rect) {
rect.setWidth(3);
rect.setHeight(4);
assert 12 == rect.calculateArea();
}
```
显然在这个场景中如果用子类Square替换父类Rectangle计算面积calculateArea将返回16而不是12程序是不能正确运行的这样的继承不满足里氏替换原则是不合适的继承。
## 子类不能比父类更严格
类的公有方法其实是对使用者的一个契约,使用者按照这个契约使用类,并期望类按照契约运行,返回合理的值。
当子类继承父类的时候,根据里氏替换原则,使用者可以在使用父类的地方使用子类替换,那么从契约的角度,子类的契约就不能比父类更严格,否则使用者在用子类替换父类的时候,就会因为更严格的契约而失败。
在上面这个例子中,正方形继承了长方形,但是正方形有比长方形更严格的契约,即正方形要求长和宽是一样的。因为正方形有比长方形更严格的契约,那么在使用长方形的地方,正方形因为更严格的契约而无法替换长方形。
我们开头小马继承马的例子也是如此,小马比马有更严格的要求,即不能骑,那么小马继承马就是不合适的。
在类的继承中如果父类方法的访问控制是protected那么子类override这个方法的时候可以改成是public但是不能改成private。因为private的访问控制比protected更严格能使用父类protected方法的地方不能用子类的private方法替换否则就是违反里氏替换原则的。相反如果子类方法的访问控制改成public就没问题即子类可以有比父类更宽松的契约。同样子类override父类方法的时候不能将父类的public方法改成protected否则会出现编译错误。
通常说来,子类比父类的契约更严格,都是违反里氏替换原则的。
子类不应该比父类更严格,这个原则看起来既合理又简单,但是在实际中,如果你不严谨地审视自己的设计,是很可能违背里氏替换原则的。
在JDK中类Properties继承自类Hashtable类Stack继承自Vector。
![](https://static001.geekbang.org/resource/image/48/c4/489a0c187c2f89ca7aee52cba59816c4.png)
这样的设计其实是违反里氏替换原则的。Properties要求处理的数据类型是String而它的父类Hashtable要求处理的数据类型是Object子类比父类的契约更严格Stack是一个栈数据结构数据只能后进先出而它的父类Vector是一个线性表子类比父类的契约更严格。
这两个类都是从JDK1就已经存在的我想如果能够重新再来JDK的工程师一定不会这样设计。这也从另一个方面说明不恰当的继承是很容易就发生的设计继承的时候需要更严谨的审视。
## 小结
实践中,当你继承一个父类仅仅是为了复用父类中的方法的时候,那么很有可能你离错误的继承已经不远了。一个类如果不是为了被继承而设计,那么最好就不要继承它。粗暴一点地说,如果不是抽象类或者接口,最好不要继承它。
如果你确实需要使用一个类的方法,最好的办法是组合这个类而不是继承这个类,这就是人们通常说的**组合优于继承**。比如这样:
```
Class A{
public Element query(int id){...}
public void modify(Element e){...}
}
Class B{
private A a;
public Element select(int id){
a.query(id);
}
public void modify(Element e){
a.modify(e);
}
}
```
如果类B需要使用类A的方法这时候不要去继承类A而是去组合类A也能达到使用类A方法的效果。这其实就是**对象适配器模式**了使用这个模式的话类B不需要继承类A一样可以拥有类A的方法同时还有更大的灵活性比如可以改变方法的名称以适应应用接口的需要。
当然,继承接口或者抽象类也并不保证你的继承设计就是正确的,最好的方法还是用里氏替换原则检查一下你的设计:使用父类的地方是不是可以用子类替换?
违反里氏替换原则不仅仅发生在设计继承的地方,也可能发生在使用父类和子类的地方,错误的使用方法,也可能导致程序违反里氏替换原则,使子类无法替换父类。
## 思考题
下面给你留一道思考题吧。
父类中有抽象方法f抛出异常AException
```
public abstract void f() throws AException;
```
子类override父类这个方法后想要将抛出的异常改为BException那么BException应该是AException的父类还是子类
为什么呢?请你用里氏替换原则说明,并在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起交流一下。