设计模式概览

《设计模式:可复用面向对象软件的基础》笔记

Posted by John Mactavish on October 21, 2020

引言

学习的有效方法是理论与实践相结合,所以既要经常 coding 又要阅读经典书籍; 人对自然的认识程度是螺旋上升的,所以 coding 与读书要交替进行。

《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software)是软件工程领域 有关软件设计的最有名的一本书,提出和总结了对于一些常见软件设计问题的标准解决方案,称为软件设计模式。 该书作者是埃里希·伽玛(Erich Gamma)、Richard Helm、Ralph Johnson 和 John Vlissides,后以“四人帮”(Gang of Four,GoF)著称。

我初读这本书大概是一年前,当时只是囫囵吞枣,因为代码量不足以支撑我的理解。在通过实践获得了一些经验的今天,我重新拾起这本书, 于上课时翻翻,竟然只花了两三天时间就基本看完了。而且我对其中的一些内容还产生了“这么自然的设计用得着写进书里作为模式吗”的想法。 想必这就是所谓的“理论与实践相结合”的成果吧。既然如此,索性最后写一篇笔记概述性地总结一下书中的设计模式,以作为消化了这本书的标志, 我今后大概再也不会翻开这本书了,毕竟对我来说已经没有什么新东西了。

书中共计介绍了 23 种设计模式,有两种分类方法:按模式主要用于类还是对象分类和按目的准则分类(如下图,来源于书中插图)。 章节设计是根据后一种分类来的,所以我下面也按照这种分类依次介绍。按本人感觉的新鲜度与重要性程度,23 种设计模式篇幅不等。

创建型模式

创建型模式全部是关于如何创建实例的。这组模式可以被划分为两组:类创建模式及对象创建模式。 类创建模式在实例化过程中有效地使用类之间的继承关系,对象创建范例则使用代理来完成其任务。

抽象工厂 (Abstract Factory)

面向对象的一个重要的设计思想是:分析出系统中不常改变的部分与经常改变的部分,并确保系统不易被经常改变的部分破坏。 一般接口稳定而具体实现易变,所以系统内各部件应该只通过接口(或抽象类)交互。所以在这里 Application 只接触 GuiFactory 与 Button, 而不关心具体的 Factory 或 Button 的实现。

同时,GuiFactory 接口中的 createButton 方法返回 Button 类型的对象,返回 Button 的哪种实现依赖于使用 GuiFactory 的哪种实现。 为了简洁起见,以上类图仅仅展示了创建一个类型对象的工厂,而在实际中(参考后面的图),通常一个工厂能够创建若干种不同类型的对象(如 Text,Form,Grid 等)。 这也体现了模块分类的思想,把不同种类的 Button 等按操作系统(WinFactory、OSXFactory)的不同分类归放。

这样也获得了将一个系列的产品族统一到一起创建的能力。比如,通过创建 Button、Form 等进一步组装 Login Interface 的方法可以 直接放进 GuiFactory 了。

public interface GuiFactory {
	Button createButton();

	Border createForm();

  LoginInterface createLoginInterface() { // default method
    createForm();
    // ...
    createButton();
  }
}

工厂方法 (Factory Method pattern)

工厂方法模式的实质是“定义一个创建对象的接口,但让实现这个接口的类来决定实例化哪个类。工厂方法让类的实例化推迟到子类中进行。”

慢着,有没有搞错,为什么配图与抽象工厂差不多。其实是因为这两个模式本来就很像。 抽象工厂模式与工厂方法模式最大的区别在于抽象工厂中每个工厂可以创建多个种类的抽象产品。 具体如何在抽象工厂与工厂方法之间选择可依据实际需求而定。

简单工厂 (Simple Factory pattern)

这不是书中介绍的 23 种设计模式之一,确是平时最常用到的工厂模式变种。

它简化了工厂的存在,用一个具体工厂的多个选项(“创建”方法的输入参数)代替用抽象工厂的多个子类来创建产品

构造器 (Builder Pattern)

又名建造模式,它用于复杂对象是一步一步创建出来的情形。它十分实用,使用场景易于识别,具体实现如下:

