ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

前段时间帮某团队做架构评审,看到一堆虚基类躺在核心模块里,每个派生类只重写一两个方法,调用方捏着基类指针满世界传递。这其实就是策略模式,但用C++写成这样,观感总是差点意思。C++20这一波特性落地之后,策略模式完全可以换一种更有质感的写法——concepts约束模板策略、std::function做轻量策略、span统一数据视图,这套组合拳打下来,策略模式不仅没那么“重”,反而可以既灵活又干净。

这篇文章就是围绕C++20策略模式的一次实战复盘,我会把三种主流实现路线(虚函数多态、模板策略、std::function函数式策略)掰开揉碎讲清楚,然后用一个完整的多后端文件上传器案例把代码串起来,最后聊一聊组合策略、运行期热切换和我在实际项目里总结的坑。适合正在用C++17/C++20写业务代码、想重构多态代码,或者对模板+concepts组合感兴趣的同学参考。

1. 为什么说C++20让策略模式值得重新写一遍

1.1 策略模式的本意和它在C++里常见的“变味”写法

策略模式本身不复杂:定义一族算法,把每个算法封装成独立对象,让它们可以相互替换,算法的变化不影响到使用算法的客户端。经典的UML图大家都见过,Context持有Strategy接口,ConcreteStrategyA和B各自实现算法。

落到C++里,最传统的写法就是抽象基类加一堆派生类,然后由Context通过基类指针调用具体策略。这种写法在Java这类偏重运行期多态的语言里很顺手,但在C++里有个奇妙的现象:很多人写着写着,策略模式就变成了“虚函数海洋”——抽象基类里堆了六七个纯虚方法,每个派生类又继承又组合,调用链一深,人直接看懵了。

我见过一个更夸张的版本:策略接口里同时有setConfig、setVerbose、setTimeout,还有个返回布尔值的supports(),策略类之间还有交叉引用。这种设计早就不属于策略模式了,纯粹是把多态当成收纳箱,什么东西都往里塞。

1.2 传统虚函数策略的三个隐藏成本

第一个成本是接口约定靠自觉。虚函数接口是运行期多态,编译器在编译阶段完全不检查子类实现的“语义”对不对。你定义了一个virtual bool compress(...),某个派生类返回true但啥也没干,编译器一脸无辜。换句话说,虚基类只是给了一个调用约定,并没有给你一个“可验证的算法契约”。C++20的concepts主要解决的恰恰是这个问题,这点后面我会专门展开。

第二个成本是对象生命周期管理。基类指针一旦被传出去,谁持有、谁释放、能不能在别的线程里用,这些问题全部变成隐式约定。遇到某个策略内部还持有缓存、句柄、连接池这类资源时,生命周期问题会被放大。很多线上偶现的崩溃,追到最后都是某个策略对象在多线程里被释放了,而另一个线程还在调它的虚方法。

第三个成本藏在了一个冷门细节里:虚函数的默认参数是按静态类型解析的。假设基类声明virtual void run(int mode = 1);,派生类重写成void run(int mode = 2);,你用派生类对象调用时是2,用基类引用调用时反而是1。这种问题在策略模式里尤其恶心,因为调用方持有的恰恰是基类引用或指针,默认参数一错,排查半天可能都发现不了。

1.3 C++20真正改变了哪些筹码

我一直觉得C++20不是那种“革命性”的标准,但它把很多以前只能用怪招解决的问题,变成了有正经答案的问题。

  • concepts:让模板策略第一次有了“接口声明”。以前你用模板写策略,类型错了报错信息经常在深不见底的模板实例化链里,现在requires子句直接告诉你缺哪个成员函数。
  • std::span:策略方法的参数不用再传递指针加长度,更不用传const std::vector<T>&把整个容器拷来拷去,一个span就统一了数据视图。
  • std::function的小对象优化:很多编译器的std::function内部有SBO(Small Buffer Optimization),小的可调用对象直接存在对象内部,不触发堆分配。这让“函数式策略”的成本进一步下降。
  • 观念变化:C++社区越来越接受“值语义的多态”,也就是通过模板和概念在编译期完成多态,而不是一定要靠虚函数。

这几个变化叠加起来,策略模式在C++20里至少多了两条非常合理的实现路线:模板策略和函数式策略。它们和虚函数路线各有各的适用范围,而不是互相替代。

