ARTICLE DETAIL

资讯详情

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

C++装饰器模式变体详解:经典继承、CRTP、函数式与宏实现

C++装饰器模式变体详解:经典继承、CRTP、函数式与宏实现 1. 为什么我重新审视了装饰器模式装饰器模式说实话在我刚接触C的头两年里一直觉得这东西有点鸡肋。学过设计模式的人都知道它的标准定义动态地给一个对象添加一些额外的职责就增加功能来说装饰器模式相比生成子类更为灵活。这话背得滚瓜烂熟但真正在项目里用起来总觉得别扭——C不像Java或者C#那样有原生的注解、反射机制想实现一个像样的装饰器代码量不小抽象层次一多调试起来还特别费劲。直到有一次我在做一个网络协议解析模块需要对数据流做多层处理解压缩、解密、格式校验、日志记录每一层都可能独立开关。如果我给每一种组合都写一个子类那类数量是爆炸式的增长而且一旦需求变了比如要在解密和校验之间再插一层流量统计子类体系就得大改。当时我就想这场景不就是教科书里说的装饰器模式适用场景吗但我用传统写法试了一下发现C的装饰器实现有很多变体有的变体代码更优雅、性能更好有的变体虽然看起来绕但扩展性极强。这篇文章我就想把这些变体整理一下聊聊它们各自的适用场景、代码写法和踩坑经验。我不会把装饰器模式包装成什么银弹但如果你正在做中间件架构、流式处理管道、或者想让代码具备运行时插拔能力这篇文章应该能帮你省掉不少自己摸索的时间。2. 装饰器模式的核心思想到底是什么在聊变体之前得先把地基打牢。装饰器模式本质上是组合优于继承这一设计原则的典型实践。它的核心思想其实就一句话在不修改原始类代码的前提下通过包装Wrapper的方式给对象增加新功能。我们想象一个场景你开了一家奶茶店你有一杯基础奶茶BaseMilkTea现在顾客想加珍珠、加奶盖、加椰果。如果按照继承的思路你得写珍珠奶茶类、奶盖奶茶类、椰果奶茶类、珍珠奶盖奶茶类、珍珠椰果奶茶类……你想想这得多少个类而装饰器模式的思路是把加珍珠、加奶盖这些动作包装成一个装饰器类每个装饰器接收一个奶茶对象然后在它的基础上增强。《Head First 设计模式》里那个经典案例——星巴克咖啡——就是把饮料和调料拆开用装饰器做组合。C实现装饰器模式的关键在于抽象基类定义接口具体组件ConcreteComponent实现这个接口装饰器类Decorator也实现同样的接口同时内部持有一个指向基类的指针。这样装饰器可以层层嵌套你做一个加珍珠加奶盖的奶茶实际上就是new 奶盖Decorator(new 珍珠Decorator(new 基础奶茶()))。核心要点是装饰器和被装饰对象必须实现同一个抽象接口否则外层的装饰器就无法嵌套工作。这个模式的第一个好处是类数量可控组合爆炸被线性增长替代。第二个好处是运行时灵活性继承是编译期决定的而装饰器在运行期可以任意组合、任意调整顺序。第三个好处是符合开闭原则新增一个装饰器类不需要改动任何已有代码。但在C里这个模式有几个天然需要面对的问题一是C没有垃圾回收指针的归属和生命周期要处理好否则内存泄漏是家常便饭二是C有值语义而传统的装饰器模式依赖引用语义跟STL容器和算法的配合不太顺畅三是虚函数调用有性能开销。所以后来才衍生出了各种变体有的变体就是在解决这三大问题。3. 经典继承式变体教科书写法在C里的真实表现3.1 标准装饰器结构的代码骨架我们先看最标准的装饰器实现。假设我们有一个文本处理器的接口#include iostream #include memory #include string // 抽象组件 class TextProcessor { public: virtual ~TextProcessor() default; virtual std::string process(const std::string input) 0; }; // 具体组件 class PlainTextProcessor : public TextProcessor { public: std::string process(const std::string input) override { return input; } }; // 装饰器基类 class TextDecorator : public TextProcessor { protected: std::shared_ptrTextProcessor wrapped_; public: explicit TextDecorator(std::shared_ptrTextProcessor wrapped) : wrapped_(std::move(wrapped)) {} }; // 具体装饰器A转大写 class UpperCaseDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { std::string result wrapped_-process(input); for (auto ch : result) { ch static_castchar(std::toupper(ch)); } return result; } }; // 具体装饰器B加前缀后缀 class BracketDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { return [ wrapped_-process(input) ]; } }; int main() { auto processor std::make_sharedBracketDecorator( std::make_sharedUpperCaseDecorator( std::make_sharedPlainTextProcessor())); std::cout processor-process(hello world) std::endl; // 输出: [ HELLO WORLD ] return 0; }这段代码展示了继承式装饰器的基本骨架TextDecorator继承了TextProcessor接口同时内部又持有另一个TextProcessor。调用时装饰器先委托给内部对象处理然后自己增加额外的逻辑。注意我用的是std::shared_ptr这是C里处理装饰器模式最友好的智能指针它解决了内存所有权问题让嵌套对象可以安全共享。3.2 继承式的优缺点分析这个经典变体的优点是结构直观、符合教科书描述、IDE跳转调试友好团队里换个人来接手也能快速看懂。但它的缺点在C项目里也很突出第一类型膨胀问题。如果装饰器种类比较多比如有5种装饰器那组合类型虽然有运行时灵活性但每层包装都是std::shared_ptrTextProcessor实际类型已经被擦除了后面想拿回具体类型做特殊操作就得做dynamic_cast这是反模式的味道。第二虚函数开销。每层装饰器都是一次虚函数调用。如果装饰链有五六层加上每层的业务逻辑虽然单次开销不大但如果你写的是性能敏感型代码比如每秒钟调用几十万次这个成本就会被放大。第三构造顺序和析构顺序要小心。装饰器嵌套时析构顺序由shared_ptr的引用计数管理如果链条构造的时候有循环引用——比如装饰器A持有装饰器B装饰器B又要持有A——那就会内存泄漏。shared_ptr解决不了循环引用。不过话说回来对于大多数业务逻辑代码每秒几万次虚函数调用根本不是瓶颈继承式装饰器最大的价值就是代码意图清晰。我个人的观点是如果是团队协作项目、维护优先级高于性能优先级经典写法是首选。4. 模板变体之一基于CRTP奇异递归模板模式的编译期装饰器4.1 为什么会有模板装饰器的需求C开发者写久了就会发现很多东西其实在编译期就能确定根本不需要运行时多态去解决。比如日志装饰器如果日志功能在编译期就确定要加而且确定加在哪个环节那用虚函数动态包装就有点杀鸡用牛刀了。编译期就能定下来的类型组合应该让编译器去生成代码而不是让运行时去做类型擦除和重新包装。CRTP是这个思路的典型代表。CRTP全称是Curiously Recurring Template Pattern中文一般叫奇异递归模板模式。它的核心是基类模板以派生类类型作为模板参数。这样一来基类在编译期就能知道派生类的存在可以把某些行为静态地注入派生类。4.2 CRTP装饰器的实现细节我们看一个实用的例子给一个文件操作类加带日志功能和带耗时统计功能用CRTP实现。#include iostream #include chrono #include string template typename Derived class LoggingBase { public: void write(const std::string data) { std::cout [LOG] 即将写入数据长度 data.size() std::endl; static_castDerived*(this)-writeImpl(data); std::cout [LOG] 写入完成 std::endl; } }; template typename Derived class TimingBase { public: void write(const std::string data) { auto start std::chrono::high_resolution_clock::now(); static_castDerived*(this)-writeImpl(data); auto end std::chrono::high_resolution_clock::now(); auto ms std::chrono::durationdouble, std::milli(end - start).count(); std::cout [TIME] 耗时: ms ms std::endl; } }; // 基础组件普通文件写入 class SimpleFileWriter { public: void writeImpl(const std::string data) { // 假装写文件 std::cout 写入内容: data std::endl; } }; // 组合方式1带日志的文件写入 class LoggedFileWriter : public LoggingBaseLoggedFileWriter { public: void writeImpl(const std::string data) { std::cout 写入内容: data std::endl; } }; // 组合方式2带日志和计时注意继承顺序 class LoggedTimedFileWriter : public LoggingBaseLoggedTimedFileWriter, public TimingBaseLoggedTimedFileWriter { public: void writeImpl(const std::string data) { std::cout 写入内容: data std::endl; } };CRTP装饰器的巧妙之处在于LoggingBaseDerived里调用的writeImpl是静态绑定的编译器在编译期就知道Derived的具体类型所以跳过了虚函数表也没有shared_ptr搬运性能极佳。如果你写的是嵌入式、游戏引擎底层之类的高性能代码这种编译期装饰器几乎是零开销。但这里有一个大坑也是我踩过的多个CRTP基类组合时成员函数重名会导致歧义。比如上面的LoggedTimedFileWriter同时继承了两个基类两个基类都有write()方法如果直接调用writer.write(hello)编译器会报对write的访问不明确。你必须自己定义转发函数或者用一个基类去包装另一个基类。这个问题让CRTP的装饰链变得很别扭——想任意组合装饰器CRTP反而不如经典运行时版本灵活。4.3 适用场景和局限CRTP装饰器适合的场景装饰器种类和组合关系在编译期就完全确定、不需要动态变化对性能有要求代码不需要通过抽象基类来统一管理比如不需要容器里存一堆不同的装饰器对象。它不适合的场景想在运行时动态切换装饰策略、装饰器需要作为参数传给某个函数且类型各异因为CRTP没有公共基类除非你额外加一层接口——这种情况下模板装饰器会把类型系统搞得很复杂反而得不偿失。5. 模板变体之二用std::function实现函数式装饰器5.1 函数式装饰器的设计思路还有一种在C里越来越流行的变体就是借助std::function把装饰逻辑从类和对象层面降到函数和闭包层面。这种写法适合场景是你不需要保存一个多次调用的装饰器对象而只是想给某个函数打一层皮处理完之后这层皮就可以丢掉了。回到奶茶的例子。如果用函数式装饰器来理解基础奶茶就是一个函数makeDrink()加珍珠就是一个高阶函数它接收一个制作函数返回一个新的制作函数。这个思路在Python里很常见Python的装饰器就是语法糖C用std::function lambda 也能实现类似的效果只是没有语法糖写起来需要一些仪式感。5.2 一段可运行的函数式装饰器代码#include functional #include iostream #include string #include chrono // 定义一个饮品制作函数类型 using DrinkMaker std::functionstd::string(); DrinkMaker addPearl(DrinkMaker base) { return [base std::move(base)]() { return base() 珍珠; }; } DrinkMaker addCheeseFoam(DrinkMaker base) { return [base std::move(base)]() { return base() 奶盖; }; } DrinkMaker addTiming(DrinkMaker base) { return [base std::move(base)]() { auto start std::chrono::high_resolution_clock::now(); auto result base(); auto end std::chrono::high_resolution_clock::now(); auto ms std::chrono::durationdouble, std::milli(end - start).count(); std::cout [TIME] 制作耗时: ms ms std::endl; return result; }; } int main() { DrinkMaker basic []() { return 基础奶茶; }; DrinkMaker drink addCheeseFoam(addPearl(addTiming(basic))); std::cout 最终饮品: drink() std::endl; return 0; }这个变体的优势是代码极度灵活、装饰器本身就是一个函数对象、不需要抽象基类、不需要维护继承体系、组合顺序就是函数嵌套顺序。它适合做流水线处理、钩子机制、中间件链。比如我做过的HTTP请求处理框架每个中间件就是一个高阶函数把下一个中间件作为参数传进去这种设计写起来非常自然。5.3 缺点分析与使用心得但std::function版本有一个软肋性能。std::function内部会做类型擦除可能涉及堆分配虽然小函数优化SBO能避免部分情况。如果你的装饰链很长、调用频率极高建议用auto和模板传递来替代std::function或者干脆用CRTP方案。但如果是在业务层、I/O层做包装性能完全够用。我在使用函数式装饰器时的一个心得是lambda捕获列表最好用std::move显式转移底层函数对象。如果你直接[base]复制捕获复制std::function也有开销而且底层的可调用对象如果是不可复制的比如捕获了std::unique_ptr的lambda就会编译失败。代码里的[base std::move(base)]是C14引入的初始化捕获写法尽量用它。注意用std::function装饰链时类型擦除会让调试器里很难看到完整的调用栈层次。建议在生产代码里给每个装饰lambda加一个合理的函数名或者干脆用auto返回类型推导 模板参数传递这样编译器能保留完整类型信息报错和调试都方便很多。6. 宏魔法变体把装饰器做成属性注解6.1 宏实现装饰器的方式与价值C没有Java那样的注解但我们可以用宏模拟一个注解效果。这个变体的想法是这样的写代码的时候你在函数上贴一个标记编译时这个标记自动展开成包装逻辑。这个思路适合的场景是装饰器种类固定、项目里已经标准化了日志/埋点/权限校验的写法、你希望装饰器既保留声明式美感又减少重复代码。举个我自己用过的例子。我在项目里要做接口耗时监控每个RPC服务入口都要打印入参、执行时间、返回值。如果每个接口都手写一遍计时逻辑那太蠢了。当时就用宏做了一个简单的包装#include iostream #include chrono #include string // 定义一个计时器宏调用 __func__ 获取函数名打印耗时 #define TIMED_FUNCTION \ auto start_time_ std::chrono::high_resolution_clock::now(); \ struct TimingGuard { \ const char* func_name; \ ~TimingGuard() { \ auto end_time std::chrono::high_resolution_clock::now(); \ auto ms std::chrono::durationdouble, std::milli(end_time - start_time_).count(); \ std::cout [TIME] func_name 耗时: ms ms std::endl; \ } \ } timing_guard_ { __func__ }; class UserService { public: std::string getUserName(int id) { TIMED_FUNCTION // 模拟耗时操作 for (volatile int i 0; i 1000000; i) {} return user_ std::to_string(id); } };这个宏展开后函数体内多了一个TimingGuard局部对象它的析构函数会在函数退出时自动执行从而打印耗时。这其实就是RAII技巧不是严格意义上的装饰器模式但它的意图和装饰器一致在不修改函数核心逻辑的前提下给函数增加横切关注点。6.2 宏装饰器的架势和背后的风险宏变体最大的优点是调用方代码看起来非常干净函数内部没有大段包装代码而且性能开销几乎为零。但它的缺点也很明显第一宏的可调试性极差。宏展开后的代码报错错误信息往往指向一个很深层的位置你在IDE里点击错误多半跳不到宏内部。调试带宏的函数经常要在心里手动展开宏才能理解执行流。第二宏污染命名空间。短小通用的宏名很容易和第三方库冲突。我建议宏名带项目前缀比如MYLIB_TIMED_FUNCTION避免TIMED_FUNCTION这种通用名字。第三宏无法做类型检查。宏是在预处理阶段处理的它不知道C类型系统。所以宏变体适合固定、统一、简单的装饰逻辑一旦装饰器本身变复杂比如要做条件判断、循环包装宏的体积就会膨胀到难以维护。所以我现在的态度是宏装饰器作为团队内部约定固定套路可以用但只限于无脑包装的场景而且一定要配套详尽的代码注释。如果是库作者不建议用宏向用户暴露装饰能力因为用户无法很好地定制和组合。7. 实战拆解一个同时叠加日志、鉴权和缓存的业务示例前面讲了四类装饰器变体但纸上谈兵容易让人看过就忘。这节我拿一个完整的业务场景——用户信息查询接口——把几个变体放在一起对比演示。场景是这样的有个getUserInfo(int userId)函数返回用户信息现在需要叠加三个能力鉴权校验调用者有没有权限、日志记录调用参数和耗时、缓存相同userId短时间内不重复查库。7.1 基础接口和实现#include iostream #include map #include mutex #include string #include functional #include memory #include chrono struct UserInfo { std::string name; int age; }; // 抽象组件 class UserProvider { public: virtual ~UserProvider() default; virtual UserInfo fetch(int userId) 0; }; // 基础实现直接查库 class DatabaseUserProvider : public UserProvider { public: UserInfo fetch(int userId) override { std::cout [DB] 查询数据库 userId userId std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(100)); return UserInfo{user_ std::to_string(userId), 30 userId}; } };7.2 经典装饰器版实现我用经典继承式装饰器实现鉴权和缓存// 鉴权装饰器 class AuthDecorator : public UserProvider { std::shared_ptrUserProvider wrapped_; public: explicit AuthDecorator(std::shared_ptrUserProvider wrapped) : wrapped_(std::move(wrapped)) {} UserInfo fetch(int userId) override { // 简单模拟鉴权userId 大于0就有权限 if (userId 0) { throw std::runtime_error(无权限访问); } std::cout [AUTH] 鉴权通过 std::endl; return wrapped_-fetch(userId); } }; // 缓存装饰器 class CacheDecorator : public UserProvider { std::shared_ptrUserProvider wrapped_; std::mapint, UserInfo cache_; std::mutex mutex_; public: explicit CacheDecorator(std::shared_ptrUserProvider wrapped) : wrapped_(std::move(wrapped)) {} UserInfo fetch(int userId) override { std::lock_guardstd::mutex lock(mutex_); auto it cache_.find(userId); if (it ! cache_.end()) { std::cout [CACHE] 命中缓存 userId userId std::endl; return it-second; } auto result wrapped_-fetch(userId); cache_[userId] result; return result; } }; // 日志装饰器 class LogDecorator : public UserProvider { std::shared_ptrUserProvider wrapped_; public: explicit LogDecorator(std::shared_ptrUserProvider wrapped) : wrapped_(std::move(wrapped)) {} UserInfo fetch(int userId) override { std::cout [LOG] 开始查询 userId userId std::endl; auto start std::chrono::high_resolution_clock::now(); auto result wrapped_-fetch(userId); auto end std::chrono::high_resolution_clock::now(); std::cout [LOG] 查询完成耗时 std::chrono::durationdouble, std::milli(end - start).count() ms std::endl; return result; } };使用时int main() { std::shared_ptrUserProvider provider std::make_sharedLogDecorator( std::make_sharedAuthDecorator( std::make_sharedCacheDecorator( std::make_sharedDatabaseUserProvider()))); auto info provider-fetch(42); std::cout 姓名: info.name , 年龄: info.age std::endl; return 0; }7.3 函数式装饰器版实现对比同样的功能用std::function写法是这样using FetchFunc std::functionUserInfo(int); FetchFunc withAuth(FetchFunc next) { return [next std::move(next)](int userId) { if (userId 0) throw std::runtime_error(无权限访问); std::cout [AUTH] 鉴权通过 std::endl; return next(userId); }; } FetchFunc withCache(FetchFunc next) { auto cache std::make_sharedstd::mapint, UserInfo(); auto mtx std::make_sharedstd::mutex(); return [next std::move(next), cache, mtx](int userId) { std::lock_guardstd::mutex lock(*mtx); auto it cache-find(userId); if (it ! cache-end()) { std::cout [CACHE] 命中缓存 userId userId std::endl; return it-second; } auto result next(userId); (*cache)[userId] result; return result; }; } FetchFunc withLog(FetchFunc next) { return [next std::move(next)](int userId) { std::cout [LOG] 开始查询 userId userId std::endl; auto start std::chrono::high_resolution_clock::now(); auto result next(userId); auto end std::chrono::high_resolution_clock::now(); std::cout [LOG] 查询完成耗时 std::chrono::durationdouble, std::milli(end - start).count() ms std::endl; return result; }; }函数式风格的装饰器在使用上有几个直观差异缓存状态cache和mtx不用写在类成员里直接捕获进lambda代码集中度更高。装饰器是围绕函数签名的而不是围绕类的接口。如果UserProvider还有另一个方法fetchByName函数式装饰器只能单独包装函数而类装饰器可以沿用同一个对象实例的所有方法。不过也可以通过方法的函数对象做同样的包装。类型更轻量你不需要为每个装饰器建一个类写一个返回lambda的函数就够了。7.4 两种写法的选择逻辑我在实践中总结了一个简单的选择规则如果装饰的是一组相关方法某个接口的多个方法都要鉴权和缓存用经典继承式装饰器因为装饰器可以拿到整个接口统一处理所有方法。如果装饰的是单个函数、且对容器和多态没有要求用函数式装饰器代码量少、结构简单。如果对性能极度敏感且组合在编译期确定用CRTP但通常业务代码用不着上这种强度。宏版本只在团队内统一横切逻辑时使用不要作为通用库能力暴露。8. 装饰器模式在C项目中的典型应用场景8.1 协议栈与中间件架构这是装饰器模式在C里最出彩的领域。网络通信的数据在进入业务逻辑之前往往要经过多个处理层SSL解密、流量解压、协议头解析、报文格式校验、流量统计、限流。每一层都是一种装饰器组合顺序就是协议栈顺序。用经典装饰器模式实现协议处理管道比传统的if-else流水账要清晰得多。每层是一个独立的鬼层级之间的关系通过构造函数组装新增一层协议处理不需要改动其他层。我参与过一个物联网网关项目就是按照这个思路把数据解析管道做成了一串装饰器当时还配了一个配置文件——通过配置决定哪些装饰器要启用、以什么顺序启用——这样网关面向不同的传感器厂商不需要改代码只要改配置灵活性非常好。8.2 GUI系统里的行为增强在C GUI框架里装饰器模式经常被用来做行为增强。比如Qt里你想要一个控件既支持右键菜单又支持拖拽上传你不需要去改 QWidget 的源代码可以用装饰器包装。不过Qt本身有事件过滤器机制功能上可以替代部分装饰器场景。但如果是自绘引擎、轻量级UI库没有现成的事件过滤器那装饰器就是很实用的选择。8.3 并发场景下的锁包装C并发编程里装饰器模式可以包装一个需要线程安全的操作函数在进入函数时自动加锁退出时自动解锁。这个用法其实和RAII锁很像但装饰器可以做得更通用——给一个任意操作函数自动加读写锁、加超时保护、加重试逻辑。比如// 装饰器模式封装互斥锁 template typename Func auto withLock(std::mutex mtx, Func func) { return [mtx, func std::forwardFunc(func)](auto... args) mutable { std::lock_guardstd::mutex lock(mtx); return func(std::forwarddecltype(args)(args)...); }; }不过这里要提醒一下装饰器加锁有一个隐蔽的坑——装饰器对象本身要能跨线程访问且安全初始化。如果你在构造函数里没有初始化好互斥锁就被别的线程调用了那是未定义行为。C的std::mutex不允许拷贝装饰器的拷贝构造会被delete最好以引用的方式持有外部传入的锁对象或者用std::shared_ptrstd::mutex。8.4 测试代码中的Mock扩展写单元测试时我经常给一个对接外部服务的接口加一个假装饰器它不调外部服务而是返回预设的结果。这个假装饰器和真实装饰器实现同一个接口测试时可以自由替换。这种用法本质上就是装饰器模式对开闭原则的实践——被测代码完全不知道底层是真实对象还是Mock对象。9. 装饰器模式实战中的几个疑难杂症9.1 疑难杂症一装饰器链的构造顺序和调用顺序不一致装饰器链的执行顺序和构造顺序是相反的这一点容易被新手忽略。看代码auto provider std::make_sharedLogDecorator( std::make_sharedAuthDecorator( std::make_sharedDatabaseUserProvider()));构造函数从内到外执行先创建 DatabaseUserProvider然后 AuthDecorator 包住它最后 LogDecorator 包住 AuthDecorator。但调用provider-fetch(42)时执行顺序是从外到内先走 LogDecorator再走 AuthDecorator最后到 DatabaseUserProvider。所以日志装饰器打印的耗时包含了鉴权耗时。如果业务要求鉴权不计入日志耗时或日志装饰器要放在最内层那你必须调整装饰器的嵌套顺序而不是改代码逻辑。这是个非常容易踩坑的细节建议在代码注释里明确标注执行顺序和构造顺序的关系。9.2 疑难杂症二装饰器的拷贝与移动语义C装饰器如果不小心允许拷贝很容易出现同一份被包装对象被多个装饰器同时持有的情况。比如LogDecorator d1(std::make_sharedAuthDecorator(...)); LogDecorator d2 d1; // 默认拷贝构造函数会出现d1和d2内部的wrapped_是同一个shared_ptr所以两者共享底层对象。这在装饰器场景下通常不是问题反而共享是合理的。但要注意的是如果装饰器内部有自己独立的std::mutex、std::vector等成员默认拷贝构造会导致两个装饰器共享一个互斥锁或两份独立的状态这就会出现逻辑错误。我的习惯是装饰器类的拷贝构造和赋值都显式删除只允许移动。如果确实需要拷贝语义比如你想把装饰器作为值保存在容器里那就得设计好内部状态是共享的还是深拷贝的。9.3 疑难杂症三多层装饰器下的类型识别当装饰器嵌套到三四层之后你想在某个环节判断这个对象实际上是带缓存的还是不带缓存的就会很麻烦。用dynamic_cast需要装饰器基类是多态的有虚函数这一点装饰器通常满足但动态类型是装饰链最外层的类型你无法从外层判断内层有没有CacheDecorator。解决这个问题有几个思路给装饰器加一个hasCapability之类的查询接口在装饰链里逐层传递查询。把装饰器的能力信息放在被包装对象身上而不是装饰器身上。这相当于一个简单的注册表模式。用类型列表std::tuple 编译期递归把装饰器类型信息保留在编译期运行时独立判断但这就复杂了。大多数业务场景其实用不着动态查询装饰能力。如果你发现需要频繁区分装饰链内部结构先停下来想想是不是把装饰器模式用错了方向此时可能应该改用策略模式或者责任链模式。9.4 疑难杂症四std::function装饰器里的递归嵌套性能用std::function做装饰链时lambda的嵌套会让每个调用都经过一层std::function的间接调用。如果装饰链有5层drink()的调用成本大概是5次std::function的operator()调用。每一次std::function的调用不一定内联所以性能上有一定损耗。我在一个日志系统中实际测过用std::function装饰器实现的三层中间件链单次调用比手写线性代码慢了约80~150ns跟具体编译器和优化选项有关系这个量级对大多数日志场景可以忽略但对高频热路径还是有影响的。如果热路径需要保持极快建议用模板传递代替std::functiontemplate typename F auto addTiming(F base) { return [base std::forwardF(base)](auto... args) mutable { // ... return base(std::forwarddecltype(args)(args)...); }; }这样装饰器的具体类型在编译期被完整保留编译器能内联展开性能接近手写代码。10. 模式变体横向对比哪种装饰器写法更适合你的项目这节我把前文讲到的四类变体放在一张表里做对比方便你根据项目情况快速选型。对比维度经典继承式CRTP编译期std::function函数式宏变体运行时动态组合支持不支持支持不支持性能开销每次调用一次虚函数几乎为零std::function类型擦除开销为零代码可读性清晰符合设计模式直觉偏复杂模板学习成本高简洁函数式思维最简洁但宏难以调试类型安全强强编译期检查中等std::function擦除部分类型弱宏不检查类型装饰信息保留运行时可见编译期完全保留类型被擦除不保留不保留适合规模中小型装饰器种类不多性能敏感组合固定高灵活性业务管道团队内固定横切逻辑调试友好度好中中差内存管理复杂性需要shared_ptr/unique_ptr无堆分配压力依赖lambda捕获需注意生命周期无特殊要求从我的使用经验来看项目维护团队的平均水平决定了选什么变体。如果团队里大部分人对模板还不够熟那CRTP装饰器维护起来会有些吃力不如经典继承式稳妥。如果是个人项目、你一个人掌控全局函数式装饰器效率最高。11. 写在最后两个实战中的个人体会装饰器模式在C里的价值与其说是让代码更短不如说是让代码的逻辑边界更清晰。我见过很多代码逻辑本身是对的但各种横切关注点——日志、鉴权、统计——全部散落在业务代码中间非常难维护。装饰器模式无论采用哪种变体的核心收益就是把这些横切关注点从业务主线里剥离出来每个关注点各自独立演化。另外一个体会是不要试图用装饰器模式解决所有代码复用问题。装饰器模式的本质是包装增强它做的是加法不适合做减法。如果一个功能只是部分应用需要同时暴露内部对象的额外方法装饰器写起来就很尴尬——你想访问内部对象的某个方法要么在装饰器里转发要么就得破坏封装。这个时候更合适的选择可能是策略模式、职责链模式或者干脆把增强逻辑直接写进原类的方法里。我实际做项目时的做法是先画出核心数据流主线看横切关注点是否稳定、是否多变。横切关注点多且经常增删用装饰器横切关注点少、就固定两三个直接用RAII或者if-else可能更省事。设计模式是用来简化设计的不是用来证明你会用设计模式的。如果你正在琢磨自己手里的模块能不能用装饰器重构不妨先把装饰链画在纸上从上到下列出所有包装层、标出每层的输入输出、确认装饰顺序确实是你想要的然后照着经典继承式或者函数式先跑通一条最小链路。等链路通了再考虑要不要用模板优化性能。这样步子小一点踩坑的几率也会小很多。
返回列表