// Sample comes from Netty Source Code and is modified
// https://github.com/netty/netty/blob/bd8cea644a07890f5bada18ddff0a849b58cd861/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsConfigBuilder.java
public final class CorsConfigBuilder {
    final Set<String> origins;
    final boolean anyOrigin;
    boolean allowNullOrigin;
    boolean enabled = true; // 部件可能有默认值,因而不是所有部件都必须 build
    final Set<String> exposeHeaders = new HashSet<String>(); 
    long maxAge;

    CorsConfigBuilder() {
        anyOrigin = true;
        origins = Collections.emptySet();
    }

    // 部件建造方法一般返回 Builder 自身以便链式调用
    public CorsConfigBuilder allowNullOrigin() {
        allowNullOrigin = true;
        return this;
    }

    public CorsConfigBuilder disable() {
        enabled = false;
        return this;
    }

    public CorsConfigBuilder exposeHeaders(final String... headers) {
        exposeHeaders.addAll(Arrays.asList(headers));
        return this;
    }

    public CorsConfigBuilder maxAge(final long max) {
        maxAge = max;
        return this;
    }

    // 结束建造,返回产品
    public CorsConfig build() {
        // ...
        return new CorsConfig(this);
    }
}

// 有时也会把体积较小的 Builder 作为产品的内部类以更好分类归放
public final class CorsConfig {
    // correspond fields
    private final Set<String> origins;
    private final boolean anyOrigin;
    private final boolean enabled;
    private final Set<String> exposeHeaders;
    private final long maxAge;
    private final boolean allowNullOrigin;

    // 仅支持由 Builder 创建此类
    CorsConfig(final CorsConfigBuilder builder) {
        // 深拷贝以防 Builder 被重复使用
        origins = new LinkedHashSet<String>(builder.origins); 
        anyOrigin = builder.anyOrigin;
        enabled = builder.enabled;
        exposeHeaders = builder.exposeHeaders;
        maxAge = builder.maxAge;
        allowNullOrigin = builder.allowNullOrigin;
    }
}

单例模式 (Singleton pattern)

许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中, 该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。 这种方式简化了在复杂环境下的配置管理。这个模式要求一个类必须只有一个实例存在。

这个模式也十分实用,推荐的 Java 具体实现有三:

// 双重检查模式、懒汉模式、线程安全
public class Singleton {
    private volatile static Singleton singleton;  // volatile 修饰静态对象
    private Singleton (){}  // 屏蔽构造器

    public static Singleton getSingleton() {  
        if (singleton == null) {  // 同步前通过判读是否初始化,减少不必要的同步
            synchronized (Singleton.class) {  // 同步,线程安全
                if (singleton == null) { // 双重检查 
                    singleton = new Singleton();  // 创建 singleton 对象
                }  
            }  
        }  

        return singleton;  
    }  
}

为什么要使用 volatile 修饰?

虽然已经使用 synchronized 进行同步,但在创建对象时,会有下面的伪代码:

memory=allocate(); //1:分配内存空间
ctorInstance();   //2: 初始化对象
singleton=memory; //3: 设置singleton指向刚排序的内存空间

当线程 A 在执行上面伪代码时,2 和 3 可能会发生重排序,因为重排序并不影响运行结果,还可以提升性能,所以 JVM 是允许的。 如果此时伪代码发生重排序,步骤变为 1->3->2,线程 A 执行到第 3 步时,线程 B 调用 getsingleton 方法,在判断 singleton==null 时不为 null, 则返回 singleton。但此时 singleton 并还没初始化完毕,线程 B 访问的将是个还没初始化完毕的对象。 当声明对象的引用为 volatile 后,伪代码的 2、3 的重排序在多线程中将被禁止。

// 静态内部类模式、懒汉模式、线程安全
public class Singleton { 
    private Singleton(){}

    public static Singleton getSingleton(){  
        return Inner.instance;  
    }  
    private static class Inner { // 静态内部类是在使用的时候初始化的 
        private static final Singleton instance = new Singleton();  
    }  
} 