2. 三种主流策略写法:工作原理、骨架代码和适用场景

2.1 虚函数多态策略:运行期切换的老牌主力

先看一份我常用的压缩器策略接口:

#include <span> #include <vector> #include <system_error> #include <string_view> class ICompressor { public: virtual ~ICompressor() = default; // 输入和输出都用 span/引用,避免拷贝 virtual bool compress(std::span<const std::byte> input, std::vector<std::byte>& output, std::error_code& ec) = 0; virtual std::string_view name() const noexcept = 0; };

LZ4、Zlib、Brotli各写一个派生类,然后Context持有std::unique_ptr<ICompressor>,通过工厂根据配置创建具体策略。这种写法的核心价值在于运行期切换:程序跑起来之后,可以根据配置、灰度开关、甚至运行时的数据特征动态替换压缩算法。

我处理过一个日志服务的场景:磁盘压力大的时候,需要从“快速压缩”切换到“高压缩比”,继续用低压缩比算法就会触发磁盘告警。这种切换无法在编译期决定,必须运行期做,那虚函数多态就是最直接的选择。虚函数调用本身虽然有一层间接跳转,但一次多态调用也就几纳秒级别,对“每次压缩一批MB级数据”这种粒度来说,完全不是瓶颈。

2.2 模板策略:编译期静态派发,零额外开销

模板策略的思路是完全抛弃虚函数,把策略类型直接作为模板参数传给Context。C++标准库里到处都是这种设计,最典型的就是std::sort的比较器、字符串的char_traits、allocator的模板参数。

template <typename CompressorT> class Pipeline { public: bool run(std::span<const std::byte> input) { std::vector<std::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在编译期把所有分支摊开。

模板策略在C++17时代有个挺烦的问题:约束靠的是“能用就行”的静态鸭子类型,报错信息惨不忍睹。一个成员函数名字拼错,可能报错在模板实例化的第三层。C++20的concepts出现之后,这个短板被补上了,模板策略的价值一下子高了很多。这点我放在第3节细讲。

2.3 std::function轻量策略:不想建类层次时的逃生舱

如果策略只是一个回调点,没必要定义一个有名字的接口类型和一堆策略类,那std::function就是最顺手的选择。

using CompressFn = std::function<bool(std::span<const std::byte>, std::vector<std::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::span<const std::byte> input, std::vector<std::byte>& output, std::error_code& ec) { return fn_(input, output, ec); } private: CompressFn fn_; };

使用方可以直接塞一个lambda进去:

auto lz4_lambda = [](std::span<const std::byte> in, std::vector<std::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不满足要求,编译器会从模板内部一路爆出一长串实例化日志,错误信息把人类语言的耐心消磨殆尽。

C++17时代有人用SFINAE写enable_if来做约束,比如:

template <typename T, typename = std::void_t< decltype(std::declval<T&>().compress( std::declval<std::span<const std::byte>>(), std::declval<std::vector<std::byte>&>(), std::declval<std::error_code&>()))>> struct can_compress : std::true_type {};

这玩意能跑,但写起来像天书。而且一旦约束失败,编译器给出的提示依然是“模板参数替换失败”这类绕来绕去的话。

3.2 用concept给“上传策略”写一份显式契约

C++20的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_as<bool>; { s.rollback(dst) } -> std::same_as<void>; { s.name() } -> std::convertible_to<std::string_view>; };

这段代码读完不需要额外注释,因为你已经用自然语言的方式把“上传+回滚+名字”三个动作写进requires表达式了。任何想当UploadStrategyLike的类型,都得保证这三个成员函数可用且返回值符合约束。

3.3 concept的边界细节和三个容易踩的坑

第一个坑是std::same_as<bool>和std::convertible_to<bool>的区别。如果你在constraint里写-> std::convertible_to<bool>,那么返回int的类型也能通过编译,因为int可以隐式转换成bool。大部分时候这不是你想要的“严格布尔”。我建议用std::same_as<bool>做严格匹配,除非你确实接受隐式转换。

第二个坑是判定表达式里我用的是T& s而不是const T& s。这背后有个实际考虑:如果写成const T& s,concept会要求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_as<bool>; };

