11. 策略类与策略模式:用模板实现策略模式、策略类的组合与复用
策略模式,说白了就是把「做什么」和「怎么做」拆开。你想想看,写代码最怕什么?最怕一个函数里塞满了 if-else,今天加一种算法,明天改一种行为,改着改着就崩了。策略模式就是来解决这个问题的。
我最早接触策略模式是在一个图像处理项目里。那时候要支持多种滤镜算法,每种算法参数还不一样。一开始我用虚函数 + 继承,跑起来总觉得有点重。后来换成模板实现,编译期就把策略定死,性能直接拉满。嗯,今天我们就聊聊怎么用 C++ 模板把策略模式玩出花来。
11.1 策略模式的核心思想
策略模式定义了一族算法,把它们封装起来,让它们可以互相替换。使用策略的对象叫做「上下文」,它不关心具体策略怎么实现,只关心策略提供的接口。
传统面向对象做法是这样的:
class Strategy {
public:
virtual int execute(int a, int b) = 0;
virtual ~Strategy() = default;
};
class AddStrategy : public Strategy {
public:
int execute(int a, int b) override { return a + b; }
};
class Context {
Strategy* strategy_;
public:
explicit Context(Strategy* s) : strategy_(s) {}
int doWork(int a, int b) { return strategy_->execute(a, b); }
};
这段代码能跑,但有个问题:每次调用 doWork 都要走虚函数表,性能有损耗。而且策略对象是运行时绑定的,你没法在编译期做优化。
核心要点:策略模式把变化的部分封装成策略,上下文只依赖策略接口。这样新增策略不用改上下文,符合开闭原则。
11.2 用模板实现编译期策略
模板可以把策略从「运行时多态」变成「编译期多态」。说白了,就是用模板参数代替虚函数。我个人的习惯是,只要策略在编译期能确定,就优先用模板。
template <typename Strategy>
class Context {
Strategy strategy_;
public:
explicit Context(Strategy s = Strategy{}) : strategy_(std::move(s)) {}
int doWork(int a, int b) {
return strategy_.execute(a, b);
}
};
struct AddStrategy {
int execute(int a, int b) const { return a + b; }
};
struct MulStrategy {
int execute(int a, int b) const { return a * b; }
};
// 使用
Context<AddStrategy> ctx_add;
Context<MulStrategy> ctx_mul;
你看,这里没有虚函数,没有继承。编译器看到 Context<AddStrategy> 和 Context<MulStrategy> 会生成两份完全不同的代码,每份都是内联展开的。性能上,跟手写 a + b 没区别。
小提示:模板策略的代价是代码膨胀。如果你的策略种类非常多(比如上百种),要考虑编译时间和二进制体积。我一般控制在 10 种以内,超过这个数就考虑运行时策略了。
11.3 策略类的组合
实际项目中,一个上下文往往需要多个策略。比如一个排序算法,可能需要比较策略和分区策略。这时候我们可以把多个策略组合起来。
我曾在写一个通用数据处理器时遇到过这种情况:既要指定数据源格式,又要指定输出格式,还要指定过滤规则。三个策略组合在一起,用模板参数包搞定:
template <typename... Strategies>
class Pipeline {
std::tuple<Strategies...> strategies_;
public:
explicit Pipeline(Strategies... args)
: strategies_(std::move(args)...) {}
template <typename T>
T process(const T& input) {
return std::apply([&](auto&... strat) {
return (strat.execute(input), ...);
}, strategies_);
}
};
struct ReadStrategy {
std::string execute(const std::string& raw) {
return "read: " + raw;
}
};
struct FilterStrategy {
std::string execute(const std::string& data) {
if (data.size() > 10) return data;
return "";
}
};
// 使用
Pipeline<ReadStrategy, FilterStrategy> pipe;
auto result = pipe.process("hello world");
这里用了 C++17 的折叠表达式和 std::apply,把多个策略串成一个流水线。每个策略的输出作为下一个策略的输入。这种组合方式非常灵活,你可以在编译期任意拼装策略链。
注意:组合策略时,要保证每个策略的输入输出类型匹配。我曾经在项目里因为类型不匹配,编译报错信息长达几百行,排查了半天。建议给每个策略加上 static_assert 做类型检查。
11.4 策略的复用与默认策略
策略类本身也可以复用。比如很多策略都有相同的「前置处理」或「后置处理」逻辑。我们可以用模板基类来提取公共部分。
template <typename Derived>
class StrategyBase {
public:
int execute(int a, int b) {
// 前置处理:检查参数
if (a < 0 || b < 0) {
throw std::invalid_argument("negative not allowed");
}
// 调用子类的具体实现
return static_cast<Derived*>(this)->doExecute(a, b);
}
};
class SafeAdd : public StrategyBase<SafeAdd> {
public:
int doExecute(int a, int b) const { return a + b; }
};
这种 CRTP(奇异递归模板模式)的技巧,可以在不引入虚函数的前提下,给策略加上通用逻辑。我经常用它来做参数校验、日志记录、性能统计等横切关注点。
另外,给模板参数提供默认策略也是个好习惯:
template <typename Strategy = AddStrategy>
class Context {
// ...
};
// 用户可以不传策略,默认用加法
Context<> default_ctx;
这样既保留了灵活性,又降低了使用门槛。新手可以直接用默认行为,高手可以自定义策略。
11.5 策略模式的知识体系
下面这张图总结了本章的核心脉络:
11.6 避坑指南与最佳实践
讲几个我踩过的坑:
- 模板策略不要写虚函数:既然用了编译期多态,就别混用运行时多态。混用会导致代码既膨胀又慢,两头不讨好。
- 策略接口要稳定:一旦模板策略被多处使用,修改接口会导致所有使用者重新编译。我建议把策略接口定义成概念(C++20 Concept),这样编译报错更友好。
- 小心策略对象的拷贝:模板策略是按值存储的,如果策略对象很大(比如包含大量状态),记得用
std::reference_wrapper或者移动语义。
最佳实践总结:
- 策略数量少(≤10)且编译期确定 → 模板策略
- 策略数量多或运行时动态加载 → 虚函数策略
- 多个策略需要组合 → 模板参数包 + 折叠表达式
- 策略需要公共逻辑 → CRTP 基类提取
- 给模板参数提供默认策略,降低使用门槛
嗯,策略模式用模板实现,说白了就是把「选择」从运行时提前到编译期。代价是灵活性降低,但换来的是性能提升。我个人更倾向于在性能敏感的核心路径上用模板策略,在配置灵活的外围代码上用虚函数策略。两者不冲突,看场景选就好。