设计模式

继承会导致以下问题:

  1. 1.
    为了满足多态性, 子类无法减少继承来的接口中的成员
  2. 2.
    为了避免出现令人意外的行为, 子类覆盖父类的方法时, 需要确保新行为与父类的行为兼容
  3. 3.
    由于子类在实现时需要了解父类的实现细节, 继承实际上是一种破坏父类封装的行为
  4. 4.
    由于子类与父类紧耦合, 父类的修改可能会破坏子类的行为
  5. 5.
    使用继承的解决方案可能会导致组合爆炸, 子类激增:
    卡车 -> 交通工具
    汽车 -> 交通工具
    电动卡车 -> 卡车
    混合动力卡车 -> 卡车
    电动汽车 -> 汽车
    混合动力汽车 -> 汽车
    自动驾驶电动卡车 -> 电动卡车
    自动驾驶混合动力卡车 -> 混合动力卡车
    自动驾驶电动汽车 -> 电动汽车
    自动驾驶混合动力汽车 -> 混合动力汽车

正确的(组合)做法是把"动力"和"驾驶方式"作为交通工具的两种维度(类的属性)拆分出去.

该模式是为替代类原本的构造函数而存在, 主要用于将返回值类型从具体的类换成接口, 或创建与类自身类型相对应的子组件.
工厂方法也可以是"重写自父类"或者"实现抽象类".

本质上是为了把代码从具体到特定类的 new Constructor() 中解耦.

该模式的"抽象工厂"指的是一种具有多个工厂方法的接口(interface).
通过实现此接口, 将得到对应的"工厂类".
通过调用"工厂类"的工厂方法, 可得到对应的对象.

本质上是把具体的工厂方法进一步抽象成更具通用性的工厂方法, 也用于对工厂方法进行分组
(通常被用于组件的风格化).

举例来说, 我们有一个"生产家具"的抽象工厂, 这个抽象工厂定义了"生产椅子"和"生产桌子"两种工厂方法.
接着我们实现该抽象工厂, 创建一个"极简风格家具工厂"的工厂类和一个"华丽风格家具工厂"的工厂类.
"极简风格家具工厂"生产出的是"极简风格椅子"和"极简风格桌子".
"华丽风格家具工厂"生产出的是"华丽风格椅子"和"华丽风格桌子".
虽然家具的风格截然不同,
但这两个工厂类使用了相同的抽象工厂作为接口("生产椅子"和"生产桌子"), 因此可以相互替换.

当一个类的构造过程需要很多参数时, 可以创建一个名为建造者(Builder)的接口.
该接口包含与参数有关的大量setter方法, 将构造所需的参数分解成建造者的属性.
通过一步步设置这些属性, 我们最终可以通过内置的一个能够利用这些属性的工厂方法来创建出所需的复杂对象.
建造者模式是"组合优于继承原则"的体现, 允许一个类的构造函数有非常多的参数.

本质上, 建造者只是以类实现的工厂方法, 由于参数太多, 只能通过将参数化作属性的形式来增强代码可读性.
该模式也用来在支持重载构造函数的编程语言里避免重载构造函数
(于是可以只使用那个参数数量最多的构造函数签名).

事实上也可以通过将所有参数放进一个Map对象, 将对象传给来工厂方法来实现相同的模式.

该模式是为了能够重用一个已经存在的对象的属性而出现的, 原型模式本身没有太多特殊之处.
绝大多数语言对原型(Prototype)模式的应用只是提供clone方法, 把相关的属性复制一遍.

JavaScript等内置原型支持的语言, 在应用原型模式时不需要克隆,
只需要把原型挂载给一个新的空对象就可以实现重用, 当设置新的属性时, 新属性会遮蔽掉原型上的属性.

单例模式是一种特殊的工厂方法, 这个工厂方法作为创建该类实例的唯一方法暴露给客户.
作为工厂方法, 它会判断是否已经创建过实例, 如果创建过, 则将那个实例作为结果返回.

编程语言需要支持将单例类的构造函数访问修饰符设置为私有, 以避免通过其他方式创建类的实例.

静态类在一定程度上可以替代单例模式, 主要缺点是静态化不支持手动初始化.

单例模式本质上是全局变量的一种变体.

单例模式违反了单一职责原则, 它有两项职责:

  • 保证类只有一个实例
  • 为该实例提供一个全局的访问方式

适配器模式就是通过一种名为适配器的中间件将一种接口转换为另一种接口.

对象适配器是一种通过封装实现的适配器:
适配器实现了其中一个对象的接口, 并对另一个对象进行封装.

