尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++装饰器模式四种变体:实现原理、选型对比与工程踩坑

C++装饰器模式四种变体:实现原理、选型对比与工程踩坑 前两年带团队做中间件重构的时候我对着项目里那个已经膨胀到十几个类的装饰器继承链头疼了很久。教科书上把Component、Decorator、ConcreteDecorator的UML画得干干净净可一旦接入真实的业务流接口要透传但又不完全透传装饰顺序错了就出大事加一个装饰要动四个文件这些现实问题立刻涌上来。C里的装饰器模式绝不是背一张类图就能应付的——它有至少四种变体每种都有自己适合的舞台以及一套对应的坑。这篇就是想把我在C工程里碰过的、验证过的装饰器模式变体整理一遍经典继承版、模板化版、函数式版、类型擦除版、策略模板版从实现原理到选型再到调试时的血泪经验。无论你是刚学23种设计模式的新手还是在为设计模式大作业、重构课设发愁的在校生或者写业务代码写腻了想琢磨点设计味道的工程师这篇都能给你一些能直接拿去用的东西。1. 经典装饰器模式回顾先看懂它到底在解决什么问题1.1 不需要动基类也能给对象加行为装饰器模式解决的问题本质上是子类爆炸。举个例子你就懂了假设有一个文件流类FileStream现在想给它加两种能力——加密和压缩。如果直接用继承就得为每一种组合写一个类EncryptedFileStream、CompressedFileStream、EncryptedCompressedFileStream。如果能力增加到三种、四种组合数量是指数级的绝对跟不上需求变化。装饰器模式的思路完全不同把附加能力也建模成对象每个附加能力对象持有另一个流对象的引用。调用时先处理自己的附加逻辑再转发给内部持有的对象。这样一来加密就是一个类压缩也是一个类想叠加就在运行时把对象一层层套起来想拆掉就少套一层根本不需要预知所有组合方式。这种设计思维的转换非常关键继承是在静态地定义一种类型装饰器是在动态地组装一条行为链。业务上今天要加密压缩明天要加密校验压缩后天只要压缩——继承方案要么提前全建好要么推倒重来装饰器方案只是换一下套娃的顺序而已。1.2 一份能跑的经典C实现先把这个基准版本写出来后面所有变体都拿它当参照物。#include iostream #include memory #include string // 抽象组件 class Stream { public: virtual ~Stream() default; virtual void write(const std::string data) 0; }; // 具体组件 class FileStream : public Stream { public: explicit FileStream(const std::string path) : m_path(path) {} void write(const std::string data) override { std::cout [FileStream] write \ data \ to m_path std::endl; } private: std::string m_path; }; // 装饰器基类持有被装饰对象 class StreamDecorator : public Stream { public: explicit StreamDecorator(std::unique_ptrStream wrapped) : m_wrapped(std::move(wrapped)) {} void write(const std::string data) override { m_wrapped-write(data); } protected: std::unique_ptrStream m_wrapped; }; // 具体装饰器加密 class EncryptedStream : public StreamDecorator { public: explicit EncryptedStream(std::unique_ptrStream wrapped) : StreamDecorator(std::move(wrapped)) {} void write(const std::string data) override { // 加密逻辑 std::string encrypted [encrypted: data ]; m_wrapped-write(encrypted); } }; // 具体装饰器压缩 class CompressedStream : public StreamDecorator { public: explicit CompressedStream(std::unique_ptrStream wrapped) : StreamDecorator(std::move(wrapped)) {} void write(const std::string data) override { std::string compressed [compressed: data ]; m_wrapped-write(compressed); } }; int main() { std::unique_ptrStream stream std::make_uniqueFileStream(test.txt); stream std::make_uniqueEncryptedStream(std::move(stream)); stream std::make_uniqueCompressedStream(std::move(stream)); // 实际调用顺序CompressedStream - EncryptedStream - FileStream stream-write(hello); return 0; }运行结果是[FileStream] write [compressed:[encrypted:hello]] to test.txt注意这里的处理顺序最外层CompressedStream先处理然后转发给EncryptedStream最后落到FileStream。这个顺序在业务上往往有讲究——先加密再压缩压缩比更好和先压缩再加密安全性有所取舍效果完全不同后面第6节我会展开讲。1.3 经典版本在真实工程里的三个痛点第一个痛点是接口透传。装饰器基类必须重写基类的所有虚函数哪怕只是转发也要写一遍。接口一旦有几十个方法装饰器类就充满了纯模板代码而且基类每加一个方法所有装饰器都要跟着改。这是接口膨胀问题。第二个痛点是类数量并没有想象中那么少。虽然避免了组合类爆炸但每个附加能力仍然是一个类如果项目里有二三十种装饰需求仍然要维护二三十个类文件配合职责链、工厂这些模式才能勉强压住复杂度。第三个痛点是运行时的虚调用开销。虽然是多态的必要成本但在高吞吐的底层模块里每个数据包都过一遍虚函数加上unique_ptr的移动、堆分配实测性能是会有可感知的损耗的。也正因为这些痛点才会催生出后面几种各有取舍的变体。2. 变体一模板基类装饰器把装饰关系搬进编译期2.1 核心思路让模板参数替我们继承经典版本中每个具体装饰器都要自己写构造函数和转发函数代码重复度很高。模板版本的核心思路很简单把被装饰的基类变成一个模板参数装饰器本身只需要实现附加逻辑继承关系和转发代码全部由模板自动生成。#include iostream #include string #include utility // 日志装饰器模板参数 Base 是被装饰的类 templatetypename Base class LoggingDecorator : public Base { public: // 透传构造参数 templatetypename... Args explicit LoggingDecorator(Args... args) : Base(std::forwardArgs(args)...) {} void write(const std::string data) override { std::cout [LOG] before write: data std::endl; Base::write(data); std::cout [LOG] after write std::endl; } }; // 加密装饰器 templatetypename Base class EncryptedDecorator : public Base { public: templatetypename... Args explicit EncryptedDecorator(Args... args) : Base(std::forwardArgs(args)...) {} void write(const std::string data) override { Base::write([encrypted: data ]); } }; // 原始类 class FileStream { public: explicit FileStream(const std::string path) : m_path(path) {} void write(const std::string data) { std::cout [FileStream] write \ data \ to m_path std::endl; } private: std::string m_path; }; // 用类型别名组合装饰器 using LoggedEncryptedFileStream LoggingDecoratorEncryptedDecoratorFileStream; int main() { // 层层模板嵌套构造参数会逐层转发给 FileStream LoggedEncryptedFileStream stream(test.txt); stream.write(hello); return 0; }输出结果和经典版类似只是最外层多了一圈日志。你看原始文件流类不需要知道任何装饰器的存在装饰器也不需要单独持有被装饰对象——被装饰对象就是模板基类的自身实例。少了m_wrapped成员少了构造转移所有权的操作代码明显清爽了。这里有个技术细节值得注意模板装饰器的构造函数用了变参模板加std::forward目的是把构造参数原封不动地转发给最底层的类。比如FileStream需要路径参数那么LoggedEncryptedFileStream(test.txt)这个表达式里字符串会先传给LoggingDecorator再转发给EncryptedDecorator最后转发给FileStream。如果哪天FileStream构造函数多加了一个参数使用方代码完全不用动这种转发机制就是它的核心价值。2.2 模板变体的优势与限制优势很明显一是虚调用可能被完全消除。因为装饰器类型在编译期就确定了如果编译器做内联最终生成的代码可能就是一个直线调用的函数性能上接近手写组合。二是代码复用度高写一个模板装饰器就能应用到任意符合接口的类上不必为每个具体类都写一个装饰器。三是组合关系在类型层面显式可见读代码时一眼就能看出这个对象拥有哪些装饰能力可读性比运行期套娃强很多。限制同样明显最核心的一点是装饰关系在编译期固定没法在运行时动态增删。假设请求要按配置决定今天走加密还是不走加密模板变体就无能为力了你必须在运行时根据条件构造不同的类型。另外多层模板嵌套之后类型名会变得很长写错了编译器报错信息也极其酸爽满屏的模板实例化信息能让人瞬间血压升高。还有一个隐性问题如果装饰器之间是平级的、都需要访问被装饰对象的构造参数模板转发会变得很绕。总的来说它适合装饰链固定、追求性能、不需要动态调整的场景比如嵌入式模块、性能敏感的日志埋点。3. 变体二函数式装饰器用std::function串起一条横切流水线3.1 什么时候该放弃类来写装饰器说真的很多所谓的装饰器场景根本没有必要动用继承。如果除了一个核心对象之外其余装饰者做的事情都是调用前打日志、调用后埋点、失败重试、参数校验这类横切逻辑那用类和继承就是杀鸡用牛刀。函数式装饰器的思路是把调用本身当成一个值。核心实现是一个std::functionvoid(const std::string)装饰器则是一个接收函数、返回函数的包装器。每个装饰器不持有任何对象它只负责接收上一个函数返回一个新的函数。这种写法在回调多、消息处理多、网络组件多的C项目里尤其顺手。#include functional #include iostream #include string #include utility // 用函数别名表达流写入能力 using WriteFunc std::functionvoid(const std::string); // 具体组件文件写入 WriteFunc makeFileStream(const std::string path) { // 捕获 path返回一个可调用对象 return [path](const std::string data) { std::cout [FileStream] write \ data \ to path std::endl; }; } // 装饰器日志 WriteFunc withLogging(WriteFunc wrapped) { return [wrapped std::move(wrapped)](const std::string data) { std::cout [LOG] before: data std::endl; wrapped(data); std::cout [LOG] after std::endl; }; } // 装饰器加密 WriteFunc withEncryption(WriteFunc wrapped) { return [wrapped std::move(wrapped)](const std::string data) { wrapped([encrypted: data ]); }; } // 装饰器重试 WriteFunc withRetry(WriteFunc wrapped, int times) { return [wrapped std::move(wrapped), times](const std::string data) { for (int i 0; i times; i) { wrapped(data); } }; } int main() { // 直接通过函数套函数组合装饰链 WriteFunc stream makeFileStream(test.txt); stream withEncryption(std::move(stream)); stream withLogging(std::move(stream)); stream withRetry(std::move(stream), 2); stream(hello); return 0; }输出为[LOG] before: hello [FileStream] write [encrypted:hello] to test.txt [LOG] after [LOG] before: hello [FileStream] write [encrypted:hello] to test.txt [LOG] after这个例子里重试装饰器把整个加密写文件的流程跑了两次而日志装饰器只在外层打了一次包裹。组合顺序完全由函数调用顺序决定比C继承体系里的对象套娃直观得多。如果想让代码更有流水线的味道可以再写一个通用的compose组合函数把多级包装压扁成一个表达式。3.2 函数式装饰器的实现要点第一个要点是捕获方式。上面代码用了wrapped std::move(wrapped)的移动捕获语义把std::function从外层移进lambda里避免拷贝。C11时代移动捕获还不标准得用std::bind的占位技巧C14之后[x std::move(x)]就是常规操作。如果装饰器函数还会被并发调用要注意内部的可变状态是否需要加锁——std::function的const调用和mutable状态之间隐藏着多线程读者容易踩的坑。第二个要点是接口种类问题。函数式装饰器只能处理我们能抽象成统一签名的调用。如果被装饰对象有五个方法函数式装饰器就得准备五个std::function或者用一个结构体把所有函数成员包起来这本质上又回到了类接口设计的范畴。所以它适合一个入口点的场景比如OnMessage、OnRequest、ProcessPacket这类统一处理方法。第三个要点是性能。std::function本身就有一次间接调用开销再加上嵌套lambda的捕获和移动单次调用开销比虚函数只高不低。但如果你拿它装饰的是业务回调、网络消息这种微秒级以上耗时的工作这点开销完全可以忽略。我在实际项目里用函数式装饰器做过一套请求处理中间件装饰器链最多叠了七八层性能测试下来完全没成为瓶颈。3.3 这个变体最好用在哪里日志、鉴权、限流、重试、熔断这类与核心业务无关、但又绕不开的横切关注点用函数式装饰器最舒服。给网络回调加个超时重试给消息处理器加个耗时统计给文件写入加个审计日志都只需要写一个返回std::function的函数接上就行不用建类、不用继承、不用管理生命周期。等业务做完再回头审代码这段装饰链几乎是全项目里最清爽的部分——因为它没有对象状态每个装饰器都是纯输入输出的变换。4. 变体三类型擦除装饰器把装饰器链变成可存储的稳定接口4.1 为什么需要类型擦除经典装饰器和函数式装饰器都有个共同短板装饰链一旦构造出来你很难把它作为一个值保存、传递、放入容器。用经典继承倒是有统一的Stream*指针可那要求所有类型都必须继承自同一个抽象基类用std::function则要求所有调用必须统一签名。如果想实现根据配置动态拼一条装饰链然后存进某个管理器里之后还能再往里插入一段装饰这样更灵活的需求就需要类型擦除。类型擦除的思路是对外暴露一个稳定的非虚接口比如值语义的Stream可以复制、移动内部用一个继承自隐藏Concept基类的模板Model包装任意具体类型。外部只看到Concept的接口具体类型在编译期就被擦除掉了。最经典的例子就是std::function本身——它把所有可调用对象擦除成统一的函数签名。4.2 一个支持流式装饰的类型擦除写法这里我用一个模板实现的类型擦除包装器把装饰器链封装成一个可复制的值对象#include iostream #include memory #include string // 值语义的流接口内部通过类型擦除实现 class StreamValue { public: // 概念接口纯虚 struct Concept { virtual ~Concept() default; virtual void write(const std::string data) 0; virtual std::unique_ptrConcept clone() const 0; }; // 针对具体类型的模型 templatetypename T struct Model : Concept { explicit Model(T obj) : m_obj(std::move(obj)) {} void write(const std::string data) override { m_obj.write(data); } std::unique_ptrConcept clone() const override { return std::make_uniqueModelT(m_obj); // 要求 T 可拷贝 } T m_obj; }; templatetypename T StreamValue(T obj) : m_concept(std::make_uniqueModelT(std::move(obj))) {} StreamValue(const StreamValue other) : m_concept(other.m_concept ? other.m_concept-clone() : nullptr) {} StreamValue(StreamValue) default; StreamValue operator(StreamValue other) { std::swap(m_concept, other.m_concept); return *this; } void write(const std::string data) { if (m_concept) m_concept-write(data); } private: std::unique_ptrConcept m_concept; }; // 实际的文件流对象无需继承任何基类 class FileStream { public: explicit FileStream(const std::string path) : m_path(path) {} void write(const std::string data) { std::cout [FileStream] write \ data \ to m_path std::endl; } private: std::string m_path; }; // 加密装饰器把任意可写对象包装成 StreamValue class EncryptedWrapper { public: explicit EncryptedWrapper(StreamValue inner) : m_inner(std::move(inner)) {} void write(const std::string data) { m_inner.write([encrypted: data ]); } private: StreamValue m_inner; }; // 压缩装饰器 class CompressedWrapper { public: explicit CompressedWrapper(StreamValue inner) : m_inner(std::move(inner)) {} void write(const std::string data) { m_inner.write([compressed: data ]); } private: StreamValue m_inner; }; int main() { // 构造一条装饰链 StreamValue stream FileStream(test.txt); stream StreamValue(EncryptedWrapper(stream)); // 注意此时 stream 已经被复制 stream StreamValue(EncryptedWrapper(stream)); // 演示复制构造 stream.write(hello); return 0; }坦白说这个例子为了展示类型擦除在装饰链上的可复制性写得比较刻意。实际工程里更常见的做法是把StreamValue作为所有装饰器的内部存储类型让装饰器自身也拥有值语义从而可以放进std::vectorStreamValue、std::mapstd::string, StreamValue这类容器里。有了clone的支持装饰链在复制时不会出现多个对象共享底层资源的问题。4.3 类型擦除的关键陷阱所有权与会话劫持类型擦除实现里的最大陷阱是拷贝和所有权。上面对StreamValue的拷贝构造做了深拷贝通过clone可如果内部包装的对象是std::unique_ptrStreamclone就没法直接拷贝了得手动复制底层资源。另一个陷阱是上面main代码里写的那种用当前stream构造新装饰器的表达式非常容易变成自持有。看这段stream StreamValue(EncryptedWrapper(stream));——求值顺序在C标准里是未指定的编译器可能先复制旧stream再构造EncryptedWrapper也可能先构造EncryptedWrapper再复制stream。如果EncryptedWrapper构造函数里拿了stream的引用而stream随后被移动赋值就会导致装饰器内部持有的引用悬空。这个问题的标准解法是先构造临时变量再移动赋值或者像经典版一样用unique_ptr逐层转移所有权不给复制留机会。这个变体在C项目里最典型的使用场景是实现组件式中间件框架调度器维护一个StreamValue容器每个节点都可以被动态增删节点之间通过Value语义传递上下文。缺点是代码量明显增大理解和维护成本高如果项目只需要一个简单的装饰链不建议一上来就把类型擦除方案端出来。5. 变体四策略模板装饰器零开销叠加多个横切能力5.1 用模板模板参数把策略们粘合起来前面提过模板装饰器只有一条线性的嵌套组合如果想在一个类上同时叠加多种独立策略嵌套写起来依然繁琐。策略模板装饰器也有人叫mixin式装饰器的思路是让模板同时继承多个策略装饰器形成一个多面体。这里用一个小例子定义一个策略装饰器基类它接受一个实际写入者的模板参数然后让多个策略各自继承这个基类#include iostream #include string // 被装饰的底层类 class FileWriter { public: explicit FileWriter(const std::string path) : m_path(path) {} void write(const std::string data) { std::cout [FileWriter] write \ data \ to m_path std::endl; } protected: std::string m_path; }; // 日志策略继承实际写入者形成一个装饰层 templatetypename Base class LogPolicy : public Base { public: using Base::Base; void write(const std::string data) { std::cout [LOG] before: data std::endl; Base::write(data); std::cout [LOG] after std::endl; } }; // 加密策略 templatetypename Base class EncryptPolicy : public Base { public: using Base::Base; void write(const std::string data) { Base::write([encrypted: data ]); } }; // 最终组合把策略像叠buff一样叠上去 templatetemplatetypename class... Policies struct CompositeWriter; templatetemplatetypename class First, templatetypename class... Rest struct CompositeWriterFirst, Rest... { templatetypename Base using Type Firsttypename CompositeWriterRest...::template TypeBase; }; template struct CompositeWriter { templatetypename Base using Type Base; }; int main() { // 依次叠加LogPolicy 最外层EncryptPolicy 次外层FileWriter 最内层 using FinalWriter CompositeWriterLogPolicy, EncryptPolicy::TypeFileWriter; FinalWriter writer(test.txt); writer.write(hello); return 0; }这个CompositeWriter其实就是一个编译期的装饰链构造器。你可以往参数列表里塞任意多个策略模板它会按从左到右的顺序把它们逐层往外叠。最终得到的FinalWriter类型直接就能使用没有虚函数没有堆分配编译期就把整条装饰链展开了。编译器如果开了优化上面的writer.write调用甚至可能被内联成一条直线。5.2 策略模板装饰器的适用边界这个变体的最大优势是零抽象开销和极好的组合灵活性。你不需要为每一种组合写一个类只需要定义策略模板然后用别名组合它们。它很适合那些策略集合固定、数量多、对性能有强要求的底层模块。比如一个自定义的序列化器可以拆出压缩、校验、加密、编码四个策略然后用CompositeWriter随意组合编译产物干净利落。不过它的门槛也很明显模板语法不熟练的话光是那个递归的CompositeWriter就能劝退一半人而且所有装饰行为都必须在编译期确定一旦需要根据配置动态决定装饰链这招就完全失效。另外如果各个策略之间有共享状态比如日志策略需要把计数传给加密策略这种模板组合就很难处理了——因为策略之间没有共同的运行时对象它们只是同一继承链上的兄弟。我的建议是项目里模板玩得熟、场景又真的需要大量组合且零开销再用这个变体否则会为了炫技付出很高的维护代价。6. 选型对比、调试技巧与工程经验6.1 四个变体怎么选一张表说清楚变体绑定时机动态组合性能代码量最适场景经典继承运行期支持有虚函数开销中接口稳定、装饰种类固定模板基类编译期不支持零开销可内联少装饰链固定、性能敏感函数式运行期灵活有std::function开销极少日志、重试、校验等横切逻辑类型擦除运行期支持且可存储有虚函数/堆开销多需要动态存储装饰链策略模板编译期不支持零开销、可内联展开中策略多、组合固定、底层模块选型时记住一个原则能编译期搞定就别拖到运行期能少建类就少建类能用函数就别用继承。我见过太多人给一个打印日志的函数硬套五个装饰器类套得代码自己都认不出来那真是设计模式学成了设计魔咒。6.2 装饰顺序为什么能决定成败装饰链的顺序问题在真实业务里不是调换一下代码行数那么简单。拿最开始的流例子来说如果业务链路是网络发送——加密——压缩——文件落地那流落盘文件的顺序应该压缩最内层、加密次外层、文件流最外面因为数据要从文件流出去前得先加密再压缩。如果反了变成先压缩再加密数据流到对端就没法正常解密了因为对方收到的是被压缩过的密文解不出来。判断顺序是否正确只需要模拟一遍数据流从最外层进入、最内层退出的路径再检查每两层之间的数据格式是否匹配。写个单元测试把每层输出打出来验证上一层的输出能正确喂给下一层比依赖人肉推导靠谱得多。实际踩坑时我发现很多人把顺序搞错不是因为不懂装饰器而是因为底层对象的写入动作放在链的末尾还是开头没理清——记住一句话调用进入最外层数据落到最内层处理顺序与调用顺序相反。6.3 调试多层装饰链的三个技巧第一个技巧是给每层打日志时带上唯一的层次标记。不要只打before/after要在日志前缀里约定每层的名称缩写否则四层装饰链的日志混在一起根本看不出数据哪一步被改成了什么样。第二个技巧是构造完装饰链后找个地方把链的顺序dump出来。用经典继承版可以遍历m_wrapped指针用函数式版可以在包装函数里存一个装饰器名称栈调试时把栈打印出来。第三个技巧是单元测试要按层拆先测最内层裸对象再逐层包一层装饰器每包一层验证一次输出。这样一旦出问题二分定位法立刻就能缩小到具体某一层。6.4 最容易踩的三个坑与解决办法坑一共享所有权下的循环引用。用shared_ptr做装饰链时如果某个装饰器持有链内下一层的shared_ptr而外部又持有了这个装饰器的shared_ptr两条路径绕起来就有循环引用。最简单的解法装饰链内部统一用unique_ptr所有权从上到下单向转移如果实在要共享对底层对象用weak_ptr。坑二装饰器里漏掉override或漏转发。接口多的时候极其容易发生。解法给基类析构函数加virtual给所有重写函数加override关键字让编译器帮你查漏同时对装饰器基类只保留纯转发的默认实现具体装饰器需要改动某个方法时才去重写它。坑三std::function捕获了可变的内部状态后在并发场景被多个线程同时调用。这个坑很隐蔽因为std::function的调用是const的看起来线程安全但lambda里捕获的可变计数器并不会同步。解法是要么在装饰器内部加mutex要么在语义上明确装饰链对象不跨线程共享要么把状态移到一个线程局部变量里。最后分享一点实操体会我个人做中间件的习惯是核心业务对象用经典继承或类型擦入来建模让它们可以放进容器、可以热插拔日志、重试、鉴权这类横切操作全部用函数式装饰器来拼代码量骤减且逻辑一目了然。模板基类和策略模板则留给那些确定不变、性能敏感的底层组件比如数据包的编码解码链。每换一个项目我都会重新过一遍这四个变体到底哪个真的适用——装饰器模式不是银弹但它在C里的形状确实比教科书上那张类图丰富得多。
返回列表