C++20策略模式重构:用concepts与std::function替代虚函数多态

发布时间:2026/10/18 8:09:55
C++20策略模式重构:用concepts与std::function替代虚函数多态 前段时间帮某团队做架构评审看到一堆虚基类躺在核心模块里每个派生类只重写一两个方法调用方捏着基类指针满世界传递。这其实就是策略模式但用C写成这样观感总是差点意思。C20这一波特性落地之后策略模式完全可以换一种更有质感的写法——concepts约束模板策略、std::function做轻量策略、span统一数据视图这套组合拳打下来策略模式不仅没那么“重”反而可以既灵活又干净。这篇文章就是围绕C20策略模式的一次实战复盘我会把三种主流实现路线虚函数多态、模板策略、std::function函数式策略掰开揉碎讲清楚然后用一个完整的多后端文件上传器案例把代码串起来最后聊一聊组合策略、运行期热切换和我在实际项目里总结的坑。适合正在用C17/C20写业务代码、想重构多态代码或者对模板concepts组合感兴趣的同学参考。1. 为什么说C20让策略模式值得重新写一遍1.1 策略模式的本意和它在C里常见的“变味”写法策略模式本身不复杂定义一族算法把每个算法封装成独立对象让它们可以相互替换算法的变化不影响到使用算法的客户端。经典的UML图大家都见过Context持有Strategy接口ConcreteStrategyA和B各自实现算法。落到C里最传统的写法就是抽象基类加一堆派生类然后由Context通过基类指针调用具体策略。这种写法在Java这类偏重运行期多态的语言里很顺手但在C里有个奇妙的现象很多人写着写着策略模式就变成了“虚函数海洋”——抽象基类里堆了六七个纯虚方法每个派生类又继承又组合调用链一深人直接看懵了。我见过一个更夸张的版本策略接口里同时有setConfig、setVerbose、setTimeout还有个返回布尔值的supports()策略类之间还有交叉引用。这种设计早就不属于策略模式了纯粹是把多态当成收纳箱什么东西都往里塞。1.2 传统虚函数策略的三个隐藏成本第一个成本是接口约定靠自觉。虚函数接口是运行期多态编译器在编译阶段完全不检查子类实现的“语义”对不对。你定义了一个virtual bool compress(...)某个派生类返回true但啥也没干编译器一脸无辜。换句话说虚基类只是给了一个调用约定并没有给你一个“可验证的算法契约”。C20的concepts主要解决的恰恰是这个问题这点后面我会专门展开。第二个成本是对象生命周期管理。基类指针一旦被传出去谁持有、谁释放、能不能在别的线程里用这些问题全部变成隐式约定。遇到某个策略内部还持有缓存、句柄、连接池这类资源时生命周期问题会被放大。很多线上偶现的崩溃追到最后都是某个策略对象在多线程里被释放了而另一个线程还在调它的虚方法。第三个成本藏在了一个冷门细节里虚函数的默认参数是按静态类型解析的。假设基类声明virtual void run(int mode 1);派生类重写成void run(int mode 2);你用派生类对象调用时是2用基类引用调用时反而是1。这种问题在策略模式里尤其恶心因为调用方持有的恰恰是基类引用或指针默认参数一错排查半天可能都发现不了。1.3 C20真正改变了哪些筹码我一直觉得C20不是那种“革命性”的标准但它把很多以前只能用怪招解决的问题变成了有正经答案的问题。concepts让模板策略第一次有了“接口声明”。以前你用模板写策略类型错了报错信息经常在深不见底的模板实例化链里现在requires子句直接告诉你缺哪个成员函数。std::span策略方法的参数不用再传递指针加长度更不用传const std::vectorT把整个容器拷来拷去一个span就统一了数据视图。std::function的小对象优化很多编译器的std::function内部有SBOSmall Buffer Optimization小的可调用对象直接存在对象内部不触发堆分配。这让“函数式策略”的成本进一步下降。观念变化C社区越来越接受“值语义的多态”也就是通过模板和概念在编译期完成多态而不是一定要靠虚函数。这几个变化叠加起来策略模式在C20里至少多了两条非常合理的实现路线模板策略和函数式策略。它们和虚函数路线各有各的适用范围而不是互相替代。2. 三种主流策略写法工作原理、骨架代码和适用场景2.1 虚函数多态策略运行期切换的老牌主力先看一份我常用的压缩器策略接口#include span #include vector #include system_error #include string_view class ICompressor { public: virtual ~ICompressor() default; // 输入和输出都用 span/引用避免拷贝 virtual bool compress(std::spanconst std::byte input, std::vectorstd::byte output, std::error_code ec) 0; virtual std::string_view name() const noexcept 0; };LZ4、Zlib、Brotli各写一个派生类然后Context持有std::unique_ptrICompressor通过工厂根据配置创建具体策略。这种写法的核心价值在于运行期切换程序跑起来之后可以根据配置、灰度开关、甚至运行时的数据特征动态替换压缩算法。我处理过一个日志服务的场景磁盘压力大的时候需要从“快速压缩”切换到“高压缩比”继续用低压缩比算法就会触发磁盘告警。这种切换无法在编译期决定必须运行期做那虚函数多态就是最直接的选择。虚函数调用本身虽然有一层间接跳转但一次多态调用也就几纳秒级别对“每次压缩一批MB级数据”这种粒度来说完全不是瓶颈。2.2 模板策略编译期静态派发零额外开销模板策略的思路是完全抛弃虚函数把策略类型直接作为模板参数传给Context。C标准库里到处都是这种设计最典型的就是std::sort的比较器、字符串的char_traits、allocator的模板参数。template typename CompressorT class Pipeline { public: bool run(std::spanconst std::byte input) { std::vectorstd::byte compressed; std::error_code ec; if (!compressor_.compress(input, compressed, ec)) { // 记录 ec.message() return false; } return true; } private: CompressorT compressor_{}; };这种写法的优势是彻底消灭了虚函数调用编译器有可能把compress直接内联展开在热循环里收益实打实。但它也有明显代价策略必须在编译期确定。如果你需要运行期根据配置切换策略这就做不到除非你用if constexpr或者std::variant在编译期把所有分支摊开。模板策略在C17时代有个挺烦的问题约束靠的是“能用就行”的静态鸭子类型报错信息惨不忍睹。一个成员函数名字拼错可能报错在模板实例化的第三层。C20的concepts出现之后这个短板被补上了模板策略的价值一下子高了很多。这点我放在第3节细讲。2.3 std::function轻量策略不想建类层次时的逃生舱如果策略只是一个回调点没必要定义一个有名字的接口类型和一堆策略类那std::function就是最顺手的选择。using CompressFn std::functionbool(std::spanconst std::byte, std::vectorstd::byte, std::error_code); class RuntimeCompressor { public: explicit RuntimeCompressor(CompressFn fn) : fn_(std::move(fn)) {} void setCompressor(CompressFn fn) { fn_ std::move(fn); } bool compress(std::spanconst std::byte input, std::vectorstd::byte output, std::error_code ec) { return fn_(input, output, ec); } private: CompressFn fn_; };使用方可以直接塞一个lambda进去auto lz4_lambda [](std::spanconst std::byte in, std::vectorstd::byte out, std::error_code ec) - bool { // 调用 lz4 的实现 return true; }; RuntimeCompressor compressor(lz4_lambda);这种写法的可读性非常好尤其是当“策略”本质上就是一个行为差异点时。它还能捕获上下文状态比建一个策略类更轻巧。缺点是std::function本身做类型擦除如果函数对象超过内部缓冲区大小需要一次堆分配同时一个std::function只能表达“一个函数”如果要表达“上传回滚查询状态”这种多操作组合还是建类更合适。2.4 三条路线的横评表我自己在选型时基本是照下面这张表来判断的维度虚函数多态模板策略 conceptsstd::function绑定时机运行期编译期运行期运行期切换灵活不行除非配 variant灵活调用开销间接跳转可内联零额外开销类型擦除可能有堆分配接口表达力一个类可含多个方法靠 concept 约束一次一个回调生命周期管理需要智能指针值语义最省心值语义最省心报错信息运行期才暴露编译期即暴露编译期即暴露lambda类型不匹配时典型场景配置驱动、插件式热路径、算法族固定简单回调注入提示这三者不是互斥关系。我经常在同一个项目里混用框架层用模板策略保证热路径性能给外部扩展点留一个虚函数策略接口再对某些阈值类逻辑用std::function直接注入。3. concepts上场让模板策略从“鸭子类型”进化为“显式契约”3.1 为什么模板策略以前那么怕“写错”模板策略的静态多态核心是“结构兼容”只要类型T有compress方法且签名吻合就能用。这在C标准库里也被大量使用但问题在于你没法在模板开头告诉读者“这个T必须有什么”。更惨的是一旦T不满足要求编译器会从模板内部一路爆出一长串实例化日志错误信息把人类语言的耐心消磨殆尽。C17时代有人用SFINAE写enable_if来做约束比如template typename T, typename std::void_t decltype(std::declvalT().compress( std::declvalstd::spanconst std::byte(), std::declvalstd::vectorstd::byte(), std::declvalstd::error_code())) struct can_compress : std::true_type {};这玩意能跑但写起来像天书。而且一旦约束失败编译器给出的提示依然是“模板参数替换失败”这类绕来绕去的话。3.2 用concept给“上传策略”写一份显式契约C20的concepts把这件事变成了普通的声明你直接说清楚“一个上传策略必须长什么样”让编译器帮你盯着。以我准备在第4节落地的多后端文件上传器为例先定义一个策略概念#include concepts #include filesystem #include system_error #include string_view namespace fs std::filesystem; template typename T concept UploadStrategyLike requires(T s, const fs::path src, const fs::path dst, std::error_code ec) { { s.upload(src, dst, ec) } - std::same_asbool; { s.rollback(dst) } - std::same_asvoid; { s.name() } - std::convertible_tostd::string_view; };这段代码读完不需要额外注释因为你已经用自然语言的方式把“上传回滚名字”三个动作写进requires表达式了。任何想当UploadStrategyLike的类型都得保证这三个成员函数可用且返回值符合约束。3.3 concept的边界细节和三个容易踩的坑第一个坑是std::same_asbool和std::convertible_tobool的区别。如果你在constraint里写- std::convertible_tobool那么返回int的类型也能通过编译因为int可以隐式转换成bool。大部分时候这不是你想要的“严格布尔”。我建议用std::same_asbool做严格匹配除非你确实接受隐式转换。第二个坑是判定表达式里我用的是T s而不是const T s。这背后有个实际考虑如果写成const T sconcept会要求upload、rollback、name都是const成员函数。但不少策略对象内部有可变状态比如限速器要记录已发送字节数这类策略的upload天然不能是const。面对“有的策略有状态有的策略无状态”我倾向于用非const引用做判定让策略自己决定成员方法是否加const。代价是如果某个策略只有const的upload用非const引用判定也能过——因为从T可以调用const成员函数这反而是更宽容的选择。第三个坑是静态成员函数策略。如果策略的方法是静态的requires表达式没法直接调用因为requires里的对象表达式是“拿对象调方法”。解决办法是多包一层template typename T concept StaticCompressorLike requires(const fs::path p, std::error_code ec) { { T::compress(p, ec) } - std::same_asbool; };实际项目中静态策略和实例策略混用是常态写concept的时候要想清楚你约束的是对象方法还是静态方法别等编译报错了再抓头。3.4 用了concept之后编译器的报错风格完全变了以前用模板策略传一个不满足约束的类进去输出可能是一百多行模板实例化栈。现在你写template UploadStrategyLike T class StaticFileUploader { ... };传进去一个缺rollback的类时GCC和Clang会直接告诉你约束UploadStrategyLike未被满足因为rollback(dst)表达式无效。这个提示基本上直接指向修复方向而不是让你在模板的汪洋里考古。这也是我这次重构最大的体会concepts让模板策略第一次具备和虚函数接口同等级别的“接口自文档”能力但代价是编译期的静态检查运行期开销依然为零。4. 完整实战做一个多后端的文件上传器4.1 需求设定和场景背景用一个我近期在模拟项目X里做过的事情来举例。某同步服务需要把文件从本地暂存区落到不同后端本地磁盘、远端SFTP、某对象存储网关。后端类型在配置文件里指定启动时读取并且运行中可以通过控制接口切换。需求提炼一下就三条同一个上传动作在不同后端上有不同实现调用方不关心落盘细节。上传失败时必须有回滚动作比如删除半成品文件避免污染目标端。每个策略需要有标识名日志和监控能看出当前流量走了哪个后端。这个场景就是策略模式的教科书案例行为家族清晰、需要运行期切换、每个行为都有独立状态连接池、分片信息等。我决定先用经典虚函数版本给出一个可运行的全貌然后再用concepts模板策略重构一把对比两种写法的差异。4.2 接口设计为什么错误信息用std::error_code而不是string先定接口。我坚持一个原则策略方法不抛异常作为常规控制流错误通过std::error_code返回。原因有三上传是I/O密集操作调用频率高抛异常的开销在这种非极端场景下虽不明显但异常的语义是“违反了前置条件或发生了不可恢复错误”而上传失败是一个业务上预期内可能发生的普通分支。调用方经常要在失败时做回滚和重试必须拿到具体错误码才能做决策。std::error_code自带系统错误分类和std::filesystem的接口配合得很自然。于是接口长这样class IUploadStrategy { public: virtual ~IUploadStrategy() default; virtual bool upload(const fs::path src, const fs::path dst, std::error_code ec) 0; virtual void rollback(const fs::path dst) 0; virtual std::string_view name() const noexcept 0; };4.3 三个后端的实现和注册工厂本地磁盘策略最简单也是我默认的测试后端class LocalDiskStrategy final : public IUploadStrategy { public: bool upload(const fs::path src, const fs::path dst, std::error_code ec) override { fs::create_directories(dst.parent_path(), ec); if (ec) return false; fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); return !ec; } void rollback(const fs::path dst) override { std::error_code ignore_ec; fs::remove(dst, ignore_ec); } std::string_view name() const noexcept override { return local-disk; } };SFTP和对象存储的策略核心逻辑都是打开会话、建目录、传数据、失败清理差别主要体现在连接管理和API调用上。这里不展开细节实现只强调一个节点每个策略的构造参数可能完全不同本地磁盘不需要连接信息SFTP需要主机端口账号对象存储需要桶名和凭证。这种“构造参数不一致”正是虚函数策略最难处理的地方——你得用工厂把差异性藏在创建逻辑里。我用的注册表很简单#include functional #include map #include memory #include string #include string_view using StrategyFactory std::functionstd::unique_ptrIUploadStrategy(); class UploadStrategyRegistry { public: void registerFactory(std::string_view key, StrategyFactory factory) { factories_.emplace(std::string(key), std::move(factory)); } std::unique_ptrIUploadStrategy create(std::string_view key) const { auto it factories_.find(std::string(key)); if (it factories_.end()) { return nullptr; } return it-second(); } private: std::mapstd::string, StrategyFactory, std::less factories_; };用std::less做透明比较是为了让find直接吃std::string_view避免每次查找都构造一个临时std::string。这是个小优化但在工厂被频繁调用的服务里积少成多。4.4 用模板策略加concepts重写一遍有的同学会问“既然虚函数版已经能跑为什么还要重写”答案在于调用侧对性能的敏感度。同步服务里上传小文件非常频繁每次都要走一次虚函数调用虽然单次开销不大但监控和日志每文件都打聚合起来就值得优化。concepts版本把策略直接内联进模板同一套接口定义template typename T concept UploadStrategyLike requires(T s, const fs::path src, const fs::path dst, std::error_code ec) { { s.upload(src, dst, ec) } - std::same_asbool; { s.rollback(dst) } - std::same_asvoid; { s.name() } - std::convertible_tostd::string_view; }; template UploadStrategyLike T class StaticFileUploader { public: bool upload(const fs::path src, const fs::path dst) { std::error_code ec; if (!strategy_.upload(src, dst, ec)) { log_error(strategy_.name(), src, dst, ec); strategy_.rollback(dst); return false; } return true; } private: T strategy_{}; };注意LocalDiskStrategy只要稍微调整一下让upload和rollback不依赖虚函数也能直接使用。由于concept要求的是“成员函数存在且签名匹配”而LocalDiskStrategy本身就有这些方法所以在不改类定义的前提下它既能当虚函数派生类又能当模板策略参数。这一点非常关键意味着你可以渐进式迁移而不是推倒重来。4.5 测试套路用一个fake后端验证回滚逻辑策略模式的单元测试有个很有效的套路写一个专门捣乱的策略模拟“上传明明失败但目标端已经留了半截文件”的场景。我通常这样组织用LocalDiskStrategy作为正常路径验证断言上传后文件存在、内容一致。用一个FlakyBackendStrategy作为故障注入点upload返回false但rollback里故意做了标记测试断言rollback被调用且目标文件被清理。再用一个抛异常的模拟后端验证调用方的异常安全兜底。这种fake策略在测试里极其有用因为策略接口本身就是那三四个方法伪造一套完全不费劲。这也是策略模式一个被低估的优点可测试性爆棚。相比把一堆后端实现塞进同一个函数里策略模式让每条路径都可以独立跑到。5. 进阶玩法组合策略、热切换和状态管理5.1 组合策略用装饰器给策略“叠buff”策略模式经常和装饰器模式混着用。拿上传器举例你不应该把限速逻辑直接写进每个后端策略里因为本地磁盘、SFTP、对象存储的限速需求完全一致重复三遍就是罪。正确做法是写一个包装策略class RateLimitedUploadStrategy final : public IUploadStrategy { public: RateLimitedUploadStrategy(std::unique_ptrIUploadStrategy inner, std::size_t bytes_per_second) : inner_(std::move(inner)), rate_(bytes_per_second) {} bool upload(const fs::path src, const fs::path dst, std::error_code ec) override { // 按 rate_ 计算分片大小循环读取并转发给 inner_ // 每次转发前 sleep 到下一个时间片 return inner_-upload(src, dst, ec); } void rollback(const fs::path dst) override { inner_-rollback(dst); } std::string_view name() const noexcept override { return inner_-name(); } private: std::unique_ptrIUploadStrategy inner_; std::size_t rate_; };把“限速”做成装饰器好处不止是复用策略对象的职责被拆得很干净底层只负责“传上去”装饰层只负责“控制节奏”。组合顺序可以随意调整——先加密传输再限速先把大文件分片再断点续传一切都可以像搭积木一样组装。5.2 运行期热切换在多线程环境下安全换策略还是回到同步服务的场景运行中切换后端时可能有几十个上传任务正持有旧策略对象。这时候不能直接释放策略对象否则轻则任务失败重则悬垂指针崩溃。我用的是快照锁方案而不是无锁。class ConcurrentUploader { public: void setStrategy(std::shared_ptrIUploadStrategy strategy) { std::lock_guardstd::mutex lock(mutex_); strategy_ std::move(strategy); } bool upload(const fs::path src, const fs::path dst) { std::shared_ptrIUploadStrategy snapshot; { std::lock_guardstd::mutex lock(mutex_); snapshot strategy_; } // 快照拿到的是 shared_ptr策略对象不会被并发释放 return snapshot-upload(src, dst); } private: std::mutex mutex_; std::shared_ptrIUploadStrategy strategy_; };之所以先拿快照再调用是为了不在持锁的情况下执行外部代码。持锁调用意味着所有上传任务都得排队等一个mutex这会把并发能力打没。先锁、拷贝shared_ptr、解锁再调用策略方法锁的持有时间极短。std::atomicstd::shared_ptrT在C20里也可以用来做这类切换但lock-free不保证而且部分实现下性能未必优于mutex快照。对于“切换频率远低于上传频率”的场景mutex快照是最稳妥的方案。5.3 状态型策略的两个陷阱策略对象内部若有状态容易踩两个坑。第一个坑是多线程共享。一个上传策略对象被多个线程并发调用时如果内部有计数器、缓冲区这类可变成员必须自己做同步或者干脆保证每个线程持有独立实例。我通常建议有状态的策略配合thread_local工厂或按线程分实例无状态的策略才适合全局共享。第二个坑是策略复用时的状态残留。比如“断点续传策略”上一次上传中断时记录了offset下一次新任务如果不重置状态就会接着旧offset继续传结果数据错乱。所以在策略接口里加一个reset()方法或者在context里要求每次任务后强制执行一次状态清理是值得考虑的。设计策略接口时能先把“状态生命周期”想清楚后面省事很多。另外一个思路是用std::variant承载一整套编译期固定的策略集合运行期通过索引切换这算是模板策略和虚函数策略之间的一条中间路线using BackendVariant std::variantLocalDiskStrategy, SftpStrategy, ObjectStoreStrategy;再用std::visit统一分发。这种方式既能运行期切换又避开了虚函数适合“策略种类不多、但确实需要在运行期选择”的场景。代价是每加一种策略都要改variant类型定义扩展性不如注册表式工厂。6. 我的最佳实践清单和那些值得记住的坑6.1 什么时候真的别用策略模式这话可能有点反直觉但策略模式最大的问题就是容易被过度使用。如果算法集合只有两个固定分支且未来半年内几乎确定不会加第三个那直接if / switch最快。引入策略模式相当于给两个分支装上了“可扩展接口”而这套接口本身是有维护成本的——文件多了、阅读路径绕了、测试也要多写几层。我为模拟项目X做代码评审时就见过一个反面案例整套鉴权逻辑只有“白名单”和“黑名单”两种模式硬是抽了一个虚基类和四个派生类结果三个月后需求根本没变这四层抽象纯属自找麻烦。我的判断标准很简单策略模式付出去的成本是“抽象”买回来的是“在不修改调用方的情况下增加或替换算法”。如果这个‘买回来’的能力半年内用不上就别先付钱。6.2 策略接口的卫生习惯接口设计直接决定策略模式能撑多久几个原则我基本不破不要在策略里塞布尔开关。比如upload(bool verify)、upload(bool fast)这种参数说明你要么拆成两个策略要么写装饰器。布尔开关会让策略数量爆炸而且未来每个开关都会演化出新组合。策略方法不要返回裸指针或引用。策略对象由Context持有方法只返回结果不返回内部状态的所有权。错误信息别用string。用std::error_code实在要带业务上下文可以额外定义一个result结构体把错误码和错误描述放在一起。命名要能说明业务意图。接口方法名别叫execute()、run()这种万能词直接叫upload()、compress()、validate()读代码的人一眼就知道策略是干什么的。6.3 机制选择的参考表最后把我常用的决策逻辑汇总一张表方便直接抄你的真实情况优先选型策略算法集合固定追求热路径性能模板策略 concepts运行期需要根据配置动态切换且策略对象有状态和生命周期虚函数多态 unique_ptr 工厂注册表策略就是单个回调不想引入类体系std::function lambda策略集合很小3-5个但要运行期切换std::variant std::visit既要性能又要插件式扩展核心热路径用模板策略扩展点留虚函数接口提示策略模式和工厂模式、命令模式经常混在一起出现。工厂负责“按配置创建策略对象”策略负责“算法可替换”命令模式负责“把操作封装成对象”。不要把三个职责塞进同一个类里。我在这个项目里最大的体会是C20压根不需要你放弃虚函数它只是给了你更多武器。最好的策略代码往往是按热路径区分——高频调用用模板静态派发低频配置用虚函数运行期派发。与其纠结“哪种写法更正统”不如先把需求跑通再沿着性能热点去调整机制。如果你正好在把老代码往C20迁移可以试一条最小的改造路径把最痛的那个策略接口定义成concept把第一个策略实现改成模板参数跑通后再逐步扩散。这个循序渐进的做法比一次性推翻重来稳妥得多。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询