00.3 ANDROID 面向对象的六大原则——里氏替换原则

返回设计模式博客目录
|
第一篇:单一职责原则
第二篇:开闭原则
第三篇:里氏替换原则
第四篇:依赖倒置原则
第五篇:接口隔离原则
第六篇:迪米特原则

前一篇博文中的图片加载器项目,MemoryCache、DiskCache、DoubleCache 都可以替换 ImageCache 的工作,并且能够保证行为的正确性。ImageCache 建立了获取缓存图片、保存缓存图片的接口规范,MemoryCache 等根据接口规范实现了相应的功能,用户只需要在使用时指定具体的缓存对象就可以动态地替换 ImageLoader 中的缓存策略。这就使得 ImageLoader 的缓存系统具有了无限的可能性,也就是保证了可扩展性。

想象一种情况,当 ImageLoader 中的 setImageCache(ImageCache cache) 中的 cache 对象不能被子类所替换,那么用户如何设置不同的缓存对象,以及用户如何定义自己的缓存实现,通过前文中的 useDiskCache 方法吗?显然不是的,里氏替换原则就为这类问题提供了指导原则,也就是建立抽象,通过抽象建立规范,具体的实现在运行时替换掉抽象,保证系统的扩展性、灵活性。

开闭原则和里氏替换原则往往是生死相依、不离不弃的,通过里氏替换来达到对扩展开放,对修改关闭的效果。然而,这两个原则都同时强调了一个 OOP 的重要特性——抽象。因此,在开发过程中运用抽象是走向代码优化的重要一步。


定义


里氏替换原则(Liskov Substitution Principle,LSP)有两种定义:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为 S 的对象 o1,都有类型为 T 的对象 o2,使得以 T 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 S 是类型 T 的子类型。)

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。)


问题


里氏替换原则的核心原理是抽象,抽象又依赖于继承这个特性。继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。

继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。

问题描述:有一功能 P1,由类 A 完成。现需要将功能 P1 进行扩展,扩展后的功能为 P,其中 P 由原有功能 P1 与新功能 P2 组成。新功能 P 由类 A 的子类 B 来完成,则子类 B 在完成新功能 P2 的同时,有可能会导致原有功能 P1 发生故障。

解决方案:当使用继承时,遵循里氏替换原则。类 B 继承类 A 时,除添加新的方法完成新增功能 P2 外,尽量不要重写父类 A 的方法,也尽量不要重载父类 A 的方法。

举例说明:我们需要完成一个两数相减的功能 P1,由类 A 来负责。

1
2
3
4
5
6
7
8
9
10
11
12
13
class A {
public int func1(int a, int b) {
return a - b;
}
}
public class Client {
public static void main(String[] args) {
A a = new A();
System.out.println("100-50=" + a.func1(100, 50));
System.out.println("100-80=" + a.func1(100, 80));
}
}

运行结果:
100-50=50
100-80=20

后来,我们需要增加一个新的功能 P2:完成两数相加,然后再与100求和,由类 B 来负责。即类 B 需要完成两个功能:

  • P1:两数相减。
  • P2:两数相加,然后再加100。

由于类 A 已经实现了第一个功能,所以类 B 继承类 A 后,只需要再完成第二个功能就可以了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class B extends A {
public int func1(int a, int b) {
return a + b;
}
public int func2(int a, int b) {
return func1(a, b) + 100;
}
}
public class Client {
public static void main(String[] args) {
B b = new B();
System.out.println("100-50=" + b.func1(100, 50));
System.out.println("100-80=" + b.func1(100, 80));
System.out.println("100+20+100=" + b.func2(100, 20));
}
}

运行结果:
100-50=150
100-80=180
100+20+100=220

我们发现原本运行正常的相减功能发生了错误。原因就是类 B 在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类 B 重写后的方法,造成原本运行正常的功能出现了错误。

在本例中,引用基类 A 完成的功能,换成子类 B 之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
  • 子类中可以增加自己特有的方法;
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松;
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

应用


ANDROID 中的 Window 与 View 的关系,可以写成一个简单的示例,其 UML 图如下所示。

具体的代码实现:

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
// 窗口类
public class Window {
public void show(View child) {
child.draw();
}
}
// 建立视图抽象,测量视图的宽高为公用代码,绘制实现交给具体的子类
public abstract class view {
public abstract void draw();
public void measure(int width, int height) {
// 测量视图大小
}
}
// 文本控件类的具体实现
public class TextView extends View {
public void draw() {
// 绘制文本
}
}
// ImageView 的具体实现
public class ImageView extends View {
public void draw() {
// 绘制图片
}
}

上述示例中,Window 依赖于 View,而 View 定义了一个视图抽象,measure 是各个子类共享的方法,子类通过覆写 View 的 draw 方法实现具有各自特色的功能。在这里,这个功能就是绘制自身的内容。任何继承自 View 的子类都可以传递给 show 函数,就是所说的里氏替换。通过里氏替换,就可以自定义各式各样、千变万化的 View,然后传递给 Window,Window 负责组织 View,并将 View 显示到屏幕上。