ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modern C++策略模式:从虚函数到std::variant的进阶实践

Modern C++策略模式:从虚函数到std::variant的进阶实践

策略模式大概是设计模式里被用得最多、也最容易写走样的一个。大家学 C++ 的时候,都见过那套“定义一个抽象接口,然后写一堆派生类,上下文持有基类指针,运行时调用虚函数”的经典写法。它解决的核心问题很朴素:把一组可以相互替换的算法或行为封装起来,让调用方不用关心具体实现,也能在运行时切换行为。对正在用 C++ 做业务系统、中间件或者游戏逻辑的同学来说,策略模式几乎是“解耦”的代名词,但真正把它用好、用对,并不是记住一张 UML 图就够的。

这篇内容我想聊的是“进阶”之后的东西:在 Modern C++ 环境下,策略模式该长什么样?虚函数还是唯一答案吗?std::function、模板、std::variant能不能替代它?什么时候该用哪个?以及我这些年踩过的一些坑。适合已经会写基本设计模式的读者,也适合那些想在项目里引入策略但怕过度设计的同学。

1. 策略模式的本质与设计思路拆解

1.1 策略模式到底在解决什么问题

很多教材喜欢从“算法族”的角度定义策略模式:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。但实际项目里,策略模式的“算法”并不是只有排序、压缩这种数学算法,它更像是一种“行为的选择策略”。

比如支付场景里的多种支付方式、文本输出时的大写/小写/加粗、日志系统里的控制台/文件/远程上报。它们的共同点是:外层业务逻辑是一样的,但内部某一环的处理方式经常变化。如果把这部分写死在业务代码里,那每加一种方式都要改一遍业务代码,改着改着就变成了密密麻麻的if-else。

策略模式在这里做的事情,是把“变化的这部分行为”单独抽象出来,让业务逻辑只依赖一个稳定的抽象接口。这样增加新策略时,业务逻辑不用动,符合开闭原则。同时策略的切换也灵活,运行时可以随便换,甚至可以由配置驱动。

1.2 经典 UML 的 C++ 落地形态

教科书上的经典形态大家应该都不陌生:一个Strategy抽象基类,一组派生类实现具体行为,Context持有Strategy*,构造时注入或者运行时更换。C++ 写出来大致是这样:

#include <iostream> #include <memory> class IStrategy { public: virtual ~IStrategy() = default; virtual void execute() = 0; }; class ConcreteStrategyA : public IStrategy { public: void execute() override { std::cout << "Strategy A\n"; } }; class ConcreteStrategyB : public IStrategy { public: void execute() override { std::cout << "Strategy B\n"; } }; class Context { public: explicit Context(std::unique_ptr<IStrategy> s) : strategy_(std::move(s)) {} void setStrategy(std::unique_ptr<IStrategy> s) { strategy_ = std::move(s); } void run() { if (strategy_) strategy_->execute(); } private: std::unique_ptr<IStrategy> strategy_; }; int main() { Context ctx(std::make_unique<ConcreteStrategyA>()); ctx.run(); ctx.setStrategy(std::make_unique<ConcreteStrategyB>()); ctx.run(); }

这段代码本身没什么问题。它把“行为”和“使用行为的上下文”解耦了,而且因为Context持有的是基类指针,运行时可以自由切换策略。这个版本在很长一段时间里都是主流,甚至现在很多框架里依然能看到它的影子。

但它的坏处也很明显:你只是为了封装一个execute(),就得写一个基类加 N 个派生类,每个策略一个文件。一旦策略数量多起来,类会爆炸,而且虚函数调用有间接开销,unique_ptr的生命周期管理也必须小心翼翼。

1.3 为什么策略模式容易被用烂

我见过不少项目,一开始只是想封装一两个回调,结果设计者照搬书上“策略模式三大角色”的写法,硬生生造出IXxxStrategy接口、十几个策略类、一个复杂的Context。最后代码确实“符合设计模式”了,但没人愿意往里加新策略,因为每加一个都要新建文件、注册、改工厂,代价高得离谱。

还有一个很常见的误区,是把策略模式和if-else完全对立。实际上策略模式并不消灭条件分支,它只是把分支的选择从业务代码里移走了。选择策略这件事本身仍然需要某种机制:工厂、注册表、配置映射、switch,你总得选一个。如果你连选择逻辑都硬塞进Context,那照样还是一堆分支。

