编码 · 设计模式
模板方法模式详解与示例
简短回答
模板方法是一种行为型设计模式,它在基类的一个方法中定义算法的骨架,并允许子类重写特定步骤,而不改变整体顺序。基类决定做什么以及何时做,子类决定其中某些步骤怎么做。带有默认实现的可选步骤称为钩子。
意图与它要解决的问题
意图:在一个操作中定义算法的骨架,把某些步骤推迟到子类中。模板方法让子类可以在不改变算法结构的前提下,重新定义算法的某些步骤。它是《设计模式》(Gang of Four,GoF)一书中的行为型模式之一。
设想一个报表工具,可以把数据导出为 CSV 和 JSON。两个导出器按相同的顺序做相同的事:加载行、检查是否有数据、格式化、再添加带行数的页脚。只有格式化不同。如果分别编写每个导出器,共同的步骤就会被复制,而在其中一个里做的修复(例如更好地处理空结果)很容易在另一个里被遗忘。
使用模板方法时,基类 Exporter 包含带有固定步骤序列的 export 方法。会变化的步骤是抽象方法。CsvExporter 和 JsonExporter 只实现这些步骤。
结构与参与者
| 角色 | 职责 |
|---|---|
| 抽象类 | 实现模板方法,按固定顺序调用各个步骤;声明抽象步骤,也可以提供钩子 |
| 模板方法 | 定义算法的公开方法;通常不应被重写 |
| 基本操作 | 每个子类都必须实现的抽象步骤 |
| 钩子 | 带有默认(通常为空)实现的步骤,子类可以选择性地重写 |
| 具体类 | 实现基本操作,并按需重写钩子 |
这种设计反转了控制权:由基类调用子类的方法,而不是反过来。这有时被称为好莱坞原则:“不要打电话给我们,我们会打给你”。
TypeScript 实现
export 是模板方法。format 是子类必须实现的基本操作,header 是默认为空的钩子。
type Row = { name: string; score: number };
abstract class Exporter {
// The template method: fixed order of steps
export(rows: Row[]): string {
if (rows.length === 0) return "no data";
const lines = [this.header(), this.format(rows), "rows: " + rows.length];
return lines.filter((line) => line !== "").join("\n");
}
protected abstract format(rows: Row[]): string; // required step
protected header(): string { return ""; } // hook: optional step
}
class CsvExporter extends Exporter {
protected header() { return "name,score"; }
protected format(rows: Row[]) {
return rows.map((r) => r.name + "," + r.score).join("\n");
}
}
class JsonExporter extends Exporter {
protected format(rows: Row[]) {
return JSON.stringify(rows);
}
}
const rows = [{ name: "Ana", score: 9 }, { name: "Ben", score: 7 }];
console.log(new CsvExporter().export(rows));
// name,score
// Ana,9
// Ben,7
// rows: 2
console.log(new JsonExporter().export(rows));
// [{"name":"Ana","score":9},{"name":"Ben","score":7}]
// rows: 2
console.log(new JsonExporter().export([])); // no data空数据检查和页脚只在一处。新增一个 XML 导出器只需实现 format,其余的自然就有了。
用组合实现同样的思想
继承并非唯一的办法。你可以把变化的步骤作为函数传入,这样该模式就成了策略的近亲,并且避免了类层次结构:
type Row = { name: string; score: number };
type Steps = { format: (rows: Row[]) => string; header?: () => string };
function exportRows(rows: Row[], steps: Steps): string {
if (rows.length === 0) return "no data";
const lines = [steps.header?.() ?? "", steps.format(rows), "rows: " + rows.length];
return lines.filter((line) => line !== "").join("\n");
}
console.log(exportRows([{ name: "Ana", score: 9 }], { format: (r) => r[0].name }));
// Ana
// rows: 1当步骤很小、基类中不需要共享状态,或一个导出器应当组合来自多处的步骤时,优先使用这种形式。
何时使用,何时不用
- 当多个类运行相同的算法,只有少数步骤不同,而你想消除重复部分时使用。
- 用于必须控制操作顺序、同时允许用户定制步骤的框架,例如测试中的准备(setup)和清理(teardown)。
- 用钩子提供可选的扩展点,而不强迫每个子类都去实现它们。
- 当差异很大或需要在运行时变化时不要使用。基于组合的策略更灵活。
- 避免多层的模板方法层次结构。当子类重写子类的子类的步骤时,流程会变得难以跟踪。
实际应用
- Java 的
AbstractList:你实现get(int)和size(),基类基于它们提供迭代、indexOf、contains等其他方法。 - Java 的
InputStream:子类实现单字节的read(),基类通过调用它来实现read(byte[], int, int)(子类为了速度可以重写)。 - 测试框架:JUnit、Python 的
unittest等会按固定顺序调用setUp、测试方法和tearDown,你只需重写需要的步骤。 - 带有生命周期方法的界面框架,例如 React 类组件中的
componentDidMount,会在自身算法的固定节点调用你的方法。
相关模式
| 模式 | 与模板方法的区别 |
|---|---|
| 策略 | 策略在运行时通过组合替换整个算法;模板方法固定算法,并在编译期通过继承改变其中的部分。 |
| 工厂方法 | 工厂方法通常是模板方法的其中一个步骤:即创建对象的那一步。 |
| 钩子函数与回调 | 把回调传入固定的算法,是同一思想基于组合的版本。 |
常见问题
模板方法模式中的钩子是什么?
钩子是在基类中有默认实现的步骤,通常什么也不做。子类可以重写它,在那个节点添加行为,但不是必须这样做。
模板方法和策略有什么区别?
模板方法使用继承:基类拥有算法,子类重写步骤。策略使用组合:上下文把整个算法委托给一个可以在运行时替换的对象或函数。
模板方法本身应该可以被重写吗?
不应该。它的目的就是固定步骤的顺序。在 Java 或 C# 中可以把它标记为 final 或非虚方法;在 TypeScript 中只能靠文档说明,因为没有 final 关键字。
模板方法与工厂方法有什么关系?
工厂方法通常是模板方法的一种特化:基类的算法中包含一个创建对象的步骤,由子类决定创建哪个类。