《设计模式之美》学习笔记

时光不语,静等花开。

当你的能力撑不起你的野心的时候,当你感到怀才不遇的时候,当你迷茫找不着方向的时候,你只需要努力、坚持,再努力、再坚持,慢慢地,你就会变得越来越强大,方向就会变得越来越清晰,机会就会越来越青睐你。


说明

本文是在 极客时间 APP 学习 王争 老师专栏 《设计模式之美》的学习笔记。



1. 开篇词 & 设计模式导读

为什么要学设计模式?

  • 数据结构与算法是为了写出高效的代码;设计模式为了写出高质量的代码
  • 写代码是程序员的看家本领,要做更优秀的人,要写出“好用”的代码,而不仅仅是“能用”的代码
  • 当熟练掌握编写高质量代码的技巧、方法和理论后,写烂代码和好代码的时间基本相同
  • 项目的代码质量可能因为各种原因有所妥协,但起码要了解高质量代码的样子,具备写出高质量代码的能力
  • 让读源码、学框架事半功倍
  • 应对面试,职级进阶、招聘人员、带领新手

如何评价代码质量的高低?

  • 可维护性
    • 是否能够在 不破坏原有代码设计、不引入新BUG的情况下,快速的修改或添加代码
  • 可读性
    • “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” —— Martin Fowler
    • 代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否高内聚低耦合等
  • 可扩展性
    • 在不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码
  • 灵活性
    • 一段代码是否易扩展、易复用 或者 易使用
  • 简洁性
    • 尽量保持代码简单、逻辑清晰。是否易读、易维护
    • 思从深而行从简,真正的高手能云淡风轻地用最简单的方法解决最复杂的问题。
  • 可复用性
    • 尽量减少重复代码的编写,复用已有的代码
  • 可测试性
    • 是否易写单元测试

面向对象、设计原则、设计模式、编程规范、重构 这五者之间的关系?

这五者都是保持或提高代码质量的方法论,本质上都是服务于编写高质量的代码。

面向对象

主流的编程范式或编程风格有三种:

  • 面向过程
  • 面向对象
  • 函数式编程

其中,面向对象是目前最主流的;大部分流行的编程语言都是面向对象编程语言,大部分项目都是基于面向对象风格开发。

通过 面向对象的四大特性:封装、抽象、继承、多态,可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础。

设计原则

设计原则是指导我们代码设计的一些经验总结。

这些原则听起来都比较抽象,定义描述都比较模糊,不同的人会有不同的解读。所以,不能单纯地去死记硬背,要掌握每一种设计原则的初衷,了解设计原则用于解决哪些问题,应用于哪些场景,从而在实际项目中灵活恰当的使用这些原则。

常用的设计原则:

  • SOLID原则
    • SRP 单一职责原则
    • OCP 开闭原则
    • LSP 里氏替换原则
    • ISP 接口隔离原则
    • DIP 依赖倒置原则
  • DRY原则
  • KISS原则
  • YAGNI原则
  • LOD原则

设计模式

设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。大部分设计模式要解决的都是代码的可扩展性问题。

经典的设计模式有23种。随着编程语言的演进,一些设计模式也随之过时,甚至成了反模式,一些则被内置在编程语言中,另外还有一些新的设计模式诞生。

经典的23种设计模式,可以分为三大类:

  • 创建型
    • 常用:
      • 单例模式
      • 工厂模式
      • 建造者模式
    • 不常用:
      • 原型模式
  • 结构型
    • 常用:
      • 代理模式
      • 桥接模式
      • 装饰者模式
      • 适配器模式
    • 不常用:
      • 门面模式
      • 组合模式
      • 享元模式
  • 行为型
    • 常用:
      • 观察者模式
      • 模板模式
      • 策略模式
      • 职责链模式
      • 迭代器模式
      • 状态模式
    • 不常用:
      • 访问者模式
      • 备忘录模式
      • 命令模式
      • 解释器模式
      • 中介模式

编程规范

编程规范主要解决的是代码的可读性问题。相对于其他的更注重于代码的细节。基本的编程规范有 如何给变量、类、函数命名,如何写代码注释,如何写函数 等等。

代码重构

只要软件在不停的迭代,就没有一劳永逸的设计。随着需求的变化,代码的不停堆砌,原有的设计必定会存在各种问题。针对这些问题,就有必要进行代码的重构。重构是软件开发中非常重要的一个环节。持续重构是保持代码质量不下降的有效手段,能有效避免代码腐化到无可救药的地步。

重构的工具就是前面讲到的 面向对象设计思想、设计原则、设计模式、编码规范。

对于重构,要了解:

  • 重构的目的、对象、时机、方法
  • 保证重构不出错的技术手段:单元测试和代码的可测试性
  • 两种不同规模的重构
    • 大重构,大规模高层次
    • 小重构,小规模低层次




2. 设计原则与思想:面向对象

封装、抽象、继承、多态分别可以解决哪些编程问题?

封装

封装特性,也叫做信息隐藏或者数据访问保护。

类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持。

通过封装特性,一方面保护数据不被随意修改,提高代码的可维护性;另一方面仅暴露有限的必要接口,提高类的易用性。

抽象

抽象可以通过接口类或者抽象类来实现,并不需要特殊的语法机制来支持。

通过抽象特性,一方面提高代码的可扩展性、可维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。

继承

继承特性需要编程语言提供特殊的语法机制来支持。

主要解决代码复用的问题,但过度使用继承,继承层次过深过复杂,会导致代码可读性,可维护性变差。

多态

多条特性需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。

多态特性能提高代码的可扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。

面向对象相比面向过程有哪些优势?

什么是面向过程编程与面向过程编程语言

面向对象:

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。

  • 面向对象编程语言是支持类或对象的语法机制,并有线程的语法机制,能方便地实现面向对象编程四大特性的编程语言。

面向过程:

  • 面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
  • 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程的特性,仅支持面向过程编程。

面向对象编程相比面向过程编程的优势

  1. 面向对象编程更加能够应对大规模复杂程序的开发
  2. 面向对象编程风格的代码更易复用、易扩展、易维护
  3. 面向对象编程语言更加人性化、更加高级、更加智能

看似面向对象的代码,实际上是面向过程?

什么样的代码设计是面向过程的?

  1. 滥用 getter、setter 方法,所有成员变量都设置getter、setter方法。
  2. 滥用全局变量和全局方法
  3. 定义数据和方法分离的类

为什么容易写出面向过程风格代码?

  • 面向过程风格,符合人类思考习惯:先做什么,后做什么,一步步顺序执行一系列操作。

  • 面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计需要技巧及经验。

面向过程编程已无用武之地?

  • 如果开发的是微小程序 或 以算法为主、数据为辅的数据处理相关代码,脚本式的面向过程编程风格更适合。
  • 面向过程实际上是面向对象编程的基础,先做好面向过程,再去做好面向对象。
  • 无论使用哪种风格代码,最终目的是写出 易维护、易读、易复用、易扩展的高质量代码;只要能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,就无须避讳在面向对象编程中写面向过程风格的代码。

接口与抽象类

在面向对象编程中,抽象类和接口时两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式,设计思想,设计原则编程实现的基础。例如,可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等。

定义(what)

抽象类:

  • 不允许被实例化,只能被继承。
  • 可以包含属性和方法。其中方法可以包含代码实现也可不包含,不包含代码实现的方法叫抽象方法。
  • 子类继承抽象类,必须实现抽象类种所有抽象方法。

接口:

  • 接口不能包含属性
  • 接口只能声明方法,方法不能包含代码实现。
  • 类实现接口的时候,必须实现接口中声明的所有方法。

区别:

  • 抽象类表示 is-a 关系,接口表示 has-a 关系

解决什么问题(why)

抽象类主要解决代码复用问题,而且在实现面向对象的多态特性时更加优雅便捷。

接口主要解决设计中的解耦,它是对行为的一种抽象,调用者只需要关注接口而非具体实现。

如何区分使用(how)

同定义种的区别,如果要实现 is-a 关系的设计,使用抽象类;如果要实现 has-a 关系的设计,使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类;接口正好相反,是一种自上而下的设计思路,一般先设计接口,再去考虑具体的实现。

基于接口而非实现编程

原因?

  • 越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。

如何做?

  • 函数的命名不能暴露任何实现细节。
  • 封装具体的实现细节。
  • 为实现类定义抽象的接口。

多用组合少用继承

为什么不推荐使用继承?

  • 继承可以解决代码复用问题。但是继承层次过深、过复杂,会影响到代码的可读性和可维护性。

为什么不用组合完全替换继承?

  • 继承改写组合要做更细粒度的类的拆分,因此要定义更多的类和接口,更复杂且维护成本更高。

组合如何代替继承?

  • 继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。
  • 使用 组合、接口、委托 三种技术手段来代替继承,并且避免它产生的问题。
    • 通过组合和接口的 has-a 关系,可以替代继承的 is-a 关系。
    • 通过接口可以替代继承的多态特性。
    • 通过组合和委托可以实现代码复用。

如何合理使用组合与继承?

  • 如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(最多有两层继承关系),继承关系不复杂,就可以大胆的使用继承。反之,系统越不稳定,继承层次越深,继承关系越复杂,就尽量使用组合来替代继承。
  • 某些设计模式会固定使用继承或者组合。

实战: MVC & DDD

what

什么是MVC?什么是DDD?

  • MVC架构将整个项目分为三层:展示层(View)、逻辑层(Controller)、数据层(Model)。

    它是一种软件设计典范,三层分离的方法组织代码,将业务逻辑聚集到一起,在改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑。

  • DDD,即领域驱动设计,主要用来指导如何解耦业务系统,划分业务模块,定义业务领域及其交互。

什么是贫血模型?什么是充血模型?

  • 贫血模型;存在只包含数据,不包含业务逻辑的类。贫血模型将数据与操作分离,破坏了面向对象的封装特性,是典型的面向过程的编程风格。
  • 充血模型;相对于贫血模型,所有的类都包含数据和对应的业务逻辑。

基于贫血模型的传统开发模式既然违反OOP,那又为什么流行?

  1. 充血模型的设计相对于贫血模型更有难度。大部分情况下系统业务比较简单,用贫血模型就足以应付;即使设计好充血模型,由于业务逻辑不多,表现出的样子和贫血模型差不多,没有太大意义。
  2. 思维固化,转型有成本。基于贫血模型的传统开发模式经历了很多年,已经深入人心,并且没有出过大的差错,在这种情况下进行转型,没有太大收益,成本却很大。

什么情况下我们应该考虑使用基于充血模型的DDD开发模式?

  • 业务复杂的系统开发适合使用DDD开发模式。越复杂的系统,对代码的复用性、易维护性要求越高,就应该花费更多的时间和精力在前期设计上。基于充血模型的DDD开发模式,需要前期做大量的业务调研、领域模型设计,因此更加适合复杂系统的开发。

实战:面向对象开发

面向对象开发的三个环节:

  1. 面向对象分析(OOA)
  2. 面向对象设计(OOD)
  3. 面向对象编程(OOP)

面向对象分析

需求分析的整个过程,从最粗糙、最模糊的需求开始,通过“提出问题-解决问题”的方式,循序渐进的优化,最后得到一个足够清晰、可落地的需求描述。

明确需求:

将笼统的需求细化到足够清晰、可执行。通过 沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。

具体分析:

  1. 基础分析
    • 从最简单的方案想起,再去优化。(先实现,再优化)
  2. 分析优化(有多轮)
    • 不断探寻优化点,判断优化的成本收益比而是否要做。
  3. 最终确定需求
    • 确定需求,可用文本或者时序图、流程图等描述。

面向对象设计

面向对象分析的产物是详细的需求描述。面向对象设计的产出是类。

在这一环节,将描述转化为具体的类的设计,可以分为下面几部分:

  1. 划分职责进而识别出有哪些类
  2. 定义类及其属性和方法
  3. 定义类与类之间的交互关系
  4. 将类组装起来并提供执行入口




3. 设计原则与思想:设计原则

