ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C++装饰器模式从原理到实战:解决对象切片与所有权陷阱

C++装饰器模式从原理到实战:解决对象切片与所有权陷阱 装饰器模式在C里被聊得很多但真正能从上手到落地、把设计意图讲透的文章其实并不多。刚工作那阵子我看《Head First 设计模式》Java写得很爽等回到C项目里自己上手才发现有一堆坑等着踩对象切片、虚析构、所有权转移、和STL的配合还有性能上那些看似不起眼的开销。折腾过几轮之后才慢慢摸出一套适合自己的打法。这篇文章我不打算讲教科书式的定义也不会贴一堆只有类名没有业务场景的抽象代码。我会从实际项目中最常见的需求切入比如给已有的接口加日志、加计时、加权限校验把装饰器模式从基础实现到C特色改进讲一遍附带踩过的坑和排查思路。适合那些设计模式看过但没真用明白的C程序员也适合准备重构手头模块、想在不改老代码的前提下扩展功能的人。1. 装饰器模式要解决的真实问题1.1 先从“子类爆炸”说起想象你有一条消息发送链路基础发送可能只是把文本写进日志文件后来产品说需要加密运维说要压缩测试说要统计耗时老板说敏感信息要脱敏……如果每加一个功能就继承一次类数量直接起飞。我见过最夸张的一个项目一个FileSender派生出了十几个子类EncryptedFileSender、CompressedEncryptedFileSender、TimedCompressedEncryptedFileSender……光看名字就知道维护有多酸爽更别提组合顺序一变又要新增一个类。子类爆炸的真正根源是功能扩展点被硬编码成了类型层级而实际需求往往是一组正交的横切关注点日志、鉴权、统计、缓存叠加在同一个核心逻辑上。装饰器模式给出的答案是把这些横切关注点做成一层一层的“外包装”每层实现同一个接口内部再调用下一个对象。核心业务类不用改新功能就是新增一个装饰器类组合顺序由运行时构造决定。1.2 C 语境下的特殊考量Java里装饰器模式几乎是纯面向对象的标准作业接口 多个实现类 组合。但C有自己的一套脾气直接搬到C会出问题对象切片如果接口用基类传参而装饰器持有基类对象赋错一次值就把派生部分切没了。所有权语义装饰器包着被装饰对象到底谁删谁裸指针时代这是悬垂指针和多删的温床。虚析构缺失接口基类没有虚析构delete的时候只析构了基类部分资源泄漏没跑。性能敏感虚函数调用有间接跳转一次两次无所谓在热路径里叠五六个装饰器就能看到性能差异。所以C里实现装饰器不只是套用UML类图关键是把值语义、移动语义、智能指针、甚至模板元编程揉进去才能写出既符合模式思想又不别扭的代码。2. 经典Gof风格实现先把基础打牢2.1 角色拆分与接口设计经典实现里有四个角色Component抽象组件定义业务接口装饰器和被装饰对象都实现它。ConcreteComponent具体组件真正干活的类。Decorator装饰器基类持有Component的引用/指针转发所有接口调用。ConcreteDecorator具体装饰器在转发前后加自己的逻辑。在实际项目里接口要设计得“恰好够用”。如果一个接口塞了几十个方法装饰器基类就得转发几十次写起来很烦。我曾经封装过一个Stream接口里面读写定位刷新全都有每个装饰器都有一大堆透传代码后来学聪明了接口拆分把读写核心抽成单独的Readable和Writable装饰器只转发自己关心的部分。2.2 一个能跑起来的示例下面我用一个文本处理链来演示TextProcessor基类核心实现是PlainTextProcessor装饰器分别是去空白、转大写。实际项目里这个思路完全可以套到网络包处理、图像滤波、价格计算等场景。#include iostream #include string #include memory #include algorithm // 抽象组件 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::unique_ptrTextProcessor inner_; public: explicit TextDecorator(std::unique_ptrTextProcessor inner) : inner_(std::move(inner)) {} }; // 具体装饰器A转大写 class UpperCaseDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { std::string result inner_-process(input); std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::toupper(c); }); return result; } }; // 具体装饰器B去除连续空格 class CompactWhitespaceDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { std::string result inner_-process(input); std::string output; bool inWhitespace false; for (char c : result) { if (std::isspace(static_castunsigned char(c))) { if (!inWhitespace) output.push_back( ); inWhitespace true; } else { output.push_back(c); inWhitespace false; } } return output; } };使用方式就是在构造时层层包裹int main() { auto processor std::make_uniqueUpperCaseDecorator( std::make_uniqueCompactWhitespaceDecorator( std::make_uniquePlainTextProcessor())); std::string text hello world from decorator ; std::cout processor-process(text) std::endl; return 0; }注意这里TextDecorator构造函数接收的是unique_ptrUpperCaseDecorator用using TextDecorator::TextDecorator继承构造函数省掉了自己写一遍转发的代码。2.3 构造函数签名设计的教训有一个细节值得单独说装饰器的构造参数我强烈建议用unique_ptr而不要用裸指针。用裸指针的话调用方很容易写出这样的代码auto inner new PlainTextProcessor(); auto deco new UpperCaseDecorator(inner); delete deco; // inner 谁来删业务代码里一旦漏了delete inner就是内存泄漏删两次就是未定义行为。用unique_ptr把所有权在构造时直接转移干净装饰器析构时自然会把内部对象析构掉生命周期清晰。传引用也试过但会有个问题被装饰对象必须活得比装饰器久在工厂函数里组合对象时临时对象生命周期很容错。最后实用下来的方案就是所有权跟着装饰器走全部用unique_ptr串成一条链。提示如果你需要多个装饰器共享同一个被装饰对象比如做观察者模式配合那unique_ptr不合适改成shared_ptr并注意循环引用。我在实现一个简单的发布订阅装饰器时装饰器要同时包装多个订阅者就用shared_ptr容器来持有它们。3. C 特色改进栈上装饰器与函数式包装3.1 栈上装饰器避免堆分配经典方式里每个装饰器都是一次堆分配。对于延迟不敏感的中后台代码没问题但游戏里的技能Buff系统、实时渲染管线里的后处理链堆分配太频繁会带来性能抖动。C特有的优雅解法是模板加变参构造在栈上完成装饰器嵌套template typename Component, typename... Decorators class DecoratedProcessor; // 递归模板展开逐层包裹 template typename Component class DecoratedProcessorComponent { public: std::string process(const std::string input) { return component_.process(input); } private: Component component_; }; template typename Component, typename Decorator, typename... Rest class DecoratedProcessorComponent, Decorator, Rest... : public Decorator { public: template typename... Args explicit DecoratedProcessor(Args... args) : Decorator(std::forwardArgs(args)...) {} };这个写法有点烧脑但核心思想是用编译期递归把装饰器展开成继承链所有对象都分配在栈上没有虚函数调用、没有堆空分配、没有运行时多态开销。缺点也明显装饰器类型必须编译期确定运行时动态组合做不到。什么时候用栈上版本装饰器集合固定的场景比如一个产品线的固定消息处理链什么时候用经典版本装饰器需要配置驱动、运行时决定的场景比如根据配置中心动态启停某个中间件。我通常把两者配合核心链用模板固定可热插拔的扩展用虚函数版本。3.2 用 std::function 简化装饰器如果装饰器逻辑非常简单加个日志、加个耗时统计不值得定义一整套类。利用C的std::function可以把装饰器退化成一个函数包装链。#include functional #include string #include chrono #include iostream using ProcessFunc std::functionstd::string(const std::string); ProcessFunc withLogging(ProcessFunc next) { return [next](const std::string input) { std::cout [log] before process, input size input.size() std::endl; std::string result next(input); std::cout [log] after process, result size result.size() std::endl; return result; }; } ProcessFunc withTiming(ProcessFunc next) { return [next](const std::string input) { auto start std::chrono::steady_clock::now(); std::string result next(input); auto end std::chrono::steady_clock::now(); std::cout [timer] std::chrono::durationdouble, std::milli(end - start).count() ms std::endl; return result; }; } int main() { ProcessFunc core [](const std::string input) { return input; }; ProcessFunc pipeline withLogging(withTiming(core)); pipeline(hello); return 0; }输出会看到先打日志、再计时实际上执行顺序是withLogging 包着 withTiming所以流程是进入withLogging lambda打印before日志调用withTiming进入withTiming lambda计时开始调用core计时结束返回给withLogging打印after日志。用std::function最大的好处是灵活性构建管道时可以按配置拼接任意一个环节都可以替换成函数或lamba。缺点也很现实std::function本身的调用开销比直接虚函数还要高一点我压测过简单的转发链每层std::function大概多几十纳秒。非极端热路径完全可接受但如果你每次请求要走几十层装饰器、每秒百万请求就得回到模板方案或者手写虚函数。3.3 C17 之后把装饰器做成可变参数模板链还有一种介于两者之间的思路利用C17的折叠表达式把装饰器直接拼成一条链代码写起来挺像函数式编程里的middleware。#include iostream #include string template typename Fn auto decorate(Fn fn) { return fn; } template typename Dec, typename... Rest auto decorate(Dec dec, Rest... rest) { return dec(decorate(rest...)); } int main() { auto core [](const std::string s) { return s; }; auto withPrefix [](auto next) { return [next](const std::string s) { return [prefix] next(s); }; }; auto withSuffix [](auto next) { return [next](const std::string s) { return next(s) [suffix]; }; }; auto pipeline decorate(withPrefix, withSuffix, core); std::cout pipeline(hello) std::endl; return 0; }这种方式比std::function更轻量类型可以在编译期完整推导但调试信息非常反人类——一旦IDE里报错模板层数深到你怀疑人生。我个人的建议装饰器数量不超过三个、逻辑简单的时候模板玩一玩没问题大型工程里还是看清楚点好可维护性优先。4. 实战案例从需求到组合完整走一遍4.1 场景设定一个可扩展的HTTP请求处理模块假设我们有一个老项目中央处理函数是个几百行的spaghettivoid handleRequest(HttpRequest req, HttpResponse resp);每次上线新需求就在这个函数里加一个if语句加一个逻辑分支改了半年之后谁也动不了。现在回头用装饰器重构第一步定义处理接口struct HttpHandler { virtual ~HttpHandler() default; virtual void handle(HttpRequest req, HttpResponse resp) 0; };然后核心处理器实现class CoreHttpHandler : public HttpHandler { public: void handle(HttpRequest req, HttpResponse resp) override { // 真正的业务逻辑 resp.setBody(Hello from core handler); } };4.2 日志装饰器日志装饰器的关注点是记录请求路径、状态码、耗时。不打进业务代码里单独一层class LoggingHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; public: explicit LoggingHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { auto start std::chrono::steady_clock::now(); inner_-handle(req, resp); auto end std::chrono::steady_clock::now(); std::cout [log] path req.path() status resp.status() cost std::chrono::duration_caststd::chrono::milliseconds(end - start).count() ms std::endl; } };4.3 权限校验装饰器权限校验的核心逻辑是没权限直接短路不再调内部处理器。这种“短路”行为正好体现了装饰器的职责分离优势——CoreHttpHandler完全不知道权限什么东西class AuthHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; public: explicit AuthHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { if (!req.hasValidToken()) { resp.setStatus(401); resp.setBody(Unauthorized); return; // 短路不再调用内部处理器 } inner_-handle(req, resp); } };4.4 缓存装饰器缓存装饰器的思路是如果命中直接返回不穿透到内部。这里要特别注意“短路缓存”两个装饰器同时存在时的顺序。class CachedHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; std::unordered_mapstd::string, std::string cache_; public: explicit CachedHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { auto key req.path() req.queryString(); auto it cache_.find(key); if (it ! cache_.end()) { resp.setBody(it-second); return; } inner_-handle(req, resp); if (resp.status() 200) { cache_[key] resp.body(); } } };4.5 组合顺序决定行为组合顺序的一个经典案例auto handler std::make_uniqueLoggingHandler( std::make_uniqueAuthHandler( std::make_uniqueCachedHandler( std::make_uniqueCoreHttpHandler())));这个是外层日志、中层鉴权、内层缓存。行为是请求进来先记日志然后鉴权鉴权通过后查缓存缓存没有再进核心。但如果你把Auth和Cache换位置auto handler2 std::make_uniqueLoggingHandler( std::make_uniqueCachedHandler( std::make_uniqueAuthHandler( std::make_uniqueCoreHttpHandler())));行为就变成请求进来先记日志直接查缓存如果缓存命中就完全不鉴权这在安全敏感系统里是事故。我说这个例子是想强调装饰器的顺序不是随便定的必须由业务语义决定最好在文档里画清楚调用顺序图别只留一份代码。4.6 从配置工厂动态组装装饰器在线系统里装饰器链经常要按配置调整。可以用简单工厂std::unique_ptrHttpHandler buildHandler(const Config cfg) { std::unique_ptrHttpHandler handler std::make_uniqueCoreHttpHandler(); if (cfg.enableCache) { handler std::make_uniqueCachedHandler(std::move(handler)); } if (cfg.enableAuth) { handler std::make_uniqueAuthHandler(std::move(handler)); } if (cfg.enableLog) { handler std::make_uniqueLoggingHandler(std::move(handler)); } return handler; }这里要小心赋值给新的unique_ptr时旧的handler对象所有权被转移进了新装饰器再访问旧handler就是悬空。我在这个工厂里实际踩过一次在cfg.enableCache分支里取handler.get()存了个全局指针后面继续用结果对象已经没了排查了好几个小时。关键原则在组合链构造期间不要保存任何对中间对象的裸指针等整个链构造完再取get()。5. 与相关模式的对比与选型5.1 装饰器 vs 继承扩展能力完全不同对比一下两种方式在增加“加解密”功能时的差异。继承方式定义EncryptedFileHandler继承FileHandler重写写方法。如果要“加密压缩”呢定义EncryptedCompressedFileHandler继承谁如果还要“加密限流”类数量继续膨胀。装饰器方式每个能力一个类自由组合需要什么包什么不需要时直接不包。核心逻辑和横切关注点彻底解耦。5.2 装饰器 vs 代理模式控制权归属不同代理模式Proxy和装饰器结构很像经常有人混淆。我的理解代理强调的是“控制访问”它创建和持有真实对象的引用在调用前后加入访问控制、延迟加载、远程通信这些管理性逻辑调用方感知到的是一个代理甚至可能未被创建。装饰器强调的是“增强功能”它在已有对象上叠加新行为而且可以递归叠加重点在于扩展。举例一个ImageProxy用于延迟加载大图这是代理一个WatermarkDecorator在图片上加水印这是装饰器。代理控制你什么时候能看到图装饰器改变你看到的图本身。5.3 装饰器 vs 策略模式业务逻辑的变换维度策略模式解决的是“同一动作的不同算法”比如排序算法的选择、压缩格式的选择在构造时注入其中一个策略。装饰器解决的是“
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表