C++ 设计模式学习笔记(五):四种接口隔离,四种不同的边界
在两个对象中间增加一个对象,是许多模式都会采用的手段。但“多了一层”只能描述结构,解释不了它为什么存在。一个间接层可能简化整个子系统的使用,也可能负责权限检查、转换旧接口,或者集中管理多个对象的协作。
课程把 P14 门面、P15 代理、P16 适配器和 P17 中介者归在“接口隔离”这一组。本文依据四讲完整音轨转录及全部抽样课件画面整理,属于 vibe watching。四讲的共同线索是识别依赖传播的方向;文中的 C++20 完整示例与工程边界分析为整理时补充,不是课堂源码的逐字抄录。
来源档案
讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P14《门面模式》、P15《代理模式》、P16《适配器》、P17《中介者》。
转载账号为“川中一郎”,合集标题《C++设计模式》,标识 BV15pxQzSE8j;上传者与原讲者分别署名。讲者账号发布的《C++设计模式》试听可核对课程署名;本次整理的转载合集用于定位上述分 P。链接失效时,可用“李建忠 C++设计模式 GeekBand”及讲次标题检索。
门面:让调用者表达目标,不必操作全部内部零件
P14 从数据访问子系统说明门面模式。假如每个业务调用者都需要了解连接、命令、参数、数据表等内部对象,任何一个内部协作流程变化,都可能要求多个调用者跟着调整。
例如,业务方真正需要的是“取得某段时间的销售汇总”,底层却要求它自己建立连接、组织查询、绑定参数、执行命令并组装结果。这些步骤散落在使用方时,使用方事实上已经参与了子系统的内部实现。
门面提供一个面向外部需求的入口,将这组协作隐藏在内部。调用者通过相对稳定的操作提出请求,子系统负责选择数据库实现、管理对象和转换结果。课堂用电脑的输入输出接口作类比:内部 CPU、内存、磁盘可以演化,外部使用者仍从约定的交互入口使用系统。
门面的价值因此不能只用“少调用了几个函数”衡量。更关键的是调用者不再分别依赖那些内部类型和协作顺序,修改的影响面能够停留在子系统内部。
稳定接口也需要选对抽象层次
门面不是把所有内部方法换个地方逐一转发。如果外部仍需要传入某个具体驱动的连接对象、理解内部事务步骤,就可能只是换了文件位置,没有建立真正的边界。
另一种问题是门面越来越大。数据访问门面如果同时负责按钮状态、HTML 拼装、用户弹窗,它就跨越了多个不同的变化方向。课程特别提醒不要因为提供了统一入口,就把无关职责全都塞进去。
合理的门面应围绕一个内聚的子系统或一类用户目标组织。它可以有多个方法,也可以分为多个面向不同用例的入口。类名里写着 Facade 并不能保证职责已经合理。
“隔离内部变化”也有前提:如果外部需求本身变了,返回数据的含义或错误处理契约发生变化,门面接口仍然可能需要调整。模式控制的是传播范围,不会让所有变化都消失。
代理:仍然请求同一种服务,但访问过程需要被控制
P15 的出发点与门面不同:客户原本就知道要请求什么对象,也有合适的业务接口,只是直接访问存在额外约束。
课程列举了几个原因:对象创建昂贵,需要推迟;某些调用需要安全控制;真实对象位于其他进程甚至另一台机器。为了保持客户的业务调用形式,可以让代理实现同一个服务接口,由它决定如何访问真实对象。
课堂代码从 ISubject、RealSubject 和客户端开始,随后让 SubjectProxy 同样实现 ISubject。客户仍然调用 process,但对象选择改为代理;代理在自己的实现中完成控制,再取得真实结果。如果客户还需要避免具体创建依赖,可以与工厂或外部注入配合。
类图里的一条 realSubject->Request() 很简洁,却隐藏了代理真正困难的部分。权限代理可能持有同进程对象;远程代理没有一个能够直接调用远端 C++ 对象的普通指针,而需要编码请求、发送消息、等待结果并处理失败。共同的接口形式不意味着实现成本相同。
一个完整例子:授权成功后才创建报表服务
下面的原创 C++20 例子同时表现访问检查与惰性创建。它使用固定的权限对象模拟已验证的身份上下文;真实系统应从可信认证过程获得身份,不能相信客户端自行提交的布尔字段。
#include <cassert>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
struct Access {
bool can_read_report;
};
class Report {
public:
virtual ~Report() = default;
virtual std::string read(const Access& access) = 0;
};
class StoredReport final : public Report {
public:
std::string read(const Access&) override {
return "sales: 42";
}
};
class ReportProxy final : public Report {
std::unique_ptr<StoredReport> real_;
int constructions_ = 0;
public:
std::string read(const Access& access) override {
if (!access.can_read_report) {
throw std::runtime_error("report access denied");
}
if (!real_) {
auto ready = std::make_unique<StoredReport>();
real_ = std::move(ready);
++constructions_;
}
return real_->read(access);
}
int constructions() const noexcept { return constructions_; }
};
std::string show_report(Report& report, const Access& access) {
return report.read(access);
}
int main() {
ReportProxy proxy;
bool denied = false;
try {
show_report(proxy, Access{false});
} catch (const std::runtime_error&) {
denied = true;
}
assert(denied);
assert(proxy.constructions() == 0);
assert(show_report(proxy, Access{true}) == "sales: 42");
assert(show_report(proxy, Access{true}) == "sales: 42");
assert(proxy.constructions() == 1);
}
客户端 show_report 只依赖业务接口。第一次请求没有权限,真实对象根本不创建;后续合法请求复用已创建的对象。创建过程如果抛异常,智能指针仍为空,下一次请求可以按契约重新尝试。
这个例子只说明单线程进程内的代理结构。多线程共享代理时,惰性初始化和实际服务访问需要相应同步;真实服务若还能从其他入口直接获得,那么这一个代理也不能独自保证全系统的访问安全。
远程代理不能抹掉远程调用的事实
把远程调用伪装成一次方法调用可以减少样板代码,但调用者仍需知道它是否阻塞、可能超时、是否支持取消。尤其当客户端超时后,不一定能判断服务器究竟有没有执行成功。重试一个只读查询和重试一次付款,显然需要不同语义。
这些是对课程远程代理动机的工程补充。代理可以集中实现编解码、重试策略和故障转换,但无法仅凭“接口相同”保证远程服务像本地调用一样可靠。
P15 还提到写时复制:先共享数据,写入前再建立副本。这是一种控制访问和复制时机的思路,但不能据此断言现代 std::string 普遍采用写时复制。理解模式应抓住延迟与控制的意图,具体标准库实现需要另行核实。
适配器:已有能力可复用,但调用契约对不上
P16 的问题是新旧接口不匹配。旧对象能够完成需要的工作,但新系统期待另一套调用形式。此时既不希望大改旧代码,也不希望让每个新调用者都知道转换细节。
课堂先定义新接口 ITarget::process,旧接口 IAdaptee 提供 bar() 和 foo(int),具体旧类实现它们。适配器对外实现 ITarget,内部组合旧对象。在 process 中先调用 bar 取得数据,再把结果传给 foo。
这个例子值得注意的地方是:适配并不一定是一对一的方法改名。新接口的一个操作,可能需要组合旧接口的多个操作,也可能需要转换参数、调整顺序或重新组织返回值。
结构上可以用下面这个示意片段理解:
// 示意片段:省略接口与旧类定义。
class Adapter : public ITarget {
IAdaptee& old_; // 非拥有引用,旧对象须比适配器活得久
public:
explicit Adapter(IAdaptee& old) : old_(old) {}
void process() override {
const int value = old_.bar();
old_.foo(value);
}
};
公开继承目标接口表达“我满足新系统的契约”,组合旧对象表达“我复用现有实现”。两种关系承担不同职责。
转换方法名,远远不够
实际适配还可能涉及温度单位、坐标系、字符编码、错误码与异常、缓冲区所有权,以及同步和异步行为。一个函数返回了相同的整数类型,不代表这个数在两边具有相同含义。
例如,旧 API 返回的指针只能存活到下一次调用,而新接口承诺返回独立结果,适配器就需要复制数据或改变内部所有权安排。把指针原样转发出去虽然可能编译成功,却违反了目标接口的契约。
因此,判断适配器是否正确,需要检查目标端期待的可观察行为。只检查函数签名,最多证明接口形状对上了。
对象适配器、类适配器与容器适配
课程比较两种常见实现。对象适配器组合一个旧对象,可以在运行时注入不同实现;类适配器通过继承具体旧类复用实现,同时实现目标接口,旧实现类型在编译时固定。
讲解中曾出现继承旧抽象接口的临时方案,随后指出仅继承抽象声明并拿不到实现,必须结合具体旧类。把演示过程中的中间代码当成最终模板,容易错过这个修正。
课程倾向组合。这个偏好在许多场景下合理,因为组合较少暴露旧类实现关系,也便于替换对象。但类适配并非在所有情形下都毫无价值,例如可能需要访问旧类的受保护行为。代价与收益应根据实际约束判断,而非只看有没有多继承。
std::stack、std::queue 对底层容器接口的适配,则说明适配器不必总以虚函数和指针出现。模板与成员对象也能建立适配关系;默认使用 deque 不代表永远只能使用它。模式描述的依然是两个接口之间的语义转换。
中介者:把对象之间的协作规则集中起来
P17 讨论多个对象互相联动的情况。界面上修改控件,需要更新数据模型;模型从其他入口发生变化,又需要更新界面。如果控件直接认识模型细节,模型也直接操作具体控件,就形成复杂的双向依赖。
更换一批界面元素时,数据模型也可能被迫变化。对象增多后,问题不止是某两个类之间的耦合,而是一张协作关系网。
课程用白板画出五个对象之间的多条联系,再引入中心对象 M。一个对象需要影响另一个对象时,先通知 M,由 M 决定接下来通知谁、调用什么操作。各个协作者不再分别保存其他协作者的具体依赖。
中介者没有消灭真实的运行时协作。对象 A 的动作仍可能影响 B,只是 A 不再必须知道 B 的具体类型和调用细节,关系由中介者集中管理。类图中的协作者也不一定必须继承同一个基类;真正需要保留的是交互关系的隔离。
类图之外,通知协议才是工作量所在
讲者特别强调,GoF 类图没有规定消息如何表达。真实系统必须回答:哪个对象发生了变化,变化的是哪个属性,新值是什么,这个事件应当影响哪些对象?
数据绑定模块就是一个具体例子。它同时认识界面属性和模型属性,知道两者如何映射。如果再结合观察者,模型变化可以触发绑定对象,由它更新控件;观察者负责变化通知,中介者负责协作规则,两者可以配合使用。
工程实现还要处理回环。模型更新界面后,界面的变化通知可能再次写回模型。如果没有区分用户输入和程序性更新,也没有比较新旧值或限定一次更新过程,双向绑定就可能反复触发。这不是增加一个中介者类便能自动解决的问题。
集中控制也意味着复杂度向一个地方汇集。如果所有页面、业务流程和消息都由一个全局中介对象协调,它就可能成为新的维护瓶颈。可以按业务流程或协作范围拆分,让每个中介者只承担一组内聚的交互规则。
课程没有给出“对象超过几个就使用中介者”的数量公式。关系简单、变更稳定时,直接调用往往更清晰;只有协作密集且变化导致修改四处传播时,额外的协议和间接层才更有理由存在。
四种模式的区别,落在依赖的方向上
| 模式 | 典型的两侧 | 间接层主要承担什么 |
|---|---|---|
| 门面 Facade | 外部调用者与子系统内部 | 提供面向目标的入口,隐藏内部协作 |
| 代理 Proxy | 客户与原本可访问的服务 | 控制访问方式、成本或位置 |
| 适配器 Adapter | 客户期待的接口与已有接口 | 转换调用协议及其语义 |
| 中介者 Mediator | 多个互相协作的对象 | 集中交互规则,减少直接相互引用 |
门面与中介者都可能调度多个对象,区别在于前者围绕子系统对外入口,后者围绕内部同事对象的协作。代理与适配器都可能包装一个旧对象,区别在于前者通常保留服务接口并控制访问,后者有意满足另一套目标接口。
代理与装饰器也可能具有几乎相同的持有与转发结构。装饰器强调逐层增加职责,代理强调对目标服务的访问安排。一次真实设计可能同时具有多种意图,不必为了给它唯一命名而忽略业务问题。
读完这一组,更实用的做法是先画出当前依赖,再标记哪一类变化正在传播。只有明确需要保护哪一侧、隔离哪种决定,新增的那一层才有清楚的职责。
来源
资料处理范围:四讲音轨全文转录与阅读,并核对门面 5 张、代理 6 张、适配器 8 张、中介者 6 张抽样画面。文章按边界意图重组;权限、异常、并发、协议语义与回环分析为整理补充。