SOLID原则

SOLID 原则:

  • S,Single Responsibility Principle(SRP),单一职责原则
  • O,Open Closed Principle(OCP),开闭原则
  • L,Liskov Substitution Principle(LSP),里氏替换原则
  • I,Interface Segregation Principle(ISP),接口隔离原则
  • D,Dependency Inversion Principle(DIP),依赖反转原则

单一职责原则(SRP,Single Responsibility Principle)

描述:

  • A class or moudle should have a single reponsibility.

重点:

  • 类或模块的职责是否单一
    • 判定标准:
      • 类中代码行数、函数或属性过多
      • 类依赖的其他类过多,或依赖类的其他类过多
      • 私有方法过多
      • 难以给类起合适的名字
      • 类中大量的方法都是集中操作类中的某几个属性

应用:

  • 先实现一个粗粒度的类,满足业务需求。随着业务发展,粗粒度的类越来越庞大,代码越来越多时,对粗粒度类进行重构,拆分成几个更细粒度的类,进行持续重构。

开闭原则(OCP,Open Closed Principle)

描述:

  • Software entities(modules, classes, functions, etc.) should be open for extension, but closed for modification.

重点:

  • 对扩展开放,对修改关闭
    • 认识:添加一个新的功能,应该是通过在已有代码基础上扩展代码,而非修改已有代码的方式来完成。但并不代表完全杜绝修改,而是以最小的代价来完成新功能的开发。同样的代码改动,在粗粒度中可能认定为“修改”,在细粒度中,可能认定为“扩展”。

应用:

  • 在编写代码时,时刻具备扩展意识、抽象意识、封装意识。充分理解需求的原因及变更方向,事先留好扩展点。

里氏替换原则(LSP,Liskov Substitution Principle)

描述:

  • If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program. (1986, Barbara Liskov 提出)
  • Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.(1996, Robert Martin 重述)

重点:

  • 子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性
    • 三种典型违背情况
      • 子类违背父类声明要实现的功能
      • 子类违背父类对输入、输出、异常的约定
      • 子类违背父类注释中所罗列的任何特殊说明
    • 与多态的区别
      • 多态是面向对象编程中的一大特性,也是面向对象编程语言的一种语法,是一种代码实现思路。
      • 里氏替换是一种设计原则,用来指导继承关系中子类的设计方法:子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。

应用:

  • 父类定义了函数的的“约定”,子类可以变函数的内部实现逻辑,但不能改变函数原有的“约定”。
    • 这里的“约定”包括:
      • 函数声明要实现的功能
      • 对输入、输出、异常的约定
      • 注释中所罗列的任何特殊说明

接口隔离原则(ISP,Interface Segregation Principle)

描述:

  • Clients should not be forced to depend upon interfaces that they do not use.

重点:

  • 对接口的三种理解
    • 一组接口集合:如果部分接口只被部分调用者使用,就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
    • 单个API接口或函数:如果部分调用者只需要函数中的部分功能,就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的细粒度函数。
    • OOP中的接口:需要将接口的设计单一化,不要让接口的实现类和调用者依赖不需要的接口函数。
  • 与单一职责原则的区别
    • 单一职责原则是对模块、类、接口的设计。
    • 接口隔离原则一方面更侧重于接口的设计,另一方面提供了一种判断接口的职责是否单一的标准。
      • 标准:通过调用者如何使用接口来间接地判定,如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

应用:

  • 根据对接口的不同理解,进行不同的设计。

依赖反转原则(DIP,Dependency Inversion Principle)

描述:

  • High-level modules shouldn’t depend on low-level modules.Both modules should depend on abstractions.In addition, abstractions shouldn’t depend on details.Details depend on abstractions.

重点:

  • 控制反转:一种指导框架层面设计的思想。从程序员自己控制整个程序执行的流程,通过使用框架,实现框架来控制整个程序执行流程。(控制权从程序员“反转”给了框架)
  • 依赖注入:一种编程技巧。不通过new的方式在类内创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。
  • 依赖注入框架:通过依赖注入框架提供的扩展点,简单配置所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。

应用:

  • Tomcat


KISS原则 与 YAGNI原则

KISS原则

KISS原则:(几个版本)

  • Keep It Simple and Stupid
  • Keep It Short and Simple
  • Keep It Simple and Straightforward

大概都可以翻译为:尽量保持简单。

KISS原则是保持代码可读性和可维护性的重要手段。

如何写出满足KISS原则的代码?

  • 不要使用同事可能不懂的技术来实现代码。比如正则表达式,一些语言中的高级语法等
  • 不要重复造轮子,要善于使用已经有的工具类库。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算数运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
  • KISS原则要综合考虑 逻辑复杂度、实现难度、代码可读性、代码行数等;本身复杂的问题,用复杂的方法解决,并不违背KISS原则。同样的代码,在某个业务场景下满足KISS原则,换一个应用场景可能就不满足了。

YAGNI原则

YAGNI原则:

  • You Ain’t Gonna Need It

核心思想:不要做过度设计

两者关系

KISS & YAGNI:

  • KISS原则是保持代码可读性与可维护性的重要手段。主要讲 “如何做” 的问题(尽量保持简单)。
  • YAGNI原则核心思想是不要做过度设计。主要讲 “要不要做” 的问题(当前不需要的就不要做)。


DRY原则

DRY原则

  • Don’t Repeat Yourself

三种代码重复:

  • 实现逻辑重复
  • 功能语义重复
  • 代码执行重复

违反原则情况:

  • 实现逻辑重复、功能语义不重复;不违反DRY原则
  • 实现逻辑不重复、功能语义重复;违反DRY原则
  • 代码执行重复;违反DRY原则

代码复用性(Code Reusability) & 代码复用(Code Resue) & DRY原则

  • 定义:
    • 代码复用性:一种特性或能力,在编写代码的时候,尽量保证代码可被复用。
    • 代码复用:一种行为,在开发新功能的时候,尽量复用已经存在的代码。
    • DRY原则:一条原则,不要写重复的代码
  • 区别:
    • “不重复” 并不代表 “可复用”
    • “复用” 和 “可复用性” 关注的角度不同

如何提高代码复用性:

  • 减少代码耦合
  • 满足单一职责原则
  • 模块化
  • 业务与非业务逻辑分离
  • 通用代码下沉
  • 继承、多态、抽象、封装
  • 应用模板等设计模式

Rule of Three 原则:

  • 第一次编写代码的时候,不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。


迪米特法则(LOD, Law of Demeter)

迪米特法则(Law of Demeter,The Least Knowledge Principle)

  • Each unit should have only limited knowledge about other units: only units “closely” related to the current unit.Or: Each unit should only talk to its friends; Don’t talk to strangers.

迪米特法则可以帮助实现代码 “高内聚、松耦合”,从而有效的提高代码的可读性与可维护性,缩小功能改动导致的代码改动范围。

高内聚、松耦合:

  • 一个设计思想,能够有效的提高代码可读性与可维护性。“高内聚” 用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。
  • 高内聚,指相近的功能应该放在同一个类中,不想近的功能不要放在同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。
  • 松耦合,指类与类之间的依赖关系简单清晰。两个类有依赖关系,一个类的代码改动也不会或很少导致依赖类的代码改动。

如何理解 迪米特法则:

  • 不该有直接依赖关系的类之间,不要有依赖
  • 有依赖关系的类之间,尽量只依赖必要的接口
  • 迪米特法则是希望减少类之间的耦合,让类越独立越好




4. 设计原则与思想:规范与重构

重构

很多技术问题本身就不是单纯靠技术来解决的,更重要的是要有这种认知和意识。

Why - 重构的目的

1
2
3
重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。

—— Martin Fowler

重点:

  • 重构不改变外部的可见行为

进一步理解:

  • 在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
  • 重构对工程师本身的技术成长有重要意义。
    • 初级工程师在维护代码
    • 中级工程师在设计代码
    • 高级工程师在重构代码


What - 重构的对象

根据重构的规模,可以分为:

  • 大型重构:大规模、高层次的重构
    • 对象:对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系
    • 手段:更多的利用 设计思想、原则、模式;比如:分层、模块化、解耦、抽象可复用组件
    • 影响:涉及代码改动多,影响面大,难度大,耗时长,风险大
  • 小型重构:小规模、低层次的重构
    • 对象:对代码细节的重构,包括:类、函数、变量等
    • 手段:更多的利用编码规范;比如:规范命名、规范注释、消除超大类或函数、提取重复代码
    • 影响:修改集中,比较简单,可操作性强,耗时短,风险小


When - 重构的时机

两个不要:

  • 不要等到代码烂到一定程度,再去重构
  • 不要花尽心思去构思完美设计,避免以后的重构

要有 持续重构意识

  • 正确的看待代码质量和重构
  • 代码质量总会因各种原因下降,代码总会存在不完美,避免开发初期的过度设计


How - 重构的方法

针对大型重构

  • 有组织、有计划的进行,分阶段地小步快跑,时刻让代码处于可运行的状态

针对小型重构

  • 看个人意愿,随时随地都可以去做

重构技巧之 代码的可测试性

代码的可测试性就是针对代码编写单元测试的难易程度。

依赖注入是编写可测试性代码的最有效手段。

常见的测试不友好的代码类型:(Anti-Patterns)

  • 代码中包含未决行为逻辑
  • 滥用可变全局变量
  • 滥用静态方法
  • 使用复杂的继承关系
  • 高度耦合的代码

重构技巧之 单元测试

单元测试是保证代码质量最有效的两个手段之一(单元测试 & Code Review)

为什么要写单元测试:

  1. 能有效地帮助发现代码中的bug
  2. 能帮助发现代码设计上的问题
  3. 是对集成测试的有力补充
  4. 写单元测试的过程本身就是代码重构的过程
  5. 阅读单元测试能帮助快速熟悉代码
  6. 单元测试是TDD(Test-Driven Development 测试驱动开发)可落地执行的改进方案

树立编写单元测试的正确认知:

  • 编写单元测试尽管繁琐,但并不是太耗时
  • 可以稍微放低对单元测试代码质量的要求
  • 覆盖率作为衡量单元测试质量的唯一标准是不合理的
  • 单元测试不要依赖被测试代码的具体实现逻辑
  • 单元测试框架无法测试,多半是因为代码的可测试性不够好

为何一般难以落地执行:

  • 写单元测试比较繁琐,技术挑战不大,程序员意愿低
  • 很多研发偏向:快、糙、猛,容易因为开发进度紧,导致单元测试的执行虎头蛇尾
  • 团队没有建立对单元测试正确的认知

重构技巧之 解耦

大型重构最有效的一个手段

解耦的重要性

  • 过于复杂的代码往往在可读性、可维护性上都不友好。解耦保证代码松耦合、高内聚,是控制代码复杂度的有效手段。

判断是否需要解耦:

  • 看修改代码是否要牵一发而动全身
  • 根据模块之间、类之间的依赖关系图的复杂度

如何解耦:

  • 封装与抽象
  • 添加中间层
  • 模块化
  • 设计思想与原则
    • 单一职责
    • 基于接口而非实现编程
    • 依赖注入
    • 多用组合少用继承
    • 迪米特法则
  • 设计模式
    • 观察者模式

20条编码规范

最重要的的一点:统一编码规范!!

命名与注释(Naming and Comments)
  • 命名的长度:命名的关键是能准确达意。对于不同作用域的命名,可以适当地选择不同长度。作用域小的变量(比如临时变量),可以适当地短一些的命名方式。除此之外,命名中也可以使用一些耳熟能详的缩写。
  • 简化命名:可以借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
  • 命名需可读、可搜索:不要使用生僻的、不好读的英文单词来命名。除此之外,命名要符合项目的统一规范。
  • 接口与抽象类的命名
    • 接口有两种方式命名,一种是在接口中带前缀“I”,另一种是在接口的实现类中带后缀“Impl”。
    • 抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,另一种是不带前缀。
    • 无论采用哪种,关键是要在项目中统一
  • 注释的目的:注释目的是让代码更容易看懂。只要符合这个要求,就可以将它写到注释里。注释的内容主要包含三个方面:做什么、为什么、怎么做。对于一些复杂的类与接口,可能还需要写明 如何用。
  • 注释的数量:注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写的尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
