ARTICLE DETAIL

资讯详情

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

C++装饰器模式三种变体:CRTP、虚函数与lambda实战

C++装饰器模式三种变体:CRTP、虚函数与lambda实战 最近在C项目里把装饰器模式几种变体都折腾了一遍先把结论放在前面C的装饰器模式变体本质上都是在解决同一个问题——不改原有类的前提下用包装的方式动态叠加行为。但C不像那些带动态代理的语言值语义、静态类型、虚函数开销这三个问题凑在一起直接决定了你实际会写出好几种风格完全不同的装饰器。网上讲到装饰器模式绝大多数例子都是GoF那套UML图配一个Java伪代码放到C工程里照着写十有八九会踩所有权、对象切片、调用顺序这些坑。这篇文章把我在真实代码里用过的三种装饰器模式变体整理出来分别是运行时虚函数包装、编译期CRTP模板包装、函数级lambda包装每种都给了能直接抄走的示例和选型建议。适合正在设计C组件接口、被重复埋点和日志搞烦、或者想在性能敏感代码里避免虚函数开销的人参考。1. 装饰器模式在C里到底别扭在哪先说说为什么C里的装饰器模式会被折腾出各种“变体”。经典装饰器模式的核心套路很简单一个组件接口一个具体组件再加一个装饰器类装饰器持有组件对象并实现同样的接口。调用时先执行装饰逻辑再转发给内部对象。这个套路在C里落地时会遇到三个绕不过去的矛盾。第一个矛盾是静态类型系统和运行时动态组合之间的错位。装饰器模式的价值在于可以在运行期“组装”出不同行为组合比如今天要日志加缓存明天只要日志后天换成缓存加权限校验。C没有内建的动态行为注入机制类型在编译期就定死了你想让一个对象在运行期“长出”新能力只能靠包装指针或者包装模板参数这直接导致实现风格分裂成运行时和编译时两派。第二个矛盾是值语义。C里对象默认按值传递、按值拷贝而装饰器模式天然需要引用或指针来串联对象。如果没想清楚所有权很容易写出一个返回局部对象的函数返回时发生对象切片装饰器白写。很多刚接触C装饰器的人第一个崩溃现场就是这里明明包了一层调用的还是原对象的方法。第三个矛盾是性能取舍。经典虚函数装饰器每层调用都有一次虚函数间接跳转三五个装饰器叠下来热点路径上浪费不少。但如果为了性能全上模板又会遇到类型爆炸、编译时间变长、无法在容器里统一持有各种装饰后类型的问题。所以C里谈装饰器模式关键不是背UML而是搞清楚你处在什么约束下。我的经验是需要运行期动态组合就老老实实用虚函数加智能指针需要在编译期确定行为、对性能敏感就上CRTP模板只是给某个具体函数加点横切逻辑lambda包装是最划算的。下面依次展开说。2. 运行时装饰虚函数 智能指针的经典组合2.1 一个可直接跑的最小实现先看最经典的运行时装饰器。假设我现在有一个数据访问对象接口是按下拉一个字符串底层从数据库读我想在它外面加一层内存缓存#include memory #include string #include unordered_map class DataSource { public: virtual ~DataSource() default; virtual std::string fetch(int id) 0; }; class DbSource : public DataSource { public: std::string fetch(int id) override { // 这里模拟数据库查询实际项目里换成真实 IO return db: std::to_string(id); } }; class Decorator : public DataSource { protected: std::shared_ptrDataSource inner_; public: explicit Decorator(std::shared_ptrDataSource inner) : inner_(std::move(inner)) {} std::string fetch(int id) override { return inner_-fetch(id); } }; class CacheDecorator : public Decorator { std::unordered_mapint, std::string cache_; public: using Decorator::Decorator; std::string fetch(int id) override { auto it cache_.find(id); if (it ! cache_.end()) { return it-second; } auto value inner_-fetch(id); cache_[id] value; return value; } }; class LogDecorator : public Decorator { public: using Decorator::Decorator; std::string fetch(int id) override { // 模拟埋点日志 auto value inner_-fetch(id); return value; } };使用时拼接起来auto source std::make_sharedDbSource(); auto cached std::make_sharedCacheDecorator(source); auto logged std::make_sharedLogDecorator(cached); std::string result logged-fetch(42);这段代码本身没什么高深的地方真正值得说的是三个设计决策。2.2 为什么我用shared_ptr而不是unique_ptr装饰器链在构造时每一层都需要持有下一层而且外部往往还要保留最外层的句柄。很多场景下最内层的DbSource并没有第二个持有者用unique_ptr和裸指针也可以实现还更省。我选择shared_ptr主要看重两点一是移动语义在某些中间环节会把链搞乱现场排查起来费劲二是装饰器链往往还要被其它系统共享比如同一个缓存装饰器对象要同时供两个业务模块使用最内层被引用的次数根本数不清。shared_ptr的代价是引用计数的原子操作其实在大多数业务场景里可以忽略真正性能敏感的地方不会选择运行时装饰这个方案。如果非要追求极致可以用unique_ptr但要注意两点移动后原来的装饰器会变成空壳再调用就是悬空而且装饰器类内部向外传递unique_ptr时只能单向流动没法实现两个装饰器互相依赖的结构。提示无论用哪种智能指针基类析构函数必须写成virtual。否则你通过基类指针delete时析构的是基类部分派生类资源不会释放这是未定义行为。2.3 构造顺序和调用顺序相反这是最容易栽的坑上面代码里我先装了Cache再装Log调用时却是Log先执行然后进入Cache最后才到DbSource。这跟直觉正好相反越晚构造的装饰器越先被调用。很多人在构造函数里顺手串联装饰器结果发现日志打印顺序和预想完全颠倒。如果要调整行为优先级不是调整调用代码而是要调整构造顺序。想先查缓存、再记日志那就必须先构造Log装饰器再在外面包Cache。我把这条规则记在代码注释里防止下次重构时又踩一遍。运行时装饰器适合的场景是装饰链在程序运行过程中会动态改变比如根据配置文件决定要不要启用缓存或者插件系统里外部模块往里塞装饰器。这种灵活性是编译期模板方案给不了的。3. 编译期装饰CRTP 把装饰器玩成了模板游戏3.1 CRTP装饰器的基本写法如果你不需要运行期动态组装所有装饰都在编译期确定CRTP风格会清爽很多。CRTP全称是Curiously Recurring Template Pattern奇异递归模板模式。思路是把被装饰类作为模板参数传给装饰器基类装饰器继承这个模板参数然后重写关键方法。#include iostream class Worker { public: void doWork() { std::cout core work\n; } }; template typename Base class LoggingDecorator : public Base { public: void doWork() { std::cout log begin\n; Base::doWork(); std::cout log end\n; } }; template typename Base class TimingDecorator : public Base { public: void doWork() { std::cout timing start\n; Base::doWork(); std::cout timing end\n; } }; using LoggedWorker LoggingDecoratorWorker; using LoggedTimedWorker TimingDecoratorLoggingDecoratorWorker;这里的关键是TimingDecoratorLoggingDecorator 这个模板参数展开。你可以理解成一层套一层每一层都把所有父类继承过来然后在自己的方法里先做横切逻辑、再调用父类实现跟运行时装饰器要转发给inner_的工作完全一样但这里没有虚函数没有任何智能指针也没运行时开销。3.2 静态装饰的性能优势和应用前提静态装饰最爽的点是零成本抽象。调用LoggedTimedWorker::doWork时编译完就是一个直接的函数调用序列中间没有间接跳转没有引用计数操作栈上对象也不涉及堆分配。在性能敏感路径上比如高频的算法核心循环、网络协议栈每包处理我用CRTP装饰器都拿过明显的性能收益。更妙的是CRTP装饰器本身可以继续作为被装饰类传给上一层。想组合多少层都可以只要你自己别把类型写得嵌套到晕。但它的限制也极其明显装饰后的类型必须留存在具体类型中。你想在容器里放一堆不同装饰组合的对象或者在运行期根据配置决定装饰顺序CRTP完全办不到。常见做法是再套一个虚函数接口来擦除类型但那样一来CRTP的性能优势也没了。所以这个方案只适合那些“装饰链已经稳定下来不会在运行期变化”的场景。3.3 使用CRTP容易忽略的继承细节有个细节容易翻车CRTP类重写方法时必须确认被继承的Base里对应方法不是private。我去GitHub上翻过一些开源项目有人把基类方法写成private然后装饰器里调用Base::doWork()编译直接报“cannot access private member”。另外如果被装饰类的某些状态需要构造参数CRTP装饰器需要透传构造参数代码写起来比较啰嗦我通常会用C17的构造函数模板加完美转发来解决但可读性会下降建议写的时候加注释说明每一层的构造参数是什么。4. 函数级装饰lambda、std::function 与类型擦除的轻量方案4.1 用lambda包装函数最轻量的“装饰器模式变体”前面聊的都是装饰“对象”但现实中你经常只是想给某个成员函数或自由函数加一层埋点、加错误重试、加超时控制。在这种场景强行抽象类体系过度设计。函数级装饰器就是为这个而生的本质上是一个高阶函数输入一个函数输出一个新函数新函数内部先做自己的事再调用原函数。template typename F auto with_logging(F f) { return [f std::forwardF(f)](auto... args) - decltype(auto) { std::cout call begin\n; if constexpr (std::is_void_vstd::invoke_result_tF, decltype(args)...) { f(std::forwarddecltype(args)(args)...); std::cout call end\n; } else { decltype(auto) result f(std::forwarddecltype(args)(args)...); std::cout call end\n; return result; } }; }这个代码里写了if constexpr双分支区分返回void和非void的情况。为什么要区分因为C不允许定义一个void类型的局部变量来接收函数返回值如果你直接写decltype(auto) result f(...)而f返回void编译器会报错。很多初学者在这个地方挂一次就放弃了其实知道原因就好解决。这种方法适用范围很广可以包装普通函数、lambda、仿函数、成员函数只要用bind或lambda捕获。我经常把它用在算法调试上。比如调试单调栈我给入栈出栈逻辑包一层with_logging每次调用都会打印当前栈顶和操作元素不用改一行算法代码。又比如网上搜“快速幂算法C”的写法时想看某次快速幂函数被调了几次、每次耗时多少直接在外层套一个计时装饰器就行。这种能力在排查线上问题时特别顺手。4.2 需要统一签名时用std::function做类型擦除lambda装饰器的问题在于装饰后的类型是一个不具名的lambda类型没法放进容器也没法作为统一接口传给上层模块。这时候需要用std::function做类型擦除。#include functional std::functionint(int) make_cached(std::functionint(int) fn) { auto cache std::make_sharedstd::unordered_mapint, int(); return [fn std::move(fn), cache](int key) { auto it cache-find(key); if (it ! cache-end()) { return it-second; } int value fn(key); cache-emplace(key, value); return value; }; }这个缓存装饰器比CRTP的版本宽容很多它不要求被装饰类型是特定类也不要求实现某个接口。只要是std::functionint(int)能容纳的任何可调用对象都能包进去。代价就是std::function本身会引入一次类型擦除调用时有额外的间接开销堆分配也可能发生。不过对于大多数应用层代码这点开销在可接受范围。值得一提的还有C23新增的std::move_only_function它支持只移动不可拷贝的可调用对象。比如某个lambda捕获了unique_ptr以前只能塞进std::function的坑位现在用move_only_function就能装上。如果你的项目已经切到C23建议优先考虑。4.3 一个实用的重试装饰器示例装饰器还能做成通用可复用的“重试”组件。这算是我项目里用得最多的函数级装饰器之一template typename F auto with_retry(F f, int max_retry 3) { return [f std::forwardF(f), max_retry](auto... args) - decltype(auto) { int last_error 0; for (int attempt 0; attempt max_retry; attempt) { try { return f(std::forwarddecltype(args)(args)...); } catch (const std::exception e) { last_error attempt 1; if (attempt max_retry - 1) { throw; } } } // 理论上走不到这里但编译器需要返回 throw std::runtime_error(unreachable); }; }把重试逻辑和业务逻辑解耦之后任何一个可能临时失败的调用外面套一层with_retry就能获得重试能力业务代码里一行脏逻辑都不用加。这种组合能力是函数式装饰器最大的价值。5. 实际项目中的几种落地形态说了这么多理论落到真实项目里到底长什么样我从自己的实践中挑几个典型场景你会发现装饰器模式变体在C工程里无处不在。5.1 日志与耗时的无侵入埋点很多业务代码埋点特别脏核心逻辑中间穿插几十条性能打点日志。用了装饰器模式之后我在算法对象和业务对象外层统一挂一个TimingDecorator或with_logging。核心算法部分保持干净装饰器独立维护。想验证“C为什么用单调栈解决某些问题”的耗时差异时把同样结构的算法函数用装饰器包起来跑benchmark代码复用率极高。这里我的建议是埋点这类横切关注点优先用函数级装饰器因为它的定位精度最高不会引入额外类层次。5.2 给高开销函数做结果缓存缓存是装饰器最经典的落地场景。我处理过一些重复计算密集的场景比如快速幂算法同一组底数和指数可能被反复计算我用CacheDecorator包了一层加了unordered_map存历史结果。改完以后接口没变调用方不需要知道底下有缓存新功能上线后面临的性能压力直接缓解。做缓存装饰器时要特别注意两个问题内存上限和失效策略。无脑缓存可能导致内存爆掉所以我在装饰器内部加了简单的容量上限超过就清掉最老的一批条目。这种策略在中小型项目里够用如果缓存需求复杂还是建议用成熟的缓存库。5.3 数据库访问层的包装和重试C里访问数据库尤其是TDengine这类时序数据库时会用到C绑定和预处理语句接口比如taos_stmt_prepare。直接调用这些API写业务代码你会发现在查询失败、需要重试、要统计执行时间的时候代码会散落到各处。我在数据访问层里习惯用一个核心执行器只负责“准备语句、绑定参数、执行、读取结果”外层再套日志装饰器、重试装饰器、耗时统计装饰器。这样一来每个横切关注点都在固定位置代码审查时一目了然。数据库连接池的获取和释放也被封装在最内层装饰器链不需要关心连接细节。用运行时装饰器做数据库层包装还有个好处不同环境开发、测试、生产可以通过配置动态决定开启哪些装饰器不需要重新编译。这在处理线上问题定位时价值很大。5.4 游戏系统里的Buff叠加看网上一些“C小游戏源码”的时候会发现装备系统、Buff系统非常容易滑向深度继承战士装备剑剑有火属性火属性剑又带吸血每多一个属性就多一个子类。这时候装饰器模式其实是更好的建模方式。比如玩家类我用一个PlayerDecorator层层包裹武器装饰器增加攻击力防具装饰器增加防御力Buff装饰器增加额外属性。每一层装饰器各自管理自己的状态组合时通过构造顺序表达叠加效果。但我也要泼一盆冷水当装饰器数量超过四五个调试难度指数上升定位究竟是哪一层出了问题会非常痛苦。我自己的项目里会规定超过三层装饰就要考虑是不是该换成事件系统或者组合组件模式。5.5 顺便说一句环境问题很多人在VSCode里配置C/C环境调试装饰器链时报错其实多半不是环境问题而是上面说的对象切片、缺少虚析构、模板推导失败这些代码问题。我的习惯是配置好断点后在装饰器基类的转发函数里打断点然后单步进入顺着调用链一层层看比看日志高效得多。微软那个C运行时库报错大概率也是堆损坏或析构顺序问题跟装饰器本身无关。6. 三种变体怎么选对照表与踩坑实录6.1 选型对照表维度运行时装饰器编译期CRTP装饰器函数级lambda装饰器叠加单位对象类型函数是否保留具体类型通过基类接口必须保留具体类型可擦除为std::function运行期动态组装支持不支持部分支持靠std::function运行时开销虚函数调用 智能指针近乎零std::function有间接开销调试难度中等看调用链即可类型信息爆炸报错难读较简单断点直观适用场景插件、动态配置、数据库封装性能热点、固定装饰链埋点、重试、缓存、超时控制没有哪个变体绝对优于另一个。我自己的习惯是能用函数级解决的不建类需要管理对象生命周期和状态时才上运行时装饰只有性能热点或编译器静态能力能带来明确收益时再用CRTP。6.2 坑一基类没有虚析构这是所有面向对象C代码的经典坑装饰器尤其容易因为多层嵌套而中招。你Delete一个基类指针如果析构函数不是virtual派生类成员根本没机会析构内存泄漏和资源泄漏就这么来的。现代C里你觉得反正有智能指针兜底但智能指针在释放时也是通过基类指针delete的一样绕不过虚析构。所以写装饰器基类第一条规则就是virtual ~Decorator() default。6.3 坑二装饰顺序与调用顺序正好相反前面提到过我再重复一遍因为这是我被问得最多的一个问题。很多人以为装了Log再装Cache调用时也应该先Log再Cache实际刚好反过来。如果你想先记录日志再去查缓存构造顺序应该是CacheDecorator包住LogDecorator。这个坑在运行时装饰器里最明显在CRTP里表现为模板参数展开顺序在函数式装饰器里表现为外层lambda先执行。6.4 坑三shared_ptr循环引用导致内存泄漏我写过一段缓存装饰器内部持有shared_ptr 同时缓存对象又被外部共享结果缓存对象永远释放不了。原因是缓存装饰器的引用计数互相咬住形成了环。解决办法要么把最外层改成unique_ptr要么把内层引用改成weak_ptr要么明确“最内层对象不被智能指针共享只在链内存在”。6.5 坑四过度装饰让代码难以理解装饰器模式确实很优雅但不要看见什么都往装饰器上套。我曾经把一个简单的读取文件功能拆成日志、加解密、压缩、校验四层装饰器结果任何一个函数调用都像剥洋葱排查问题时让人抓狂。后来我拆掉压缩装饰器把它挪进核心实现内部整个系统反而更易维护。装饰器链超过三到四层时你就应该停下来想想是不是该重新划分职责了。6.6 坑五CRTP类型爆炸与编译器报错难读CRTP模板嵌套几十层之后稍微写错一点编译器会吐出几千行乱七八糟的类型错误。这种情况下我的办法是尽量把装饰器做成“同构”的也就是都实现同名接口减少转发层的数量另外每层装饰器单独用一个using别名类型名取得短一点、语义明确一点至少报错时能看出来是哪一层出了问题。7. 我个人在实际项目里的选型思路如果再让我重新设计一遍C项目的装饰器模块我大概率会这么做先明确装饰的对象是“类”还是“函数”。大多数埋点、重试、缓存其实作用在函数级别直接写lambda包装器简单到不需要开类。只有需要共享状态、生命周期复杂、多个装饰器之间要互相传递数据时才升级成运行时装饰器。编译期CRTP装饰器我只会用在那些被调用百万次、明确不许有额外开销的热点路径上。最后分享一个小技巧装饰器链很长时我会在调试阶段额外加一个空壳调试装饰器专门负责打印整条调用链的路由。每层进入时打一行“enter decorator X”出来时打一行“leave decorator X”。这个额外装饰器上线前删除即可。有了它你在调试装饰器模式的时候能省下大量脑细胞去验证谁先执行、谁后执行的问题。
返回列表