在抽象基类中把"算法的骨架与执行顺序"固定下来,把其中会变化的步骤延迟到子类实现——父类定流程,子类填细节。
2026-09-05 · 第 21 天 · 学习路线:创建型(1-5) → 结构型(6-12) → 行为型(13-23)
模板方法模式(Template Method Pattern):定义一个操作中的算法骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
通俗讲:流程骨架写在基类里(通常用 final 锁死),会变化的步骤声明为虚函数,子类只负责"填空"。它解决的核心问题是:多个类共享同一套执行流程、只有局部细节不同时,如何既消除重复代码、又保证流程顺序不被破坏。
目前进度:创建型 5 个(单例→工厂方法→抽象工厂→建造者→原型)、结构型 7 个(适配器→桥接→组合→装饰器→外观→享元→代理)已全部学完;行为型已学 责任链、命令、迭代器、中介者、备忘录、观察者、状态、策略 8 个,今天学第 9 个行为型模式。特别地,昨天的策略模式(Day 20)与今天的模板方法是一对"镜像"方案,第 ⑪ 节会重点对比。
设想你在写一个桌面报表工具:数据计算完成后,要把结果导出成 CSV 和 HTML 两种文件。没有设计模式时的典型写法是"复制粘贴两份几乎一样的函数":
// ❌ 反例:两种导出格式各自维护一份"打开→写表头→逐行写→关闭"骨架
bool exportToCsv(const TableData &data, const QString &path) {
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) return false; // ① 打开文件
QTextStream out(&file);
out << "id,name,score\n"; // ② 写表头
for (const Row &r : data.rows) // ③ 逐行写数据
out << r.id << ',' << r.name << ',' << r.score << '\n';
file.close(); // ④ 关闭文件
return true;
}
bool exportToHtml(const TableData &data, const QString &path) {
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) return false; // ① 打开文件(重复)
QTextStream out(&file);
out << "<table><tr><th>id</th><th>name</th></tr>\n"; // ② 写表头(重复)
for (const Row &r : data.rows) // ③ 逐行写(重复)
out << "<tr><td>" << r.id << "</td><td>" << r.name << "</td></tr>\n";
file.close(); // ④ 关闭(重复)
return true;
}
这两份代码 70% 的结构完全相同:打开文件 → 写表头 → 逐行写 → 关闭。只有"表头长什么样、一行怎么写"不同。问题随需求增长立刻爆发:
本质矛盾:"流程"是稳定的、值得复用的;"步骤细节"是变化的、需要定制的。模板方法模式就是把这个矛盾用继承优雅地解开——稳定流程上移进基类的模板方法,变化细节下沉为子类覆盖的虚函数。
模板方法模式只做三件事:
💡 生活类比:公司新员工入职流程由 HR 部门定死——签合同 → 领工牌 → 配电脑 → 部门报到 → 入职培训。前两步对谁都一样(基类的具体方法);"部门报到"和"入职培训"的内容由各部门自己定(子类实现的虚步骤);而流程顺序任何人无权更改(final 模板方法)。新开一个部门只需要准备自己那份"报到/培训内容",不需要重新发明整套入职流程。
回到软件工程:流程 = 算法骨架 = 稳定部分;各部门差异 = 虚步骤 = 变化部分。模板方法把"稳定"和"变化"放在继承体系的两个层次上,各自独立演化——这正是"封装变化"思想在流程维度的体现。
类图(以第 ⑤ 节"热饮制作"为例):
classDiagram
class HotDrinkMaker {
<<abstract>>
+make() final 模板方法:固定流程
#boilWater() 具体步骤(基类实现)
#pourIntoCup() 具体步骤(基类实现)
#brew()* 抽象步骤(子类必须实现)
#addCondiments() 虚步骤(默认空实现)
#wantCondiments() bool 钩子(默认false)
}
class TeaMaker
class CoffeeMaker
class PlainTeaMaker
HotDrinkMaker <|-- TeaMaker
HotDrinkMaker <|-- CoffeeMaker
HotDrinkMaker <|-- PlainTeaMaker
角色只有两个,但基类里的"步骤"按可变程度分三种:
| 角色/步骤 | 类名举例 | 职责 | 可变性 |
|---|---|---|---|
| 抽象类 AbstractClass | HotDrinkMaker | 声明模板方法 make(),定义骨架与调用顺序;提供步骤的默认/公共实现 | 稳定,一般不再修改 |
| 具体方法(步骤) | boilWater() / pourIntoCup() | 所有子类共享的公共步骤,基类直接实现 | 所有子类相同 |
| 抽象方法(步骤) | brew()(纯虚) | 子类必须实现的核心差异点 | 每个子类不同 |
| 钩子方法 Hook | wantCondiments() / addCondiments() | 基类给默认行为,子类可选覆盖,用于干预流程分支 | 子类按需定制 |
| 具体类 ConcreteClass | TeaMaker / CoffeeMaker / PlainTeaMaker | 实现抽象方法、按需覆盖钩子;绝不重写模板方法本身 | 新增子类即扩展 |
关键依赖关系:调用方只面向 AbstractClass 的模板方法编程(多态);模板方法内部通过虚函数调用"自己的子类实现"(self-reference 反向调用)。扩展点在子类这一侧:加新饮料 = 加新子类,基类和调用方一行都不用改(OCP)。
经典"冲泡热饮"例子:烧水、倒杯对任何饮品都一样;"冲泡什么、加不加料"由子类决定。完整代码(C++17,已实测编译运行通过):
#include <iostream>
// ========== 抽象基类:定义"冲泡热饮"的算法骨架 ==========
class HotDrinkMaker {
public:
// 模板方法:整体流程固定
// virtual + final = 可多态调用,但禁止任何子类覆盖/打乱骨架
virtual void make() final {
boilWater(); // 具体步骤 1:所有子类都一样
brew(); // 抽象步骤 2:由子类决定冲泡什么
if (wantCondiments()) { // 钩子步骤:默认"不加料",子类可改写
addCondiments(); // 虚步骤 3:配合钩子使用
}
pourIntoCup(); // 具体步骤 4
std::cout << "---------- 完成 ----------\n";
}
virtual ~HotDrinkMaker() = default; // 基类析构必须虚,避免内存泄漏
protected:
void boilWater() const { std::cout << "1. 烧水至 100℃\n"; }
void pourIntoCup() const { std::cout << "4. 倒入杯中\n"; }
virtual void brew() = 0; // 纯虚:子类必须实现
virtual void addCondiments() {} // 虚步骤:默认空实现
virtual bool wantCondiments() const { return false; } // 钩子:默认不加料
};
// 子类 1:红茶——实现抽象步骤,并打开"加料"钩子
class TeaMaker : public HotDrinkMaker {
protected:
void brew() override { std::cout << "2. 放入茶叶,焖泡 3 分钟\n"; }
bool wantCondiments() const override { return true; }
void addCondiments() override { std::cout << "3. 加一片柠檬\n"; }
};
// 子类 2:拿铁咖啡
class CoffeeMaker : public HotDrinkMaker {
protected:
void brew() override { std::cout << "2. 咖啡豆研磨、萃取浓缩液\n"; }
bool wantCondiments() const override { return true; }
void addCondiments() override { std::cout << "3. 加牛奶和糖\n"; }
};
// 子类 3:纯茶——只实现 brew(),钩子沿用基类默认"不加料"
class PlainTeaMaker : public HotDrinkMaker {
protected:
void brew() override { std::cout << "2. 放入茶包,焖泡 2 分钟\n"; }
};
int main() {
std::cout << "==== 制作红茶 ====\n";
TeaMaker tea;
tea.make();
std::cout << "\n==== 制作拿铁咖啡 ====\n";
CoffeeMaker coffee;
coffee.make();
std::cout << "\n==== 制作纯茶(钩子默认不加料)====\n";
PlainTeaMaker plain;
plain.make();
return 0;
}
逐段解读:
make() 是模板方法:它不实现任何"产品知识",只负责编排顺序。写成 virtual ... final 后,子类即便手滑定义了同名函数也无法通过基类指针破坏流程。boilWater() / pourIntoCup() 是具体方法:放在 protected 段,子类看得见、调用方看不见;所有饮品共用。brew() 是纯虚函数:这是每个子类必须回答的问题"你到底是什么饮品"。wantCondiments() 是钩子(hook):基类默认返回 false(不加料),PlainTeaMaker 什么都不用写就自动跳过加料步骤;TeaMaker 想加料只需把钩子翻成 true 并实现 addCondiments()。📦 程序输出(g++ -std=c++17 实测):
==== 制作红茶 ====
1. 烧水至 100℃
2. 放入茶叶,焖泡 3 分钟
3. 加一片柠檬
4. 倒入杯中
---------- 完成 ----------
==== 制作拿铁咖啡 ====
1. 烧水至 100℃
2. 咖啡豆研磨、萃取浓缩液
3. 加牛奶和糖
4. 倒入杯中
---------- 完成 ----------
==== 制作纯茶(钩子默认不加料)====
1. 烧水至 100℃
2. 放入茶包,焖泡 2 分钟
4. 倒入杯中
---------- 完成 ----------
注意纯茶输出里没有第 3 步——钩子默认值生效,证明"流程由基类控制、分支由子类钩子决定"。
做一个真实 Qt 场景:桌面程序常有"耗时任务"——计算大文件校验和、统计日志文件。它们的生命周期完全一样:启动准备 → 干活(带进度)→ 收尾 → 报告结果,只有"干什么活"不同。这正是 Qt 里 QThread::run() 的设计思想(详见第 ⑫ 节),我们用模板方法实现一个任务基类 AsyncJob,配两个子类:ChecksumJob(SHA-256)和 TextStatsJob(行数/单词数统计),进度与结果通过信号上报(呼应 Day 18 观察者模式的信号槽)。
asyncjob.h(任务基类,模板方法所在):
#pragma once
#include <QObject>
#include <QString>
// 抽象任务基类:用模板方法锁死"一个后台任务的完整生命周期"
class AsyncJob : public QObject {
Q_OBJECT
public:
explicit AsyncJob(QObject *parent = nullptr);
// 模板方法:任务骨架(非虚、统一入口,子类不得重写整个流程)
bool execute();
signals:
void progressChanged(int percent); // 进度上报(0-100)
void finished(bool ok, const QString &message); // 结束通知
protected:
virtual bool onStart() { return true; } // 钩子:准备阶段,默认直接成功
virtual bool doWork() = 0; // 抽象步骤:核心工作(子类必须实现)
virtual void onFinish() {} // 钩子:收尾,默认空(保证资源释放的时机)
virtual bool wantProgress() const { return true; } // 钩子:是否上报进度
void reportProgress(int percent); // 子类调用:上报进度(自动去重)
void setError(const QString &message) { m_lastError = message; }
qint64 m_totalBytes = 0; // 供子类计算进度百分比
private:
QString m_lastError;
int m_lastReportedPercent = -1; // 去重:相同进度不重复发信号
};
asyncjob.cpp:
#include "asyncjob.h"
AsyncJob::AsyncJob(QObject *parent) : QObject(parent) {}
bool AsyncJob::execute() {
m_lastError.clear();
m_lastReportedPercent = -1;
if (!onStart()) { // ① 准备阶段(钩子,失败则直接结束)
emit finished(false,
m_lastError.isEmpty() ? QStringLiteral("准备阶段失败") : m_lastError);
return false;
}
reportProgress(5); // ② 骨架自带的起始进度
const bool ok = doWork(); // ③ 核心工作(虚调用:实际执行子类代码)
onFinish(); // ④ 无论成败都收尾,保证资源释放(类似 RAII 语义)
reportProgress(100); // ⑤ 骨架自带的结束进度
emit finished(ok,
ok ? QStringLiteral("执行成功")
: (m_lastError.isEmpty() ? QStringLiteral("执行失败") : m_lastError));
return ok;
}
void AsyncJob::reportProgress(int percent) {
if (wantProgress() && percent != m_lastReportedPercent) {
m_lastReportedPercent = percent;
emit progressChanged(percent);
}
}
filejobs.h(两个子任务):
#pragma once
#include "asyncjob.h"
#include <QCryptographicHash>
// 任务 1:分块计算大文件的 SHA-256 校验和(边读边报进度)
class ChecksumJob : public AsyncJob {
Q_OBJECT
public:
explicit ChecksumJob(const QString &filePath, QObject *parent = nullptr);
protected:
bool onStart() override; // 检查文件存在性、拿到文件大小
bool doWork() override; // 分块读取并喂给哈希器
void onFinish() override; // 打印最终校验和
private:
QString m_filePath;
QCryptographicHash m_hash{QCryptographicHash::Sha256};
QString m_digest;
};
// 任务 2:统计文本文件的行数与单词数
class TextStatsJob : public AsyncJob {
Q_OBJECT
public:
explicit TextStatsJob(const QString &filePath, QObject *parent = nullptr);
protected:
bool onStart() override;
bool doWork() override; // 逐行读取统计
void onFinish() override; // 打印统计结果
private:
static int countWords(const QString &line);
QString m_filePath;
int m_lines = 0;
int m_words = 0;
};
filejobs.cpp:
#include "filejobs.h"
#include <QDebug>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QTextStream>
/* ---------------- ChecksumJob ---------------- */
ChecksumJob::ChecksumJob(const QString &filePath, QObject *parent)
: AsyncJob(parent), m_filePath(filePath) {}
bool ChecksumJob::onStart() {
const QFileInfo info(m_filePath);
if (!info.exists() || !info.isFile()) {
setError(QStringLiteral("文件不存在:%1").arg(m_filePath));
return false; // 返回 false 时 execute() 会统一发出失败信号
}
m_totalBytes = info.size(); // 记录总大小,供进度计算
return true;
}
bool ChecksumJob::doWork() {
QFile file(m_filePath);
if (!file.open(QIODevice::ReadOnly)) {
setError(file.errorString());
return false;
}
qint64 loaded = 0;
while (!file.atEnd()) {
const QByteArray chunk = file.read(64 * 1024); // 每次读 64KB,避免大文件占满内存
if (chunk.isEmpty()) { // 未到结尾却读不到数据 = 读错误
setError(QStringLiteral("读取文件失败"));
return false;
}
m_hash.addData(chunk); // 增量喂给哈希器
loaded += chunk.size();
reportProgress(5 + static_cast<int>(90 * loaded / m_totalBytes));
}
m_digest = QString::fromLatin1(m_hash.result().toHex()); // 256 位 → 64 个十六进制字符
return true;
}
void ChecksumJob::onFinish() {
qInfo().noquote() << QStringLiteral("SHA-256:%1(%2 字节)")
.arg(m_digest).arg(m_totalBytes);
}
/* ---------------- TextStatsJob ---------------- */
TextStatsJob::TextStatsJob(const QString &filePath, QObject *parent)
: AsyncJob(parent), m_filePath(filePath) {}
bool TextStatsJob::onStart() {
const QFileInfo info(m_filePath);
if (!info.exists() || !info.isFile()) {
setError(QStringLiteral("文件不存在:%1").arg(m_filePath));
return false;
}
m_totalBytes = info.size();
return true;
}
bool TextStatsJob::doWork() {
QFile file(m_filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
setError(file.errorString());
return false;
}
QTextStream in(&file);
// 注意:Qt 6 的 QTextStream 默认按 UTF-8 读写;Qt 5 需额外 in.setCodec("UTF-8")
while (!in.atEnd()) {
const QString line = in.readLine();
++m_lines;
m_words += countWords(line);
// 用底层文件位置近似进度(QTextStream 内部有缓冲,逐行精确推进代价高)
reportProgress(5 + static_cast<int>(90 * file.pos() / m_totalBytes));
}
if (in.status() != QTextStream::Ok) {
setError(QStringLiteral("文本读取失败"));
return false;
}
return true;
}
// 按空白字符切分一行文本,空段跳过;正则 \s 在 C++ 字符串里要写成 "\\s+"
int TextStatsJob::countWords(const QString &line) {
return line.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts).size();
}
void TextStatsJob::onFinish() {
qInfo().noquote() << QStringLiteral("行数:%1,单词数:%2").arg(m_lines).arg(m_words);
}
main.cpp:
#include <QCoreApplication>
#include <QDebug>
#include <QFile>
#include "asyncjob.h"
#include "filejobs.h"
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv); // 纯控制台程序,不需要界面
// 1. 先造一个测试用文本文件(运行目录下会生成 sample.txt)
{
QFile sample(QStringLiteral("sample.txt"));
if (!sample.open(QIODevice::WriteOnly | QIODevice::Text)) {
qWarning() << "无法创建测试文件";
return 1;
}
sample.write("Hello C++\nQt Template Method\nhello hello\n");
}
// 2. 任务 A:统计行数/单词数(连接进度与结束信号)
TextStatsJob stats(QStringLiteral("sample.txt"));
QObject::connect(&stats, &AsyncJob::progressChanged, [](int p) {
qInfo().noquote() << QStringLiteral(" [统计] 进度 %1%").arg(p);
});
QObject::connect(&stats, &AsyncJob::finished, [](bool ok, const QString &msg) {
qInfo().noquote() << QStringLiteral(" [统计] %1:%2").arg(ok ? "成功" : "失败").arg(msg);
});
stats.execute(); // 调用模板方法(信号槽为直连,同步执行,无需事件循环)
// 3. 任务 B:计算 SHA-256 校验和
ChecksumJob checksum(QStringLiteral("sample.txt"));
QObject::connect(&checksum, &AsyncJob::finished, [](bool ok, const QString &msg) {
qInfo().noquote() << QStringLiteral(" [校验] %1:%2").arg(ok ? "成功" : "失败").arg(msg);
});
checksum.execute();
return 0;
}
CMakeLists.txt(Qt 6 + C++17,AUTOMOC 由 qt_add_executable 自动开启):
cmake_minimum_required(VERSION 3.16)
project(TemplateMethodDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Core)
# 若使用 Qt 5:把上面改为 find_package(Qt5 REQUIRED COMPONENTS Core),
# 并把 qt_add_executable 换成 add_executable + set_target_properties(... PROPERTIES AUTOMOC ON)
qt_add_executable(TemplateMethodDemo
main.cpp
asyncjob.h asyncjob.cpp
filejobs.h filejobs.cpp
)
设计与运行说明:把上述文件放进 Qt Creator(或命令行 cmake + make)即可运行,程序会在构建目录生成 sample.txt 并依次执行两个任务。子类里没有任何"流程控制代码"——谁先谁后、失败怎么处理、进度怎么归一化,全部由基类 execute() 这个模板方法说了算。
📦 程序输出(SHA-256 值与 sample.txt 内容一一对应,可用 sha256sum sample.txt 验证):
[统计] 进度 5%
[统计] 进度 95%
行数:3,单词数:7
[统计] 进度 100%
[统计] 成功:执行成功
SHA-256:9a831db06af642a749927a0470ff363900fc1dbf5571a41dda5abb6abc30060b(41 字节)
[校验] 成功:执行成功
小文件只有 41 字节、一次读完,所以进度直接从 5% 跳到 95%;换一个大文件(几百 MB),就能看到进度平滑递增——这正是把"进度上报"放进骨架、而不是让每个子类各自为政的好处。
以 Qt 示例为主线,程序实际运行顺序如下:
TextStatsJob stats("sample.txt")、ChecksumJob checksum(...)——只传文件路径,不知道也不关心任务"内部流程"。QObject::connect 把 progressChanged / finished 信号接到 lambda 上(Day 18 观察者模式:任务状态变化 → 通知订阅者)。同线程直连,emit 即同步调用。execute():这是唯一入口。骨架开始执行:清空上次错误状态 → onStart()(虚调用,进入 TextStatsJob::onStart 检查文件、记录总字节数)→ reportProgress(5) 发出 5% 信号 → lambda 打印。doWork():控制权第二次"反向"进入子类。TextStatsJob 逐行 readLine() 计数、按 file.pos() 估算进度;ChecksumJob 循环 64KB 分块 addData()。注意:此刻是基类代码在调用子类代码。doWork() 返回后骨架调用 onFinish()(打印统计结果 / 校验和),再统一发 100% 与 finished(ok, msg) 信号,lambda 打印"成功:执行成功"。把同样的视角套到第 ⑤ 节:main 构造 TeaMaker → tea.make() 进入基类骨架 → 依次虚调用 brew()、钩子判断 wantCondiments() 后调用 addCondiments() → 流程结束。两次"虚调用"的时机和顺序,完全由基类说了算。
解耦点在哪里?骨架与步骤被放在继承体系的两层:基类的 execute()/make() 不知道任何"具体任务/具体饮品"的知识,只认识自己声明的虚函数;子类不知道流程的全貌,只实现被点名的步骤。两边各自独立演化,互不干扰。
扩展点在哪里?全部在"新增子类"这一侧。今天的示例里,想加一个 Md5Job 或 OolongTeaMaker,基类零改动、调用方零改动——教科书式的开闭原则(OCP)。final 关键字进一步明确了"哪些门对子类敞开(protected 虚函数)、哪些门焊死(骨架本身)",把扩展面收敛成可控的接口。
依赖倒置(DIP)的体现:高层模块(骨架)不再依赖低层模块(具体子类),两者都依赖"虚函数接口"这一抽象;而且调用方向整个反转——基类调子类,这就是好莱坞原则。Qt 里处处是这种"框架回调你的代码"的结构(第 ⑫ 节)。
与"组合优于继承"冲突吗?不冲突,要分清适用面:模板方法是"继承式复用"的正面典型,但它只适用于骨架稳定的场景;如果整个算法都可能被替换、需要在运行时切换,那就要用 Day 20 的组合式策略模式。二者是互补关系而非竞争关系(详见 ⑪)。
C++ 工程化形态:NVI(Non-Virtual Interface)惯用法。模板方法的工业级写法是:public 的非虚模板方法作外壳(在调用虚步骤前后统一做日志、加锁、参数校验、异常翻译),虚步骤放 protected 甚至 private。好处:公共横切逻辑只写一遍,且调用方永远走统一入口。今天的 AsyncJob::execute() 已经是这个形态。
对单元测试的帮助:公共流程只需要测一次——用一个"记录型测试子类"把每个虚步骤的调用记进 std::vector,然后断言调用顺序与次数;每个真实子类只需测试自己那几步(例如给 ChecksumJob 一个小文件断言摘要)。相比第 ② 节"每份拷贝各测一遍",测试量与重复代码同步下降。
资源管理更安全:收尾时机(onFinish()、关闭文件)被骨架集中控制,配合 RAII(栈上的 QFile/QTextStream)和 Qt 父子对象机制,不存在"某个子类忘了释放"的泄漏路径。
不使用模板方法,重复流程一般会以两种形态腐烂:
形态一:复制粘贴(第 ② 节的导出函数)。格式从 2 种涨到 5 种时,同一套"打开→写头→写行→关闭"逻辑存在 5 份。某天修了 CSV 的空文件崩溃 bug,HTML/JSON 的拷贝大概率被漏掉——行为漂移从此开始。
形态二:枚举 + 巨型 switch/if-else:
// ❌ 反例:用枚举 + switch 管理所有饮品的制作流程
void makeDrink(DrinkType type) {
boilWater(); // 每个分支都要重复"烧水"
switch (type) {
case DrinkType::Tea: brewTea(); break;
case DrinkType::Coffee: brewCoffee(); break;
case DrinkType::MilkTea: brewTea(); addMilk(); break; // 组合开始爆炸
// 每加一种饮品,这里多一个 case,主函数越来越长
}
pourIntoCup(); // 每个分支都要重复"倒杯"
}
问题一:流程与实现糊在一起,"先烧水、最后倒杯"的顺序只是惯例,编译器不帮你保证,新来的同事完全可能写出"先倒杯再烧水"的 case;问题二:改公共需求要扫雷——产品要求"所有饮品制作前先消毒杯子",你得记得改每一个 case 和每一份拷贝,漏一个就是线上事故;问题三:类职责膨胀——一个函数认识所有饮品,加一种饮料就要动它,违反 OCP 且无法并行开发。
🚩 代码信号:当你发现 N 个函数/类"骨架长得一样、只有中间几步不同",或者正在"复制—粘贴—微调",就是提炼模板方法的最佳时机。先忍到第三次重复再动手,通常是比较健康的节奏。
✅ 适合使用(3-5 个场景):
❌ 不适合使用(2-4 个场景):
⚠️ 过度设计提醒:教科书例子(饮料、排序)到处都是,容易让人产生"两个类相似就该建基类"的冲动。真实工程里,两三个相似函数直接写、等第三个重复出现再提取,是更务实的判断标准。设计模式是重构的目标,不是编码的起点。
① 模板方法 vs 策略模式(Day 20,最易混淆):
| 维度 | 策略模式(Day 20) | 模板方法模式(今日) |
|---|---|---|
| 复用手段 | 对象组合(Context 持有 Strategy 指针) | 继承(子类继承基类骨架) |
| 封装对象 | 整套可替换的算法/行为 | 算法骨架中的局部步骤 |
| 变化粒度 | 换"整个算法" | 改"个别步骤",顺序不动 |
| 绑定时机 | 运行时(可 setStrategy 动态切换) | 编译期(继承关系一旦确定不可变) |
| 谁控制流程 | Context 调用 Strategy 接口 | 基类模板方法回调子类虚函数 |
一句话记忆:策略 = 换整套算法;模板方法 = 骨架不动、换零件。Qt 里也有对应:给 QComboBox 换一个 Model 是策略式的"整体替换";而 QAbstractItemModel 的子类只需实现 data()/rowCount() 等步骤、渲染流程由框架固定,是模板方法思想。实践中,若发现模板方法的子类把大部分虚步骤都重写了,通常意味着这里本该用策略。
② 模板方法 vs 工厂方法(Day 2):工厂方法可看作模板方法的一个特化——它的"骨架"就是创建流程,虚步骤就是"构造哪个具体产品"。两者都靠继承 + 虚函数实现反转控制;区别只是模板方法封装的是任意步骤的变化,工厂方法专精于对象创建这一步。所以工厂方法常被描述为"用于创建对象的模板方法"。
③ 模板方法 vs 普通抽象接口:纯虚接口只约定"会做什么"(能力契约),不约束"按什么顺序做";模板方法额外锁定了调用顺序与流程,并把部分步骤设为 protected 防止外部乱调——控制力更强,适合"流程合规"类需求。
1. QThread::run() —— Qt 里最经典的模板方法。调用 start() 后,Qt 在新线程入口处调用虚函数 run(),其默认实现是 exec()(进入事件循环)。经典用法就是子类化 QThread 并重写 run()——父类管"线程怎么启动、怎么收尾",子类只管"在线程里跑什么"。值得注意:Qt 官方如今推荐 worker 对象 + moveToThread() 而非子类化 QThread,理由正是"组合优于继承"——一个任务类不该被绑死在线程类里,这也印证了 ⑧ 节说的:模板方法擅长固定骨架,需要灵活性时就该切到组合。
2. QWidget 事件体系:event() 是分发骨架,事件处理器是钩子。QWidget 收到 QEvent 后,event() 按事件类型把控制权分发给 mousePressEvent()、keyPressEvent()、paintEvent() 等几十个虚函数;框架持有"分发顺序与默认行为"(如 paintEvent 默认空实现 = 钩子默认值),子类只重写自己关心的钩子就能定制外观与交互。若重写 event() 则相当于连骨架一起接管——这也解释了为什么 Qt 文档总说"大多数情况重写特定的事件处理器而不是 event()"。
3. Model/View 中的好莱坞原则:QTableView 渲染时回调 model 的虚函数 data() / rowCount() / columnCount()(抽象步骤),而 index()、parent()、flags() 基类提供了默认实现(钩子,按需覆盖)。子类 model 从不主动找 view——是视图在恰当的时机"访问"模型。理解这一点,阅读 Qt 源码时就能预判:框架类里那些被调用的虚函数,就是留给你的扩展点。
📌 阅读方法小结:在 Qt 源码里看到"public 非虚入口函数内部调用 protected 虚函数",基本就是模板方法/好莱坞结构。找扩展点时,优先看类文档里标着 "virtual" 的 protected 成员——那就是骨架留给你的填空位。
Q1:模板方法模式和策略模式有什么区别?
答案:模板方法用继承复用算法骨架,封装的是"骨架中个别步骤的变化",绑定发生在编译期,子类无法改变流程顺序;策略模式用对象组合封装"整套算法的变化",可在运行时通过更换策略对象动态切换行为。选型口诀:只改零件用模板方法,要换整机用策略。另外策略模式避免了继承层次过深的问题,更灵活;模板方法则结构更直观、复用更直接。
Q2:什么是钩子方法(hook)?有什么用?
答案:基类中带默认实现(通常为空或返回默认值)的虚函数,子类可选覆盖。作用有三:给子类一个"干预流程"的扩展点而不强制实现(如 wantCondiments 默认 false);让骨架在"做与不做"之间留出决策口;便于测试默认行为。区分:纯虚函数是"必答题",钩子是"选答题"。
Q3:如何防止子类破坏算法骨架?
答案:三层手段。① 模板方法本身写成非虚,或 virtual ... final(C++11 起),子类无法覆盖;② 步骤方法设为 protected,外部调用方无法绕过骨架单独调步骤;③ 工程上采用 NVI 惯用法:public 非虚外壳统一做前置/后置处理,内部调用 private/protected 虚步骤,把子类可重写面缩到最小。
Q4:什么是好莱坞原则?Qt 里有哪些体现?
答案:"Don't call us, we'll call you"——别主动打电话给我们,需要时我们会打给你。即高层/框架在合适的时机回调低层注册的实现,控制权反转。Qt 体现:事件循环把 QEvent 交给 QWidget::event() 再分发给各事件处理器虚函数;QThread::start() 回调 run();QTableView 回调 model 的 data()/rowCount();信号槽本质上也是"对象状态变化时框架通知订阅者"。
Q5:QThread::run() 属于什么模式?为什么现代 Qt 更推荐 moveToThread?
答案:run() 是典型的模板方法——start() 固定线程生命周期骨架,run() 是留给子类重写的虚步骤(默认实现进入事件循环)。不推荐子类化 QThread 的原因:任务逻辑与线程对象强耦合,一个线程类只能跑一种任务,复用性差;改用 worker 对象 + moveToThread 后,任务与线程解耦,同一个 worker 可配不同线程、一个线程可服务多个 worker(信号槽连接),是"组合优于继承"的实践。模式本身没有问题,是继承这种复用手段在灵活性上的天花板问题。
练习:项目脚手架生成器(ProjectScaffold,建议 25-30 分钟)
需求:很多团队要反复创建新项目。请用模板方法实现一个小工具:抽象基类 ScaffoldGenerator 提供模板方法 bool generate(const QString &projectName, const QString &destDir),固定流程为——① 创建目录结构(公共步骤)② 生成 CMakeLists.txt(虚步骤,提供一份默认内容)③ 生成入口源文件 main.cpp(纯虚步骤)④ 是否执行 git init(钩子 wantGitInit(),默认 true)⑤ 打印生成清单(公共步骤)。
实现两个子类:ConsoleAppScaffold(main.cpp 里只有 Hello World,CMake 不依赖 Qt)与 QtWidgetsAppScaffold(main.cpp 创建 QApplication + 主窗口,CMake 需要 find_package(Qt6 Widgets))。写完后,新增一个 QtQuickAppScaffold 验证扩展是否真的"零改动"。
💡 提示 1:用 std::filesystem::create_directories + std::ofstream(C++17)即可完成,不必引入 Qt;文件内容用多行字符串或函数返回 QString/std::string 模板。💡 提示 2:想清楚哪些步骤放基类默认实现、哪些纯虚、哪些做成钩子——判断标准是"将来最可能变的是什么";最后用 final 锁住 generate() 骨架,体会"焊死流程、开放步骤"的边界感。