如果你维护的 C 代码里开始出现FooWithBar、FooWithBaz、FooWithBarAndBaz这种命名越来越长的类而且每个新类的存在意义只是把两个已有功能拼在一起那你大概率已经踩进了类爆炸的坑。装饰模式decorator pattern就是专门解决这个问题的它不修改原有类不新增一大堆排列组合式的子类而是通过一层层包装对象在运行时给对象动态叠加职责。我在实际项目里用过它写数据处理管线、日志系统和界面控件属于使用频率很高的结构型设计模式。这篇文章我从 C 工程落地的角度把装饰模式的四个角色、调用链、内存管理、完整实战代码以及我在真实项目里踩过的坑都过一遍。适合已经会用继承和多态但开始被功能组合需求逼得想重构的 C 开发者参考。1. 装饰模式解决的问题当你的子类开始按功能个数爆炸时1.1 一个数据输出模块的继承演进史假设你要做一个数据输出模块。第一版需求很简单往控制台打印一行文本。你很自然会写一个Output接口然后实现ConsoleOutputclass Output { public: virtual void write(const std::string data) 0; virtual ~Output() default; }; class ConsoleOutput : public Output { public: void write(const std::string data) override { std::cout data std::endl; } };过了一阵子产品说要支持写文件。好你加一个FileOutput。又过了一阵子说要支持压缩后输出。好你加一个CompressedFileOutput。接着要支持加密、要支持缓冲、要支持前置时间戳。每来一个功能你就得问自己这个功能和已有的功能会不会同时出现如果只有压缩和文件两个维度你还能忍CompressedConsoleOutput、CompressedFileOutput再加原始两个总共也就 4 个类。但维度一旦多起来情况就完全不一样。缓冲、加密、压缩、时间戳、按大小轮转、断点续传……每一个新需求都会和已有需求自由组合。这时候你的类数量会呈指数级增长类名长得像在写加密协议。这个发展过程几乎每个做业务系统的 C 开发者都会经历一遍。一开始觉得继承很好用代码复用很爽后来发现复用和组合是两回事继承解决不了运行时的自由组合问题反而会把类的数量拖向失控。1.2 组合数公式与运行时选择这道坎假设现在有 n 个可选功能比如压缩、加密、缓冲、加时间戳每个功能都能和其他功能任意叠加。用继承去覆盖所有组合需要 2^n 个子类1 个功能2 个子类原始功能类 带功能的类2 个功能4 个子类3 个功能8 个子类4 个功能16 个子类5 个功能32 个子类这只是理论上的最小值。实际操作中每个子类还得写构造函数、成员变量、初始化逻辑代码量不是线性增长而是指数膨胀。而且这些组合在编译期就被固定死了程序跑起来以后用户想临时换一种组合方式你必须提前把所有可能的组合都编译进去。这就像你想点一杯半糖去冰加珍珠少奶盖的奶茶但菜单上只提供几种提前做好的固定搭配服务员当场帮你组合这件事根本不在系统设计之内。装饰模式的核心思路就是把这个当场组合的能力做进系统里。它不再为每一种组合创建新类而是把每个功能拆成一个独立的装饰器这些装饰器实现同一个接口内部再持有一个同接口的对象。你想要的组合就是你调用时把它们一层套一层。代码数量从 2^n 个类变成 n1 个类而且组合发生在运行时用户想要什么搭配就现拼什么搭配。2. 四个角色一条链装饰模式的构成与一次调用的完整路径2.1 Component、ConcreteComponent、Decorator、ConcreteDecorator各扮演什么装饰模式虽然概念不复杂但它有非常明确的结构。先记住这四个角色后面所有代码都建立在这套结构上角色作用C 中的表现Component 抽象构件定义业务接口统一装饰器与被装饰对象的对外行为抽象基类含纯虚函数ConcreteComponent 具体构件基础功能的实现是装饰链的最内层最底层的具体类如 ConsoleOutputDecorator 抽象装饰器持有一个 Component 对象并实现同样的接口抽象类内含指向 Component 的指针/智能指针ConcreteDecorator 具体装饰器在调用内层对象前后附加自己的逻辑每个具体功能对应一个类如 CompressDecorator一句话串起来就是装饰器继承接口、包含对象、转调对象在转调前后做自己的事。由因为装饰器和被装饰对象实现的是同一个接口所以装饰器可以继续被另一个装饰器包装递归嵌套。这里有一个容易被忽略的重点装饰器内部持有的对象类型是 Component也就是抽象接口而不是某个具体类。这一点保证了装饰器只依赖抽象不依赖具体实现。你可以给FileOutput加压缩也可以给ConsoleOutput加压缩甚至给一个已经被压缩的FileOutput再加一层加密装饰器本身不需要改动。2.2 用咖啡订单跑一遍装饰链cost()调用如何逐层传递用一个经典的咖啡订单例子来跑调用链。基础咖啡是 Espresso可以加 Mocha摩卡、Whip奶泡等配料。#include iostream #include memory #include string class Beverage { public: virtual std::string description() const 0; virtual double cost() const 0; virtual ~Beverage() default; }; class Espresso : public Beverage { public: std::string description() const override { return Espresso; } double cost() const override { return 2.00; } }; class CondimentDecorator : public Beverage { protected: std::unique_ptrBeverage beverage_; public: explicit CondimentDecorator(std::unique_ptrBeverage beverage) : beverage_(std::move(beverage)) {} }; class Mocha : public CondimentDecorator { public: using CondimentDecorator::CondimentDecorator; std::string description() const override { return beverage_-description() , Mocha; } double cost() const override { return beverage_-cost() 0.50; } }; class Whip : public CondimentDecorator { public: using CondimentDecorator::CondimentDecorator; std::string description() const override { return beverage_-description() , Whip; } double cost() const override { return beverage_-cost() 0.30; } };客户端这样组装一杯Espresso 双份摩卡 奶泡int main() { auto order std::make_uniqueWhip( std::make_uniqueMocha( std::make_uniqueMocha( std::make_uniqueEspresso()))); std::cout order-description() : order-cost() std::endl; return 0; }输出Espresso, Mocha, Mocha, Whip : 3.30注意描述和价格都是递归累加的。当外部调用order-cost()时执行路径是这样的Whip 的cost()先调用内层 Mocha 的cost()Mocha 再调用更内层 Mocha 的cost()最内层 Espresso 返回 2.00然后逐层加上自己的 0.50、0.50、0.30最终得到 3.30。这条链之所以能成立是因为每一层的接口都一样description()和cost()。每一层不用关心内层是什么对象只需要调用接口然后处理自己的增量逻辑。这就是装饰模式最优雅的地方——功能以链式叠加的方式工作而不是以类型排列组合的方式工作。3. C实现装饰模式的几个落地细节继承、所有权和拷贝3.1 虚析构是绕不过去的第一个细节C 里用装饰模式第一个要注意的点就是析构函数。因为所有装饰器都通过基类指针持有对象装饰链也经常通过基类指针操作所以整个继承体系最顶层一定要声明虚析构函数class Beverage { public: virtual std::string description() const 0; virtual double cost() const 0; virtual ~Beverage() default; // 必须 };如果不写这个虚析构通过std::unique_ptrBeverage删除一个Mocha对象时行为是未定义的。编译器只会调用Beverage的析构派生类析构函数永远不会执行如果派生类里持有资源就会泄漏。这个细节看起来基础但我在不少项目里见到过因为装饰器基类漏写虚析构导致的诡异内存泄漏建议当作默认习惯来记。另外装饰器类本身一般不需要额外写析构函数因为资源都是通过智能指针管理的。如果某天你发现需要在装饰器析构里做清理动作比如关闭文件句柄、断开网络连接那就要想清楚这个装饰器是否真正拥有底层资源的所有权以及析构顺序是否符合预期。3.2 装饰器里该用unique_ptr还是shared_ptr装饰器内部保存被装饰对象C 里最常见的选择是std::unique_ptr。使用unique_ptr表达的是装饰器拥有被装饰对象的所有权装饰链一旦构建完成整条链的生存期就由最外层所有者的生命周期管理。如果不涉及共享我强烈推荐unique_ptr理由有几个语义清晰装饰链是一个明显的所有关系链从外层到内层逐个拥有。性能好没有引用计数的额外开销。防止无意共享一个被装饰对象同时被两个装饰器引用这种情况在设计上通常是坏的unique_ptr在编译期就杜绝了。那什么时候用shared_ptr如果你的被装饰对象是某个公共资源确实需要被多个装饰器共享比如同一个核心服务对象同时要接到日志装饰器和监控装饰器上可以考虑shared_ptr。但这种用法会让装饰链变成菱形结构调用路径会变复杂需要非常明确语义。大多数场景下一层包一层的链式结构用unique_ptr就够了。还有一条路是裸指针配合外部所有权管理。我不推荐装饰模式天然增加了对象层级裸指针很容易在某个环节泄漏或者双重释放。除非你有严格的架构约束和生命周期管理工具否则智能指针是现代 C 下的默认选择。3.3 拷贝语义浅拷贝与clone方案的取舍装饰器对象一旦持有了被装饰对象拷贝问题立刻浮现。如果装饰器内部用的是unique_ptr拷贝是禁用的这正好保护你一个装饰器默认不允许复制想要复制就必须显式设计。如果内部用的是shared_ptr默认拷贝会让两个装饰器共享同一个被装饰对象——这通常不是你需要的行为。假如你确实需要复制一条装饰链比如想把当前日志配置复制一份给另一个模块使用我建议优先定义一个纯虚的clone()方法class Beverage { public: virtual std::string description() const 0; virtual double cost() const 0; virtual std::unique_ptrBeverage clone() const 0; virtual ~Beverage() default; };每个具体装饰器实现 clone 时先递归克隆内层再构造自己std::unique_ptrBeverage Mocha::clone() const { return std::make_uniqueMocha(beverage_-clone()); }这种方案比依赖shared_ptr浅拷贝要安全得多。它也显式告诉读者装饰链的复制是深复制每层都要克隆。在真实项目里很多人会把复制装饰链和复用装饰链搞混前者应该 clone后者应该直接共享外层指针。区分好这两种需求代码就不容易埋隐患。4. 一个能直接抄的实战案例日志系统的时间戳、等级过滤和压缩装饰器4.1 需求拆解怎么把日志需求映射成装饰器下面给一个我在项目里反复用过的场景日志输出组件。假设需求是这样的底层输出方式有两种打印到控制台、写入文件希望可以叠加功能前缀时间戳、日志等级、线程名/模块名希望可以在输出前对日志进行过滤比如只输出 INFO 及以上等级的日志后续可能还要加压缩、加密如果用继承光输出方式 x 时间戳 x 等级 x 过滤这个组合就已经爆炸了。装饰模式很自然地解决这个问题输出方式是最内层的ConcreteComponent时间戳、等级、过滤、压缩这些都是ConcreteDecorator每个装饰器只做一件事然后互相嵌套。设计接口时我强调一点接口越精简越好。这个Logger接口只有log(const std::string message)一个方法好处是所有装饰器只需要转发这一个方法代码非常轻量。如果接口里塞了open()、flush()、close()那每个装饰器都要转发所有方法很痛苦。4.2 代码实现Logger接口与三个装饰器#include algorithm #include chrono #include ctime #include iomanip #include iostream #include memory #include sstream #include string enum class LogLevel { DEBUG, INFO, WARNING, ERROR }; class Logger { public: virtual void log(const std::string message) 0; virtual ~Logger() default; }; class ConsoleLogger final : public Logger { public: void log(const std::string message) override { std::cout message std::endl; } }; class FileLogger final : public Logger { public: explicit FileLogger(const std::string path) : ofs_(path, std::ios::app) {} void log(const std::string message) override { ofs_ message std::endl; } private: std::ofstream ofs_; }; class Decorator : public Logger { protected: std::unique_ptrLogger inner_; public: explicit Decorator(std::unique_ptrLogger inner) : inner_(std::move(inner)) {} }; class TimestampDecorator final : public Decorator { public: using Decorator::Decorator; void log(const std::string message) override { auto now std::chrono::system_clock::now(); auto time std::chrono::system_clock::to_time_t(now); std::tm tm{}; localtime_s(tm, time); std::ostringstream oss; oss [ std::put_time(tm, %Y-%m-%d %H:%M:%S) ] message; inner_-log(oss.str()); } }; class LevelDecorator final : public Decorator { public: using Decorator::Decorator; void log(const std::string message) override { inner_-log([ level_to_string(current_level_) ] message); } void set_level(LogLevel level) { current_level_ level; } private: static std::string level_to_string(LogLevel level) { switch (level) { case LogLevel::DEBUG: return DEBUG; case LogLevel::INFO: return INFO; case LogLevel::WARNING: return WARNING; case LogLevel::ERROR: return ERROR; } return UNKNOWN; } LogLevel current_level_ LogLevel::INFO; }; class FilterDecorator final : public Decorator { public: using Decorator::Decorator; void log(const std::string message) override { if (current_level_ min_level_) { inner_-log(message); } } void set_min_level(LogLevel level) { min_level_ level; } private: LogLevel min_level_ LogLevel::INFO; };客户端组装一条带时间戳、带等级、带过滤、输出到文件再加一个控制台输出的日志链int main() { auto console std::make_uniqueConsoleLogger(); auto filtered std::make_uniqueFilterDecorator(std::move(console)); filtered-set_min_level(LogLevel::WARNING); auto leveled std::make_uniqueLevelDecorator(std::move(filtered)); auto timestamped std::make_uniqueTimestampDecorator(std::move(leveled)); timestamped-log(disk space low); timestamped-log(cache hit ratio is normal); return 0; }输出类似[2025-01-12 21:30:15] [WARNING] disk space low注意cache hit ratio is normal被过滤掉了因为它的默认等级低于 WARNING。你还可以继续加CompressDecorator、EncryptDecorator每加一个功能就是新增一个类而不是新增一堆组合类。这个例子里有个实现细节值得注意FilterDecorator的过滤操作发生在inner_-log()之前但没有在转发时附加任何文本。装饰器不一定都要修改消息内容它完全可以只控制是否转发、转发几次、转发顺序。很多刚接触装饰模式的人以为装饰器只能在消息前后拼字符串这是理解上的误区。4.3 装饰顺序为什么会影响最终输出装饰链的嵌套顺序直接决定最终行为。看下面这个场景组合方式实际效果适合场景Encrypt(Compress(logger))先压缩再加密网络传输的字节包更小且全程密文网络传输、对大小敏感的场景Compress(Encrypt(logger))先加密再压缩密文压缩率往往不高但压缩前数据已加密对安全更敏感、不关心体积的场景Timestamp(Filter(logger))先过滤再加工无效日志不会浪费时间戳转换大型系统中需要保证过滤成本最低Filter(Timestamp(logger))先加时间戳再过滤每条日志都先格式化再丢弃浪费 CPU不推荐除非过滤条件依赖时间戳内容我见过有人在日志模块里把FilterDecorator放在最外层结果每条日志都先经过时间戳格式化、等级拼接最后才被过滤掉浪费了大量 CPU 在无关日志上。实际项目中日志量大的时候这种浪费是非常明显的。所以组装装饰链之前先想想每个装饰器的职责是加工内容还是控制转发加工类装饰器放外层控制类装饰器放内层通常性能更好。这个规律在数据处理管线上同样适用消耗大的加工步骤尽量放在经过裁剪或过滤之后避免做无用功。装饰顺序的设计不仅仅是语义问题还是性能问题。5. 装饰模式的适用边界该用和不该用的场景5.1 三种真正适合装饰模式的场景第一类是功能可以横切、叠加的场景典型代表就是日志、权限校验、缓存、压缩、加密、格式转换。这些功能彼此独立叠加顺序有业务语义而且经常会动态组合。第二类是运行时需要动态配置能力组合的场景比如用户在前端勾选要压缩、要加密、要缓冲后端不需要在编译期穷举这些组合装饰模式可以在运行时按配置拼装。第三类是希望保持接口稳定的场景。底层实现不断变化但业务层只依赖抽象接口。装饰器在接口不变的前提下给实现加料上层代码完全无感。凡是符合接口不变、功能即插即用需求的地方装饰模式都很顺手。5.2 三类容易误用的场景装饰模式不是万能的。第一类误用是装饰器需要访问被装饰对象的内部状态。比如你想写一个ProgressDecorator它需要知道底层Downloader已经下载了多少字节。如果这些信息不在接口上装饰器就不得不做向下转型或者修改接口暴露内部数据。一旦出现这种情况说明装饰器实际上是在破坏封装你更应该考虑用观察者模式或者把进度信息提取到接口层。第二类误用是接口特别庞大。如果一个组件有十几个方法装饰器为了保持接口一致必须把所有方法都转发一遍哪怕某个装饰器根本不关心其中一半的方法。这种代码写起来无聊、读起来痛苦、维护起来折磨。遇到这种情况先想想能不能把接口拆分或者用非侵入式的方式做装饰。第三类误用是依赖装饰顺序才能保证正确性。如果一个功能必须在另一个功能之前执行否则就出错这时候把顺序隐含在装饰链里是不安全的。调用方一旦搞错顺序系统就出 bug而且这种 bug 很难排查。更好的做法是把顺序逻辑封装到工厂函数里只暴露固定顺序的组装结果而不是让每个调用者自己组装。5.3 和代理、适配器、策略模式的区分装饰模式在类图上和代理模式非常像都持有被包装对象都转发接口调用。但两者的意图完全不同模式核心意图是否改变原对象行为典型场景装饰模式动态添加职责层层叠加功能是在原行为基础上扩展日志加时间戳、数据流加压缩代理模式控制对真实对象的访问不一定重点是访问控制延迟加载、远程代理、权限代理适配器模式改变接口让不兼容的类能协作改变的是外部接口形状把旧接口适配到新接口策略模式在运行时切换算法改变的是算法实现排序算法、压缩算法的可替换具体来说代理通常不增加业务功能而是管理生命周期、做访问限制、实现延迟初始化装饰器则是增加实实在在的职责。适配器解决的是接口不匹配问题装饰器要求接口完全一致并层层透明。策略模式强调的是算法替换装饰器强调的是结构叠加两者解决的不是一个问题。在实际项目里我经常见到有人把访问控制写进装饰器里比如某个装饰器负责判断用户有没有权限再转发。这种做法也能工作但语义上会让装饰器的职责边界变得模糊。权限控制更适合用代理实现功能增强才交给装饰器。6. 实践中的经验与进阶写法6.1 接口精简装饰器好不好写的关键按照我刷过几个项目下来的体会装饰模式写起来爽不爽90% 取决于接口设计得够不够小。接口越小每个装饰器的转发就越简单新增装饰器的成本就越低。之前接手过一个旧模块底层的DataChannel接口有 7 个纯虚函数包括open、close、read、write、flush、seek、tell。在这个接口上写装饰器非常痛苦每加一个装饰器7 个方法全要转发哪怕这个装饰器只关心write。后来我们把接口拆分把StreamChannel和ReadableChannel分开装饰器数量没增加每行代码的维护成本却降了不少。如果你在设计阶段就预见到未来会有装饰器那就把接口收敛到这个对象最核心的职责。像日志就是log像输出流就是write像咖啡就是cost。多一个方法装饰器就多一份转发负担和出错可能。6.2 使用工厂函数把装饰链组装隐藏起来装饰模式的一个隐藏风险是调用者可能不小心组装出错误的顺序。为了不让每个使用方都自己拼装饰链我习惯提供工厂函数封装固定的组装流程。比如把日志配置封装成一个函数enum class LogSink { CONSOLE, FILE }; struct LogOptions { bool enable_timestamp true; bool enable_level true; bool enable_filter true; LogLevel filter_min_level LogLevel::INFO; }; std::unique_ptrLogger make_logger(const LogOptions options) { std::unique_ptrLogger logger; if (options.log_sink LogSink::FILE) { logger std::make_uniqueFileLogger(app.log); } else { logger std::make_uniqueConsoleLogger(); } if (options.enable_filter) { auto filter std::make_uniqueFilterDecorator(std::move(logger)); filter-set_min_level(options.filter_min_level); logger std::move(filter); } if (options.enable_level) { logger std::make_uniqueLevelDecorator(std::move(logger)); } if (options.enable_timestamp) { logger std::make_uniqueTimestampDecorator(std::move(logger)); } return logger; }调用方只需要关心LogOptions这个配置结构体不需要知道装饰器的存在也不需要关心组装顺序。这其实也解决了上一节提到的顺序隐含问题固定顺序由工厂保证调用者不会误组装。工厂函数把装饰模式的灵活性封装在内部外部暴露的是一个稳定的配置入口。这在实际工程里非常实用装饰链的灵活性未必需要每个调用者感知有时候内部灵活、外部稳定才是更合适的设计。6.3 用std::function实现的轻量装饰如果场景比较简单不想为了装饰功能建一整套类结构也可以考虑用std::function加lambda做轻量装饰。比如日志功能定义一个日志函数类型然后用函数包装函数using LogFunc std::functionvoid(const std::string); LogFunc console_log [](const std::string msg) { std::cout msg std::endl; }; LogFunc with_timestamp(LogFunc inner) { return [inner std::move(inner)](const std::string msg) { auto now std::chrono::system_clock::now(); auto time std::chrono::system_clock::to_time_t(now); std::tm tm{}; localtime_s(tm, time); std::ostringstream oss; oss [ std::put_time(tm, %H:%M:%S) ] msg; inner(oss.str()); }; } LogFunc with_level(LogFunc inner, const std::string level) { return [inner std::move(inner), level](const std::string msg) { inner([ level ] msg); }; } int main() { LogFunc logger console_log; logger with_timestamp(std::move(logger)); logger with_level(std::move(logger), INFO); logger(service started); return 0; }这种写法不需要定义任何装饰器类代码短适合传递函数指针、回调队列、过滤管道这种轻量场景。代价是丢失了类型层级调试时函数栈会比类更抽象而且不容易用多态方式做扩展。我的建议是如果只是两三层简单包装std::function版本更快如果功能要长期演进、要有很多组合类版本的装饰模式更清晰。此外还有一种半装饰的写法就是用enable_if或if constexpr在编译期做功能叠加适合功能组合在编译期就确定好的场景。它不算严格意义上的装饰模式但可以看作是继承和装饰之间的一种折中。具体选哪种关键还是看需求组合是否发生在运行时决定了你需要类版本还是模板版本。我自己的实践体会是装饰模式是那种设计时很简单、实现时细节很多、用好了极其顺手的模式。它的价值不在于它有多炫而在于它把功能组合这个从需求层面就到来的诉求用很直白的方式落到了代码结构上。最后再分享一个小技巧我会在每个装饰器基类里加一个可选的const char* tag()方法默认返回类名调试的时候输出整条装饰链的顺序排查组装问题特别省力。这个习惯救了我好几次建议你试试。