相较于“实现一”,更推荐这一种,因为它更加简洁。

// 枚举单例模式、饿汉模式、代码简洁、线程安全、能在序列化和反射中保证实例的唯一性
public enum Singleton {
    INSTANCE;
    
    public void doSomething(){
        //todo doSomething
    }
}

Joshua Bloch 大神在《Effective Java》中推荐这个方法。不过实际中并没有被广泛采用, 而且可读性稍微较差一些。

原型 (Prototype pattern)

其特点在于通过“复制”一个已经存在的实例来返回新的实例,而不是新建实例。 被复制的实例就是我们所称的“原型”,这个原型是可定制的。 原型模式多用于创建复杂的或者耗时的实例,因为这种情况下,复制一个已经存在的实例使程序运行更高效;或者创建值相等,只是命名不一样的同类数据。

操作类似于某些语言中的结构体更新语法(struct update syntax)。在 Java 等语言中实现时可以有多种写法。 有时为了真正解决实例创建的耗时问题还会共享一部分底层数据结构(immutable)。

// 来自 Rust 文档
// https://kaisery.github.io/trpl-zh-cn/ch05-01-defining-structs.html
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};

// 使用结构体更新语法为一个 User 实例设置新的 email 和 username 值,不过其余值来自 user1 变量中实例的字段
let user2 = User {
    email: String::from("another@example.com"),
    username: String::from("anotherusername567"),
    ..user1
};

结构型模式

这组模式都是关于类及对象组合关系的。

适配器 (Adapter pattern)

通过增加一个适配器,可以将一个无法修改的类(第三方库,或已向用户暴露 API 的库)的接口转接成另一种接口,解决了接口不兼容问题。

Adapter 与 Target 对接可以是继承 Target 类,也可以是实现 Target 接口;与 Adaptee 对接可以是组合(类中持有 Adaptee 实例)或者 继承(当然,不能同时继承 Target 类和 Adaptee 类)。

桥接 (Bridge pattern)

它把事物对象和其具体行为、具体特征分离开来,使它们可以各自独立地变化。

如上图所示,Window 与 WindowImp 分离,前者是事物对象,后者是具体行为(不同操作系统的 Window)。 前者组合了后者,用具体的行为(imp -> DevDrawLine())完成抽象的动作(DrawRect())。

其实,我认为桥接模式还可以进一步推广:一个抽象类可以按一种分类标准衍生出子类,当存在多种分类标准时,就针对每种分类标准建立一个抽象类,最终组合起来。例如,上图中存在两种分类标准:按操作系统分和按功能分, 那么分别建立抽象类(Window 与 WindowImp),最后组合(Window 持有 WindowImp 实例)。否则就会发生“组合爆炸”现象, 即若各种分类标准分别有 M1, M2, M3, … 种分法,本可以(利用桥接模式)只设计 M1 + M2 + M3 + … 种具体类,结果 (不利用桥接模式)设计出了 M1 * M2 * M3 * … 种具体类(数量大幅增加)。

组合 (Composite pattern)

把多个对象组成树状结构来表示局部与整体,这样用户可以一致地对待单个对象和对象的组合。

在对于树形结构(文件路径、抽象语法树)建模时很实用。

sealed trait Expr { // Component
    def value: Value
}

case class BinaryOp(op: Operator, a: Expr, b: Expr) extends Expr { // Composite
    def value: Value = op(a.value, b.value)
}

case class AtomInt(i: Int) extends Expr { // Leaf
    def value: Value = Value(i)
}

Component 的接口设计一般有两种方案,一种是提供所有子类的接口的并集,另一种是提供交集。 前者可能导致 Component 接口臃肿,且根据当前实际使用的子类的不同,一些接口会事实上无法使用; 而后者可能导致 Component 接口过于简陋,以至于无法用其进行有效操作而不得不向子类 cast。 具体怎么设计要根据实际情况而定。

装饰器 (Decorator pattern)

