☰
C++装饰器模式实战:用组合替代继承实现灵活扩展
2026/10/7 23:58:02 网站建设 项目流程

接手过一个实时数据上报模块,最初用继承硬撑,后来需求一改再改,类爆炸到连自己都不想看。C++里做功能扩展,继承不是唯一的解,装饰器模式用组合代替继承,把职责一条条“穿”在核心对象上,灵活度完全不在一个量级。这篇文章聊聊装饰器模式在C++里的实战姿势,从标准结构、业务场景到现代C++的轻量实现,最后附上我踩过的坑和排查技巧,适合想搞清楚“什么时候该用装饰器、怎么在C++里落地”的读者。

1. 装饰器模式到底是什么?

1.1 从一个真实需求说起

先说一个我实际遇到的场景:当时要给一个数据上报服务增加功能,上报前需要压缩、加密、加签名,上报后还要记录耗时、失败时重试。最开始想得很简单,写一个上报类,核心逻辑就是“发送数据”,然后各种需求加进来,我本能地选择继承派生类——CompressedReporter、EncryptedReporter、SignedReporter,但很快发现一个问题:三个需求组合起来怎么办?

继承组合子类?CompressedEncryptedSignedReporter,名字长到离谱,而且每多一个维度,组合数量就翻一倍。更要命的是没有排序灵活性:如果今天想先加密再压缩,明天想先压缩再加密,子类方案根本应付不过来。这时候我才认真用装饰器模式重构。

装饰器模式的本质一句话说清楚:不修改原有类,也不通过继承无限生成子类,而是用组合的方式,把新的职责一层层包裹在原有对象外层。每个装饰器类都“是一个”原组件,同时“持有一个”原组件,调用时既执行自己增强的逻辑,又把请求转发给内部持有的组件。

1.2 继承方案为什么不够用

继承本身没有错,它处理“同一类型不同变体”是清晰的,比如Cat继承Animal、Tiger继承Cat,这种“是一种”的关系适合继承。但功能扩展场景下,问题是“一个对象同时具备多种能力”,而不是“一个新物种”。

场景里如果继续用继承,会出现两个典型问题:

  • 组合爆炸:三个功能同时独立可配,排列组合就有(2^3-1=7)个类(如果还要选顺序则更多)。功能到达五个以上,类数量直接失控。
  • 忽视执行顺序:继承方案把功能绑死在编译期,运行期想调整顺序只能再写新类,这在真实业务中特别被动。

组合式装饰器则不同:每个功能只写一次(一个装饰器类),用不同的嵌套顺序自由组合,新增一种功能也只需要新增一个装饰器类,不动已有代码。这其实就是设计模式里的开闭原则——对扩展开放、对修改关闭。

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_ptr<Stream> inner) : inner_(std::move(inner)) {} void write(const std::string& data) override { if (inner_) inner_->write(data); } protected: std::unique_ptr<Stream> inner_; }; // ConcreteDecorator: compression class CompressedStream : public StreamDecorator { public: explicit CompressedStream(std::unique_ptr<Stream> 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_ptr<Stream> 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_unique<FileStream>("data.txt"); // 先加密,再压缩,最后写文件 auto encrypted = std::make_unique<EncryptedStream>(std::move(file)); auto compressed = std::make_unique<CompressedStream>(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_ptr<OrderService> 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_cast<std::chrono::milliseconds>(end - start).count() << "ms" << std::endl; return result; } private: std::unique_ptr<OrderService> 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_ptr<MessageSender> 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_ptr<MessageSender> inner_; }; class ValidatedSender : public MessageSender { public: explicit ValidatedSender(std::unique_ptr<MessageSender> 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_ptr<MessageSender> inner_; };

组装时:

auto raw = std::make_unique<MessageSenderImpl>(); auto validated = std::make_unique<ValidatedSender>(std::move(raw)); auto authenticated = std::make_unique<AuthenticatedSender>(std::move(validated)); authenticated->send(msg);

这段代码里,校验逻辑和业务逻辑彻底分离。如果校验顺序有讲究——先验证登录再校验内容,那组装顺序就相应调整。这种“插拔式”的入口维护方式,比把所有校验堆在业务方法开头容易读得多。

3.3 缓存、重试等横切关注点的统一封装

