ARTICLE DETAIL

资讯详情

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

C++装饰器模式实战:用组合替代继承实现灵活扩展

C++装饰器模式实战:用组合替代继承实现灵活扩展 接手过一个实时数据上报模块最初用继承硬撑后来需求一改再改类爆炸到连自己都不想看。C里做功能扩展继承不是唯一的解装饰器模式用组合代替继承把职责一条条“穿”在核心对象上灵活度完全不在一个量级。这篇文章聊聊装饰器模式在C里的实战姿势从标准结构、业务场景到现代C的轻量实现最后附上我踩过的坑和排查技巧适合想搞清楚“什么时候该用装饰器、怎么在C里落地”的读者。1. 装饰器模式到底是什么1.1 从一个真实需求说起先说一个我实际遇到的场景当时要给一个数据上报服务增加功能上报前需要压缩、加密、加签名上报后还要记录耗时、失败时重试。最开始想得很简单写一个上报类核心逻辑就是“发送数据”然后各种需求加进来我本能地选择继承派生类——CompressedReporter、EncryptedReporter、SignedReporter但很快发现一个问题三个需求组合起来怎么办继承组合子类CompressedEncryptedSignedReporter名字长到离谱而且每多一个维度组合数量就翻一倍。更要命的是没有排序灵活性如果今天想先加密再压缩明天想先压缩再加密子类方案根本应付不过来。这时候我才认真用装饰器模式重构。装饰器模式的本质一句话说清楚不修改原有类也不通过继承无限生成子类而是用组合的方式把新的职责一层层包裹在原有对象外层。每个装饰器类都“是一个”原组件同时“持有一个”原组件调用时既执行自己增强的逻辑又把请求转发给内部持有的组件。1.2 继承方案为什么不够用继承本身没有错它处理“同一类型不同变体”是清晰的比如Cat继承Animal、Tiger继承Cat这种“是一种”的关系适合继承。但功能扩展场景下问题是“一个对象同时具备多种能力”而不是“一个新物种”。场景里如果继续用继承会出现两个典型问题组合爆炸三个功能同时独立可配排列组合就有(2^3-17)个类如果还要选顺序则更多。功能到达五个以上类数量直接失控。忽视执行顺序继承方案把功能绑死在编译期运行期想调整顺序只能再写新类这在真实业务中特别被动。组合式装饰器则不同每个功能只写一次一个装饰器类用不同的嵌套顺序自由组合新增一种功能也只需要新增一个装饰器类不动已有代码。这其实就是设计模式里的开闭原则——对扩展开放、对修改关闭。1.3 装饰器模式的核心思想组合优于继承用一句话把握装饰器的精髓它给对象穿衣服。核心对象是身体压缩、加密、日志、缓存是一层一层的衣服衣服可以单独穿也可以叠穿叠穿顺序还能随时换。身体不关心自己穿了几件衣服每件衣服只做好自己这一层的增强然后把请求继续往内层传。在C里这种“一层层包裹”的实现依托两个语言机制——多态装饰器与被装饰对象实现同一个接口和组合装饰器内部持有被装饰对象。多态保证装饰器可以代替被装饰对象出现在任何需要它的地方组合保证装饰器能动态地增强对象这两点缺一不可。2. 标准装饰器模式的设计与实现2.1 四个核心角色拆解装饰器模式在Gof书里的结构放到C里通常由四个角色组成Component抽象组件定义业务接口可以是抽象类或接口类C中通常体现为含有纯虚函数的基类。ConcreteComponent具体组件实现业务接口的核心类被装饰的原始对象比如FileStream。Decorator装饰器基类持有Component指针并实现Component接口。自身不增加业务逻辑只负责把请求转发给内部组件是连接具体装饰器和具体组件的桥梁。ConcreteDecorator具体装饰器在转发请求前后插入自己的增强逻辑比如CompressedStream、EncryptedStream。实际编码时Decorator和Component往往是同一个抽象基类里分化出来的两级但C里通常可以合并成同一个抽象基类处理关键是每个装饰器都包含一个指向基类的成员指针。2.2 一个完整的C代码示例数据流处理以数据流写文件为例。核心是Stream接口具体组件是FileStream装饰器实现加密和压缩。直接看代码#include iostream #include memory #include string // Component class Stream { public: virtual ~Stream() default; virtual void write(const std::string data) 0; }; // ConcreteComponent class FileStream : public Stream { public: explicit FileStream(std::string filename) : filename_(std::move(filename)) {} void write(const std::string data) override { std::cout [FileStream] write to filename_ : data std::endl; } private: std::string filename_; }; // Decorator base class StreamDecorator : public Stream { public: explicit StreamDecorator(std::unique_ptrStream inner) : inner_(std::move(inner)) {} void write(const std::string data) override { if (inner_) inner_-write(data); } protected: std::unique_ptrStream inner_; }; // ConcreteDecorator: compression class CompressedStream : public StreamDecorator { public: explicit CompressedStream(std::unique_ptrStream inner) : StreamDecorator(std::move(inner)) {} void write(const std::string data) override { std::string compressed [compressed] data; std::cout [CompressedStream] compressing data... std::endl; inner_-write(compressed); } }; // ConcreteDecorator: encryption class EncryptedStream : public StreamDecorator { public: explicit EncryptedStream(std::unique_ptrStream inner) : StreamDecorator(std::move(inner)) {} void write(const std::string data) override { std::string encrypted [encrypted] data; std::cout [EncryptedStream] encrypting data... std::endl; inner_-write(encrypted); } }; // usage int main() { auto file std::make_uniqueFileStream(data.txt); // 先加密再压缩最后写文件 auto encrypted std::make_uniqueEncryptedStream(std::move(file)); auto compressed std::make_uniqueCompressedStream(std::move(encrypted)); compressed-write(hello decorator); return 0; }这段代码的输出[EncryptedStream] encrypting data... [CompressedStream] compressing data... [FileStream] write to data.txt: [compressed][encrypted]hello decorator注意输出的调用顺序外部先调用CompressedStream::write但它内部先调用EncryptedStream::write而EncryptedStream又先调用FileStream::write。也就是说数据到达文件时其实是“加密→压缩→写入”而打印日志的顺序却是反向的——这是因为先进入装饰器的是外层。2.3 为什么这么设计从调用链看职责划分这个示例里最需要领悟的一点是每个装饰器不关心自己外层是谁也不关心内层是谁只关心自己“有没有完成任务”。EncryptedStream只管把数据加密加密完传给下一个CompressedStream只管压缩压缩完传给下一个底层的FileStream只管最后落盘。这种设计带来的直接好处有三个顺序可配置main函数里先包Encrypted再包Compressed就是“先加密后压缩”调换顺序就是“先压缩后加密”。这个决定完全在组装代码里完成不用改任何装饰器类。职责独立想加一个新的功能比如ChecksumStream只需要写一个新装饰器插入链中即可FileStream一行都不用动。复用方便同一套装饰器可以用在NetworkStream、MemoryStream任意具体组件上因为它们都实现了同一个Stream接口。这里提醒一下inner_之所以用std::unique_ptr而不是裸指针是为了自动管理生命周期。装饰器链销毁时从外到内依次析构不会泄漏。如果要支持拷贝需要单独处理后面第五部分专门讲。3. 业务场景中的装饰器实战3.1 日志与性能监控的装饰器实现数据流场景偏底层业务系统里更常见的装饰器用途是给业务接口加日志、加监控。假设有一个订单查询服务核心接口是OrderService::query。我不想改动原实现又想知道每次查询耗时多少、参数是什么、结果是否正常那就用装饰器包一层。代码结构class OrderService { public: virtual ~OrderService() default; virtual Order query(const std::string orderId) 0; }; class OrderServiceImpl : public OrderService { public: Order query(const std::string orderId) override { // 核心查询逻辑 return Order{orderId, 100}; } }; class LoggedOrderService : public OrderService { public: explicit LoggedOrderService(std::unique_ptrOrderService inner) : inner_(std::move(inner)) {} Order query(const std::string orderId) override { auto start std::chrono::steady_clock::now(); Order result inner_-query(orderId); auto end std::chrono::steady_clock::now(); std::cout [Log] orderId: orderId cost: std::chrono::duration_caststd::chrono::milliseconds(end - start).count() ms std::endl; return result; } private: std::unique_ptrOrderService inner_; };这个装饰器侵入性很低OrderServiceImpl完全不知道自己的查询被监视了加日志、加监控、加超时重试都不需要改业务代码。上线后想临时关掉日志只需要在组装处换回原始对象即可连代码都不用删除。类似地还能做缓存装饰器接口不变内部先查缓存命中则直接返回未命中再调内层服务同时把结果放入缓存。这种装饰器对已有代码的侵入为零特别适合在交接别人写的模块时“隔空增强”。3.2 权限校验与参数校验装饰器另一个高频场景是接口入口处的权限校验和参数校验。把校验从业务逻辑里抽出来做成装饰器好处是业务代码不需要写满if (!checkPermission(...))这种防御逻辑。比如一个消息推送服务MessageSender发送前要校验用户是否登录、消息内容是否合法。用装饰器串起来class MessageSender { public: virtual ~MessageSender() default; virtual bool send(const Message msg) 0; }; class AuthenticatedSender : public MessageSender { public: explicit AuthenticatedSender(std::unique_ptrMessageSender inner) : inner_(std::move(inner)) {} bool send(const Message msg) override { if (!checkLogin(msg.userToken)) { std::cout [Auth] login invalid std::endl; return false; } return inner_-send(msg); } private: std::unique_ptrMessageSender inner_; }; class ValidatedSender : public MessageSender { public: explicit ValidatedSender(std::unique_ptrMessageSender inner) : inner_(std::move(inner)) {} bool send(const Message msg) override { if (msg.content.empty() || msg.content.size() 1024) { std::cout [Validate] content length invalid std::endl; return false; } return inner_-send(msg); } private: std::unique_ptrMessageSender inner_; };组装时auto raw std::make_uniqueMessageSenderImpl(); auto validated std::make_uniqueValidatedSender(std::move(raw)); auto authenticated std::make_uniqueAuthenticatedSender(std::move(validated)); authenticated-send(msg);这段代码里校验逻辑和业务逻辑彻底分离。如果校验顺序有讲究——先验证登录再校验内容那组装顺序就相应调整。这种“插拔式”的入口维护方式比把所有校验堆在业务方法开头容易读得多。3.3 缓存、重试等横切关注点的统一封装日志、校验、缓存、重试、监控这些常被称为“横切关注点”因为它们会穿插散落在各个业务方法里。假如不用装饰器这些逻辑会重复出现在每一个业务方法里后续想调整“重试次数”都会折腾一整个方法体。用装饰器统一封装后可以这样组装一个“带缓存重试日志”的服务auto base std::make_uniqueRemoteDataFetcher(); auto cached std::make_uniqueCacheDecorator(std::move(base)); auto retried std::make_uniqueRetryDecorator(std::move(cached), 3); auto logged std::make_uniqueLogDecorator(std::move(retried)); auto client *logged;这样组装出的对象具备完整能力链而每层代码都只负责一件事。真的遇到线上问题比如缓存穿透可以单独排查CacheDecorator重试策略需要调整只改RetryDecorator。这种清晰的“单层定位”是横切关注点封装的巨大价值。4. 现代C的轻量装饰方案4.1 基于std::function的更灵活实现上面几节都是“正统”装饰器模式用类和多态实现。但C里还有更轻量的一种方式std::functionstd::bind组合函数。如果业务接口只有一两个方法没必要建一整套类层级可以使用函数装饰。比如一个简单的处理函数using ProcessFunc std::functionstd::string(const std::string); std::string process(const std::string input) { return processed: input; } ProcessFunc withLogging(ProcessFunc inner) { return [inner](const std::string input) { std::cout [log] start, input input std::endl; auto result inner(input); std::cout [log] done, result result std::endl; return result; }; } ProcessFunc withRetry(ProcessFunc inner, int maxRetry) { return [inner, maxRetry](const std::string input) { for (int i 0; i maxRetry; i) { try { return inner(input); } catch (const std::exception e) { std::cout [retry] attempt i1 failed: e.what() std::endl; } } throw std::runtime_error(all retries failed); }; }使用ProcessFunc decorated process; decorated withRetry(std::move(decorated), 3); decorated withLogging(std::move(decorated)); auto result decorated(hello);这种函数装饰方式的优势是代码量小、直观适合单函数的场景劣势是不像类装饰器那样可以整体替换对象、无法处理多个接口方法。真实工程里如果只是一个函数需要增强我通常直接推荐这个方案而不是引入全套多态结构。4.2 模板与lambda的组合编译期装饰另一种思路是用模板在编译期完成装饰。比如定义模板装饰器参数是具体组件类型这样不需要虚函数运行时零开销。template typename Inner class LoggingWrapper { public: explicit LoggingWrapper(Inner inner) : inner_(std::move(inner)) {} template typename... Args auto operator()(Args... args) { std::cout [log] before call std::endl; auto result inner_(std::forwardArgs(args)...); std::cout [log] after call std::endl; return result; } private: Inner inner_; };这里operator()直接转发参数Inner可以是lambda、仿函数或任何可调用对象。装饰后的对象仍保持可调用而且类型推导把整个链型都固定在编译期没有虚函数开销。这种方案的代价是类型变得很长使用需要auto辅助而且编译期固定后运行期就不能动态改顺序。适合确定性的、追求极致性能的场合比如高频调用但功能组合不变的模块。4.3 三种方案怎么选标准多态装饰器功能多、顺序动态可变、接口需要抽象统一。适合业务系统里的服务接口。std::function装饰单函数或少数几个函数、希望快速实现。适合工具模块、回调函数增强。模板/Lambda装饰性能敏感、组合确定不变。适合底层组件、库开发。选择的核心依据只有一个变化的时机和变化的维度。是运行期变还是编译期变是接口变还是实现变。明确这两个维度方案就清晰了。5. 实战中的常见问题与排查技巧5.1 拷贝与生命周期管理的陷阱装饰器持有内部对象是组合关系如果用std::unique_ptr装饰器自然不可拷贝这是符合语义的——一件“衣服”穿在不同“身体”上不合理但有时候业务确实需要复制装饰链比如多线程各自处理一份数据。解决方案是自定义深拷贝。给每个装饰器实现clone()函数class StreamDecorator : public Stream { public: virtual std::unique_ptrStream clone() const 0; // ... };具体装饰器实现std::unique_ptrStream CompressedStream::clone() const { return std::make_uniqueCompressedStream( inner_ ? inner_-clone() : nullptr ); }注意这里如果inner_为空要处理空指针。另外如果内部对象没有实现clone()装饰器也无法深拷贝。工程上如果只是临时持有同一个组件直接用shared_ptr更省事但要注意共享状态下的线程安全问题。5.2 接口设计不当时容易踩的坑装饰器要求“外层装饰器与内部组件实现同一个接口”但如果接口设计得太大、方法太多装饰器就不得不转发所有方法代码变得冗长。实践中我见过接口上有十几个方法的类装饰器照搬以后每个方法都是“转发增强”非常难看。应对手段接口隔离把接口拆小比如把“读”和“写”分开装饰器只装饰需要增强的方法。默认转发基类装饰器基类中实现所有接口方法默认只是转发具体装饰器只重写需要增强的方法。这样新装饰器不必实现所有方法。第二种手段特别实用代码里StreamDecorator已经示范了基类里write方法直接转发CompressedStream和EncryptedStream只实现自身逻辑。即使以后接口加了一个read方法只在基类加一次默认转发即可。5.3 多层装饰时的调试技巧多层装饰器调试起来有迷惑性。有时候你看到日志顺序觉得“不对”其实是理解上颠倒。比如main函数里组装顺序是Compressed(Encrypted(File))但日志输出顺序是Encrypted→Compressed→File看起来像是内部先执行了。调试技巧是从一开始就为每层装饰器加上带缩进或前缀的日志这样可以直观看到调用链的方向。例如void write(const std::string data) override { std::cout [EncryptedStream::write] enter std::endl; inner_-write(data); std::cout [EncryptedStream::write] exit std::endl; }有了这种入口出口日志一眼就能看出整条调用链的走向外层enter → 内层enter → 最底层业务 → 内层exit → 外层exit。调试时先加这种临时日志定位完再删除比一步步断点快很多。还有一个常用手段是打印“装饰链信息”比如每个装饰器对外暴露一个description()方法返回自身名称并拼接内部对象的描述。这在实际排查配置错误时特别有用能快速确认当前对象上套了哪些装饰器、顺序是什么。5.4 装饰器顺序错误的排查最常见的问题是把装饰器的顺序搞错。比如缓存装饰器放在日志外层还是内层重试装饰器放在缓存外层还是内层都会影响运行效果。举例来说Retry(Cache(Service))先查缓存缓存未命中才调Service如果Service失败重试那重试的是整个“缓存服务”链路可能造成缓存校验重复执行。Cache(Retry(Service))重试只发生在Service层缓存只需查一次。通常这种顺序更合理。排查这类问题看“哪一层重复执行了”是最有用的线索。如果发现日志显示缓存查了多次那大概率是重试放在缓存外层了。5.5 动态配置与装饰器工厂另一个工程化问题是装饰链往往是运行时组装但组装代码写死在main函数里不灵活。更好的做法是结合工厂模式根据配置文件动态创建装饰链。示例伪代码std::unique_ptrStream buildStream(const Config cfg) { std::unique_ptrStream stream std::make_uniqueFileStream(cfg.filename); if (cfg.enableCompression) { stream std::make_uniqueCompressedStream(std::move(stream)); } if (cfg.enableEncryption) { stream std::make_uniqueEncryptedStream(std::move(stream)); } return stream; }这样上线前调整配置文件就能开关功能和顺序不需要重新编译。要注意的是配置顺序要和代码逻辑一致否则容易配出反直觉的链条。6. 收益分析与应用边界6.1 用装饰器后代码到底好在哪里就我自己的经验装饰器模式最大的好处不是“代码变少”而是“每个类和方法的职责变得单一修改的影响范围可控”。以订单服务为例加一个访问日志功能继承方案需要新建一个子类装饰器方案只要在组装处加一行代码。后面的同事接手时看组装处的代码就能知道整个对象身上有哪些能力不必翻遍每个类的继承关系。这种模式还有一个隐形收益它逼着你把接口写得稳定、设计得干净。因为装饰器依赖接口多态如果接口设计得不好装饰器的成本会陡然上升因此你不得不在设计接口时多花心思。6.2 什么情况下不要用装饰器装饰器不是万能药有些场景使用它会适得其反功能数量少且固定只有一个增强需求未来也不会变直接写在业务类里即可没必要引入多态层级。装饰器数量太多链过长超过5层以上调用链和调试成本会明显上升读代码的人需要层层解开。这时候考虑是否真的需要这么多职责或者可以用策略模式合并部分功能。内部对象需要暴露大量特有接口装饰器要求接口一致如果内部对象有特殊接口而基类没有暴露外层就访问不到强行设计会让接口膨胀。6.3 个人使用体会我实际使用下来最大的感触是装饰器模式不是让你堆类而是让你发现哪些职责是可拆分的。每次想把一个类的方法加上一堆横切逻辑时都可以先停下来问自己“这段逻辑真的属于业务核心还是属于包装层”如果答案是后者那装饰器往往比直接塞进业务方法更容易长期维护。再补充一个小建议装饰器的粒度不宜太细像“打印一行日志”这种小功能也单独做成装饰器反而会增加类数量得不偿失。判断标准很简单就是“这个职责会不会在至少两个地方以不同顺序复用”。会就独立不会就合并。最后分享一个我在代码评审里常用的提问这个新功能是“加在对象身上”还是“改在对象内部”如果你回答是前者把它做成装饰器回答是后者再考虑修改原类。久而久之代码结构会自然走向更易维护的方向。
返回列表