十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++代理模式实战:延迟加载、权限校验与远程代理

C++代理模式实战:延迟加载、权限校验与远程代理 我入行接手的第一个C项目是一个给内部运营用的数据查询服务。Service 类的每个公开方法开头都是同样一段权限校验加审计日志复制的代码粘了快一百行。后来同事在 code review 里写了一句你直接调底层接口不就好了吗我愣了几秒意识到问题的根源不是权限校验本身而是所有横切逻辑都和业务逻辑揉成了一团。这就是代理模式在 C 里最典型的用武之地它是一个中间层站在调用方和真实对象之间把权限、日志、缓存、延迟加载、远程通信这些非业务的动作挡在业务逻辑之外。这篇文章我从真实项目出发讲代理模式在 C 里的四种实战形态以及拷贝、移动、虚析构这些语言特性在实现代理时踩过的坑。无论你是刚学设计模式还是已经被服务端、客户端代码里的重复样板折磨了一段时间都值得往下看。1. 先搞明白代理模式在C里解决的是什么问题1.1 一个让我重构三次的真实场景我之前在做一个图形编辑器的素材库面板。素材库里的图层原图很多是几十兆的 PNG大的甚至上百兆。最初版本是所有素材在面板打开时一次性加载结果就是 UI 卡死几秒同事以为程序崩溃了。第一次重构改成懒加载但懒加载逻辑偏偏写在了每一个点击事件的处理函数里每个函数都得判断这个素材加载了没没有就加载重复代码满天飞。第二次重构加了缓存结果因为多人同时打开同一个素材缓存没有控制并发同一个文件被反复解析了好几遍。第三次重构我把素材是否加载、加载后缓存到哪里、缓存要不要失效全部塞进了一个ImageProxy对象里。调用方拿到代理对象和使用真实图片对象时的接口完全一样但内部逻辑彻底隔离了代理负责在合适的时机加载真实图片负责从缓存池里取对象负责决定缓存策略。业务层从此只需要调用render()不需要关心图片到底是从磁盘读的、从缓存拿的、还是已经被加载过了。这就是代理模式的核心价值——把因为实现细节产生的复杂状态管理从调用方手里接管过来。1.2 代理模式的角色组成与C映射关系代理模式在 C 里的角色划分和教科书一致但落到语言层面有几个 C 特有的注意点。角色职责C 中的实现方式Subject 接口声明业务方法统一代理和真实对象的调用契约含纯虚函数的抽象类RealSubject 真实对象真正执行业务逻辑继承 Subject 的具体类Proxy 代理持有真实对象拦截调用并附加横切逻辑继承 Subject内部持有真实对象的指针/智能指针Client 调用方只依赖 Subject 接口不感知代理存在通过基类指针或引用使用对象C 的特殊之处在于接口类必须声明虚析构函数否则通过基类指针 delete 派生类对象就是未定义行为。此外代理类通常内部持有std::unique_ptr或std::shared_ptr指向真实对象这决定了代理类自身的拷贝、移动语义要慎重处理。Java 里随便写个代理类就行C 里还得回答一个问题代理对象放进std::vector后vector 扩容时需要拷贝或移动它你的代理支持吗这个坑我后面专门用一章讲。2. 虚拟代理实战大对象延迟加载与缓存池的设计2.1 用代理挡在昂贵的构造函数前面虚拟代理最常见的用途是延迟加载。一个对象的构造函数非常昂贵比如读取一个大文件、建立数据库连接、拉取远端配置但我们又不确定调用方什么时候真正需要这个对象的数据。最自然的做法是先创建一个廉价的代理对象占位等第一次真正调用业务方法时代理再去创建真实对象。先看接口和真实对象#include memory #include string #include vector class PixelBuffer { public: int width() const { return width_; } int height() const { return height_; } private: int width_ 0; int height_ 0; std::vectoruint8_t data_; }; class Image { public: virtual ~Image() default; virtual void render() 0; virtual int width() const 0; virtual int height() const 0; }; class HugeImage : public Image { public: explicit HugeImage(const std::string path) { // 这里才做真正的磁盘读取和解析耗时可能几百毫秒 pixels_ readPixelsFromDisk(path); } void render() override { // 真正的绘制逻辑 } int width() const override { return pixels_.width(); } int height() const override { return pixels_.height(); } private: static PixelBuffer readPixelsFromDisk(const std::string path); PixelBuffer pixels_; };然后实现代理class ImageProxy : public Image { public: explicit ImageProxy(std::string path) : path_(std::move(path)) {} void render() override { loadIfNeeded(); real_-render(); } int width() const override { loadIfNeeded(); return real_-width(); } int height() const override { loadIfNeeded(); return real_-height(); } private: void loadIfNeeded() const { if (!real_) { real_ std::make_uniqueHugeImage(path_); } } std::string path_; mutable std::unique_ptrHugeImage real_; };这里有两个细节值得展开说。第一real_声明为mutable因为在width()这种const方法里也要能延迟加载真实对象。很多新手会忘记这一点编译报错后一脸懵。第二真实对象用std::unique_ptr持有这样代理析构时自动释放真实对象不会泄漏。构造函数里只保存路径字符串代理对象的创建成本几乎为零这就是虚拟代理的核心用便宜的对象挡住昂贵的对象把创建真实对象的开销推迟到真正需要的那一刻。但上面的实现有个隐患多线程环境下如果两个线程同时第一次调用width()loadIfNeeded()里的if (!real_)判断和赋值不是原子的可能两个线程都会进入创建分支甚至产生数据竞争。解决方式是用std::call_onceclass ImageProxy : public Image { public: explicit ImageProxy(std::string path) : path_(std::move(path)) {} void render() override { loadIfNeeded(); real_-render(); } int width() const override { loadIfNeeded(); return real_-width(); } int height() const override { loadIfNeeded(); return real_-height(); } private: void loadIfNeeded() const { std::call_once(flag_, [this] { real_ std::make_uniqueHugeImage(path_); }); } std::string path_; mutable std::unique_ptrHugeImage real_; mutable std::once_flag flag_; };std::call_once保证无论多少个线程同时进入真实对象只被创建一次。这在 UI 线程和后台加载线程并发访问素材时特别重要。实测下来单线程场景里call_once的开销可以忽略但带来的线程安全收益是实打实的。2.2 缓存池代理多个代理共享同一个真实对象延迟加载解决了什么时候加载的问题但当一个文件被多个地方引用时我们希望同一份数据只加载一次多个代理共享。按上一小节的写法每个ImageProxy都会创建自己的HugeImage内存被浪费好几倍。解决思路是引入一个缓存池代理在创建真实对象前先查缓存。下面这种用weak_ptr做缓存的设计在实际项目中我用了很久#include unordered_map #include mutex #include memory class ImageCache { public: std::shared_ptrHugeImage acquire(const std::string path) { std::lock_guardstd::mutex lock(mutex_); auto it cache_.find(path); if (it ! cache_.end()) { if (auto sp it-second.lock()) { return sp; } cache_.erase(it); // 弱引用已失效清理掉 } return nullptr; } void put(const std::string path, std::shared_ptrHugeImage img) { std::lock_guardstd::mutex lock(mutex_); cache_[path] img; } private: std::mutex mutex_; std::unordered_mapstd::string, std::weak_ptrHugeImage cache_; }; class CachedImageProxy : public Image { public: CachedImageProxy(std::string path, ImageCache cache) : path_(std::move(path)), cache_(cache) {} void render() override { getOrLoad()-render(); } int width() const override { return getOrLoad()-width(); } int height() const override { return getOrLoad()-height(); } private: std::shared_ptrHugeImage getOrLoad() const { if (auto img cache_.acquire(path_)) { return img; } auto img std::make_sharedHugeImage(path_); cache_.put(path_, img); return img; } std::string path_; ImageCache cache_; };缓存池用weak_ptr保存对象意味着当所有代理都不再使用某张图片时图片对象会被自动销毁缓存不会造成内存泄漏。acquire返回shared_ptr保证调用方在持有返回值期间真实对象不会被析构。这个设计把该不该缓存缓存怎么失效的逻辑全部收进了代理层调用方完全无感。2.3 需要警惕的隐患mutable、并发与缓存穿透延迟加载代理和缓存代理看起来简单实际项目中坑不少。第一个坑是忘记把real_声明为mutable。代理类的width()、height()通常是const方法因为从语义上讲查询宽度不应该修改代理对象。但延迟加载正是在const方法里修改了内部状态所以必须用mutable修饰。如果不用编译期就会报错。第二个坑是缓存穿透。当HugeImage的构造函数特别昂贵而且多个线程同时调getOrLoad()时虽然缓存池有put操作但并发场景下两个线程可能同时发现缓存中没有对象然后同时创建真实对象。为了避免这个问题可以把创建过程也加锁或者用双重检查锁定模式。在实际项目里如果加载成本高且并发访问频繁我会在缓存池上面再加一层加载中标记防止同一个路径被重复加载。第三个坑是weak_ptr的失效时机。如果某个代理正在使用真实对象但最后一个持有shared_ptr的代理被销毁了缓存池里的weak_ptr就失效了。下一次另一个代理再来获取时会重新加载文件。对某些场景来说这是合理的图片不常驻内存但如果缓存命中率很重要就该评估改用shared_ptr常驻缓存并配一个淘汰策略。3. 保护代理实战把权限校验从业务代码中剥离3.1 为什么权限校验不该写在业务类里权限校验是代理模式最经典的应用场景之一但我见过不少团队把它写进了业务类内部。比如TaskServiceImpl::deleteTask里先判断user.hasPermission(task:delete)再执行删除逻辑。表面上看挺方便但用一段时间就会出现三个问题。第一同一个业务类在不同调用场景下权限规则不同。比如内部运营工具允许管理员直接删除任务但外部 API 要求更细的权限粒度。把权限写死在业务类里就只能通过加 if 分支来兼容代码越来越乱。第二权限策略变更频繁。一个权限规则改了就要重新编译包含业务逻辑的模块如果权限逻辑集中在代理层业务类是稳定的测试回归范围也小得多。第三业务类被内部模块直接调用时权限校验就被绕过了。我见过一个分布式任务系统外层 HTTP 接口做了权限检查但内部定时任务调度器直接调用了TaskServiceImpl的方法导致任何内部模块都能绕过权限删除任务。把权限校验收口到代理层然后强制所有入口都使用代理才能堵住这个洞。3.2 一个TaskService的完整保护代理下面是一个完整的保护代理实现。先定义业务接口和真实实现#include stdexcept #include string #include unordered_set #include vector #include memory class Task { public: Task(int id, std::string title) : id_(id), title_(std::move(title)) {} int id() const { return id_; } const std::string title() const { return title_; } private: int id_; std::string title_; }; class UserContext { public: bool hasPermission(const std::string perm) const { return permissions_.count(perm) 0; } void addPermission(std::string perm) { permissions_.insert(std::move(perm)); } private: std::unordered_setstd::string permissions_; }; class TaskService { public: virtual ~TaskService() default; virtual std::vectorTask listTasks(const UserContext user) 0; virtual void createTask(const UserContext user, const std::string title) 0; virtual void deleteTask(const UserContext user, int taskId) 0; }; class TaskServiceImpl : public TaskService { public: std::vectorTask listTasks(const UserContext user) override { // 真实的 DB 查询 return {Task(1, write report), Task(2, fix login bug)}; } void createTask(const UserContext user, const std::string title) override { // 真实的 insert 逻辑 } void deleteTask(const UserContext user, int taskId) override { // 真实的 delete 逻辑 } };再定义权限异常和代理class PermissionDenied : public std::runtime_error { public: explicit PermissionDenied(const std::string msg) : std::runtime_error(msg) {} }; class TaskServiceProxy : public TaskService { public: explicit TaskServiceProxy(std::unique_ptrTaskService real) : real_(std::move(real)) {} std::vectorTask listTasks(const UserContext user) override { requirePermission(user, task:list); return real_-listTasks(user); } void createTask(const UserContext user, const std::string title) override { requirePermission(user, task:create); real_-createTask(user, title); } void deleteTask(const UserContext user, int taskId) override { requirePermission(user, task:delete); real_-deleteTask(user, taskId); } private: void requirePermission(const UserContext user, const std::string perm) const { if (!user.hasPermission(perm)) { throw PermissionDenied(missing permission: perm); } } std::unique_ptrTaskService real_; };调用方永远只依赖TaskService接口注入TaskServiceProxy实例auto service std::make_uniqueTaskServiceProxy( std::make_uniqueTaskServiceImpl()); UserContext user; user.addPermission(task:list); auto tasks service-listTasks(user); // 正常 service-deleteTask(user, 1); // 抛出 PermissionDenied权限字符串用task:list、task:create这种格式管理起来很直观而且可以和后端权限系统的字符串一一对应。3.3 代理用组合装饰实现日志与审计的层层叠加保护代理解决了权限校验但日志审计往往也横在所有接口上。这时候可以用链式组合把不同的横切代理串起来。比如加一层日志代理#include iostream class LoggingTaskService : public TaskService { public: explicit LoggingTaskService(std::unique_ptrTaskService real) : real_(std::move(real)) {} std::vectorTask listTasks(const UserContext user) override { std::cout [AUDIT] listTasks called\n; return real_-listTasks(user); } void createTask(const UserContext user, const std::string title) override { std::cout [AUDIT] createTask called: title \n; real_-createTask(user, title); } void deleteTask(const UserContext user, int taskId) override { std::cout [AUDIT] deleteTask called: taskId \n; real_-deleteTask(user, taskId); } private: std::unique_ptrTaskService real_; };然后组合使用auto service std::make_uniqueTaskServiceProxy( std::make_uniqueLoggingTaskService( std::make_uniqueTaskServiceImpl()));调用链是调用方 → TaskServiceProxy权限校验 → LoggingTaskService审计日志 → TaskServiceImpl真实业务。每一层只做一件事业务类完全不知道自己被套了几层代理。这种「代理的代理」在 Java 里叫装饰器在 C 里实现方式完全一样唯一的注意点是每一层都要正确实现移动/拷贝语义否则unique_ptr链式组合会在递归时出问题。从设计模式分类上讲保护代理关注的是控制访问日志代理更接近装饰器增强功能。但实现层面对我来说没有区别都是实现同一个接口内部持有下一个对象转发调用前做点额外的事。理解这一点后面组合使用就会很自然。4. 远程代理实战让调用方无感知的跨进程通信层4.1 远程代理是什么以及代理要替你做什么很多教科书提到远程代理但很少给 C 的可运行示例。远程代理的本质是调用方调用的本地接口方法实际在远端进程里执行。代理层要替你完成四件事——序列化请求、通过网络发送、等待响应、反序列化结果。此外超时、重试、错误码到异常的转换也都应该收拢在代理里而不是散落在业务调用方。为什么需要代理因为调用方根本不需要关心数据是本地算出来的还是从远端服务拿到的。把网络细节封装在接口后面业务代码里就是一行service-query(metric, from, to)清爽得很。4.2 一个简化版指标查询RPC代理假设后端有一个指标查询服务客户端需要按时间范围查询指标数据。先定义连接抽象#include chrono #include cstdint #include string #include vector #include utility class Connection { public: virtual ~Connection() default; virtual bool send(const std::string payload) 0; virtual std::string receiveWithTimeout(int timeoutMs) 0; };再定义接口和远程代理struct MetricPoint { int64_t timestamp; double value; }; class MetricsService { public: virtual ~MetricsService() default; virtual std::vectorMetricPoint query(const std::string metric, int64_t from, int64_t to) 0; }; class RemoteError : public std::runtime_error { public: explicit RemoteError(const std::string msg) : std::runtime_error(msg) {} }; class RemoteMetricsService : public MetricsService { public: explicit RemoteMetricsService(Connection conn) : conn_(conn) {} std::vectorMetricPoint query(const std::string metric, int64_t from, int64_t to) override { QueryMetricsRequest req{metric, from, to}; // 序列化请求并发送 std::string payload serialize(req); if (!conn_.send(payload)) { throw RemoteError(send failed); } // 接收响应带超时 std::string respData conn_.receiveWithTimeout(3000); if (respData.empty()) { throw RemoteError(receive timeout); } // 反序列化响应 auto resp deserializeQueryMetricsResponse(respData); // 统一错误码转换 if (resp.errorCode ! 0) { throw RemoteError(resp.errorMessage); } // 返回真实数据 return std::move(resp.points); } private: Connection conn_; };这里的serialize和deserialize我用函数名占位实际项目中可以用 protobuf、flatbuffers 或者手写二进制编码。代理层最关键的设计是所有与网络和协议相关的细节全部封装在query()方法内部调用方看不到任何send、receive、序列化之类的操作。如果将来服务端从自定义 TCP 协议改成 HTTP 接口只需要新写一个HttpMetricsService替换实现调用方代码一行都不用改。4.3 代理里的重试、熔断与降级远程调用最烦人的问题就是服务端不稳定。代理层是实现重试和降级的好地方。我在实际项目中给远程代理加过简单重试std::vectorMetricPoint query(const std::string metric, int64_t from, int64_t to) override { constexpr int kMaxRetry 3; for (int attempt 0; attempt kMaxRetry; attempt) { try { return doQuery(metric, from, to); } catch (const RemoteError e) { if (attempt kMaxRetry - 1) { throw; // 最后一次失败直接抛给调用方 } // 指数退避50ms、100ms std::this_thread::sleep_for(std::chrono::milliseconds(50 attempt)); } } throw RemoteError(unreachable); }再进一步可以在代理里内置一个熔断器连续失败次数超过阈值后直接快速失败不再发起网络请求避免雪崩。这个逻辑放在代理层的好处是业务代码完全不用感知熔断的存在。降级是另一个实用技巧。当后端不可用时代理层可以返回一份最后成功缓存的指标数据让上层报表不至于白屏。这种返回陈旧数据的策略放在代理里实现最合适因为调用方无感知它以为自己只是拿到了一份稍微旧一点的数据。代理层还可以做请求合并。比如多个调用方同时查询同一个指标代理层可以合并成一个批量请求发给后端减少网络往返。这些优化放在代理层本质都是把横切复杂度从业务代码里抽走。5. C特有坑位拷贝、移动、虚析构对代理的影响5.1 一个让编译失败的拷贝陷阱C 的代理模式和 Java 最大的区别在于值语义。Java 里你随便把代理对象放进ArrayList拷贝引用就行。C 的std::vector扩容时会拷贝或移动元素而代理内部持有的std::unique_ptr是不可拷贝的于是编译直接报错。遇到的典型场景是我在素材管理里维护了一个std::vectorstd::unique_ptrImage存放各种图片代理。vector 扩容时编译器试图调用ImageProxy的拷贝构造但ImageProxy里有不可拷贝的unique_ptr编译失败。解决办法是给ImageProxy提供移动构造和移动赋值并显式删除拷贝构造class ImageProxy : public Image { public: ImageProxy(ImageProxy other) noexcept : path_(std::move(other.path_)), real_(std::move(other.real_)) {} ImageProxy operator(ImageProxy other) noexcept { path_ std::move(other.path_); real_ std::move(other.real_); return *this; } ImageProxy(const ImageProxy) delete; ImageProxy operator(const ImageProxy) delete; };给ImageProxy定义移动语义后vector 扩容时就能正确移动代理对象而不是拷贝。这是 C 特有的代理模式实现必须处理的问题——Java、Python 这类基于引用的语言压根不会遇到。5.2 虚析构是保命底线C 的代理模式和真实对象之间都是通过继承实现接口统一而接口类最容易被忽略的就是虚析构。如果Image没有虚析构通过std::unique_ptrImage持有ImageProxy在析构时只会调用~Image()而不会调用~ImageProxy()代理内部持有的unique_ptrHugeImage不会被释放真实对象的内存就泄漏了更严重的是这是未定义行为。正确的接口类写法class Image { public: virtual ~Image() default; // 必须 virtual void render() 0; virtual int width() const 0; virtual int height() const 0; };这条规则对任何设计模式都适用但在代理模式里尤其关键因为代理对象和真实对象通常是通过基类指针到处传递的稍不留神就会触发未定义行为。我在 code review 时看到接口类第一眼就找virtual ~没有就直接打回这个习惯救过不少次。5.3 const方法修改内部状态用mutable和thread-safe的权衡延迟加载代理的width()是const方法但它内部可能第一次触发真实对象的创建。这要求真实对象指针被声明为mutable。使用mutable本身没错但要清楚它带来的线程安全责任。mutable本质上告诉编译器允许在 const 方法里修改该成员但修改依然需要加锁。更稳的方案是std::call_once它保证多线程环境下初始化只发生一次。代价是每次调用都需要检查once_flag这个检查非常廉价。如果要追求极致性能可以先用std::atomicbool做快速路径判断再调用call_once慢路径初始化。不过我在实际项目中很少需要这种优化call_once足够用了。5.4 被移动后的代理还能不能用移动语义带来了一个新问题移动后的代理对象处于有效但未指定的状态。实践中就是path_为空、real_为 null。如果调用方不小心在移动后继续使用源代理可能出现两种情况一是real_为 null调用render()疯狂空指针解引用崩溃二是path_为空重新加载时读出来诡异的东西。我在代理的render()里加过防御性检查void render() override { if (path_.empty()) { throw std::logic_error(proxy has been moved-from); } loadIfNeeded(); real_-render(); }但从工程角度讲更推荐在文档和接口注释里写清楚移动后的源对象只能被赋值或析构不能调用其他方法。防御性检查用于兜底不应当做第一道防线。实际项目里代理对象很少会被移动但如果代理被放进容器或作为函数返回值移动语义就是绕不开的话题。5.5 该用unique_ptr还是shared_ptr持有真实对象这是一个设计权衡不是技术上的对错。我的经验是如果代理独占真实对象的生命周期就用unique_ptr轻量无引用计数开销如果多个代理共享同一个真实对象比如缓存池场景就必须用shared_ptr。但要注意一旦你返回shared_ptr给调用方对方就拿到了真实对象的直接访问权代理的封装就被打破了一层。缓存代理的早期版本我犯过这个错getOrLoad()直接返回shared_ptrHugeImage调用方拿到后调用render()绕过了代理的权限和日志逻辑。所以现在的设计原则是代理内部可以持有和处理shared_ptr但对外只返回接口方法绝不让调用方直接获得真实对象。如果确实需要暴露给外界暴露的也是代理本身的shared_ptr或者定义一个单独的只读接口。6. 模板化与静态多态搬掉重复代码的进阶思路6.1 C没有动态代理转发样板怎么消除Java 里有java.lang.reflect.Proxy可以运行时动态生成代理C 没有这种机制。所以如果一个接口有十多个方法代理类就得手写十多个转发函数每个函数里都是检查权限 → 调用真实对象 → 返回结果样板代码还是不少。最直观的笨办法是宏。写一个简单的宏#define PROXY_METHOD(RetType, MethodName, ...) \ RetType MethodName(__VA_ARGS__) override { \ requirePermission(#MethodName); \ return real_-MethodName(__VA_ARGS__); \ } class TaskServiceProxy : public TaskService { public: PROXY_METHOD(std::vectorTask, listTasks, const UserContext user) PROXY_METHOD(void, createTask, const UserContext user, const std::string title) PROXY_METHOD(void, deleteTask, const UserContext user, int taskId) };宏能减少重复但有两个明显限制。第一如果参数列表里有逗号比如std::pairint, int宏展开就会断在逗号上编译报错指向完全莫名其妙的地方。第二宏做不了完美转发想传右值引用和std::move语义就很别扭。所以宏只适合参数简单的场景复杂接口我建议用代码生成器或者干脆手写。另一个思路是用模板模板参数生成代理骨架。比如把持有真实对象和转发的参数化逻辑抽出来template typename Interface, typename Impl class GuardProxy : public Interface { public: template typename... Args explicit GuardProxy(Args... args) : real_(std::forwardArgs(args)...) {} protected: Impl real() { return real_; } const Impl real() const { return real_; } private: Impl real_; };这个骨架解决了真实对象的构造和持有问题但方法转发还是要手写。C 没有语言级别的反射所以自动为所有接口方法生成转发在标准 C 里做不到。如果接口特别多工程上最实用的方案是代码生成用一个 Python 脚本读接口定义生成代理类的转发函数。生成出来的代码直接进仓库虽然没有手写的优雅但可读性和可维护性都优于宏。6.2 什么时候适合CRTP静态多态代理模式默认依赖虚函数实现接口统一但虚函数调用有一次间接跳转开销。如果一个代理是热路径上的核心对象每一帧渲染调用几千次这个开销可能就不容忽视。可以考虑把动态多态换成 CRTP 静态多态template typename Derived class ImageProxyBase { public: void render() { static_castDerived*(this)-beforeRender(); static_castDerived*(this)-real().render(); static_castDerived*(this)-afterRender(); } int width() const { static_castconst Derived*(this)-ensureLoaded(); return static_castconst Derived*(this)-real().width(); } };CRTP 的好处是没有虚函数调用编译器可以完全内联展开坏处是失去了运行时多态不能再往std::vectorstd::unique_ptrImage里塞不同类型。所以我的建议是默认用动态多态代理只有当 profiler 明确指出代理层的虚函数调用是热点瓶颈时才考虑对特定接口做 CRTP 静态多态改写。过早优化只会把代码搞复杂。6.3 实测一次虚函数间接跳转到底慢多少我写过一个简单的 benchmark对比直接调用HugeImage::render()和通过ImageProxy::render()调用迭代一千万次constexpr int kIterations 10000000; // 直接调用 auto start std::chrono::steady_clock::now(); for (int i 0; i kIterations; i) { hugeImage.render(); } auto directMs std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start).count(); // 通过代理调用 start std::chrono::steady_clock::now(); for (int i 0; i kIterations; i) { imageProxy.render(); } auto proxyMs std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start).count();实测下来代理层多一层虚函数调用和call_once判断总耗时通常只比直接调用多几个百分点前提是真实对象的渲染本身不是毫秒级空操作。如果render()内部有实际工作代理的开销可以忽略不计。但如果代理调用的方法本身只是一个返回int的 getter那多出来的间接跳转占比就很大了。这时候优化思路不是去掉代理而是把 getter 改成非虚内联或者在代理层直接返回缓存好的值避免每次都做延迟加载判断。说句实话代理模式最大的性能损耗通常不是虚函数跳转而是设计不当导致重复创建真实对象、频繁加锁、缓存命中率低。这些才是真正值得砸时间优化的地方。我在实际项目中养成的习惯是先用朴素的动态多态代理把功能做对再用 profiler 确认热点。绝大多数人
返回列表