模板方法这东西。
我一直觉得。
它不像“一个模式”。
更像一条教训。
教训来自很久以前。
那时候我们写 C++。
写的是继承。
写的是 virtual。
也写的是自信。
你写一个基类。
留几个虚函数。
让子类去“实现”。
听上去很合理。
直到你开始维护别人写的子类。
你才明白。
“给了自由”。
就等于“交出了控制”。
继承最危险的地方,不是复用。 是你以为别人会按你的想法复用。
那些年我们都写过的代码:一把梭,想怎么覆写就怎么覆写
假设你要做一件常见事。
读文件。
处理内容。
写结果。
新人最自然的想法是。
把整个流程做成一个虚函数。
子类爱咋写咋写。
struct Job {
virtual ~Job() = default;
virtual void run() = 0;
};
然后某个子类就这么写。
struct WordCountJob : Job {
void run() override {
// 打开文件
// 读
// 数
// 写
}
};
问题来了。
你本来想在 run() 前后做点“框架该做的事”。
比如计时。
比如统一打日志。
比如异常边界。
比如资源兜底。
可你一旦把 run() 交给子类。
这些东西就变成了“靠自觉”。
靠自觉。
在工程里通常靠不住。
Template Method:算法骨架我来写,你只填几个空
GoF 给这套做法起了名字。
Template Method。
模板方法。
意思很直白。
流程像模板一样。
骨架先钉死。
可变的地方留几个“洞”。
子类只负责把洞填上。
你把“怎么走流程”留在基类。
把“某一步怎么做”留给子类。
#include <chrono>
#include <iostream>
class FileJob {
public:
void run() {
auto t0 = std::chrono::steady_clock::now();
try {
open();
process();
close();
} catch (...) {
close();
throw;
}
auto t1 = std::chrono::steady_clock::now();
std::cout << "cost(ms)="
<< std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count()
<< "\n";
}
protected:
virtual void open() = 0;
virtual void process() = 0;
virtual void close() = 0;
};
你注意看。
run() 不是虚函数。
它是“框架的命”。
不能让任何人随便改。
而 open/process/close 才是扩展点。
子类只需要实现这三个洞。
#include <fstream>
#include <string>
class WordCountFileJob final : public FileJob {
protected:
void open() override {
in_.open(path_);
}
void process() override {
std::string w;
while (in_ >> w) {
++count_;
}
}
void close() override {
in_.close();
}
private:
std::string path_{"a.txt"};
std::ifstream in_;
std::size_t count_{};
};
这时候你再想加“统一计时”。
加在基类 run() 里就行。
你再想加“统一异常边界”。
也加在 run() 里。
子类写得再野。
也越不过这条线。
这就是模板方法的价值。
它不是为了优雅。
是为了控场。
然后你会遇到一个更现实的问题:public virtual 是个坑
Template Method 把流程锁住了。
但工程里还有一类更常见的事故。
你写了一个类。
对外公开一个函数。
里面必须做几件事。
验参。
加锁。
打点。
维护不变式。
你当然可以写成 public virtual。
看起来“可扩展”。
可扩展的另一面是。
子类能跳过你的规则。
比如你写一个发送器。
你想保证所有发送都必须加锁。
#include <mutex>
#include <string>
class Sender {
public:
virtual ~Sender() = default;
virtual void send(const std::string& msg) {
std::lock_guard<std::mutex> lk(mu_);
// ... 统计/日志/限流
doSend(msg);
}
protected:
virtual void doSend(const std::string& msg) = 0;
private:
std::mutex mu_;
};
表面上没问题。
可你把 send() 设成了 virtual。
那就等于告诉所有人。
“你可以覆写它。”
于是某天。
有人为了“性能”。
或者为了“方便”。
写了这样一个子类。
class UdpSender : public Sender {
public:
void send(const std::string& msg) override {
// 省略加锁
// 省略统计
doSend(msg);
}
protected:
void doSend(const std::string& msg) override {
(void)msg;
}
};
你看。
他没写错语法。
他只是绕开了你的规矩。
然后你线上偶发崩溃。
你排查半天。
发现是数据竞争。
最后只能在群里说一句。
“兄弟,别覆写 send()。”
这话听上去就像。
“兄弟,别踩雷。”
但雷就在路中间。
NVI:对外接口不让你覆写,想扩展去改那几个私活
NVI。
Non-Virtual Interface。
名字很学术。
做的事很朴素。
把对外的 public 方法变成非虚函数。
让它成为“契约”。
真正可覆写的东西。
藏到 private/protected 的虚函数里。
这样子类想扩展。
只能扩展那一小块。
扩展不了加锁。
扩展不了验参。
更扩展不了你的不变式。
#include <mutex>
#include <string>
class Sender2 {
public:
void send(const std::string& msg) {
std::lock_guard<std::mutex> lk(mu_);
doSend(msg);
}
protected:
virtual void doSend(const std::string& msg) = 0;
private:
std::mutex mu_;
};
这就是 NVI。
很像 Template Method。
只是粒度更小。
Template Method 往往是一段流程。
NVI 往往是一个接口。
你再写子类。
只能实现 doSend()。
你想绕开锁。
做不到。
因为你根本没有机会覆写 send()。
什么时候用它,什么时候别上头
如果你发现。
“流程必须固定”。
而“其中某几步必须可扩展”。
Template Method 很合适。
如果你发现。
“对外接口必须守规矩”。
而“具体实现细节需要多态”。
NVI 往往更合适。
但如果你的可变部分很大。
还经常要在运行时切换。
那你更像在找 Strategy。
不是在找继承。
我见过太多项目。
用继承硬做可插拔。
最后把自己做成了一个“只能重启才能换策略”的系统。
小结
模板方法教你。
把流程钉死。
把洞留出来。
NVI 再补一刀。
把对外接口钉死。
把私活留出来。
你会发现。
它们都不花哨。
都很老。
也都很实用。
因为它们解决的不是“写法”。
是人性。