ARTICLE DETAIL

资讯详情

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

C++代理模式实战:从编译期模板到智能指针的生命周期管理

C++代理模式实战:从编译期模板到智能指针的生命周期管理 1. 为什么在C里写代理模式和别的语言完全不是一回事先抛个问题你在Java里写代理模式动态代理一上反射一调接口一拦活就干完了。但在C里没有内置反射没有统一的接口体系甚至“接口”这个概念都只是约定俗成。那代理模式在C里还能玩出什么花答案是能玩出的花样比Java多得多。因为C的代理模式天然和RAII、模板、移动语义、智能指针、甚至内存布局绑定在一起。换句话说Java的代理是“运行时的影子”而C的代理可以是“编译期的替身”“栈上的看门人”或“堆上的真正管理者”。我自己最早接触代理模式是在写一个缓存中间件的时候。当时需要一个远程服务对象的本地替身网络通了就真实转发网络断了就返回缓存。这个需求看起来很简单但如果用传统的“写一个接口接口后面放一个真实类”的思路代码会膨胀得非常快尤其是当你要代理的类有十几个方法的时候。后面我去读了Modern C Design里关于策略混入的写法又翻了不少开源库里对Proxy的处理方式才算把C版本的代理模式理清楚。这篇文章不会给你画类图也不会讲教科书上那种“Subject-RealSubject-Proxy”三段式。我会直接讲C里实现代理模式的几种靠谱姿势、每种姿势解决什么实际问题、参数怎么设计、在哪里容易踩坑。全程用我实际写过的代码说话。顺便说一句网上的“C代理模式”教程绝大多数是把Java例子翻译成C语法然后拿个virtual函数演示一下就完事了。你照着写能跑但一旦放到真实项目里你会发现 copied 那套东西根本撑不住工程级的需求。这篇文章就是要补上这块空白。2. 从零搭一个可复用的代理框架三种核心实现路线2.1 编译期代理用模板把“替身”写进类型系统C的代理模式和Java最大的区别在于C可以在编译期决定代理关系而不需要运行时反射。这意味着你在写代码的时候编译器就能帮你检查方法签名是否匹配、返回值类型是否一致。最常见的一种做法是CRTPCuriously Recurring Template Pattern。它的思路是让代理类继承自一个模板基类模板参数就是被代理的真实对象类型。代理类只需要重写operator-就能把所有的成员访问转发给真实对象。template typename T class LoggingProxy { public: explicit LoggingProxy(T* target) : target_(target) {} T* operator-() { std::cout [Proxy] Calling method at std::chrono::system_clock::now() std::endl; return target_; } private: T* target_; };很多教程到这里就结束了但实际工程里有个绕不开的问题operator-的转发粒度太粗了。你只能在“调用任何一个成员函数”之前插入日志却没法区分“到底调的是哪个函数”更没法对不同的函数做不同的代理策略。要解决这个问题得换个思路代理对象本身不透明代理对象自己实现了和真实对象一模一样的接口。但在C里“一模一样的接口”意味着你得写一堆转发函数很蠢也很容易漏。更工程化的做法是结合C17的if constexpr和decltype做接口探测在编译期判断真实类是否支持某个操作然后选择走代理还是报错。比如template typename T, typename void struct has_foo : std::false_type {}; template typename T struct has_fooT, std::void_tdecltype(std::declvalT().foo()) : std::true_type {}; template typename T class SelectiveProxy { public: void foo() { if constexpr (has_fooT::value) { log_before(); target_-foo(); log_after(); } else { static_assert(has_fooT::value, Target does not have foo()!); } } private: T* target_; };这种写法的好处是对真实类的依赖从“硬编码的方法列表”变成了“编译期的能力探测”。真实类加一个方法代理类不需要改真实类删了一个方法代理类的if constexpr会自动落到static_assert分支编译直接失败把错误暴露在编译期而不是运行期。2.2 运行期代理通过虚接口管理多态替身编译期代理适合“代理关系在编译时就明确”的场景。但如果你想要的是“这个代理对象是运行时动态创建出来的”比如网络重连之后代理要换一个真实的连接对象那CRTP就不好使了。这种情况下最传统的做法是提取抽象接口。你定义一个纯虚基类真实类和代理类都继承它调用方持有基类指针代理类内部持有真实类指针。class IRemoteService { public: virtual ~IRemoteService() default; virtual std::string fetchData(const std::string key) 0; }; class RemoteServiceImpl : public IRemoteService { public: std::string fetchData(const std::string key) override { // 真正走网络请求 return real-data-for- key; } }; class RemoteServiceProxy : public IRemoteService { public: explicit RemoteServiceProxy(std::shared_ptrIRemoteService real) : real_(std::move(real)) {} std::string fetchData(const std::string key) override { if (cache_.count(key)) { return cache_[key]; } auto value real_-fetchData(key); cache_[key] value; return value; } private: std::shared_ptrIRemoteService real_; std::mapstd::string, std::string cache_; };这段代码看着简单但里面藏着一个特别关键的设计决策代理类持有的是shared_ptrIRemoteService而不是裸指针。原因有两个。第一代理的生命周期可能比调用方长如果调用方先析构了代理还在缓存数据或者等待网络返回裸指针就悬空了。第二真实对象可能被多个代理共享比如一个代理做缓存另一个代理做计费如果两个代理共享同一个真实对象引用计数能保证真实对象在所有代理都销毁之后才被释放。用shared_ptr还有一个隐藏的好处代理可以随时换掉内部的真实对象。比如写一个setRealService方法断线重连之后把新的RemoteServiceImpl塞进去调用方完全无感知。这是虚接口方案比编译期方案灵活的地方。2.3 智能指针式代理走operator-但不止于operator-第三种做法介于前两者之间不定义虚接口但用智能指针的语义来包装真实对象。这种代理尤其适合“我需要管理真实对象的生命周期同时还要拦截每次访问”的场景。我见过最典型的一个场景是一个只读的全局配置对象多线程都要读但不允许写。你想只读访问但又想记录到底是哪个模块在频繁读它。如果直接暴露真实对象没人能拦截如果复制一份配置可能更新数据就不一致了。于是我用了一个代理template typename T class ReadOnlyProxy { public: explicit ReadOnlyProxy(std::shared_ptrT target, std::string owner) : target_(std::move(target)), owner_(std::move(owner)) {} const T* operator-() const { std::cout [ReadOnlyProxy] Access by owner_ std::endl; return target_.get(); } const T operator*() const { std::cout [ReadOnlyProxy] Dereference by owner_ std::endl; return *target_; } private: std::shared_ptrT target_; std::string owner_; };注意这里的细节operator-返回的是const T*operator*返回的是const T。这意味着通过代理只能读不能写。任何试图通过代理修改配置内容的代码在编译期就会被拒绝——不需要额外加锁也不需要运行时检查。这种“用类型系统做权限控制”的思路是C代理模式最迷人的地方之一。我自己在实际项目里反复验证过这种写法加日志、加统计、加读写权限控制都特别方便唯一的限制是你得接受“代理对象不是真实对象”这个事实。也就是说如果有一段代码用了重载void process(const MyConfig config)这样的函数它无法直接接收一个ReadOnlyProxyMyConfig除非你写一个隐式转换。这是一个需要权衡的点接下来我会专门讲。3. 生命周期管理这是90%的C代理项目翻车的地方3.1 引用计数与弱引用的博弈代理模式一旦和智能指针绑定“谁持有谁”就成了核心问题。最常见的死锁式设计是代理持有真实对象的shared_ptr真实对象在某个回调里又持有代理的shared_ptr于是循环引用谁也不释放。这个问题在Java里其实也存在但GC会兜底。在C里没有GC你必须自己打破环。最直接的方法是真实对象不持有代理真实对象只持有真实对象应该依赖的东西代理如果需要把自己传给真实对象用weak_ptr。但weak_ptr不是银弹。代理的核心职责是“拦截”如果真实对象内部调用了某个回调回调里又通过代理来发起新一轮请求用weak_ptr拿到代理后你必须马上lock()然后快速用完、释放。如果这个lock()发生在热点路径上性能损耗是实打实的。我实际踩过一个坑一个代理对象内部保存了真实服务的shared_ptr真实服务的一个回调函数又反向调用了代理的另一个方法。结果就是每次请求都走了很长一段lock()、引用计数增减的代码压测的时候性能比裸调用慢了40%还多。排查了半天最后发现罪魁祸首就是每一次回调都做了一次weak_ptr::lock()。解决方案不复杂把回调按生命周期分两类。一类是“请求生命周期内”的回调这类回调在执行期间代理必然存活直接传裸指针或引用不涉及引用计数操作另一类是“代理可能已销毁”的回调这种才需要用weak_ptr。核心原则是不要在热点路径上做无谓的引用计数增减。3.2 悬空与失效代理出现后真实对象的生命周期怎么规划引入代理之后原来清晰的“new了谁delete谁”的生命周期规划会变得混乱。我这里列几种常见的失效场景和处理方式场景一代理对象比真实对象活得长如果你构造了一个代理然后真实对象在别的地方被提前释放了代理就悬空了。这种情况最容易发生在全局缓存里代理被缓存真实对象却被业务代码随手删了。处理方式强制统一用shared_ptr管理真实对象禁止任何人持有裸指针代理内部也只存shared_ptr。你可以把这条规则写进代码评审清单或者用一个自定义的using RealPtr std::shared_ptrReal类型别名来约束。场景二真实对象比代理活得长这个场景相对安全因为代理析构时只需要减少一个引用计数。但这里有个隐患真实对象的析构是在所有代理都销毁之后才发生的。如果你的代理析构函数里做了某些事情比如回滚一个事务而真实对象的析构又把同一个资源释放了就会double-free。处理方式明确代理是“观察者”还是“持有者”。观察者代理不应该在析构时操作真实对象的资源持有者代理应该在构造时就声明自己负责真实对象的生命周期。两者混用是灾难。场景三代理链代理套代理三层以上就很危险了。比如外层是日志代理内层是缓存代理最内层是真实对象。一旦你想释放中间层引用计数管理就变得极其繁琐。我的建议是代理链不要超过两层如果超过两层优先考虑使用装饰器模式替代或者把多个职责合并到一个代理类里。提示判断一个代理设计是否合理最简单的问题就是——在代理析构的那一刻真实对象是否已经不再被任何东西引用如果在某些分支下答案是“不确定”那就说明生命周期设计还不合格。3.3 移动语义带来的新坑代理能不能被移动能不能被拷贝C的代理和Java代理还有一个关键区别C对象有移动语义和拷贝语义。Java对象只有一个引用拷贝引用和拷贝对象没有本质区别。但在C里一个代理被拷贝了代理内部持有的真实对象是被共享还是被复制如果共享多个代理副本之间会不会相互影响我建议除非你有非常明确的需求否则代理类应该定义为只移动、不可拷贝。理由很简单代理往往持有的是外部资源网络连接、文件句柄、缓存这些资源天然不可复制。让代理可拷贝等于默认了“代理间的状态可以互相干扰”这跟代理的透明性设计是冲突的。class NonCopyableProxy { public: NonCopyableProxy() default; ~NonCopyableProxy() default; NonCopyableProxy(const NonCopyableProxy) delete; NonCopyableProxy operator(const NonCopyableProxy) delete; NonCopyableProxy(NonCopyableProxy) noexcept default; NonCopyableProxy operator(NonCopyableProxy) noexcept default; // 其他成员... };那什么时候允许拷贝我个人经验是当代理内部不持有可变状态只持有“真实对象的指针 一个只读的配置”时允许拷贝是安全的。比如格式转换代理、日志代理它们只是透传没有自己的状态。一旦代理内部有缓存、有计数器、有连接池就一律禁拷贝。4. 实战写一个带缓存、日志和异步转同步的远程服务代理4.1 需求拆解与整体设计很多教程讲代理模式就止步于“加一层日志”但真实项目里的代理往往是多个横切关注点的集合。我在这里用一个完整案例来串联前面所有的知识点。假设你有一个远程配置服务通过HTTP接口获取配置网络不稳定调用可能超时而且你不想让每次业务请求都等待网络返回。需求如下配置数据要缓存到本地缓存有过期时间。每次真实网络请求都要记录耗时和返回状态。调用方不想处理异步逻辑代理把异步转同步。代理必须能在运行时切换真实服务的地址服务发现。代理要统计每个方法被调用的次数方便做热力分析。我把代理类定义成这样class ConfigServiceProxy : public IConfigService { public: ConfigServiceProxy(ServiceDiscovery discovery, CachePolicy policy); ConfigValue getConfig(const std::string key) override; void refreshAll() override; private: std::shared_ptrIRemoteConfigService resolveService(); ConfigValue loadFromCache(const std::string key); void writeToCache(const std::string key, ConfigValue value, Duration ttl); void statsAdd(const std::string method, Duration cost, bool success); std::shared_ptrServiceRegistry registry_; std::shared_ptrCacheStore cache_; std::shared_ptrStatsCollector stats_; std::mutex mutex_; };代理持有的是三个基础组件服务注册表负责任何时候返回一个可用的真实服务、缓存存储、统计收集器。核心业务逻辑只有两三行真正的复杂度都在三个组件的组合上。4.2 关键实现超时控制、缓存穿透与并发保护网络代理最容易出现的两个坑是缓存穿透和并发击穿。所谓缓存穿透是指请求了一个不存在的key每次都穿透缓存直达网络并发击穿是指某个key的缓存刚好过期瞬间有几百个请求同时打到后端。最简单的解决办法是加锁。但加锁也有讲究锁的粒度是“整个代理”还是“单个key”如果整个代理一把锁所有请求都会串行化性能大打折扣。所以在实际项目中我用的是细粒度锁每个key一把锁。这里有个变通方案不实现真正的每key锁而是用一个小的锁池比如256把锁根据key哈希取余映射。ConfigValue ConfigServiceProxy::getConfig(const std::string key) { // 1. 先查缓存命中的话直接返回 if (auto cached loadFromCache(key)) { statsAdd(getConfig, 0ms, true); return *cached; } // 2. 没命中取锁双重检查 auto lock lockPool_.getLock(key); std::lock_guardstd::mutex guard(lock); // 3. 锁内二次查缓存防止并发击穿 if (auto cached loadFromCache(key)) { return *cached; } // 4. 真正发起网络请求 auto service resolveService(); auto start std::chrono::steady_clock::now(); FutureConfigValue future service-asyncGetConfig(key); auto status future.wait_for(std::chrono::milliseconds(500)); if (status FutureStatus::Ready) { auto value future.get(); writeToCache(key, value, cachePolicy_.ttl); statsAdd(getConfig, duration(start), true); return value; } statsAdd(getConfig, duration(start), false); throw ConfigServiceTimeout(key); }这里有几个细节值得注意缓存命中时依然调用statsAdd但耗时记为0这样做的好处是统计口径一致你后续可以精确算出“缓存命中率”和“平均网络耗时”。超时后不写缓存也不做降级处理直接抛异常。避免把“失败”当成“可以缓存的结果”存起来。锁池比“每key一把mutex”内存效率高而且避免了锁对象生命周期管理的麻烦。超时时间的参数设计也讲究。500ms是我实际项目里的默认值但这个值必须可配置且最好写上“根据网络环境动态调整”的逻辑。我见过一个项目把超时定死为3秒结果上游服务本身要5秒才能算完每次调用都必然超时这个问题在代码审查时完全没暴露出来直到压测才暴露。4.3 代理与真实服务的对接接口设计上的取舍在这个案例里ConfigServiceProxy和真实服务之间用的是虚接口。这意味着真实服务必须继承IRemoteConfigService而且代理能代理的方法必须提前在接口里面声明好。这种设计的优点是简单、清晰、类型安全。缺点是接口一旦定义就不能轻易加新方法否则所有实现类都要改。如果真实服务是第三方SDK你没有权限改它的类怎么办那就不能让它继承你的接口了。这种时候我会用一个适配器层class SdkConfigServiceAdapter : public IRemoteConfigService { public: explicit SdkConfigServiceAdapter(std::shared_ptrThirdPartyConfigSdk sdk) : sdk_(std::move(sdk)) {} FutureConfigValue asyncGetConfig(const std::string key) override { return sdk_-fetchConfigAsync(key); // 把第三方SDK的异步接口适配成统一接口 } private: std::shared_ptrThirdPartyConfigSdk sdk_; };这个适配器不是代理但它和代理配合天衣无缝。代理不认识第三方SDK代理只认识IRemoteConfigService适配器把第三方SDK包装成IRemoteConfigService。这样代理层和SDK层完全解耦SDK升级、替换、甚至从HTTP切到gRPC代理代码一个字都不用改。4.4 性能验证代理本身的成本到底有多少很多人对“代理模式”有疑虑觉得多一层就慢一层。这是对的但代理的损耗完全取决于实现方式。我这里给一组我自己项目里测过的数字在缓存命中路径上代理的开销大概是一次std::string的哈希计算拿锁一次mutex::lock和unlock一次map查找一次std::shared_ptr的拷贝返回值这整套操作在Release模式下大约是100~200纳秒。如果走网络路径那开销占比就无所谓了因为网络延迟动辄毫秒级。所以结论很明确代理的开销在缓存命中路径上是微乎其微的瓶颈永远在真实服务的执行和网络IO上。但这里有一个性能陷阱如果代理的每个方法都使用std::shared_ptr传递返回值在高并发场景下引用计数的原子操作会让性能急剧下降。比如你每秒处理100万次配置缓存读取每次读都拷贝一次shared_ptr那么就是200万次原子增减操作。在多核机器上这会触发大量的cache line竞争性能会断崖式下跌。解决方案是对于只读数据返回const std::string而不是shared_ptr。虽然这会带来生命周期管理上的约束但在性能敏感路径上值得。我自己在这个问题上做过一个折中代理提供两种读取接口一种是安全但慢的shared_ptr版本一种是unsafe但快的引用版本调用方根据自己的场景选择。5. Debug代理链路定位“到底是谁调慢了我的服务”5.1 代理栈的打印与调用链追踪代理模式加上之后线上排查问题变得很头疼。原因很简单你看到的对象深度多了几层本来直接看日志就能定位的问题现在要一层层剥开。我在项目里给代理增加了一个Lazy Debug Mode默认关闭需要排查时通过配置动态开启。开启之后代理会在每次调用时记录完整的调用栈——包括当前函数名、真实对象的方法名、代理层的方法名。这样线上一旦出现耗时异常我直接在日志里搜索ProxyStack就能看到整条代理链路在哪里花了多少时间。class DebugInfo { public: std::string proxy_layer; std::string real_method; std::string caller_info; std::chrono::microseconds cost; };这个模式在正常生产环境千万别开因为打印调用栈本身非常贵获取函数名和行号可能需要符号解析慢的离谱。但一旦怀疑“代理是不是引入了额外开销”这个功能就是救命稻草。5.2 代理状态观测给代理加一个“体检接口”另一个实用技巧是给代理类加一个inspect()方法返回代理内部的状态。这个方法不走真实服务也不走缓存直接返回当前代理的统计数据。我在项目中会用这个办法当前持有的真实服务地址便于确认服务发现是否生效缓存条目数和预估内存占用最近5分钟的平均网络耗时最近5分钟的缓存命中率当前正在等待网络响应的请求数有了这些数据你可以不用上监控系统直接在测试环境或临时调试环境中快速判断代理是否在工作。我强烈建议所有代理类都预留一个这样的观测接口别等到线上出了问题再临时加。5.3 真实遇到过的坑代理导致了死锁说一个我记忆犹新的线上事故。当时我在一个支付服务里用了代理模式代理内部持有一个std::mutex用于保护缓存。真实服务在某个回调里又会反向调用代理的另一个方法而那个方法也需要同样的锁。结果就是外层持锁等人里层等锁典型的自死锁。排查了半天最后用std::recursive_mutex换掉了普通std::mutex才解决。但这个“解决”其实并不优雅因为recursive_mutex是把错误掩盖了如果将来代码路径变化可能又会死锁。更合理的方案是代理设计一个明确的“无锁路径”。也就是说真实服务的回调如果又要走代理它应该走一个不涉及加锁的快速路径比如直接操作底层缓存而不经过代理。这样虽然多了一段重复代码但至少不会把整个服务卡死。注意代理类里如果要持锁永远先明确锁的层级。如果代理A和代理B之间有循环调用持锁顺序必须一致否则死锁是迟早的事。6. 用代理模式做横切关注点日志、权限、统计的高级玩法6.1 方法级权限控制只读代理和写保护代理前面提到的ReadOnlyProxy只是一个最简单的例子。在真实的业务系统里权限控制往往是方法级的而不是类级的。比如普通用户只能调用getConfig管理员才能调用updateConfig。用代理模式做这件事可以根本不用改真实类的代码。你只需要在代理里对每个方法做不同的权限验证template typename T class PermissionProxy : public T { public: bool requireRole(const std::string method, const std::string role) { // 根据方法名和角色判断是否放行 } void updateConfig(const std::string key, ConfigValue value) override { if (!requireRole(updateConfig, admin)) { throw PermissionDenied(updateConfig requires admin role); } T::updateConfig(key, value); } };这种方式的优点是不侵入真实类权限逻辑完全收敛在代理层。缺点是如果真实类没有virtual方法继承这条路就走不通了。C里没有virtual就没有多态这是硬限制。所以权限代理通常只用于有虚接口的场景或者用模板特化接口探测来模拟。6.2 延迟加载代理把昂贵对象的构建推迟到真正使用时在很多C项目里有些对象很重构造很贵但不一定每次都用。典型的例子数据库连接池、日志文件句柄、AI模型的加载。你可以在程序启动时就初始化它们也可以等到第一次真正使用时再初始化。后一种方式就是延迟加载代理。延迟加载代理的关键是线程安全。第一次调用时多个线程可能同时触发初始化这里必须保证只有一个线程做实际初始化其他线程等待。class LazyDatabaseProxy : public IDatabase { public: void query(const std::string sql) override { ensureInitialized(); real_-query(sql); } private: void ensureInitialized() { std::call_once(init_flag_, [this]() { real_ std::make_sharedRealDatabase(load_connection_config()); }); } std::shared_ptrRealDatabase real_; std::once_flag init_flag_; };std::call_once在这里是首选它比用mutexbool更可靠编译器会保证只执行一次。这虽然是一个很小的细节但很多初学者会忽略导致初始化逻辑在并发环境下被重复执行。延迟加载代理的另一个实用技巧是初始化失败时允许重试。std::call_once一旦执行完成后续不会重试所以如果你希望初始化失败后能重试就不能用std::call_once而要用mutexstd::atomicbool。这又是一个看似简单但暗藏玄机的点。6.3 虚拟代理与远程代理同一套代码多个部署场景代理模式在分布式系统里最常见的两个变体是虚拟代理和远程代理。虚拟代理指的是“本地还没有真实对象先放一个替身”远程代理指的是“真实对象在另一台机器上代理负责序列化和网络传输”。C里写远程代理有一个问题是Java里不太会遇到的序列化。Java原生的序列化机制自带类型信息C里你得自己搞定结构体的二进制布局、字节序、甚至版本兼容。我个人工作中遇到过的做法是用protobuf定义消息格式代理层负责把方法调用翻译成protobuf消息再把响应解析回来。这样代理层不关心底层是TCP还是共享内存还是UDP只要有一个Transport接口就行了。class RemoteServiceProxy : public IRemoteService { public: explicit RemoteServiceProxy(std::shared_ptrTransport transport) : transport_(std::move(transport)) {} std::string fetchData(const std::string key) override { RequestMsg req; req.set_key(key); ResponseMsg resp transport_-call(fetchData, req); return resp.value(); } private: std::shared_ptrTransport transport_; };这个代理的Transport可以是本地socket可以是UNIX域套接字甚至可以是共享内存。代理不关心调用方也不关心。这带来的好处是部署极其灵活进程内fake transport可以作为单元测试的桩TCP transport用于跨机器部署共享内存transport用于同机多进程通信。测试和生产用同一套接口代理模式的价值在这里体现得淋漓尽致。7. 从代理到切面AOP风格的横切逻辑组织7.1 多个代理的叠加与冲突当你同时需要日志、缓存、权限、统计时是写一个巨无霸代理类还是叠多个小代理我的经验是能叠就叠但必须控制叠加顺序。每个代理负责一个职责叠加的方式有两种一种是用组合一个代理内持有另一个代理另一种是用模板在编译期把多个代理的策略混入同一个类。组合方式的优点是运行时可以动态改变顺序缺点是类型类型信息会丢失调试起来比较麻烦模板方式相反编译期确定类型信息保留完整但运行时灵活性差。如果业务逻辑里横切点不是很多我倾向于用模板方式。下面是典型的模板叠加写法template template typename typename... Policies class CompositeProxy; template template typename typename FirstPolicy, template typename typename... RestPolicies class CompositeProxyFirstPolicy, RestPolicies... { // ... };但这种写法有个天然的问题策略之间可能有依赖。缓存策略希望日志策略在它外层这样日志才能记录“缓存是否命中”权限策略又希望在日志外层记录“谁尝试了什么”。一旦顺序错了日志输出的信息就不完整。解决这个问题的方法是给每个策略定义优先级组合时按优先级排序。但这也是个双刃剑优先级混乱会让代码变得难以理解。在团队协作中我通常不建议过度模板化除非你一个人维护全部代码并且对模板元编程有绝对把握。7.2 用宏与代码生成减少重复但不牺牲可读性也许你已经发现了C的代理模式尤其是需要代理多个方法时最大的痛点就是“重复代码太多”。如果真实类有20个方法代理也要写20个方法每个方法就是转调加逻辑。这部分重复代码就算你是模板高手也没法完全消除。我的折中方案是对于“纯转发”的方法用模板加宏来减少重复。对于“需要自定义逻辑”的方法手写。具体做法是#define FORWARD_METHOD(ClassName, MethodName, ...) \ auto MethodName(__VA_ARGS__) - decltype(real_-MethodName(__VA_ARGS__)) { \ return real_-MethodName(__VA_ARGS__); \ }虽然宏在C里名声不好但在“大量转发方法”的场景下它反而是最实用的工具。注意宏的作用范围要尽量小用完就#undef避免污染后面的头文件。另一种更现代的方式是用C20的std::generator配合代码生成脚本。如果整个项目使用了IDL接口定义语言来定义接口生成代理代码就是一个很自然的选择。你只需要写一个脚本读IDL文件输出代理类的C代码然后放到build系统里自动生成。这样既保留了可读性又避免了手写重复。7.3 选择策略什么时候用代理什么时候用装饰器什么时候用策略模式代理模式和装饰器模式、策略模式经常被混用很多人分不清区别。一句话总结代理模式控制“能不能访问”“何时访问”“以什么方式访问”真实对象。它通常和真实对象用的是同一个接口调方根本不知道自己在跟代理打交道。装饰器模式增强真实对象的功能重点在“增加行为”。装饰器可以层层嵌套每个装饰器加一项功能。策略模式把“怎么做”的算法抽出来运行时切换。C实践中代理和装饰器往往是同一套代码逻辑。区别在于你对外的解释和接口设计。如果外部调用方应该感知到“有一个控制器”那更接近装饰器如果外部调用方完全无感知只通过接口使用那更接近代理。从工程角度看我会用这条判断标准如果真实对象的实例在调用方手里是可创建的且调用方明确知道自己创建的是真实对象那用装饰器嵌套是自然的如果真实对象的创建受控只能通过代理获得那用代理模式。学过代理模式的朋友应该都知道和代理最容易混淆的就是适配器。区别在于代理和真实对象服务的是同一个接口代理是替身适配器服务的是另一个接口适配器是翻译。这一点在C里尤其容易踩坑因为C的隐式类型转换会让接口不一致的边界模糊。8. 避坑清单C代理模式九条军规我把这些年写C代理模式踩过的坑和总结出的规律整理成一份清单。每一条背后都是实际线上事故或者至少是压测环境中暴露的问题。优先用接口类型而不是具体类类型作为代理的模板参数。具体类耦合太紧测试和替换都难。代理类默认禁止拷贝只有确认无状态时才允许拷贝。有状态的代理一旦被拷贝出问题会非常隐蔽。不要在热点路径上做weak_ptr::lock()。如果你发现必须这么做先想想是不是生命周期设计有问题。代理内部持锁时明确锁的层级和持锁顺序。多代理叠加时循环持锁必死锁。缓存代理一定要有“穿透保护”和“并发击穿保护”。双层检查锁池是最低配方案。代理的返回值类型要谨慎设计。返回shared_ptr省心但有性能损耗返回引用快但有生命周期约束提前明确你的核心路径要求的是什么。代理上的调用栈追踪功能默认关闭。需要排查时再动态开启别让调试功能成为生产环境的隐形性能炸弹。给每个代理类增加一个inspect()观测接口。排查问题时你不知道多省事。代理链不要超过两层。超过两层后性能和可维护性都会断崖式下降优先考虑合并职责或换模式。最后再分享一个经验代理模式在C里不只是一个设计模式它是一种“控制反转的基层设施”。你用好了它日志、监控、权限、缓存这些横切逻辑就都有了统一的落点你用不好它就会陷入层层代理、层层排查、越改越乱的泥潭。我刚入行时觉得代理模式是最简单的设计模式一个类图就画完了。现在回头看C的代理模式恰恰是最考验程序员对内存、生命周期、并发和接口设计理解深度的模式之一。如果你能把代理模式写出“编译期权限控制”“无锁快速路径”“策略化叠加”这些层次那你对C的掌控绝不只停留在语法层面。
返回列表