类适配器是一种通过多重继承实现的适配器(因此只适用于支持多重继承的编程语言):
适配器通过重写对应的方法, 将其改写为我们需要的实现.

桥接模式是一种为了防止子类激增而诞生的模式.
桥接模式创建了一层面向客户的桥接层, 桥接层之后的部分可以是多种不同的实现, 这些实现可以相互替换.

举例来说, 一个面向多种平台的GUI程序, 其背后需要调用Windows, Linux和MacOS三种操作系统的API.
如果没有桥接层, 则我们要么为每一种API建立一个GUI, 要么在每次调用功能时都用条件语句判断当前的操作系统.
在有了桥接层以后, 不同系统的API被整合进桥接层相同的接口里,
对于不同的操作系统, 只需要切换背后的实现就可以了.

桥接模式与适配器模式有点像, 适配器是用来转换已存在的接口, 而桥接层是为了创建出统一的接口.

组合模式是一种类似于树结构的继承关系, 树根是一个接口, 树中的每个对象都是树根的实现.

组合模式中包含以下两种基本对象:

  • 叶节点: 树根的实现, 没有子元素.
  • 容器: 树根的实现, 可以容纳树根作为子元素, 在调用树根定义的方法时, 会遍历调用其子元素的相同方法.

老实说, 该模式的应用场景比较少见.
注1: 浏览器的DOM只是一种树, 而不是组合模式, 因为方法调用不会传递.
注2: 抽象语法树(AST)只是一种二叉树, 而不是组合模式, 因为AST是一种结构体, 不带有方法.
如果我们给AST加上方法, 可能就接近于组合模式的情况了.

装饰器模式首先创建一个继承了被装饰类的子类BaseDecorator,
这个子类的构造函数是被装饰类本身, 该类会保存被装饰类以供之后调用.
然后, 该BaseDecorator类将作为真正包含功能的各种装饰器的父类被继承.
每个装饰器的方法都会调用其父类的方法, 然后实现自己的功能.

在使用装饰器时, 首先初始化一个被装饰类, 然后将该被装饰类作为装饰器的构造函数参数, 创建出装饰器.
接着循环这一过程, 用装饰器作为下一个装饰器的构造函数参数, 创建出下一层装饰器.
如此一来, 就形成了一种栈(Stack)结构, 当我们调用装饰器的方法时, 会一层层向下调用保存的对象.

例如, 我们有一个通知器类Notifier, 它有一个send方法, 调用此方法意味着发出通知.
然后我们实现BaseDecorator类, 该类继承自Notifier类,
该类有一个wrappee属性用来保存被装饰的对象, 其send方法将调用wrappee对象的send方法.
接着我们实现EmailDecorator类, 该类继承自BaseDecorator类,
它的send方法会调用其父类的send方法, 然后执行自己的行为(发送电子邮件通知).
接着我们实现SMSDecorator类, 该类继承自BaseDecorator类,
它的send方法会调用其父类的send方法, 然后执行自己的行为(发送短信通知).

现在我们要串联起EmailDecorator和SMSDecorator类.
首先, 创建一个Notifier类, 这个类可以什么功能也没有, 只是作为起点.
然后, 我们创建EmailDecorator类, 构造函数参数为刚刚创建的Notifier类的实例.
然后, 我们创建SMSDecorator类, 构造函数参数为刚刚创建的EmailDecorator类的实例.

当我们调用SMSDecorator类的send方法时, 它会按以下顺序调用对应类的send方法.
SMSDecorator.send -> EmailDecorator.send -> Notifier.send
实际功能的执行顺序为Notifier(没有功能) -> EmailDecorator -> SMSDecorator

个人认为这是一种反模式, 因为用装饰器串联起多个对象不如遍历一个对象集合来得方便直观.
在使用装饰器时, 还需要浪费一个对象用于保存空的类
(尽管也可以不让BaseDecorator继承其他的类, 但这会导致BaseDecorator的构造函数变复杂).
装饰器Decorator的语义也很糟糕, 它在功能上等同于支持串联调用的Command命令对象, 却担任了两种职责:

  • 保存被装饰对象
  • 提供新功能

在Python和TypeScript这样支持函数式范式的编程语言里, 装饰器直接作为语法出现,
与OOP繁琐的装饰器模式有着相当大的区别.
尽管在本质上极为相似, 但这些装饰器语法往往不会被用来实现功能,
而是用于提升高阶函数的可读性或标记用于反射的元数据.

外观模式是一种用于解耦的模式, 它创建了一个名为外观类的中间件, 将复杂的细节隐藏在外观类的实现里.
客户只使用外观类就可以满足需求, 无需关心外观类本身是怎么实现功能的.