向某个对象动态地添加更多的功能。装饰模式是除类继承外另一种扩展功能的方法。 比如,我们为所有的 VisualComponent 增加边框(Border)与滚动条(Scroll),为了防止出现桥接模式中介绍的“组合爆炸”, 采用装饰器实现。

带边框或者滚动条的 VisualComponent 应该还是 VisualComponent,装饰不改变对象的性质,所以 Decorator is an VisualComponent(继承/实现被装饰的类/接口)。 同时,Decorator 持有一个被装饰对象,这样就可以代理它原本的功能。

Java 标准库的 java.io.InputStream 就是装饰器模式的好例子。FilterInputStream 为所有其他的 InputStream 提供一些装饰功能, 如缓存(BufferedInputStream)、可放回(PushbackInputStream)等。

外观 (Façade pattern)

又叫作门面模式,是一种通过为多个复杂的子系统提供一个一致的接口,而使这些子系统更加容易被访问的模式。 该模式对外有一个统一接口,外部应用程序不用关心内部子系统的具体细节,这样会大大降低应用程序的复杂度,提高了程序的可维护性。 简单来说,就是体现了模块化开发的思想,模块对外仅仅提供简洁的接口,而封装内部细节

享元 (Flyweight pattern)

运用共享技术来有效地支持大量细粒度对象的复用。它通过共享已经存在的对象来大幅度减少需要创建的对象数量、 避免大量相似类的开销,从而提高系统资源的利用率。熟悉函数式编程的应该对这一思想很熟悉,这不就是尽可能 共享(最好是 immutable 的)数据结构吗。

值得注意的是共享结构的粒度问题。考虑 Scala 中的 Vector 为什么用高分支因子的 Trie 树作为底层结构来存储 Immutable IndexedSeq 而 不用很自然想到的一整块数组。就是因为前者把整个 IndexedSeq 的数据分散了,在某小部分数据更新后依然可以共享其他小部分的数据; 而后者对于单个元素的修改可能都会导致整块数组的复制(Copy on Write),并且其任意长度的切片(Slice)都可能会持有整块数组的引用以使数组无法被 GC 回收。

代理 (Proxy pattern)

由于某些原因(实际对象在远程机器上,需要控制访问权限等原因)需要给某对象提供一个代理以控制对该对象的访问。 这时,用户对象不直接引用目标对象,代理对象作为用户对象和目标对象之间的中介。

行为型模式

这组模式都是关于对象之间如何交互的。

责任链 (Chain-of-responsibility pattern)

为了避免请求发送者与多个请求处理者耦合在一起,让请求的所有处理者通过持有下一个对象的引用而连成一条链; 当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

比较常见的例子是 UI 中的事件冒泡:

如果处理者是同构的(实现同一接口或继承同一类),可以利用 List[Handler],Queue[Handler],Stack[Handler] 等把递归的请求传导变成迭代的。 否则就只能写成递归形式。责任链常常与组合模式同时使用,比如组合模式中举的例子里 value 函数的调用就属于责任链模式。

命令 (Command pattern)

将一个请求封装为一个对象,使发出请求的对象和执行请求的对象解耦,两者之间仅通过命令对象进行沟通。 同时方便对命令对象进行缓存、记录、共享与管理。熟悉函数式编程的应该可以体会到这是一种数据驱动的设计模式, 它对程序涉及到的物品尽可能细致地建模。命令模式是非常实用而常见的。比如:

// Order 和 HttpRequest 就是“命令对象”,很自然,你总不会传一堆参数吧

def update(order: Order): Result = {
    order match {
        case Order(id, userName, product, offer) => // ...
        // ...
    }
    // ...
}

def handler(req: HttpRequest): HttpResponse = {
    // ...
}

解释器 (Interpreter pattern)

当系统中某一特定类型的问题发生的频率很高,考虑将这些问题的实例表述为一个语言中的句子(So, Domain-Specific Language?)。 基于此可以定义语言的文法,并且建立一个解释器来解释该语言中的句子。一般来说很难遇到这种情境吧……除非你在开发大型专业软件。 一般人写个状态机还是很轻松的,但是定义 DSL、写工业级解释器所需的知识不是这儿三言两语就能讲清楚的,还是交给专业的人来做吧。

