行为型设计模式
行为型模式的研究的是对象之间构成的动态的控制流抽象关系。
1: Chain of Responsibility
一句话总结
- 本质: 使一个请求能够被多个对象依次处理,解耦请求的发送者和处理者,形成一条链。(个人认为更好的说法是,解耦每个处理者之间的耦合,倒不是发送者和处理者)
- 优点: 低耦合,链条上的每个处理者都不需要知道该请求是谁发送过来的也不需要知道链条的结构。
OOP写法
传统OOP里责任链关键是要有一个公共的Handler基类,该基类需要有一个handle方法和一个setNext来组装链条,根据输入来动态的分发请求。
csharp
// 抽象处理者
abstract class Handler {
protected next: Handler | null = null;
public setNext(handler: Handler): Handler {
this.next = handler;
return handler; // 返回 handler 以支持链式调用
}
public handle(request: string): void {
if (this.canHandle(request)) {
this.process(request);
} else if (this.next) {
this.next.handle(request);
} else {
console.log(`请求 ${request} 未被处理`);
}
}
protected abstract canHandle(request: string): boolean;
protected abstract process(request: string): void;
}
然后继承该基类生成不同的处理类,再进行组装。
scala
// 具体处理者 A
class ConcreteHandlerA extends Handler {
protected canHandle(request: string): boolean {
return request === "A";
}
protected process(request: string): void {
console.log(`处理者 A 处理了请求: ${request}`);
}
}
// 具体处理者 B
class ConcreteHandlerB extends Handler {
protected canHandle(request: string): boolean {
return request === "B";
}
protected process(request: string): void {
console.log(`处理者 B 处理了请求: ${request}`);
}
}
// 客户端调用
const handlerA = new ConcreteHandlerA();
const handlerB = new ConcreteHandlerB();
// 组装链: A -> B
handlerA.setNext(handlerB);
// 测试
handlerA.handle("A"); // A 处理
handlerA.handle("B"); // B 处理
handlerA.handle("C"); // 未处理
现代写法
在现代编程中,几乎很难再见到纯正的责任链设计,也不再使用OOP来设计,这个设计进化出了两个大名鼎鼎的近亲:Pipeline、洋葱模型。或者,统一称为Middleware。
Pipeline与责任链的区别是,责任链的每个节点可以直接决定处理过程是否停止,而Pipeline只负责处理数据并传递给下个节点,而且每个节点通常会改变数据的内容。二者的核心区别用代码来体现是非常明显的。
rust
// 定义处理阶段的 trait
trait Stage<I, O> {
fn process(&self, input: I) -> O;
}
// 管道结构体
struct Pipeline<I, O> {
// 使用 Box<dyn ...> 存储不同类型的管道
stages: Vec<Box<dyn Fn(I) -> O>>,
}
// 这里为了简化,假设所有阶段输入输出类型一致,在真正的用的时候通常会通过泛型链式组合处理不同类型的数据
struct PipelineProcessor<T> {
stages: Vec<Box<dyn Fn(T) -> T>>,
}
impl<T> PipelineProcessor<T> {
fn new() -> Self {
Self { stages: Vec::new() }
}
fn add_stage<F>(&mut self, stage: F)
where F: Fn(T) -> T + 'static {
self.stages.push(Box::new(stage));
}
fn execute(&self, mut input: T) -> T {
for stage in &self.stages {
input = stage(input); // 每一阶段都必须处理并传递
}
input
}
}
fn main() {
let mut pipe = PipelineProcessor::new();
// 添加加工阶段
pipe.add_stage(|x: i32| x + 1);
pipe.add_stage(|x: i32| x * 2);
let result = pipe.execute(5); // (5 + 1) * 2 = 12
println!("Pipeline Result: {}", result);
}
洋葱模型的本质是递归式包裹。与责任链的单向直线不同,它的核心特征是能够同时在请求处理前和处理后都执行逻辑。这个名字是因为,请求从外层进入,层层穿透核心业务,然后再原路返回,再次穿透每一层。
在Koa/Express等框架里的核心,就是洋葱模型。
typescript
// 定义上下文类型 (Context)
type Context = Record<string, any>;
// 定义中间件函数类型
type Next = () => Promise<void>;
type Middleware = (ctx: Context, next: Next) => Promise<void>;
class OnionPipeline {
private middlewares: Middleware[] = [];
// 注册中间件
use(fn: Middleware) {
this.middlewares.push(fn);
}
// 洋葱执行器
private compose() {
return (ctx: Context) => {
// 这是一个核心的递归函数,每次重新middlewares列表里掏出来一个新的中间件
// 执行完之后,调用next()然后启动新的递归下降,next()执行完成之后再递归上升
const dispatch = async (i: number): Promise<void> => {
if (i === this.middlewares.length) return;
const fn = this.middlewares[i];
// 递归执行下一个
await fn(ctx, () => dispatch(i + 1));
};
return dispatch(0);
};
}
async run(ctx: Context) {
await this.compose()(ctx);
}
}
// --- 使用示例 ---
const app = new OnionPipeline();
// 中间件 1
app.use(async (ctx, next) => {
console.log("1. 进入中间件 A (前置)");
await next();
console.log("1. 离开中间件 A (后置)");
});
// 中间件 2
app.use(async (ctx, next) => {
console.log("2. 进入中间件 B (前置)");
ctx.result = "处理完毕"; // 模拟业务逻辑
await next();
console.log("2. 离开中间件 B (后置)");
});
app.run({}).then(() => console.log("执行结束"));
回顾思考
组合责任链+Pipeline+洋葱模型,可以获得一个更强大控制能力更强的混合模型。可以写出西面这样一个更强的升级版。
这个函数在派发时,middleware不仅可以和责任链一样,决定是否继续继续传递给下一个中间件,还可以在像Pipeline一样将数据进行依次的处理,最终在结束时调用真正的finalAction。
typescript
private pipeline(message: BaseMessage, finalAction: () => void) {
let index = 0;
const next = () => {
if (index < this.middlewares.length) {
this.middlewares[index++](message, next); // 核心递归调用,传递 next 控制权
} else {
finalAction(); // 链条末端执行核心逻辑
}
};
next();
}
这种组合模式,是现代框架的典型写法。这就是一个用洋葱模型实现的中间件管道。它将业务逻辑执行前后的预处理与核心逻辑调度完全分离开了。
2: Command
一句话总结
- 本质: 把一个请求封装为对象,并携带请求的具体操作。
- 优点: 解耦请求的发送者与接受者,实现撤销/重做最典型的手段。
OOP写法
在命令模式的 OOP 标准写法中,重点在于将调用逻辑(Invoker)与执行逻辑(Receiver)通过命令接口(Command)彻底剥离。
typescript
// 接收者 (Receiver):真正干活的对象
class Light {
turnOn() { console.log("灯已打开"); }
turnOff() { console.log("灯已关闭"); }
}
// 抽象命令 (Command)
interface Command {
execute(): void;
}
// 具体命令 (Concrete Command):持有接收者,封装具体的动作
class TurnOnCommand implements Command {
constructor(private light: Light) {}
execute() { this.light.turnOn(); }
}
class TurnOffCommand implements Command {
constructor(private light: Light) {}
execute() { this.light.turnOff(); }
}
// 调用者 (Invoker):只负责触发命令,完全不知道具体动作
class RemoteControl {
private command?: Command;
setCommand(command: Command) {
this.command = command;
}
pressButton() {
this.command?.execute();
}
}
// --- 客户端使用 ---
const light = new Light();
const turnOn = new TurnOnCommand(light);
const turnOff = new TurnOffCommand(light);
const remote = new RemoteControl();
remote.setCommand(turnOn);
remote.pressButton(); // 输出: 灯已打开
现在,RemoteControl 不需要知道 Light 类存在,它只认 Command 接口。即使以后要控制"风扇"或"空调",只要实现 Command 接口,RemoteControl 的代码一行都不用改。
一个请求变成了具体的命令对象,就可以方便地给它添加:
- 日志记录: 在
execute里加个打印或者记录。 - 排队机制: 把
Command对象存进Array,依次execute()。 - 撤销功能: 给
Command接口加一个undo()方法,记录上一个命令对象,点击撤销时执行undo()。
这里的关键在于,在真正的复杂的业务里,一个command所携带的操作需要的数据可能需要由Invoker传入。所以Invoker必须知道对应的数据才行。这是一个巨大的雷点,无数项目都没避开,处理不好就会让Invoker成为上帝(掌管一切,全知全能)类。
这里有几种解决方法:
- 依赖注入:不要让
Invoker去"持有"数据,而是让Command在初始化时就注入好数据,让Invoker只负责维护Command队列。 - 闭包:
Invoker执行时,通过某种上下文或回调函数获取运行时数据。 - 使用
Mediator模式:见下文。
现代写法
在现代语言中,函数本身就是一等公民函数,闭包本质上可以看成是不需要显式实现接口的命令对象。
我们不再通过 Invoker 调用,而是将闭包塞进任务队列。例如nodejs的nextTick,或者浏览器环境下的 requestIdleCallback。它们把要执行的动作封装成任务对象,放入队列等待系统调度。
Redux/Vuex 的Actions也是经典的命令模式,你的每一个 dispatch({ type: 'ADD', payload: 1 }) 就是一个典型的命令。它把动作序列化了,才使得可以进行撤销与重做。
回顾思考
在现代,函数式编程和闭包很大程度上代替了一部分命令模式的职责。
但是,对于重度需要撤销与重做的应用(比如vscode),往往还是严格遵循OOP中的命令模式,这是因为因为撤销逻辑往往非常复杂,需要维护大量的状态快照,而闭包往往承受不了这种复杂的要求。
不过,命令模式的最大缺点正如之前说的,Invoker非常容易变成上帝类,导致代码混乱。
3: Interpreter
一句话总结
- 本质: 编译器限定,主要是关于如何解析抽象语法树,应该定义为一种编译器技术,不建议作为一种设计模式,因为不具备普适性。
- 优点: ------
回顾思考
在业务代码中不应该使用,否则一年也写不完代码了,属于是编译器领域才会用到的特色设计。
4: Iterator
一句话总结
- 本质: 一种不需要知道对象内部结构前提下,也能访问对象内部的方法。
- 优点: 解耦外部访问者和对象内部的关系,使外部代码不需要知道对象的内部结构。
回顾思考
现代几乎所有的编程语言中,都变成了内置的Iterator。基本已被弃用。
5: Mediator
一句话总结
- 本质: 使用一个中间对象来和其他对象交互,让其他对象之间不需要直接引用,只需要知道中介对象即可。(和现实里的中介一模一样)
- 优点: 解耦对象与对象之间的直接引用。
OOP写法
每个对象的引用需要持有一个中介的引用,当自己变化时通知中介,这些对象可以继承一个Colleague基类。
然后中介变成了一个巨大无比的,而且还需要手动维护的调度中心,根据自己被其他对象调用的情况来判断应该怎么做。
scala
// 中介者接口
interface Mediator {
notify(sender: Colleague, event: string): void;
}
// 同事类基类
abstract class Colleague {
constructor(protected mediator: Mediator) {}
}
// 具体同事类:只负责通知中介者
class UserA extends Colleague {
doAction() {
console.log("UserA 完成了操作");
this.mediator.notify(this, "A_COMPLETED");
}
}
class UserB extends Colleague {
react() {
console.log("UserB 收到指令,开始行动");
}
}
// 具体中介者:持有所有同事的引用,负责逻辑流转
class ConcreteMediator implements Mediator {
private userA?: UserA;
private userB?: UserB;
setUsers(a: UserA, b: UserB) {
this.userA = a;
this.userB = b;
}
notify(sender: Colleague, event: string) {
if (event === "A_COMPLETED") {
this.userB?.react(); // 中介者在这里处理复杂的交互逻辑
}
}
}
回顾思考
它是星型拓扑的结构。解决了随着业务越来越大,每个对象都要知道其他对象才能调用的问题。但随之而来的问题是,这个中介需要知道其他的所有对象,因此他也一样极其变成上帝类。
不建议使用。现代人更喜欢 Event Bus。
6: Memento
一句话总结
- 本质: 在不破坏封装的前提下,使用一个对象存储另一个对象的状态,以便后续恢复。
- 优点: 主要目的是为了撤销和重做。
OOP写法
备忘录模式需要三个核心角色:
- Originator(发起人): 需要保存状态的对象。它负责创建备忘录,并使用备忘录恢复状态。
- Memento(备忘录): 存储 Originator 内部状态的对象。
- Caretaker(负责人/管理员): 负责保管备忘录,但不能对备忘录的内容进行操作或检查。它只负责在需要时(如撤销操作)将备忘录归还给 Originator。
typescript
// 备忘录:存储状态
class Memento {
constructor(private state: string) {}
getState(): string { return this.state; }
}
// 发起人:状态的拥有者
class Editor {
private state: string = "";
setState(state: string) { this.state = state; }
// 创建备忘录
save(): Memento { return new Memento(this.state); }
// 恢复状态
restore(memento: Memento) { this.state = memento.getState(); }
}
// 负责人:保管备忘录
class History {
private history: Memento[] = [];
push(memento: Memento) { this.history.push(memento); }
pop(): Memento | undefined { return this.history.pop(); }
}
现代写法
在现代这种模式已经演化出了更多更完善的模式。
第一种方式,直接将对象转为 JSON 或二进制流,然后存入数据库或内存缓冲区,而不是创建一个专门的 Memento 类。数据库事务里大量应用了这种Memento模式的变种思想,通常被称为MVCC(多版本并发控制)。数据库中以行数据或数据页为单位,将数据被分在磁盘和内存中,每修改一次就标记不同的版本号并且允许多个版本号并行共存。
虽然其实更严格来说,MVCC要比简单的Memento模式复杂的多,甚至是数据库设计的核心难题之一(因为数据库还涉及并发控制和事务)。但是不严谨的讲,可以认为MVCC继承了Memento的核心血脉。
第二种方式,在React/Redux架构中,通过"创建新状态"来代替"修改旧状态"。每次修改都生成一个新的State对象,通过这种方式天然实现撤销,其本质上其实可以认为是一种"差量的Memento"。
经典 Memento 通常是"全量"的,每次都备份一整个完整的Memento对象,而Redux/Immutable.js通过结构共享(只替换被修改的节点,未修改的节点直接引用旧对象指针),实现极低开销的差量存储,但代价是控制版本的代码复杂程度指数级飙升。(其实更严格来说,在现代前端中,Memento 的终极形态不是全量备份,也不是复杂的差量,而是借由不可变数据带来的"零成本引用快照"。因为数据不可变,每次修改都是生成新指针,旧指针天然就是历史快照,把Memento变成了纯粹的历史指针数组。)
回顾思考
Memento这种模式在传统的OOP里过于死板,因为每次都要保存一整个Memento对象,因此很容易导致内存问题。
但这并不是说这种模式的核心思想不正确,不然数据库也不可能采用这种模式的现代版本了。现代的演化往往都主要集中在"怎么更好的存储这些变化的状态"问题上。解决好这个问题,Memento还是非常有用而且要比Command模式方便得多的一种实现撤销/重做的设计模式。
7: Observer
一句话总结
- 本质: 当对象之间的关系为一对多时,改变其中的"一"时,让"多"能够感知并自动响应变化。
- 优点: 解耦类之间的调用耦合。
OOP写法
观察者模式由 Subject(被观察者) 和 Observer(观察者) 组成。
- Subject: 维护观察者列表,提供注册/删除方法,状态变更时触发通知。
- Observer: 定义一个更新接口,确保在
Subject变化时能得到回调。
按照二者之间数据传输层次来看,又可以分为推模式与拉模式。
在推模式下,Subject在状态变化时,主动将状态作为参数传给Observer。
优点是Subject不用查询,拿到数据直接用。缺点则是Observer必须了解Subject传递的参数结构。
typescript
// 抽象接口
interface Observer { update(data: any): void; }
interface Subject { attach(o: Observer): void; notify(): void; }
// 被观察者
class ConcreteSubject implements Subject {
private observers: Observer[] = [];
private state: string = "";
attach(o: Observer) { this.observers.push(o); }
setState(state: string) {
this.state = state;
this.notify(); // 触发通知
}
notify() {
// 推模式:直接把 state 推送过去
for (const o of this.observers) o.update(this.state);
}
}
// 观察者
class ConcreteObserver implements Observer {
update(data: any) { console.log("收到数据:", data); }
}
在拉模式下,Subject只通知Observer自己变化了,Observer需要自己去问Subject要数据。
优点是Observer只需知道Subject的引用,按需调用接口,可以根据自身逻辑选择是否获取、获取多少。在系统更加复杂时,拉模式显然是更好的选择,因为Observer知道的越少,耦合度就越低。
typescript
// 观察者接口
interface Observer {
update(subject: Subject): void; // 可以传入 Subject 引用,而非直接数据
}
// 被观察者接口
interface Subject {
attach(o: Observer): void;
getState(): string; // 暴露出获取数据的接口
notify(): void;
}
// 具体被观察者
class WeatherStation implements Subject {
private observers: Observer[] = [];
private temperature: string = "25°C"; // 内部状态
attach(o: Observer) { this.observers.push(o); }
getState() { return this.temperature; } // 观察者自己来"拉"
setTemperature(temp: string) {
this.temperature = temp;
this.notify();
}
notify() {
// 推模式里这里会传参数,拉模式这里只传 this
for (const o of this.observers) o.update(this);
}
}
// 具体观察者
class PhoneDisplay implements Observer {
update(subject: Subject) {
// 这里体现了"拉":观察者主动调用 getState()
const data = subject.getState();
console.log(`手机显示更新: ${data}`);
}
}
回顾思考
在现代,Observer模式是所有设计模式中进化最彻底的一个,它已经演变成了现代分布式系统、响应式编程和事件驱动架构的基础设施,而不仅仅是一个简单的一对多自动通知模式。例如Vue的核心,就是使用Proxy来在数据变化时通知每个应该变化的组件去重新渲染自己。浏览器的各种点击、鼠标事件,本质上也是一种推模式的Observer。
8: State
一句话总结
- 本质: 将状态表示为对象,State模式就是状态机的OOP实现。
- 优点: 解决状态复杂时if-else的深层嵌套和爆炸问题,将状态变换从业务中剥离。
OOP写法
关键是需要一个负责记录和切换状态的Context类。这时候不需要 if-else只需要无限调用 brain.request(),内部的黑盒就会自动轮转。这就是状态机的最简骨架。
typescript
// 定义状态行为接口
interface State {
handle(ctx: Context): void;
}
// 环境类:只负责持有状态、提供切换状态的方法
class Context {
constructor(public state: State) {} // 注入初始状态
request() {
this.state.handle(this); // 把自己传给状态类,供其切换
}
}
// 具体状态 A
class StateA implements State {
handle(ctx: Context) {
console.log("当前是 A 状态 -> 自动切换到 B");
ctx.state = new StateB(); // 直接通过属性赋值切换
}
}
// 具体状态 B
class StateB implements State {
handle(ctx: Context) {
console.log("当前是 B 状态 -> 自动切换到 A");
ctx.state = new StateA();
}
}
// --- 使用用例 ---
const brain = new Context(new StateA());
brain.request(); // 输出: 当前是 A 状态 -> 自动切换到 B
brain.request(); // 输出: 当前是 B 状态 -> 自动切换到 A
brain.request(); // 输出: 当前是 A 状态 -> 自动切换到 B
现代写法
在现代语言的代数数据类型与模式匹配的强大工具下,不需要像OOP那样在使用复杂的继承。甚至连状态的数据都可以不再放到Context中,而且还有完整的类型安全。
rust
// 代数数据类型 (ADT):状态本身可以携带各自特有的数据
enum State {
Unpaid,
Paid { receipt_id: String }, // 已支付状态,自带收据 ID
Shipped,
}
// 环境(Context)
struct Order {
state: State,
}
impl Order {
fn new() -> Self {
Order { state: State::Unpaid }
}
// 模式匹配,match统一处理所有状态流转
fn progress(&mut self) {
self.state = match &self.state {
State::Unpaid => {
println!("未支付 -> 支付成功!");
State::Paid { receipt_id: "REC-1024".to_string() }
}
State::Paid { receipt_id } => {
println!("已支付 (单号: {}) -> 已发货!", receipt_id);
State::Shipped
}
State::Shipped => {
println!("已发货 -> 流程已终结,无后续状态。");
State::Shipped // 保持原样
}
};
}
}
fn main() {
let mut order = Order::new();
order.progress(); // 输出: 未支付 -> 支付成功!
order.progress(); // 输出: 已支付 (单号: REC-1024) -> 已发货!
order.progress(); // 输出: 已发货 -> 流程已终结,无后续状态。
}
回顾思考
现代语言的代数数据类型和模式匹配工具完胜了经典OOP状态模式。带来了三个OOP下根本做不到的巨大优势:
- 无类爆炸: 经典模式下你要写1个接口+n个状态类。Rust 里只需要一个
enum搞定所有状态的定义。 - 类型安全: 在OOP中,如果"已支付"状态需要一个收据ID,你得在基类里想办法兼容,或者搞恶心的类型强转。而 Rust 的
enum(和 TS 的联合类型)天然允许不同状态绑定不同的数据结构 。只有切换到Paid状态时,对应的数据才是可访问的。
- 编译器穷举检查: 如果你以后给
enum State增加了一个Refunded(已退款)状态,Rust编译器会直接报错 ,强迫你在match里必须处理这个新状态。在OOP里,你漏写了一个状态类,只会出现神奇的Bug。
9: Strategy
一句话总结
- 本质: 把算法封装成对象,让调用方可在不更改源代码的情况下切换使用的算法。
- 优点: 解耦了算法的实现与调用,使算法可以独立与其他部分代码存在和演化。
OOP写法
非常简单,其实本质上,如果把当前选择的算法也看作系统的一种状态,那么Strategy模式其实可以被包含在State模式之中。甚至可以说,Strategy 模式是 State 模式的一种特例。因此下面的代码看着和State模式非常像。
typescript
// 抽象策略
interface PaymentStrategy {
calculateFee(amount: number): number;
}
// 具体策略 A:微信支付
class WeChatPayStrategy implements PaymentStrategy {
calculateFee(amount: number): number { return amount * 0.01; } // 1% 手续费
}
// 具体策略 B:支付宝
class AliPayStrategy implements PaymentStrategy {
calculateFee(amount: number): number { return amount * 0.005; } // 0.5% 手续费
}
// 环境类:负责调用策略
class OrderContext {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy; // 注入具体策略
}
// 动态更换策略
setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
execute(amount: number) {
const fee = this.strategy.calculateFee(amount);
console.log(`应付手续费: ${fee}`);
}
}
// --- 使用用例 ---
const order = new OrderContext(new WeChatPayStrategy());
order.execute(100); // 微信支付处理
order.setStrategy(new AliPayStrategy());
order.execute(100); // 动态切换为支付宝处理
现代写法
在现代语言中,简单的算法往往都被Lambda函数解决了。而在以前,比如Java中,要实现这样的功能必须强行把函数打包成一个对象,导致为了传一个只有一行的逻辑,你不得不写一堆外壳,而现代函数式语言已经解决了这个问题。
ini
fn main() {
// 定义两个简单的 Lambda 函数(闭包)
let add_one = |x| x + 1;
let multiply_by_two = |x| x * 2;
// 将 Lambda 函数作为"资源"赋值给变量
let mut strategy = add_one;
println!("执行策略 1: {}", strategy(5)); // 输出: 6
// 动态替换为另一个 Lambda 函数
strategy = multiply_by_two;
println!("执行策略 2: {}", strategy(5)); // 输出: 10
}
对于复杂的算法,往往还是需要包装成一些独立的模块和函数,但是也不再需要写一大堆条条框框的模板,只需要利用简单的状态机即可,多亏了现代函数式语言里函数已经成为了一等公民。
回顾思考
Strategy是空间维度的展开,多个并行的算法资源来回切换。
State是时间维度的展开,多个状态按时间线来回切换。
算法其实就是一种特殊的静态状态资源。在现代支持高阶函数和ADT的语言中,无需过度纠结其概念分类。
10: Template Method
一句话总结
- 本质: 在父类中定义一个算法的骨架,而将一些步骤延迟到子类中实现。
- 优点: 其实就是继承的基本技巧。
现代写法
现代写法消灭模板方法,连高级的语言特性都不需要,最常用的有两种方案:
方案一:直接用Strategy模式替代。不要让子类去继承父类,而是把可变的部分做成策略插件注入进来。(如前所讲)
方案二:高阶函数 / 中间件模式。你想在核心逻辑前后加点什么,加个中间件就行了,根本不需要去动别的代码。
回顾思考
该模式在现代基本已经被判死刑,无需也不应该再使用。
归根到底,这种方法最可恶的问题是导致程序员代码阅读心智负担极大,因为非常容易导致阅读代码时控制流混乱,在子类和父类之间来回跑。这是一种典型的OOP遗物,在现代应该被舍弃。
11: Visitor
一句话总结
- 本质: 解决在不改变数据结构的前提下,动态增加新功能的问题。
- 优点: 极强的扩展性,打破继承的局限,支持"双分派"。
OOP写法
在传统的OOP观念中,我们强调内聚,把数据和操作数据的行为写在同一个类里。 而 Visitor 模式的本质,则是反其道而行之的解耦,他把对象拆分成两个部分:
- 元素类(Element): 变成纯粹的"数据容器",只负责保存数据结构,不包含任何复杂的业务逻辑。
- 访问者类(Visitor): 变成了"行为仓库",所有的业务逻辑、计算规则、导出功能,全部分流到各个具体的Visitor中。
typescript
// 访问者接口
interface Visitor {
visitCircle(circle: Circle): void;
visitSquare(square: Square): void;
}
// 被访问的元素接口
interface Shape {
accept(visitor: Visitor): void; // 核心:接受访问者
}
// 具体元素
class Circle implements Shape {
accept(visitor: Visitor) {
// 第一次分派:运行时决定调用哪个 Visitor 的方法
// 第二次分派:把 this(Circle 类型)传过去,决定调用 visitCircle
visitor.visitCircle(this);
}
}
class Square implements Shape {
accept(visitor: Visitor) { visitor.visitSquare(this); }
}
// 具体访问者:具体的业务逻辑全在这里
class ExportSvgVisitor implements Visitor {
visitCircle(circle: Circle) { console.log("导出圆形为 SVG"); }
visitSquare(square: Square) { console.log("导出方形为 SVG"); }
}
// 具体使用
const shapes: Shape[] = [new Circle(), new Square()];
const svgVisitor = new ExportSvgVisitor();
for (const shape of shapes) {
shape.accept(svgVisitor);
}
现代写法
它和Template Method的悲惨命运不同。Visitor 模式的代码在现代依然大量存在,但它换了一种更高级的存在方式:模式匹配。
rust
// 定义数据结构 (代数数据类型)
enum Shape {
Circle { radius: f64 },
Square { side: f64 },
}
// 独立出来的业务操作
fn calculate_area(shape: &Shape) -> f64 {
// 使用 match 进行模式匹配,直接解构并提取数据
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Square { side } => side * side,
}
}
// 独立出来的业务操作 B
fn export_to_svg(shape: &Shape) {
match shape {
Shape::Circle { radius } => println!("<circle r='{}' />", radius),
Shape::Square { side } => println!("<rect width='{}' height='{}' />", side, side),
}
}
// --- 使用用例 ---
fn main() {
// 准备一组数据结构列表
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Square { side: 10.0 },
];
println!("--- 业务功能 1:计算面积 ---");
for shape in &shapes {
println!("面积: {}", calculate_area(shape));
}
println!("\n--- 业务功能 2:导出 SVG (动态增加的新功能) ---");
for shape in &shapes {
export_to_svg(shape);
}
}
在编译器构建和处理AST时,利用代数数据类型和模式匹配,可以完美的解决AST的遍历和翻译问题。
回顾思考
现代语言中Visitor模式没有死,但是也基本上也被更高阶的语法取代了。可见,行为模式里的State、Strategy、Template Method、Visitor模式,全都被代数数据类型和模式匹配杀死比赛了。因为本质上,这些模式就不应该存在,只是因为当时万物OOP的思想导致的设计补丁。
其实换成另一种观点,把该模式推向极端,其实就可以看作ECS中的System。
补充:发布与订阅
在之前我们说过,Observer模式是所有设计模式中进化最彻底的一个,在现代,他进化除了一个更高级的形态:Pub-Sub模式。
那么我们就来看看,到底现在他到底已经演化为了什么。
分布式消息队列
在分布式系统中,发布-订阅模式直接演化成了独立的基础设施中间件。以前的发布者和订阅者还在同一个进程的内存里。现在的发布者在一台电脑上的进程A,订阅者在远程电脑上的进程B,它们中间隔着一个庞大的服务器集群。
在这种发布订阅里,引入了更多的概念:
- 持久化: 消息不再是发出去就丢了,而是像数据库一样持久化在磁盘上,支持消息回溯。
- 高吞吐与削峰: 即使下游消费者挂了,上游依然可以疯狂发布消息,消息堆积在缓冲区里,实现了真正的时空解耦。
物联网通讯协议MQTT
MQTT协议的底层,就是纯粹的、教科书级别的发布-订阅模式。在 MQTT 的世界里,发布者和订阅者互不相识,它们通过一个叫做Broker(消息代理/服务器)的中介来沟通。
一个温度传感器,它会把数据发给 Broker,并贴上标签:"home/livingroom/temperature",这个类似于HTTP中的URL,但在MQTT中被称为Topic。Broker看到这个Topic,就会检查谁订阅了这个Topic,然后把数据转发过去。如果你的手机App向Broker订阅了 "home/livingroom/temperature",就能实时收到温度。
微服务与微前端
微服务与微前端的架构核心,都高度依赖发布-订阅模式。
在微服务中,如果服务之间全部使用 HTTP 或 gRPC 进行同步调用,微服务就会退化成一个屎山,因为只要一个服务挂了,整条链路崩溃。因此,现代微服务都通过分布式消息队列(如前所述)引入发布-订阅。
微前端把一个巨大的前端单页应用拆成多个独立的子应用。它们由一个主应用负责调度。因为各个子应用运行在被沙箱里,它们不能直接调用对方的函数,这时就必须用发布-订阅。
事件驱动/消息驱动
事件驱动经常和消息驱动被混为一谈,因为从表现上来看二者很像,但其实有一些细微的区别。
事件驱动是拉的逻辑。事情发生了,接收方根据自己的职责去响应,自己从Event对象里找出来自己需要的数据。发送方只是在客观地宣告一件已经发生的事情,它完全不关心、也不知道谁会对此做出反应,所以一个事件是可以没有任何响应的(因为发送方不知道接收方需要什么,当然也就不需要也不能关心有没有人响应)。
消息驱动是推的逻辑。发送方把任务推给接收方,接收方往往必须做,消息里往往含有接收方需要的准确数据。一条消息往往存在固定的消费者(这也能解释为什么分布式消息队列不叫做分布式事件队列了)。
结论:行为型模式的新生
回顾十一个行为型模式,可以发现行为型模式和创造/结构模式的演化方向很不同。这些模式存在着明显的两极分化现象,要么被完全弃用,要么直接演变成了很多行业重要的基石直接发展出了更完善的形态。
被完全弃用的那些模式,往往是因为当时OOP的设计缺陷导致的补丁。在现代已被更完善更强大的功能所取代了,自然也就不存在了。
继续发展的那些模式,可以很明显的看出他们解决的并不是"在OOP下我们该怎么做"这样的问题,而是"怎么才能解耦A与B"这样的问题。这种本质属性,导致了他不会随着OOP的变化而变化,而是随着时代演变以不同的形式得到新的解决。