C++装饰器模式实战:组合优于继承的代码增强艺术
接手过数据上报模块的同学大概率都遇到过类似的场景最初只是把字符串落到本地文件后来产品要求在写之前先做加密再后来又要加签名、压缩还要统计每次写入耗时。如果每次新需求都在原来的 FileWriter 类里加一个if分支那个类很快就会膨胀成一坨没人敢动的代码。C 里的装饰器模式就是专门解决这类问题的不修改核心类的内部实现把“增强功能”拆成一个个独立的小类像套娃一样按需组合。这篇文章我会从需求推导、接口设计、代码实现、现代 C 进阶玩法、实战踩坑到应用场景完整拆一遍装饰器模式在 C 工程里的落地方式适合正在写业务代码的 C 开发者参考。1. 装饰器模式到底解决了什么问题1.1 一个真实项目的需求演变先还原一个典型的演进过程。你有一个FileWriter提供Write(const std::string)把数据写入文件。第一版很简单类里只有文件路径和真实的写入操作。接着运营那边说“数据太敏感落盘前要加密”于是你在Write方法里加了加密分支成员变量多了密钥。后来又提“日志文件太大写之前压缩一下”你又加了一个压缩分支管线变成了compress(encrypt(data))。再后来要记录耗时、要打日志、要支持不同加密算法切换这个类的方法和成员会越来越臃肿构造函数参数也越来越难读。更麻烦的是单元测试。每个分支组合都要单独测一个 10 行的Write方法最后可能有十几种状态组合测试用例数爆炸。说到底问题不在这些功能本身复杂而在于你把多个变化方向硬塞进了一个类里。加密、压缩、打日志、计时这些都是独立关注点它们本质上是“核心写入能力”的外层增强而不是核心写入能力的一部分。装饰器模式就是把这些增强从核心类里剥离出去各自独立再用组合的方式串起来。1.2 为什么继承扩展会很痛苦面对上面的需求很多人第一反应是用继承。写一个EncryptedFileWriter : public FileWriter再写一个CompressedFileWriter : public FileWriter然后如果要“压缩后再加密”就得再来一个CompressedEncryptedFileWriter。你很快会发现一个残酷事实如果核心能力有 3 个增强维度每个维度有两种开关状态组合类数量就是 2 的 3 次方等于 8 个。如果增强维度变成 5 个就是 32 个类。这还没算不同基类组合的情况。继承的第二个坑是行为绑定太死。子类在编译期就确定了从哪个父类继承客户代码一旦写成EncryptedFileWriter以后想临时不要加密得改类型、改构造而不是改一下“包装层”。而且继承体系里父类的构造函数改动会传导到所有子类任何一个中间层构造签名变化整个继承树都被波及。所以继承适合“本来就是一种”的稳定关系不适合“可以动态叠加”的增强关系。1.3 组合优于继承装饰器的核心思想装饰器模式换了个思路。它要求核心组件和所有装饰器都实现同一个抽象接口装饰器内部持有这个接口的一个对象。调用发生时外层装饰器先做自己的事情比如加密然后委托给内部对象继续处理读取数据时则反过来先把内层结果拿到再做自己的处理最后返回给更外层。这样一层层包下去就形成一个递归组合结构。这个模式最漂亮的地方是核心类FileWriter完全不知道外面有哪些装饰器每个装饰器也不知道自己外层是谁只知道下一个被调用对象是谁。新增一个增强维度不需要改任何现有类只需要新增一个装饰器类然后在使用方组装。这符合开闭原则对扩展开放对修改关闭。如果你想在运行时根据配置文件选择是否加密装饰器组合就是天然的支撑结构。2. 五步写出第一个 C 装饰器2.1 抽象接口装饰器的生命线写 C 装饰器第一步永远是定义抽象接口。不要急着定具体类先想清楚“核心操作到底是什么”。以文件流为例核心操作是写入和读取所以接口长这样class DataStream { public: virtual ~DataStream() default; virtual void Write(const std::string data) 0; virtual std::string Read() 0; };注意析构函数声明成virtual。后面所有操作都会通过基类指针持有可能的派生类对象如果不是虚析构通过基类指针delete时只会调用基类析构派生类的资源就泄漏了。这一点是 C 里所有多态基类的底线装饰器这种层层持有多态对象的场景尤其要重视。接口方法按需定义但也不要贪多方法越多装饰器被迫转发的方法就越多后面我会单独讲接口膨胀的问题。2.2 基础组件先有一个能跑的最小实现接口定义好之后实现一个最原始的具体组件也就是整个装饰链的最内层真实对象。这里用一个极简的FileStream路径在构造时传入真实写读逻辑用打印和返回字符串代替方便演示class FileStream : public DataStream { public: explicit FileStream(std::string path) : path_(std::move(path)) {} void Write(const std::string data) override { std::cout [File] path_ write data.size() bytes std::endl; } std::string Read() override { return [FileData]; } private: std::string path_; };这个类不需要知道任何装饰器的存在。它只需要保证自己实现了接口的全部方法行为是正确的“普通文件流”。以后无论外面套几层什么功能这个类本身都保持不变这恰恰是装饰器模式的核心收益核心逻辑不被污染。2.3 装饰器基类转发与所有权管理具体装饰器有个共同点都要保存被包装对象的引用或指针并且把接口方法默认转发给内部对象。所以抽一个装饰器基类很有必要否则每个装饰器都要重复写持有一份内部对象的代码。class StreamDecorator : public DataStream { public: explicit StreamDecorator(std::unique_ptrDataStream wrapped) : wrapped_(std::move(wrapped)) {} void Write(const std::string data) override { wrapped_-Write(data); } std::string Read() override { return wrapped_-Read(); } protected: std::unique_ptrDataStream wrapped_; };这里有两个关键决策。第一内部对象用std::unique_ptrDataStream持有而不是裸指针。装饰器持有所有权被包装对象的生命周期就跟着装饰器走装饰器析构时内层对象自动析构不需要手工管理也不容易出现悬垂指针。第二基类的Write和Read都提供了默认转发实现。如果某个装饰器只想增强Read而不碰Write它只需要继承StreamDecorator并重写ReadWrite自动转发代码非常干净。这种“默认转发”让装饰器真正做到按需重写。2.4 具体装饰器给读写链加压缩和加密现在写两个具体装饰器一个做压缩一个做加密。压缩我这里用占位函数演示真实项目你可以换成 zlib 或自研压缩算法。加密用最简单的 XOR 自反加密方便验证逻辑class CompressDecorator : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void Write(const std::string data) override { std::cout [Compress] before write std::endl; std::string compressed compress(data); wrapped_-Write(compressed); } std::string Read() override { std::string compressed wrapped_-Read(); return decompress(compressed); } private: static std::string compress(const std::string s) { return [c: s ]; // 演示用实际请换成 zlib 等 } static std::string decompress(const std::string s) { if (s.rfind([c:, 0) 0) { return s.substr(3, s.size() - 4); } return s; } }; class EncryptDecorator : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void Write(const std::string data) override { std::cout [Encrypt] before write std::endl; wrapped_-Write(xor_cipher(data)); } std::string Read() override { return xor_cipher(wrapped_-Read()); } private: static std::string xor_cipher(const std::string s) { std::string out s; for (char ch : out) { ch ^ 0x5A; } return out; } };这两个装饰器的结构一模一样重写Write时先做自己的增强处理再调用内层对象的Write重写Read时先取内层结果再做逆处理返回给外层。加密是 XOR 自反的解密就是再执行一次同样的函数所以Read里不用区分是先加密还是后解密。压缩的逆过程则要把包装前缀剥掉。核心点在于每个装饰器对接口的“契约”认知是完整对称的。2.5 组装与调用客户端怎么用装饰器写完之后使用方式非常灵活。你可以像搭积木一样嵌套make_uniqueint main() { auto stream std::make_uniqueEncryptDecorator( std::make_uniqueCompressDecorator( std::make_uniqueFileStream(data.bin) ) ); stream-Write(hello decorator); auto data stream-Read(); return 0; }运行时行为是写入时外层的EncryptDecorator先拿到原始数据hello decorator加密后传给内层CompressDecorator压缩后再传给最内层FileStream落盘。读取时完全反过来的FileStream先返回存储数据CompressDecorator解压EncryptDecorator解密最后外层拿到原始明文。这就是“写入从外往里走读取从里往外走”的规律。在这个组合里落盘内容是“先加密后压缩”的结果。从压缩率角度更合理的通常是先压缩后加密也就是把CompressDecorator包在最外层。具体选择取决于你是不是想让压缩算法作用在原始数据上。这个顺序问题非常容易踩坑我在第 4 章会展开讲。3. 现代 C 装饰器的进阶玩法3.1 模板装饰器编译期组合经典继承版装饰器依赖虚函数每个方法调用都是一次虚函数跳转。装饰链一旦很长性能敏感路径上会有明显开销。如果你明确知道装饰链在编译期就固定下来不想承受多态成本可以用模板装饰器。模板装饰器直接继承被装饰的具体类型并在自己内部调用基类方法templatetypename T class LoggingDecorator : public T { public: using T::T; void Write(const std::string data) override { std::cout [log] before write std::endl; T::Write(data); std::cout [log] after write std::endl; } };组装方式就从运行期的嵌套对象变成了编译期的类型嵌套using LoggedFileStream LoggingDecoratorFileStream; int main() { LoggedFileStream stream(data.bin); stream.Write(hello); return 0; }模板装饰器的好处是对象更紧凑、调用链在编译期展开、不需要make_unique和虚函数跳转。缺点是类型绑定死了编译完成后没法在运行时换成“不打印日志”的版本而且LoggingDecoratorFileStream和LoggingDecoratorEncryptDecoratorFileStream是不同的类型没法放进同一个vectorunique_ptrDataStream容器里。它适合组合关系稳定、性能优先的内部管道不适合需要运行时灵活配置的场景。3.2 函数式装饰链std::function 封装如果你的“增强”只作用于单个操作不需要维护一个完整对象接口可以用std::function做函数式装饰链这是我在很多项目里特别喜欢的轻量写法。比如只增强写入操作先定义基础函数再用高阶函数一层层包using WriteHandler std::functionvoid(const std::string); auto with_encrypt(WriteHandler next) { return [next std::move(next)](const std::string data) { std::string encrypted data; for (char ch : encrypted) ch ^ 0x5A; next(encrypted); }; } auto with_log(WriteHandler next) { return [next std::move(next)](const std::string data) { std::cout [log] before write std::endl; next(data); }; } int main() { WriteHandler base [](const std::string data) { std::cout [File] write data.size() bytes std::endl; }; WriteHandler handler with_log(with_encrypt(base)); handler(hello); return 0; }这段代码里with_encrypt返回一个新的std::function捕获了next作为内部处理函数调用时先做加密再调用next。with_log再包一层。整个装饰链是函数式的没有类、没有继承、没有虚函数成本极低。适合用在数据处理管道、中间件分层、AOP 式切入逻辑上。缺点是没有统一的接口对象装饰器之间只能通过函数签名耦合读写双向接口就要设计两个 function 字段复杂度会上升。3.3 完美转发与工厂函数组装手工嵌套make_unique在装饰层数少的时候清晰一旦超过四层代码就很难读了而且中间参数转写容易出错。更工程化的做法是用工厂函数。工厂函数接收配置在函数内部从最底层开始往上包并且利用完美转发把构造参数传给具体装饰器struct StreamConfig { std::string path; bool encrypt false; bool compress false; }; templatetypename DecoratorT, typename... Args auto make_decorated(std::unique_ptrDataStream wrapped, Args... args) { return std::make_uniqueDecoratorT(std::move(wrapped), std::forwardArgs(args)...); } std::unique_ptrDataStream create_stream(const StreamConfig cfg) { std::unique_ptrDataStream result std::make_uniqueFileStream(cfg.path); if (cfg.compress) { result make_decoratedCompressDecorator(std::move(result)); } if (cfg.encrypt) { result make_decoratedEncryptDecorator(std::move(result)); } return result; }这里make_decorated的关键是std::forwardArgs(args)...它保持了参数左右值属性不变装饰器需要移动语义时不会被卡住。工厂函数的意义不只是封装构造细节更重要的是集中管理装饰器的组装顺序。客户端只要传一个StreamConfig加密、压缩这些增强全走配置开关代码可读性和可维护性都上了一个台阶。4. 实战中踩过的坑与排查技巧4.1 读写顺序加密和压缩到底谁在外谁在里装饰器模式最容易踩的坑就是读写顺序不对称。假设你写入端用的是Encrypt(Compress(File))那么落盘前的数据流是data - encrypt - compress - file文件里保存的是压缩后的密文。读取端必须严格倒着还原file - decompress - decrypt - data也就是EncryptDecorator在最外层的时候Read必须先内层解压再解密。如果你读取端图省事也写成了同样的嵌套顺序直接调Read数据就是乱的。更隐蔽的问题是“哪种包装顺序更科学”。一般原则是先压缩后加密得到的是encrypt(compress(data))这样压缩算法处理的是原始冗余数据压缩率最好先加密后压缩得到的是compress(encrypt(data))密文熵很高压缩基本不起作用还可能把数据变大。很多新手上来就写Encrypt(Compress(File))以为加密是安全层应该在最外结果发现压缩率惨不忍睹。经验做法是写文件场景用Compress(Encrypt(File))即先压缩后加密。当然如果业务上有特殊的检查规则那就按业务来但一定要用工厂函数把读端和写端的组装顺序统一管理别手工拼。4.2 生命周期unique_ptr、裸指针与悬垂对象装饰器持有一个内部对象这个所有权的边界如果没定义清楚就会出现悬垂指针或 double free。最典型的问题是把同一个裸指针同时传给两个装饰器auto* raw new FileStream(data.bin); auto e1 std::make_uniqueEncryptDecorator(raw); auto e2 std::make_uniqueCompressDecorator(raw); // 问题两个 unique_ptr 同时拥有 raw这段代码编译能过但运行时会 double free第一个装饰器析构时会delete raw第二个装饰器析构时再delete raw一次直接崩溃。正确做法是每次包装前用std::move转移所有权保证任何时候只有一个所有者。如果装饰器只负责“借用”对象而不负责回收那就用DataStream*或std::shared_ptrDataStream但必须非常明确谁负责释放。我自己的习惯是装饰器内部一律用std::unique_ptr外层对象的所有者就是最外层变量整条链上的所有权清晰不存在二义性。4.3 接口膨胀与动态类型判断这些信号说明设计要重构装饰器模式运行得好依赖接口稳定且足够小。我见过一个工程里DataStream有七八个纯虚函数每个装饰器都要写一遍转发代码又臭又长。接口膨胀的根源是抽象没有收敛把不同维度的操作混进了同一个接口。解决办法是给接口瘦身把高频核心方法保留为纯虚低频方法用非虚接口默认组合实现或者干脆把接口拆成几个小接口装饰器只增强关心的小接口。另一个危险信号是装饰器内部出现dynamic_cast或typeid判断。比如想判断内层是不是某个具体类型再做特殊处理这往往说明装饰器违反了它“只依赖接口”的约束也说明真正需要的增强没有被抽象到接口层。遇到这种情况优先检查你的接口设计是不是漏了某个操作而不是用类型判断去强行补漏洞。4.4 重复装饰、缓存副作用与性能损耗给同一个对象重复套相同装饰器是很容易被忽略的问题。最直观的就是日志包了两个LoggingDecorator每笔业务就会打印两条日志排障时反而被日志刷屏。缓存装饰器也类似如果包两层缓存外层缓存 miss 后内层缓存大概率也 miss等于白白多占一层内存。所以装饰链组装前要检查配置项防止同一个增强被重复添加。这种检查放在工厂函数里最合适因为所有客户端都走同一个入口。性能方面虚函数链的代价也不能忽略。每次写数据都要经过 N 次虚函数跳转装饰器层数越多开销越大。如果这条路径是高频热点优先考虑第 3 章的模板装饰器或函数式装饰链它们可以避免虚函数开销。也可以把多个装饰器合并成一个“复合装饰器”用一次虚函数调用内部做多件事虽然灵活性下降但性能可控。4.5 常见问题速查表症状可能原因处理方式编译报错 “declared pure virtual”装饰器没有实现某个纯虚函数在具体装饰器里补上所有未实现的接口方法析构时崩溃基类析构函数不是 virtual给抽象接口基类添加virtual ~DataStream() default;读取数据乱码读写端装饰器顺序不一致统一使用工厂函数组装严格执行“写从外到内、读从内到外”运行时 double free同一个裸指针被多个装饰器持有改用unique_ptr并std::move转移所有权内存持续增长缓存装饰器缓存无上限给缓存装饰器加容量限制或 TTL性能明显下降装饰链虚函数层数太多用模板装饰器或函数式装饰链优化热点路径动态类型判断越来越多接口设计漏了增强点重构接口把需要的行为收敛成接口方法5. 装饰器模式在真实项目里的应用场景复盘5.1 日志与性能埋点最安全的入门应用在业务代码里打日志是最容易破坏结构的操作因为日志本质上是横切关注点和业务逻辑无关。装饰器可以把日志逻辑完全抽走。给核心服务类包一层MetricsDecoratorRead方法开头记一个起始时间调用内部对象后记录耗时和返回大小业务代码里不再出现一行计时逻辑。这种增强的粒度可以很细比如只统计慢请求超过阈值才打印告警。最重要的是切到生产环境时你可以在工厂函数里直接不包这个装饰器日志和埋点全部静默一切改动都在使用方完成核心服务类不受影响。5.2 权限校验与参数校验把非业务逻辑从核心代码里拆出去很多服务接口在真正干活之前都要做权限校验、参数合法性校验、频率限制。这些逻辑直接写在业务方法里会让核心代码越来越杂。用装饰器包一层校验器校验不过就直接抛异常或返回错误根本不会调用内部业务对象。这样做的好处是业务对象专心做业务校验逻辑可以单独测试更换校验规则时也不用碰业务类。不过要注意权限校验这一类装饰器很容易滑向“代理模式”。区别在于意图代理关注“控制访问和生命周期”装饰器关注“增强行为”。实践中不必过分纠结命名只要校验逻辑是层层叠加在核心对象外面而且可以按需组合你用的就是组合增强的思路。5.3 缓存增强装饰器做缓存要注意什么缓存是个典型的装饰场景接口方法调用前先查缓存命中则直接返回未命中才调用内部对象并回填缓存。但缓存装饰器有几个特殊的坑。第一key 怎么生成。装饰器不知道业务语义它只能基于方法参数做通用序列化所以适合用参数 hash 作为 key。如果你的方法有对象指针等无法序列化的参数缓存装饰器就不适用了。第二缓存一致性。装饰器只管查缓存和写缓存没有能力知道底层数据是否变化所以使用方必须在数据变更时主动失效缓存。第三缓存数量要有上限否则内存会被占满。这些约束不由装饰器保证而是由组合链路的设计者负责。所以在引缓存装饰器之前先想清楚参数到 key 的映射和失效策略这也是我对缓存装饰器最想强调的一点。5.4 协议栈与流处理类似 TCP/TLS/HTTP 的封装思路装饰器模式在网络协议栈中的应用非常自然。想象一条基础字节流外面包一层“加密装饰器”变成 TLS 流再包一层“编码装饰器”变成 HTTP 流最外层业务层拿到的就是一个完整的 HTTP 请求对象。每层只关心自己职责范围内的头处理或负载转换下层到底是什么存储或传输介质上层完全不关心。这种思路在 C 里也适合做音视频数据流处理基础数据源、加滤镜装饰器、加水印装饰器、加编码装饰器每一层都是纯增量处理。对比传统的管线代码装饰器最大的优势是把每一级处理变成可独立测试、可配置的组合单元不用在一条几百行的函数里追每一帧数据被哪个模块改过。5.5 与代理、策略、责任链的边界对比很多初学者会把装饰器和几个近亲模式搞混我整理了一个速查表格帮你看清差别模式核心目的典型场景和装饰器的关键差异装饰器按顺序叠加增强行为流处理、缓存、日志、加解密每个装饰器都会执行链路不会中断代理控制访问、生命周期管理远程接口代理、懒加载代理通常关注“是否放行本次调用”不一定做增量处理策略替换一组算法排序算法、压缩算法切换一次调用只走一个策略不叠加责任链一个一个尝试处理可能中断审批流、多重兜底处理某个节点处理完就可能终止装饰器不会终止判断自己该用哪个模式时核心问一句这次调用是需要“做更多增强”还是需要“决定要不要继续”或者“换成另一种算法”。答案不同选型就不同。6. 关于装饰器模式我的几个实用建议用了几年装饰器模式之后我最大的体会是装饰器模式本身不难难在控制接口稳定性和管理组装顺序。我现在写装饰器有几个固定习惯接口只保留最核心的三五个纯虚方法内部对象一律unique_ptr持有所有装饰链都走工厂函数生成禁止在业务代码里手工嵌套make_unique。另外一个小技巧是给装饰器加一个Describe()方法每层都把自己的名字追加进字符串排障时直接打印整个链一眼看出当前对象被套了几层、顺序对不对比断点一层层看快得多。如果你刚开始在项目里推广装饰器模式我建议先从日志和耗时统计这类无副作用、无状态变更的装饰器入手它们最容易写出纯正的装饰器也不会因为缓存或权限逻辑引入额外复杂度。等技术成熟了再逐步把缓存、协议栈这类带状态和顺序要求的场景交给装饰器管理。说到底装饰器模式是 C 工程里最实用的一组组合技巧它背后的“组合优于继承”思想比任何一个具体设计模式都更值得你在日常编码里长期实践。