这是理解
SOLID
原则,关于
里氏替换原则为什么提倡我们面向抽象层编程而不是具体实现层,以及为什么这样可以使代码更具维护性和复用性。
什么是里氏替换原则
Objects should be replaceable with instances of their subtypes without altering the correctness of that program.某个对象实例的子类实例应当可以在不影响程序正确性的基础上替换它们。
这句话的意思是说,当我们在传递一个父抽象的子类型时,你需要保证你不会修改任何关于这个父抽象的行为和状态语义。
如果你不遵循里氏替换原则,那么你可能会面临以下问题:
- 类继承会变得很混乱,因此奇怪的行为会发生
- 对于父类的单元测试对于子类是无效的,因此会降低代码的可测试性和验证程度
通常打破这条原则的情况发生在修改父类中在其他方法中使用的,与当前子类无关联的内部或者私有变量。这通常算得上是一种对于类本身的一次潜在攻击,而且这种攻击可能是你在不经意间自己发起的,而且不仅在子类中。
反面例子
让我们通过一个反面例子来演示这种修改行为和它所产生的后果。比如,我们有一个关于Store
的抽象类和它的实现类BasicStore
,这个类会储存一些消息在内存中,直到储存的个数超过每个上限。客户端代码的实现也很简单明了,它期望通过调用retrieveMessages
就可以获取到所有储存的消息。
代码如下:
interface Store {
store(message: string);
retrieveMessages(): string[];
}
const STORE_LIMIT = 5;
class BasicStore implements Store {
protected stash: string[] = [];
protected storeLimit: number = STORE_LIMIT;
store(message: string) {
if (this.storeLimit === this.stash.length) {
this.makeMoreRoomForStore();
}
this.stash.push(message);
}
retrieveMessages(): string[] {
return this.stash;
}
makeMoreRoomForStore(): void {
this.storeLimit += 5;
}
}
之后通过继承BasicStore
,我们又创建了一个新的RotatingStore
实现类,如下:
class RotatingStore extends BasicStore {
makeMoreRoomForStore() {
this.stash = this.stash.slice(1);
}
}
注意RotatingStore
中覆盖父类makeMoreRoomForStore
方法的代码以及它是如何隐蔽地改变了父类BasicStore
关于stash
的状态语义的。它不仅修改了stash
变量,还销毁了在程序进程中已储存的消息已为将来的消息提供额外的空间。
在使用RotatingStore
的过程中,我们会遇到一些奇怪的现象,这正式由于RotatingStore
本身产生的,如下:
const st: Store = new RotatingStore()
st.store("hello")
st.store("world")
st.store("how")
st.store("are")
st.store("you")
st.store("today")
st.store("sir?")
st.retrieveMessages() // 一些消息丢失了
一些消息会无故消失,当前这个类的表现逻辑与所有消息均可以被取出的基本需求不一致。
如何实践里氏替换原则
为了避免这种奇怪现象的发生,里氏替换原则推荐我们通过在子类中调用父类的公有方法来获取一些内部状态变量,而不是直接使用它。这样我们就可以保证父类抽象中正确的状态语义,从而避免了副作用和非法的状态转变。
它也推荐我们应当尽可能的使基本抽象保持简单和最小化,因为对于子类来说,有助于提供父类的扩展性。如果一个父类是比较复杂的,那么子类在覆盖它的时候,在不影响父类状态语义的情况下进行扩展绝非易事。
对于内部系统做可行的后置条件检查也是一个不错的方式,这种检查通常会验证是否子类会搅乱一些关键代码的运行路径(译者注:也可以理解为状态语义),但是我本身对这个实践并没有太多的经验,所以无法给予具体的例子。
代码评论也可以一定程度上给予好的帮助。当你在开发一些你可能无意间做出一些对已有系统的破坏,但是你的同事可能会很容易地发现这些(当局者迷旁观者清)。软件设计保持一致性是一件十分重要的事情,因此应当尽早、尽可能多地查明那些对对象继承链作出潜在修改的代码。
最后,在单一职责原则中,我们曾提及,考虑使用组合模式来替换继承模式。
总结
正如你所看到的,在开发软件时,我们往往需要额外花一些努力和精力来使它变得更好。将这些原则牢记于心,理解它们所存在的意义以及它们想要解决的问题,这样会使你的工作变得更加容易、更具条理性,但是同时记住,这并不是一件容易的事,相反,你应当在构思软件时,花相当多的事件思考如何更好地实践这些原则。
试着让自己设计的软件系统具备可适应性,这种适应性可以抵御各种不利的变化以及潜在的错误,这样自然而然地可以使你少加班和早回家(译者注:看来加班是每个程序员都要面临的问题啊)
译者注
这是SOLID原则中我所接触和了解较少的一个原则,但经过仔细思考后,发现其实我们还是经常会在实际工作中运用它的。
在许多面向相对的编程语言中,关于对象的继承机制中,都会提供一些内部变量和状态的修饰符,比如public(公有)
、protect(保护)
和private(私有)
,关于这些修饰符本身的异同这里不再赘述,我想说的是,这些修饰符存在必然有它存在的意义,一定要在实际工作中,使用它们。之前做java后端时,经常在公司的项目的历史代码中发现,很少使用protect
和private
对类内部的方法和变量做约束,可见当时的编写者并没有对类本身的职能有一个清晰的认识,又或者是随着时间一步步迭代出来的结果。
那么问题来了,一些静态语言有这些修饰符,但是像javascript
这种鸭子类型语言怎么办呢?其实没有必要担心,最早开始学前端的时候,这个问题我就问过自己无数次,javascript
虽然没有这些修饰符,但是我们可以通过别的方式来达到类似的效果,或者使用typescript
。
除了在编程语言层面,在前端实际工作中,你可能会听到一个叫作immutable
的概念,这个概念我认为也是里氏替换原则的一直延伸。因为当前的前端框架一般提倡的理念均是f(state) => view
,即数据状态代表视图,而数据状态本身由于javascript
动态语言的特性,很容易会在不经意间被修改,一旦存在这种修改,视图中便会产生一些意想不到的问题,因此immutable
和函数式
的概念才会在前段时间火起来。
写在最后
经过这五篇文章,我们来分别总结一下这五条基本原则以及它们带来的好处:
- 单一职责原则:提高代码实现层的内聚度,降低实现单元彼此之间的耦合度
- 开闭原则:提高代码实现层的可扩展性,提高面临改变的可适应性,降低修改代码的冗余度
- 里氏替换原则:提高代码抽象层的可维护性,提高实现层代码与抽象层的一致性
- 接口隔离原则:提高代码抽象层的内聚度,降低代码实现层与抽象层的耦合度,降低代码实现层的冗余度
- 依赖倒置原则:降低代码实现层由依赖关系产生的耦合度,提高代码实现层的可测试性
可以注意到我这里刻意使用了降低/提高 + 实现层/抽象层 + 特性/程度(耦合度、内聚度、扩展性、冗余度、可维护性,可测试性)
这样的句式,之所以这么做是因为在软件工作中,我们理想中的软件应当具备的特点是, 高内聚、低耦合、可扩展、少冗余、可维护、易于测试,而这五个原则也按正确的方向,将我们的软件系统向我们理想中的标准推进。
为了便于对比,特别绘制了下面的表格,希望大家从真正意义上做到将这些原则牢记于心,并付诸于行。
原则 | 耦合度 | 内聚度 | 扩展性 | 冗余度 | 维护性 | 测试性 | 适应性 | 一致性 |
---|---|---|---|---|---|---|---|---|
单一职责原则 | - | + | o | o | + | + | o | o |
开闭原则 | o | o | + | - | + | o | + | o |
里氏替换原则 | - | o | o | o | + | o | o | + |
接口隔离原则 | - | + | o | - | o | o | + | o |
依赖倒置原则 | - | o | o | - | o | + | + | o |
Note: +
代表增加, -
代表降低, o
代表持平
关注公众号 全栈101,只谈技术,不谈人生