装饰器模式在C++社区里,说实话一直处于“听过很多次,代码里不常用”的尴尬位置。大家知道它能让对象在运行时套上新的能力,又不想动原来的类定义,但真正要写的时候,却发现C++的老三样——继承、组合、虚函数,搭出来的东西又笨又容易出问题。这篇内容就是想把我实际写过的、调试过的、踩过坑的C++装饰器实战经验梳理清楚,从最基础的实现到现代C++里的各种变体,再到日志、计时、权限校验这些真实场景,一条条过一遍,直接给能跑的代码和能复用的思路。
这篇内容适合谁?一是需要在已有类上临时加能力、又不敢大改接口的人,二是被多重继承和虚函数搞得焦头烂额的C++开发者,三是想搞清楚装饰器、代理、函数式包装到底怎么选型的人。我会假设你至少写过C++的类、虚函数、智能指针,不需要你精通模板元编程,但如果你知道偏特化和std::function是什么,读起来会更轻松。
1. 装饰器模式到底在解决什么问题
1.1 从一个让我头疼的业务需求说起
去年我接手一个老系统的消息推送模块,核心类是消息发送器,负责把各种内部消息推到不同终端。这个类用了好多年,几十个调用点都在直接使用它。某天业务方提了个需求:所有推送消息必须记录发送日志,超时的要告警,部分接口需要校验调用权限。
这个需求看起来人畜无害,往类里加三个方法就行,但问题来了——修改基类,意味着所有派生类和所有调用点都要重新审视一遍,而且日志、告警、权限这些东西压根不该属于消息发送器的核心职责。更要命的是,权限校验只针对部分接口,如果用继承,就得为“有权限的发送器”“有日志的发送器”“有告警的发送器”各写一个子类,三个功能一组合,类数量直接爆炸。
这时候装饰器模式的价值就体现出来了:它允许我把“发消息”这个核心操作保持原样,然后在外面一层层包上“记日志”“算耗时”“验权限”这些洋葱皮。调用端看到的依然是同一个发送接口,丝毫无感。
1.2 为什么直觉方案都不行
有人可能会说,我用Python那种函数装饰器、或者Go的接口组合做得很顺手,C++凭什么要单独讲?因为C++没有内置的装饰语法,也没有隐式接口概念,一切都得靠手写代码,这里面就有几个反直觉的坑。
先看继承这条路。假如你有三个功能,每个功能一个子类,那就是发送器加日志类、发送器加权限类、发送器加告警类,这是三个。如果还要日志加权限、日志加告警、权限加告警、三个全加,一共要写2的3次方减1个类,也就是7个子类。功能增加到五个,31个,根本维持不住。再看多重继承,试图把日志发送器和权限发送器合并成一个类,钻石继承的虚基类问题马上就会跳出来咬人,而且两个父类各自持有的发送器实例可能根本不是同一个对象,状态直接分裂。最后看模板混入,也就是基于模板的实现,编译期能解决组合爆炸的一部分问题,但它意味着所有装饰组合必须在编译期确定,你没法在程序运行到一半的时候,突然决定要不要给发送器加日志能力。
装饰器模式的核心思路其实特别朴素:用组合代替继承,每个装饰类持有被装饰对象的引用或指针,自己实现同样的接口,调用时先做自己的事,再转发给被装饰对象。一层套一层,想怎么搭就怎么搭。
2. 经典实现:抽象接口加组合
2.1 最朴素的C++装饰器长什么样
先给一个最小可复现的例子。假设我们有一个文本格式化接口,原始类负责把纯文本变成HTML段落。
#include <iostream> #include <memory> #include <string> class TextFormatter { public: virtual ~TextFormatter() = default; virtual std::string format(const std::string& input) const = 0; }; class PlainFormatter : public TextFormatter { public: std::string format(const std::string& input) const override { return "<p>" + input + "</p>"; } }; class TrimDecorator : public TextFormatter { public: explicit TrimDecorator(std::unique_ptr<TextFormatter> inner) : inner_(std::move(inner)) {} std::string format(const std::string& input) const override { std::string trimmed = input; // 实际开发里这里会做真正的trim处理 while (!trimmed.empty() && trimmed.front() == ' ') { trimmed.erase(trimmed.begin()); } while (!trimmed.empty() && trimmed.back() == ' ') { trimmed.pop_back(); } return inner_->format(trimmed); } private: std::unique_ptr<TextFormatter> inner_; }; class EscapeDecorator : public TextFormatter { public: explicit EscapeDecorator(std::unique_ptr<TextFormatter> inner) : inner_(std::move(inner)) {} std::string format(const std::string& input) const override { std::string escaped; for (char c : input) { if (c == '<') { escaped += "<"; } else if (c == '>') { escaped += ">"; } else { escaped += c; } } return inner_->format(escaped); } private: std::unique_ptr<TextFormatter> inner_; };调用端写起来很直观:
int main() { auto formatter = std::make_unique<EscapeDecorator>( std::make_unique<TrimDecorator>( std::make_unique<PlainFormatter>())); std::cout << formatter->format(" <hello> ") << std::endl; }这段代码的输出是<p><hello></p>,先去掉空格,再转义尖括号,最后包进HTML段落。注意装饰顺序:最外层的Escape先执行,然后调用内层Trim,最后才是真正的PlainFormatter。
2.2 这里必须搞明白的几个设计决策
第一个是基类析构函数必须做成虚函数,这个细节很少有人会强调。装饰器链里的析构会从最外层一路向内触发,如果析构函数不是虚的,当你通过基类指针删除最外层的装饰对象时,内层的智能指针虽然能正确释放自己的资源,但整个链路的语义会被破坏。
第二个是装饰类持有的是接口类型,而不是具体类。我在代码里用的是std::unique_ptr<TextFormatter>,这就保证了任意装饰器都可以嵌套任意装饰器,因为每个装饰器的构造参数都接受同一个抽象类型。如果你偷懒直接持有PlainFormatter,那这个装饰器就再也装饰不了其他装饰器了。
第三个是在转发调用时,要用inner_->format(...)而不是自己实现逻辑后不转发。很多初学者会把装饰器写成“复制一份原逻辑再改改”,那就变成代理模式了。代理通常控制访问、控制生命周期,但不会增强功能;装饰器必须把调用完整传递给被装饰对象,自己的任务只是包装一层。
2.3 经典实现藏着的三个坑
这个写法看起来干净,实际用起来有三个地方容易踩坑。
第一个坑是异常安全。假定装饰器在转发前分配了资源,比如打开了日志文件,然后调用inner_->format时抛了异常。如果装饰器没有处理好异常清理,这个文件句柄可能就泄漏了。最好的做法是尽量使用RAII类型,让资源分配和释放绑定在栈对象生命周期上,而不是手动try-catch。
第二个坑是对象切割。有人图省事,想直接按值持有被装饰对象:TextFormatter inner_。这样在构造装饰器时,传入的派生类对象会被切割成基类对象,所有多态行为都消失了。这个错误极其隐蔽,编译不报错,运行结果却永远和预期不同。我排查过同事的这个问题,最后发现就是按值成员变量吞掉了多态。
第三个坑是链路的空状态。装饰器类允许inner_为空吗?很多实现假设不能为空,但没人写检查。如果某个地方传入了空指针,析构时没问题,调用format时就会直接崩溃。我建议在构造函数里加断言或抛出异常,而不是留到运行时随机崩溃。
3. 现代C++风格下的装饰器变体
3.1 用std::function做轻量装饰
到了C++11之后,我们其实有更轻巧的做法。如果装饰的粒度是单个函数,而不是整个类,那么完全没必要定义一堆接口和实现类,直接用一个std::function包住原函数,在包装函数里前置处理或者后置处理即可。
#include <functional> #include <iostream> using FormatFunc = std::function<std::string(const std::string&)>; FormatFunc with_logging(FormatFunc inner) { return [inner = std::move(inner)](const std::string& input) { std::cout << "[decorator] before format" << std::endl; auto result = inner(input); std::cout << "[decorator] after format" << std::endl; return result; }; } FormatFunc with_prefix(FormatFunc inner) { return [inner = std::move(inner)](const std::string& input) { return inner("[prefix] " + input); }; }这种函数式装饰的好处是零抽象基类,零继承,写起来非常快。坏处是当你有多个方法需要装饰时,就得对每个方法分别写一套,类的状态难以维护。它更适合用来改造那些本来就用std::function注入函数策略的代码,比如回调函数、事件处理函数。
3.2 用模板在编译期固定装饰链
如果装饰的组合在编译期就能确定,模板是一个更高效的选择。核心思路是让装饰器成为模板类,模板参数是被装饰的具体类型:
template <typename Inner> class LoggingDecorator { public: explicit LoggingDecorator(Inner inner) : inner_(std::move(inner)) {} std::string format(const std::string& input) const { std::cout << "[log] start" << std::endl; auto result = inner_.format(input); std::cout << "[log] end" << std::endl; return result; } private: Inner inner_; };这里不需要抽象接口,因为模板编译期会检查Inner类型到底有没有format方法。调用端是从外向内实例化的:
using Decorated = LoggingDecorator<TrimDecorator<PlainFormatter>>; Decorated formatter{{}}; auto out = formatter.format("hello");这种实现的优点是没有虚函数,没有堆分配,性能非常好,所有逻辑全部在编译期展开。缺点也很明显,装饰器的类型本身变成了模板参数,你在运行时没法自由地切换装饰组合,而且类型名的可读性随着装饰层数增加急剧恶化。
3.3 智能指针和所有权模型选择
现代C++里做装饰器绕不开一个问题:谁是装饰链的所有者?我推荐两种模型。
第一种是独占所有权模型,也就是std::unique_ptr链。每次装饰都发生所有权转移,最外层的持有者拥有整条链的独占所有权。优点是开销低、生命周期清晰,缺点是没法多个对象共享同一个底层对象。
第二种是共享所有权模型,std::shared_ptr链。适合那种“多个装饰器共享同一个核心对象,各自增强不同维度”的场景。比如一个核心消息发送器,日志装饰器各持有一个shared_ptr,权限装饰器也持有同一个shared_ptr,两边互不影响。缺点是有引用计数的原子操作开销,但在现代C++运行时里这点开销通常可以忽略。
还有一种比较坑的模型是裸指针链,装饰器不拥有所有权,外部某个容器来统一管理生命周期。这种模型在复杂项目里非常危险,因为你必须保证外部容器直到最后都不释放核心对象,否则装饰器可能持有一个悬垂指针。我见过不止一次因为裸指针装饰器导致线上崩溃的事故,建议新代码直接放弃这个模型。
4. 实战场景:日志、计时、权限、缓存一次说清
4.1 日志装饰器:记录调用参数和返回结果
日志装饰器是最常见的入门场景。假定有一个订单服务接口,我们想记录每个下单请求的标识和返回码。用经典装饰器写起来这样:
class OrderService { public: virtual ~OrderService() = default; virtual int submit(const std::string& orderId) = 0; }; class RealOrderService : public OrderService { public: int submit(const std::string& orderId) override { // 真正提交订单 return 0; } }; class LoggingOrderService : public OrderService { public: explicit LoggingOrderService(std::unique_ptr<OrderService> inner) : inner_(std::move(inner)) {} int submit(const std::string& orderId) override { auto start = std::chrono::steady_clock::now(); int code = inner_->submit(orderId); auto end = std::chrono::steady_clock::now(); std::cout << "order=" << orderId << " code=" << code << " cost_ms=" << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << std::endl; return code; } private: std::unique_ptr<OrderService> inner_; };这里我把计时代码直接揉进了日志装饰器里,对一个简单项目来说没问题。但如果日志和耗时统计以后会分别开关,我建议把计时抽成独立的计时装饰器,做链式组合。
日志装饰器有个细节:要不要记录被装饰对象的完整入参?订单号这类业务标识可以记,但密码、token这类敏感信息绝对不能进日志。所以日志装饰器在实现时应该谨慎选择记录哪些字段,而不是无脑把所有参数全打印出来。
4.2 计时装饰器:专门用于性能采集
计时逻辑单独抽出来是很有价值的,因为它能让你在不用改动业务代码的情况下,随时给任意接口加上耗时埋点。特别是重构老系统时,我想快速定位某个慢接口,直接在外层包一层计时装饰器,跑一轮调用就能拿到数据。
class TimedOrderService : public OrderService { public: explicit TimedOrderService(std::unique_ptr<OrderService> inner) : inner_(std::move(inner)) {} int submit(const std::string& orderId) override { auto start = std::chrono::high_resolution_clock::now(); int code = inner_->submit(orderId); auto end = std::chrono::high_resolution_clock::now(); auto cost = std::chrono::duration_cast<std::chrono::microseconds>(end - start); metrics_hook(orderId, cost); // 把耗时上报给监控 return code; } private: std::unique_ptr<OrderService> inner_; };这里我用的是微秒而不是毫秒,因为接口内部的耗时差距经常不到1毫秒,毫秒精度会直接把微小差异抹掉。如果项目里已经有统一监控框架,计时装饰器的metrics_hook可以替换成真正的上报函数,否则也可以把耗时信息放进一个队列,由专门线程批量上报,避免阻塞业务调用。
4.3 权限校验装饰器:改接口侵入最小的方案
权限校验这个场景最能体现装饰器的价值。老系统里的订单服务接口已经被几十个调用方使用,你不可能一个个去改调用方代码加权限判断,在不触碰调用方的情况下,只需在最外层包一个权限校验装饰器。
class AuthorizedOrderService : public OrderService { public: AuthorizedOrderService(std::unique_ptr<OrderService> inner, PermissionChecker checker) : inner_(std::move(inner)), checker_(std::move(checker)) {} int submit(const std::string& orderId) override { if (!checker_.canSubmit(orderId)) { return kPermissionDenied; } return inner_->submit(orderId); } private: std::unique_ptr<OrderService> inner_; PermissionChecker checker_; };代码极其简单,但有几个工程决策值得说。一是权限拒绝时返回的码和业务冲突码必须区分,最好用专用错误码,否则调用方会误以为订单提交未成功,然后去重试,重试又会被拒绝,形成无意义的循环。二是在组合装饰链时,权限装饰器应该尽量放在最外层还是最内层?我的经验是放在最外层,这样未授权的请求根本不会进入后端的业务逻辑,也不会触发日志里那些敏感参数记录,既省资源又安全。
4.4 缓存装饰器:最容易被误用的场景
缓存可能是装饰器模式最诱人、也最容易出事儿的场景。给某个查询接口加缓存装饰器,遇到相同入参时直接返回缓存结果,看起来天衣无缝,但第一个巨坑就是缓存的一致性。如果你的数据在别处被更新了,缓存装饰器不知道,返回的就是陈旧数据。缓存装饰器必须配合显式的失效机制,也就是另一个同接口的clear方法,或者依赖缓存框架的过期策略,绝不能只写一个get方法就完事。
第二个坑是缓存键的构造。如果接口入参是一个复杂对象,你要把整个对象的所有字段拼成一个字符串作为缓存键,字段边界分不清楚时,极容易出现键碰撞。比如名字叫"a-b"、邮箱叫"c",和名字叫"a"、邮箱叫"b-c"可能拼出相同的字符串。正确做法是使用长度前缀或者直接哈希对象序列化后的字节流。
5. 性能与设计权衡:装饰器不是免费午餐
5.1 每次调用多几层虚函数调用
经典装饰器每一次转发都是一次虚函数调用,链的长度越长,调用栈越深。如果是个高频调用的基础接口,比如每秒百万次,每一层多出来的分支预测失败和间接跳转,就有可能变成真实的性能损耗。我实测过三层装饰器链和裸调用的差距,在普通机器上大约每百万次调用差几十毫秒,对于业务系统来说通常可忽略,但如果是嵌入式底层或者游戏引擎的热路径,就不能这么随便了。
应对手段有两种。一种是用模板装饰器把虚函数变成内联函数,编译期将整个链展开,代价是类型复杂。另一种是减少装饰层数,比如把日志和计时合并成一个装饰器,不要为了“单一职责”而堆出十条链。我见过极端案例,一个接口套了八层装饰器,光函数调用栈就占了几百字节,性能下降只是冰山一角,调用栈都变得极难分析。
5.2 堆分配与对象膨胀问题
用智能指针构建装饰器链,每一层装饰都要在堆上创建对象。链越长堆分配次数越多,缓存局部性也会渐渐变差。现代内存分配器对小块分配做了优化,成本没那么高,但如果你处在性能敏感场景,可以在初始化时把整条链一次性组装好,而不是每次调用都重新创建。这也说明装饰器链应该尽量设计成只创建一次、长期复用的对象。
5.3 装饰器与代理模式、策略模式如何取舍
很多人区分不清装饰器和代理,写出来的东西四不像。我自己的判断标准很简单:装饰器会继续把调用往里传,自己逻辑是“附加物”;代理则控制了对外入口,经常做延迟加载、远程调用、访问控制,不需要往里传原始调用。实践中,远程代理和服务代理建议用代理实现,而日志、监控、缓存这些功能性附加,用装饰器更自然。
策略模式又不一样,策略是想换掉内部算法,装饰器是在不动原功能的基础上增加外围功能。如果你的需求本质上是要换一套算法实现,应当改写核心类或者注入策略,而不是装饰。
6. 调试装饰器链:遇到的问题和排查实录
6.1 我的装饰器链为什么没生效
这是我同事遇到的一个经典问题:他给某个发送器外层加了日志装饰器,也调用了,但日志就是不出来。我排查时发现,他真正调用的并不是装饰器对象,而是旧的裸发送器对象。项目里有一个服务容器,容器初始化时注入了发送器的原始实例,后来他只在某个局部地方手动创建了装饰器,容器那边的调用点根本不知道这回事。
这类问题几乎每个用装饰器的项目都会遇到。解决方案是把装饰器的组装放到统一的工厂函数或依赖注入容器中,让所有调用方拿到的都是组装后的装饰链,而不是各发各的自行封装。类似地,如果装饰器通过配置文件来决定,那么配置的生效时机也要统一,不能出现半个系统用新链半个系统用旧链的混乱状态。
6.2 同一装饰器重复装饰导致日志翻倍
另一个常见问题是有时多人协作,没人注意到发送器已经被某个装饰器包装过,又在外面再包一层相同的日志装饰器,结果每次调用打两遍日志。排查后发现是因为没有幂等机制。解决思路有三种:一是在组装阶段检查已存在的能力清单,如果已有同类型装饰器就复用;二是把装饰器的职责尽量原子化,让团队依赖统一组装函数;三是减少手工组装,不用到处直接new装饰器。
6.3 调试技巧:打印整条链的结构
一旦装饰器层数上去了,读代码就不太直观。我习惯写一个调试工具函数,遍历整条链并输出每一层的类型。如果是经典虚函数实现,可以用类型信息打印;如果是模板链,可以在模板构造函数里记录类型名,形成链条。实际调试时,给每个装饰器在构造和析构时打印一条信息也是一个简单粗暴但有效的手段,不过线上别这么干。
6.4 单元测试怎么覆盖装饰器
装饰器模式的单元测试有个绕不开的难点:你测的应该是最外层的行为,但又不想真正触发底层副作用。我的做法是引入一个可注入的假底层对象,它实现同一个接口但只记录调用列表,不执行真实逻辑。这样测试就能验证:权限装饰器拒绝时不会调用内层对象,日志装饰器确实记录了参数,计时装饰器返回的结果与内层一致。
class FakeOrderService : public OrderService { public: int submit(const std::string& orderId) override { lastOrder = orderId; ++callCount; return 0; } int callCount = 0; std::string lastOrder; }; TEST(DecoratorTest, LoggingServiceForwardsCall) { auto fake = std::make_unique<FakeOrderService>(); auto raw = fake.get(); LoggingOrderService service(std::move(fake)); service.submit("12345"); ASSERT_EQ(raw->lastOrder, "12345"); ASSERT_EQ(raw->callCount, 1); }测试重点不是装饰器本身,而是装饰器正确调用了内层对象。有很多装饰器的bug就出在改写了参数或者漏传了调用,这种测试能快速兜住底线。
7. 个人经验:装饰器落地的几个坚持
最后分享几条我实际项目里固守的规矩。第一条,能不新增装饰器层数就不新增。装饰器模式的优势是灵活,代价是链上路多了一层理解成本。如果一个装饰器可以用简单的拦截回调实现,我就优先用回调,装饰器留给那些需要严格封装接口整体行为的场景。
第二条,装饰器要尽量保持薄。不要想着在装饰器里做大量业务逻辑,装饰器应该只是“切面”,真正的逻辑宁可在独立函数里,然后在装饰器里调用。一个装饰器超过一百行,我就得考虑是不是设计出了问题。
第三条,给装饰器写文档。别以为人人一看类名就明白。我见过最糟的代码是几个装饰器类名都带Guard后缀,但有的做权限、有的做参数校验、有的做重试,完全猜不出来。好好地写清楚每个装饰器是干什么的,加在什么位置,输出什么副作用,对后维护者来说太重要了。
绝大多数的老系统其实没有必要大改架构,一个设计得当的装饰器链就能把日志、权限、缓存、监控这些横向关注点统统收拢起来,让原来的核心类变得干净,让新来的开发者也敢往里加需求。这种“不动地基就能盖新楼”的方案,正是装饰器模式在C++项目里最大的价值。