资讯详情

资讯详情

C++装饰器模式实战:从继承到std::function与CRTP的变体与取舍

写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_ptrHttpRequestHandler wrapped_; public: explicit HandlerDecorator(std::unique_ptrHttpRequestHandler 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_ptrHttpRequestHandler handler std::make_uniqueLoggingDecorator( std::make_uniqueCoreHandler()); 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::functionint(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::durationdouble, 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_mapstd::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::durationdouble, 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() { TimedDecoratorHardWorker worker; worker.compute(2); return 0; }这里TimedDecoratorHardWorker继承了HardWorker在派生类里重写compute()逻辑。注意HardWorker::compute()不是虚函数但因为模板继承发生在编译期编译器能直接解析到正确的Base::compute(x)调用链在编译期就完全确定下来了。优点是性能好到几乎没有额外成本缺点是整个装饰链的类型是固定的想换组合方式必须换类型本质上是“编译期策略”。在实际工程里我发现这种静态装饰非常适合协议处理、编解码流水线这类场景。比如把“数据压缩——加密——日志统计”固定成一个Pipeline类型组件之间调用关系永远不变不存在运行期动态插拔的需求。4.2 CRTP装饰器的适用边界设个纠结点严格意义上的CRTP奇异递归模板模式是class Derived : public BaseDerived目的让基类通过static_castDerived*(this)使用派生类的实现通常用于静态多态。而上面的TimedDecoratorHardWorker是“模板包装器”和经典CRTP形态不完全一样。但在社区实践里大家往往把这两种都属于“编译期装饰/静态装饰”的策略放一起讨论因为它们解决的是同一类问题在编译期决定装饰关系。CRTP风格的实际代码通常是这样的template typename Derived class LoggingBase { public: void log(const std::string msg) const { std::cout [LOG] msg std::endl; static_castconst Derived*(this)-do_log(msg); } }; class Service : public LoggingBaseService { public: void do_log(const std::string msg) const { // 具体日志实现比如写文件、上报监控 } };这种静态多态的好处是日志框架和业务逻辑可以在编译期贴合起来不存在虚函数跳转同时LoggingBase也能访问派生类的方法实现了“基类调用派生类”的逆调用。但它的局限也很明显一旦用了CRTP装饰关系就被焊死在类型系统里你没法在运行期把LoggingBaseService换成MetricsBaseService除非你重新写一个组合类型。这里给一个实用建议当装饰链在编译期完全确定、并且对性能有硬要求时优先用模板式当需要在运行期根据配置切换装饰时模板式不适合老老实实回到std::function或继承式。不要试图把模板式塞进一个std::vector去动态管理——除非你愿意付出std::any或std::function的类型擦除开销那就又回到上一章的起点了。模板式还有一个配套的甜点可以用别名模板把装饰链封装成类型提升可读性template typename Core using FullStack CompressionDecoratorTimedDecoratorRetryDecoratorCore; FullStackHardWorker worker; // 整个装饰链变成一个类型5. 组合链与实战场景5.1 缓存、重试与权限校验三个高频实战装饰器模式在真实项目里最常见的使用场景就是给“核心业务流程”加横切关注点。我挑三个高频的举例子。第一个是缓存装饰器。当被装饰函数的计算结果对同一入参是确定性的用缓存可以直接拦截重复计算#include unordered_map #include memory Handler with_cache(Handler next, size_t capacity_hint 128) { auto cache std::make_sharedstd::unordered_mapint, 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::functionstd::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::vectorTimedDecoratorHardWorker来同时装TimedDecoratorWorkerA和TimedDecoratorWorkerB。解决思路是类型擦除——把装饰链的最终对象统一转成std::function或std::any。但要注意这一转就把模板式省下来的性能全还回去了。所以模板式装饰器通常停留在编译期固定链内不做动态收集。另一个常见坑是参数转发。装饰器模板要调用基类构造函数时漏写std::forward会让所有参数都变成左值导致原本能匹配的右值引用构造函数熄火。稳妥写法是提供一个模板构造函数template typename Derived class WrapperDecorator : public Derived { public: template typename... Args explicit WrapperDecorator(Args... args) : Derived(std::forwardArgs(args)...) {} };别小看这几行它能防止你把参数转发写错方向。关于vscode排查这是我在Windows和Linux上写模板装饰器时最常遇到的问题函数、变量无法跳转定义点半天弹不出结果。这通常不是代码问题而是IntelliSense的includePath没配全或者没有compile_commands.json。最省事的解法是用CMake生成编译数据库cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDSON然后在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把装饰逻辑圈在一个小范围内不需要跳文件。等装饰器的数量增长到十几个、或者需要做运行期配置时再转而用继承式建一套中间件框架。模板式留给性能敏感且组合固定的热路径。想清楚“你要的是运行期自由组合还是编译期零开销”选型就不纠结了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →