C++代理模式高级应用:远程代理、延迟加载与编译期实现

发布时间:2026/9/9 18:34:08
C++代理模式高级应用:远程代理、延迟加载与编译期实现 写了两年代码之后代理模式是我在C里用过最多、也最容易翻车的设计模式。说它容易翻车不是因为概念难而是C和Java、Go不一样没有内置的反射和动态代理机制也没有接口自带的拦截器所有代理逻辑都要靠手写而一旦手写你马上就会遇到生命周期、拷贝语义、异常传播、编译期约束这些问题。这篇博客我打算把代理模式在C里的高级应用场景摊开聊一遍从远程代理到延迟加载从保护代理到缓存代理再到C特有的编译期代理实现每一块都配合可落地的代码和经验教训适合已经会用继承和多态的C开发者阅读想深入了解设计模式在工程里怎么真正落地的朋友也会有用。1. 代理模式在C里的真实定位1.1 代理模式到底在解决什么问题代理模式的核心思想其实就一句话不直接操作真实对象而是通过一个替身来控制访问。这个替身和真实对象实现同一个接口外部调用方甚至感知不到自己正在跟代理打交道。这不是简单的中间层它是把“调用前的准备、调用后的处理”全部封装在代理里面让真实对象保持干净只做自己该做的业务逻辑。举个例子你有一个UserService类里面有GetUser、UpdateUser这些方法。如果直接在类里面加日志、加权限校验、加缓存这个类很快就会变成一堆横切逻辑的大杂烩。用代理以后业务类只需要关心数据库操作和业务规则日志、权限、缓存全部挪到代理层职责边界一下子就清楚了。在C里这个模式的实现手段比别的语言更丰富。Java有动态代理C#有DispatchProxy而C有虚函数、模板、智能指针、constexpr这些机制组合起来可以做出运行时代理也可以做出编译期就完成的静态代理后者是很多C程序员没有认真挖掘过的点。1.2 C实现代理的独特之处与难点C里的代理有一个天然优势值语义和栈上对象。你完全可以把一个代理对象放在栈上让它包装一个堆上的真实对象或者包装一个栈上的对象生命周期清清楚楚不需要像Java那样所有对象都在堆上漂着。但这也带来了特有的难点。第一C没有反射代理类必须为每个接口方法手写转发逻辑接口一旦有几十个方法手写代理类的代码量会爆炸。第二拷贝问题。代理对象本身应该能够被安全拷贝吗如果拷贝了里面包装的真实对象是共享还是深拷贝这些在Java里不太需要关心在C里必须显式设计。第三异常安全。代理层很可能在调用前后做额外操作如果真实对象抛出异常代理里的状态是否还能保持一致性这些问题如果不提前想清楚代理模式写得越多代码越乱。真正的工程做法是在动手前先把代理要干的事分类把代理形态选好再决定是手写类还是用模板生成。2. 远程代理跨进程服务调用的接口一致性2.1 场景拆解与设计思路远程代理是代理模式最经典的场景客户端和服务端不在同一个进程里但客户端希望像调用本地函数一样调用远程服务。典型的做法是定义一个抽象接口服务端实现真实逻辑客户端拿到一个代理类代理内部通过网络协议把请求序列化并发给服务端再接收响应。我在实际项目里做过一个内部RPC框架当时面临一个选择到底是让客户端直接操作socket还是隐藏一层代理如果直接操作socket业务代码里会到处是序列化和反序列化几个项目下来就失控了。后来确定为每个服务定义纯虚接口然后让代理类实现这个接口内部封装socket通信。这样业务代码永远只看到接口测试的时候可以传入一个mock实现生产环境传入代理对象代码完全不用改。这里有个设计细节值得注意接口本身必须做成纯抽象类析构函数声明为virtual并且接口里不要出现任何数据成员。否则一旦有成员变量就会牵扯到对象布局代理类和真实类之间也不能混用。2.2 手写一个最小可用的远程代理假设有一个用户服务接口class UserService { public: virtual ~UserService() default; virtual User GetUser(int id) 0; virtual void UpdateUser(const User user) 0; };客户端这边的远程代理大致长这样class UserServiceProxy : public UserService { public: explicit UserServiceProxy(std::string endpoint) : endpoint_(std::move(endpoint)) {} User GetUser(int id) override { Request req; req.set_method(GetUser); req.set_param(id); // 序列化并发送请求 auto resp transport_.Call(endpoint_, req); // 反序列化响应出错时抛异常 if (resp.error_code() ! 0) { throw RemoteException(resp.error_message()); } return parse_user(resp.payload()); } void UpdateUser(const User user) override { Request req; req.set_method(UpdateUser); req.set_param(user); auto resp transport_.Call(endpoint_, req); if (resp.error_code() ! 0) { throw RemoteException(resp.error_message()); } } private: std::string endpoint_; Transport transport_; };这段代码不复杂但里面埋着几个远程代理特有的坑。第一个坑是超时。TCP请求可能一直挂起代理实现里必须给每个方法设置超时时间我一般用300ms作为默认值但对于批量接口单独调大。超时之后应该抛异常还是返回空对象我的建议是抛异常因为远程调用的失败是严重的非正常情况静默吞掉会让调用方得到错误数据排查成本更高。第二个坑是代理的线程安全性。一个代理对象可能被多个线程同时调用如果Transport内部不是线程安全的就必须在代理层加锁或者做成每次调用创建独立请求上下文。我见过不少线上故障就是因为多个线程复用同一个代理连接导致响应串包。最简单的做法是代理内部用互斥锁保护请求过程或者用连接池而不是让代理持有单一长连接。第三个坑是异常传播。远程调用失败和本地调用失败性质完全不同网络抖动、服务端崩溃、请求超时这些都要反馈到调用方。如果不定义统一的异常类型调用方就得分门别类地catch一堆底层网络异常非常痛苦。设计一个继承自std::runtime_error的RemoteException把所有远程错误包装起来是值得的。2.3 减少手写转发代码的模板技巧接口方法一多手写代理类就让枯燥和出错并存。我后来用了一个模板基类来减少重复劳动思路是定义一个泛型的Invoker代理类只需绑定方法名和参数template typename Ret, typename... Args class AsyncInvoker { public: virtual Ret Invoke(std::string_view method, Args... args) 0; };然后所有代理方法改成这样User GetUser(int id) override { return invoker_.InvokeUser(GetUser, id); }当然这个Invoker内部还是需要做类型擦除和序列化但至少每个方法的代码量压到了三行以内。类型擦除可以用std::function和std::any组合或者直接依赖protobuf这类带反射的序列化库。我的实际感受是远程代理的关键不在写起来多酷而是把序列化协议、鉴权、超时重试这些非业务逻辑全部收敛到一层业务代码永远保持干净。3. 虚拟代理与智能指针延迟加载的两种实现路径3.1 虚拟代理的应用场景虚拟代理解决的痛点很明确真实对象创建很昂贵但并不一定每次都会被用到。典型场景包括加载一个大图片、初始化一个数据库连接池、读取一个巨大的配置文件。如果程序启动时就把这些全部创建出来启动时间长内存占用高而大量对象可能整个运行周期都没被访问过。代理的做法是代理对象本身很轻记录真实对象的存在位置或构建方式第一次真正调用方法时才把真实对象new出来后续调用直接转发给真实对象。3.2 手动实现延迟加载代理假设有高分辨率图片类class Image { public: explicit Image(const std::string path) { // 大量耗时操作解析文件头解码像素 load_from_disk(path); } void Draw() const { /* 绘制 */ } private: void load_from_disk(const std::string path); };客户端如果直接用Image每构造一个实例都会触发一次磁盘读取。改成虚拟代理class ImageProxy { public: explicit ImageProxy(std::string path) : path_(std::move(path)) {} void Draw() { ensure_loaded(); real_image_-Draw(); } private: void ensure_loaded() { if (!real_image_) { real_image_ std::make_uniqueImage(path_); } } std::string path_; std::unique_ptrImage real_image_; };这里一个重要细节是ensure_loaded必须处理真实对象构造失败的情况。比如文件路径不存在Image构造抛异常此时real_image_不能处于“半初始化”的状态。用std::make_unique的好处是如果构造抛异常不会留下悬空指针智能指针本身会正确释放。但如果多次调用Draw第一次失败后第二次仍会重新尝试加载这到底好不好看具体业务。如果文件几乎不会变化失败后重复尝试没问题如果失败应该快速失败可以增加一个failed_标记避免反复做昂贵的加载。3.3 std::shared_ptr本身也是一种代理说一个很多人没细想过的点std::shared_ptr其实就是一个引用计数型代理。它包装了裸指针替用户管理生命周期用户通过-调用真实对象的方法。它做的事情恰恰是代理模式的经典动作在调用前增加引用计数在析构时减少引用计数归零时释放对象。理解这一点对设计高级代理很有帮助。你可以在shared_ptr的自定义删除器里加入“清理连接池”之类的逻辑本质上就是给代理层挂接了额外的析构行为。同理std::unique_ptr是一个所有权代理它不允许多个实例共享同一个真实对象这其实是在编译期限制代理的使用方式很多C新手会抱怨unique_ptr不能拷贝但换个角度想这正是代理模式在C里的一种编译期约束帮你在编译阶段就排除掉不合理的设计。3.4 虚拟代理与智能指针组合时的坑把虚拟代理和shared_ptr结合起来最容易踩的坑是循环引用。比如你的ImageProxy内部持有shared_ptr而Image又需要回调到代理层上报进度于是也持有shared_ptr 这样就形成了循环引用真实对象永远不会被释放。解决办法是把其中一个改为weak_ptr或者让回调接口直接绑定到this的弱引用。另一个坑是延迟加载与多线程。如果代理对象可能被多个线程同时画图ensure_loaded里就要加锁。不加锁的话两个线程同时发现real_image_为空同时创建对象轻则白做一遍重复加载重则发生数据竞争。一种稳妥做法是void ensure_loaded() { std::call_once(init_flag_, [] { real_image_ std::make_uniqueImage(path_); }); }这样就把初始化变成了线程安全的一次性操作后续Draw直接转发锁开销几乎为零。如果说要优化性能这算是一个很实用的习惯。4. 保护代理与缓存代理控制访问的两种实战技巧4.1 保护代理把权限校验集中到一个入口保护代理的价值在于把权限校验从业务代码中抽离。常见的错误写法是每个Service方法里都写一遍“if (!user.HasPermission(...)) throw ...”最后权限逻辑散落各处改一个权限要求要找遍全项目。保护代理的做法是让代理在调用真实对象之前统一做权限判断。实现上C没有Java的反射没法自动给方法加注解但可以用模板包装函数指针或std::function来做统一拦截。一个比较干净的写法是template typename Func, typename... Args auto CheckPermissionAndCall(const UserContext ctx, Permission required, Func func, Args... args) { if (!ctx.HasPermission(required)) { throw PermissionDeniedException(permission denied: to_string(required)); } return std::forwardFunc(func)(std::forwardArgs(args)...); }然后代理类的每个方法都调用这个模板函数。注意这里的std::forward非常重要它保证了参数以完美转发的方式传递给真实对象不会产生多余的拷贝也不会丢失右值语义。如果漏掉std::forward传入的临时对象就可能被拷贝一份对于只能移动的对象直接编译不过。保护代理里经常被忽略的一点是权限判断需要数据支持比如订单的归属人权限不但要看角色还要看资源属于谁。所以保护代理不能只检查一个静态的角色枚举很多时候需要传入上下文对象先查资源归属再判断。我的做法是代理层依赖一个AuthorizationContext由调用方显式传入避免在代理内部隐式去取全局状态。全局状态会让单元测试变得很痛苦显式传入则干净得多。4.2 缓存代理用空间换时间的典型实现缓存代理是另一个实用场景。比如一个天气预报服务真实服务每次调用都要访问第三方接口耗时几百毫秒而很多请求查的是同一个城市。直接在真实服务里做缓存当然可以但会把缓存逻辑和第三方通信逻辑混在一起。缓存代理的做法是代理层持有一个缓存容器收到请求先查缓存命中就直接返回没命中才调用真实服务并把结果写入缓存。一个简化的缓存代理代码如下class WeatherServiceProxy : public WeatherService { public: Weather getWeather(std::string_view city) override { std::shared_lock lock(mutex_); auto it cache_.find(city); if (it ! cache_.end() !it-second.expired()) { return it-second.value; } lock.unlock(); auto weather real_service_.getWeather(city); std::unique_lock write_lock(mutex_); cache_[std::string(city)] CacheEntry{weather, steady_clock::now()}; return weather; } };这里用了shared_mutex做读写锁因为缓存命中是高频读操作多个线程同时读没问题只有写入时才需要独占锁。很多新手在这里直接用mutex结果高并发下缓存代理反而成了性能瓶颈。当然如果你的场景是单线程用std::unordered_map加std::map也完全够用不用上来就上共享锁。缓存代理最麻烦的不是缓存本身而是失效策略。如果数据变更频率很高缓存价值就低了如果数据很长时间不变缓存可以做得很激进。常见做法是给缓存项加上过期时间戳类似代码里的expired()判断。另一种更强的一致性方案是主动失效也就是在更新接口里同时更新或删除缓存。我推荐把主动失效和过期时间结合起来用前者保证一致性后者兜底防止缓存永久占用内存。4.3 缓存代理的常见陷阱缓存代理最容易出的问题是缓存穿透。当请求的key在真实服务里不存在时代理层会把空结果也缓存下来吗如果每次都不缓存空结果恶意请求或重复查询就会一直打到真实服务缓存形同虚设。我的做法是即使是空结果也缓存一小段时间比如10秒避免热点key被打穿。另一个问题是缓存对象被外部修改。如果你把对象实例从缓存里返回给调用方调用方又修改了对象那缓存里的数据也跟着变了下次其他调用方拿到的就是被污染的数据。解决办法要么是返回深拷贝要么是只读接口让调用方拿到的永远是一个不可变视图。在C里我比较倾向返回const引用或者const std::shared_ptr 明确表达出只读语义。5. 编译期代理C模板带来的独特优势5.1 为什么说C可以在编译期完成代理注入前面几类代理都是运行时多态通过虚函数表在运行期完成转发。C还有一条独特的路利用模板在编译期生成代理代码。这种代理没有虚函数调用开销类型检查在编译期完成编译器可以把代理逻辑内联进调用点性能上可以做到零成本抽象。编译期代理的典型实现手法是CRTP即Curiously Recurring Template Pattern。通过让派生类继承一个以自己为模板参数的基类基类可以调用派生类的成员实现类似代理的拦截效果。5.2 CRTP实现静态日志代理假设要给多个业务类统一添加日志和耗时统计但又不想为每个类手写代理类。用CRTP可以写一个日志代理基类template typename Derived class LoggingProxy { public: template typename... Args void LogAndCall(const char* method, Args... args) { auto start steady_clock::now(); try { static_castDerived*(this)-RealImpl(method, std::forwardArgs(args)...); } catch (...) { log_error(method, current_exception()); throw; } log_duration(method, steady_clock::now() - start); } };派生类继承它并将业务方法名和实际调用的实现绑在一起。这里没有虚函数编译器可以完全内联整个调用链。当然实际工程里更常见的是把代理逻辑抽成一个函数模板在调用真实服务时统一包一层。从代码维护角度CRTP的缺点也很明显模板报错信息极难读而且链路一长推导出来的类型名能长到让人怀疑人生。我自己的建议是对于性能敏感的底层组件比如高频的序列化、解码器值得用编译期代理对于业务层接口可读性和可调试性更重要优先用运行时代理。5.3 concept约束让编译期代理更可控C20引入了concept之后编译期代理的写法舒服多了。你可以约束Derived必须实现CertainMethod否则编译直接失败而不是给出一堆模板错乱信息。template typename T concept HasGetUser requires(T t, int id) { { t.GetUser(id) } - std::convertible_toUser; };然后代理基类可以这样写template HasGetUser T class CachedProxy { public: User GetUser(int id) { ... } private: T impl_; };这等于把“代理能够包装的对象必须支持哪些接口”写进了类型系统。相比传统的虚函数接口它更灵活不需要所有真实类都继承同一个基类只要满足concept约束即可。这个特性在写泛型库的时候特别值钱但你得先接受C20编译环境。如果你还在用C17CRTP加enable_if也能达到类似效果只是代码丑不少。编译期代理的性能确实好但我必须泼一盆冷水在绝大多数业务系统里虚函数调用的开销本身就低到可以忽略不计真正的性能瓶颈在数据库、网络、序列化而不是一次虚函数调用。所以编译期代理最大的优势不是性能而是类型约束和编译期静态检查带来的可靠性。这也是我在新项目里越来越喜欢它的原因。6. 常见问题与排查技巧实录6.1 代理对象生命周期管理代理模式在C里的头号杀手就是生命周期。代理本身可能很长命但真实对象可能已经被提前释放了导致调用代理方法时崩溃。最常见的场景是代理内部保存了一个裸指针或引用指向外部对象外部对象提前析构代理还在继续使用。我的经验是遵循一个原则代理和真实对象之间要么明确是所有权关系要么明确不是。如果是所有权关系就用unique_ptr或shared_ptr管理代理析构时释放真实对象绝不暴露裸指针给外部调用方。如果不是所有权关系真实对象的生命周期必须由外部明确保证长于代理而且尽量用weak_ptr或观察者模式让代理感知对象失效。最忌讳的是构造一个代理时传入一个裸指针心里想着“反正外部会保证它活着”结果某天外部代码重构后保证就不成立了。如果在调试时遇到“use-after-free”或崩溃先从代理持有的指针类型入手是裸指针还是智能指针是强引用还是弱引用这能解决大部分问题。6.2 代理层的性能开销与调优代理模式不可能完全没有代价。运行时代理多一次虚函数调用网络代理多一次序列化和反序列化缓存代理多一次锁操作和哈希查找。单看每一项开销都不大但如果调用链叠了很多层代理比如日志代理包着缓存代理缓存代理再包着远端代理性能损耗就会累积。我测试过一个三层代理链虚函数调用加锁加日志每个调用的额外开销大约在600纳秒左右。对普通查询来说可以接受但对一个每秒调用几十万次的底层接口来说就太贵了。调优方向有三个第一减少代理层数把多个横切逻辑合并到一个代理而不是每个逻辑一个代理第二把高频路径的锁替换成无锁结构或thread_local缓存第三对编译期就能确定的代理逻辑用模板inline掉。还有一个容易被忽略的点代理层的拷贝开销。如果代理返回一个大的结构体而结构体内又有动态分配的内存每次返回都是拷贝成本远高于虚函数调用。设计接口时尽量返回std::shared_ptr、移动语义的对象或者直接输出到调用方提供的引用里能显著降低代理层的隐性开销。6.3 调试代理层时的实用技巧代理层是横切逻辑聚集地也是最难排查问题的地方因为问题很可能不在业务逻辑而在代理逻辑封装的边界条件里。我调试代理层时有一个习惯在代理里加一个可开关的跟踪日志并且用特定的前缀方便grep。比如所有代理类的日志统一用[Proxy]开头这样线上排查时grep一次就能捞出所有代理调用记录。代理层里最费时间的问题之一是“请求参数被篡改”。缓存代理可能因为返回了同一个可变对象而导致后续调用数据错乱保护代理可能因为权限判断的上下文使用不当导致越权或误杀。遇到这类问题先把代理层的输入输出完整打出来对比调用了哪些方法、传了什么参数、返回了什么结果基本能定位是代理自身的问题还是真实对象的问题。另外给代理层写单元测试时尽量传入一个mock真实对象专门验证代理逻辑本身。比如测试缓存代理时用一个计数真实对象确保第二次调用没有再走真实对象测试保护代理时传入没有权限的上下文确认抛出异常。这样能提前把代理层的边界条件、缓存策略、权限判断全部固定下来重构时也更有底气。最后再分享一个我自己的习惯每次在项目里引入代理模式之前我先问自己一个问题是单个方法需要横切逻辑还是整个类的所有方法都需要如果只是个别方法我倾向于用函数包装器或者装饰器函数根本不动类结构如果是整个类都需要再考虑代理类。代理模式是个好工具但它不是万能的用错了场景反而会让代码变复杂。C里的代理选择太多远程就认真设计传输层延迟加载就处理好线程安全保护权限就把上下文设计清楚缓存就考虑好失效策略。把这些细节一点点打磨到位代理模式才能真正发挥出它的高级价值。

相关新闻