日志、校验、缓存、重试、监控,这些常被称为“横切关注点”,因为它们会穿插散落在各个业务方法里。假如不用装饰器,这些逻辑会重复出现在每一个业务方法里,后续想调整“重试次数”都会折腾一整个方法体。

用装饰器统一封装后,可以这样组装一个“带缓存+重试+日志”的服务:

auto base = std::make_unique<RemoteDataFetcher>(); auto cached = std::make_unique<CacheDecorator>(std::move(base)); auto retried = std::make_unique<RetryDecorator>(std::move(cached), 3); auto logged = std::make_unique<LogDecorator>(std::move(retried)); auto& client = *logged;

这样组装出的对象具备完整能力链,而每层代码都只负责一件事。真的遇到线上问题,比如缓存穿透,可以单独排查CacheDecorator;重试策略需要调整,只改RetryDecorator。这种清晰的“单层定位”是横切关注点封装的巨大价值。

4. 现代C++的轻量装饰方案

4.1 基于std::function的更灵活实现

上面几节都是“正统”装饰器模式,用类和多态实现。但C++里还有更轻量的一种方式:std::function+std::bind组合函数。如果业务接口只有一两个方法,没必要建一整套类层级,可以使用函数装饰。

比如一个简单的处理函数:

using ProcessFunc = std::function<std::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 " << i+1 << " 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::forward<Args>(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_ptr<Stream> clone() const = 0; // ... };

具体装饰器实现:

std::unique_ptr<Stream> CompressedStream::clone() const { return std::make_unique<CompressedStream>( 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_ptr<Stream> buildStream(const Config& cfg) { std::unique_ptr<Stream> stream = std::make_unique<FileStream>(cfg.filename); if (cfg.enableCompression) { stream = std::make_unique<CompressedStream>(std::move(stream)); } if (cfg.enableEncryption) { stream = std::make_unique<EncryptedStream>(std::move(stream)); } return stream; }

这样上线前调整配置文件就能开关功能和顺序,不需要重新编译。要注意的是配置顺序要和代码逻辑一致,否则容易配出反直觉的链条。

6. 收益分析与应用边界

6.1 用装饰器后代码到底好在哪里

就我自己的经验,装饰器模式最大的好处不是“代码变少”,而是“每个类和方法的职责变得单一,修改的影响范围可控”。以订单服务为例,加一个访问日志功能,继承方案需要新建一个子类;装饰器方案只要在组装处加一行代码。后面的同事接手时,看组装处的代码就能知道整个对象身上有哪些能力,不必翻遍每个类的继承关系。

这种模式还有一个隐形收益:它逼着你把接口写得稳定、设计得干净。因为装饰器依赖接口多态,如果接口设计得不好,装饰器的成本会陡然上升,因此你不得不在设计接口时多花心思。

6.2 什么情况下不要用装饰器

装饰器不是万能药,有些场景使用它会适得其反:

  • 功能数量少且固定:只有一个增强需求,未来也不会变,直接写在业务类里即可,没必要引入多态层级。
  • 装饰器数量太多,链过长:超过5层以上,调用链和调试成本会明显上升,读代码的人需要层层解开。这时候考虑是否真的需要这么多职责,或者可以用策略模式合并部分功能。
  • 内部对象需要暴露大量特有接口:装饰器要求接口一致,如果内部对象有特殊接口而基类没有暴露,外层就访问不到,强行设计会让接口膨胀。

6.3 个人使用体会

我实际使用下来最大的感触是:装饰器模式不是让你堆类,而是让你发现哪些职责是可拆分的。每次想把一个类的方法加上一堆横切逻辑时,都可以先停下来问自己“这段逻辑真的属于业务核心,还是属于包装层?”如果答案是后者,那装饰器往往比直接塞进业务方法更容易长期维护。

再补充一个小建议:装饰器的粒度不宜太细,像“打印一行日志”这种小功能也单独做成装饰器,反而会增加类数量,得不偿失。判断标准很简单,就是“这个职责会不会在至少两个地方以不同顺序复用”。会,就独立;不会,就合并。

最后分享一个我在代码评审里常用的提问:这个新功能是“加在对象身上”还是“改在对象内部”?如果你回答是前者,把它做成装饰器;回答是后者,再考虑修改原类。久而久之,代码结构会自然走向更易维护的方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询