代码风格(Code Style)
  • 函数、类的大小:函数的代码尽量不要超过一屏幕的大小,例如50行。
  • 一行代码长度:一行代码尽量不要超过IDE显示的宽度,也不要太小。
  • 善用空行分割单元块:对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。
  • 缩进大小:四格缩进与两格缩进,尽量使用两格缩进,可以节省空间,特别是在代码嵌套层次比较深的情况下。尽量不要使用tab键缩进。
  • 大括号是否另起一行:推荐大括号放到跟上一条语句同行的风格,可以节省代码行数。
  • 类中成员的排列顺序:依赖类按照字母序从小到大排列。类中先写成员变量,后写函数。成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列。
编程技巧(Coding Tips)
  • 将复杂的逻辑提炼拆分成函数和类
  • 通过拆分多个函数或将参数封装为对象的方式,来处理参数过多的情况
  • 函数中不要使用参数来做代码执行逻辑的控制
  • 函数涉及要职责单一
  • 移除过深的嵌套层次,方法包括:
    • 去除多余的 if 或 else 语句
    • 使用 continue、break、return 关键字,提前退出嵌套
    • 调整执行顺序来减少嵌套
    • 将部分嵌套逻辑抽象成函数
  • 用字面常量取代魔法数
  • 用解释性变量来解释复杂表达式,以此提高代码可读性




5. 设计模式与范式

设计模式主要做的事情就是解耦:

  • 创建型模式
    • 解决:
      • 对象的创建问题,封装复杂的创建过程
    • 方式:
      • 将 创建代码 和 使用代码 解耦
    • 包含:
      • 单例模式
      • 工厂模式
      • 建造者模式
      • 原型模式
  • 结构型模式,
    • 解决:
      • 类或对象的组合问题
    • 方式:
      • 将 不同功能的代码 解耦
    • 包含:
      • 代理模式
      • 桥接模式
      • 装饰器模式
      • 适配器模式
      • 门面模式
      • 组合模式
      • 享元模式
  • 行为型模式,
    • 解决:
      • 类或对象之间的交互问题
    • 方式:
      • 将 不同的行为代码 解耦
    • 包含:
      • 观察者模式
      • 模板模式
      • 策略模式
      • 职责链模式
      • 迭代器模式
      • 状态模式
      • 访问者模式
      • 备忘录模式
      • 命令模式
      • 解释器模式
      • 中介模式

创建型

创建型模式主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。

  • 单例模式用来创建全局唯一的对象
  • 工厂模式用来创建不同但相关类型的对象(继承同一父类或接口的一组子类),由给定的参数来决定创建哪种类型对象
  • 建造者模式用来创建复杂对象,可通过设置不同的可选参数,“定制化”地创建不同的对象
  • 原型模式针对创建成本比较大的对象,利用已有对象进行复制的方式创建,以达到节省创建时间的目的


单例模式

概念:

  • 一个类只允许创建一个对象(或实例)

应用场景:

  • 处理资源访问冲突
    • 主要解决多线程时的冲突
    • 其他解决方法还有:分布式锁、并发队列等
    • 相对来说,单例模式方案更加简单
  • 表示全局唯一类

实现重点:

  • 构造函数需要是private访问权限,避免外部通过new创建实例
  • 考虑对象创建时的线程安全问题
  • 考虑是否支持延迟加载
  • 考虑 getInstance() 性能是否高(是否加锁)

实现方案:

  1. 饿汉式

    • 方式:在类加载的时候,instance 静态实例就已经创建并初始化好。

    • 缺点:不支持延迟加载,若占用资源多或初始化时间长,提前初始化会浪费资源。

  2. 懒汉式

    • 方式:在调用的时候,判断是否存在,并加锁创建实例

    • 缺点:由于加锁,导致 getInstance() 性能低。若频繁使用,会导致性能瓶颈

  3. 双重检测

    • 方式:在instanc被创建后,加类级别的锁,避免再次加锁
    • 缺点:有可能因为指令重排序导致其他问题
  4. 静态内部类

  5. 枚举

存在的问题:

  • 单例对OOP特性的支持不友好
  • 单例会隐藏类之间的依赖关系
  • 单例对代码的扩展性不友好
  • 单例对代码的可测试性不友好
  • 单例不支持有参数的构造函数

替代方案:

  • 用静态方法来实现
  • 通过工厂模式、IOC容器来保证,由程序员来保证

其他:

  • 如何理解单例模式的唯一性
    • 单例类中对象的唯一性的作用范围是“进程唯一”的。
    • “进程唯一”指的是进程为内唯一,进程间不唯一(即 线程内唯一,线程间也唯一)
    • “线程唯一”指的是线程内唯一,线程间不唯一
    • “集群唯一”指的是进程内唯一、进程间也唯一

工厂模式

概念:

  • 可以分为 简单工厂、工厂方法、抽象工厂。(简单工厂模式可看作是工厂方法模式的一种特例)
  • 解耦对象的创建与使用

应用场景:

  • 当创建逻辑比较复杂,是个”大工程“的时候,可以考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用分离。
  • 第一种情况:类似规则配置解析,代码中存在 if-else 分支判断,动态地根据不同的类型创建不同的对象。将这一大坨 if-else 创建对象的代码分离出来,放到工厂类中。
  • 第二种情况:尽管不需要根据不同的类型创建不同的对象,但是单个对象本身的创建过程比较复杂,比如前面提到的要组合其他类的对象,做各种初始化操作。此时,也可以考虑使用工厂模式,将对象的创建过程封装到工厂类中。

实现重点:

  • 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。
  • 代码复用:创建代码抽离到独立的工厂类之后可以复用。
  • 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。
  • 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。

DI容器:(依赖注入容器 Dependency Injection COntainer)

  • DI容器在一些软件开发中已经成为了标配,比如 Spring IOC、Google Guice。
  • 实现逻辑主要包括:
    • 配置文件解析
    • 对象创建
    • 对象的生命周期管理

