写C++的同学,十有八九在面试或书里见过装饰器模式:一个抽象基类、一个具体实现类、再用一个包装类层层嵌套,给对象叠加功能。教科书把这套流程画得明明白白,可真放到C++工程里,很多人会发现一个尴尬的问题——为了给一个函数加日志,我得先写一个Decorator基类、再写一个派生装饰器类,还要处理unique_ptr不能拷贝的毛病,代码量比业务功能本身还大。于是这几年,我在项目里开始有意识地尝试装饰器模式的各种变体:用std::function包的、用模板参数包的、用CRTP做静态装饰的,各有各的脾气。这篇文章就是把这些变体整理一遍,结合可编译的示例代码,讲清楚每种方案适合什么场景、性能上有什么取舍,再把实际踩过的坑一并列出来。适合已经掌握C++基础、想在工程里真正用上设计模式的同学,也适合准备技术面试、想从“背八股”进阶到“讲得清为什么”的人。
1. 装饰器模式的本质与适用场景
1.1 从“给对象加功能”说起
装饰器模式的核心思路一句话就能讲完:不修改原对象代码,给对象动态添加新职责。它和继承解决的是两个维度的问题。继承是在类型层面扩展能力,比如从“鸟”派生出“企鹅”,子类会永远带上企鹅的基因。装饰器则是在实例层面叠加能力,就像给一部手机套上防摔壳、再贴一张镜头膜——手机还是那部手机,但多了防摔和镜头保护的功能,而且壳可以随时换。
我用个生活化类比。假设你开了一家奶茶店,核心产品是“珍珠奶茶”,现在要支持加冰、加糖、加奶盖。如果只用继承,你得搞出“加冰珍珠奶茶”“加糖珍珠奶茶”“加冰加糖加奶盖珍珠奶茶”……类会爆炸。如果用装饰器,核心奶茶类不变,外面套一层“加冰”装饰器、再套一层“加奶盖”装饰器,组合方式由运行时的调用顺序决定。这是装饰器模式最本质的优势:开闭原则——对扩展开放,对修改封闭。
在实际代码里,装饰器必须满足两个条件:第一,和原对象实现同一个抽象接口,这样装饰完还能当原对象用;第二,内部持有被装饰对象的引用或指针,调用时先做前置逻辑,再调原对象,最后做后置逻辑。
1.2 为什么C++里会有这么多变体
C++特殊的地方在于,它同时拥有虚函数多态、模板、std::function、宏这好几套“武器”,而它们都能实现装饰语义,只是取舍完全不同。经典设计模式书里的方案是面向Java/C++通用的继承式写法,强调运行期动态组合;模板和CRTP则把装饰链“压”进编译期,追求零额外开销;std::function变体用函数和高阶函数代替类和接口,代码短到不像在写设计模式。
不同变体的适用边界差别很大,我带过的很多初级同事最容易犯的错就是把继承式装饰器写进所有地方:一个业务包了七八层装饰器,类定义堆了快一百行,为的是给一个只有十来行的函数加个耗时统计。这类场景用std::function变体三五行就解决了。反过来,如果要在服务器框架里做通用中间件,允许使用方在运行期任意组合装饰链,那继承式反而最合适,因为模板式在运行期根本没法“换壳”。
先给一张总览表,后面每一章会展开讲:
| 变体 | 运行期动态性 | 调用开销 | 代码量 | 典型场景 |
|---|---|---|---|---|
| 继承式 | 高,可任意组合 | 每层虚函数跳转 | 高,每功能一个类 | 架构级中间件、插件链 |
| std::function式 | 中,类型擦除 | 内部间接调用+SBO | 低,函数即装饰器 | 轻量中间件、数据流水线 |
| 模板/CRTP式 | 低,编译期固定 | 零额外跳转 | 中 | 协议栈、固定装饰链 |
| 函数指针式 | 中 | 很低,但要手动传上下文 | 低 | C接口回调链,基本被前者替代 |
2. 经典继承式装饰器:教科书方案的成与败
2.1 教科书式实现回顾
先把经典写法完整过一遍。假设有一个HTTP请求处理流程,核心逻辑处理业务,外层需要打印日志:
#include <iostream> #include <memory> #include <string> class HttpRequestHandler { public: virtual ~HttpRequestHandler() = default; virtual std::string handle(const std::string& request) = 0; }; class CoreHandler : public HttpRequestHandler { public: std::string handle(const std::string& request) override { return "core result of " + request; } }; // 装饰器基类:持有一个被包装对象 class HandlerDecorator : public HttpRequestHandler { protected: std::unique_ptr<HttpRequestHandler> wrapped_; public: explicit HandlerDecorator(std::unique_ptr<HttpRequestHandler> wrapped) : wrapped_(std::move(wrapped)) {} }; // 具体装饰器:打印日志 class LoggingDecorator : public HandlerDecorator { public: using HandlerDecorator::HandlerDecorator; std::string handle(const std::string& request) override { std::cout << "[LOG] before: " << request << std::endl; auto response = wrapped_->handle(request); std::cout << "[LOG] after: " << response << std::endl; return response; } }; int main() { std::unique_ptr<HttpRequestHandler> handler = std::make_unique<LoggingDecorator>( std::make_unique<CoreHandler>()); std::string r = handler->handle("GET /user"); std::cout << "final: " << r << std::endl; return 0; }这是最正统的写法:抽象接口定义行为契约;HandlerDecorator负责持有被包装对象;LoggingDecorator在调用前后插入逻辑。注意LoggingDecorator自己并没有调用wrapped_->handle()之外的实现,它全部功能都建立在转发调用之上。这个转发动作是整个模式的关键。
2.2 继承式方案的三个痛点
痛点一:类数量爆炸。每加一个关注点(日志、重试、限流、超时熔断)就要写一个新的派生装饰器类。假设一套服务要组合“日志+限流+缓存+降级”,哪怕每个装饰器只有十几行,也要维护四个类,加上基类就是六个。真正让维护者头疼的是,四个装饰器之间往往还有隐性的顺序依赖,代码一多,新人根本不敢乱动。
痛点二:虚函数调用不是免费的。C++的虚函数调用本质上是一次间接跳转,CPU分支预测还要参与,在现代CPU上单次开销很小,但如果一条装饰链套了四五层,每次请求要经过四五次虚函数跳转,高频路径上这个成本会被放大。我在一个网关服务里做过实测:五六层装饰器把单次请求的耗时增加了几百纳秒。对大多数业务来说几百纳秒可以忽略,但对每秒钟处理几十万请求的系统,这点时间累积起来就是实打实的容量损耗。
痛点三:拷贝和生命周期管理特别费劲。std::unique_ptr不可拷贝,装饰器对象一旦包进去就不能原样复制;真要做深拷贝,还得为每个装饰器手写clone()虚函数。更揪心的是,如果wrapped_存的是裸指针或裸引用,对象被提前释放后再次调用,就会触发经典的access violation C0000005访问冲突。这个问题后面专门开一节讲。
3. std::function装饰器:现代C++的轻量变体
3.1 从继承到函数包装的思路转变
如果装饰链处理的场景比较单一——包装一个函数、入参出参固定——那完全没有必要维护整套OOP接口。思路可以彻底转变:被装饰对象用std::function表示,装饰器做成一个高阶函数,输入一个函数、输出一个新的函数。这个思路其实和Web框架里的“中间件”“洋葱圈模型”同源,只是很多C++开发者没意识到设计模式和中间件本质是同一件事。
这样做的好处很直接:不用再定义抽象接口,不用写基类,甚至不用在意原来那个函数是普通函数、lambda还是可调用对象,只要是能调用的东西,就能被装饰。换句话说,装饰逻辑从类层级转移到了函数组合层面。
3.2 用lambda实现日志与计时装饰器
直接看代码,这一段是整套方案里最实用的部分:
#include <functional> #include <iostream> #include <chrono> #include <utility> using Handler = std::function<int(int)>; Handler with_logging(Handler next) { return [next = std::move(next)](int input) { std::cout << "[LOG] before: " << input << std::endl; int out = next(input); std::cout << "[LOG] after: " << out << std::endl; return out; }; } Handler with_timing(Handler next) { return [next = std::move(next)](int input) { auto start = std::chrono::steady_clock::now(); int out = next(input); auto end = std::chrono::steady_clock::now(); std::cout << "[TIME] cost " << std::chrono::duration<double, std::milli>(end - start).count() << " ms" << std::endl; return out; }; } int core(int x) { return x * 2 + 1; } int main() { Handler h = core; h = with_logging(std::move(h)); h = with_timing(std::move(h)); std::cout << "result: " << h(3) << std::endl; return 0; }这段代码有几个细节值得注意。第一,捕获next时用了std::move(next)而不是直接拷贝,因为std::function可能持有较大的可调用对象,值捕获会产生额外拷贝。第二,传播顺序是反直觉的:先调用with_logging、再调用with_timing,真正执行时却是计时在最外层、日志在内层。所以装饰链的书写顺序和执行顺序是“先写后包”,和经典继承式里从内到外的构造顺序一致。第三,裸函数core可以直接赋给std::function,这里存在一次隐式转换,允许你把一个函数无缝变成装饰链的起点。
3.3 为什么std::function变体能少写一半代码
对比第2章的类族和这一章的装饰器函数,代码量差距是一目了然的。经典方案维护至少三个类,std::function方案每个关注点只需要一个函数模板三五行代码。核心原因在于,OOP接口把“行为”和“类型”绑定死了,而函数式组合把“行为”提升为一等公民——装饰行为本身就是一个带捕获的lambda,不需要新建类型来表达。
代价也有三个。第一,std::function内部有类型擦除,每次调用多一跳,同时为了保证小对象性能,标准库实现通常会做小缓冲区优化(SBO),但缓冲区大小因库而异:libstdc++大约16字节、libc++约24字节、MSVC约64字节。如果lambda捕获了较大的状态(比如一个有几万条数据的unordered_map),std::function会退化为堆分配,性能反而比虚函数方案更差。第二,类型擦除后调试时看类型名是一长串std::_Function_handler<...>,配合某些编辑器跳转经常找不到实现,排查问题体验不佳。第三,如果入参或出参发生变化,错误的暴露点通常在编译期,IDE的智能提示很难提前帮忙兜住。
所以我的结论是:std::function变体适合“装饰链短、关注点清晰、状态量不大”的场景。一旦发现某个lambda捕获的对象已经让std::function开始堆分配,并且这条链处在高频路径上,就需要考虑模板式或恢复继承式。
4. 模板与CRTP:零开销的编译期装饰
4.1 静态装饰的思路与实现
继承式和std::function式都有一个共同点:在运行期通过间接调用(虚函数或类型擦除)解耦。如果装饰链本身固定不变,这种动态性纯粹是白交学费。模板思路则完全不同:把被装饰类作为模板参数,编译期直接展开所有调用,零虚函数跳转、零类型擦除。
看一个静态装饰的例子:
#include <chrono> #include <iostream> #include <thread> template <typename Base> class TimedDecorator : public Base { public: using Base::Base; // 继承构造函数 int compute(int x) const { auto start = std::chrono::steady_clock::now(); int result = Base::compute(x); auto end = std::chrono::steady_clock::now(); std::cout << "[TIME] " << std::chrono::duration<double, std::milli>(end - start).count() << " ms" << std::endl; return result; } }; class HardWorker { public: int compute(int x) const { std::this_thread::sleep_for(std::chrono::milliseconds(x)); return x * 3; } }; int main() { TimedDecorator<HardWorker> worker; worker.compute(2); return 0; }这里TimedDecorator<HardWorker>继承了HardWorker,在派生类里重写compute()逻辑。注意HardWorker::compute()不是虚函数,但因为模板继承发生在编译期,编译器能直接解析到正确的Base::compute(x),调用链在编译期就完全确定下来了。优点是性能好到几乎没有额外成本;缺点是整个装饰链的类型是固定的,想换组合方式必须换类型,本质上是“编译期策略”。
在实际工程里,我发现这种静态装饰非常适合协议处理、编解码流水线这类场景。比如把“数据压缩——加密——日志统计”固定成一个Pipeline类型,组件之间调用关系永远不变,不存在运行期动态插拔的需求。
4.2 CRTP装饰器的适用边界
设个纠结点:严格意义上的CRTP(奇异递归模板模式)是class Derived : public Base<Derived>,目的让基类通过static_cast<Derived*>(this)使用派生类的实现,通常用于静态多态。而上面的TimedDecorator<HardWorker>是“模板包装器”,和经典CRTP形态不完全一样。但在社区实践里,大家往往把这两种都属于“编译期装饰/静态装饰”的策略放一起讨论,因为它们解决的是同一类问题:在编译期决定装饰关系。
CRTP风格的实际代码通常是这样的:
template <typename Derived> class LoggingBase { public: void log(const std::string& msg) const { std::cout << "[LOG] " << msg << std::endl; static_cast<const Derived*>(this)->do_log(msg); } }; class Service : public LoggingBase<Service> { public: void do_log(const std::string& msg) const { // 具体日志实现,比如写文件、上报监控 } };这种静态多态的好处是,日志框架和业务逻辑可以在编译期贴合起来,不存在虚函数跳转,同时LoggingBase也能访问派生类的方法,实现了“基类调用派生类”的逆调用。但它的局限也很明显:一旦用了CRTP,装饰关系就被焊死在类型系统里,你没法在运行期把LoggingBase<Service>换成MetricsBase<Service>,除非你重新写一个组合类型。
这里给一个实用建议:当装饰链在编译期完全确定、并且对性能有硬要求时,优先用模板式;当需要在运行期根据配置切换装饰时,模板式不适合,老老实实回到std::function或继承式。不要试图把模板式塞进一个std::vector去动态管理——除非你愿意付出std::any或std::function的类型擦除开销,那就又回到上一章的起点了。模板式还有一个配套的甜点:可以用别名模板把装饰链封装成类型,提升可读性:
template <typename Core> using FullStack = CompressionDecorator<TimedDecorator<RetryDecorator<Core>>>; FullStack<HardWorker> worker; // 整个装饰链变成一个类型5. 组合链与实战场景
5.1 缓存、重试与权限校验:三个高频实战
装饰器模式在真实项目里最常见的使用场景就是给“核心业务流程”加横切关注点。我挑三个高频的举例子。
第一个是缓存装饰器。当被装饰函数的计算结果对同一入参是确定性的,用缓存可以直接拦截重复计算:
#include <unordered_map> #include <memory> Handler with_cache(Handler next, size_t capacity_hint = 128) { auto cache = std::make_shared<std::unordered_map<int, int>>(); cache->reserve(capacity_hint); return [next = std::move(next), cache](int x) { auto it = cache->find(x); if (it != cache->end()) { return it->second; } int v = next(x); cache->emplace(x, v); return v; }; }注意变量捕获:这里把cache用shared_ptr包起来,再捕捕获到lambda内部,以保证lambda可拷贝也能安全共享缓存。这个装饰器的陷阱也很明显:缓存无限增长,程序跑几个小时内存就可能飙升。生产环境必须配LRU或过期策略,不能直接用unordered_map裸奔,这一点我在项目里吃过亏。同时如果缓存对象被多个线程共享,unordered_map的并发读写会出问题,需要加锁或换用并发容器。
第二个是重试装饰器。网络请求、外部服务调用的失败概率不低,把重试逻辑做成装饰器,可以让业务代码保持干净:
Handler with_retry(Handler next, int times = 3) { return [next = std::move(next), times](int x) { for (int i = 0; i < times; ++i) { try { return next(x); } catch (const std::exception& e) { std::cout << "[RETRY] attempt " << i + 1 << " failed: " << e.what() << std::endl; if (i == times - 1) { throw; // 最后一次失败,原样上抛 } } } return int{}; // 不可达,但需要满足编译路径 }; }这个装饰器的核心是控制流:它内部有循环,异常时继续调用下一个next,正常时立即返回。在实际使用中要小心“重试是否应该对入参变化敏感”:如果被装饰函数不是纯函数,重试两次返回的结果可能不同,缓存和重试之间的顺序就必须格外设计。
第三个是权限校验装饰器。服务端框架里,每个接口的鉴权逻辑已经深入人心,用装饰器可以把“验token”和“查权限”从业务逻辑里剥离出来:
using AuthHandler = std::function<std::string(const Request&)>; AuthHandler with_authorization(AuthHandler next) { return [next = std::move(next)](const Request& req) { if (!req.has_valid_token()) { return std::string("403 Forbidden"); } return next(req); }; }这类装饰器的价值在于,它把安全横切逻辑集中在装饰器里,业务开发者写核心逻辑时完全不需要理会鉴权细节,后期加权限策略也只需要替换装饰器实现。
5.2 如何组装一条装饰链
实战里最关键的不是单个装饰器怎么实现,而是装饰链的组装顺序。同样一组装饰器,顺序不同,行为完全不同。以“日志——重试——缓存”这套组合为例,两种顺序效果如下:
把with_cache包在with_retry外层:
auto pipeline = with_cache(with_retry(core_handler, 3), 128);在这种顺序下,缓存是整个链的最外层。第一次调用如果缓存未命中,会触发重试逻辑;重试内部的结果返回后,因为缓存感知不到重试内部发生了什么,它只会把最终结果缓存下来。如果重试期间发生了临时异常、最后一次才成功,那缓存会保存成功的值,这个语义是可以接受的。但问题是:重试失败的中间尝试是发生在缓存之内的,也就是说缓存无法拦截“注定失败”的调用,这会让重试次数真正被消耗掉,而且第一次失败后缓存里不会有失败记录,下一次调用还会再重试一遍。
把顺序反过来:
auto pipeline = with_retry(with_cache(core_handler, 128), 3);缓存包在重试内层。每次重试时,缓存会先检查入参;如果入参是稳定的,第二次重试会直接命中缓存,等于重试逻辑里多了一个快速通道。这个顺序更适合“入参稳定、怕重复计算”的场景。
我给一条经验法则:越通用的关注点越靠外,越具功能性的关注点越靠内。日志、指标采集这类几乎不会影响正确性的装饰器放最外层;缓存、限流这类会改变调用频次的放中层;真正的核心业务逻辑在最底层。实际项目我还会在装饰链最外层加一个总入口,统一处理异常转为错误码,避免每一层都要重复写try-catch。
6. 踩坑记录与排查技巧
6.1 悬空引用与访问冲突
装饰器模式在C++里最经典的崩溃场景,我排第一的一定是悬空引用。样板长这样:
Handler make_broken() { int local = 42; return [&local](int x) { return local + x; // local 在函数返回后就没了 }; }这里&local捕获的是栈上的局部变量,make_broken返回时local立刻失效,但装饰器本体还在。等真正调用的时候,访问到的地址已经被别的数据覆盖,轻则返回一个随机值,重则直接访问冲突,Windows上报错就是access violation C0000005。
我在帮同事排查一个C#调用C++导出函数的崩溃时,现象是导出函数内部逻辑正常,但只要走到某个装饰器链就报0xC0000005。最后定位到的问题本质上也是生命周期:C++侧返回了一个持有裸指针的装饰器链,C#侧把它当成托管对象管理,自动释放后C++侧再次调用,指针已经是野指针了。这种跨界崩溃如果不用调试器去抓完全无从下手。
排查技巧有三个:第一,审代码时把每个捕获&的lambda都过一遍,确认捕获的对象的生命周期严格覆盖std::function的存活期;第二,调试阶段用AddressSanitizer跑一遍,use-after-scope直接就能报出来;第三,出问题时看调用栈,如果栈底是std::_Function_handler或__CxxFrameHandler这类内部符号,基本可以确定问题出在类型擦除或生命周期。
6.2 模板装饰器的编译期坑与vscode排查
模板装饰器有一类特有的坑:不同模板参数实例化出来的类型是不同类型。这意味着你没法写std::vector<TimedDecorator<HardWorker>>来同时装TimedDecorator<WorkerA>和TimedDecorator<WorkerB>。解决思路是类型擦除——把装饰链的最终对象统一转成std::function或std::any。但要注意,这一转就把模板式省下来的性能全还回去了。所以模板式装饰器通常停留在编译期固定链内,不做动态收集。
另一个常见坑是参数转发。装饰器模板要调用基类构造函数时,漏写std::forward会让所有参数都变成左值,导致原本能匹配的右值引用构造函数熄火。稳妥写法是提供一个模板构造函数:
template <typename Derived> class WrapperDecorator : public Derived { public: template <typename... Args> explicit WrapperDecorator(Args&&... args) : Derived(std::forward<Args>(args)...) {} };别小看这几行,它能防止你把参数转发写错方向。
关于vscode排查,这是我在Windows和Linux上写模板装饰器时最常遇到的问题:函数、变量无法跳转定义,点半天弹不出结果。这通常不是代码问题,而是IntelliSense的includePath没配全,或者没有compile_commands.json。最省事的解法是用CMake生成编译数据库:
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON然后在vscode的C/C++插件设置里把compile_commands.json路径指过去。模板装饰器还有个天然劣势:因为要在编译期实例化,很多符号要到实例化点才存在,跳转不准确是常态,不必纠结。
6.3 性能取舍速查表
把几种变体的性能特性整理成一张表,方便选型时快速对照:
| 变体 | 每层调用开销 | 额外内存 | 运行期重排装饰器 | 适合场景 |
|---|---|---|---|---|
| 继承式 | 一次虚函数间接跳转 | 虚表指针 | 可以 | 架构级中间件、插件 |
| std::function式 | 内部间接调用+SBO或堆分配 | 捕获状态 | 可以 | 轻量中间件、流水线 |
| 模板/CRTP式 | 零额外间接跳转 | 零额外 | 不可 | 编译期固定链、性能敏感 |
我在本地做过一个简单基准:三层std::function装饰链的调用开销大约是三层虚函数装饰链的80%左右(编译器开O2后两者接近),但如果某个lambda捕获了较大的对象导致std::function堆分配,单层调用可能反而比虚函数慢50%以上。模板式在三者里通常最快,但它的快是以失去动态性换来的。大多数实际业务场景下,装饰器链外的网络I/O和锁竞争才是绝对大头,“快”在这里没多大意义,按可维护性选型永远优先。只有当profiler明确告诉你装饰器链本身是热点时,才值得把std::function换成模板静态化。
最后分享一个我在项目里的默认建议:业务代码里的装饰链,默认用std::function变体,它最省事、最好读,lambda把装饰逻辑圈在一个小范围内,不需要跳文件。等装饰器的数量增长到十几个、或者需要做运行期配置时,再转而用继承式建一套中间件框架。模板式留给性能敏感且组合固定的热路径。想清楚“你要的是运行期自由组合还是编译期零开销”,选型就不纠结了。