实际项目中,静态策略和实例策略混用是常态,写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::function<std::unique_ptr<IUploadStrategy>()>; class UploadStrategyRegistry { public: void registerFactory(std::string_view key, StrategyFactory factory) { factories_.emplace(std::string(key), std::move(factory)); } std::unique_ptr<IUploadStrategy> create(std::string_view key) const { auto it = factories_.find(std::string(key)); if (it == factories_.end()) { return nullptr; } return it->second(); } private: std::map<std::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_as<bool>; { s.rollback(dst) } -> std::same_as<void>; { s.name() } -> std::convertible_to<std::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_ptr<IUploadStrategy> 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_ptr<IUploadStrategy> inner_; std::size_t rate_; };

把“限速”做成装饰器,好处不止是复用:策略对象的职责被拆得很干净,底层只负责“传上去”,装饰层只负责“控制节奏”。组合顺序可以随意调整——先加密传输再限速?先把大文件分片再断点续传?一切都可以像搭积木一样组装。

5.2 运行期热切换:在多线程环境下安全换策略

还是回到同步服务的场景:运行中切换后端时,可能有几十个上传任务正持有旧策略对象。这时候不能直接释放策略对象,否则轻则任务失败,重则悬垂指针崩溃。

我用的是快照锁方案,而不是无锁。

class ConcurrentUploader { public: void setStrategy(std::shared_ptr<IUploadStrategy> strategy) { std::lock_guard<std::mutex> lock(mutex_); strategy_ = std::move(strategy); } bool upload(const fs::path& src, const fs::path& dst) { std::shared_ptr<IUploadStrategy> snapshot; { std::lock_guard<std::mutex> lock(mutex_); snapshot = strategy_; } // 快照拿到的是 shared_ptr,策略对象不会被并发释放 return snapshot->upload(src, dst); } private: std::mutex mutex_; std::shared_ptr<IUploadStrategy> strategy_; };

之所以先拿快照再调用,是为了不在持锁的情况下执行外部代码。持锁调用意味着所有上传任务都得排队等一个mutex,这会把并发能力打没。先锁、拷贝shared_ptr、解锁,再调用策略方法,锁的持有时间极短。

std::atomic<std::shared_ptr<T>>在C++20里也可以用来做这类切换,但lock-free不保证,而且部分实现下性能未必优于mutex快照。对于“切换频率远低于上传频率”的场景,mutex快照是最稳妥的方案。

5.3 状态型策略的两个陷阱

策略对象内部若有状态,容易踩两个坑。

第一个坑是多线程共享。一个上传策略对象被多个线程并发调用时,如果内部有计数器、缓冲区这类可变成员,必须自己做同步,或者干脆保证每个线程持有独立实例。我通常建议:有状态的策略配合thread_local工厂或按线程分实例,无状态的策略才适合全局共享。

第二个坑是策略复用时的状态残留。比如“断点续传策略”,上一次上传中断时记录了offset,下一次新任务如果不重置状态,就会接着旧offset继续传,结果数据错乱。所以在策略接口里加一个reset()方法或者在context里要求每次任务后强制执行一次状态清理,是值得考虑的。设计策略接口时,能先把“状态生命周期”想清楚,后面省事很多。

另外一个思路是用std::variant承载一整套编译期固定的策略集合,运行期通过索引切换,这算是模板策略和虚函数策略之间的一条中间路线:

using BackendVariant = std::variant<LocalDiskStrategy, 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
既要性能又要插件式扩展核心热路径用模板策略,扩展点留虚函数接口

提示:策略模式和工厂模式、命令模式经常混在一起出现。工厂负责“按配置创建策略对象”,策略负责“算法可替换”,命令模式负责“把操作封装成对象”。不要把三个职责塞进同一个类里。

我在这个项目里最大的体会是:C++20压根不需要你放弃虚函数,它只是给了你更多武器。最好的策略代码往往是按热路径区分——高频调用用模板静态派发,低频配置用虚函数运行期派发。与其纠结“哪种写法更正统”,不如先把需求跑通,再沿着性能热点去调整机制。如果你正好在把老代码往C++20迁移,可以试一条最小的改造路径:把最痛的那个策略接口定义成concept,把第一个策略实现改成模板参数,跑通后再逐步扩散。这个循序渐进的做法,比一次性推翻重来稳妥得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进