# C++设计模式:策略模式——把变化的算法从业务代码里拆出来 当 `if/else` 越写越长 很多团队第一次遇到策略模式,不是在做"高大上框架",而是在维护一段越来越臃肿的业务代码。 假设你在做一个文件归档系统。不同客户对压缩方式的要求不一样: - • 有的要求**压缩速度优先** - • 有的要求**压缩率优先** - • 有的要求**完全不压缩,只求最快上传** 最直接的写法通常是这样: ``` #include #include class ArchiveService { public: void compress(const std::string& file, const std::string& mode) { if (mode == "zip") { std::cout << "使用 ZIP 压缩: " << file << std::endl; std::cout << "压缩率中等,兼容性好" << std::endl; } elseif (mode == "gzip") { std::cout << "使用 GZIP 压缩: " << file << std::endl; std::cout << "压缩率较高,适合传输" << std::endl; } elseif (mode == "lz4") { std::cout << "使用 LZ4 压缩: " << file << std::endl; std::cout << "压缩速度快,适合实时场景" << std::endl; } elseif (mode == "store") { std::cout << "不压缩,直接归档: " << file << std::endl; } else { std::cout << "未知压缩模式" << std::endl; } } }; ``` 刚开始看起来很直接,但项目一长大,问题马上就出来了。 **问题 1:算法选择逻辑和业务流程混在一起** ``` void backup() { // 这里本来应该只关注"备份流程" // 结果却掺杂了大量"如何压缩"的细节 if (mode == "zip") { /* ... */ } else if (mode == "gzip") { /* ... */ } else if (mode == "lz4") { /* ... */ } } ``` 调用方本来只关心"我要执行一次备份",但现在还要理解每种算法的内部差异。**流程代码和变化点没有分离。** **问题 2:每增加一种算法都要改原来的类** ``` // 现在客户又要 Brotli else if (mode == "brotli") { std::cout << "使用 Brotli 压缩: " << file << std::endl; } ``` 每次新增一种算法,都要回到老类里继续补分支。这样写几个月之后,核心类就会慢慢变成一个"算法大全"。 **问题 3:运行时扩展能力很差** ``` // 配置文件写的是 zstd? // A/B 实验要按用户等级切换算法? // 某个租户想注入自定义排序/压缩规则? // 传统 if/else 做得到,但会越来越难维护 ``` 这类代码的本质问题不是"分支太多",而是:**变化的算法没有被当成一等公民来设计。** 生活里其实经常有类似场景: - • **导航软件**会根据你的目标切换"最快路线""最短路线""避开高速"。路线规划流程没变,变化的是路线算法。 - • **外卖平台**会根据活动规则切换"满减""折扣""会员价"。结算流程没变,变化的是计价策略。 - • **杀毒软件**会根据环境切换"快速扫描""全盘扫描""自定义扫描"。扫描入口没变,变化的是执行策略。 没有策略模式时,通常是这样的: ``` [业务流程类] ├── if mode == A -> 算法A ├── if mode == B -> 算法B └── if mode == C -> 算法C ``` 用了策略模式之后,结构会变成: ``` [业务流程类 Context] ──持有──> [Strategy 接口] ├── 策略A ├── 策略B └── 策略C ``` **策略模式就是为解决"同一件事有多种做法,而且这些做法需要可替换、可扩展"这个问题而生的。** ------ ## 策略模式详解 ### 模式定义 > **策略模式(Strategy Pattern)**,核心思想是:**定义一组可互换的算法,把它们分别封装起来,并让客户端在运行时选择具体使用哪一种算法。** 如果把"算法"理解得再宽一点,策略模式其实是在做一件很朴素的事:**把变化点从稳定流程中拆出去**。 ![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E) 所谓"稳定流程",通常是结算、导出、备份、过滤、排序、路由这类大框架;所谓"变化点",则是折扣规则、排序规则、压缩方式、校验方式等具体算法。 ### 核心角色 可以把策略模式拆成三个角色: 1. 1. **Context(上下文)**:对外提供统一业务入口,内部持有一个策略对象 2. 2. **Strategy(策略接口)**:定义一组算法的公共抽象 3. 3. **ConcreteStrategy(具体策略)**:提供某一种算法的具体实现 它们之间的关系可以用一句话概括: - • `Context` 决定"什么时候调用算法" - • `Strategy` 规定"算法必须长什么样" - • `ConcreteStrategy` 决定"算法到底怎么做" 如果用生活类比来理解: - • `Context` 像**导航 App** - • `Strategy` 像"路线规划规则"这个统一接口 - • `ConcreteStrategy` 就是"最快路线""最短路线""避开收费"这些具体规则 导航界面没有变,用户的操作方式也没有变,变化的是背后的选择逻辑。这正是策略模式最核心的价值:**对外保持稳定,对内允许变化。** ### 基本实现(经典版本) 先看最经典的面向对象实现。这个版本的重点不是语法有多新,而是把角色边界理清楚。 ``` #include #include #include class CompressionStrategy { public: virtual ~CompressionStrategy() = default; virtual std::string name() const = 0; virtual void compress(const std::string& file) const = 0; }; class ZipStrategy : public CompressionStrategy { public: std::string name() const override { return "ZIP"; } void compress(const std::string& file) const override { std::cout << "[ZIP] 压缩文件: " << file << std::endl; std::cout << " - 压缩率: 中等" << std::endl; std::cout << " - 速度: 中等" << std::endl; std::cout << " - 兼容性: 最好" << std::endl; } }; class GzipStrategy : public CompressionStrategy { public: std::string name() const override { return "GZIP"; } void compress(const std::string& file) const override { std::cout << "[GZIP] 压缩文件: " << file << std::endl; std::cout << " - 压缩率: 较高" << std::endl; std::cout << " - 速度: 中等偏慢" << std::endl; std::cout << " - 传输场景友好" << std::endl; } }; class Lz4Strategy : public CompressionStrategy { public: std::string name() const override { return "LZ4"; } void compress(const std::string& file) const override { std::cout << "[LZ4] 压缩文件: " << file << std::endl; std::cout << " - 压缩率: 一般" << std::endl; std::cout << " - 速度: 很快" << std::endl; std::cout << " - 适合实时归档" << std::endl; } }; class ArchiveContext { public: explicit ArchiveContext(std::unique_ptr strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptr strategy) { strategy_ = std::move(strategy); } void backupFile(const std::string& file) const { std::cout << "\n开始备份: " << file << std::endl; std::cout << "当前策略: " << strategy_->name() << std::endl; strategy_->compress(file); std::cout << "备份完成\n" << std::endl; } private: std::unique_ptr strategy_; }; int main() { ArchiveContext context(std::make_unique()); context.backupFile("report.csv"); context.setStrategy(std::make_unique()); context.backupFile("metrics.json"); context.setStrategy(std::make_unique()); context.backupFile("realtime.log"); return 0; } ``` 这个实现已经具备了策略模式最重要的两个特点: - • **算法被独立封装** - • **算法可以在运行时替换** 这意味着以后你要加 `ZstdStrategy`、`BrotliStrategy` 时,不需要修改 `ArchiveContext` 的备份流程,只需要新增具体策略类即可。 ### 经典版本的问题 不过,教科书式实现也不是没有代价。 **代价 1:策略类数量可能很多** 如果每一种小变化都做成一个类,项目里会出现大量"只有几十行代码"的策略类。对于复杂场景这很合理,但对于简单规则可能显得偏重。 **代价 2:对象管理成本增加** 经典实现通常配合基类指针和动态分发使用,这意味着你要考虑: - • 生命周期由谁管理 - • 是否允许空策略 - • 是否需要共享同一个策略实例 **代价 3:某些简单策略其实更适合函数对象** 像"升序排序""打 9 折""按名字比较"这类很轻量的算法,专门写一个派生类有时会显得啰嗦。现代 C++ 往往会用 `std::function`、lambda、函数对象来让策略变得更轻量。 所以接下来我们看一个更贴近现代 C++ 风格的版本。 ------ ## 关键技术专题:运行时策略选择与策略注册 策略模式最常见的落地点,不是写出若干派生类本身,而是解决这两个实际问题: 1. 1. **策略从哪里来** 2. 2. **运行时如何选择** ### 方式一:直接注入策略对象 这是最简单也最清晰的方式: ``` ArchiveContext context(std::make_unique()); ``` 优点是依赖关系明确,编译期就能看到用的是哪一种策略;缺点是如果策略来自配置文件、数据库或者用户输入,你还需要额外写一层选择逻辑。 ### 方式二:通过注册表按名字选择策略 真实项目中,更常见的是配置驱动: ``` // config.json { "compression": "zstd" } ``` 这时可以用一个简单的注册表把"配置值"映射到"策略创建函数": ``` #include #include #include #include class StrategyRegistry { public: using Creator = std::function()>; void registerStrategy(std::string name, Creator creator) { creators_[std::move(name)] = std::move(creator); } std::unique_ptr create(const std::string& name) const { auto it = creators_.find(name); if (it == creators_.end()) { return nullptr; } return it->second(); } private: std::unordered_map creators_; }; ``` 这样做的意义非常大: - • `Context` 不再关心每种策略的构造细节 - • 策略选择可以由配置、环境变量、用户等级、A/B 实验结果来驱动 - • 后续新增策略时,只需要注册,不必改主流程 ### 方式三:编译期选择 vs 运行时选择 在 C++ 里,策略还有一个很有意思的延伸:**有些策略是在运行时选,有些策略是在编译期定。** | 维度 | 运行时策略模式 | 编译期策略 / Policy | | -------- | ---------------------------- | ---------------------------- | | 切换时机 | 程序运行时 | 模板实例化时 | | 典型实现 | 虚函数、`std::function` | 模板参数、CRTP、Policy Class | | 灵活性 | 高 | 中 | | 性能 | 有一定间接调用开销 | 通常更容易内联 | | 场景 | 配置驱动、用户选择、插件扩展 | 性能敏感、算法固定 | 如果算法需要被**用户、配置、数据**驱动,优先考虑运行时策略模式。 如果算法在编译期就确定,而且非常追求零开销抽象,那更接近后面会讲到的 `Policy-Based Design`。 换句话说:**策略模式解决的是“可替换”,而不一定追求“零开销”。** ------ ## 现代 C++ 实现 ### 基于 `std::function` 的轻量策略 当策略本身足够简单时,用继承体系未必是最舒服的写法。现代 C++ 更常见的方式,是把策略抽象成一个可调用对象。 下面用"订单折扣"的例子演示: ``` #include #include #include class CheckoutContext { public: using DiscountStrategy = std::function; explicit CheckoutContext(DiscountStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(DiscountStrategy strategy) { strategy_ = std::move(strategy); } double checkout(double originalPrice) const { returnstrategy_(originalPrice); } private: DiscountStrategy strategy_; }; int main() { CheckoutContext checkout([](double price) { return price; // 原价 }); std::cout << std::fixed << std::setprecision(2); std::cout << "原价用户: " << checkout.checkout(1000.0) << std::endl; checkout.setStrategy([](double price) { return price * 0.90; // 会员九折 }); std::cout << "会员用户: " << checkout.checkout(1000.0) << std::endl; checkout.setStrategy([](double price) { return price > 500.0 ? price - 120.0 : price; }); std::cout << "满减活动: " << checkout.checkout(1000.0) << std::endl; return0; } ``` 这个版本和经典继承版本相比,有几个很明显的优势: | 特性 | 经典继承版本 | `std::function` 版本 | | -------- | ------------------------------------ | -------------------------------- | | 表达形式 | 基类 + 派生类 | lambda / 函数对象 / 普通函数 | | 适合场景 | 策略复杂、状态较多、需要统一层次结构 | 策略轻量、变化频繁、需要快速组合 | | 扩展方式 | 新增类 | 新增可调用对象 | | 可读性 | 结构清晰 | 写法紧凑 | | 额外成本 | 类层次较多 | 类型擦除有一定开销 | ### 现代版本的边界 不过 `std::function` 也不是银弹。 - • 如果策略需要暴露很多额外接口,纯函数就不够用了 - • 如果策略内部有大量状态和复杂初始化,类对象会更合适 - • 如果你在极度性能敏感的热路径里频繁调用,模板/内联可能更优 所以经验上可以这样选: - • **简单规则**:优先 `std::function` / lambda - • **复杂算法对象**:优先经典策略类 - • **极致性能且编译期固定**:考虑模板策略或 Policy ------ ## 策略模式与设计原则 ### 开闭原则(Open-Closed Principle) 策略模式最典型地体现了开闭原则。 增加一种新算法时,通常只需要: 1. 1. 新增一个策略类,或者新增一个 lambda 2. 2. 在注册表中注册 3. 3. 在配置中选择 `Context` 本身不需要被修改。也就是说,**对扩展开放,对修改关闭**。 ### 单一职责原则(Single Responsibility Principle) 引入策略之后,职责边界会更清楚: - • `Context` 负责业务流程编排 - • `Strategy` 负责算法实现 如果一个类既负责"做备份",又负责"实现所有压缩算法",那它显然承担了太多职责。策略模式把这两部分自然拆开了。 ### 依赖倒置原则(Dependency Inversion Principle) `Context` 不依赖具体算法,而依赖抽象接口: ``` class ArchiveContext { private: std::unique_ptr strategy_; }; ``` 这里依赖的是 `CompressionStrategy`,而不是 `ZipStrategy` 或 `Lz4Strategy`。 这让高层模块不再被底层实现细节绑死,替换算法时也更从容。 ------ ### 与状态模式的区别 这两个模式的结构很像,都是"上下文 + 一组可替换对象"。 真正的区别在于**意图**: - • **策略模式**:客户端主动选择一种算法 - • **状态模式**:对象根据内部状态自动切换行为 比如: - • 用户选择"按分数排序/按胜率排序"是策略模式 - • 订单从"待支付 -> 已支付 -> 已发货"导致行为变化是状态模式 一句话记忆: > **策略是“我选怎么做”,状态是“我现在是什么,所以只能这么做”。** ### 与模板方法模式的区别 模板方法模式是把稳定流程放在基类里,再把可变步骤交给子类重写。 策略模式则是把整个可变算法当成对象注入进去。 可以这样理解: ``` 模板方法:通过继承改步骤 策略模式:通过组合换算法 ``` 在现代 C++ 中,如果你更强调**组合优于继承**,策略模式往往更灵活。 ------ ## 常见陷阱与最佳实践 ### 陷阱 1:用 `enum + switch` 冒充策略模式 很多代码会写成这样: ``` enum class SortMode { Score, Level, WinRate }; void sortPlayers(std::vector& data, SortMode mode) { switch (mode) { case SortMode::Score: // ... break; case SortMode::Level: // ... break; case SortMode::WinRate: // ... break; } } ``` 这不是不能用,但它的扩展方式依然是"回到老函数里继续加分支"。 如果变化点越来越多,这种写法很快就会重新走回老路。 **解决**:当算法数量持续增长,且需要独立演进时,优先改造成真正的策略对象或可调用策略。 ### 陷阱 2:策略对象偷偷依赖外部可变状态 比如某个排序策略依赖一个全局变量: ``` int g_priorityLevel = 3; ``` 这会让策略的行为变得不可预测,测试也会变难。 策略最好是**显式接收依赖**,而不是偷偷读取全局状态。 **解决**:通过构造函数、闭包捕获或者上下文参数把依赖传进去。 ### 陷阱 3:把非常琐碎的逻辑也硬拆成策略 不是每个 `if` 都值得上策略模式。 如果只有两种固定逻辑,而且未来几乎不会扩展,直接分支反而更简单。 策略模式的价值在于**变化频繁、替换明确、扩展持续发生**。 **解决**:先看变化趋势,再决定是否抽象。 ### 陷阱 4:没有提供默认策略或空策略保护 如果上下文允许: ``` strategy_ = nullptr; ``` 那调用时就很容易空指针崩溃。 **解决**:至少选一种: - • 构造函数强制传入有效策略 - • 提供默认策略 - • 在调用前做明确校验并抛出异常 ### 最佳实践总结 | 实践 | 说明 | | ------------------------ | -------------------------------- | | 先识别稳定流程和变化点 | 不是所有条件分支都值得抽象 | | 简单策略优先用 lambda | 写法更轻量,维护成本更低 | | 复杂策略再上类层次 | 有状态、有依赖、有多方法时更合适 | | 配置驱动配合注册表 | 让策略选择从代码迁移到配置 | | 为上下文提供默认策略 | 避免空策略导致运行时错误 | | 避免策略读写隐藏全局状态 | 提高可测试性和可预测性 | ------ ## 何时使用策略模式 ### 适用场景 - • 一个业务流程中存在多种可替换算法,而且未来还会继续增加 - • 你想消除大量 `if/else` 或 `switch` 带来的分支膨胀 - • 具体算法需要根据配置、环境、用户类型、运行时数据来选择 - • 你希望把算法单独测试,而不是混在大类里一起测 - • 你希望遵循开闭原则,让新增算法尽量不改旧代码 ------ ## 写在最后 策略模式在 C++ 里之所以特别实用,是因为它恰好踩中了工程开发中一个非常常见的矛盾:**业务流程想保持稳定,但算法细节总在变。** 从排序、过滤、压缩,到折扣、路由、校验,很多看起来风马牛不相及的需求,本质上都是同一个问题:**同一件事有多种做法,而且这些做法需要能平滑替换。** 在现代 C++ 里,策略模式也不再局限于"基类 + 派生类"这一种写法。 你可以用经典虚函数体系来表达复杂策略,也可以用 `std::function`、lambda、函数对象来实现轻量策略。形式可以变,但核心思想始终没变:**把变化的算法从稳定的上下文中拆出来。** 实现演进路线: ``` if/else 分支堆叠 → 虚函数策略类 → std::function/lambda → 注册表/配置驱动 → 编译期 Policy ↓ ↓ ↓ ↓ ↓ 简单但易膨胀 结构清晰 写法轻量灵活 扩展性更强 极致性能 ``` 记住这 3 句话: > **策略模式的重点不是“面向对象得很漂亮”,而是把变化点单独隔离。** > **当你发现一个类里塞满了可替换算法时,往往就是策略模式登场的时候。** > **简单规则优先用轻量策略,复杂规则再上完整类层次。** 从系列位置来看,**观察者模式**,解决的是"对象之间如何通知";而**策略模式**,解决的是"同一个动作如何切换不同算法"。它标志着行为型模式正式进入"把行为本身抽象出来"这一阶段。