☰
C++策略模式五种变体:从虚函数到模板的选型指南
2026/9/30 17:49:46 网站建设 项目流程

我之前在某家公司的支付模块里接手过一段历史代码,那里面全是if (payType == 1) ... else if (payType == 2) ...,每个分支还带两三层嵌套,新接入一个渠道基本要把整个函数通读一遍,改完还得担心把别的渠道带崩。后来我花了两天把它重构成策略模式,代码量直接砍掉一半。但从那之后我也开始意识到,教科书里那种经典的策略模式写法,在 C++ 里并不是唯一答案,甚至未必是最优答案。

今天这篇就专门聊 C++ 里的策略模式变体。我已经默认你是会写 C++、但还没彻底吃透设计模式的开发者,或者你正准备面试,看到"策略模式变体"这类题目想搞清楚它到底在问什么。我准备了五个方向:经典虚函数策略、std::function函数式策略、模板策略、策略组合与策略工厂、最后是一套我自己的选型经验和踩坑记录。每一种我都会给代码、给理由、给适用边界。

1. 先从最熟悉的写法开始:虚函数策略与它的三个隐患

1.1 教科书版策略模式长什么样

经典策略模式的定义很朴素:定义一族算法,分别封装起来,让它们之间可以互相替换。在《设计模式》那本书里,策略模式的类图是 Context 持有一个 Strategy 接口指针,ConcreteStrategyA、ConcreteStrategyB 实现这个接口。

C++ 里最常见的实现长这样:

class IPayStrategy { public: virtual ~IPayStrategy() = default; virtual void pay(double amount) = 0; }; class CreditCardPay : public IPayStrategy { public: void pay(double amount) override { std::cout << "[CreditCard] pay " << amount << std::endl; } }; class AlipayPay : public IPayStrategy { public: void pay(double amount) override { std::cout << "[Alipay] pay " << amount << std::endl; } }; class PayContext { public: explicit PayContext(std::unique_ptr<IPayStrategy> strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptr<IPayStrategy> strategy) { strategy_ = std::move(strategy); } void doPay(double amount) { strategy_->pay(amount); } private: std::unique_ptr<IPayStrategy> strategy_; };

这段代码本身没毛病,我当年重构支付时也是这么写的。但用着用着,问题就一个个浮出来了。

1.2 第一个隐患:侵入性的接口继承

C++ 的类继承本身就带着耦合和侵入性。

CreditCardPay只要想当"策略",就必须继承IPayStrategy,哪怕它内部还有其他祖宗类。这时候就会出现所谓"被迫继承"的尴尬:如果一个策略类本身已经继承了某个业务基类,或者它想复用某个公共工具类,而在 C++ 里又是一个类只能有一个直接基类(不考虑多余继承的情况),你往往就得动继承结构。

这还不是最难受的。最难受的是,虚函数接口是"行为签名"层面的约定,它没法表达"默认实现"和"组合式复用"。

比如你有一堆策略都涉及"计算折扣",你可以在每个策略里重复写折扣逻辑,也可以搞一个公共基类BasePayStrategy把折扣算好,让子类继承。但你一旦引入这个公共基类,接口就变得不纯,子类们继承的不再只是一个"策略签名",而是一大堆可能用不上的默认行为。等哪天某个子类只想实现pay而不想要折扣时,你才意识到继承树已经长歪了。

在我那个支付项目里,最开始我设计了一个BasePayStrategy,里面放了一些日志、验签、回调处理的公共方法。后来新渠道接入时,有同事直接继承BasePayStrategy,只为复用其中一个日志方法,其他东西全部空实现。我当时看着NotSupported()成片出现,就知道设计已经变味了。

1.3 第二个隐患:组合爆炸与策略状态

经典策略模式在用继承表达"策略族"时,一旦算法受多个维度影响,继承组合的方式就会遭殃。

