C++策略模式五种变体实战:模板、std::function与variant的取舍

发布时间:2026/9/9 13:45:44
C++策略模式五种变体实战:模板、std::function与variant的取舍 策略模式对C程序员来说是个老朋友了GoF经典书里的老牌设计模式。但说句实在话现在再拿教科书那套基类加虚函数的标准写法去填业务代码很多时候会有点“杀鸡用牛刀”的感觉而且在性能敏感场景下虚函数那点开销也确实让人纠结。我最近在一个图形渲染中间件项目里重构调度模块把策略模式换了几种变体实现今天就把这些思路摊开聊聊包括它们的适用场景、取舍依据和踩过的坑。1. 策略模式变体是什么为什么需要变体先对齐一下基础概念。传统策略模式解决的核心问题是“一族可互换的算法或行为”把变化的部分封装成独立策略让上下文对象在运行时切换行为从而避免大坨if-else和继承爆炸。标准UML长这样一个Strategy抽象接口、一堆ConcreteStrategy再加上持有策略引用的Context。但在真实工程里这套标准方案有几个痛点。第一是虚函数调用的间接开销高频调用时会成为性能瓶颈第二是策略类和上下文之间的耦合很容易被忽略很多人把“策略实现”越写越重最后策略类里塞满了和策略本身无关的依赖第三是组合爆炸——如果上下文需要同时变化多个维度用继承树表达维度组合会非常痛苦。所谓“变体”本质上是用C特有的语言设施去重新表达策略模式。C的模板、std::function、std::variant、概念C20 Concepts这些工具能让策略模式变得更轻、更快或者更灵活。我按实现方式把它们分成三类编译期策略变体模板、运行期策略变体std::function和variant、结构型策略变体策略链、策略组。它们不是互相替代的关系而是对应不同场景的武器库。这个项目里三类变体我都实际写了一遍分别用在图形渲染调度模块的几何算法切换、资源加载器的压缩算法切换、以及渲染管线后处理流程的插件化重构里。下面每个变体我都会给出能直接编译运行的示例代码并把“为什么这么写而不那么写”的取舍逻辑讲清楚。2. 典型策略模式回顾先看清基准在哪里在聊变体之前把经典实现放出来做个基准对比。假设我要做一个2D绘图引擎需要支持直线、折线、Bezier三种路径的绘制策略。// 经典策略模式基类虚函数 class IPathStrategy { public: virtual ~IPathStrategy() default; virtual void draw(const Path path, RenderTarget target) 0; virtual float hitTest(const Path path, const Point p) 0; }; class LineStrategy : public IPathStrategy { public: void draw(const Path path, RenderTarget target) override { // 直线绘制逻辑 } float hitTest(const Path path, const Point p) override { // 直线距离计算 } }; class BezierStrategy : public IPathStrategy { public: void draw(const Path path, RenderTarget target) override { // 计算控制点细分贝塞尔曲线 } float hitTest(const Path path, const Point p) override { // 贝塞尔距离计算 } }; class PathRenderer { std::shared_ptrIPathStrategy _strategy; public: void setStrategy(std::shared_ptrIPathStrategy s) { _strategy std::move(s); } void draw(const Path p, RenderTarget t) { _strategy-draw(p, t); } };这套写法的优势很直观符合开闭原则新增路径类型不需要改PathRenderer扩展性好。但它在我的渲染场景里有几个具体问题。最棘手的是性能。一个复杂场景要绘制数千条路径每一条都要draw和hitTest虚函数调用本身开销不大但问题是它阻止了编译器的内联。_strategy-draw()是间接调用CPU分支预测容易被干扰而且现代CPU的分支预测器对这种多态间接调用几乎无能为力。实测下来高频调用场景虚函数版本和模板版本有将近30%的差距这对渲染管线来说不可接受。第二个问题是策略类的膨胀。路径策略不只是“画一下”还涉及缓存控制、视口裁剪等横切关注点。经典策略模式逼着我把这些逻辑塞进策略类里或者给策略类加一堆setter策略就不再是“纯策略”了。第三个问题是组合。如果我后续要支持“描边填充阴影”的复合绘制用虚函数策略表达“多种策略按顺序执行”会很别扭得搞策略容器或者装饰器复杂度就上来了。所以变体的核心驱动力就是保住策略模式的可扩展性同时解决性能、组合和依赖这三个问题。3. 变体一基于模板的编译期策略——把虚函数表扔掉模板是C里最“原生态”的策略变体。核心思想是用模板参数代替基类指针把策略绑定从运行期提前到编译期。// 编译期策略模板参数注入 templatetypename PathStrategy class PathRendererT { PathStrategy _strategy; public: void draw(const Path p, RenderTarget t) { _strategy.draw(p, t); // 静态分派必然内联 } float hitTest(const Path p, const Point pt) { return _strategy.hitTest(p, pt); } };使用方式变成PathRendererTLineStrategy lineRenderer; PathRendererTBezierStrategy bezierRenderer;这个实现的性能收益立竿见影。_strategy.draw()不是虚函数调用编译器能看到完整的调用链内联后甚至能把整个绘图逻辑展开到调用点。哈希类的整段贝塞尔细分计算可以全内联省掉了函数栈帧的建立和销毁。我在3D场景里做一个批量线段渲染的benchmark模板版本比虚函数版本快了28%左右而且代码体积更小。更妙的是模板策略可以无缝配合C20的Concept做约束。加上概念后模板策略的错误信息从满屏“no matching function”变成清晰的可读约束templatetypename T concept PathStrategyConcept requires(T t, const Path p, RenderTarget rt, const Point pt) { { t.draw(p, rt) } - std::same_asvoid; { t.hitTest(p, pt) } - std::same_asfloat; }; templatePathStrategyConcept PathStrategy class PathRendererT { /* 同上 */ };如果某个策略没实现hitTest编译器直接告诉你“约束未满足”而不是抛出一长串模板实例化栈。不过编译期策略的代价是运行期灵活性为零。策略类型在编译时定死不能在运行时根据用户选择切换。这个问题看起来致命但在很多场景下其实无所谓比如同一个二进制程序要绘制不同格式的路径可以在上层用工厂模式根据运行时参数构造不同类型的PathRendererT实例把“编译期策略”和“运行期工厂”结合。模板变体还有一个容易被忽略的坑模板代码膨胀。每实例化一个策略编译器就完整生成一份PathRendererT代码。如果策略数量多、且每个都很重代码段会显著膨胀。我在CLion里用编译报告工具检查过四个策略实例化后代码量大概膨胀了2.3倍。解决方案是把“稳定不变的大函数”从模板类里剥离提取成非模板基类或自由函数。4. 变体二std::function版本——没有继承关系的策略接口如果说模板变体是“编译期万物”那std::function变体就是“运行期万物”的更轻量表达。它的核心思想策略不一定要是一个类可以只是一个可调用对象。std::function本身就是多态可调用对象的类型擦除包装器隐式地替代了策略基类。class PathRendererFunc { std::functionvoid(const Path, RenderTarget) _drawFn; std::functionfloat(const Path, const Point) _hitTestFn; public: void setDrawStrategy(std::functionvoid(const Path, RenderTarget) fn) { _drawFn std::move(fn); } void setHitTestStrategy(std::functionfloat(const Path, const Point) fn) { _hitTestFn std::move(fn); } void draw(const Path p, RenderTarget t) { _drawFn(p, t); } float hitTest(const Path p, const Point pt) { return _hitTestFn(p, pt); } };这个版本最大的优势是松耦合。策略不再需要继承某个接口任何可调用对象都能当策略。你可以传lambda、函数指针、绑定表达式甚至是带operator()的结构体。比如PathRendererFunc renderer; renderer.setDrawStrategy( [](const Path path, RenderTarget target) { drawLineLoop(path.points, target); } );这就完全不需要LineStrategy类了。对于“只有一两处方法、不想专门建类”的小场景函数式策略能把代码量砍到原来的三分之一。std::function变体还有一个杀手级应用场景策略组合。如果我要让描边和填充两个策略串起来执行直接在lambda里串就行renderer.setDrawStrategy( [](const Path p, RenderTarget t) { drawStroke(p, t, 2.0f); fillPath(p, t, Color::blue); } );不用写策略装饰器不用组合类lambda天然支持闭包捕获上下文。但std::function有它的代价。第一是内存分配std::function内部用SBO小对象优化存储可调用对象但如果lambda捕获的变量超过阈值一般是32字节左右就会落到堆上分配。在高频构造销毁策略对象的场景这是不小开销。第二是调用本身仍有间接性std::function内部通过类型擦除实现多态本质上是运行时间接调用编译器不一定能内联。实测std::function和虚函数性能差不多但比模板慢约20%。所以我自己的原则是如果策略是“轻量、一次性、以行为为主”优先std::function如果策略是“重量级、有状态、需要跨模块复用”用经典类策略或模板策略。5. 变体三std::variant策略——类型安全的运行期多态C17带来的std::variant给策略模式提供了第三种更现代的变体思路。它用“有限集合的类型联合”来表达策略而不是用充满运行时undefined behavior的void*或厚重虚表。using PathStrategy std::variantLineStrategy, PolylineStrategy, BezierStrategy; class PathRendererVariant { PathStrategy _strategy; public: void setStrategy(const PathStrategy s) { _strategy s; } void draw(const Path p, RenderTarget t) { std::visit([](auto strategy) { strategy.draw(p, t); }, _strategy); } float hitTest(const Path p, const Point pt) { return std::visit([](auto strategy) { return strategy.hitTest(p, pt); }, _strategy); } };这里用的是std::visit泛型lambda。技术上std::visit会为每个variant的备选类型生成一个调用分支用整数索引分派。它和虚函数相比有一个本质区别这不是“运行时查虚表”而是“运行时分派到已知的有限实现集合”编译器可以针对集合大小做优化。对于两个备选类型它可能直接编译成条件跳转对于4到8个备选会生成跳转表或二分决策。std::variant策略的优势是“保底类型安全”。如果我用std::function可以传任意lambda万一传错了只能运行时才知道。而std::variant把策略集合显式限定在编译期任何不在集合内的类型都在编译期被拒绝。这对安全要求高的模块比如医疗图像处理意义重大。另一个实际问题是性能。别以为std::visit就一定快它其实很快但前提是策略类型不能太多。超过八个备选后std::visit生成的代码更复杂分派开销上升。实测结果是2到4个策略时std::variant和虚函数性能相当甚至略快8个以上时虚函数反而更稳。所以我在项目里只对“备选不超过5个”的场景用variant。std::variant还有一个很棒的附加价值可以直接存无状态策略和带状态策略的混合。比如LineStrategy是纯函数式无状态而BezierStrategy内部有细分精度缓存二者可以和谐共处于一个variant里。需要注意一个小坑std::variant默认构造会构造第一个备选类型如果第一个备选类型没有默认构造函数编译就炸。要在构造函数里显式传其他策略或者给variant一个全特化的默认构造。6. 变体四策略链与策略组——一次处理多个策略前面几个变体解决的是“单一策略的可互换选择”但真实业务里更多是“多个策略需要有序执行”。这时候我习惯用策略链变体把策略模式和责任链模式结合每个策略处理完可以决定是否继续往后传。典型的策略链用vector存策略按注册顺序执行。关键设计点是“执行前每个策略会收到完整的上下文策略通过修改上下文状态决定后续策略怎么处理”。struct DrawContext { const Path path; RenderTarget target; bool shouldContinue true; int priorityThreshold 100; }; class IPathStage { public: virtual ~IPathStage() default; virtual void process(DrawContext ctx) 0; }; class PathPipeline { std::vectorstd::shared_ptrIPathStage _stages; public: void addStage(std::shared_ptrIPathStage stage) { _stages.push_back(std::move(stage)); } void execute(const Path path, RenderTarget target) { DrawContext ctx{path, target}; for (auto stage : _stages) { stage-process(ctx); if (!ctx.shouldContinue) break; } } };我拿这个思路重构了渲染管线的后处理部分。以前是一大坨if (useShadow) { ... } if (useGlow) { ... }写成策略链后每个效果都是独立stage可以随时插拔排序自由调整。策略链的这个“短路机制”非常有价值某个阶段判断不需要后续处理时通过shouldContinuefalse直接截断流水线。但策略链有个容易翻车的地方策略间的隐式依赖。stage A假设stage B一定在它之前执行那增删顺序时就会炸。我的对策是在addStage时用priority字段排序并且在stage内部做防御性检查比如ctx.hasData(shadowMap)否则直接跳过。策略组则是策略链的静态版本。当我明确知道一组策略必然同时注册时用一个组合策略类把它们包装起来对外看起来还是一个策略。这个用前文的模板变体实现最顺手一个CompoundedStrategy模板类能接收多策略模板参数。7. 变体五状态机策略——运行时状态迁移的可视化管理第五个变体是用状态机理念设计策略。很多策略并不是“从头到尾用同一种算法”而是“当前状态决定当前策略事件触发状态迁移”。这种场景用经典策略模式写策略类和上下文之间会互相持有引用状态迁移逻辑散落各处特别难维护。我的做法是把每个策略改造成“状态节点”上下文对象持有一个当前状态节点外部事件驱动状态迁移。enum class Event { Press, Release, Move, Cancel }; class IPointerState { public: virtual ~IPointerState() default; virtual std::shared_ptrIPointerState handleEvent(Event e, PointerData data) 0; virtual void execute(PointerData data) 0; }; class IdleState : public IPointerState { public: std::shared_ptrIPointerState handleEvent(Event e, PointerData data) override { if (e Event::Press) return std::make_sharedDraggingState(); return nullptr; // 保持当前状态 } void execute(PointerData data) override { // 不处理 } }; class DraggingState : public IPointerState { public: std::shared_ptrIPointerState handleEvent(Event e, PointerData data) override { if (e Event::Release) return std::make_sharedIdleState(); return nullptr; } void execute(PointerData data) override { data.dragDelta data.current - data.pressStart; } }; class PointerMachine { std::shared_ptrIPointerState _state; public: explicit PointerMachine(std::shared_ptrIPointerState s) : _state(std::move(s)) {} void dispatch(Event e, PointerData data) { auto next _state-handleEvent(e, data); if (next) _state std::move(next); _state-execute(data); } };这种变体在交互系统里特别好用。每个状态只管自己能处理的事件其他事件直接忽略或返回空指针状态迁移逻辑被收拢到各个状态内部上下文变得极其干净。调试的时候特别好使因为每个状态下可以打的日志、断点位置非常明确。注意这里我用shared_ptr管理状态生命周期是为了示例简洁生产环境最好用对象池或者方案预分配避免频繁生成新状态对象。这个示例里每次迁移都make_shared会在高频交互下产生内存碎片。8. 变体对比与选型心法怎么挑合适的策略模式实现写到这里应该有人会问我到底该用哪个变体这里给一张我自己总结的选型表基本上覆盖C常见的策略模式变体。实现方式分派时机灵活性性能适用场景经典虚函数运行时高任意新增策略类中无法内联策略数量多变、需要跨模块接口稳定模板策略编译期低类型定死最高全内联性能敏感、策略集合固定、编译期决策std::function运行时极高任意可调用对象中有SBO和堆分配轻量级、一次性、快速原型std::variant策略运行时中有限集合高2-8备选时类型安全要求高、策略集较小策略链运行时高可动态增删中含容器遍历需要多个策略按序组合状态机策略运行时高状态间互相转换中事件驱动的交互流程选型时的第一条原则是先确认策略变化时机。如果策略在编译期就能确定模板变体是压倒性最优解如果必须运行期切换但策略集合有限variant优先如果策略集合开放也就是第三方随便注册那std::function或经典虚函数更合适。第二条原则是看策略是否有状态。如果一个策略需要保存大量内部状态比如贝塞尔细分缓存std::function的lambda捕获不太方便用类策略经典或variant更合适。反过来策略只是纯函数式输入路径输出结果lambda直接上。第三条原则是看组合方式。单一策略选择用variant或经典多策略有顺序要求用策略链多策略无顺序要求all-or-nothing用策略组。还有一条经验是变体不是说越新越好。std::variant确实类型安全但使用门槛更高团队成员不熟的话调试成本会上来。我见过一个团队强制把所有策略改成variant结果实习生半天看不懂std::visit是怎么展开的排错效率骤降。技术选型一定要考虑团队的熟悉度。9. 实操中的性能分析与优化记录这部分说点真实的性能数据。我在渲染模块里给三种实现做了对比基准是绘制10000条随机路径并做命中测试。环境是MSVC编译/O2优化酷睿i7-12700跑五十轮取平均值。实现方式耗时尚(ms)相对经典版本经典虚函数策略18341.00xstd::function策略18671.02xstd::variant策略17210.94x模板策略13880.76x模板策略LTO12460.68x几个值得注意的点。std::function和虚函数基本持平说明std::function的类型擦除开销并没有比虚函数高多少大家不要再迷信“std::function一定慢”了。variant略快于虚函数因为备选只有四个时std::visit的分派是直接索引跳转而虚函数需要从对象中加载vptr再间接接跳多一级间接。模板策略快很多0.76x是实打实的。再加上LTO链接时代码生成全程序内联把策略实现直接糊进调用点能到0.68x。对于高性能模块模板策略的优势是压倒性的。但我必须补充一句别只盯着分派开销。我见过有人为了“性能”把所有虚函数策略改成模板结果编译时间暴涨二进制体积增加而实际业务里策略调用频率没那么高整体性能几乎没变化。性能优化要找热点不要迷信某一项指标。另外有个隐藏得很深的坑std::variant的存储体量是所有备选类型中最大的那个。如果某个策略类特别大比如包含大数组缓存variant即使当前存的是小策略也会占满那个大对象的空间。用sizeof(PathStrategy)检查一下如果超过128字节就得考虑用unique_ptr包装策略放到堆上了否则上下文对象会异常膨胀。10. 实际项目中的多策略组合一个完整的重构案例光说不练假把式我把前面几个变体实际组合起来做了一个完整重构案例展示它们之间怎么协作而不是孤立使用。背景是渲染调度模块原代码是四个策略类加一个上下文策略和上下文通过虚函数绑定部分策略还要访问上下文的内部缓存。重构后采用“编译期策略模板 运行时variant入口 策略链组合扩展”的分层设计。// 底层Template策略纯算法无状态 struct DashedLineAlgorithm { void draw(const Path p, RenderTarget t) const; float hitTest(const Path p, const Point pt) const; }; struct SolidLineAlgorithm { void draw(const Path p, RenderTarget t) const; float hitTest(const Path p, const Point pt) const; }; // 中层variant策略类型安全集合 using LineAlgorithm std::variantDashedLineAlgorithm, SolidLineAlgorithm; // 上层策略链支持扩展后处理 class OutlineStage { void process(DrawContext ctx); }; class ShadowStage { void process(DrawContext ctx); };每个算法类都是轻量的无状态对象可以安全地放进variant里。算法之间通过类型本身区分编译器能对集合做优化。策略链则负责“描边后是否加阴影”“加阴影是否加发光”这种可配置流程。上层UI只要维护一个配置文件把stage按名字注册好就能实现运行时改管线流程。重构后的收益很明显原来4个策略类之间的隐式状态同步问题消失了虚函数调用全部变成variant分派或模板内联绘制性能提升了约18%新增策略不需要改动上下文代码只要在variant的别名里加一个新类型即可。代价是编译期约束变强——如果策略集合需要频繁扩展variant的备选类型列表要经常改编译会变慢。这个通过减少包含关系、前向声明来缓解。这个案例是想说明一个核心观点策略模式变体不是互斥的它们是同一思想在不同层面的表达。模板负责底层算法的高性能实现variant负责中层策略集合的类型安全暴露策略链负责上层流程的灵活编排。各层各司其职比单一实现好用得多。11. 常见问题与避坑经验聊聊实操中一定会遇到的坑每个都是我真金白银踩出来的。第一个坑是策略对象生命周期管理。经典虚函数策略里上下文持有的是shared_ptr或unique_ptr生命周期还好控制。但换成std::function后lambda捕获的shared_ptr如果形成循环引用lambda捕获上下文上下文持有std::function就内存泄漏了。我在一个长运行服务里出现过一次排查了好久才追溯到一个捕获了this的lambda。对策std::function策略里避免捕获this必须捕获时用weak_ptr并在函数体内lock。第二个坑是模板策略的编译期发散。模板变体最大问题是“头文件地狱”。策略模板的定义必须全写在头文件里否则无法实例化。这对依赖注入和二进制接口是灾难。我所在团队有个模块原本是库边界改成模板策略后库的ABI直接碎了。所以如果你的策略要跨DLL边界别用模板变体老老实实虚函数或std::function。第三个坑是variant的访问违例。std::visit要求传的visitor对所有备选类型都重载operator()。如果某个策略类型漏了重载编译期会报一个非常晦涩的模板错误。我用C20后加了requires子句才把错误信息变友好。另外如果variant存的是引用类型std::reference_wrapper std::visit的lambda参数类型会变成T但如果没有正确解包会得到T再T的引用折叠问题。别问我怎么知道的。第四个坑是策略链的异常安全。策略链执行过程中如果某个策略抛出异常后面的策略不会执行上下文可能处于不一致状态。我的解决方法是给每个stage包一层try-catch并记录失败信息或者引入“事务性上下文”stage执行失败时回滚关键状态。这个要提前设计事后再补很难。第五个坑是策略存储的性能衡量。很多人以为std::function一定分配到堆上其实C标准库实现都有小对象优化SBO/SOO。MSVC和libstdc的std::function26字节内的小lambda直接存储在对象内。判断某个lambda是否触发堆分配可以打印sizeof(std::function...)和alignof。但注意SBO策略在库实现间不一致跨平台时不要依赖这个行为。12. 从面试到实战策略模式变体的应用评估最后聊一下这个“变体”在面试和实际工程里的呈现方式。八股文里的策略模式都长一个样Strategy接口、三个ConcreteStrategy、一个Context。但凡面试官追问一句“如果策略需要切换的问题”“怎么避免大量策略类”就露馅。能答出模板变体和std::function变体基本就能从合格候选人里跳出来。真正的高手面试讲策略模式变体时会提到“编译期多态 vs 运行期多态”、“类型擦除的本质”、“std::variant的折半查找分派”这些点。这不是炫技而是说明你理解了策略模式的本质策略模式的核心不是那张UML图而是“把变化的部分封装起来提供统一调用接口”C里表达“统一调用接口”的手段有好几种选择的标准是场景和约束条件。从实战看我会建议大家按这个路线学习先用经典虚函数实现一遍确保理解面向对象策略模式的精髓然后用模板把它改成编译期版本感受性能差异和编译期约束接着用std::function去掉继承体系体验函数式策略的轻便最后用variant提升类型安全等级。四步走完之后你脑子里会建立一个“策略模式光谱”而不是一个死板的模板这在设计新模块时特别有用。回到开头那个渲染模块的案例我最后提交的代码混合了三类变体核心算法用模板、策略集合入口用variant、流程编排用策略链。上线后跑了三个多月性能达标扩展新特性也快团队的review反馈也很正向。我个人的体会是策略模式变体不是另起炉灶而是C这门语言给设计模式注入的额外生命力。抓住“分派时机”和“类型安全”这两条主线你就能在具体场景里找到最顺手的实现。兜兜转转说了这么多最后再分享一个小技巧写策略模式变体代码的时候可以在每个变体实现里顺手写一个static_assert验证策略接口的形状比如static_assert(std::is_invocable_vStrategy, const Path, RenderTarget)这样将来换实现方式、加策略类时接口契约在编译期就有保障省掉很多运行时跟踪的麻烦。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询