OOP与结构体+函数的不同之处在于OOP的类被用于隐藏状态信息,
由于这些状态信息是独立存在的, 那么当存在着大量的类实例时, 状态信息就会占用大量内存.
假如这些状态信息有一部分可以共用, 想必可以减轻内存消耗.

该模式便是通过将可共用的状态信息提取到单独的类实例里(这种类叫做享元类),
通过共用实例达到减轻内存消耗的目的.

游戏编程经常会在海量对象中使用同一个享元类(例如纹理), 因此享元模式也被GPU硬件支持.

代理模式跟外观模式很相似, 它创建了一个名为代理类的中间件, 插入在客户和实际实现之间.
与外观模式的区别在于, 代理模式不隐藏复杂性和重构代码, 代理模式通常使用与实际实现相同的接口.
使用代理模式的目的在于, 代理类作为中介者,
可以在调用实际实现之前和之后完成那些不需要被用户关注到的额外行为.

一个典型的代理类只有一个构造函数参数, 就是被代理的对象.
代理类的方法被调用时, 会去调用被代理对象的方法, 在此调用之前和之后插入我们需要的额外行为.

代理模式的功能:

  • 延迟被代理对象的初始化(通常是很重的对象).
  • 访问控制.
  • 执行远程过程调用.
  • 记录日志.

职责链是一种按顺序执行命令对象的模式, 命令对象可以决定是否继续调用下一个命令对象.
功能上类似于函数式编程里的Pipeline或Waterfall, 只不过职责链是松耦合的.

每个命令对象(被称作处理者, Handler)都包含:

  • next字段用来保存下一个命令对象
  • setNext方法用来设置下一个命令对象
  • handle方法会执行动作, 视具体情况也可以选择拒绝处理.
    此方法会检查next字段是否存在, 如果存在, 则继续调用next.handle, 视具体情况也可以拒绝继续处理.
// 创建处理者
h1 = new HandlerA()
h2 = new HandlerB()
h3 = new HandlerC()
// 形成职责链
h1.setNext(h2)
h2.setNext(h3)
// 执行
h1.handle(request)

命令模式是由于部分OOP语言不支持回调函数而出现的替代方式(这一点是得到GoF承认的).
除非带有状态, 否则命令模式和回调函数没有本质区别.

在少数情况下, 命令模式比回调函数要好, 因为命令对象可以存储一些与命令有关的元数据.
闭包尽管也可以做到类似的事, 但闭包的状态往往只能在函数内部访问.

命令模式对架构的影响是它能通过将代码提取成函数将实际的行为间接化, 从而将避免代码堆积在一起:

// 未重构
function handleInput() {
if (isPressed(BUTTON_A)) jump()
else if (isPressed(BUTTON_B)) fire()
else if (isPressed(BUTTON_X)) swapWeapon()
else if (isPressed(BUTTON_Y)) heal()
}
// 非OOP重构, 提取成函数
function handleInput() {
if (isPressed(BUTTON_A)) handleButtonA()
else if (isPressed(BUTTON_B)) handleButtonB()
else if (isPressed(BUTTON_X)) handleButtonX()
else if (isPressed(BUTTON_Y)) handleButtonY()
}
// OOP重构, 提取成方法
// 这要好于非OOP版本, 因为按钮实例在内部还可以运用策略模式
function handleInput() {
if (isPressed(BUTTON_A)) ButtonA.execute()
else if (isPressed(BUTTON_B)) ButtonB.execute()
else if (isPressed(BUTTON_X)) ButtonX.execute()
else if (isPressed(BUTTON_Y)) ButtonY.execute()
}

迭代器是一种用来辅助遍历数据结构的对象.
迭代器总是使用统一的接口, 因此可以对客户隐藏底层数据结构的细节.

interface Iterator<T> {
getNext(): T
hasMore(): boolean
}

多个对象之间的互相调用很容易发展成混乱的局面, 降低代码的可维护性.
引入中介者对象后, 所有对象都只与中介者进行沟通, 解耦了对象之间的依赖关系.

中介者模式与观察者模式很相似, 事实上最流行的中介者模式实现就是观察者模式.
中介者的缺点在于, 中介者具有太多的"知识", 因此会发展成一个什么都了解的"上帝对象".

interface Mediator<T> {
notify(sender: T, event: string): void
}
class DialogMediator implements Mediator<Button> {
confirmButton: Button
cancelButton: Button
dialogWindow: Window
notify(sender: Button, event: string) {
if (sender === this.confirmButton && event === 'click') {
doSomething()
this.dialogWindow.close()
}
if (sender === this.cancelButton && event === 'click') {
this.dialogWindow.close()
}
}
}
class confirmButton implements Button {
mediator: Mediator
click() {
this.mediator.notify(this, 'click')
}
}
class cancelButton implements Button {
mediator: Mediator
click() {
this.mediator.notify(this, 'click')
}
}

