ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++装饰器模式实战:告别继承爆炸,用层层包装优雅扩展功能

C++装饰器模式实战:告别继承爆炸,用层层包装优雅扩展功能 先说个真实场景。我前几年接手过一个日志组件需求一开始就两个往文件里写、往控制台里写。后来产品经理加功能先是加缓存然后要加密再然后要校验和最后还要压缩。最离谱的是这些功能开关还得在运行时动态切换——测试环境只开缓存生产环境要加密加校验某些客户还要求全开。要按老写法每加一个功能就派生子类几轮迭代下来类会膨胀成什么样我估计你们都能猜到。我当时重构用的方案就是这篇要聊的装饰器模式。这篇内容适合两类人一类是刚学完C语法想弄明白设计模式在实际项目里到底怎么落地的同学另一类是写过一阵子业务代码正在被“继承爆炸”困扰想找一条更干净扩展路径的开发者。我会把装饰器模式在C里的完整实现思路、实际案例、典型坑位以及现代C下的几种“减重用法”都拆开讲清楚让你看完就能上手改代码。1. 为什么需要装饰器继承“堆功能”的失控现场很多初学者对设计模式的第一反应是“又多了一种花哨写法”但装饰器模式恰恰是那种“你不主动用它迟早会被迫重新造一个出来”的模式。要理解它的价值得先看清楚继承在应对功能组合时的崩溃过程。1.1 从一个小需求开始的类爆炸假设我们要做一个数据写入组件基础的数据源有两种文件流和网络流。一开始需求很简单就是“能写”。代码量很小两个类就够class FileStream { public: void write(const std::string data); }; class NetworkStream { public: void write(const std::string data); };然后需求来了要给写入过程加缓冲。很多人第一时间想的是“继承它”class BufferedFileStream : public FileStream { // 重写write先写进缓冲区 }; class BufferedNetworkStream : public NetworkStream { // 同上 };看起来还行两个新类而已。可下一个需求是加密于是class EncryptedFileStream : public FileStream { ... }; class EncryptedNetworkStream : public NetworkStream { ... };这时候还不算太离谱。但真正的灾难是“既要缓冲又要加密”——请问这个类从哪里继承如果你从BufferedFileStream继承那么BufferedNetworkStream那边的组合你还得再写一遍。做个简单的排列组合基础类型有2种附加功能有3个缓冲、加密、校验那么需要的具体类数量是 2 × 2³ 16 个。如果基础类型变成4种附加功能变成5个那就是 4 × 2⁵ 128 个类。这还没算功能本身还有参数变体比如加密算法A和加密算法B。这就是典型的“继承堆功能”失控现场。你每增加一个附加功能所有基础类型都得各自派生一遍每增加一个基础类型所有附加功能又得给它适配一遍。类数量呈指数级膨胀代码重复几乎是必然的而且一旦某个组合的逻辑要微调你会发现自己根本找不到该改哪个类。1.2 真正的问题功能变化频率远高于类型变化频率再仔细琢磨一下上面这个场景会发现一个本质矛盾基础类型文件、网络变化得慢附加功能缓冲、加密、校验变化得快。继承把“附加功能”硬编码进了类的继承树上相当于把变化最频繁的维度提前固定死了。每来一个新附加功能整棵继承树都要跟着动这直接违背了开闭原则——对扩展开放、对修改关闭。那有没有一种办法让附加功能可以“即插即用”这就是装饰器模式的核心思想把附加功能做成独立的包装类每个包装类持有被包装对象的引用包装类对外暴露相同的接口调用时由最外层逐层向内最终落到原始对象上。这个思路就像你去奶茶店点单一杯基础茶底是“原始对象”加珍珠、加奶盖、加芋泥就是一个个“装饰器”。你想加几个就加几个不想加就只要茶底。老板不需要为了“珍珠奶盖芋泥茶”单独创建一个菜单项因为任何组合都能用“茶底装饰”拼出来。2. C实现装饰器的基础接口、组件与两个角色要把装饰器模式在C里落地先记住一句话装饰器的存在前提是接口统一。如果被装饰的对象没有一个共同的抽象基类装饰器就无从谈起因为包装类没法在“接口层面”替代原始对象。2.1 第一步定义抽象接口在C里这个抽象接口通常是一个含有纯虚函数的抽象类class Stream { public: virtual ~Stream() default; virtual void write(const std::string data) 0; virtual void flush() 0; };我见过不少初学者在这里出错他们图省事直接拿FileStream当基类然后让装饰器继承FileStream。这样做有两个坏处如果FileStream里有非虚成员函数装饰器重写不了接口就不统一了FileStream的构造函数可能有参数比如文件名装饰器被迫也要处理这些无关的参数耦合度变高。正确的做法是先抽象接口再让所有具体组件和装饰器都实现这个接口。这个接口就是整条装饰链的“共同语言”。2.2 具体组件被装饰的原始对象具体组件是装饰链的终点它是真正干活的类不需要知道装饰器的存在。拿数据流来说class FileStream : public Stream { public: explicit FileStream(const std::string path) : path_(path) {} void write(const std::string data) override { // 实际写入文件的逻辑 std::cout [File] write to path_ : data \n; } void flush() override { std::cout [File] flush\n; } private: std::string path_; };注意这个类重写的write和flush会在装饰链的最里层被调用。你可以在心里把装饰链想成一个洋葱具体组件就是洋葱最里面的芯。2.3 装饰器基类那个“转发一切”的中间层装饰器基类是理解这个模式的关键也是最容易被忽略的部分。它的作用是持有一个指向Stream的指针指向被包装的对象默认把所有接口调用转发给被包装对象具体装饰器只重写它关心的那一个方法其他方法继续走默认转发。代码是这样的class StreamDecorator : public Stream { public: explicit StreamDecorator(Stream* wrapped) : wrapped_(wrapped) {} void write(const std::string data) override { wrapped_-write(data); } void flush() override { wrapped_-flush(); } protected: Stream* wrapped_; };这个“转发一切”的中间层价值非常大。如果没有它每个具体装饰器都得把write和flush都重复实现一遍。有了它具体装饰器只需要重写自己关心的行为。用生活类比解释一下装饰器基类就像一个公司前台所有外部来电它都默认转给对应部门。新来的装饰器同事只需要告诉前台“我管哪条线”而不是把所有线路都自己重新布一遍。2.4 具体装饰器在转发前后插入逻辑有了基类具体装饰器写起来非常清爽。比如加密装饰器class EncryptedStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { // 在转发前先把数据加密 std::string encrypted encrypt(data); wrapped_-write(encrypted); // 或者直接调用 write 的默认转发 } void flush() override { wrapped_-flush(); } private: static std::string encrypt(const std::string data) { // 示意代码真正的加密算法按需替换 std::string result data; for (char c : result) c static_castchar(c ^ 0x5A); return result; } };同样缓冲装饰器可以在write之前先攒数据在flush时一次性写出class BufferedStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { buffer_ data; if (buffer_.size() 1024) { wrapped_-write(buffer_); buffer_.clear(); } } void flush() override { if (!buffer_.empty()) { wrapped_-write(buffer_); buffer_.clear(); } wrapped_-flush(); } private: std::string buffer_; };注意这里的调用次序装饰器在转发前做前置处理在转发后做后置处理这也是装饰器能层层叠加、灵活控制行为次序的根本原因。3. 实战案例给数据流做一层透明的“加工流水线”前面讲了理论这一节直接上完整案例。我以一个日志写入管线为例把装饰器模式的组合方式、调用链、以及藏在细节里的坑都铺开讲。3.1 需求拆解日志组件为什么会变成“流水线”先看需求背景。一个日志系统基础写入目标是文件。随着项目演进陆续出现这些附加需求需求说明对应的装饰器缓冲减少磁盘IO次数攒够一批再写BufferedStream加密敏感日志字段加密落盘EncryptedStream校验和写入前计算校验值读的时候验证完整性ChecksumStream压缩落盘前压缩节省磁盘CompressedStream如果这些需求是同时要开的用继承会写出多少个类就不用多说了。用装饰器模式我们只需要实现4个装饰器类然后在运行时自由拼装。3.2 完整实现代码我这里把接口、基础组件和所有装饰器都写了一遍代码不多但每行都值得看。为了省篇幅各装饰器的算法都用了示意实现真实项目里替换成对应的库调用即可#include iostream #include string #include memory #include vector // 抽象接口 class Stream { public: virtual ~Stream() default; virtual void write(const std::string data) 0; virtual void flush() 0; }; // 具体组件文件流 class FileStream : public Stream { public: explicit FileStream(std::string path) : path_(std::move(path)) {} void write(const std::string data) override { std::cout [File: path_ ] data \n; } void flush() override { std::cout [File: path_ ] flush\n; } private: std::string path_; }; // 装饰器基类 class StreamDecorator : public Stream { public: explicit StreamDecorator(Stream* wrapped) : wrapped_(wrapped) {} void write(const std::string data) override { wrapped_-write(data); } void flush() override { wrapped_-flush(); } protected: Stream* wrapped_; }; // 具体装饰器1缓冲 class BufferedStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { buffer_ data; if (buffer_.size() 1024) { wrapped_-write(buffer_); buffer_.clear(); } } void flush() override { if (!buffer_.empty()) { wrapped_-write(buffer_); buffer_.clear(); } wrapped_-flush(); } private: std::string buffer_; }; // 具体装饰器2加密 class EncryptedStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { wrapped_-write(encrypt(data)); } void flush() override { wrapped_-flush(); } private: static std::string encrypt(const std::string data) { std::string result data; for (char c : result) c static_castchar(c ^ 0x5A); return result; } }; // 具体装饰器3校验和 class ChecksumStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { uint32_t sum checksum(data); wrapped_-write(data | std::to_string(sum)); } void flush() override { wrapped_-flush(); } private: static uint32_t checksum(const std::string data) { uint32_t sum 0; for (char c : data) sum static_castunsigned char(c); return sum; } }; // 具体装饰器4压缩 class CompressedStream : public StreamDecorator { public: using StreamDecorator::StreamDecorator; void write(const std::string data) override { wrapped_-write([compressed: data ]); } void flush() override { wrapped_-flush(); } };3.3 运行时拼装不同环境组合不同装饰器模式最大的好处就是拼装动作可以放到运行时。同样的四个装饰器类不同环境可以配出不同的链。比如开发环境只开缓冲生产环境缓冲加密校验还要压缩int main() { // 开发环境只加缓冲 auto dev_stream std::make_uniqueBufferedStream( new FileStream(dev.log) ); // 生产环境缓冲 加密 校验 压缩 auto prod_stream std::make_uniqueCompressedStream( new ChecksumStream( new EncryptedStream( new BufferedStream( new FileStream(prod.log) ) ) ) ); prod_stream-write(user login event); prod_stream-flush(); return 0; }调用prod_stream-write(...)时实际的数据流向是这样的CompressedStream::write先把数据标记为压缩转发给ChecksumStream::write计算校验和并拼到数据尾部转发给EncryptedStream::write整体加密转发给BufferedStream::write攒进缓冲区缓冲区攒够时转发给FileStream::write真正写盘。你可以把这条链想成工厂流水线每个装饰器都是一个工位每个工位只负责一道工序最后流到组装车间完成落盘。想调整工序只需要改变工位的“排列顺序”。3.4 组合顺序不是小事先加密还是先压缩这里有个特别容易忽略的点装饰器的顺序直接决定语义。还拿上面的生产环境配置举例子。如果顺序是“压缩 → 加密 → 校验”那么校验和是对加密后的数据计算的反过来如果先“校验”再“加密”校验和就是基于明文计算的。这两种方案在数据完整性验证时的行为完全不同如果在加密前校验解谜后校验和就能直接用于完整性校验如果在加密后校验那校验和本身是加密的校验时需要先解密才能比对。没有绝对的“哪个更好”但你必须清楚每种顺序的实际效果并且要在文档或配置里把顺序固定下来。我见过有项目因为在多处代码里以不同顺序拼装装饰器导致数据写出来格式不一致排查了整整一天。建议把所有拼装逻辑收敛到一个工厂函数或者配置文件里别散落在各个调用点。3.5 这个案例暴露的几个经典坑第一个坑忘了定义虚析构函数。装饰器链是通过基类指针管理的如果Stream没有virtual ~Stream()通过Stream*删除派生类对象时派生类析构函数不会被调用轻则资源泄漏重则程序崩溃。前面代码里我已经写了但这里强调一下这个不是可选项是必须项。第二个坑装饰器链的所有权归属。上面的代码里new FileStream(...)被传进装饰器装饰器销毁时要不要负责销毁它包装的对象这是装饰器模式在C里最绕的问题。建议的做法是用unique_ptr表达所有权转移装饰器持有unique_ptrStream而不是裸指针class StreamDecorator : public Stream { public: explicit StreamDecorator(std::unique_ptrStream wrapped) : wrapped_(std::move(wrapped)) {} protected: std::unique_ptrStream wrapped_; };这样拼装时make_unique一层层包所有权清晰也不会出现“out了某个中间对象导致链断裂”的问题。如果你确实想用裸指针做“借用的引用”那就要保证外部对象的生命周期一定长于装饰器链并且在文档里写清楚防止后续维护的人踩坑。第三个坑装饰器的拷贝行为。默认情况下装饰器拷贝会把包装指针一并浅拷贝两个装饰器可能指向同一个内部组件。如果其中一个链在执行write时改变内部状态另一个链会看到“被篡改”的数据。通常我们会把装饰器设计为不可拷贝的删除拷贝构造或者用shared_ptr统一管理内部组件按需选择。4. 现代C对装饰器模式的两种“减重方案”经典 GoF 书里的装饰器模式是用虚函数和继承实现的但到了现代C很多场景其实可以“轻量化”甚至“编译期化”。我根据自己的使用经验整理了两条更贴合现代写法的路径供你参考。4.1 std::function lambda 的轻量版装饰很多项目的实际需求并不是要给某个类体系加多个装饰器而是想给某一个函数调用加额外的行为比如打印耗时、加日志、失败重试。这种情况再上一整套StreamDecorator类体系确实有点杀鸡用牛刀。用std::function加 lambda 就够了写法非常直接#include functional #include iostream #include chrono // 原始函数 void sendRequest(const std::string url) { std::cout send request to url \n; } // 一个“装饰器”工厂给任意函数加耗时统计 template typename Func auto withTiming(Func func) { return [func std::forwardFunc(func)](auto... args) { auto start std::chrono::high_resolution_clock::now(); func(std::forwarddecltype(args)(args)...); auto end std::chrono::high_resolution_clock::now(); auto elapsed std::chrono::duration_caststd::chrono::microseconds(end - start).count(); std::cout elapsed: elapsed us\n; }; } int main() { auto timedSend withTiming(sendRequest); timedSend(https://example.com); return 0; }这种写法本质上是“函数式装饰器”它灵活、开销小、不用定义类适合装饰点比较集中的场景。它不适合的场景是多个方法需要一起被包装比如write和flush要同时被装饰或者装饰器本身还要参与运行时拼装链并保持对象状态。一句话总结装饰的是函数用lambda装饰的是对象体系用类装饰器。4.2 模板与CRTP把装饰挪到编译期如果你的装饰组合在编译期就能确定不需要在运行时动态切换那么可以用模板实现“零运行时开销”的静态装饰。最典型的思路是CRTP奇异递归模板模式template typename Base class EncryptedDecorator : public Base { public: using Base::Base; void write(const std::string data) { Base::write(encrypt(data)); } private: static std::string encrypt(const std::string data) { std::string result data; for (char c : result) c static_castchar(c ^ 0x5A); return result; } }; template typename Base class BufferedDecorator : public Base { public: using Base::Base; void write(const std::string data) { buffer_ data; if (buffer_.size() 1024) { Base::write(buffer_); buffer_.clear(); } } void flush() { if (!buffer_.empty()) { Base::write(buffer_); buffer_.clear(); } Base::flush(); } private: std::string buffer_; }; // 直接通过模板参数规定装饰顺序 using EncryptedBufferedFileStream EncryptedDecoratorBufferedDecoratorFileStream;这个方案的优点很突出没有虚函数调用编译器在编译期就能完成所有类型解析性能上几乎零损耗。但代价也很明显组合成了类型的一部分。一旦你编译期确定了EncryptedBufferedFileStream运行时就很难再改成“只缓冲不加密”的组合。所以它的适用范围是行为策略相对固定、性能要求很高的场景。4.3 运行时装饰 vs 编译期装饰怎么选我自己在项目里的经验可以用下面这个表格做快速判断对比维度运行时装饰经典类装饰器编译期装饰模板/CRTP组合时机运行时动态拼接编译期固定运行时开销有虚函数调用开销无虚函数可内联灵活度高可配置低类型固定代码复杂度中类多但清晰中高模板错误信息不友好适用场景配置驱动、插件化、需要开关切换性能敏感、组合固定大部分业务系统、中间件、日志库选运行时装饰就够了只有底层网络库、高频数据通路这类环境才值得考虑编译期装饰。5. 装饰器与邻近模式的边界以及实战中的选型经验设计模式学到后面最让人头疼的不是“怎么实现”而是“该不该用”以及“和另一个模式长得太像怎么办”。装饰器、代理、适配器这三种模式在类图上看都差不多都是“包了一层”但它们解决的问题完全不同。5.1 装饰器、代理、适配器的差异模式核心目的典型场景装饰器动态增加/叠加职责日志流加缓冲、加密、校验代理控制访问延迟加载权限拦截虚拟代理、远程代理、访问控制适配器转换接口让不兼容的接口互通把第三方库接口适配成自己的接口要记住一句话装饰器改变功能代理控制访问适配器转换接口。判断标准也很简单如果你包装之后对外仍然暴露同一个接口、核心行为不变只是增加附加行为那是装饰器如果你在调用前面加了一道“门卫”条件是能过才放行那是代理如果你把A接口翻译成B接口那是适配器。我见过一个真实的项目里有人用装饰器去做了权限校验某个请求来的时候先检查token不过就拒绝。其实这个场景用代理模式更贴切因为权限校验不是“附加能力”而是一种“访问控制”。虽然实现上几乎一样但语义不同会直接影响代码的可读性和维护性。5.2 我什么时候会用装饰器什么时候不会这几年用下来我给自己定了几条很朴素的选择标准分享给你第一当附加功能的组合是排列组合时无脑用装饰器。比如前面讲的流处理8种基础类型×5种附加功能不用装饰器就是类爆炸。第二当附加功能之间有明确的先后依赖时谨慎用装饰器。比如说先压缩再加密还是先加密再压缩这个顺序必须有全局约定不能把选择权完全交给调用方。如果顺序错了会导致数据不可用我倾向于把一种“标准顺序”固化到工厂函数里只允许调用方做有限的顺序选择而不是全开放。第三当功能本身是“二选一”的替代策略时用策略模式而不是装饰器。比如日志可以输出到文件也可以输出到网络这两个是平级的不是层层包装的关系适合用策略或者简单的接口分派硬套装饰器反而绕。第四当嵌套层数超过3层时我会停下手来想想。装饰器本身是灵活的但过深的链会让调用栈极难追踪。调试的时候你会面对一长串 A::write - B::write - C::write - D::write - ...每一层都得仔细看。如果你发现自己要加第四个装饰器了建议先问一句是不是应该把这些行为组合成一个独立的服务对象而不是继续往链上挂节点。5.3 保持装饰器健康的几条维护建议代码写出来是一时的怎么让后来的人包括三个月后的自己能轻松维护才是关键。我自己会坚持这几条一个装饰器只干一件事。缓冲就只管攒数据和刷盘不要顺手在里面加格式转换。职责混在一起之后组合顺序就变成“黑盒”业务根本没法猜数据到底经过了哪些变化。命名要一眼能看出是包装。BufferedStream、EncryptedStream、ChecksumStream后缀Stream表明它是流体系的一员前缀表明加了什么料。命名清晰能省去读者大量猜测。每个装饰器要有独立的单元测试也要有组合测试。单个装饰器测好后能确保“每层工位都没问题”组合测试则专门验证多装饰器一起工作时数据流转是否符合预期。尤其是顺序相关的组合我会写死几个顺序样本防止后续维护时有人随手改了拼装顺序。另外还有一个调试技巧可以在装饰器基类里加一个std::string debugName_或者提供一个debugPrint()方法打印出当前装饰链的层级。这在排查调用链时非常救命。6. 更进一步的玩法利用范畴思想看装饰器前面聊的都是实战向的最后我想提一个算是进阶视角的东西。如果你把装饰器模式放到范畴论的框架里看会发现它其实和“函子”这个概念非常相似——一个包装结构在其上可以继续施加变换且变换可以复合。当然日常C工程里聊范畴论有点过于理论了但它能给你一个启发装饰器并不只是“类包装类”这么简单。当你写下auto newObj decorator(originalObj)这种形式的代码时其实就是在表达“给某个东西的外层加上一个变换层”。坚持用这种统一的视角看待装饰器你会发现它能应用的地方远比GoF书里的那几个例子广从日志记录、缓存管理、重试机制到数据校验、性能埋点、权限提示都可以用同一种“包装”思维解决。我个人的看法是装饰器模式是C开发者最值得熟练掌握的三个模式之一另外两个是策略模式和观察者模式。它不是“老古董设计书里的过时概念”而是你在写真实项目时随时可能靠它自救的工具。尤其是当需求开始疯狂叠加、而你又不想把代码改成一锅粥的时候装饰器就是那根帮你把复杂度拆回正确形状的杠杆。希望这篇能把你的“第一行装饰器代码”引上路。别急着把工程里所有继承都改成装饰器先找出一个真正符合“附加功能在运行时变化”的场景动手写一次你就会明白这种包装思想的威力。
返回列表