所以,进阶的第一课不是“怎么画出更标准的 UML”,而是想清楚:你的策略是运行期可变的,还是编译期固定的?是开放式的(还会不断新增),还是封闭式的(就这几个)?不同的答案,对应完全不同的现代 C++ 实现方案。

2. 现代 C++ 实现策略模式的四种姿势

2.1 经典虚函数:什么时候仍然首选

虚函数版本最大的优势是“稳定”。接口关系明确,策略类的结构和职责一目了然,团队协作时很容易做 Code Review。而且虚函数不仅能封装一个方法,还能封装一组相关的操作。如果策略不止一个动作,比如“初始化”、“执行”、“清理”三个步骤都要一起替换,那经典抽象接口仍然是最自然的选择。

另外,虚函数的多态是运行时的,这意味着你可以在运行期间根据配置、用户输入、环境信息动态换策略。这一点在插件化架构里是刚需。比如一个网络请求组件,根据内网/外网环境选择不同的协议处理策略,你不可能在编译期写死,只能运行时多态。

但使用虚函数版本时,有几个细节需要注意:

  • 基类一定要有虚析构函数,否则通过基类指针删除派生类对象是未定义行为。
  • 如果策略对象不需要被继承,可以用final标记,帮助编译器优化。
  • Context尽量只依赖抽象接口,不要在Context里出现具体策略类的类型判断。
  • 如果策略状态很多,可以考虑用shared_ptr让多个Context共享同一个策略对象,但要注意写权限问题。

虚函数不是过时技术,关键看场景。在策略集合开放、策略包含多个协同方法、需要运行期替换这三者同时成立时,它还是最稳的选择。

2.2 std::function 策略:轻量回调化

如果你的策略本质上只是“一个可调用对象”,那完全没必要定义抽象基类。std::function就是这个场景的利器。它能把普通函数、lambda、函数对象、成员函数统一包装起来,底层用的是类型擦除,使用起来非常轻量。

用std::function重写支付策略:

#include <functional> #include <iostream> using PayStrategy = std::function<bool(double amount)>; class PaymentContext { public: explicit PayStrategy pay_; void setPayStrategy(PayStrategy pay) { pay_ = std::move(pay); } bool pay(double amount) { if (pay_) return pay_(amount); return false; } }; int main() { PaymentContext ctx; ctx.setPayStrategy([](double amount) { std::cout << "支付宝支付: " << amount << std::endl; return true; }); ctx.pay(100.0); ctx.setPayStrategy([](double amount) { std::cout << "微信支付: " << amount << std::endl; return true; }); ctx.pay(200.0); }

这个版本读起来清爽很多。策略从“类”变成了“行为”,新增策略不需要新建文件,哪里需要就在哪里写 lambda。它非常适合以下场景:

  • 策略只有一个核心方法,且没有复杂的自身状态。
  • 策略的使用范围很小,不会到处传。
  • 你希望调用方能自由组合一些简单行为。

std::function也不是没有代价。它内部需要类型擦除,可能产生堆分配,调用时有额外的间接开销,但通常比虚函数略重一点。在业务系统里,这点开销几乎无感;但在每秒调用上百万次的循环里,就要谨慎了。

还有一个坑,std::function可以捕获 lambda 的引用,但如果你捕获的是临时对象或者局部变量的引用,lambda 存活期比引用长,就会悬垂。所以用std::function存策略时,尽量按值捕获,或者把策略对象本身的生命周期管理好。

2.3 模板与编译期策略:零成本抽象

如果你的策略在编译期就能确定,那完全可以让编译器来做多态。用模板参数代替基类虚函数,调用点直接静态绑定,甚至可以内联。这是所谓“零成本抽象”的典型玩法。

最简单的例子是一个模板函数:

#include <iostream> struct UpperCaseFormatter { std::string format(const std::string& in) const { std::string out = in; for (auto& c : out) c = static_cast<char>(std::toupper(c)); return out; } }; struct PlainFormatter { std::string format(const std::string& in) const { return in; } }; template <typename Formatter> std::string formatText(const std::string& text, Formatter fmt) { return fmt.format(text); } int main() { std::cout << formatText("hello", UpperCaseFormatter{}) << std::endl; std::cout << formatText("hello", PlainFormatter{}) << std::endl; }

这个版本没有抽象基类、没有虚函数、没有std::function的类型擦除,性能几乎和直接调用普通类方法一样。编译器在formatText("hello", UpperCaseFormatter{})处就能确定Formatter具体类型,把fmt.format内联展开。

