C++代理模式实战:懒加载、智能指针与多线程踩坑指南

📅 发布时间:2026/10/11 4:55:18
C++代理模式实战:懒加载、智能指针与多线程踩坑指南
C的代理模式很多人学的时候觉得就是个“中间层转发”真正用到项目里才发现坑一个接一个。前阵子我接手一个图片查看模块图片对象加载慢、还频繁被重复创建改代理模式后不仅加载变快了顺手还拿到了调用统计。这篇文章就围绕“C中的代理模式实现”展开讲讲代理模式的设计思路、几种典型用法以及我自己在项目中踩过的坑。适合刚学设计模式想上手实战的人也适合已经写过代理但总觉得不对劲的同行。1. 先搞清楚代理模式到底在解决什么问题1.1 代理模式长什么样代理模式的核心是不直接操作真实对象而是通过一个和真实对象实现相同接口的代理对象去间接操作。调用方只认识代理根本不知道真实对象的存在或者不关心真实对象是怎么创建、什么时候创建的。用生活类比的话就是房产中介。你租房时不直接见房东而是跟中介签合同、交钱、拿钥匙。中介和房东做的事情在“提供房子”这件事上是等价的但中介在中间可以加自己的逻辑登记你的身份、收手续费、安排看房时间。这些逻辑如果暴露在房东身上房东会疯掉放在中介身上大家都省心。在代码里也一样。真实对象可能创建成本高、可能位于另一台机器、可能需要权限校验、可能希望被缓存住别重复创建。把这些横切逻辑塞进真实对象会让真实对象变得又胖又乱。代理对象把这些逻辑接过来真实对象专注自己的本职工作调用方只需要面对一个统一的接口。一个标准的代理模式包含三个角色抽象主题Subject定义真实对象和代理对象的共同接口。真实主题RealSubject真正干活的对象。代理Proxy持有对真实主题的引用控制访问并在调用前后插入附加逻辑。1.2 什么时候该用代理什么时候别硬用我见过不少同行把代理模式当万能膏药看到“想在调用前后加日志”就上代理。其实代理模式最适合的典型场景就那么几类懒加载对象创建开销大希望真正首次使用时再初始化。访问控制调用方需要区分权限代理负责校验。缓存代理可以存储上一次调用结果避免重复计算或重复加载。日志与统计代理在转发前后记录调用信息。远程通信本地代理负责网络传输调用方像调用本地对象一样。如果只是想在某个操作前后加一点逻辑而且这个逻辑只针对某一个具体类那装饰器模式可能更直接。更简单的情况是直接在原类里加一个函数连设计模式都不用上。我自己定的原则是代理模式至少要带来两个以上独立的好处才值得引入比如同时做到懒加载和访问统计否则代码里多一个类维护成本是实打实的。另外如果调用的目标对象并不复杂、创建成本很低也不存在权限控制需求那代理就是纯浪费。YAGNI原则在这里特别好用暂时不需要的复杂度就不要提前做。2. C实现代理模式的前置知识2.1 统一接口与多态代理的生命线代理模式在C里能不能成立关键看抽象接口设计得干不干净。真实类和代理类必须继承同一个纯虚基类这个基类把所有对外暴露的操作定义成纯虚函数调用方持有基类的指针或引用。接口设计有几个容易忽略的点虚析构函数必须有。如果基类的析构不是虚的通过基类指针删除一个派生类对象时只会调用基类析构派生类资源全部泄漏。这个是老生常谈但我真在代码评审里见过漏写的。接口只放必要的操作。我曾经把内部的一些辅助函数也塞进接口结果代理类要跟着实现几个根本用不上的函数代码变得很别扭。接口越小代理越容易写。考虑const正确性。如果调用逻辑只读不改接口函数就应该标记为const。代理内部如果需要在const成员函数里改缓存可以用mutable变量这个在实现懒加载代理时很常见。一个比较稳的接口设计是这样的class Image { public: virtual ~Image() default; virtual void display() const 0; virtual int width() const 0; virtual int height() const 0; };为什么接口统一这么重要因为代理模式的核心价值之一就是让客户端“无感”使用代理。客户端代码只依赖Image这个抽象传入RealImage还是ImageProxy业务代码一行不用改。这个能力完全靠接口多态撑着。2.2 RAII与生命周期代理最容易翻车的区域真实对象和代理对象之间的生命周期关系是代理模式里最容易出错的地方比接口设计容易翻车得多。这里要分清楚两种关系代理拥有真实对象代理负责创建和销毁真实对象。这种情况下代理析构时真实对象一定要跟着析构。C里最佳实践是直接用一个成员unique_ptr管理而不是裸指针加手动delete。这样代理析构时自动释放异常安全也更好。代理借用真实对象真实对象由别的地方创建和持有代理只是保存一个指针或引用。这种情况下要格外小心真实对象提前被销毁代理变成悬空引用。可靠做法是持有shared_ptr或者用weak_ptr来观察用之前lock判断是否还活着。先看一个错误示范这种代码我见过不止一次class ImageProxy : public Image { public: ImageProxy() : real_(nullptr) {} ~ImageProxy() { if (real_ ! nullptr) { delete real_; // 手动管理容易漏 } } private: Image* real_; // 裸指针拷贝时容易悬空 };这段代码至少有两个问题一是析构函数里手动delete容易因为异常路径漏掉二是如果代理对象被拷贝默认拷贝构造函数会把real_的值复制过去两个代理同时持有同一个真实对象析构时双重释放。推荐方式是把真实对象直接包在unique_ptr里class ImageProxy : public Image { private: std::unique_ptrImage real_; };这样拷贝构造被编译器直接禁止代理天然不可拷贝就只能用它管理独占生命周期。如果确实需要多个代理引用同一个真实对象就改成shared_ptr。生命周期问题别看简单能在项目里炸出各种诡异现象值得多花心思。3. 实操实现一个带缓存和计数的图片加载代理3.1 需求定义与接口设计我拿之前一个模拟项目X来讲解。项目里有一个图片查看功能Image对象从磁盘加载大图每张图都要几十毫秒甚至更久。原先的逻辑是每次显示图片都新创建Image对象结果用户浏览图库时卡片得转半天。我提了两个需求图片只在真正要显示时才加载不显示不加载也就是懒加载。给图片加一个访问计数方便统计热门图片。顺便输出加载日志方便排查性能问题。接口保持简单class IImage { public: virtual ~IImage() default; virtual void display() const 0; virtual std::string name() const 0; };display负责加载并显示name返回图片名称。为什么放到IImage而不是直接放在代理里因为客户端只认IImagedisplay、name这两个操作必须和真实对象完全一致才能做到无感替换。真实图片类是这样的class RealImage : public IImage { public: explicit RealImage(std::string filename) : filename_(std::move(filename)) { // 模拟从磁盘加载大图 std::cout [RealImage] Loading from disk: filename_ std::endl; } void display() const override { std::cout [RealImage] Displaying: filename_ std::endl; } std::string name() const override { return filename_; } private: std::string filename_; };注意细节构造里既然已经模拟加载如果直接new RealImage创建成本立刻产生。这正好是懒加载要解决的问题。3.2 代理类完整实现接下来是代理类。它有三件事要做懒加载、访问计数、日志输出。class ImageProxy : public IImage { public: explicit ImageProxy(std::string filename) : filename_(std::move(filename)), real_(nullptr), accessCount_(0) {} void display() const override { ensureLoaded(); std::cout [ImageProxy] access # accessCount_ - ; real_-display(); } std::string name() const override { return filename_; } size_t accessCount() const { return accessCount_; } private: void ensureLoaded() const { if (!real_) { std::cout [ImageProxy] First access, loading... std::endl; real_ std::make_uniqueRealImage(filename_); } } std::string filename_; mutable std::unique_ptrIImage real_; mutable size_t accessCount_; };这里有三个关键点必须解释清楚real_声明成了mutable因为ensureLoaded是在const成员函数里被调用的它要修改real_。这不算打破const语义因为对客户端来说代理对象的外部状态没有变内部缓存变化属于实现细节。accessCount_也声明成mutable这样display即使被const调用也能统计访问次数。ensureLoaded里用make_unique创建RealImage真正加载发生在这里而不是构造代理对象的时候。客户端使用方式int main() { auto img std::make_uniqueImageProxy(photo1.jpg); std::cout Proxy created, but image not loaded yet. std::endl; img-display(); img-display(); img-display(); std::cout Total accesses: img-accessCount() std::endl; return 0; }运行结果大概长这样Proxy created, but image not loaded yet. [ImageProxy] First access, loading... [RealImage] Loading from disk: photo1.jpg [ImageProxy] access #1 - [RealImage] Displaying: photo1.jpg [ImageProxy] access #2 - [RealImage] Displaying: photo1.jpg [ImageProxy] access #3 - [RealImage] Displaying: photo1.jpg Total accesses: 3可以看得出来懒加载是成功的。前三次访问都复用同一个RealImage对象Load只执行了一次。加载日志和访问次数也都拿到了。3.3 运行验证与扩展思路这个例子跑通之后能明显感受到好处一是创建代理对象几乎零成本真正显示时才加载二是重复显示不再重复加载三是热门图片统计顺手就有了。扩展方向也很自然如果图片数量很多、内存压力大可以给代理加一个“缓存上限”和“淘汰策略”比如代理内部保存最近使用列表超过N张就释放最久不用的real_。另外如果把代理对象放进shared_ptr传给多个组件访问计数还能变成全局视图方便做运营统计。我当时还顺手加了一个releaseResource方法用于主动释放真实图片、但保留代理本身这样内存吃紧时可以让代理腾出手里的图片又不影响调用方持有的代理对象。不过要注意releaseResource之后再次displayensureLoaded会重新加载这个逻辑必须测试到位。4. 进阶多线程场景下的代理封装4.1 延迟加载的线程安全问题上面那个代理如果放到多线程环境里问题立刻就暴露了。两个线程同时调用display可能同时进入ensureLoaded发现real_为空然后各自创建了一个RealImage其中一个还被丢弃。如果都执行到赋值还会出现数据竞争。经典的双检锁写法可以解决但写起来有陷阱而且C11之后有更简单的手段。我自己比较推荐的方案是用std::call_once配合unique_ptr。看代码class LazyImageProxy : public IImage { public: explicit LazyImageProxy(std::string filename) : filename_(std::move(filename)) {} void display() const override { std::call_once(onceFlag_, [this] { std::cout [LazyImageProxy] Loading... std::endl; real_ std::make_uniqueRealImage(filename_); }); real_-display(); } std::string name() const override { return filename_; } private: std::string filename_; mutable std::unique_ptrIImage real_; mutable std::once_flag onceFlag_; };std::call_once保证传入的lambda在同一时刻只会被一个线程执行完其他线程会在call_once内部等待。它比手动双检锁简单可靠不容易弄错内存序。注意once_flag和real_一样需要mutable因为它们在const成员函数里会被修改。4.2 用unique_ptr和shared_ptr做代理的取舍C里很特别的一点是智能指针本身就可以承担一部分代理职责。shared_ref、unique_ptr在RAII层面管理资源生命周期shared_ptr还能通过循环引用检测、弱回调等方式做访问控制。所以不要一上来就自己写代理类先看看智能指针能不能满足需求。什么时候直接用智能指针就行如果需求只是自动释放资源、防止泄漏那unique_ptr就是最好的代理。什么时候必须自定义代理类当你有“懒加载计数日志权限控制”这种强业务逻辑时智能指针表达不了这时候就需要自定义代理类内部再用智能指针管理真实对象。还有一个小技巧如果想给某个具体对象加一个“临时权限校验层”但又不想改动接口可以写一个局部的代理对象拦截之后转发给同一个真实对象。示例泛型不太好写因为C没有内置的简化转发所以我通常直接按接口实现一次确实会有样板代码但好在这种代理一般都不会太复杂。5. 代理模式和相邻模式怎么区分5.1 代理、装饰器、适配器对比很多初学者会把代理模式、装饰器模式、适配器模式搞混因为它们都涉及“包一层”。这里我列一个我常用的对比表模式核心意图结构特点典型场景代理模式控制访问代理与真实对象实现相同接口对象创建和使用分离懒加载、权限校验、日志、远程调用装饰器模式扩展功能装饰器和被装饰对象实现相同接口层层包装增加行为给流加缓冲、加压缩、加加密适配器模式转换接口适配器把不兼容的接口转换为目标接口老接口兼容新系统第三方库接入一句话记忆适配器改变接口让别人能用装饰器增加功能让别人用得爽代理控制访问让别人用得放心。5.2 实际选择经验我自己的挑选逻辑是先问自己“这层包装是改接口还是加行为还是控制访问”。如果客户端的调用方式和真实对象接口对不上那是适配器问题。如果客户端调用方式不变但我希望调用结果被增强比如计算耗时、打日志、增加校验这是装饰器或代理都能做的区域。如果客户端调用方式不变而且我特别关心“什么时候才真正创建对象”或者“谁被允许调用”这通常就是代理模式的主场。有一次我在代码里看到一个类叫XxxProxy点进去发现它给原功能加了一大堆数据处理逻辑最后返回的结果都不一样了。这个严格来说已经算装饰器甚至策略模式了但名字还叫代理。这种命名混乱在团队里很常见容易误导后来的人。最好在代码评审时就纠正或者在注释里说明用途不然别人看到Proxy两个字会以为只是转发结果被里面的业务逻辑坑到。6. 实战中容易翻车的地方踩坑笔记6.1 拷贝构造浅拷贝悬空代理类如果成员里有裸指针默认拷贝构造函数是浅拷贝。两个代理对象的裸指针指向同一个真实对象析构时谁先delete谁后delete完全取决于代码路径。如果第二个代理先析构第一个再调用display就是一个典型的悬空指针崩溃。解决方式我前面提到了优先用unique_ptr。如果代理需要可拷贝要么给代理实现深拷贝创建新的真实对象要么把成员改成shared_ptr多个代理共享真实对象。如果成员是shared_ptr要注意潜在的风险如果代理对象被复制多份Each副本析构时计数会安全递减这是最省心的方案。唯一要注意的是不要人为地把真实对象也做成shared_ptr然后互相引用形成环那就得用weak_ptr打破环。6.2 双检锁的旧问题和虚析构的坑旧式双检锁写法里很多人会用两个if判断加一个mutexif (!real_) { std::lock_guardstd::mutex lock(mtx_); if (!real_) { real_ std::make_uniqueRealImage(filename_); } }这段代码在现代C里其实也不是完全错但不同C版本下要小心内存序问题。如果不加atomic变量或者std::once_flag编译器可能对real_的读写进行重排另一个线程可能看到半初始化状态。与其自己处理这些细节不如直接使用std::call_once把复杂的内存序问题交给标准库。还有一个几乎所有人都会栽的坑基类析构函数不写virtual。代理模式里客户端和容器都通过基类指针管理对象IImage* p new ImageProxy(a.jpg); delete p;如果IImage的析构函数不是虚的delete p只会调用IImage::~IImage()不会调用ImageProxy的析构ImageProxy内部的unique_ptr不会释放真实对象泄漏。解决办法就是在基类写上virtual ~IImage() default; 或者至少virtual ~IImage() {}。这个错误很难立刻暴露往往要跑几天性能测试才会发现内存不断增长排查半天才定位到是析构没被调用。6.3 调试与排查技巧代理模式的调试有一个特点调用链上多了一层异常和日志的位置可能会变。我踩过几次坑之后总结了一套调试方法在所有代理类的构造、析构、各个接口里打印一下当前对象地址和操作名能用最小成本看出调用了谁、生命周期怎么走。用地址编号给每个代理和真实对象命名比如RealImage#1、Proxy#7。这样日志里能直接看到哪个代理对应哪个真实对象不会混。怀疑双重释放和悬空访问时跑一遍AddressSanitizer。ASAN能精确到具体代码行比人肉看日志快太多了。如果代理内部使用shared_ptr在关键位置使用use_count打印引用计数。有时候引用计数和想象的不一样立刻能发现是哪里多拷贝了一份。定位问题的时候还有一个经验代理出问题先别急着断点看代码先看对象的创建和销毁顺序。很多诡异的空指针和崩溃本质都是生命周期管理顺序出问题或者某个代理在真实对象释放之后还被调用。把代理模式放在真正的工程上下文里看它其实是一种“访问策略”的载体。懒加载、权限控制、日志统计、远程调用这些和业务无直接关系的关注点都被代理挡在了真实对象前面。这也是我喜欢代理模式的原因它能让真实的业务类保持干净把横切关注点收纳到一个专门的类里。不过我也想说代理模式不是越多越好。接口多一层调用的心智成本就高一层维护者要时刻清楚自己拿到的是代理还是真实对象。我一般在设计评审时会建议团队如果代理带来的收益能写清楚说明三条以上就放心用如果只能勉强说“以防以后需要”那不如别用。做实事比想象中的优雅重要能跑得稳的设计才是好设计。