NJU 软件系统设计 2025 设计模式部分

记录对部分设计模式的本质的理解,尤其是与课程教授内容不同的独到理解🧐。

三种分类

  • 创建型:创建对象。创建逻辑和业务逻辑的解耦。 本来创建一个对象逻辑可能有 114 行,但是业务逻辑对着过程不感兴趣。所以将其封装为一个 createXXX() 接口。其实和行为型在本质上是一样的,只是强调创建对象的接口目标。
  • 结构型:处理类和对象的组合。接口和实现的解耦。 对于一些特殊的非功能需求(或者严格来说,一些与具体功能无关的需求。也有可能是功能需求,比如给一个类增加新职责,但是结构型设计模式不关心增加的是什么需求),结构型提供一套接口和类、对象的组合方式。能够满足开闭原则等设计原则。
  • 行为型:处理职责分配。行为和控制流的解耦。 行为型如前所说,控制流对具体行为不关心,所以将具体行为封装为一个 doXXX() 接口。

策略模式

为什么策略模式需要一个上下文类?

正如其名,上下文类扮演的是一个有状态的具体策略容器,职责是持有某种策略并接受状态转移。以便于在客户端的不同部位保持某一上下文。

顺便:

  • 客户端负责策略选择、上下文类创建的职责
  • 具体策略类负责策略的执行,将具体策略封装与解耦。所以是个行为型模式

其实其他模式的上下文类也起到了类似的作用,请举一反三

工厂模式

工厂方法和简单工厂

课上讲的比较复杂,比如工厂方法是将具体产品的创建推迟到具体工厂等等。但这其实造成了误解,如果仅仅是推迟创建,表面上看那工厂方法并没解决简单工厂的弊端——新增产品需要修改工厂类,违反了开闭原则。但是现实是工厂方法新增产品确实不需要修改工厂类。

根本原因是:

  • 简单工厂中,客户端创建什么类型的产品的决策是通过参数传递的。客户端传递一个如字符串的参数,工厂类中写个switch来判断创建什么产品。这样自然会违反开闭原则
  • 工厂方法中,抽象工厂是个接口或者抽象类。客户端创建产品的决策树通过创建对应的具体工厂来决定的。这时创建产品的判断逻辑是由多态或者 RTTI 来实现的。

前者的选择逻辑依赖于具体代码(switch或者ifelse),后者依赖于一系列具体工厂类。所以后者是更加符合开闭原则的选择。

所以,工厂方法的职责分配本质是:

  • 客户端:选择产品类的别名(比如字符串,或者工厂方法的工厂类)
  • 工厂:建立别名到具体产品的映射,以及负责产品的初始化
  • 产品:哈哈我是工厂模式里没有职责的摸鱼产品😆

抽象工厂

就是在工厂类封装多种产品。记住英文叫 Kit 模式。

建造者模式

  • 客户端:选择复杂产品的种类,指定产品参数
  • 指挥者:维护建造者上下文(状态),零件制造控制流
  • 建造者:每个零件具体怎么造

原型模式

无他,唯 Java.Object.clone()

状态模式

行为封装在状态里面。

不对吧,这和策略模式没区别阿???

与策略模式的区别

状态类可以持有上下文类的引用,并在封装的行为中改变上下文类的状态为其他状态。(所以类图里状态类应该有一个指向上下文类的关联或组合)

而策略模式中,各策略是完全独立的。策略也不知道上下文类的存在。

举例:从IdleStateRunningState 的转移

classDiagram
    class Context {
        +State state
        +doSomething()
    }
    class State {
        +doSomething()
    }
    class IdleState {
        -State state
        +doSomething()
    }
    class RunningState {
        +doSomething()
    }

    Context o--> State
    State <|-- IdleState
    State <|-- RunningState
    IdleState --> Context
IdleState::doSomething():
	this.state = runningState

命令模式

如何理解“命令模式解耦了发送方和接收方”

客户端预制了一些命令,命令里面封装了接收方的引用了。发送方(Invoker)只需要管调用就行了,而且只需面向抽象命令接口来执行。

宏命令 / 组合命令

一个特殊的具体命令类,包含了一堆其他的命令。执行就是把其持有的其他命令全部执行一遍。