它的问题也很明显:策略类型被编译进调用点,无法在运行时动态切换。你没法从配置读一个字符串,然后在formatText里根据字符串选择不同策略——除非先把所有可能的类型都列出来做分发,那又回到了运行时多态。

模板策略适合策略集合完全确定、且不会变的场景。比如内部某个算法有几种变体,你可以在编译期通过宏、类型萃取或者if constexpr来选择。它的可读性比虚函数版本稍差,模板报错信息也比较吓人,所以建议在团队对模板都比较熟练时再大规模使用。

2.4 std::variant 策略:有限集合的静态分发

std::variant是 C++17 带来的一个很有意思的工具,它相当于一个类型安全的联合体。用std::variant来装策略,可以让“策略集合是封闭的”这一事实在类型层面显式表达出来。

比如一个文件导出功能,只有三种格式:CSV、JSON、XML。用std::variant表达:

#include <iostream> #include <string> #include <variant> #include <vector> struct CsvExporter { std::string exportData(const std::vector<int>& data) const { std::string out; for (int v : data) { out += std::to_string(v) + ","; } return out; } }; struct JsonExporter { std::string exportData(const std::vector<int>& data) const { std::string out = "["; for (size_t i = 0; i < data.size(); ++i) { if (i) out += ","; out += std::to_string(data[i]); } return out + "]"; } }; struct XmlExporter { std::string exportData(const std::vector<int>& data) const { std::string out = "<data>"; for (int v : data) { out += "<item>" + std::to_string(v) + "</item>"; } return out + "</data>"; } }; using Exporter = std::variant<CsvExporter, JsonExporter, XmlExporter>; std::string exportByType(const std::vector<int>& data, const Exporter& ex) { return std::visit([&](const auto& exporter) { return exporter.exportData(data); }, ex); } int main() { Exporter ex = JsonExporter{}; std::cout << exportByType({1, 2, 3}, ex) << std::endl; }

std::visit会根据variant当前存储的类型,在编译期生成一个相当于 switch 的分发,不会像虚函数那样有间接跳转,也不像std::function那样有堆分配。它的值语义让对象可以轻松拷贝、存储,不需要管理生命周期。

代价是:策略集合必须是封闭的。你无法在程序外部新增策略,每次加一个类型都要改using Exporter = std::variant<...>这一行。如果你希望系统像插件一样无限扩展,那 variant 就不合适。反过来,如果产品明确就是三种格式,用它非常合适,可读性比一堆继承类强多了。

3. 实操:一个文本格式化器的策略演进过程

3.1 场景设定与需求变化

为了更直观地比较这几种实现,我拿一个实际项目里用到的例子:文本格式化器。需求不复杂,给一段原始文本,按照不同规则输出。

最初的版本只有两种格式:普通文本和全大写文本。后来产品又要求支持 Markdown 加粗,再后来又要求支持 JSON 字符串转义。整个需求变化过程,非常适合用来展示策略模式的演进。

3.2 第一版:经典虚函数接口

按照教科书思路,定义ITextFormatter接口,两个实现类,TextContext持有基类指针。

#include <algorithm> #include <cctype> #include <iostream> #include <memory> #include <string> class ITextFormatter { public: virtual ~ITextFormatter() = default; virtual std::string format(const std::string& input) const = 0; }; class PlainFormatter : public ITextFormatter { public: std::string format(const std::string& input) const override { return input; } }; class UpperFormatter : public ITextFormatter { public: std::string format(const std::string& input) const override { std::string out = input; std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return std::toupper(c); }); return out; } }; class MarkdownBoldFormatter : public ITextFormatter { public: std::string format(const std::string& input) const override { return "**" + input + "**"; } }; class TextContext { public: explicit TextContext(std::unique_ptr<ITextFormatter> f) : formatter_(std::move(f)) {} void setFormatter(std::unique_ptr<ITextFormatter> f) { formatter_ = std::move(f); } std::string format(const std::string& input) const { return formatter_->format(input); } private: std::unique_ptr<ITextFormatter> formatter_; };

调用:

TextContext ctx(std::make_unique<PlainFormatter>()); std::cout << ctx.format("hello") << std::endl; ctx.setFormatter(std::make_unique<UpperFormatter>()); std::cout << ctx.format("hello") << std::endl;

这个版本能跑,但问题已经冒出来了:为了三个字符级的转换操作,我造了三个类、一个接口、一个上下文。要加第四种策略,就得再写一个类。而且这些策略类没有任何内部状态,纯粹是对std::string的纯函数,用类来承载它们有点重。