备忘录模式用于保存对象的状态, 以便日后恢复.

该模式实际上就只是给目标对象加上save和restore两个方法而已:

  • save方法返回一个Memento对象, 该对象包含其原发器(Originator, 也就是目标对象本身)当前的所有状态.
  • restore方法接收一个Memento对象, 对原发器进行操作, 恢复所有状态.

此外, 还可以创建一个用于保管状态的History类, 以实现栈式(Stack)的状态记录.

即PubSub, 观察者订阅者模式, 一种相当常见, 广泛使用的模式.
此模式可以完美解耦对象之间的调用依赖, 因此在各种软件架构里也经常出现.

该模式创建了一种作为发布者的类Publisher, 任何从发布者接收消息的类都被称为订阅者Subscriber.
订阅者会将自己注册到发布者里, 当有对象调用发布者的通知方法时, 发布会就会向已注册的订阅者发送消息.

策略模式定义了一种策略接口, 不同的策略被封装在实现了该接口的类里.

策略模式几乎等同于命令模式, 区别只在于语义.

状态模式是一种实现有限状态机的模式, 本质上是策略模式的一种应用.
在该模式下, 一个状态下的所有行为都被封装在一个状态对象里.
状态对象在创建时通过构造函数接收相关的上下文.

通过调用上下文里的changeState/setState方法, 可以改变上下文使用的状态对象.
状态改变是由状态对象主动发起的, 主要有2种实现方式:

  • 状态对象直接调用上下文的changeState方法.
  • 通过返回值返回下一个状态, 由上下文来调用changeState方法.
    这种方式要好一些, 因为上下文可以控制状态改变发生的时间点, 比如等到委托给状态对象所有的行为执行完毕后才改变状态.

状态模式避免了传统的(也是最简单的)有限状态机通过一个集中于一处的大量条件判断语句和开关变量来决定行为模式的做法,
每种状态的行为模式都被分解到对应的状态对象里, 使得不同状态下的行为变得直观清晰.
尽管这种模式仍然不如单独将状态变更抽象成状态机对象来得彻底(可推理性和可测试性更好), 但也已经是很大的进步了.

public interface IState {
void Update(float dt);
void HandleInput();
// 进入状态时的钩子, 如果需要重用状态对象, 则在进入状态时可以通过此钩子的参数传入上下文对象.
void Enter();
// 退出状态的钩子.
void Exit();
}
public class EmptyState : IState {
public void Update(float dt) {}
public void HandleInput() {}
public void Enter() {}
public void Exit() {}
}
public class MoveState : IState {
private Character char;
MoveState(Character char) {
this.char = char;
}
public void Update(float dt) {
// ...
}
public void HanldeInput() {
// ...
}
public void Enter() {
// ...
}
public void Exit() {
// ...
}
}
public class StateMachine {
private Dictionary<string, IState> stateMap = new Dictionary<string, IState>();
private IState currentState = new EmptyState();
public void Add(string name, IState state) {
stateMap.Add(name, state);
}
public void Remove(string name) {
stateMap.Remove(name);
}
public void ChangeState(string name) {
currentState.Exit();
IState nextState = stateMap[name];
nextState.Enter();
currentState = nextState;
}
public void Update(float dt) {
currentState.Update(dt);
}
public void HandleInput() {
currentState.HandleInput();
}
}

有时代码里会出现仅有少数位置不同的重复代码,
模板方法模式将代码分解, 把重复的代码块封装成方法,
把不同的代码部分暴露给子类去重写(或者作为抽象方法强制要求子类实现它),
从而达到减少重复代码的效果(DRY原则).

访问者模式将行为放在一个名为访问者的类里,
在不修改原有代码(被访问者)的前提下新增行为(访问者方法).

访问者具有对被访问者进行操作的方法 visit(被访问者):

class Visitor {
visit(node: Node) {
if (node.type === foo) {
...
}
if (node.type === bar) {
...
}
...
}
}

被访问者可能会有一个 accept(访问者) 方法, 该方法会将自身(this)作为参数调用访问者的方法:

class Node {
accept(visitor: Visitor) {
visitor.visit(this)
}
}

对象池模式是一种将暂时不用的对象保留, 以便在之后重用对象的模式.
对象池模式可以避免大量创建和销毁内存, 从而提高性能.
可以防止内存碎片(内存中存在大量不连续的片段, 导致无法申请到大块内存).

对象池模式不推荐在有垃圾回收的语言里使用.