宏命令还能包含其他命令。这样似乎可以组成一个树形的结构,变成了一个组合模式🤯。

观察者模式

类图理解

模式分为接口层和实现层。

接口层包含了 被观察对象 对 观察者 的通知逻辑。因为这个通知 notify 的逻辑是不变的,所以直接写死在接口(抽象类)中。

实现层包含了 观察者 对 被观察对象 的观察逻辑(拿状态)。因为这个观察的逻辑是可变的,所以封装在实现层中。

缺点以及替代方案

这是基于事件通知的观察者模式。缺点是频繁更新会多次触发,效率低。

替代方案是基于轮询的观察者模式。主动权从被观察对象转移到了观察者。缺点是实时性低,存在状态不一致的时机。

中介者模式

一个统一的中介。没啥好介绍的,需要具体例子具体分析。

模板方法模式

控制流中的某些部分可能有多种版本。将其抽离出来。

这些抽出来的部分有三个分类:

  • 部分逻辑(可选步骤,默认实现,也可重写)以具体方法以及具体构造函数实现问题:通过子类重写默认步骤可能违反里氏替换原则
  • 剩余逻辑(抽象步骤,必须由子类实现)声明抽象方法来让子类实现。
  • 钩子(hook):钩子是内容为空的可选步骤。即使不重写钩子,模板方法也能工作。通常放置在算法重要步骤的前后,为子类提供额外的算法扩展点

说实话,有点强行分类的意味。钩子和剩余逻辑的界限不是很明确,每个人的理解不一样的。比如 init(),你可以说是初始化的剩余逻辑,也可以说是生命周期的钩子。

部分逻辑同理。

开闭模式的体现

增加新版本的逻辑,通过子类就能拓展父类行为。 无法修改现有的逻辑,因为现有的逻辑是封装在父类的。

好莱坞原则

子类不需要调用父类,而通过父类来调用子类,将某些步骤的实现写在子类中,由父类来控制整个过程。

不要给我们打电话,我们会给你打电话

适配器模式

两种实现:类适配器和对象适配器

前者基于多继承,后者基于组合。

两种实现区别与使用场景

类适配器就是一坨:强耦合、违反合成复用、有时第三方库无法继承、需要多继承、扩展性差。

唯一的优点是某些情况下写起来方便、容易理解。

能用对象适配器就用对象适配器吧。

双向适配器

我不明白,为啥不用两个适配器?揉在一起乱麻了。

组合模式

无他,唯文件夹、文件尔。

桥接模式

讲得很抽象。其实就是对于有多个变化维度的对象:

  • 指定一个维度为主变化维度,通过继承/实现来获得变化
  • 其他的变化维度通过组合来获得变化

至于如何选取主变化维度,不同的业务视角会给出不同的答案。所以不用太过纠结,言之有理即可。

装饰模式

抽象装饰类的作用

为啥要用这个抽象装饰类?直接继承要装饰的类不就好了,这样也不违反开闭原则。而且不管对于使用方还是装饰类的创建方,都是必须要知道自己在操作哪个具体装饰类的。

并非,抽象装饰类主要目的是递归组合。装饰是可以递归组合的:

Component *c = new BorderDecorator(
                  new UnderlineDecorator(
                    new BoldDecorator(
                      new Text("Hello")
                    )
                  )
                );

只有一个具体装饰类就可以省略抽象装饰类。

外观模式

有一个外观/门户类 Facade。

  • 体现迪米特法则
  • 违反开闭原则,可能需要添加抽象外观类。

享元模式

共享对象池。记住英文为 flyweight,即轻量。

代理模式

作用与特点

  • 至少与被代理的对象有相同的接口
  • 对已有接口增加额外操作,比如权限控制、错误处理
  • 增加新的接口

类型

  • 远程代理:与被代理的对象有相同的接口,代理和代理对象物理上分开
  • 虚拟代理:重型对象的轻量表示。在必要的时候才加载重型对象
  • CoW 代理:Copy-on-Write。与虚拟代理类似
  • 保护代理:增加访问控制
  • 缓冲代理:提供临时存储空间
  • 防火墙代理:白名单、黑名单式的访问控制
  • 智能引用代理:C++ 智能指针,有引用计数。甚至可以做调用日志这种