迭代器 (Iterator pattern)

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。 迭代器对象使下面的功能成为可能(虽然未必真的实现了):

  • 并发安全地并行迭代
  • 迭代不要求加载所有元素到内存
  • 重复使用单个迭代器对象
  • 主动控制迭代过程

没想到这也是设计模式, 基本上多数语言都会在标准库中提供这种东西,它常用到我觉得已经没什么好讲的了。

public interface Iterator {
    boolean hasNext();

    Object next();
}

public interface Container {
    public Iterator getIterator();
}

中介者 (Mediator pattern)

用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散, 而且可以独立地改变它们之间的交互。比如:MVC 框架中 C(控制器)就是 M(模型)和 V(视图)的中介者。 更加没想到这也是设计模式,添加中间层以减少耦合应该是面向对象设计的自然结果。

备忘录 (Memento pattern)

在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样可以在以后将对象恢复到原先保存的状态。 与命令模式一样,是一种数据驱动的设计模式,它建模的是对象的内部状态。这也可以看做是对状态机建模,用不同的对象表示 不同的状态。对特定问题有效,例如,游戏中的存档、软件的 Undo 与 Redo、浏览器中的后退与前进、数据库的事务管理等。

状态机 (State pattern)

没什么好说的,就是写一个状态机呗。

观察者 (Observer pattern)

对于对象间的一对多的关系,当一个对象的状态发生改变时,如果所有依赖于它的对象都需要得到通知以自动更新,那么考虑观察者模式。

此模式中 Observer 的数量、种类可以经常改变而不会破坏系统的稳定性。

trait Observer[S] {
    def receiveUpdate(subject: S)
}

trait Subject[S] {
    this: S =>
    private var observers: List[Observer[S]] = Nil
    def addObserver(observer: Observer[S]) = observers = observer :: observers

    def notifyObservers() = observers.foreach(_.receiveUpdate(this))
}

访问者 (Visitor)

将作用于某种数据结构中的各元素的操作分离出来封装成独立的类,使其在不改变数据结构的前提下可以添加作用于这些元素的新的操作, 为数据结构中的每个元素提供多种访问方式,它将对数据的操作与数据结构本身进行分离。

trait Visitor[T<:Element] {
    def visit(elem: T): Unit
}

trait Element[T] {
    def accept(visitor: Visitor[T]) = visitor.visit(this) // 移交控制权
}

对于函数式语言,访问者模式可用模式匹配(pattern match)代替。

Observer pattern 与 Visitor pattern 比较相似,它们的主要区别在于前者主动权在被观察者手上(由它通知观察者), 后者主动权在观察者(或者叫访问者)手上(它主动访问被观察者)。这也就是经典的 push or pull 的区别。

策略 (Strategy pattern)

定义一系列算法,将每一个算法封装起来,并让它们可以相互替换。策略模式让算法可以独立于使用它的客户而变化。 简单来说,把算法抽象成类,使其与其他部分解耦。比如缓存池利用 LRU、LFU、FIFO 等算法工作,但算法模块不接触 任何上下文信息,与缓存池本身高度解耦。

模板方法 (Template method pattern)

定义一个模板结构,将具体内容延迟到子类去实现,是对多态特性的模式化应用。 一个常见的例子是各种系统的生命函数钩子:

abstract class Scene {
    val view: View
    // other fields

    def init():Unit = {
        onCreated()
        // other operations
    }

    def refresh():Unit = {
        onRefresh()
        // other operations
    }

    // ...

    protected def onCreated(): Unit = {} // 默认实现

    protected def onRefresh(): Unit = {}

    // ...
}

class UserDefinedScene extends Scene {
    private var config: Config = _

    def onCreated(): Unit = {
        config = loadConfig() 
    }

    def onRefresh(): Unit = {
        printLog()
    }

    // ...
}

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

新小梦的掘金博客

相当一部分内容来自 WiKi 相关词条

最后附上 GitHub:https://github.com/gonearewe