【译】Understanding SOLID Principles - Liskov Substitution Principle

Understanding SOLID Principles: Liskov Substitution Principle

这是理解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后端时,经常在公司的项目的历史代码中发现,很少使用protectprivate对类内部的方法和变量做约束,可见当时的编写者并没有对类本身的职能有一个清晰的认识,又或者是随着时间一步步迭代出来的结果。

那么问题来了,一些静态语言有这些修饰符,但是像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代表持平

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏landv

C#本质论第四版-1,抄书才能看下去,不然两三眼就看完了,一摞书都成了摆设。抄下了记忆更深刻

4423
来自专栏web前端教室

初学js钻太深,不太好

其实我个人觉得新手不太应该追求彻底的学透每一个知识点。因为初学的时候,钻的太深并不太利于对JS有一个整体的理解。反而有可能钻牛角尖。但这种方法和心态却是必须有的...

2106
来自专栏程序员维他命

出一套 iOS 高级面试题

一千个读者眼中有一千个哈姆雷特,一千名 iOS 程序员心目中就有一千套 iOS 高级面试题。本文就是笔者认为可以用来面试高级 iOS 程序员的面试题。

5052
来自专栏玄魂工作室

Python黑帽编程 2.0 第二章概述

于 20世纪80年代末,Guido van Rossum发明了Python,初衷据说是为了打发圣诞节的无趣,1991年首次发布,是ABC语言的继承,同时也是一...

3667
来自专栏java思维导图

跳槽时,这些Java面试题99%会被问到

工作多年以及在面试中,我经常能体会到,有些面试者确实是认真努力工作,但坦白说表现出的能力水平却不足以通过面试,通常是两方面原因:

2213
来自专栏养码场

限时领取| 6GJavaScript高级视频,高级前端工程师必备武器!

之前场主分享了13G的JavaScript基础视频,共140集实战教学。没想到领取人数竟超过了5000+,着实让场主感受到了JavaScript教程的需求,及还...

842
来自专栏编程

Python大牛告诉你一行代码能干什么?神奇

Python令人着迷的黑魔法。那么我们高效的Python语言一行代码能干什么呢?请先自行脑补! 我们先说说一行代码输出“The Zen of Python”Py...

23710
来自专栏python+iOS学习交流

2018最新最全BAT 全套高级iOS面试题以及面试资料强势来袭

一千个读者眼中有一千个哈姆雷特,一千名 iOS 程序员心目中就有一千套 iOS 高级面试题。本文就是笔者认为可以用来面试高级 iOS 程序员的面试题。

4072
来自专栏平凡文摘

如果电脑技术最初是中国人发明的,那现在编程是不是就是中文的?

1745
来自专栏企鹅号快讯

C语言的前世今生,及其特点、利弊和入门须知三把斧

C语言的开展前史: ? 20世纪70年代初,贝尔实验室的Dennis Richie 等人在B语言基础上开发C语言,最初是作为UNIX的开发语言; 20世纪70年...

2216

扫码关注云+社区

领取腾讯云代金券