
为什么C里也要谈装饰器模式很多人觉得装饰器模式是Java、Python这类动态语言的专利C这边好像写个static函数就够了。实际在C工程里装饰器模式往往是最容易救急的一种设计——日志、缓存、权限校验、性能埋点这些横切关注点硬塞进业务类里代码瞬间就烂了。装饰器模式能在不改原类的前提下给对象一层一层叠功能叠到最后卸载某个功能也只是少套一层的事。它和继承的本质区别在于继承是编译期就绑死的静态血缘装饰器是运行期可组合的动态职位。打个生活化的比方——手机裸机是原类手机壳是普通包装但装饰器模式更像给手机加镜头模块你可以只加广角也可以广角加微距顺序还能换而且裸机本身完全不用改动。放到C里这个手机壳就是一个包装类它内部持有原始对象的接口同时自己实现了同样的接口。这篇文章适合两类读者一是写过一阵子C但没系统学过设计模式的人想搞明白装饰器和继承到底该怎么取舍二是已经在用接口和虚函数组织代码但每次加日志、加缓存都得改动业务函数想找到一个更干净的扩展方式。下面就把装饰器模式从接口设计到内存管理再到实战完整案例一步步拆开讲透。1. 装饰器模式的核心思路和架构拆解1.1 一句话定义装饰器模式解决的核心问题是需要动态地、可回退地为一个对象增加能力但不希望为此修改原有的类定义。它要求所有装饰器和原始对象实现同一个抽象接口装饰器内部持有一个该接口的引用在调用接口方法时先做自己的附加逻辑再调用持有的对象的方法。听起来像函数回调里常见的中间件吧其实装饰器模式就是面向对象版本的中间件思想。C里你完全可以用函数指针链或者模板lambda实现这种管道式扩展但装饰器模式的优势在于你扩展的往往不只是行为还可能附带状态、资源、生命周期管理这些用函数指针链维护起来远不如一个小对象清晰。1.2 为什么不用继承搞定一切很多C初学者会问我继承原类覆写一下方法不也能加功能吗没错但如果同时要加日志、缓存、权限校验三件事继承就得组合出2的3次方个子类。更严重的是这些功能还要支持动态排列组合比如某些场景只要日志加校验某些场景只要缓存继承体系直接爆炸成类爆炸。装饰器模式另外一个好处是热插拔。你可以在运行时决定装饰器的叠加层数和顺序而不是在编译期就锁死。掉一个功能只要在创建装饰链时少包一层或者通过工厂函数改变装饰器顺序即可不用动任何业务代码也不破坏开闭原则。对比维度继承实现扩展装饰器模式实现扩展编译期/运行期编译期固定运行期可组合功能组合数量子类数量指数级增长装饰器数量线性增长是否修改原类不修改但产生新类不修改也不产生功能组合类扩展粒度整类特征按对象实例灵活叠加1.3 关键的角色划分装饰器模式有四个角色搞懂这个代码怎么写都不会跑偏抽象组件接口Component定义业务方法的抽象接口就是所有对象和装饰器都要实现的共同语言。具体组件ConcreteComponent)真正干活的类实现业务核心逻辑装饰器围绕它做增强。抽象装饰器Decorator继承/实现抽象组件接口内部持有一个抽象组件的引用。具体装饰器ConcreteDecorator)覆写接口方法在调用被持有组件之前或之后插入增强逻辑。在C里抽象组件接口一般用纯虚基类实现具体组件可以是私有类装饰器设计为可拼装的标准具名类。持有关系建议用std::unique_ptr管理所有权或者用原始指针配合外部生命周期管理到底怎么选后面第3节专门讲。2. C实现装饰器模式时的核心设计要点2.1 接口设计的三种形态接口设计是整个装饰器模式的地基。在C里抽象组件接口常见三种写法第一种经典纯虚基类class IDiscountCalculator { public: virtual ~IDiscountCalculator() default; virtual double calculate(double price) 0; };第二种带默认实现的非纯虚基类某些基础钩子方法可以先给出空实现子类按需覆写。这种方式对装饰器特别友好因为装饰器常需要透传大部分方法只增强其中一两个。第三种用std::function组合替代纯虚接口。当只有单个行为需要装饰时其实可以不定义装饰器类直接用函数包装using DiscountFunc std::functiondouble(double); DiscountFunc logged [](double price) { /* ... */ };这不是装饰器模式但思路类似。工程上我一般建议行为单一就用std::function包装行为有成组的接口方法就用纯虚基类装饰器。如果一个类的方法超过三个都需要被装饰函数包装式写法会散落在一堆lambda里反而不如装饰器类清晰。2.2 透传转发装饰器最容易被写错的地方装饰器往往需要覆写抽象接口里的全部方法。如果接口有5个方法而某个装饰器只想增强其中1个剩下4个必须原样转发给持有的对象。这段转发代码虽然机械但容易漏class AbstractDecorator : public IDiscountCalculator { protected: std::unique_ptrIDiscountCalculator inner_; public: explicit AbstractDecorator(std::unique_ptrIDiscountCalculator inner) : inner_(std::move(inner)) {} double calculate(double price) override { return inner_-calculate(price); } };注意这个抽象装饰器它的重点不在具体逻辑而在完整透传方便后续具体装饰器只覆写自己关心的方法。漏转发一个方法业务上就是某些接口调用没有得到装饰效果而且编译器不会报错这是比较隐蔽的问题。C里有一个比手动转发优雅的工具std::shared_ptr加模板化继承或者直接用CRTP来做自动透传但后者会让代码变得很绕。在C17及以上我更推荐一个简化方案装饰器基类模板利用继承using声明转发templatetypename Component class Decorator : public Component { protected: std::unique_ptrComponent inner_; public: explicit Decorator(std::unique_ptrComponent inner) : inner_(std::move(inner)) {} };这时候子类虽然还是只想覆写关心的方法但其他方法可以依赖外层类的继承链天然转发不对这里有个陷阱DecoratorComponent继承自Component内部inner_也是Component类型如果不显式转发基类方法实现是虚函数最终落到inner_对象上这个继承的基类方法本身是纯虚的话就无法实例化不是纯虚的话则这种派生又引入了一个平行实现语义不对。所以装饰器模式透传这事在C里老实写转发函数反而是最可靠直观的。2.3 内存管理和所有权策略C实现装饰器模式最让人头疼的就是内存。装饰器链本质是一条持有指针的链条谁拥有最内层对象谁释放最外层装饰器被拷贝时内层怎么处理实际工程中三种策略比较常见第一种std::unique_ptr独占所有权。创建链的时候用std::move逐层包装外部持有最外层装饰器的unique_ptr析构时整条链自动释放异常安全。这是最推荐的做法但代价是链一旦构建你无法从外面访问中间层的装饰器实例去单独配置参数只能整个链条销毁重建。第二种std::shared_ptr共享所有权。适合需要从多个地方访问同一个装饰器对象的场景。但shared_ptr会引入循环引用风险——如果被装饰对象反过来持有了装饰器的引用就可能内存泄漏。用shared_ptr记得千万别让被装饰对象内部保存外层指针。第三种裸指针配合外部管理。适合装饰器生命周期由某个管理器统一维护的场景但一旦业务代码中持有裸指针的装饰器被过早或者过晚释放直接崩溃。新代码我通常不建议这么做。C里有个额外需要注意的坑拷贝。装饰器类如果被按值拷贝内部的unique_ptr会被禁止赋值如果定义成shared_ptr拷贝后两个装饰器共享同一个内层对象一部分代码里没有问题但如果内层对象本身有状态共享状态就可能产生数据不一致。稳妥做法是装饰器类在构造期定义完整运行期只允许移动禁止拷贝class LoggingDecorator final : public AbstractDecorator { public: using AbstractDecorator::AbstractDecorator; LoggingDecorator(const LoggingDecorator) delete; LoggingDecorator operator(const LoggingDecorator) delete; };2.4 虚析构和非虚接口的平衡抽象接口一定要声明虚析构这个所有C开发者都会注意。但装饰器模式中出现另一个问题如果被装饰的类本身含有值类型成员或者布尔标志我们通过接口调用时某些编译器优化如去虚化就失效了。所以设计接口时方法数量要克制接口保持在高内聚、小面积避免为了装饰器模式做一个大而全的接口导致每个装饰器都不得不转发一大堆根本不管的方法。可以从另一个维度来放轻接口负担把装饰器关注点拆分成更细的接口。比如一个邮件发送服务先定义IMailSender只负责发送再定义IAttachmentProvider只提供附件日志装饰器只管IMailSender这样每个装饰器的转发范围都被局限在很小的接口内。3. 一个完整实战案例商品计价和优惠叠加3.1 业务场景说明模拟一个电商订单价格计算系统。原始对象叫BasePricing功能是根据商品单价和数量算出基础价格。现在产品经理要求上线三种可自由组合的优惠能力会员折扣根据用户等级打折满减优惠订单满特定金额就减掉一定额度加急处理费如果用户选择加急配送收取额外费用。这三个能力必须自由组合可以叠加任意顺序未来还可能新增赠品捆绑首次购买减价等新能力而且不能改动已有类。这正是装饰器模式的经典应用场景。先把接口定义好#include iostream #include memory #include string class IPriceCalculator { public: virtual ~IPriceCalculator() default; virtual double calculate(double basePrice) 0; };3.2 具体组件class BasePricing : public IPriceCalculator { public: double calculate(double basePrice) override { return basePrice; } };3.3 抽象装饰器class PriceDecorator : public IPriceCalculator { protected: std::unique_ptrIPriceCalculator inner_; public: explicit PriceDecorator(std::unique_ptrIPriceCalculator inner) : inner_(std::move(inner)) {} ~PriceDecorator() override default; double calculate(double basePrice) override { return inner_-calculate(basePrice); } };这里重点说一下为什么抽象装饰器的析构函数要显式override default。因为基类接口是虚析构抽象装饰器继承它如果不显式声明析构并把内层unique_ptr正确释放整条链的释放顺序就不可控。虽然编译器会自动生成析构但规范上要求手写出来以确保子孙类析构时正确释放成员。实际上unique_ptr成员会自动析构只要析构函数存在且可访问链上的释放就是安全的。但写清楚更利于阅读也让编译警告闭嘴。3.4 三个具体装饰器会员折扣装饰器在原有价格基础上打折假设金牌会员打85折。class MemberDiscountDecorator : public PriceDecorator { private: double rate_; public: MemberDiscountDecorator(std::unique_ptrIPriceCalculator inner, double rate) : PriceDecorator(std::move(inner)), rate_(rate) {} double calculate(double basePrice) override { double price inner_-calculate(basePrice); return price * rate_; } };满减装饰器订单满300减50满500减100。class ThresholdDiscountDecorator : public PriceDecorator { private: double threshold_; double discount_; public: ThresholdDiscountDecorator(std::unique_ptrIPriceCalculator inner, double threshold, double discount) : PriceDecorator(std::move(inner)), threshold_(threshold), discount_(discount) {} double calculate(double basePrice) override { double price inner_-calculate(basePrice); if (price threshold_) { price - discount_; } return price; } };加急处理费装饰器无论前面算出来多少都加收一个固定额度。class ExpressFeeDecorator : public PriceDecorator { private: double fee_; public: ExpressFeeDecorator(std::unique_ptrIPriceCalculator inner, double fee) : PriceDecorator(std::move(inner)), fee_(fee) {} double calculate(double basePrice) override { double price inner_-calculate(basePrice); return price fee_; } };3.5 构建装饰链的三种方式第一种直接嵌套构造std::unique_ptrIPriceCalculator pricing std::make_uniqueMemberDiscountDecorator( std::make_uniqueThresholdDiscountDecorator( std::make_uniqueExpressFeeDecorator( std::make_uniqueBasePricing(), 20.0), 300.0, 50.0), 0.85);这种方式最直观缺点是缩进层级一多代码就变成圣诞树可读性不好。第二种写一个工厂函数把参数和策略集中起来struct PricingConfig { double expressFee; double threshold; double discount; double memberRate; bool useMember; bool useThreshold; bool useExpress; }; std::unique_ptrIPriceCalculator buildPricingChain(const PricingConfig cfg) { std::unique_ptrIPriceCalculator chain std::make_uniqueBasePricing(); if (cfg.useExpress) { chain std::make_uniqueExpressFeeDecorator(std::move(chain), cfg.expressFee); } if (cfg.useThreshold) { chain std::make_uniqueThresholdDiscountDecorator(std::move(chain), cfg.threshold, cfg.discount); } if (cfg.useMember) { chain std::make_uniqueMemberDiscountDecorator(std::move(chain), cfg.memberRate); } return chain; }注意装饰器叠加顺序对结果的影响。满减和会员折扣谁先谁后最终价格是不同的。实操中一定要让订单规则明确指定叠加次序否则不同调用方拼接出的链计算结果不一样排查起来也麻烦。第三种用可变模板在编译期固定组合顺序。适合装饰器集合在编译期就确定、且顺序固定不变的场景源码也很干净templatetypename... Decorators auto createPriceChain() { return std::make_uniqueMemberDiscountDecorator( std::make_uniqueThresholdDiscountDecorator( std::make_uniqueExpressFeeDecorator( std::make_uniqueBasePricing(), 20.0), 300.0, 50.0), 0.85); }这里模板参数没有实际展开使用只是示意。真正要用模板做装饰链一般结合CRTP或者类型列表复杂度更高收益不一定明显所以工程上推荐优先用工厂函数。3.6 验证计算结果写一个简单的主程序验证int main() { PricingConfig config; config.expressFee 20.0; config.threshold 300.0; config.discount 50.0; config.memberRate 0.85; config.useMember true; config.useThreshold true; config.useExpress true; auto chain buildPricingChain(config); double originalPrice 500.0; double finalPrice chain-calculate(originalPrice); std::cout Original: originalPrice , Final: finalPrice std::endl; return 0; }执行顺序是先算基础价500→加急费变成520→满减减50变成470→会员折扣乘以0.85变成399.5。如果把会员折扣放到满减之前计算过程就变成了500乘0.85等于425然后满减从425里判断大于300再减50等于375。同一个原始价格仅仅因为装饰顺序不同相差了接近25块。这个例子能直接说明顺序控制有多重要。4. 实战中绕不开的陷阱和优化技巧4.1 装饰器与状态管理装饰器内部如果要保存每次调用的结果比如缓存装饰器就要考虑线程安全问题。C里一个常见的做法是给装饰器内部加一个std::mutex把内部对象的调用包在锁里。但这里有个经验缓存装饰器最好只缓存纯函数的结果如果原始对象的计算结果依赖外部状态比如汇率、库存缓存会返回陈旧结果这是业务层面必须想清楚的问题。我曾经踩过一个坑缓存装饰器把价格缓存了但基础价格本身发生变化后缓存没有失效直到第二天用户投诉才发现。后来在装饰器里加了一个invalidate()方法由价格变动模块显式调用。但这个调用要穿过装饰链到达缓存层又暴露了装饰器应该透明却需要被外部感知的矛盾。为了解决这种矛盾可以把缓存失效也抽象成装饰器的能力或者直接采用两级结构价格计算用装饰链缓存和业务状态放在被装饰对象内部。引入状态对象、策略对象配合装饰器比试图让装饰器自己管理一切要稳得多。4.2 性能开销虚函数和内存分配装饰器模式天然引入两条开销一是每层装饰器调用都要过一层虚函数叠加过多层会损失一点性能但对现代CPU来说这种开销通常微乎其微二是一次make_unique就是一次堆内存分配装饰器链越长堆分配次数越多。如果价格计算是高频调用建议通过缓存在创建时把unique_ptr链构建好不要每次调用都重新创建一整条链。必要时可以考虑用一个扁平化优化让装饰器在构造阶段把需要拼接的操作压缩成一个std::function运行时只保留一个函数指针链。实际测下来把三层装饰器压成单个lambda之后单次调用开销大约降低30%到40%。代价是配置和状态的可观测性变差所以这个优化要用在确有必要的高频路径上。4.3 调试和日志看清链的调用顺序背了装饰器之后报错堆栈会变得很深而且每层装饰器代码看起来很相似排查时容易看花眼。我的实践经验是每个具体装饰器构造时记录一条日志说明自己包在谁的外面calculate函数入口和出口也各打一条日志。日志里带上装饰器的类型名使用typeid(T).name()输出。这样一张图能直观看到调用顺序定位问题快很多。比较麻烦的是RTTI可能被编译器关闭这时候可以自己定义const char* name() const虚函数每个装饰器返回自己的名字日志输出时直接调它。4.4 C17之后的替代思路模板装饰器如果你写的装饰器不需要运行时动态组合并且要装饰的接口是模板类C的CRTP写法可以完成更轻量的装饰templatetypename Component class LoggerMixin : public Component { public: using Component::Component; double calculate(double price) override { double result Component::calculate(price); std::cout log result: result std::endl; return result; } };这种写法的优势是零虚拟函数开销缺点是如果两个装饰器要共享同一个真实组件的实例CRTP方式做不了——因为它每次实例化都会创建一个独立的Component子对象。所以模板装饰器适合无状态、静态组合的装饰需求而普通虚函数装饰器更适合运行期、共享状态、热插拔的需求。理解了这个区别选择哪种写法就迎刃而解。4.5 使用std::reference_wrapper复用同一内层对象如果一个原始对象要被多个装饰链同时引用比如同一个数据库服务接口上面同时挂一条日志链和一条缓存链unique_ptr就不行了。可以用多个shared_ptr指向同一个BasePricing但要注意线程安全问题。更干净的做法是使用std::reference_wrapper但装饰器内保存引用的话析构时就不需要管理生命周期class ReferencingDecorator : public IPriceCalculator { private: std::reference_wrapperIPriceCalculator inner_; public: explicit ReferencingDecorator(IPriceCalculator inner) : inner_(inner) {} double calculate(double price) override { return inner_.get().calculate(price); } };引用方式最大的隐患是原始对象生命周期必须比装饰器长。如果做不到严格保证就会悬垂引用所以工程上一定要在创建装饰器的地方用作用域清晰、所有权明确的对象来配合引用版装饰器使用。5. 扩展装饰器模式与AOP思想的结合5.1 装饰器模式往AOP方向演化装饰器模式是面向切面编程思想的一种面向对象式落地。切面就是横切关注点比如日志、监控、安全校验它们横跨多个业务模块。你可以在装饰器链上挂各种切面而不侵入业务类本身。但装饰器模式和完整AOP还有区别AOP通常强调编译期织入或代理机制装饰器更侧重于手动显式地构建链。C里没有官方AOP标准库但可以借助boost::di这类依赖注入框架把装饰器的构建集中设置业务代码里完全感知不到链的存在。我自己在工程上常用的结构是工厂函数里根据配置创建完整装饰链业务层拿到的永远是接口永远不关心对象被装饰了几层。这种模式也叫配置驱动装饰。想加新功能时只在工厂里加一行业务调用方完全不动。5.2 装饰器模式与策略模式组合有时一个装饰器内部需要根据条件走不通的逻辑比如会员装饰器要按用户等级选择不同的折扣算法。此时装饰器内部可以持有另一个策略对象。装饰器负责确定是否启用该策略策略负责具体的算法。两者组合出来非常灵活而且各层级职责清晰逻辑也更容易单测。一个推荐的经验每个装饰器只做一件事并且构造函数只接收它与内层组件的协作参数。如果一个装饰器需要五个参数并且承担两种职责就要马上拆成两个装饰器。否则业务新增需求时这个装饰器会变成一个越来越大、无法替换的上帝类装饰器模式就失去意义了。5.3 和std::shared_ptr别名构造配合C的std::shared_ptr支持别名构造你可以让一个shared_ptr指向装饰链的最外层但同时返回一个指向内部某个组件的shared_ptr二者共享引用计数auto chain std::make_sharedMemberDiscountDecorator(...); std::shared_ptrIPriceCalculator innerView(chain, chain-inner_.get());这种方式可以在保持整个装饰链生命周期的同时让外部访问中间层做配置。但inner_如果是unique_ptr直接从外层shared_ptr取裸指针要小心必须确保在别名shared_ptr存活的期间外层链的引用计数不为零且没有被重置。这个技巧用对了很强大用错了就是内存管理噩梦。我的建议是除非你能保证别名shared_ptr的生命周期被严格约束在相应的外层链生命周期内否则不要用。6. 常见问题与排查技巧实录6.1 为什么装饰器链上的某个方法没有被增强大概率是漏写透传。检查抽象装饰器的转发方法是否覆盖了接口的全部方法以及具体装饰器是否错误地调用了PriceDecorator::calculate而不是inner_-calculate。还有一个常见原因接口里两个方法相互调用比如calculate内部调用了另一个虚函数而装饰器增强了外部接口的calculate但内部方法依然走旧逻辑导致增强没有生效。要解决这个问题最好的办法是让接口设计单一方法独立完成一件事避免接口方法之间存在隐蔽的内部调用链。6.2 内存报错Segmentation fault / double free先排查最内层对象的所有权。如果装饰链用unique_ptr那么创建链时务必一层层用std::move包装不能出现两个装饰器共享同一层unique_ptr的情况。再排查析构顺序装饰器析构时inner_会自动递归释放整条链不需要手动delete但如果你在某个装饰器里手写了delete inner_就会造成双重释放。另一个隐蔽问题是抽象基类析构函数没有声明为virtual导致通过基类指针删除装饰链时派生类析构不执行仅基类析构被调用资源泄漏不会立刻崩溃但会随着调用次数增多逐步膨胀。C的接口类析构函数不写virtual编译器不警告但行为无疑是错的。6.3 拷贝装饰器时出现编译错误原因基本就是内部持有unique_ptr。两个思路第一删除拷贝构造和拷贝赋值只允许移动第二把内部持有改为shared_ptr保留拷贝语义。但即使使用shared_ptr也要注意拷贝后的两个装饰器共享底层组件状态可能引发数据相互影响。大多数装饰器场景里组件应该是不可变或线程安全的否则千万不要允许拷贝。6.4 装饰器层层包着单测覆盖怎么做单测策略是分层验证。最内层具体组件完全按照原始逻辑测每个具体装饰器单独测测试时给装饰器塞一个伪造的内层对象断言它是否正确增强了伪对象的返回值最后整体链做1到2个集成测试验证顺序和结果。这样某个装饰器改挂了单测能第一时间定位到具体层而不用在一整条链上打日志猜。6.5 性能监控装饰器的运行期统计如果性能监控装饰器想要统计各个方法耗时可以在装饰器内保存一个统计对象用std::chrono::steady_clock计时。但是要注意统计对象如果是所有装饰链共享的全局对象多线程并发更新要加原子量或者锁。我的偏好是让统计对象通过构造函数传入便于单测注入假统计器测试断言统计正确性。7. 写在最后一个老经验踩过装饰器的坑多了以后我自己总结了一条规矩装饰器模式在C里最大的风险不是实现复杂而是所有权不清晰。所以每写一个装饰器链我都要求代码中必须能回答三个问题谁创建了链、谁拥有最内层对象、谁负责销毁。只要创建者和销毁者是同一个人用unique_ptr整个链非常安全只要出现跨模块共享再谨慎也不为过。最后分享一个让装饰器代码更实用的习惯不要为了用模式而用模式。如果你只加一层日志而且原类本来就要改那直接改原类反而更简单不值得为了开闭原则硬上装饰器。但是一旦你已经预见到会有两种以上横切功能要叠加而且未来可能新增优先级和组合方式那么从第一天就搭好装饰器骨架比事后再从继承泥潭里迁过来划算得多。装饰器模式不是银弹但在C的工程世界里它是在不改动历史代码的前提下给旧系统续命的一把好工具。