举个例子:假设你的排序策略有两个维度——按什么字段排(价格、销量、评价数)、按什么方向排(升序、降序)。如果都用继承来做,你要写PriceAscSort、PriceDescSort、SalesAscSort、SalesDescSort、RatingAscSort、RatingDescSort……六个类。如果再加一个维度(比如排序稳定性),直接就组合爆炸了。策略模式本身不是用来解决这种多维度问题的,但经典写法会让这种问题看起来"只能用继承解决",从而把你带进坑里。

还有一个问题:策略对象该不该持状态?

按经典定义,策略一般应该是无状态的,或者状态极简。但现实中策略往往需要配置参数、需要上下文数据、需要临时缓存。比如同一个支付策略实例在多线程环境下被多个 Context 共享,那策略内部就不能有非线程安全的状态。如果你给每个 Context 都 new 一个策略对象,那内存和构造开销又上来了。

这些问题不是"策略模式错了",而是"经典策略模式在 C++ 里的表达能力有限"。所以后面那些变体,本质上都是在补这两个短板:降低侵入性、让策略状态和组合更灵活。

2. std::function 变体:把策略从"对象"还原成"行为"

2.1 无继承的策略定义

C++11 之后有了std::function和 lambda,我才彻底开了窍。策略本质上是一段行为,不一定非得以"对象"为容器。用std::function直接把"行为"存下来,一切都简单了:

class PayService { public: using PayStrategy = std::function<void(double)>; void setStrategy(PayStrategy strategy) { strategy_ = std::move(strategy); } void doPay(double amount) { if (strategy_) { strategy_(amount); } else { throw std::runtime_error("no pay strategy"); } } private: PayStrategy strategy_; }; // 使用侧 PayService service; service.setStrategy([](double amount) { std::cout << "[Alipay] pay " << amount << std::endl; });

是不是清爽很多?没有IPayStrategy接口,没有继承,没有unique_ptr。策略就是一个可调用对象,谁想传谁就传。这就是策略模式第一种重要变体:从"接口继承"变成"函数对象注入"。

这种写法里,std::function就像是一个通用插座,任何满足签名的函数、lambda、函数对象都能插上去。传统虚函数接口要求你"成为这个接口的子类",而std::function只要求你"长得像这个接口"——这叫作鸭子类型。

2.2 策略当一等公民的好处

std::function策略是值语义,可以随便拷贝、随便存容器、随便作为参数传来传去。我直接把它塞进std::vector做组合也毫无压力:

using PayStrategy = std::function<void(double)>; std::vector<PayStrategy> strategies; strategies.emplace_back([](double amount) { std::cout << "[CreditCard] pay " << amount << std::endl; }); strategies.emplace_back([](double amount) { std::cout << "[Alipay] pay " << amount << std::endl; });

对"策略集合"这种需求,std::function变体是天然适配的。我在做多通道付款时的策略链,就是先把所有可用通道策略放进数组,然后逐个尝试,谁成功就停。

而且 lambda 捕获能力让策略可以携带自己的上下文,又不会搞出一堆返回 void 的类:

int discountRate = 20; service.setStrategy([discountRate](double amount) { double finalAmount = amount * (1.0 - discountRate / 100.0); std::cout << "[DiscountPay] pay " << finalAmount << std::endl; });

discountRate不需要存成员,不需要构造函数传参,lambda 捕获一步到位。这在经典策略里至少要写一个带构造函数的类,还得多写几行样板代码。

2.3 这种变体的弱点你也得知道

std::function变体不是银弹,否则我也不会继续研究模板变体了。

第一个痛点是性能。std::function内部要做类型擦除,调用时往往有间接跳转,某些实现还可能在构造时发生堆分配。虽然现代编译器优化得越来越好,但在热路径(比如每帧要调几千上万次的地方)上还是有影响的。做游戏客户端的同事应该深有体会。

第二个痛点是"策略带复杂逻辑时,lambda 会变得很丑"。策略只有一行表达式时 lambda 很爽;但策略逻辑有个十行八行,还分阶段,你就不得不在 lambda 里写一大段,可读性并不好。这时候不如老老实实定义一个函数对象类。