3.3 第二版:std::function + lambda 组合

我决定把策略类型改成std::function<std::string(const std::string&)>。这样每种格式只是一个 lambda,不再需要一整套继承体系。

#include <functional> #include <string> class TextContext { public: explicit TextContext(std::function<std::string(const std::string&)> f) : formatter_(std::move(f)) {} void setFormatter(std::function<std::string(const std::string&)> f) { formatter_ = std::move(f); } std::string format(const std::string& input) const { return formatter_(input); } private: std::function<std::string(const std::string&)> formatter_; }; auto plain = [](const std::string& in) { return in; }; auto upper = [](const std::string& in) { std::string out = in; std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return std::toupper(c); }); return out; }; auto bold = [](const std::string& in) { return "**" + in + "**"; }; TextContext ctx(plain); std::cout << ctx.format("hello") << std::endl; ctx.setFormatter(upper); std::cout << ctx.format("hello") << std::endl; ctx.setFormatter(bold); std::cout << ctx.format("hello") << std::endl;

这一版的代码量一下子就降下来了。策略从一个“类”变成了一个“行为”,新增策略的时候只需要写一个 lambda 或者普通函数,如果你的策略逻辑真的很简单,甚至不需要单独命名。

当时我在项目里踩过一个坑:std::function看起来可以赋任意可调用对象,但如果你给它赋一个nullptr,调用时会抛std::bad_function_call。最好在format里检查一下formatter_是否为空,避免线上崩溃。同时,这类强类型别名最好用using TextFormatter = std::function<...>,别让一大串模板参数到处飞。

3.4 第三版:编译期模板策略

如果这个格式化器只会在编译期确定格式,而且是在性能敏感路径上高频调用,那么模板版本更合适。

#include <string> template <typename Formatter> class TextContextT { public: explicit TextContextT(Formatter f) : formatter_(std::move(f)) {} std::string format(const std::string& input) const { return formatter_.format(input); } private: Formatter formatter_; }; struct UpperFormatter { std::string format(const std::string& input) const { return ...; } }; struct BoldFormatter { std::string format(const std::string& input) const { return "**" + input + "**"; } }; TextContextT<UpperFormatter> ctx(UpperFormatter{}); ctx.format("hello");

模板版本的优点是没有虚函数开销,也不存在std::function的类型擦除,所有调用都能内联。但代价是TextContextT<UpperFormatter>和TextContextT<BoldFormatter>是两个完全不同的类型,你不能像虚函数那样把它们放进同一个容器。

我的建议是:如果你确定策略类型在编译期不会变,也不需要在容器里统一保存,那就用模板。如果策略会在运行期从配置中切换,模板就不方便,直接用运行时多态那套。

3.5 第四版:std::variant 封闭策略集

回到业务场景,如果产品明确只支持这几种格式,而且希望避免类爆炸,那么std::variant是很好的落点。

#include <variant> #include <string> struct PlainFormatter { std::string format(const std::string& in) const { return in; } }; struct UpperFormatter { std::string format(const std::string& in) const { ... } }; struct BoldFormatter { std::string format(const std::string& in) const { return "**" + in + "**"; } }; using TextFormatter = std::variant<PlainFormatter, UpperFormatter, BoldFormatter>; class TextContextV { public: explicit TextContextV(TextFormatter f) : formatter_(std::move(f)) {} std::string format(const std::string& input) const { return std::visit([&](const auto& f) { return f.format(input); }, formatter_); } private: TextFormatter formatter_; }; TextContextV ctx(BoldFormatter{}); std::cout << ctx.format("hello") << std::endl;

ctx保存的是TextFormatter类型,它可以安全拷贝、赋值,没有堆分配,也不需要手工删除。如果以后要增加一种ItalicFormatter,只需要改using TextFormatter = std::variant<...>加一个类型,并在所有使用std::visit的泛型 lambda 里自然适配。

不过要注意,std::visit要求对每个可能类型都能编译通过。如果某个策略类型缺了format方法,会直接编译报错。这其实是好事,因为它把“类型接口”约束推到了编译期,错误比运行期诡异行为好排查。

3.6 四版对比与选择建议

为了方便决策,我整理了一张表:

实现方式类型安全运行时开销运行时切换扩展性代码量适用场景
经典虚函数中(靠基类)虚函数调用 + 堆对象容易开放,新增派生类即可最多策略包含多方法、需要插件化
std::function中(运行时可能为空)类型擦除 + 可能堆分配容易开放,传 lambda 即可最少单行为策略、轻量回调
模板高(编译期强约束)最低,可内联困难中,需重编译中等性能敏感、编译期确定
std::variant高(编译期封闭集合)低,switch 分发需重建对象封闭,增加类型要改 using少已知有限策略集、值语义

这些方案并不是互斥的。一个系统里完全可以并存:核心框架用虚函数做插件,某个高频模块用模板,业务侧的简单策略用std::function,可枚举格式用std::variant。策略模式不是一套统一的代码结构,而是一种“把变化点封装起来”的思维。

4. 实战避坑:策略模式常见问题与排查技巧

4.1 策略对象生命周期与所有权

虚函数版本里最常见的坑就是裸指针。Context如果持有Strategy*,但策略对象是外部new出来的,那么谁来 delete?如果Context删除,外部还在用,就悬垂;如果外部删除,Context析构时又删一次,就 double free。

所以我的建议很明确:Context内部用智能指针管理自己的策略,外部传入时用std::unique_ptr。如果策略要共享,用shared_ptr。总之不要为了省事存裸指针,尤其是跨模块传递的时候。

std::function版本的生命周期问题更隐蔽。如果策略是 lambda 捕获了局部变量的引用:

auto badLambda = [&tmp](const std::string& in) { return tmp + in; };

lambda 被存进std::function后,可能在tmp生命周期结束后才被调用,造成悬垂引用。这种问题运行时很难查,崩溃完全随机。规避办法是:尽量按值捕获,或者确保捕获的变量存活期覆盖住策略的使用期。如果一定要从外部配置源创建策略,那你需要把配置数据也拷贝进策略对象里。

4.2 策略选型与运行时开销

虚函数调用的开销通常是一个间接跳转,现代 CPU 分支预测好的话,影响很小。std::function因为类型擦除,可能涉及小对象优化或堆分配,如果策略对象很大,堆分配就躲不掉。模板和std::variant的开销都很低,但std::variant的visit在策略类型很多时会生成很大的分支表,可能会让指令缓存不友好。

我在一个高频日志模块里实测过:模板策略和std::function策略在百万条日志级别的差异大概有 10%~20%。但这点差异对绝大多数业务系统无关紧要,真正重要的是避免在每次调用时创建策略。比如你在循环里反复构造std::function,那开销肯定爆炸。正确做法是策略对象在循环外创建好,循环内只用。

注意:先确认瓶颈,再谈优化。不要因为“虚函数有开销”就强行模板化,结果把代码可读性牺牲了,收益却可以忽略。

4.3 类爆炸与组合爆炸

策略模式用着用着,会出现“为了一个方法建一个类”的现象。尤其一个类里只有format或者execute,还硬要弄成抽象基类派生,那真的没有必要。std::function或者裸函数指针就能解决。

更麻烦的是组合爆炸。假设你有压缩策略、加密策略、校验策略,每种策略 5 个变体,如果把它们排列组合成新的“完整处理策略”,那就是5×5×5=125个类,没人维护得了。正确的姿势是不要用继承去组合,而是把多个策略组合到同一个上下文里,让上下文依次调用它们,也就是“策略 + 管道”的写法:

class ProcessingContext { public: void addStep(std::function<void()> step) { steps_.push_back(std::move(step)); } void run() { for (auto& step : steps_) step(); } private: std::vector<std::function<void()>> steps_; };

这样每个维度的策略各自独立,组合逻辑由调用方自由编排,类数量从笛卡尔积变成简单相加。

4.4 可测试性与依赖注入

策略模式的测试核心是“上下文不依赖具体策略”。如果你在Context里直接new一个默认策略,那测试时想换策略就很别扭。最好通过构造函数注入,或者至少提供 setter。

我习惯在单元测试里写一个“记录型”策略,比如一个只负责记录调用次数和参数的 mock,用来验证上下文是否正确调用了策略。这比在真实策略里塞测试逻辑干净得多。

另外,策略接口的粒度要控制好。如果一个策略接口同时包含“格式化”和“持久化”两个职责,那么实现类会被迫实现不相关的方法,测试时也要同时 mock 两个维度。按单一职责拆分接口,会让测试更干净。

4.5 策略不生效的排查实录