建造者模式

概念:

  • 如果一个类中有很多属性,为了避免构造函数的参数列表过长,影响代码的可读性和易用性,可以通过构造函数配合set的方法解决。但是若存在下面情况任意一种,需考虑使用建造者模式:
    • 必填属性很多,通过set方法设置,但是校验必填属性的逻辑无处安放
    • 类的属性之间有一定的依赖关系或约束条件。依赖关系或约束条件的校验逻辑无处安放
    • 创建不可变对象,不能暴露set方法,无法通过set方法赋值

与工厂模式的区别:

  • 工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),有给定的参数来决定创建哪种类型的对象。
  • 建造者模式是用来创建一种类型的复杂对象,可以通过设置不同的可选参数,“定制化”的创建不同的对象。

原型模式

概念:

  • 如果对象的创建成本比较大,而同一个类的不同对象之间差别不大时,可以利用对已有对象(原型)进行复制(或叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。

实现方法:

  • 深拷贝。深拷贝得到的是一份完完全全独立的对象。
  • 浅拷贝。浅拷贝指挥复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的对象
  • 深拷贝更加耗时,耗空间。



结构型

结构型模式主要总结一些类或对象组合在一起的经典结构;这些结构可以解决特定应用场景的问题。


代理模式

概念:

  • 在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能

应用场景:

  • 业务系统的非功能性需求开发。如:监控、统计、鉴权、限流、事务、幂等、日志。将附加功能与业务功能解耦,放到代理类中统一处理,程序员只需要关注业务方面开发。
  • 在RPC、缓存中的应用。

实现方式:

  • 静态代理类,针对每个类都创建一个代理类。但每个代理类中的代码都存在像模板式的“重复”代码,增加了维护成本和开发成本。
  • 动态代理类,不事先为每个原始类编写代理类,而是在运行时动态地创建原始类对应的代理类,然后在系统中用给代理类替换掉原始类。

桥接模式

概念:

  • 将抽象与实现解耦,让它们可以独立变化
    • 此处“抽象”并非“抽象类”或“接口”,而是被“抽象”出来的一套“类库”,它只包含骨架代码,真正的业务逻辑需要委派给定义中的“实现”来完成。而定义中的“实现”,也并非“接口的实现类”,而是一套独立的“类库”。“抽象”和“实现”独立开发,通过对象之间的组合关系,组装在一起
  • 一个类存在两个(或多个)独立变化的维度,通过组合的方式,让这两个(或多个)维度可以独立进行扩展

装饰器模式

概念:

  • 解决继承关系过于复杂的问题,通过组合替代继承。主要作用是给原始类添加增强功能。

其他:

  • 装饰器类和原始类继承同样的父类,这样可以对原始类“嵌套”多个装饰器类
  • 装饰器类是对功能的增强,这也是装饰器模式应用场景的重要特点

适配器模式

概念:

  • 将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作

应用场景:

  • 封装有缺陷的接口设计
  • 统一多个类的接口设计
  • 替换依赖的外部系统
  • 兼容老版本接口
  • 适配不同格式的数据

代理、桥接、装饰器、适配器 四种模式的区别:

  • 共性:代理、桥接、装饰器、适配器,这四种模式都是比较常用的结构型设计模式。代码结构非常相似,都可以成为Wrapper模式,也就是通过Wrapper类二次封装原始类
  • 不同:
    • 代理模式,在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是与装饰器模式的最大不同
    • 桥接模式,目的是将接口部分和实现部分分离,从而让它们较为容易且相对独立的改变
    • 装饰器模式,在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用
    • 适配器模式,是一种事后的补救策略,适配器提供跟原始类不同的接口,而代理模式、装饰器模式都提供相同的接口

门面模式

概念:

  • 门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用

应用场景:

  • 解决易用性的问题
  • 解决性能问题
  • 解决分布式事务问题

组合模式

概念:

  • 将一组对象组织成树形结构,以表示一种“部分-整体”的层次结构。组合让代码使用者可以统一单个对象和组合对象的处理逻辑。

享元模式

概念:

  • 享元模式意图是复用对象,节省内存,前提是享元对象是不可变对象
  • 当一个系统中存在大量重复对象的时候,如果这些重复对象是不可变对象,可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多出代码引用。这样可减少内存中对象的数量,起到节省内存的目的。
  • 不可变对象是指,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或属性)就不会再被修改

实现:

  • 通过工厂模式,在工厂类中,通过一个Map或者List来缓存已经创建好的享元对象,以达到复用的目的。

享元模式与单例模式的区别:

  • 单例模式中一个类只能创建一个对象。单例模式的设计意图是限制对象个数。
  • 享元模式中,一个类可以创建多个对象,每个对象被多出代码引用共享(实际上,有些类似于多例)。享元模式的设计意图是对象复用,节省内存。

享元模式与缓存的区别:

  • 享元模式的实现中,通过工厂类来“缓存”已创建好的对象。此处缓存即为存储。
  • 平时所说的缓存,主要是为了提高访问效率,而非复用。

享元模式与对象池的区别:

  • 池化技术的复用可以理解为“重复使用”,主要目的是节省时间。
  • 享元模式中的复用可以理解为“共享使用”,主要目的是节省空间。



行为型

观察者模式

概念:

  • 在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。
  • 通常来说,被依赖的对象叫做 被观察者(Obserable),依赖的对象叫做 观察者(Observer)
  • 解耦观察者与被观察者

实现:

  • 同步阻塞是最经典的实现方式,主要为了代码解耦
  • 异步非阻塞,除了能实现代码解耦,还可以提高代码的执行效率
  • 进程间的观察者模式解耦更加彻底,一般是基于消息队列来实现

模板模式

概念:

  • 模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
  • 此处的“算法”,可以理解为广义上的 “业务逻辑”,并不是特指数据结构与算法中的“算法”

作用:

  • 复用
    • 所有的子类可以复用父类中提供的模板方法的代码
  • 扩展
    • 并非代码的扩展性,而是指框架的扩展性
    • 框架通过模板模式提供功能扩展点,让框架用户在不修改框架源码情况下,基于扩展点定制化框架的功能

模板模式 与 回调:

  • 回调简介
    • 概念:相对于普通的函数来说,回调是一种双向调用关系。A类事先注册某个函数1到B类,A类在调用B类函数2的时候,B类反过来调用A类注册给它的函数1。这里的函数1叫做 回调函数。A调用B,B反过来又调用A,这种调用机制叫做 回调。
    • 分类:回调可以分为同步回调与异步回调(或叫做延迟回调)。同步回调指在函数返回之前执行回调函数;异步回调指函数返回之后执行回调函数。
  • 区别:
    • 从应用场景上来看,同步回调与模板模式几乎一致,都是在一个大的算法骨架中,自由替换其中某个步骤,起到代码复用与扩展的目的;而异步回调跟模板模式有较大的区别,更像是观察者模式
    • 从代码实现上来看,回调和模板模式完全不同。回调基于组合关系来实现,把一个对象传递给另一个对象,是一种对象之间的关系;模板模式基于继承关系来实现,子类重写父类的抽象方法,是一种类之间的关系。
  • 组合优于继承,回调相对于模板模式会更加灵活
    • 对于只支持单继承的语言,基于模板模式编写的子类,已经继承了一个父类,不在具有继承能力
    • 回调可以使用匿名类来创建回调对象,可以不用事先定义类;而模板模式针对不同的实现都要定义不同的子类
    • 如果某个类中定义了多个模板方法,每个方法都有相对应的抽象方法,那即便我们只用到其中的一个模板方法,子类也必须实现所有的抽象方法;而回调就更加灵活,只需要往用到的模板方法中注入回调对象即可。

策略模式

概念:

  • 定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端代码(调用者)。
  • 策略模式用于解耦策略类的定义、创建、使用
    • 策略类的定义,包含一个策略接口和一组实现这个接口的类。因为所有的策略类都实现相同的接口,所以,客户端代码基于接口而非实现编程,可以灵活地替换不同的策略。
    • 策略类的创建,在使用的时候,一般会通过类型来判断创建哪个策略类使用。为了封装创建逻辑,需要对客户端代码屏蔽创建细节,可以把根据类型创建策略的逻辑抽离出来,放到工厂类中。
    • 策略类的使用,策略模式可以包含一组可选策略,客户端代码可以在运行时动态确定使用哪组策略。

应用:

  • 移出 if-else 分支判断
    • 实际上是使用策略工厂类,借助 查表法,根据类型查表替代根据类型分支判断

策略模式与工厂模式区别

  • 工厂模式
    • 目的是创建不同且相关的对象
    • 侧重于 创建对象
    • 实现方式上通过父类或者接口
    • 一般创建对象应该是现实世界中某种事物的映射,有它自己的属性与方法
  • 策略模式
    • 目的是实现方便地替换不同的算法类
    • 侧重于算法(行为)实现
    • 实现方式上通过接口
    • 创建对象对行为的抽象而非对对象的抽象,一般没有属于自己的属性

其他:

  • 一提到策略模式,有人就认为它的作用是避免if-else分支判断逻辑。实际上这是很片面的。策略模式的主要作用是解耦策略的定义、创建与使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂的代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化集中化代码改动,减少引入bug的风险。
  • 实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。

职责链模式

概念:

  • 将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。
  • 标准的职责链模式,依次执行,有可处理的或异常就终止;变体职责链模式,顺序执行,不终止。
  • 实现上可通过链表或者数组

应用:

  • 应对代码的复杂性。将大块代码逻辑拆分成函数,将大类拆分成小类。
  • 让代码满足开闭原则,提高可扩展性。
  • 具体应用场景:开发框架的过滤器与拦截器功能

状态模式

有限状态机(Finite State Machine FSM):

  • 概念:状态机由三部分组成 状态(State)、事件(Event)/ 转移条件(Transition Condition)、动作(Action)。事件触发状态的转移及动作的执行,不过动作不是必须的,也可能只转移状态,不执行动作。
  • 实现方式:
    • 分支逻辑法,参照状态转移图,将每一个状态转移,原模原样的直译成代码。这样编写会有大量的 if-else 或 switch-case 分支判断逻辑。局限在于可维护性差,无法实现复杂的状态机。
    • 查表法,参照状态转移图,构建二维表。一般以第一维度表示当前状态,第二维度表示事件,值表示当前状态经过事件之后,转移到的新状态及要执行的动作。相对于分支逻辑法,查表法更清晰,可读性与可维护性更好。局限在于只能实现执行简单的动作逻辑。
    • 状态模式,将事件触发的状态转移和动作执行,拆分到不同状态类中。局限在于引入非常多的状态类,若状态比较多,维护复杂度高。

应用:

  • 像游戏这种比较复杂的状态机,优先推荐查表法,状态模式会引入非常多的状态类,会导致代码比较难以维护。像电商下单、外卖下单这种类型状态机,状态并不多,状态转移也简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,更推荐使用状态模式来实现。

迭代器模式/游标模式

概念:

  • 用来遍历集合对象(/容器/聚合对象),实际上就是包含一组对象的对象,例如 数组、链表、树、图、跳表。迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更单一。
  • 一个完整的迭代器涉及 容器 和 容器迭代器 两部分。为了基于接口而非实现编程的目的,容器又包含容器接口、容器实现类,迭代器又包含 迭代器接口、迭代器实现类。

实现方式:

  • 迭代器中需要定义 hasNext 、currentItem 、next 三个最基本的方法。待遍历的容器对象通过依赖注入传递到迭代器类中,容器通过 iterator 方法来创建迭代器。

优势/作用:

  • 封装集合内部复杂的数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可。
  • 将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更单一。
  • 让添加新的遍历算法更加容易,更符合开闭原则。
  • 因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器类也更加容易。

