☰
C++策略模式高级玩法:从虚函数到模板与注册表的实战指南
2026/10/16 8:18:54 网站建设 项目流程

如果你问我 C++ 设计模式里最容易被低估的是哪个,我会说是策略模式。它没有观察者模式那种“事件驱动”的吸引力,也没有单例模式那种“到处可用”的便利感,但它其实是处理业务复杂度最高效的工具之一。尤其当你面对一个对象在多种算法、多种规则、多种行为方式之间需要灵活切换时,策略模式给你的不是某个具体方案,而是一整套组织代码的方法论。这篇文章我想聊的不是教科书上那种三分钟讲完的入门版,而是从我自己实际项目里总结出来的高级玩法:如何选型封装方式、如何用模板把策略压到编译期、如何用注册表让策略变成配置驱动、以及策略组合和状态机之间的边界到底在哪里。内容偏实战,适合已经会写 C++、用过一些设计模式、想在真实项目里把代码结构再往上推一层的读者。

1. 策略模式在 C++ 里的本质:别让实现细节绑架调用方

1.1 没有策略模式的时候,代码长什么样

先看一个最常见的反面教材。很多业务系统里都会有类似这样的函数:根据某个枚举值走不同逻辑。比如一个数据导出模块,要支持导出成 JSON、CSV、XML 三种格式,最常见的写法就是:

void exportData(const Data& data, ExportFormat format) { if (format == ExportFormat::JSON) { auto json = convertToJson(data); writeToFile(json); } else if (format == ExportFormat::CSV) { auto csv = convertToCsv(data); writeToFile(csv); } else if (format == ExportFormat::XML) { auto xml = convertToXml(data); writeToFile(xml); } else { throw std::invalid_argument("unsupported format"); } }

这段代码的问题不在于缩进丑,而在于它把“导出这个动作”和“每一种格式的具体算法”死死地焊在了同一个函数里。每次新增一种格式,就要跑过来在这个函数里再加一个 else if;每次改动其中一种格式的转换逻辑,都要检查有没有影响另外两条分支;而且这种函数一旦多起来,switch 和 if-else 会在整个项目里到处繁殖,改起来就变成打地鼠。

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

策略模式的定义很简洁:定义一族算法,把它们逐个封装起来,并且使它们可以互相替换。但定义简洁不代表理解到位。我自己的理解是,策略模式本质上做了一件事:把“做什么”和“怎么做”解耦。调用方只需要知道有一个策略接口,它能把数据从 A 形态变成 B 形态,至于底层是 JSON 还是 CSV,调用方完全不关心,也不需要关心。

这种解耦带来三个直接收益。第一是开闭原则:新增策略时,不需要改已有代码,只需要新增一个类或一个函数。第二是隔离变化:每种算法的内部细节被封闭在自己的类型里,不会互相干扰。第三是可测试性:策略本身就是一个小单元,单独拎出来测试非常容易,不需要先准备一整套业务环境。

class ExportStrategy { public: virtual ~ExportStrategy() = default; virtual void exportTo(const Data& data, std::ostream& os) const = 0; }; class JsonExportStrategy : public ExportStrategy { public: void exportTo(const Data& data, std::ostream& os) const override { os << convertToJson(data); } };

1.3 什么才算“高级”应用

入门级策略模式就是一个抽象基类加几个派生类,这种用法我相信大部分人都能写出来。但真正的高级应用体现在几个方面:一是封装方式的多样性,虚函数、函数指针、模板、泛型 lambda,不同场景下的最佳选择完全不同;二是策略的获取方式,策略对象是从哪来的,写死在工厂里,还是从配置里反推出来,这决定了系统的灵活上限;三是策略与策略之间的关系,单一策略只是基础,组合策略、策略链、策略装饰器才是处理真实复杂度的关键。这几个维度我会在下面逐一展开,每一块都有对应的代码和踩坑记录。

2. 从虚函数到 std::function:三种策略封装方式的取舍

2.1 经典虚函数接口:最正统的运行时多态

虚函数策略是最经典、也最容易被接受的实现方式。抽象基类定义接口,派生类实现具体算法,运行时通过基类指针或引用调用:

class CompressionStrategy { public: virtual ~CompressionStrategy() = default; virtual std::vector<uint8_t> compress(const std::vector<uint8_t>& data) const = 0; }; class ZlibCompression : public CompressionStrategy { public: std::vector<uint8_t> compress(const std::vector<uint8_t>& data) const override { // 调用 zlib 库实现 return doZlibCompress(data); } }; class Lz4Compression : public CompressionStrategy { public: std::vector<uint8_t> compress(const std::vector<uint8_t>& data) const override { // 调用 lz4 库实现 return doLz4Compress(data); } };

这种方式的优势是直白。任何熟悉面向对象的人拿到这份代码,五分钟之内就能看懂整个结构:策略在哪、实现是什么、怎么扩展。同时它是真正的运行时多态,策略的具体类型可以在程序运行过程中根据配置、用户输入、环境参数等动态决定。

但虚函数方案也有不可回避的代价。第一刀切在性能上:每次通过基类接口调用都会触发一次虚分派,虽然单次开销通常只有几纳秒,但在一个高吞吐的循环里被调用几百万次,这个开销就不是可以忽略的了。第二刀切在灵活性上:派生类和接口之间的绑定比较重,如果某个策略只是一个简单函数,也要强行封装成一个类来满足接口,代码会变得很笨重。

2.2 轻量级策略:函数指针与 std::function

很多人忽略了策略模式不一定非要“类”来承载。在 C++ 里,策略的载体可以是一个函数指针、一个 std::function,甚至是一个泛型 lambda。函数指针是最轻量的方案:

using HashFunc = uint64_t(*)(const uint8_t*, size_t); uint64_t fnv1aHash(const uint8_t* data, size_t len) { // ... } uint64_t crc32Hash(const uint8_t* data, size_t len) { // ... } class HashCalculator { public: void setHashFunc(HashFunc func) { hashFunc_ = func; } uint64_t calculate(const uint8_t* data, size_t len) const { return hashFunc_(data, len); } private: HashFunc hashFunc_ = fnv1aHash; };

函数指针没有类层次,没有虚表,调用开销和直接调用函数几乎一样。但它只能承载自由函数或者静态函数,想捕获状态就比较麻烦。这时候 std::function 就出场了。

class Processor { public: using Strategy = std::function<std::vector<uint8_t>(const std::vector<uint8_t>&)>; void setStrategy(Strategy strategy) { strategy_ = std::move(strategy); } std::vector<uint8_t> process(const std::vector<uint8_t>& input) const { return strategy_ ? strategy_(input) : input; } private: Strategy strategy_; }; // 使用 Processor proc; proc.setStrategy([](const std::vector<uint8_t>& in) { return toUpper(in); }); proc.setStrategy(std::bind(SomethingClass::staticMethod, std::placeholders::_1));

std::function 能装下任何可调用对象:lambda、函数指针、函数对象、绑定表达式。这种灵活性非常有价值,尤其适合策略本身比较轻量、不值得单独建类层次的场景。它还能捕获状态,比如在 lambda 里捕获一些上下文变量,这在虚函数方案里是做不到的,除非你再专门写一个带成员变量的派生类。

std::function 的代价是潜在的内存分配和间接调用。如果保存一个捕获了大量状态的 lambda,内部可能触发堆分配;每次调用也是一次类似虚函数的间接跳转。不过在现代编译器里,对于小对象(比如只捕获一个指针),std::function 通常会做小对象优化,不触发堆分配。实测下来大部分业务场景里性能完全可以接受。

2.3 我在选型时的判断逻辑

我在实际项目里基本按这个思路选,可以给你做个参考:

场景推荐方案理由
策略有多个复杂状态、需要多种方法协作虚函数类层次结构清晰,状态和行为能放在同一个类里
策略只是一个纯函数,无状态函数指针零成本,极致轻量
策略需要捕获上下文,但只有一个方法std::function便宜灵活,能装 lambda
策略在关键循环里被高频调用模板策略(见下一节)编译期绑定,消除虚分派
策略需要从配置字符串动态创建注册表 + 工厂(见第四节)运行期灵活性最高

这里想多提一句:选 std::function 的时候要小心复制拷贝的成本。如果策略对象很大,尽量用移动语义或者智能指针包一层。另外 std::function 在为空的时候调用会抛 std::bad_function_call,所以在 set 的时候最好默认给一个 fallback 策略,别让空指针状态暴露给调用方。

3. 模板策略的编译期路线:性能与灵活性的平衡

3.1 模板策略的基本形态

如果策略类型在编译期就能确定,那请一定优先考虑模板方案。模板策略的本质是让类型本身承载策略,不需要任何虚函数,也不存在运行时类型选择。一个典型的例子是实现一个支持多种协议打包的通信模块:

template <typename Packer> class PacketProcessor { public: void processPackedData(const std::vector<uint8_t>& raw) { auto packet = Packer::unpack(raw); // 业务逻辑 auto payload = extractPayload(packet); handlePayload(payload); } }; struct ProtobufPacker { static Packet unpack(const std::vector<uint8_t>& raw) { /* ... */ } }; struct FlatbuffersPacker { static Packet unpack(const std::vector<uint8_t>& raw) { /* ... */ } }; // 实例化 PacketProcessor<ProtobufPacker> protobufProc; PacketProcessor<FlatbuffersPacker> flatbufProc;

这里 Packer 就是策略。调用方在编译期就把策略钉死在类型参数里,编译器为每一种 Packer 实例化一份独立的 PacketProcessor 代码,所有方法调用都是直接调用,没有一次虚分派。在性能敏感的场景里,比如网络协议解析、编解码、序列化层,这种差异非常可观。

3.2 给模板策略加上 concept 约束

模板策略最大隐患是“鸭子类型”过于宽松。假如你期望 Packer 有 unpack 方法,但传入的类型没有这个方法,编译期会报一段极其难读的长模板错误。为了让错误信息可读,也为了让接口意图更明确,C++20 的 concept 就很合适:

template <typename T> concept PackerConcept = requires(const T& t, const std::vector<uint8_t>& raw) { { T::unpack(raw) } -> std::convertible_to<Packet>; { T::pack(1) } -> std::convertible_to<std::vector<uint8_t>>; }; template <PackerConcept Packer> class PacketProcessor { public: void processPackedData(const std::vector<uint8_t>& raw) { auto packet = Packer::unpack(raw); // ... } };

加了 concept 之后,如果你传进来一个没有 unpack 方法的类型,编译器会直接告诉你:这个类型不满足 PackerConcept。而不是甩给你一大段模板实例化失败的内部错误。从工程维护的角度讲,这种约束就是在给未来接手的人省钱。

3.3 结合 if constexpr 实现策略分支收敛

模板策略配合 if constexpr 还能处理一类很难缠的问题:策略之间存在一些交集,但又不完全一样。比如校验策略里,有的策略需要“先解析头部再校验”,有的策略是“直接整体校验”。传统做法是接口里放一个标志位,或者干脆定义两个接口。用 if constexpr 可以把差异收敛在模板内部:

template <typename Validator> class RequestValidator { public: bool validate(const Request& req) { if constexpr (Validator::kNeedsHeaderParse) { auto header = parseHeader(req); if (!Validator::validateHeader(header)) { return false; } } return Validator::validateBody(req.body()); } }; struct SimpleValidator { static constexpr bool kNeedsHeaderParse = false; static bool validateBody(const std::string& body) { return body.size() > 10; } }; struct StrictValidator { static constexpr bool kNeedsHeaderParse = true; static bool validateHeader(int header) { return header > 0; } static bool validateBody(const std::string& body) { return body.find("x") != std::string::npos; } };

if constexpr 的好处是:不满足条件的分支在实例化时根本不会被编译,两份模板代码互不干扰,代码量比继承方案少很多,逻辑也更集中。

3.4 模板策略的注意事项

模板策略并不是银弹。第一,策略类型一旦作为模板参数绑定,运行期就失去了动态切换能力。如果你需要根据用户配置在运行时选择算法,模板方案就不好使了,这时候回到虚函数或者 std::function。第二,模板实例化会增加二进制体积,每种策略组合都会产生一份独立的代码。策略数量十几个还好,如果几十种策略再做笛卡尔积式组合,代码膨胀会非常明显。第三,模板策略对编译时间的冲击是实打实的,项目大了之后要考虑用显式实例化来兜底。

4. 策略注册表与配置驱动:让业务规则可热插拔

4.1 注册表的基本设计

真实项目里经常遇到这样的需求:系统的算法种类非常多,而且未来大概率会增加;调用的地方分散在各个模块;策略需要由配置中心下发来决定。这时候如果每一处都硬编码一个 new,代码会变得极其难以维护。这里推荐用注册表模式配合简单工厂,把策略的“注册”和“创建”集中起来。

注册表的本质是一个从名字到创建函数的映射。创建函数通常返回 unique_ptr 或 shared_ptr:

class StrategyRegistry { public: using Factory = std::function<std::unique_ptr<CompressionStrategy>()>; void registerFactory(const std::string& name, Factory factory) { factories_[name] = std::move(factory); } std::unique_ptr<CompressionStrategy> create(const std::string& name) const { auto iter = factories_.find(name); if (iter == factories_.end()) { throw std::invalid_argument("unknown strategy: " + name); } return iter->second(); } std::vector<std::string> availableNames() const { std::vector<std::string> names; names.reserve(factories_.size()); for (const auto& [name, _] : factories_) { names.push_back(name); } return names; } private: std::unordered_map<std::string, Factory> factories_; };

4.2 配置驱动的策略选择

有了注册表之后,策略选择就可以完全由配置驱动了。例如系统从配置文件里读取 compress_strategy 字段,值是字符串,比如 "zlib" 或者 "lz4",然后通过注册表创建对应策略。新增策略时,只需要写一个新的实现类,然后在初始化阶段调用 registerFactory 注册进去,业务调用方一行代码都不用改。

void initRegistry(StrategyRegistry& registry) { registry.registerFactory("zlib", []() { return std::make_unique<ZlibCompression>(); }); registry.registerFactory("lz4", []() { return std::make_unique<Lz4Compression>(); }); registry.registerFactory("none", []() { return std::make_unique<NoCompression>(); }); } // 业务侧读取配置 auto strategyName = config.get<std::string>("compress_strategy"); auto strategy = registry.create(strategyName);

这种做法的价值叠加在微服务和后台任务里特别明显。每次要调整行为不需要重新编译、重新上线,只需要改配置。而且支持在运行时切换策略——比如某机房观察到 lz4 压缩率不理想,立刻下发配置切到 zlib,整个过程完全无感。

4.3 生命周期管理

注册表方案还有几个容易踩的细节。

一是策略对象的状态。如果策略对象内部有成员变量、缓存等,那么每次 create 都返回全新实例比较安全。如果策略是无状态的,可以考虑返回同一个单例,节省构造开销。

二是并发注册与查询。初始化阶段完成注册,之后是只读查询,这种情况下不需要加锁,非常高效。但如果你允许运行期动态注册新的策略,那就要在注册表内部加读写锁,或者干脆用一个初始化后禁止修改的语义,避免数据竞争。

三是依赖注入。有的策略构造函数里需要依赖其他服务(比如数据库连接池、缓存客户端),把这种依赖写死在策略内部会让注册表很难办。我的做法是:工厂函数捕获依赖,在 create 时把依赖注入进去:

auto dbPool = createDbPool(); registry.registerFactory("complex_strategy", [pool = dbPool]() { return std::make_unique<ComplexStrategy>(pool); });

这样策略类本身不关心依赖是怎么来的,注册表也不关心策略内部长什么样,两边都保持了干净。

5. 策略组合与策略链:单个策略解决不了问题时的两种思路

5.1 组合策略:把多个策略当成一个策略

真实业务很少只有一个策略。一个请求从进来到返回,可能要经过权限校验、限流、参数校验、缓存判断、业务处理多个步骤。如果把每个步骤都当成一个独立的策略,自然想到的是把策略串起来。最简单的方式是做一个 Composite 策略,它内部持有多个子策略,调用时逐个执行:

class CompositeValidationStrategy : public ValidationStrategy { public: void addStrategy(std::unique_ptr<ValidationStrategy> strategy) { strategies_.push_back(std::move(strategy)); } bool validate(const Request& req) const override { for (const auto& strategy : strategies_) { if (!strategy->validate(req)) { return false; } } return true; } private: std::vector<std::unique_ptr<ValidationStrategy>> strategies_; };

这种写法在接口层面上仍然是一个策略,外部行为和单一策略没有区别。它的好处是可以用任意组合方式来组装业务规则,比如核心校验 + 可选的业务校验 + 针对特定渠道的额外校验,完全通过代码串联,不涉及修改已有策略类本身。

如果子策略还需要区分权重、优先级、短路条件,可以在 addStrategy 时传入权重,或者给策略加一个 shouldApply 布尔方法,让策略自己决定是否参与本次校验。这个设计弹性很大,但也要警惕:组合策略用多了之后,调试成本会上升,因为你不太容易直观地知道最终生效的规则是哪几条。

5.2 策略链:当策略之间有先后承接

组合策略适合“所有策略都要执行”的场景,但有些场景是“沿着一条链往后找,直到某个策略能处理”。这时候责任链模式的思路跟策略模式混在一起使用,能产生特别好的效果。举个例子:订单风控场景里,用户提交一个订单请求,系统要决定是放行、人工审核还是直接拒绝。不同风险等级对应不同的处理逻辑,而且这些逻辑之间有天然的顺序关系。

class RiskHandler { public: virtual ~RiskHandler() = default; RiskHandler* next(RiskHandler* handler) { next_ = handler; return this; } virtual HandleResult handle(const Order& order) const { if (next_) return next_->handle(order); return HandleResult::Allowed; } protected: RiskHandler* next_ = nullptr; }; class BlacklistHandler : public RiskHandler { public: HandleResult handle(const Order& order) const override { if (isBlacklisted(order.userId())) { return HandleResult::Rejected; } return RiskHandler::handle(order); } };

这里每个 Handler 本身是策略,链路是策略的编排方式。黑名单、风控规则、新用户标记等策略可以自由地调整先后顺序,甚至可以在配置里指定链路的组成顺序。策略模式提供算法单元的封装,责任链模式提供这些单元的拼接方式,两个结合之后非常能打。

5.3 组合和链路的边界把控

使用策略组合时我建议一条原则:组合策略的语义必须是确定的,不能因为加入顺序不同而产生完全不同的结果。如果策略 A 和策略 B 先后执行的结果,与 B 和 A 先后执行的结果不同,说明这两个策略之间有隐含的耦合关系,这时候要么在文档和代码上明确顺序约束,要么干脆把两者合并成一个不可分割的策略。

策略链则要特别注意“链的末端默认行为”。责任链如果走到最后没有 handler 能处理,应该返回一个明确的默认结果,而不是返回空或者抛出异常。我在项目里踩过这种坑:链路默认返回 Allowed,结果把一条本该拒绝的请求放过去了。后来改成强制要求链路末尾必须有一个最终兜底 handler,才把这个隐患堵住。

6. 策略模式的状态机视角:边界在哪里

6.1 策略模式适合解决什么问题

说到底,策略模式适合的是“同一种动作,多种平行算法”的场景。“导出”可以是 JSON 也可以是 CSV;“压缩”可以是 zlib 也可以是 lz4;“校验”可以是简单规则也可以是复杂规则。它们有一个共同点:语义一致,实现不同,互不依赖,由外部决定用哪个。

6.2 状态模式不是策略模式

最典型的误用就是从策略模式走入状态模式而不自知。状态模式和策略模式的 UML 结构看起来几乎一样,都是抽象基类加若干子类,但语义完全不同。策略模式里,是“外部使用者”决定我要用哪种算法;状态模式里,是“系统内部状态”决定当前行为是什么,而且状态之间通常还有转移关系。

举个例子:一个订单系统有无状态、已支付、已发货、已完成四种状态。每种状态下“取消订单”的行为不同,看起来像四个策略,但真正正确的设计是状态模式:因为状态之间会迁移,用户支付这个动作会让订单从“无状态”迁移到“已支付”,这已经超出了策略模式能表达的范畴。如果你硬用策略模式来管理这些状态,你会发现在策略代码里到处都是 if-else 判断当前状态,反而把一个本应由状态机表达的逻辑揉碎了撒在各种策略类里,维护难度直线上升。

6.3 我的落地心得和避坑建议

结合我自己的经验,在落地策略模式时有几个小建议特别想分享给读者。

第一,如果没有复杂度就不要用策略模式。一个 if-else 能讲清楚的问题,强行抽象成五个策略类,就是过度设计。策略模式的价值在于管理变化,而不是消灭变化。等第二个真实变化出现的时候,再做抽象完全不迟。

第二,策略接口的粒度要适中。接口方法太多,策略实现会冗余;接口方法太少,策略之间无法表达差异性。我一般会先把使用方需要的操作列出来,再反向推导接口方法,尽量让接口只暴露业务语义,不暴露算法细节。

第三,默认策略要做兜底。无论用虚函数、std::function 还是注册表,都要保证在没有显式选择策略时,系统有一个安全默认值。这个默认策略宁可“什么都不做,但行为必须明确”,也不能缺失。比如压缩策略默认不压缩,校验策略默认全通过,导出策略默认抛 NotImplemented,都比空策略指针导致崩溃要好。

第四,把策略的选择和策略的执行分开。策略的选择可以根据配置、输入参数、环境变量来做;策略的执行就是简单地调用接口。不要把“怎么选”的逻辑也写进策略类里,这会让策略类不再纯粹,耦合度升高。

第五,不要怕写文档。策略模式虽然代码整洁,但“为什么这里需要这么多策略类”“每个策略应该用于什么场景”这些信息如果不写下来,几个月后连自己都会忘记当初的分层意图。我习惯在策略类头部直接写一段场景注释,说明这个策略服务于什么业务场景、对比其他策略的取舍是什么,给后面接手的人省去很多考古时间。

策略模式的最大价值是帮助你在算法和业务之间建立一道稳定的边界,让两边可以独立演化。你想换算法、加算法、调整算法组合,业务代码那边可以纹丝不动。反过来,业务规则变了,策略层也只是换一组实现,不会动核心流程。这种“不动”的背后,是设计带来的确定性。希望这篇内容能帮你在自己的 C++ 项目里把策略模式用得更有底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询