第三个痛点是调试体验。类型擦除后,你在调试器里看std::function对象内部是什么,通常只能看到一堆_Func_class、_Ptr之类的内部构造,没点耐性真看不出来执行的是哪个策略。而虚函数多态在调试器里至少还能看到具体的派生类型名称。

所以我的用法是:策略轻量、数量多、组合需求强时优先std::function;策略重量级、需要维护大量内部状态、或者明确要作为稳定的公共接口给团队其他人实现时,回到经典虚函数写法。两者并不冲突。

3. 模板策略变体:把选择从运行期搬进编译期

3.1 用模板参数表达策略

C++ 是最能体现策略模式"变体"的语言,因为模板天然提供了一套编译期策略注入机制。策略不再是运行时的某个对象,而是编译期确定的类型。这种变体也叫 policy-based design,现代 C++ 标准库大量使用了这个思想,比如std::allocator就是容器的策略参数之一,std::char_traits也是字符串操作的策略。

模板版策略长这样:

template <typename SortStrategy> class DataProcessor { public: void process(std::vector<int>& data) { SortStrategy sorter; sorter.sort(data); // 后续处理... } }; class BubbleSort { public: void sort(std::vector<int>& data) { // 冒泡排序实现 for (size_t i = 0; i < data.size(); ++i) { for (size_t j = 0; j < data.size() - i - 1; ++j) { if (data[j] > data[j + 1]) { std::swap(data[j], data[j + 1]); } } } } }; class QuickSort { public: void sort(std::vector<int>& data) { // 快速排序实现 std::sort(data.begin(), data.end()); } }; // 使用 DataProcessor<BubbleSort> bubbleProcessor; DataProcessor<QuickSort> quickProcessor; bubbleProcessor.process(data);

这里策略不是注入进来的对象,而是编译期确定的类型参数。DataProcessor<BubbleSort>和DataProcessor<QuickSort>虽然是同一个模板实例化出来的,但在编译后是完全不同的两个类。

3.2 静态多态与动态多态的取舍

模板策略是静态多态,经典策略是动态多态。二者差别我列个表:

维度虚函数策略(动态多态)std::function 策略模板策略(静态多态)
策略选择时机运行期运行期编译期
是否需要虚表/间接调用是是(类型擦除)否,可内联
策略是否可随时切换可可不可,类型已定死
代码体积相对小相对小每个策略组合产生独立实例化
接口侵入性需继承接口无侵入,鸭子类型无继承,但要满足模板接口
调试难度低中中

模板策略最容易踩的坑是"代码膨胀"。DataProcessor<BubbleSort>和DataProcessor<QuickSort>各自产生一份process机器码。如果process函数体很大,每实例化一个策略就复制一份大代码,会让二进制体积上涨。

但这个坑有时候是伪问题。现代编译器对模板内联很积极,而且你真的需要每个策略独立优化时,代码复制反而是好事,因为编译器可以针对具体策略做条件分支消除、内联,甚至把整个sort调用直接优化掉。这就是"零开销抽象"的含义。

另一个坑是模板策略难以在运行期动态变化。如果你的策略选择依赖用户输入,比如用户在界面里选了"支付通道A",运行期才知道用哪个策略,模板策略就无能为力。你不能把一个DataProcessor<BubbleSort>在运行期变成DataProcessor<QuickSort>。所以模板策略适合"策略在编译期就确定"的场景,而虚函数策略和std::function策略适合"策略在运行期才能确定"的场景。

3.3 模板策略与 trait 的结合

真正把模板策略变体做到极致的是策略 traits。你去读标准库std::map的模板参数列表就能感受到:

template < class Key, class T, class Compare = std::less<Key>, class Allocator = std::allocator<std::pair<const Key, T>> > class map;

Compare就是排序策略,Allocator就是内存分配策略。两个策略叠加在同一个类上,互不干涉。这就是组合爆炸的模板解法——用多个模板参数表示多个维度,而不是用多个继承层级来组合。

我在自己项目里也这么干过:写了一个网络协议解析框架,用两个策略参数分别表示字节序(大端/小端)和消息边界(长度前缀/定界符)。用四个组合实例化出不同处理器,代码完全复用,没有继承树,也没有运行期判断:

template <typename ByteOrderStrategy, typename FramingStrategy> class ProtocolHandler { public: std::vector<uint8_t> decode(const std::vector<uint8_t>& raw) { ByteOrderStrategy bo; FramingStrategy fs; return fs.extract(bo.convert(raw)); } }; class LittleEndian { public: std::vector<uint8_t> convert(const std::vector<uint8_t>& raw) { /* ... */ } }; class BigEndian { public: std::vector<uint8_t> convert(const std::vector<uint8_t>& raw) { /* ... */ } }; class LengthPrefixed { public: std::vector<uint8_t> extract(const std::vector<uint8_t>& raw) { /* ... */ } }; class NullTerminated { public: std::vector<uint8_t> extract(const std::vector<uint8_t>& raw) { /* ... */ } }; using LittleEndianLengthHandler = ProtocolHandler<LittleEndian, LengthPrefixed>; using BigEndianLengthHandler = ProtocolHandler<BigEndian, LengthPrefixed>;

这类写法的妙处在于,策略类不必继承任何公共接口,只要实现了模板要求的成员函数即可。你甚至可以传入一个外部库的类,只要它有对应名字的成员函数。这就是"鸭子类型"在编译期的体现,C++ 管这个叫结构类型约束,不需要基类干预。

不过模板策略也不总是第一选择。它最大的隐含成本其实是编译期封装。模板方法必须写在头文件里,否则无法实例化,这会拖慢编译速度。团队项目里,头文件膨胀导致每次改动都要触发大面积重编译,是很多公司不愿意大规模用模板策略的真实原因。

4. 策略组合、策略工厂与上下文重构

4.1 把多个策略组合成一个"大策略"

设计模式书里没怎么强调策略的组合,但真实项目里"组合策略"特别常见。比如风控场景:一个支付请求要过黑名单检查、限额检查、频次检查、设备指纹检查。每个检查都是一个策略,最终通过的判定是"所有检查都通过"。

我用std::function策略加vector一口气就能做组合:

class RiskControlService { public: using CheckStrategy = std::function<bool(const OrderContext&)>; void addCheck(CheckStrategy check) { checks_.push_back(std::move(check)); } bool checkAll(const OrderContext& ctx) { for (const auto& check : checks_) { if (!check(ctx)) { return false; } } return true; } private: std::vector<CheckStrategy> checks_; }; // 使用 RiskControlService riskCtl; riskCtl.addCheck([](const OrderContext& ctx) { return ctx.blacklist.find(ctx.userId) == ctx.blacklist.end(); }); riskCtl.addCheck([](const OrderContext& ctx) { return ctx.amount <= ctx.dayLimit; });

这本质上就是责任链模式的简化版,但以策略集合的形式出现。每个addCheck都是在往策略容器里追加一个策略,而且运行期可以动态增删,非常灵活。

如果你用经典虚函数策略来写这个"策略组合",你得额外定义一个CompositeCheck类,内部持有一组策略指针,实现check时逐个调用。当然也能写,但多一层类结构。

所以我的经验是:当策略之间需要组合时,std::function加容器的方式是最自然、最少代码的。别拿模板策略来做这种动态组合,因为模板策略在编译期就定死了,不支持运行期动态添加。

4.2 策略工厂:别让调用方直接 new

很多策略模式的教程里,调用方都是直接 new 一个策略对象塞给上下文。这在 demo 里没问题,但真实项目里你很快会发现,"策略的创建逻辑"和"策略的使用逻辑"不该混在一起。支付通道这种策略往往带配置参数,比如收单机构的商户号、密钥、回调地址,这些参数不是每个调用方都有资格提供的。

这时候就得引入策略工厂,或者更简单的策略注册表。我喜欢用unordered_map配合std::function做注册表:

class PayStrategyFactory { public: using Creator = std::function<std::unique_ptr<IPayStrategy>()>; static void registerStrategy(const std::string& name, Creator creator) { registry()[name] = std::move(creator); } static std::unique_ptr<IPayStrategy> create(const std::string& name) { auto it = registry().find(name); if (it == registry().end()) { return nullptr; } return it->second(); } private: static std::map<std::string, Creator>& registry() { static std::map<std::string, Creator> instance; return instance; } };

这个工厂本身没什么复杂的,它的价值在于"注册机制"。

你可以在每个策略类的源文件底部写一个静态注册代码:

static const bool alipayRegistered = []() { PayStrategyFactory::registerStrategy("alipay", []() { return std::make_unique<AlipayPay>(); }); return true; }();

这样一来,新增支付渠道时不需要改支付中心主流程代码。你只要把新的策略类和那两三行注册代码加进去,编译链接时它就被自动注册了。这就是 C++ 里"开局自带注册"的惯用法。它的缺点也很明显:静态初始化顺序不受保证,注册表如果被其他静态对象依赖,可能出现空引用问题。但如果你只在运行时调用create,不搞静态初始化期间创建策略,就不会有这个问题。

这种注册式工厂的真正意义是,把策略模式的最后一块拼图补上。上下文不用知道具体策略类名,工厂也不用维护一长串 if-else,每个策略自己负责登记。新策略接入的成本降到了最低,删除策略时只要删掉对应文件,注册代码也一并消失。

4.3 高频面试题:策略模式与状态模式有什么区别

这个标题下面经常会被连带问到策略模式和状态模式的差别。我每次面试都会碰见候选人把这两者搞混,因为 UML 类图结构太像了——都是一个接口有多个实现,都通过组合调用。但它们在语义上有本质区别。

策略模式解决的是"同一个行为有多种算法实现",比如排序算法、支付方式,这些算法之间是并列关系,可以互相替代。上下文在构造函数或 setter 里主动选择一个策略。状态模式解决的是"同一个对象在不同状态下行为不同",状态是被动切换的,往往由上下文内部的某些条件触发,状态之间还可能转移。

我用一个非常直白的类比:策略模式是"你想付钱时,选支付宝还是微信还是信用卡";状态模式是"你买了个订单,现在是待支付、已支付、已发货还是已签收,同一个操作(比如取消)在不同状态下行为完全不同"。

看代码也能区分。策略模式下,上下文主动设置策略:

payService.setStrategy(alipayStrategy); payService.doPay(100);

状态模式下,往往状态类自己推动流转。比如订单状态类里有一个cancel()方法,待支付状态下执行"取消并释放库存",已发货状态下执行"申请退款流程",然后状态都要转移:

class OrderState { public: virtual void cancel(OrderContext& ctx) = 0; virtual void next(OrderContext& ctx) = 0; }; class PendingState : public OrderState { public: void cancel(OrderContext& ctx) override { // 待支付取消:释放库存 ctx.refundStock(); ctx.setState(std::make_unique<CancelledState>()); } void next(OrderContext& ctx) override { // 去支付 ctx.setState(std::make_unique<PaidState>()); } };

相同点是它们都利用多态来消除分支判断;不同点在于控制权在哪里。策略模式的控制权在上下文手中,策略是被动替换的。状态模式的控制权在状态对象手中,状态转移是主动推进的。

这个区别如果你在写实际代码时没体会,可以硬记一句话:策略是"我可以选哪个",状态是"我原本是哪个、接下来要变哪个"。

5. 真实项目里怎么选:我的建议与踩坑

5.1 一套我自己总结的选型规则

前前后后用过三种变体,后来我给自己定了一套选择规则,写在这供你参考。

第一,看策略数量是否有限且固定。数量很少,只有两三种,直接用 if-else 都可能比策略模式更简单,别为了用模式而用模式。策略数量在增长,而且都是同一套签名,才考虑策略模式。

第二,看策略切换是否发生在运行期。运行期要动态切换策略,用虚函数或者std::function。编译期就能确定,用模板策略最省事。

第三,看策略的实现体量。每个策略都是几行代码的小函数,用std::function。每个策略有自己的配置、状态、辅助函数,用虚函数基类加类实现。

第四,看性能要求。热路径上,模板策略优先;虚函数策略勉强;std::function有间接调用成本,但通常也可接受,除非你测得确实有瓶颈。

第五,看团队水平。模板策略是最需要纪律的变体,因为策略类没有基类约束,完全是"约定大于配置"。团队里如果有人写一个策略类但方法名不匹配,编译错误的信息量很有限。小白多的团队,虚函数接口反而更能约束行为。

按这个规则,当年那个支付项目的重构方案是这样的:支付策略用虚函数经典写法,因为不同支付渠道差异很大,每个策略类都有一堆配置和回调逻辑;渠道组合策略用std::function,因为要动态增删尝试顺序;签名算法这种编译期固定、调用频繁的策略用模板,因为签名算法不会运行时变,而且必须在热路径上被反复调用。

5.2 这几个坑,我踩过之后才明白

先说策略生命周期的坑。经典策略模式里上下文持有策略指针,如果你把同一个策略对象给多个上下文共享,策略内部就不能持有"本次调用"特有状态。比如支付策略里存了一个currentTransactionId_成员,两个线程并发调用就会串台。解决思路有两个:要么策略做成无状态,所有可变数据通过调用参数传入;要么上下文每次创建策略副本,用原型模式。

再说策略接口的稳定性。我在重构时犯过一个错:策略接口里一开始放了pay(double amount),后来发现有的策略需要知道用户 ID、订单号、设备 ID,于是不断改接口签名,把pay(double amount)改成pay(double amount, int userId),再改成pay(const OrderInfo& order),每次改动都牵动所有策略实现。正确做法是一开始就把策略方法的参数定为一个完整的上下文字段,把后续一切扩展装进去:

struct PayContext { double amount; std::string orderId; long userId; std::string deviceId; std::map<std::string, std::string> ext; }; class IPayStrategy { public: virtual void pay(const PayContext& ctx) = 0; };

这样以后再扩展参数,只改PayContext结构体,不用改所有策略实现。如果一开始就意识到会有这类扩展,能少折腾半天。

最后说测试的事。策略模式最容易被低估的价值是它极大提升了可测试性。策略被拆成独立单元后,每个策略都能单独测试。我重构支付模块后,每个支付渠道的策略类都配了一个独立测试文件,用 mock 的通道客户端验证各种响应。以前藏在几百行 if-else 里的分支逻辑根本没法定向测试。

当你开始测试策略模式时,你才会真正体会到"小步快跑、随时替换"的妙处。比如临时加一个限时折扣策略,不需要改任何生产代码,只在服务启动时注册一下,测试环境验证完,改一行注册代码就能下线。

我在实际项目中体会最深的一点是:设计模式在 C++ 里从来不是"标准答案",而是"思想工具箱"。策略模式的精髓是"把行为抽象为可替换的单元",至于这个单元是虚函数接口、std::function、还是模板参数,完全取决于你的具体场景。

最后分享一个我常用的收尾技巧:如果你新接手一个系统,想快速判断哪里该用策略模式,就全局搜索if和else if,凡是出现了三个以上并列分支、而且每个分支做的事结构相似,那这块就是策略模式的候选地。不要一上来就动手,先把分支逻辑里会变的部分提炼成签名,再决定用哪种变体。我靠这个方法在几个项目里都快速锁定了最值得重构的代码块。希望这篇对你有用,如果你在实际项目里也做出了好玩的策略模式变体,欢迎回来聊。

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

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

立即咨询