最近在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_ptr<DataSource> inner_; public: explicit Decorator(std::shared_ptr<DataSource> inner) : inner_(std::move(inner)) {} std::string fetch(int id) override { return inner_->fetch(id); } }; class CacheDecorator : public Decorator { std::unordered_map<int, 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_shared<DbSource>(); auto cached = std::make_shared<CacheDecorator>(source); auto logged = std::make_shared<LogDecorator>(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 = LoggingDecorator<Worker>; using LoggedTimedWorker = TimingDecorator<LoggingDecorator<Worker>>;这里的关键是TimingDecorator<LoggingDecorator >这个模板参数展开。你可以理解成一层套一层,每一层都把所有父类继承过来,然后在自己的方法里先做横切逻辑、再调用父类实现,跟运行时装饰器要转发给inner_的工作完全一样,但这里没有虚函数,没有任何智能指针,也没运行时开销。
3.2 静态装饰的性能优势和应用前提
静态装饰最爽的点是零成本抽象。调用LoggedTimedWorker::doWork时,编译完就是一个直接的函数调用序列,中间没有间接跳转,没有引用计数操作,栈上对象也不涉及堆分配。在性能敏感路径上,比如高频的算法核心循环、网络协议栈每包处理,我用CRTP装饰器都拿过明显的性能收益。
更妙的是,CRTP装饰器本身可以继续作为被装饰类传给上一层。想组合多少层都可以,只要你自己别把类型写得嵌套到晕。
但它的限制也极其明显:装饰后的类型必须留存在具体类型中。你想在容器里放一堆不同装饰组合的对象,或者在运行期根据配置决定装饰顺序,CRTP完全办不到。常见做法是再套一个虚函数接口来擦除类型,但那样一来CRTP的性能优势也没了。所以这个方案只适合那些“装饰链已经稳定下来,不会在运行期变化”的场景。
3.3 使用CRTP容易忽略的继承细节
有个细节容易翻车:CRTP类重写方法时,必须确认被继承的Base里对应方法不是private。我去GitHub上翻过一些开源项目,有人把基类方法写成private,然后装饰器里调用Base::doWork(),编译直接报“cannot access private member”。另外,如果被装饰类的某些状态需要构造参数,CRTP装饰器需要透传构造参数,代码写起来比较啰嗦,我通常会用C++17的构造函数模板加完美转发来解决,但可读性会下降,建议写的时候加注释说明每一层的构造参数是什么。
4. 函数级装饰:lambda、std::function 与类型擦除的轻量方案
4.1 用lambda包装函数,最轻量的“装饰器模式变体”
前面聊的都是装饰“对象”,但现实中你经常只是想给某个成员函数或自由函数加一层埋点、加错误重试、加超时控制。在这种场景强行抽象类体系,过度设计。函数级装饰器就是为这个而生的,本质上是一个高阶函数:输入一个函数,输出一个新函数,新函数内部先做自己的事,再调用原函数。
template <typename F> auto with_logging(F&& f) { return [f = std::forward<F>(f)](auto&&... args) -> decltype(auto) { std::cout << "call begin\n"; if constexpr (std::is_void_v<std::invoke_result_t<F&, decltype(args)...>>) { f(std::forward<decltype(args)>(args)...); std::cout << "call end\n"; } else { decltype(auto) result = f(std::forward<decltype(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::function<int(int)> make_cached(std::function<int(int)> fn) { auto cache = std::make_shared<std::unordered_map<int, 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::function<int(int)>能容纳的任何可调用对象,都能包进去。代价就是std::function本身会引入一次类型擦除,调用时有额外的间接开销,堆分配也可能发生。不过对于大多数应用层代码,这点开销在可接受范围。
值得一提的还有C++23新增的std::move_only_function,它支持只移动不可拷贝的可调用对象。比如某个lambda捕获了unique_ptr,以前只能塞进std::function的坑位,现在用move_only_function就能装上。如果你的项目已经切到C++23,建议优先考虑。
4.3 一个实用的重试装饰器示例
装饰器还能做成通用可复用的“重试”组件。这算是我项目里用得最多的函数级装饰器之一:
template <typename F> auto with_retry(F&& f, int max_retry = 3) { return [f = std::forward<F>(f), max_retry](auto&&... args) -> decltype(auto) { int last_error = 0; for (int attempt = 0; attempt < max_retry; ++attempt) { try { return f(std::forward<decltype(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”。这个额外装饰器上线前删除即可。有了它,你在调试装饰器模式的时候,能省下大量脑细胞去验证谁先执行、谁后执行的问题。