不少人遇到过“明明设置了新策略,但调用还是旧行为”的问题。我排查过的案例里,出现最多的几个原因:

  • 对象切片:把派生类按值传给接受基类参数的函数,派生部分被切掉,虚函数表也变成基类的,行为自然不对。
  • 用错了指针类型:unique_ptr<Base>指向派生类没问题,但如果你不小心用Base*接收了一个Derived*,在释放时没有虚析构,行为同样诡异。
  • Lambda 捕获了旧配置:策略本身没换,只是 lambda 内部缓存了旧值。看起来像“策略不生效”,实际上是策略里的状态过期了。
  • 命名空间的 using 没到位:两个不同的类型同名,但一个来自旧模块一个来自新模块,配置映射到旧类型上,新策略根本没被加载。

排查技巧上,优先在设置策略和调用策略两个位置打日志,打印策略对象地址或类型标识。如果地址一致但行为不同,就是策略内部状态出了问题;如果地址不一致,说明设置没生效或者被其他地方覆盖了。这种二分定位法在处理策略相关 bug 时特别高效。

5. 别把策略模式用成万金油

5.1 策略 vs 状态模式

策略模式关注的是“怎么做”,状态模式关注的是“当前处于什么状态、下一步应该做什么”。策略的切换通常由外部注入,状态的迁移通常由内部事件驱动。虽然两者的类图很像,但意图差别很大。

举个例子,一个网络连接对象,根据Connected、Disconnected、Connecting状态做出不同反应,这是状态模式。如果你只是把一个压缩算法从 zip 换成 gzip,这是策略模式。把两者混用,代码结构会变得很难解释:策略对象内部竟然在改变上下文状态,上下文反过来影响策略选择。

5.2 策略 vs 命令模式

命令模式重点在“把请求封装成对象”,它支持排队、撤销、日志。策略模式重点在“算法可替换”。从接口看,命令常常有execute(),策略也常常有execute(),容易混淆。

关键区别是:命令通常封装一个具体的动作,并且可以放进队列延后执行,撤销时逆操作;策略封装的是达到某个目标的方式,它不关心调用时机和撤销。如果只是需要把一个操作传进某个容器,用std::function就够了,不必上升为策略类。

5.3 策略 vs 模板方法

模板方法用继承的方式在基类里定义算法骨架,把可变步骤留给派生类实现。策略则用组合的方式,把整个算法作为对象注入。模板方法修改的是“骨架里的某几步”,策略替换的是“整个算法”。

当多个变体共享大部分逻辑,只有小步骤不同时,模板方法能减少重复代码。但当变体之间存在较大行为差异时,策略更干净。C++ 里还可以通过 CRTP 模板实现编译期模板方法,兼顾性能和复用。

5.4 策略 vs 装饰器

装饰器可以在不改变对象接口的情况下,动态地给对象添加新职责,它强调的是“包装和增强”。策略强调的是“替换某个行为”。比如给文本加颜色、加边框、加阴影,这是装饰器;把文本从普通格式换成 JSON 格式,这是策略。

实际代码里两者可以搭配使用:策略负责基本算法,装饰器负责在算法前后附加额外处理。但不要把装饰器当成策略来用,否则每个装饰层都得实现整套策略接口,会让调用链很长,排障困难。

5.5 我的选择原则

我已经不太在意“这是不是标准策略模式”了,更关心的是“这段代码加需求时会不会让人头疼”。我现在会按下面这个顺序决策:

先看策略数量会不会变。如果策略集合封闭,优先std::variant,因为安全、快、可拷贝。如果策略集合开放,再看策略是否有状态。有状态就考虑虚函数或std::function包装类对象;没状态、只是回调,就用std::function甚至函数指针。

再看使用时机。编译期能确定就用模板,运行期要切换就只能保运行时多态。性能敏感的地方优先模板,普通业务代码别为了省一点开销去牺牲可读性。

最后看团队习惯。如果团队里大多数人不熟悉模板和std::variant,强行用新特性只会让维护成本升高。技术选型从来不是“哪个最高级”,而是“哪个最能被稳定维护”。

我个人这几年写策略相关代码最大的体会,是把策略的“接口”做得越窄越好。能传一个std::function就不定义抽象类,能用值语义就用值语义。很多所谓“设计模式过度设计”,问题就出在给本不需要扩展的东西预留了太多抽象。策略模式的价值在于封装变化,但封装的前提是识别出真正的变化点,而不是把所有方法都塞进一个统一接口。等你真正遇到策略爆炸或者难以测试时,再回头看看这篇文章里的取舍,应该能少走不少弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进