其他:

  • 在通过迭代器来遍历集合元素的同时,增删集合中的元素,有可能会导致某个元素被重复遍历或遍历不到,也有可能正常遍历,这种行为称为 结果不可预期行为 或者 未决行为。未决行为比出错更加可怕,很难debug 。为了避免这种行为发生,一般会在遍历时不允许增删元素,或者在遍历后让遍历报错,通知尽快修改(fail-fast 解决方式)。
  • 如何实现“快照”功能的迭代器
    • 快照:指为容器创建迭代器的时候,相当于给容器拍了一张快照(Snapshot)。之后增删容器中的元素,快照中的元素并不会做相应的改动,而迭代器遍历的对象是快照而非容器,避免在使用迭代器遍历的过程中,增删容器中的元素导致的不可预期的结果或报错。
    • 方案一:在迭代器类中定义一个成员变量来存储快照。每当创建迭代器的时候,都拷贝一份容器中的元素到快照中,后续的遍历操作都基于这个迭代器自己持有的快照来进行。
      • 缺点是内存消耗很高,如果一个容器中同时有多个迭代器,就会存在多个重复的快照,但对于浅拷贝类型的语言,相对还好一些。
    • 方案二:在容器中,为每个元素保存两个时间戳,一个是添加时间戳,一个是删除时间戳。在删除的时候通过设置删除时间戳为最大值的方式标记删除而非真正删除。同时,每个迭代器也保存一个迭代器创建时间戳。当使用迭代器来遍历容器的时候,只有容器内元素满足 元素添加时间戳 < 迭代器创建时间戳 < 元素删除时间戳,才属于这个迭代器的快照。
      • 缺点是底层依赖数组的数据结构,原本可以支持快速的随机访问,现在由于删除并非真正的删除,导致不支持快速的随机访问。(可以存两个数组,一个支持标记删除,来实现快照遍历功能;一个不支持标记删除,用来支持随机访问)

访问者模式

概念:

  • 允许一个或多个操作应用到一组对象上,解耦操作和对象本身

应用:

  • 访问者模式针对一组类型不同的对象。尽管这组对象类型不同但它们继承相同的父类或者实现相同的接口。在不同的应用场景下,需要对这组对象进行一系列不相关的业务操作,为了避免不断添加功能导致类不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致频繁代码修改,因此使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者类中。

其他:

  • 访问者模式,主要难点在代码实现。代码实现复杂的原因是,函数重载在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个重载函数,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。
  • 项目中应用这种模式,会导致代码的可读性比较差。除非不得已,不要使用。
  • 关于 Single Dispatch 与 Double Dispatch
    • Single Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的编译时类型来决定。
    • Double Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时类型来决定
    • 具体到编程语言的语法机制,Single Dispatch 和 Double Dispatch 跟多态和函数重载直接相关。当前主流的面向对象编程语言都只支持 Single Dispatch,不支持 Double Dispatch。

备忘录模式/快照模式

概念:

  • 在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。
  • 两部分内容:一是在存储副本以便后期恢复;另一部分是在不违背封装原则的前提下,进行对象的备份和恢复。

应用:

  • 用来防丢失、撤销、恢复等。相对于备份来说,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。

命令模式

概念:

  • 命令模式将请求(命令)封装为一个对象,这样可以使用不同的请求参数化其他对象(将不同请求依赖植入到其他对象),并且能够支持请求(命令)的排队执行、记录日志、撤销等(附加控制)功能。
  • 落实到编码实现,命令模式用到最核心的实现手段,就是将函数封装成对象。在大部分编程语言中,函数是没办法作为参数传递给其他函数的,也没法赋值给变量。借助命令模式,将函数封装成对象,可以实现把函数像对象一样使用。

应用:

  • 把函数封装成对象,对象就可以存储下来,方便控制执行。用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等等。

解释器模式

概念:

  • 解释器模式为某个语言定义它的用法(或者叫文法)表示,并定义一个解释器用来处理这个语法。
  • 这里的“语言”不仅仅指平时所说的中、英、日、法等各种语言。从广义上来讲,只要是能承载信息的载体,都可以称为“语言”,比如,古代的结绳记事、盲文、哑语、摩斯密码等。

应用:

  • 解释器模式代码实现比较灵活,没有固定的模板。代码实现的核心思想,就是将语法解析的工作拆分到各个小类中,以此来避免大而全的解析类。一般的做法是,将语法规则拆分一些小的独立的单元,然后对每个单元进行解析,最终合并为对整个语法规则的解析。

中介模式

概念:

  • 中介模式定义了一个单独的(中介)对象,来封装一组对象之间的交互。将这组对象之间的交互委派给与中介对象交互,来避免对象之间的直接交互。
  • 中介模式的设计思想类似于中间层,通过引入中介这个中间层,将一组对象之间的交互关系(或者说依赖关系)从多对多(网状关系)转换为一对多(星状关系)。最小化对象之间的交互关系,降低代码复杂度,提高代码可读性和可维护性。

中介模式 VS 观察者模式:

  • 简介:

    • 观察者模式有多种实现方式,观察者需要被注册到被观察者中,被观察者状态更新需要调用观察者update方法。但是,在跨进程的实现方式中,可以利用消息队列实现彻底解耦,观察者和被观察者都只需要跟消息队列交互,观察者完全不知道被观察者的存在,被观察者也完全不知道观察者的存在。
    • 中介模式也是解耦对象之间的交互,所有参与者都只与中介进行交互。
  • 区别:

    • 在观察者模式中,尽管一个参与者既是观察者,同时也可以是被观察者,但是,大部分情况下,交互关系往往都是单向的,一个参与者要么是观察者,要么是被观察者,不会兼具两种身份。也就是说,在观察者模式的应用场景中,参与者之间的交互关系比较有调理。
    • 中介模式中,只有当参与者之间的交互关系错综复杂,维护成本很高的时候才考虑使用中介模式。因为中介模式的应用汇带来一定的副作用,它有可能产生大而复杂的“上帝类”。
  • 应用:

    • 参与者之间交互比较有条例,一般是单向,要么观察者,要么被观察者,使用观察者模式。
    • 如果一个参与者状态改变,其他参与者执行的操作有一定先后顺序要求,此时使用中介类。