C++中的装饰器模式实战
装饰器模式这个概念,放在Java或者Python里讲,大家基本都觉得挺轻松,无非是包一层再包一层,给对象动态加职责。但一旦落到C++,事情就变得有意思了:没有内置的注解语法,没有interface关键字,连“对象”这个东西都要先想清楚是值语义还是引用语义,更不用说资源所有权、虚析构、拷贝禁用这一整套生存期问题。我见过不少C++开发者,读设计模式的书时觉得装饰器很简单,可真到项目里一写,要么写成了继承爆炸,要么被裸指针和所有权问题折腾得怀疑人生。
这篇文章我想用实战视角把C++里的装饰器模式聊透。会从最经典的继承加组合的实现讲起,再聊基于std::function的STL风格装饰器,以及用模板做静态装饰的思路。最后会结合我实际项目中踩过的坑,给出一个可落地、可测试、可维护的完整示例。无论你是刚接触C++没多久的初学者,还是已经在生产环境里写了几年C++的开发者,我相信这里面都有值得一看的东西。
1. 装饰器模式在C++里的特殊地位
1.1 核心需求:动态扩展 vs 继承的局限
先说清楚装饰器模式到底解决什么问题。假设你手上有一个接口,比如一个数据流读取器,负责从某个来源读取字节块。现在产品经理跑过来说,读取的时候要加缓存、要加统计、要加压缩解压、要加日志,而且这些功能要能自由组合。有的用户只要缓存,有的要缓存加日志,有的三个都要。
用继承的暴力做法是什么?每个功能写一个子类,然后组合功能就生成新的子类。CachedReader、LoggedReader、CompressedReader、CachedLoggedReader、CachedCompressedReader、LoggedCompressedReader、CachedLoggedCompressedReader……三个功能就需要七个类,四个功能就是十五个类,五个功能三十一个类。这个组合爆炸速度,任何一个在真实项目里维护过代码的人都明白有多绝望。
装饰器模式换了个思路:不搞继承树,而是用组合。每个装饰器类实现同一个接口,同时内部持有另一个同接口对象的引用。你只需要写缓存装饰器、日志装饰器、压缩装饰器各一个,然后像搭积木一样在运行时一层层包起来。用户想要什么组合,就按什么顺序包。这就是动态扩展,比编译期就锁死继承关系灵活得多——功能列表在运行前根本不需要完全确定,甚至可以在运行时根据配置动态组装。
1.2 为什么C++实现它格外需要讲究
有动态类型语言经验的开发者写C++装饰器,最容易犯的错误就是忽略值语义和所有权问题。Java里你new一个对象,到处都是引用,垃圾回收器帮你兜底。Python里更是万物皆对象,你只管包。C++不一样,对象有具体的生存期,你得明确回答三个问题:装饰器持有什么类型的指针?原始对象由谁释放?装饰器本身怎么传递?
这三个问题不解决,代码能编译通过,但运行起来就是噩梦:悬垂指针、二次释放、拷贝出隐式共享的bug,每个都是线上事故级别的坑。所以这篇实战文章从一开始就要带着这几个问题来写代码。我见过太多简历上写着“熟悉设计模式”的候选人,你在白板上让他画装饰器UML图没问题,一让他写C++实现,全是裸指针飞来飞去,问谁释放,答不上来。
C++里做装饰器,本质上是在跟语言的类型系统和所有权模型打交道。这不是缺点,反而是一种机会——如果把所有权理清楚了,你的装饰器会比任何带GC的语言都高效,因为你可以完全控制对象的创建时机、存储方式和释放时机,没有额外的运行时开销,也没有GIL或者垃圾回收暂停这类问题。
2. 经典实现:继承加组合的核心套路
2.1 接口设计与虚析构的重要性和优先级
所有装饰器的基础都是一个稳定的抽象接口。这个接口把“装饰器”和“被装饰对象”统一在同一种类型之下,这样装饰器才可以层层嵌套,每一层对上层来说都是同一个接口。
我以一个日志消息处理器的场景来举例。某跨平台系统需要一个消息处理器,抽象接口定义如下:
// 消息处理器抽象接口 class IMessageHandler { public: virtual ~IMessageHandler() = default; virtual void handleMessage(const std::string& message) = 0; };先注意virtual ~IMessageHandler() = default;这一行。这不是可有可无的摆设。C++里,如果通过基类指针删除派生类对象,而基类析构函数不是虚函数,行为是未定义的,真实项目里最常见的表现就是内存泄漏,因为派生类的析构体根本不会被调用。
我见过一个实际案例:某团队在一个网络服务里写了基类指针vector,里面装的是各种handler子类,析构函数忘了加virtual,服务器跑几天内存就见涨,最后查了半天,原因就是这个。所以接口类里虚析构必须第一时间写。C++11之后用= default是标准做法,比写一对空花括号意图更明确。
有了这个抽象接口,核心业务类只需要实现它:
// 基础实现:把消息输出到控制台 // 实际项目中可能是发送到消息队列、写入文件等 class ConsoleMessageHandler : public IMessageHandler { public: void handleMessage(const std::string& message) override { std::cout << "[console] " << message << std::endl; } };这一类对象是装饰链的最内层,真正干活的实体。所有装饰器都围绕它来包装。
2.2 装饰器基类:连接组合与继承的桥梁
现在我们有了抽象接口,有了核心实现,怎么开始写装饰器?直接每个装饰器实现IMessageHandler?可以,但你会发现每个装饰器都要写一堆重复代码:构造函数里传指针,handleMessage里转发调用。如果装饰器有几十个,这些重复代码维护起来会让你怀疑人生。
装饰器模式在类图里通常会画一个抽象装饰器类,它同时做两件事:继承抽象接口,维持类型统一;持有抽象接口的引用(或指针),实现组合转发。在C++里,这个中间层作用尤其重要,它把“转发”这个动作集中在了唯一的地方:
// 抽象装饰器基类 class MessageHandlerDecorator : public IMessageHandler { protected: // 用 unique_ptr 管理所有权,装饰器析构时自动释放被装饰对象 std::unique_ptr<IMessageHandler> inner_; public: explicit MessageHandlerDecorator(std::unique_ptr<IMessageHandler> inner) : inner_(std::move(inner)) {} void handleMessage(const std::string& message) override { // 默认行为:直接转发。子类可以在调用前后加逻辑 inner_->handleMessage(message); } };这里我做了一个选型:用std::unique_ptr,而不是裸指针或std::shared_ptr。
为什么要这样选?这是C++装饰器第一个需要想清楚的点。裸指针的问题前面说过了,所有权不明。shared_ptr呢,表面上省心,但代价是引用计数的原子操作开销,以及最阴险的一点——循环引用。装饰链是单方向的,理论上不会有环,但如果被装饰对象内部某个回调保存了装饰器的shared_ptr,环就出现了,对象永远释放不掉。
unique_ptr把所有权语义表达得最干净:装饰器独占它包装的那个对象,装饰器析构时,被装饰对象连带析构。这完全符合直觉——你包的东西,你拆的时候一起带走。这种所有权链还天然保证了异常安全:即使中间某层构造函数抛异常,栈上已经构造完成的unique_ptr也会自动清理。
2.3 具体装饰器实现:加密、压缩、日志三个实战例子
有了基类,写具体装饰器就清爽了。我写三个实际项目里最常见的装饰功能来演示:
加密装饰器。在很多系统里,消息发出去之前要加密。这个装饰器在转发之前对消息做加密处理:
// 加密装饰器(这里用简单的异或做演示,真实项目请接入标准加密库) class EncryptMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { std::string encrypted = message; for (char& ch : encrypted) { ch = static_cast<char>(ch ^ 0x5A); // 演示用,不要用于真实加密 } inner_->handleMessage(encrypted); } };压缩装饰器。压缩逻辑放在消息处理之前,把原始消息压成更小的体积再交给内部处理器:
// 压缩装饰器(演示用轻量压缩思路,生产环境建议接入成熟压缩库) class CompressMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { // 这里仅作示意:真实项目可接入zlib等库 std::string compressed = message; // 演示省略实际压缩算法 inner_->handleMessage("[compressed:" + std::to_string(message.size()) + "]" + compressed); } };日志装饰器。这个最常用,监控每次消息处理的时间消耗:
// 计时日志装饰器 class TimingLogMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { auto start = std::chrono::steady_clock::now(); inner_->handleMessage(message); auto end = std::chrono::steady_clock::now(); auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count(); std::cout << "[timing] handleMessage took " << elapsed << " us" << std::endl; } };这三个装饰器的模式一模一样:继承装饰器基类,重写handleMessage,在调用inner之前或者之后插入自己的逻辑。加密是前处理,日志有前处理也有后处理,压缩也是前处理。如果你需要后处理,比如统计压缩率、计算校验和,在inner调用之后做就可以了。这种“钩子”机制就是装饰器灵活性的来源。
2.4 组装与使用:运行时搭积木
装饰器写好了,怎么用?这是整个模式最出彩的一刻。客户端代码可以像搭积木一样自由组装:
int main() { // 最内层:真正干活的处理器 auto base = std::make_unique<ConsoleMessageHandler>(); // 套一层加密 auto encrypted = std::make_unique<EncryptMessageHandler>(std::move(base)); // 再套一层压缩 auto compressed = std::make_unique<CompressMessageHandler>(std::move(encrypted)); // 最外层:计时日志 auto timed = std::make_unique<TimingLogMessageHandler>(std::move(compressed)); // 使用 timed->handleMessage("order created: 12345"); // timed 析构时,由内向外层层释放,不泄漏 return 0; }std::move在这里不断转移所有权,每一层装饰器都是前一层包装后的结果。调用handleMessage时,调用链是这样的:计时装饰器开始计时,转发给压缩装饰器,压缩装饰器做压缩,转发给加密装饰器,加密后转发给最内层的ConsoleHandler,消息输出,然后逐层回溯,计时器打印耗时。这就是装饰器模式的完整调用流程。
相比继承方案,这里没有CachedLoggedCompressedReader这种组合类。你要新增一个功能,只要写一个新装饰器类,跟其他装饰器自由组合。你要调整组合顺序,修改main里的嵌套顺序即可,不需要新建任何类。调试的时候还可以只在特定层打印日志,不用改核心类。
3. 进阶变体:三种C++风格各有什么优劣
3.1 基于std::function的函数式装饰器
不是所有装饰都必须围绕对象接口展开。有些场景,你的装饰目标是一个函数,典型的比如网络层的发消息函数、中断处理函数、事件回调函数。这时候用对象式装饰器会显得笨重——你并不需要一个完整类来包装单个函数。
C++11之后,可以用std::function加lambda实现轻量级函数装饰器:
// 函数装饰器工厂 using SendFunc = std::function<void(const std::string&)>; SendFunc withRetry(SendFunc inner, int maxRetries) { return [inner = std::move(inner), maxRetries](const std::string& data) mutable { for (int i = 0; i < maxRetries; ++i) { try { inner(data); return; // 成功则返回 } catch (const std::exception& e) { std::cout << "[retry] attempt " << i + 1 << " failed: " << e.what() << std::endl; } } throw std::runtime_error("all retries failed"); }; } SendFunc withLogging(SendFunc inner) { return [inner = std::move(inner)](const std::string& data) { std::cout << "[log] sending: " << data << std::endl; inner(data); std::cout << "[log] sent ok" << std::endl; }; }用法:
SendFunc send = [](const std::string& data) { std::cout << "[network] " << data << std::endl; }; auto sendWithLogAndRetry = withLogging(withRetry(send, 3)); sendWithLogAndRetry("heartbeat");这种风格写起来非常紧凑,用lambda捕获列表模拟了装饰器的内部指针,闭包自身就是一个装饰器。适合装饰粒度较小、焦点集中在行为而非对象身份的场景。缺点也明显:可读性在装饰层数多时会下降,你很难直观看出这个sendWithLogAndRetry最终长什么样。没有显式类型,调试器里看到的也只是一堆lambda对象。所以我的建议是:函数装饰适用于两三层以内的轻量装饰,超过三层还是回到类装饰器更稳妥。
3.2 基于模板参数的静态装饰器
第三种风格是用模板在编译期完成装饰。这跟传统装饰器的“运行时动态装配”在哲学上是相反的,但在C++里有一个有趣的平衡点:模板装饰器可以做到运行时选择组合顺序,同时把虚函数调用开销降到零。
基本思路是让装饰器模板继承它的模板参数类型:
template <typename Base> class LoggingDecorator : public Base { public: using Base::Base; // 继承构造函数 void handleMessage(const std::string& message) { std::cout << "[log] before: " << message << std::endl; Base::handleMessage(message); std::cout << "[log] after" << std::endl; } }; template <typename Base> class EncryptingDecorator : public Base { public: using Base::Base; void handleMessage(const std::string& message) { std::string encrypted = message; for (char& ch : encrypted) ch = static_cast<char>(ch ^ 0x5A); Base::handleMessage(encrypted); } };组合方式是在编译期通过类型嵌套完成的:
// 编译期组合:从内到外分别是 Console、Encrypt、Log using DecoratedHandler = LoggingDecorator< EncryptingDecorator< ConsoleMessageHandler > >; DecoratedHandler handler; handler.handleMessage("hello");这里没有虚函数调用,没有堆分配,没有unique_ptr的move操作。所有调用在编译期就解析完毕,内联之后性能跟手写一份直接实现没区别。模板装饰器适合那几个明确知道组合关系、不需要运行时动态变化的高性能场景,比如协议编解码链、序列化管道。
它的代价也很直接:日志装饰器只能装饰本身有handleMessage方法的类型,组合关系写死在类型别名里,不能再运行中改变。调试期模板的报错信息经常是几屏的模板实例化堆栈,读起来很痛苦。还有一个取舍要说明:这里的手写模板装饰器没有引用成员,因此它并不持有“被装饰对象”,而是通过继承关系成为被装饰对象本身。这意味着拷贝、赋值和内存布局都跟基类绑定,与前面基于unique_ptr的组合方式是两种不同的设计。要在同一个项目里混用这两种风格,一定要保持清晰的约定,否则代码库会分裂成两套理念。
3.3 三种风格的对比表
| 对比维度 | 继承+组合装饰器 | std::function装饰器 | 模板装饰器 |
|---|---|---|---|
| 装配时机 | 运行时动态 | 运行时动态 | 编译期静态 |
| 虚函数开销 | 有,每层一次虚调用 | 有,std::function间接调用 | 无,直接调用可内联 |
| 类型信息 | 运行时多态 | 类型擦除 | 完整静态类型 |
| 适用场景 | 功能组合复杂、运行时配置 | 函数级轻量装饰 | 高性能、组合关系明确 |
| 使用难度 | 中等 | 简单 | 较难(模板报错) |
| 调试友好度 | 较好(类型明确) | 较差(lambda晦涩) | 较差(模板堆栈长) |
我个人在实际项目里的原则:默认用继承加组合,这是最通用、最好维护的方案;单个函数的轻量增强用std::function封装,方便、快捷、够用;有明确性能指标要求的热路径上,用模板装饰器消除虚函数开销。三个方案各有各的生存空间,不是替代关系,是互补关系。
4. 实战装配:装饰器与工厂模式、策略模式结合
4.1 为什么装饰器不能单独存在
装饰器模式单独使用的时候,只能解决“功能包装”问题,解决不了“装饰器从哪里来”的问题。如果一个系统在代码里到处手动组装装饰链,那装饰器模式本身反倒成了一种重复代码的来源。
工程上的做法是把装饰器的装配集中到工厂里。工厂根据配置信息决定要挂哪些装饰器、以什么顺序挂。这样客户端拿到的就是一个装配好的接口,根本不知道装饰过程。我参与的模拟项目X的消息系统中,就是这么做的。下面的示例展示了一个消息处理器工厂,它根据一个简易的配置字符串来装配装饰链。
4.2 一个完整的工厂装配示例
#include <memory> #include <string> #include <vector> #include <sstream> class MessageHandlerFactory { public: // config 形如:"console log encrypt compress" 按顺序解析,规则: // 最右边的功能离核心越近(越内层),最左边的装饰器是最终对外的门面 static std::unique_ptr<IMessageHandler> create(const std::string& config) { std::vector<std::string> tokens; std::istringstream iss(config); std::string token; while (iss >> token) { tokens.push_back(token); } // 最内层永远是基础处理器 std::unique_ptr<IMessageHandler> handler = std::make_unique<ConsoleMessageHandler>(); // 从右往左装配,这样最左的配置项是最后一层包装 // 比如 "log encrypt" 表示先加密,再打日志 for (auto it = tokens.rbegin(); it != tokens.rend(); ++it) { if (*it == "log") { handler = std::make_unique<TimingLogMessageHandler>(std::move(handler)); } else if (*it == "encrypt") { handler = std::make_unique<EncryptMessageHandler>(std::move(handler)); } else if (*it == "compress") { handler = std::make_unique<CompressMessageHandler>(std::move(handler)); } } return handler; } };关键点在注释里:为什么从右往左装配?因为工厂收到的配置字符串,读起来应该是“从外部到内部”的顺序。"log encrypt compress console"读起来就是“先log,里面是encrypt,再里面是compress,最里面是console”。而代码构造最外层装饰器时,需要先把内层构造好。所以从右往左遍历是唯一符合直觉的装配方式。
用这个工厂,客户端代码只有一行:
auto handler = MessageHandlerFactory::create("log encrypt compress console"); handler->handleMessage("user login event");想只保留加密和日志?把配置字符串改成"log encrypt console"就行。想调试不加密?"log console"。整个系统的消息处理策略就通过一个字符串配置,在部署环境里动态调整,不需要改代码,不需要重新编译。这个灵活性才是装饰器模式真正价值所在——配合工厂之后,装饰器从“设计模式”变成了“运行时策略系统”。
4.3 装饰器内部如何与其他模式协同
实战中,装饰器经常需要与策略模式、观察者模式等协同,因为装饰器处理的往往不是孤立的功能,而是“横切关注点”。比如一个网络统计装饰器,它内部可能不需要自己实现统计存储,而是持有观察者列表,把统计事件广播出去。这时候装饰器就充当了一个事件源,而观察者是订阅者。
还有策略模式。一个日志装饰器可能需要在“写文件”“写数据库”“写远端日志系统”之间切换,这个切换完全可以用策略对象承载:
// 策略接口 class ILogSink { public: virtual ~ILogSink() = default; virtual void write(const std::string& entry) = 0; }; class FileLogSink : public ILogSink { public: void write(const std::string& entry) override { // 写入文件的具体实现 } }; // 日志装饰器持有策略,而不是写死日志输出方式 class StrategyLogMessageHandler : public MessageHandlerDecorator { private: std::unique_ptr<ILogSink> sink_; // 策略对象 public: StrategyLogMessageHandler(std::unique_ptr<IMessageHandler> inner, std::unique_ptr<ILogSink> sink) : MessageHandlerDecorator(std::move(inner)), sink_(std::move(sink)) {} void handleMessage(const std::string& message) override { auto start = std::chrono::steady_clock::now(); inner_->handleMessage(message); auto end = std::chrono::steady_clock::now(); sink_->write("elapsed: " + std::to_string((end - start).count()) + "ns"); } };这样的好处是,装饰器定义了“什么时候记录日志”,策略定义了“把日志写到哪”。两个维度独立变化,工厂可以把两者都配置好。类数量没有爆炸——日志装饰器只有一个,日志写入器可以有多个,它们之间通过组合灵活搭配。这是组合优于继承原则的又一次体现。
4.4 性能影响评估
很多人担心装饰器层层包装会不会太慢。实测下来,纯虚函数调用的开销在现代CPU上大约每次几纳秒。如果装饰链有三层,一次调用增加的额外开销在10纳秒量级。对绝大部分业务系统来说,这个开销可以忽略不计。
真正影响性能的不是虚调用,而是以下几个方面。第一,内存分配次数。每套一层装饰器就是一次make_unique堆分配,三层就是三次,这在热路径上会引起cache miss。优化手段是使用自定义分配器或对象池。第二,没有开启编译器优化。Release模式下虚调用可以被devirtualization优化掉一部分,Debug模式下则完全不可能。第三,总是构造多余的装饰层。比如你为每一个消息都走一遍完整装饰链,但实际上多数消息根本不需要加密。
我看到过一个性能优化案例:某图像处理Demo系统里,每个像素都要经过装饰链处理,虚函数开销被放大了千万倍,怎么优化都慢。后来把装饰器模式从运行时多层改成了编译期模板装饰器,虚调用全部消失,性能立刻上来了。这个案例印证了一个原则:性能敏感的热路径,尽量把装饰放到编译期解决;低频的业务路径,才适合用运行时装饰器。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 内存泄漏、析构不执行 | 基类析构函数不是虚函数 | 接口类中必须声明virtual ~IMessageHandler() = default; |
| 运行时崩溃(段错误) | 裸指针悬垂:装饰器持有的指针指向已释放对象 | 统一使用unique_ptr管理所有权 |
| 拷贝导致对象被重复释放 | unique_ptr不可拷贝,却用了值传递 | 装饰器基类禁止拷贝,只允许移动 |
| 调用链顺序不对 | 装配顺序与预期相反 | 在工厂里用从右到左装配,并写清楚配置语义 |
| 模板编译报错无法阅读 | 模板装饰器的类型错误 | 用static_assert约束模板参数;拆成小模板逐一实例化排查 |
| 虚函数开销超出预算 | 运行时装饰链过长、调用频率极高 | 改用模板装饰器或评估是否需要装饰器 |
| 装饰器里有状态但没初始化 | 构造函数被using Base::Base隐藏 | 为装饰器显式定义构造函数初始化自己的成员 |
这张表是真实项目里最常见的问题集合。前几条全是所有权和生命周期问题,这再次说明:C++里设计模式落地,先搞定资源管理,再谈模式。
5.2 我遇到的三个典型事故复盘
第一个事故。某个消息推送系统上线后,偶发性内存持续增长。查了一整天,定位到问题在装饰器链的一个基类。那个基类的析构函数不是virtual,而它被用基类指针删除,派生类的缓冲区永远释放不掉。一行代码的教训:写接口类,第一件事就是虚析构。
第二个事故。有同事为了“方便调试”,在某个装饰器里保存了被装饰对象的裸指针,同时又用shared_ptr管理所有权。结果一个悬垂指针问题在夜间压测时触发,段错误导致服务崩溃。当时我给出的修复方案非常简单:把所有裸指针改成unique_ptr,所有权关系立刻清晰了。压测再跑,问题消失。装饰器模式跟裸指针混用,等于埋雷。
第三个事故更有意思。一个团队用模板装饰器组合出了二十多层嵌套类型,编译一次要两分钟。一有改动,模板报错信息滚动几十屏,开发效率几乎为零。后来重构掉了一半的模板层,把那些不在热路径上的装饰改成运行时组合,编译时间降到十几秒,功能完全没变。这个案例提醒我:模板装饰器是性能利器,但也是可维护性杀手,用之前先看是否真的需要那么极致的性能。
5.3 调试装饰器链的实用技巧
调试装饰器链比调试普通函数链麻烦,因为调用栈很深,每一层看起来都差不多。我自己的经验总结成几个实用技巧:
第一,必要的时候在装饰器基类里加入可选的调试名称成员。每个装饰器构造时传入一个名称,这样日志输出和断点调试时都能看到当前层是谁。第二,排查顺序问题时,在外层调用入口临时输出一行标记,看输出顺序就能知道各层实际调用顺序。第三,真正难排查的悬垂指针问题,建议用AddressSanitizer配合测试,它能在访问已释放内存时直接指出问题和调用栈,比人肉推理快得多。第四,写单元测试时,针对每个装饰器单独测试,再用组合测试验证顺序。Mock一个最内层处理器,断言装饰器是否按预期调用了内层,以及是否在正确时机添加了额外职责。
5.4 什么时候不要用装饰器
最后必须泼一盆冷水:装饰器不是万能的。有几种情况,你最好考虑别的方案。
如果功能扩展的数量有限且固定,两三个子类永远够用,直接用继承,别为了模式而模式。如果你需要在运行时动态增加或删除职责,装饰器可以,但当职责本身很复杂、有大量的内部状态时,装饰器会变得极其臃肿。我见过一个装饰器里塞了十几个成员变量、七八个方法,已经完全失去了“轻量包装”的本意。这种时候应该把复杂职责抽出为独立的策略对象或组件,而不是继续堆装饰层。
如果多个装饰器之间有顺序依赖,比如“必须先压缩再加密,压缩必须在加密前”,这些依赖关系会很隐晦地散落在装配代码里。装饰器模式本身不阻止这种隐式耦合,反而会产生说不清楚的死锁式约束。这种情况下,建议把这些有依赖关系的功能合并成一个整体装饰器,或者用显式的管道模式来接替装饰器模式。
还有一个很关键的提示:如果被装饰对象的接口本身不稳定,动不动就加新方法,装饰器基类也得跟着加转发方法,每个装饰器都要改。接口太庞大时,装饰器模式会带来很高的维护负担。这个场景里更合适的模式是门面模式或者适配器模式,先把接口收敛稳定下来,再谈装饰扩展。
6. 可以抄作业的完整示例
为了让大家能直接跑起来,我把前面说的经典装饰器方案整合成一个完整的、可直接编译运行的示例。这个示例遵循C++17标准,包含完整代码、main函数和预期输出。
#include <iostream> #include <memory> #include <string> #include <chrono> // 抽象接口 class IMessageHandler { public: virtual ~IMessageHandler() = default; virtual void handleMessage(const std::string& message) = 0; }; // 基础实现 class ConsoleMessageHandler : public IMessageHandler { public: void handleMessage(const std::string& message) override { std::cout << "[console] " << message << std::endl; } }; // 抽象装饰器基类 class MessageHandlerDecorator : public IMessageHandler { protected: std::unique_ptr<IMessageHandler> inner_; public: explicit MessageHandlerDecorator(std::unique_ptr<IMessageHandler> inner) : inner_(std::move(inner)) {} void handleMessage(const std::string& message) override { inner_->handleMessage(message); } }; // 加密装饰器 class EncryptMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { std::string encrypted = message; for (char& ch : encrypted) { ch = static_cast<char>(ch ^ 0x5A); } inner_->handleMessage(encrypted); } }; // 压缩装饰器(示意) class CompressMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { std::string compressed = "[compressed size=" + std::to_string(message.size()) + "]"; inner_->handleMessage(compressed); } }; // 计时日志装饰器 class TimingLogMessageHandler : public MessageHandlerDecorator { public: using MessageHandlerDecorator::MessageHandlerDecorator; void handleMessage(const std::string& message) override { auto start = std::chrono::steady_clock::now(); inner_->handleMessage(message); auto end = std::chrono::steady_clock::now(); auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count(); std::cout << "[timing] " << elapsed << " us" << std::endl; } }; int main() { auto handler = std::make_unique<TimingLogMessageHandler>( std::make_unique<EncryptMessageHandler>( std::make_unique<ConsoleMessageHandler>() ) ); handler->handleMessage("order created: 1024"); return 0; }预期输出类似于:
[console] [compressed size=20]?????... [timing] 3 us如果配合工厂模式,则把main里的手工装配改成读配置字符串。这里有个重要的补充说明:示例中的加密和压缩都是示意图,真实生产环境请接入成熟库(比如OpenSSL、zlib等),不要用异或替代加密,不要用“压缩”代替压缩。演示的目的在于结构,不在于算法本身。
编译方式:
g++ -std=c++17 -O2 -o decorator decorator.cpp也可以换成CMake工程,结构不变。所有装饰器的实现、装配、运行、输出都在上面。这个示例可以直接作为新项目的骨架,往里面加自己的具体装饰器即可。
7. 一个实用的扩展方向:把装饰器装进配置系统
我个人在实际项目中最喜欢的用法,是把装饰器链做成可配置的。前面工厂的例子是极简的字符串配置,但在真实系统里,配置往往来自文件或控制台,甚至需要支持运行中热更新。
我曾经在某个数据管道系统里做过这样的设计:一个管道处理器的装配完全由JSON配置描述,每一层的装饰器名称、参数、顺序都在JSON里。系统启动时,配置解析器负责创建对应装饰器并组装。运维调整处理策略时,只需要改配置文件,然后平滑重启或用reload机制重建装饰链。这样一来,装饰器模式从一种代码级的设计模式,升级成了产品级的可配置策略框架。
这个方案在设计上有几个要点。第一,每个装饰器都要支持“从配置参数构造”,用依赖注入避免装饰器内部自己读配置。第二,要有一个注册表,把装饰器名字映射到工厂函数,这样新增装饰器不用改装配逻辑。第三,要允许对装饰链做合法性校验,比如检查不允许同时出现的装饰器组合。这三点做扎实,装饰器系统就能长期稳定地演进。
如果你的项目里也有大量横切逻辑需要灵活组合,这个方向非常值得一试。装饰器模式本身只是工具,如何把它组织成一套可维护的装配体系,才是真正的实战功力所在。
最后说点实在的。装饰器模式在C++里的核心不是UML图,不是“开闭原则”这些概念,而是三个具体的工程决策:接口是否稳定、所有权是否清晰、装配是否集中。把这三件事想清楚,你的装饰器代码基本不会出大岔子。反过来说,只要有一件没想明白,哪怕UML图画得再漂亮,代码也会在运行时的某个角落还给你颜色。我见过很多“实战”文章讲装饰器讲得天花乱坠,但真正有价值的往往是那些失败的案例、踩坑的经历和权衡取舍的思考。希望这篇基于实际项目经验的分享,能让你在自己的C++工程里少走一些我已经走过的弯路。