9 LSP: The Liskov substitution principle
里氏替换原则(LSP)
1988 年,芭芭拉·利斯科夫(Barbara Liskov)提出了如下定义,用于描述子类型。
我们所需要的是一种类似如下的替换性质:如果对于类型 S 的每一个对象 o1,都存在一个类型 T 的对象 o2,使得在所有基于 T 定义的程序 P 中,用 o1 替换 o2 后,程序 P 的行为均不发生改变,那么 S 就是 T 的子类型。
要理解这一被称为里氏替换原则(Liskov Substitution Principle, LSP)的思想,我们来看几个例子。
指导继承的使用
假设我们有一个名为 License(许可证)的类,如图 9.1 所示。该类包含一个 calcFee() 方法,由计费应用程序调用。License 有两个“子类型”:PersonalLicense(个人许可证)和 BusinessLicense(商业许可证),二者使用不同算法计算许可费用。
图 9.1 许可证及其派生类符合里氏替换原则
这一设计符合里氏替换原则,因为计费应用的行为完全不依赖于它具体使用的是哪一个子类型。两个子类型都可以无缝替换 License 类型。
正方形/矩形问题
违反里氏替换原则最经典的例子,就是著名(也可以说是臭名昭著)的正方形-矩形问题(图 9.2)。
图 9.2 臭名昭著的正方形-矩形问题
在这个例子中,Square(正方形)并不是 Rectangle(矩形)的合理子类型。因为矩形的宽和高可以独立修改,而正方形的宽和高必须同步变化。当使用者以为自己在操作一个矩形时,很容易出现逻辑错误。
下面这段代码说明了原因:
Rectangle r = …
r.setW(5);
r.setH(2);
assert(r.area() == 10);
如果 … 处实际创建的是一个正方形,那么这条断言就会失败。
避免这类里氏替换原则被破坏的唯一办法,是在使用者代码中增加判断逻辑(例如 if 语句),检测这个矩形实际上是不是正方形。而一旦程序行为依赖于具体类型,这些类型就不再具备可替换性。
里氏替换原则与架构设计
在面向对象思想发展初期,我们只把 LSP 看作指导继承使用的原则,正如前面几节所示。但多年以来,LSP 已经演变为一个更宽泛的软件设计原则,适用于接口与实现的关系。
这里所说的接口可以有多种形式:可以是 Java 风格、由多个类实现的接口;也可以是拥有相同方法签名的多个 Ruby 类;还可以是一组遵循相同 REST 接口的服务。
在所有这些场景中,里氏替换原则都适用,因为存在依赖于清晰定义接口、并依赖于该接口各实现可替换性的使用者。
从架构层面理解 LSP 的最好方式,就是看违反该原则会对系统架构造成什么后果。
里氏替换原则违反示例
假设我们正在开发一个聚合多家出租车调度服务的系统。用户通过我们的网站选择最合适的出租车,而不关心具体是哪家公司。用户选定后,系统会通过 REST 服务调度选中的车辆。
现在假设调度服务的 URI 是司机数据库中的一项信息。系统为用户匹配到合适的司机后,会从司机记录中获取该 URI,并据此发起调度。
例如,司机鲍勃的调度 URI 如下:
purplecab.com/driver/Bob
系统会将调度信息拼接在 URI 后,使用 PUT 请求发送,类似这样:
purplecab.com/driver/Bob
/pickupAddress/24 Maple St.
/pickupTime/153
/destination/ORD
显然,这意味着所有出租车公司的调度服务都必须遵循同一套 REST 接口规范:它们必须以相同方式处理 pickupAddress(上车地址)、pickupTime(上车时间)和 destination(目的地)字段。
现在假设阿克米(Acme)出租车公司的程序员没有仔细阅读规范,他们把 destination 字段缩写成了 dest。而阿克米是当地最大的出租车公司,再加上一些复杂的人情关系……可想而知,我们的系统架构会受到怎样的影响?
很明显,我们必须加入特殊处理逻辑。针对阿克米司机的调度请求,需要用与其他所有司机完全不同的规则来构造。
最简单的实现方式,是在构造调度指令的模块中加入一条 if 判断:
if (driver.getDispatchUri().startsWith("acme.com")) …
但任何称职的架构师都不会允许系统中出现这种代码。把“acme”硬编码进程序,会埋下各种诡异、隐蔽的错误隐患,更不用说安全漏洞。
举个例子:如果阿克米进一步扩张,收购了紫出租车公司,而新公司保留原有品牌与独立网站,但统一了后台系统,我们难道要再为“purple”多加一条 if 吗?
架构师必须设计一套机制保护系统免受此类问题影响,例如创建一个调度指令构造模块,通过一个以调度 URI 为键的配置库来驱动逻辑。
配置数据大致会长这样:
URI 调度格式
Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
*.* /pickupAddress/%s/pickupTime/%s/destination/%s
于是,因为 REST 服务接口不具备可替换性,架构师不得不额外增加一套复杂而重要的机制来弥补。
结论
里氏替换原则可以,也应当被延伸到架构层面。一个简单的可替换性破坏,就可能导致系统架构被大量额外机制“污染”。

浙公网安备 33010602011771号