精读C++20设计模式——结构型设计模式:代理模式
精读C++20设计模式——结构型设计模式:代理模式
前言
到最后一个了!我们马上就要结束结构型设计模式的学习了!代理模式是一个使用极其广泛的设计模式!如果你不相信,我们可以马上来看看他到底多么的常见。
第一个例子:智能指针
一反常态,我先不说代理模式是啥。我们先来看C++ RAII一个永恒的话题——智能指针。我们包装了原始指针,给他增加RAII的功能——堆内存的析构和释放不再需要手动处理。
class Source { ... };int main()
{Source* src = new Source;src->doWork(); // everything fines!std::unique_ptr<Source> _src = std::unique_ptr<Source>(src);_src->doWork(); // everything fines!
}
啊哈!你看,我们使用了代理模式。你会发现好像套了一层之后,原来的对象该咋用还是再用,完全不受任何的使用影响!但是我们现在不需要手动析构了,这就是最简单的代理模式——产生一个代理对象迭代我们的原始对象。
第二个例子: 属性代理
#include <functional>
#include <iostream>
#include <mutex>
#include <string>template<typename T>
class property {
public:using Getter = std::function<T()>;using Setter = std::function<void(const T&)>;// 默认构造:直接持有原始值property() : getter_([this]{ return value_; }),setter_([this](const T& v){ value_ = v; }) {}// 构造并传入初始值explicit property(const T& v) : property() { value_ = v; }// 构造并传入自定义 getter/setterproperty(Getter g, Setter s) : getter_(std::move(g)), setter_(std::move(s)) {}// 读取operator T() const { return getter_(); }// 赋值property<T>& operator=(const T& new_val) {setter_(new_val);return *this;}// 可取引用(谨慎:仅当内部存储时安全)T& underlying() { return value_; }private:// 当使用默认 getter/setter 时,value_ 被使用mutable T value_{}; // mutable 以便 getter() 在 const 方法中返回 stored valueGetter getter_;Setter setter_;
};
我们还是一样,你会发现被property套一层的对象,本质上还是可以该咋用就咋用:T operator=
的重载保证了我们的默认行为。但是我们发现如果我们采用赋值的行为,该调用getter调用getter,反之setter。这就是咱们的例子。
第三个例子:虚拟代理(Virtual Proxy / Lazy-loading Proxy)
这个部分呢是我们对那些象创建或初始化成本很高的对象代理。我们选择懒加载,代理在第一次访问时延迟创建真实对象(lazy init)。典型例子是大型图片、数据库连接、远程资源等。你看到他们的获取都很耗时。因此,咱们完全可以套上一层这样的代理,保证合法的访问。
这里偷一下这本书的例子。
#include <iostream>
#include <memory>
#include <string>// 抽象接口
struct Image {virtual void display() = 0;virtual ~Image() = default;
};// 真正的图片(加载耗时)
class RealImage : public Image {
public:explicit RealImage(const std::string& file) : filename(file) {loadFromDisk();}void display() override {std::cout << "Displaying " << filename << "\n";}
private:void loadFromDisk() {std::cout << "Loading image from disk: " << filename << " (expensive)\n";// 模拟耗时...}std::string filename;
};// 虚拟代理:延迟创建 RealImage
class ImageProxy : public Image {
public:explicit ImageProxy(const std::string& file) : filename(file) {}void display() override {ensureReal();real_->display();}
private:void ensureReal() {if (!real_) real_ = std::make_shared<RealImage>(filename);}std::string filename;std::shared_ptr<RealImage> real_;
};
第四个例子:通信代理 / 远程代理(Remote / Communication Proxy)
这个跟虚拟代理有点相似,但是更加特化在访问上。比如说访问一个远程对象的时候,咱们就喜欢用通信代理在本地表现得像对象,但负责序列化、网络传输、重试、超时等通信细节。远程代理隐藏网络通信细节并呈现对象接口。
#include <iostream>
#include <string>
#include <future>
#include <chrono>
#include <stdexcept>// 模拟 Transport:发送请求、获取响应
struct Transport {// 发送同步请求(真实情况会是 socket/http/gRPC 等)std::string send_request(const std::string& req) {// 模拟网络延迟与处理std::this_thread::sleep_for(std::chrono::milliseconds(50));if (req == "get_time") return "2025-09-29T12:00:00Z";if (req.rfind("compute:", 0) == 0) return "result:" + req.substr(8);throw std::runtime_error("unknown request");}
};// 远程接口(本地抽象)
struct RemoteService {virtual std::string getTime() = 0;virtual int remoteCompute(int x, int y) = 0;virtual ~RemoteService() = default;
};// 远程代理:将方法调用转成 transport 请求
class RemoteServiceProxy : public RemoteService {
public:explicit RemoteServiceProxy(Transport* t): transport_(t) {}std::string getTime() override {auto resp = transport_->send_request("get_time");return resp; // 真实场景会解析}int remoteCompute(int x, int y) override {std::string req = "compute:" + std::to_string(x) + "," + std::to_string(y);std::string resp = transport_->send_request(req);// 解析 "result:x,y" -> 这里模拟返回 x+yreturn x + y;}
private:Transport* transport_;
};
第五个例子:值代理 / 复制-写时代理(Value Proxy / Copy-On-Write)
COW代理负责实现“按需复制(copy-on-write)”或延迟创建真实值。常见用例是实现节省内存的不可变共享,然后在写时复制(COW)。也可用于实现像 std::vector
的“写时复制”策略或字符串的优化(历史上的 std::string
COW 问题)。
#include <iostream>
#include <memory>
#include <string>
#include <atomic>class CowString {
public:CowString() : data_(std::make_shared<std::string>()) {}CowString(const std::string& s) : data_(std::make_shared<std::string>(s)) {}// 读取const std::string& str() const { return *data_; }// 写:确保独占(写时复制)void append(const std::string& s) {ensure_unique();data_->append(s);}// operator[] 写入例子char& operator[](size_t i) {ensure_unique();return (*data_)[i];}private:void ensure_unique() {if (!data_.unique()) { // shared_ptr::unique 检查引用计数 == 1data_ = std::make_shared<std::string>(*data_); // 复制}}std::shared_ptr<std::string> data_;
};
优点:
- 减少复制开销,当读取远多于写时很划算。
缺点: - 并发情况下保证
ensure_unique()
的正确性需要额外同步。 - C++ 标准库容器在现代实现中已不再推荐 COW,因为移动语义等更高效。
其他的一些奇妙代理
保护代理(Protection / Access Control Proxy)和审计代理
保护代理的核心动机很简单:把“谁能做什么”的检查从业务代码中剥离出来,统一放在一个边界层。对于任何需要授权的操作(读取敏感字段、执行管理命令、访问外部资源等),把检查逻辑包装成代理可以避免授权逻辑散落在各处,从而降低出错概率并方便审计与策略变更。
另一个常见需求是审计。权限通过与否本身就是重要事件——谁尝试访问了什么资源、发生在什么时候、出于何种上下文,都应该被记录。保护代理是最自然的审计点:因为它总是触发于访问入口,既能记录成功,也能记录拒绝并附带原因(比如权限缺失、过期凭证、来源不可信等)。
class Sensitive {
public:void secret() { std::cout << "secret data\n"; }
};class ProtectionProxy {
public:ProtectionProxy(Sensitive* s, bool allowed) : s_(s), allowed_(allowed) {}void secret() {if (!allowed_) throw std::runtime_error("access denied");s_->secret();}
private:Sensitive* s_;bool allowed_;
};
缓存代理(Caching / Memoization Proxy)
缓存代理的价值在于将昂贵或延迟敏感的计算结果暂存起来,从而提高吞吐和降低响应时延。缓存可以发生在多个层次:本地内存(进程内缓存)、进程间共享缓存(memcached/redis)、或边缘缓存(CDN)。把缓存策略封装在代理中,可以让业务逻辑只关注“需要什么”,而不必关心“如何缓存”。
在设计缓存代理时要做清晰的权衡与规划。首先考虑一致性模型:是否允许强一致性还是可容忍最终一致性?缓存的复杂程度(cache-aside、write-through、write-back)和复杂度与业务一致性需求直接相关。对于读多写少、能容忍短时不一致的场景(如公共内容、统计数据),cache-aside 或短 TTL 的缓存通常是最简单且高效的选择。对于必须保证和后端同步的场景(例如账户余额),缓存并非首选,或者必须配合强一致性机制(事务、版本号、乐观锁等)。
#include <unordered_map>
#include <optional>class Expensive {
public:virtual int compute(int x) = 0;
};class CachingProxy : public Expensive {Expensive* real_;std::unordered_map<int,int> cache_;
public:CachingProxy(Expensive* r) : real_(r) {}int compute(int x) override {auto it = cache_.find(x);if (it != cache_.end()) return it->second;int r = real_->compute(x);cache_[x] = r;return r;}
};
日志代理(Logging Proxy)
日志代理以横切关注点的形式把观测(observability)逻辑从业务实现中抽离出来。它在方法调用前后、以及异常发生时记录关键信息:调用参数、调用者标识、执行时长、返回值或异常堆栈。把日志写进代理的好处是统一、可控,并且更容易在调用链的早期插入追踪信息(比如 request-id、trace-id),从而支持分布式追踪与端到端的性能分析。
在实现日志代理时应注意不要记录敏感数据(密码、密钥、个人身份信息)或在没有掩码的情况下把大对象直接序列化到日志文件中。对日志格式的设计要考虑解析与索引,例如使用结构化日志(JSON)可以方便后续用 ELK/Graylog/Fluentd 等工具聚合和搜索。日志级别的管理也很重要:debug 级别可记录详细的输入输出,但在生产中默认应为 info 或 warning,避免日志风暴。
class Calculator {
public:virtual int add(int a,int b){ return a+b; }
};class LoggingProxy : public Calculator {Calculator* real_;
public:LoggingProxy(Calculator* r): real_(r){}int add(int a,int b) override {std::cout << "[LOG] add(" << a << ',' << b << ")\n";int r = real_->add(a,b);std::cout << "[LOG] result=" << r << "\n";return r;}
};
同步代理(Synchronization Proxy)
同步代理把并发控制放在代理层:在调用真实对象之前获取锁,调用结束后释放锁。它的主要用途是在无法或不愿修改被代理对象以添加线程安全机制时,提供一层外部同步。同步代理适用于库代码或第三方组件,或者用于临时为特定实例加锁。
#include <mutex>class SyncProxy : public SomeInterface {SomeInterface* real_;std::mutex m_;
public:SyncProxy(SomeInterface* r): real_(r){}void op() override {std::lock_guard lk(m_);real_->op();}
};
但同步并非灵丹妙药。过度使用粗粒度锁会导致严重的性能瓶颈和可伸缩性问题:单个全局互斥会使并行度为 1,失去多核优势。设计同步代理时应考虑锁的粒度与争用:按资源划分锁(分片/细粒度锁)、使用读写锁(shared mutex)以提高读多写少场景的并发度,或采用无锁数据结构/原子操作来避免锁的开销。
死锁是同步代理必须面对的问题。尤其当调用链可能再次回到另一个需要持有不同锁的代理时,很容易形成循环等待。为避免死锁,要制定一致的锁获取顺序,或者在代理中使用超时锁(尝试获取锁若超过阈值则失败或重试),并把锁的持有时间缩短到最低。
在高并发场景下,还可以用乐观并发控制(例如版本号+CAS)替代悲观锁。同步代理也可以扩展为带重试/回退策略的版本:在冲突时非阻塞失败并稍后重试,结合指数回退可以减少争用峰值。对于需要可观测性的系统,代理应记录锁等待时间分布和持有时间,帮助发现热点与性能瓶颈。
总结
它解决什么问题、如何解决(文字说明)
代理模式的核心思想是:用一个代理对象代替另一个真实对象来对外提供相同的接口。调用者与代理交互,而代理负责在必要时把请求转发给真实对象,并可在转发前后插入额外行为(鉴权、缓存、懒加载、日志、同步等)。因此代理模式并不是把功能“替换掉”,而是把访问控制点抽离出来,把横切关注点以可控、可复用的方式插入到访问路径中。
为什么需要代理?常见问题可以归纳为三类:
- 资源/成本问题:真实对象的创建或操作代价高(加载大文件、建立数据库/网络连接、解析大型结构等),不希望在每次或启动时立即耗费这些成本。
- 横切关注点:鉴权、审计、日志、缓存、并发控制等不是业务核心,却需要在很多调用点重复。把这些逻辑散在业务代码会导致重复、难维护与安全漏洞。
- 抽象与隔离:当真实对象位于远程(RPC)、第三方库或不可修改的遗留代码时,需要一个本地层来隔离网络/库细节或适配接口。
如何解决的?
- 接口同形:代理实现和被代理对象相同(或兼容)的接口,调用方无需感知替换,降低耦合。
- 延迟/惰性:虚拟代理在第一次真正需要时才创建/加载真实对象,从而避免不必要的开销。
- 封装横切逻辑:保护代理、日志代理、缓存代理等把鉴权、审计、缓存、度量等操作封装到代理中,业务类保持简洁。
- 本地化通信细节:远程/通信代理把序列化、网络重试、超时等复杂性封装在本地,使使用者像调用本地对象一样使用远程服务。
- 复制/共享策略:值代理(COW)通过共享和写时复制在读取多、写少场景下节省内存与复制成本。
- 组合与装饰:多个代理可以按责任链/装饰器方式叠加(例如鉴权→缓存→同步→日志→真实服务),实现职责分离且易于配置。
总体上,代理模式是一种“控制访问”与“插入横切行为”的结构化方式,使得非功能需求(性能、安全、可观测性、并发)以集中、可复用的方式实现,而不侵入业务实现。
对比表:常见代理类型 —— 解决的问题 / 如何实现 / 优点 / 注意点
代理类型 | 解决了什么问题(What) | 怎么解决(How) | 优点(Pros) | 注意点 / 陷阱(Caveats / Notes) |
---|---|---|---|---|
属性代理(Property) | 在字段读写处插入验证、通知、线程安全或延迟计算 | 把字段封装为 property<T> ,重载 getter / setter 或提供 operator T() 、operator= | 访问语义透明、能轻松插入横切逻辑(验证、通知、同步) | 隐式副作用(赋值可能抛异常或耗时);可能引入隐式转换问题与性能开销 |
虚拟代理(Virtual / Lazy) | 延迟创建或加载昂贵资源,减少启动或内存开销 | 代理保存资源标识,首次访问时创建真实对象并转发后续调用 | 降低启动成本、按需加载、节省内存 | 并发创建需同步(双重检查要注意内存模型);生命周期/释放复杂 |
通信 / 远程代理(Remote) | 隐藏网络调用、序列化、重试、超时等分布式复杂性 | 在本地实现对象接口,内部执行序列化 + 网络传输 + 解析 | 使用者无需关心网络细节;便于统一处理重试/超时/认证 | 网络故障模式复杂(超时、部分失败);同步远程调用会隐藏延迟,需文档标注 |
值代理 / 写时复制(Value / COW) | 减少在读多写少场景下的复制开销 | 通过共享底层数据结构,写时检测并复制(ensure_unique) | 减少内存与拷贝开销,节省性能(读多写少) | 并发一致性复杂;现代 C++ 推荐移动语义而非 COW,可能增加复杂性 |
保护代理(Protection / Authz) | 集中权限检查、审计与拒绝策略 | 在转发前调用策略决策器(Principal + Policy),拒绝或允许 | 将鉴权逻辑从业务中剥离,便于变更策略与审计 | 授权延迟/远程验证影响性能;必须设计失败策略(fail-closed)与撤销机制 |
缓存代理(Caching / Memoization) | 减少后端/计算开销、提高吞吐与响应速度 | 在代理层维护缓存(带 TTL / 驱逐),缓存命中则直接返回 | 明显提升读性能、降低后端压力 | 缓存一致性、击穿/雪崩、缓存键设计、内存占用与并发策略需处理 |
日志代理(Logging / Telemetry) | 统一调用记录与可观测性 | 在调用前后记录参数、时长、返回/异常,注入 trace-id 等 metadata | 统一结构化日志、便于追踪与告警 | 不当记录敏感数据或过度记录导致性能与隐私问题;同步日志阻塞风险 |
同步代理(Synchronization / Locking) | 为非线程安全对象提供外部并发控制 | 在调用前后加锁(互斥/读写锁/分片锁)或使用乐观重试 | 快速为不可变代码提供线程安全,便于临时方案 | 粗粒度锁造成瓶颈与死锁风险;需合理粒度、避免长时间持有锁 |