ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++策略模式五种变体:从虚函数到模板的选型指南

C++策略模式五种变体:从虚函数到模板的选型指南 我之前在某家公司的支付模块里接手过一段历史代码那里面全是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_ptrIPayStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptrIPayStrategy strategy) { strategy_ std::move(strategy); } void doPay(double amount) { strategy_-pay(amount); } private: std::unique_ptrIPayStrategy 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 无继承的策略定义C11 之后有了std::function和 lambda我才彻底开了窍。策略本质上是一段行为不一定非得以对象为容器。用std::function直接把行为存下来一切都简单了class PayService { public: using PayStrategy std::functionvoid(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::functionvoid(double); std::vectorPayStrategy 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::vectorint data) { SortStrategy sorter; sorter.sort(data); // 后续处理... } }; class BubbleSort { public: void sort(std::vectorint 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::vectorint data) { // 快速排序实现 std::sort(data.begin(), data.end()); } }; // 使用 DataProcessorBubbleSort bubbleProcessor; DataProcessorQuickSort quickProcessor; bubbleProcessor.process(data);这里策略不是注入进来的对象而是编译期确定的类型参数。DataProcessorBubbleSort和DataProcessorQuickSort虽然是同一个模板实例化出来的但在编译后是完全不同的两个类。3.2 静态多态与动态多态的取舍模板策略是静态多态经典策略是动态多态。二者差别我列个表维度虚函数策略动态多态std::function 策略模板策略静态多态策略选择时机运行期运行期编译期是否需要虚表/间接调用是是类型擦除否可内联策略是否可随时切换可可不可类型已定死代码体积相对小相对小每个策略组合产生独立实例化接口侵入性需继承接口无侵入鸭子类型无继承但要满足模板接口调试难度低中中模板策略最容易踩的坑是代码膨胀。DataProcessorBubbleSort和DataProcessorQuickSort各自产生一份process机器码。如果process函数体很大每实例化一个策略就复制一份大代码会让二进制体积上涨。但这个坑有时候是伪问题。现代编译器对模板内联很积极而且你真的需要每个策略独立优化时代码复制反而是好事因为编译器可以针对具体策略做条件分支消除、内联甚至把整个sort调用直接优化掉。这就是零开销抽象的含义。另一个坑是模板策略难以在运行期动态变化。如果你的策略选择依赖用户输入比如用户在界面里选了支付通道A运行期才知道用哪个策略模板策略就无能为力。你不能把一个DataProcessorBubbleSort在运行期变成DataProcessorQuickSort。所以模板策略适合策略在编译期就确定的场景而虚函数策略和std::function策略适合策略在运行期才能确定的场景。3.3 模板策略与 trait 的结合真正把模板策略变体做到极致的是策略 traits。你去读标准库std::map的模板参数列表就能感受到template class Key, class T, class Compare std::lessKey, class Allocator std::allocatorstd::pairconst Key, T class map;Compare就是排序策略Allocator就是内存分配策略。两个策略叠加在同一个类上互不干涉。这就是组合爆炸的模板解法——用多个模板参数表示多个维度而不是用多个继承层级来组合。我在自己项目里也这么干过写了一个网络协议解析框架用两个策略参数分别表示字节序大端/小端和消息边界长度前缀/定界符。用四个组合实例化出不同处理器代码完全复用没有继承树也没有运行期判断template typename ByteOrderStrategy, typename FramingStrategy class ProtocolHandler { public: std::vectoruint8_t decode(const std::vectoruint8_t raw) { ByteOrderStrategy bo; FramingStrategy fs; return fs.extract(bo.convert(raw)); } }; class LittleEndian { public: std::vectoruint8_t convert(const std::vectoruint8_t raw) { /* ... */ } }; class BigEndian { public: std::vectoruint8_t convert(const std::vectoruint8_t raw) { /* ... */ } }; class LengthPrefixed { public: std::vectoruint8_t extract(const std::vectoruint8_t raw) { /* ... */ } }; class NullTerminated { public: std::vectoruint8_t extract(const std::vectoruint8_t raw) { /* ... */ } }; using LittleEndianLengthHandler ProtocolHandlerLittleEndian, LengthPrefixed; using BigEndianLengthHandler ProtocolHandlerBigEndian, LengthPrefixed;这类写法的妙处在于策略类不必继承任何公共接口只要实现了模板要求的成员函数即可。你甚至可以传入一个外部库的类只要它有对应名字的成员函数。这就是鸭子类型在编译期的体现C 管这个叫结构类型约束不需要基类干预。不过模板策略也不总是第一选择。它最大的隐含成本其实是编译期封装。模板方法必须写在头文件里否则无法实例化这会拖慢编译速度。团队项目里头文件膨胀导致每次改动都要触发大面积重编译是很多公司不愿意大规模用模板策略的真实原因。4. 策略组合、策略工厂与上下文重构4.1 把多个策略组合成一个大策略设计模式书里没怎么强调策略的组合但真实项目里组合策略特别常见。比如风控场景一个支付请求要过黑名单检查、限额检查、频次检查、设备指纹检查。每个检查都是一个策略最终通过的判定是所有检查都通过。我用std::function策略加vector一口气就能做组合class RiskControlService { public: using CheckStrategy std::functionbool(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::vectorCheckStrategy 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::functionstd::unique_ptrIPayStrategy(); static void registerStrategy(const std::string name, Creator creator) { registry()[name] std::move(creator); } static std::unique_ptrIPayStrategy create(const std::string name) { auto it registry().find(name); if (it registry().end()) { return nullptr; } return it-second(); } private: static std::mapstd::string, Creator registry() { static std::mapstd::string, Creator instance; return instance; } };这个工厂本身没什么复杂的它的价值在于注册机制。你可以在每个策略类的源文件底部写一个静态注册代码static const bool alipayRegistered []() { PayStrategyFactory::registerStrategy(alipay, []() { return std::make_uniqueAlipayPay(); }); 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_uniqueCancelledState()); } void next(OrderContext ctx) override { // 去支付 ctx.setState(std::make_uniquePaidState()); } };相同点是它们都利用多态来消除分支判断不同点在于控制权在哪里。策略模式的控制权在上下文手中策略是被动替换的。状态模式的控制权在状态对象手中状态转移是主动推进的。这个区别如果你在写实际代码时没体会可以硬记一句话策略是我可以选哪个状态是我原本是哪个、接下来要变哪个。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::mapstd::string, std::string ext; }; class IPayStrategy { public: virtual void pay(const PayContext ctx) 0; };这样以后再扩展参数只改PayContext结构体不用改所有策略实现。如果一开始就意识到会有这类扩展能少折腾半天。最后说测试的事。策略模式最容易被低估的价值是它极大提升了可测试性。策略被拆成独立单元后每个策略都能单独测试。我重构支付模块后每个支付渠道的策略类都配了一个独立测试文件用 mock 的通道客户端验证各种响应。以前藏在几百行 if-else 里的分支逻辑根本没法定向测试。当你开始测试策略模式时你才会真正体会到小步快跑、随时替换的妙处。比如临时加一个限时折扣策略不需要改任何生产代码只在服务启动时注册一下测试环境验证完改一行注册代码就能下线。我在实际项目中体会最深的一点是设计模式在 C 里从来不是标准答案而是思想工具箱。策略模式的精髓是把行为抽象为可替换的单元至于这个单元是虚函数接口、std::function、还是模板参数完全取决于你的具体场景。最后分享一个我常用的收尾技巧如果你新接手一个系统想快速判断哪里该用策略模式就全局搜索if和else if凡是出现了三个以上并列分支、而且每个分支做的事结构相似那这块就是策略模式的候选地。不要一上来就动手先把分支逻辑里会变的部分提炼成签名再决定用哪种变体。我靠这个方法在几个项目里都快速锁定了最值得重构的代码块。希望这篇对你有用如果你在实际项目里也做出了好玩的策略模式变体欢迎回来聊。
返回列表