把「别人的接口」翻译成「你想要的接口」——让接口不匹配的类也能协同工作
Day 6:适配器模式(Adapter Pattern)——将一个类的接口转换成客户端期望的另一个接口,使原本因接口不匹配而无法协作的类能够一起工作。
适配器模式是 GoF 23 种设计模式中的第 6 个,也是结构型模式的第一课。前 5 天我们学完了全部创建型模式(单例→工厂方法→抽象工厂→建造者→原型),从今天起进入结构型模式阶段(适配器→桥接→组合→装饰器→外观→享元→代理,Day 6~Day 12),关注的重点从「对象怎么创建」转向「对象怎么组织、怎么协作」。
用一句话定位今天的模式:当你的代码必须使用一个「接口对不上」的已有类(第三方库、遗留系统、旧版代码)时,不要改它的源码,也不要改你的调用点——在中间加一个适配器做翻译。
先看一个真实的开发场景:你在一家做工业控制上位机的公司。2018 年公司采购了一套第三方「老式温度采集库」,它长这样:
// 老库(第三方,无法修改源码):直接暴露一个全局函数
double readTemperature(int sensorId); // 返回裸 double:温度值
今年公司要建设统一数据采集平台,平台定义了统一的「采集设备」抽象接口,所有设备(新买的、当年买的、别人家的)都要接入:
class Sensor { // 平台统一接口(Target)
public:
virtual ~Sensor() = default;
virtual SensorReading read() = 0; // 返回结构化读数:设备ID + 数值 + 时间戳
};
问题来了:老库只认识 readTemperature(int),返回裸 double;平台要求 read() 返回 SensorReading 结构体。接口对不上,新老设备没法统一调度。
没有适配器时,初入职场的你很可能这样写「坏代码」——在采集模块里直接调用老库:
// ===== 坏代码:采集模块直接依赖老库的具体函数 =====
#include "legacy_temperature.h" // 第三方头文件
SensorReading collect(int id) {
double raw = readTemperature(id); // 直接调用老库函数
SensorReading r;
r.sensorId = id;
r.value = raw; // 每个调用点都要手工做格式转换
r.timestamp = currentTimestamp();
return r;
}
这段代码有什么问题?随着需求继续增加,问题会越来越明显:
double 包装成 SensorReading、补时间戳的转换逻辑,散落在几十个调用点,改一次转换规则就要改几十处;Sensor 接口,老设备只认识老库函数,两者没法放进同一个容器轮询,只能写 if (type == 旧设备) ... else if (type == 新设备) ... 的分支,设备种类越多分支越爆炸。如果你能修改老库源码,直接改接口当然最干净——但现实是:第三方库你改不了,遗留系统不敢改,历史数据格式不能改。适配器模式就是为这种「改不了对方,又不愿妥协自己」的困境准备的:在两者之间加一个翻译官。
适配器模式的核心思想一句话:在「客户端期望的接口」与「已有的不兼容接口」之间,插入一个适配器对象,把一方的调用翻译成另一方听得懂的语言。
拆开看,这个模式里有两类「变化」和一类「稳定」:
生活类比,你一定用过:
回到软件工程:适配器「翻译」的不仅是函数签名,还可能是数据格式、单位(摄氏↔华氏)、坐标系统(对角坐标↔中心点+宽高)、异常风格(错误码↔抛异常)、类型体系(std::string ↔ QString)。关键点是:翻译逻辑集中在一个类里,而不是散落在每个调用点——这就是「集中变化」的思想。
💡 记住一个判断口诀:「接口不匹配,但逻辑匹配」→ 用适配器。旧库能算出你要的温度/能画出你要的矩形,只是「说法」不一样,适配器负责把话翻译对。
适配器模式的结构非常清晰,只有四个角色:
classDiagram
class Client {
+use(target: Target)
}
class Target {
<<interface>>
+request()*
}
class Adapter {
-adaptee: Adaptee
+request()
}
class Adaptee {
+specificRequest()
}
Client --> Target : 依赖抽象
Target <|.. Adapter : 实现接口
Adapter --> Adaptee : 组合(持有)
谁依赖谁?Client → Target(依赖抽象);Adapter → Adaptee(组合依赖)。注意:Target 与 Adaptee 之间没有任何依赖关系——这正是解耦的关键所在。
谁创建谁?通常由工厂或依赖注入把 Adapter 以 Target 类型交给客户端;Adapter 在构造时接收 Adaptee(指针/引用或数据)。
哪里可以扩展?来了一个新的不兼容库 → 只需新增一个 Adapter 子类,Client、Target、所有已有 Adapter 一行都不用改,完全符合开闭原则。
适配器有两种实现方式,面试必问:
std::unique_ptr)。灵活、不依赖多重继承、能适配「Adaptee 及其任意子类」(多态);默认永远选对象适配器——这正是「组合优于继承」原则的又一次体现。
场景:绘图程序升级。旧图形库 LegacyRectangle 用「左上角 + 右下角两个对角点」描述矩形;新渲染引擎统一接口 Shape { draw(); }。我们写一个对象适配器,让旧矩形「假装」是一个新 Shape。
// adapter_basic.cpp —— 适配器模式最小示例(C++17,可直接编译运行)
// 编译:g++ -std=c++17 adapter_basic.cpp -o adapter_basic && ./adapter_basic
#include <iostream>
#include <memory>
#include <vector>
// ========== 1. Target:客户端期望的统一接口 ==========
// 新渲染引擎只认识 Shape:画什么、怎么画,由具体对象自己决定
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0; // 纯虚函数:所有图形都必须会画自己
};
// ========== 2. Adaptee:第三方旧图形库,接口不兼容 ==========
// 旧库用「两个对角点」描述矩形,且暴露的是具体类的具体函数
class LegacyRectangle {
public:
void draw(int x1, int y1, int x2, int y2) const {
std::cout << "[LegacyRectangle] draw(" << x1 << ", " << y1
<< ") -> (" << x2 << ", " << y2 << ")\n";
}
};
// ========== 3. Adapter:对象适配器(组合方式) ==========
// 实现 Target 接口,内部持有 Adaptee,把 draw() 翻译成旧库听得懂的四个坐标
class RectangleAdapter : public Shape {
public:
RectangleAdapter(int x1, int y1, int x2, int y2)
: legacy_(std::make_unique<LegacyRectangle>()), // 组合被适配者
x1_(x1), y1_(y1), x2_(x2), y2_(y2) {} // 保管坐标数据
void draw() const override {
// 翻译动作:把「新接口的调用」转发给「旧库的具体函数」
legacy_->draw(x1_, y1_, x2_, y2_);
}
private:
std::unique_ptr<LegacyRectangle> legacy_; // RAII:适配器析构时自动释放旧库对象
int x1_, y1_, x2_, y2_; // 坐标数据由适配器保管并传递
};
// ========== 4. Client:只依赖抽象接口 ==========
// 渲染场景函数只认识 Shape,完全不知道 LegacyRectangle 的存在
void renderScene(const Shape& shape) {
std::cout << "[Client] 开始渲染场景...\n";
shape.draw();
std::cout << "[Client] 渲染完成\n";
}
int main() {
// 客户端把适配器当成普通 Shape 使用;将来换成新库的图形也无需改动
RectangleAdapter rect(10, 10, 50, 30);
renderScene(rect);
// 旧库对象从此也能进「统一图形容器」调度 —— 这正是适配前做不到的
std::vector<std::unique_ptr<Shape>> scene;
scene.push_back(std::make_unique<RectangleAdapter>(0, 0, 20, 20));
for (const auto& s : scene)
s->draw(); // 虚调用:Shape::draw() -> RectangleAdapter::draw() -> 旧库
return 0;
}
逐段解读:
Shape 是 Target:纯虚接口,客户端只依赖它;LegacyRectangle 是 Adaptee:旧库类,接口是 draw(int,int,int,int),与 Shape 完全对不上;RectangleAdapter 是 Adapter:public Shape 实现新接口,内部用 std::unique_ptr<LegacyRectangle> 组合旧库对象(RAII,无内存泄漏),构造时接收并保管坐标;renderScene(const Shape&) 是 Client:只看到 Shape 抽象,靠虚函数多态拿到具体行为。程序输出:
[Client] 开始渲染场景...
[LegacyRectangle] draw(10, 10) -> (50, 30)
[Client] 渲染完成
[LegacyRectangle] draw(0, 0) -> (20, 20)
📌 注意 renderScene(const Shape&) 接受基类引用——适配器对象被向上转型绑定到 Shape 引用,虚函数表确保调用落到 RectangleAdapter::draw(),再由它转发给旧库。将来新库的 NewRectangle 直接实现 Shape,renderScene 一行不用改。
Qt 中最经典的适配器场景就是 Model/View 架构:视图(QTableView)只认识 QAbstractItemModel 这一个抽象接口,任何想进表格的数据源都必须「适配」成模型。下面我们把公司旧版数据源 std::vector<Employee> 适配成 QAbstractItemModel,交给 QTableView 显示——这就是一个标准的对象适配器,每个 Qt 开发者都写过。
// main.cpp —— Qt6 适配器实战:把旧版 std::vector<Employee> 数据源
// 适配成 QAbstractItemModel,交给 QTableView 显示
// 运行方式:见下方 CMakeLists.txt,Qt Creator 直接打开即可运行
#include <QApplication>
#include <QTableView>
#include <QAbstractTableModel>
#include <QStringList>
#include <QVariant>
#include <vector>
#include <string>
#include <utility>
// ========== Adaptee:旧版数据源,完全不认识 Qt 的 Model/View ==========
struct Employee {
std::string name; // 姓名
int age = 0; // 年龄
double salary = 0.0; // 月薪
};
// ========== Adapter:把旧数据源「翻译」成 Qt 视图认识的模型 ==========
class EmployeeTableModel : public QAbstractTableModel {
public:
// 构造时接管数据(右值引用 + std::move,避免拷贝)
explicit EmployeeTableModel(std::vector<Employee> data)
: employees_(std::move(data)) {}
// 行数:员工条数(视图通过这两个函数询问表格形状)
int rowCount(const QModelIndex& parent = QModelIndex()) const override {
return parent.isValid() ? 0 : static_cast<int>(employees_.size());
}
// 列数:固定 3 列(姓名 / 年龄 / 薪水)
int columnCount(const QModelIndex& parent = QModelIndex()) const override {
return parent.isValid() ? 0 : 3;
}
// 数据翻译:把员工的字段翻译成视图要显示的 QVariant —— 适配器核心
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override {
if (!index.isValid() || role != Qt::DisplayRole)
return {};
const Employee& e = employees_.at(static_cast<size_t>(index.row()));
switch (index.column()) {
case 0: return QString::fromStdString(e.name); // std::string -> QString
case 1: return e.age; // int -> QVariant
case 2: return QString::number(e.salary, 'f', 2); // double -> 格式化字符串
default: return {};
}
}
// 表头翻译:列头名称 + 左侧行号
QVariant headerData(int section, Qt::Orientation orientation,
int role = Qt::DisplayRole) const override {
if (role != Qt::DisplayRole)
return {};
if (orientation == Qt::Horizontal) {
static const QStringList headers = {QStringLiteral("姓名"),
QStringLiteral("年龄"),
QStringLiteral("薪水")};
return headers.value(section);
}
return section + 1; // 行号从 1 开始
}
private:
std::vector<Employee> employees_; // 组合 Adaptee:数据仍然存在旧容器里
};
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
// 旧版数据源(Adaptee):公司旧系统导出的员工列表
std::vector<Employee> team = {
{u8"张三", 28, 12000.0},
{u8"李四", 32, 15000.0},
{u8"王五", 25, 9500.0}
};
// Adapter:把旧数据源包装成视图认识的模型(对象适配器)
auto* model = new EmployeeTableModel(std::move(team));
QTableView view;
view.setModel(model); // 视图只依赖 QAbstractItemModel 抽象,不感知 Employee
view.setWindowTitle(QStringLiteral("Employee List —— Adapter Pattern 实战"));
view.resize(360, 240);
view.show();
return app.exec();
}
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(adapter_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # Qt 需要:自动处理 Q_OBJECT 元对象编译
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(adapter_demo main.cpp)
target_link_libraries(adapter_demo PRIVATE Qt6::Widgets)
重点:Qt 框架本身就内置了适配器,最有名的是 QSortFilterProxyModel。它实现 QAbstractItemModel(Target),内部持有「源模型」(Adaptee),把「排序/过滤后的数据」翻译给视图:
// Qt 框架内置适配器:QSortFilterProxyModel
auto* proxy = new QSortFilterProxyModel(this);
proxy->setSourceModel(model); // 绑定源模型(Adaptee)
view.setModel(proxy); // 视图拿到的是「翻译后」的模型
视图调用 data() 时,proxy 先做坐标映射(mapToSource())再向源模型查询,排序/过滤逻辑全部藏在翻译层里——视图和源模型互不知情。这正是「适配器隔离变化」的教科书案例。
Qt 5 / Qt 6 差异说明:本示例两个版本通用(Qt5 只需把 find_package(Qt6 ...) 改为 find_package(Qt5 ...))。历史上 Qt5 的 QTextCodec 是字节流↔Unicode 的编码适配器,Qt6 中移出核心模块由 QStringConverter 接替;QRegExp(正则「适配器」)在 Qt6 中被 QRegularExpression 取代。这些更迭本身就是「接口变化 → 适配器变化」的活例子。
以最小示例为主线,程序实际执行顺序:
① main() 构造适配器:RectangleAdapter rect(10, 10, 50, 30) —— 构造体内 make_unique<LegacyRectangle>() 创建旧库对象并保存四个坐标;
② 调用 renderScene(rect):形参是 const Shape&,适配器对象向上转型绑定到基类引用;进入函数后调用 shape.draw(),通过虚函数表(vtable)定位到 RectangleAdapter::draw()——多态分派发生在这里;
③ RectangleAdapter::draw() 被调用:取出保存的坐标,执行 legacy_->draw(x1_, y1_, x2_, y2_)——旧库的真实绘制逻辑此刻才执行,打印 [LegacyRectangle] draw(10, 10) -> (50, 30);
④ 回到 main():把第二个适配器放进 std::vector<std::unique_ptr<Shape>> 统一容器,遍历时每个元素再次走「虚调用 → 适配器 → 旧库」链路;
⑤ 程序退出:std::vector 析构 → unique_ptr 析构适配器 → 适配器析构时自动释放 legacy_(RAII 链式清理),全程无裸指针、无内存泄漏。
Qt 示例的执行流程(模型/视图协作):
① main() 构造 std::vector<Employee>(Adaptee,旧数据源);
② new EmployeeTableModel(std::move(team)):数据被 move 进适配器内部(组合数据源,零拷贝);
③ view.setModel(model):视图拿到 QAbstractItemModel*,开始布局、计算行高列宽;
④ 布局阶段视图反复调用 rowCount() / columnCount() 询问表格形状,随后对每个可见格子调用 data(index) 索取显示内容——每一次调用都被适配器翻译成「取出 employees_[row] 的某个字段并转成 QVariant」;
⑤ 用户滚动、调整列宽、点击表头排序(若加 proxy):视图继续向模型查询,全程不知道 Employee 结构体存在。数据源换掉(比如改成数据库),只要换个适配器,视图零修改。
回到今天的代码,从架构角度分析适配器设计带来的收益:
推演一下:假设没有适配器,采集模块直接到处调用 readTemperature(),随着时间推移会发生什么:
if (type == 老设备) readTemperature(...) else if (type == 新设备) sensor->read()。每接入一种设备,这个分支就膨胀一次,最后变成没人敢动的「魔鬼函数」;🚨 识别信号:当你发现自己反复写「把 A 的返回值转换成 B 的格式」的代码、或者用 if-else 按设备/厂商/库类型分发调用时——你已经站在适配器模式的门口了。
适合使用(3~5 个场景):
不适合使用(2~4 个场景):
过度设计提醒:适配器是「补救型」模式——它的存在本身就说明接口不统一。不要为了「统一接口」把所有类都包一层。动手前先问自己:这个接口值得统一吗?翻译逻辑未来会稳定吗?两个问题都回答「是」,才值得写适配器。
适配器思想在 Qt 里无处不在,最值得记住的四个例子:
mapToSource() 把视图坐标映射回源模型坐标——这就是适配器的「翻译」动作;没有强行关联的部分:QStyle 更接近策略/桥接思想(Day 7 会讲);QObject 信号槽是观察者思想(Day 18 会讲)。读 Qt 源码时,看到「一个类实现接口 A、内部持有对象 B、方法体里全是把 A 的调用转成 B 的操作」——这就是适配器的骨架。
Q1:适配器模式解决什么问题?有哪些核心角色?
A:解决「接口不兼容」:让客户端通过统一接口使用接口不匹配的已有类。三个核心角色:Target(客户端依赖的抽象接口)、Adaptee(被适配的已有类)、Adapter(实现 Target 并组合 Adaptee,负责接口翻译)。
Q2:对象适配器和类适配器的区别?C++ 中为什么推荐对象适配器?
A:类适配器通过多重继承同时继承 Target 和 Adaptee,编译期静态绑定、与具体类强耦合,C++ 多重继承还容易产生歧义(菱形继承问题);对象适配器通过组合持有 Adaptee(C++17 用 std::unique_ptr),支持运行时多态、可适配 Adaptee 的任意子类。遵循「组合优于继承」,默认选对象适配器,类适配器仅用于必须覆盖 Adaptee 虚函数行为的罕见场景。
Q3:适配器模式和代理模式有什么区别?
A:结构上都持有另一个对象,但目的不同:适配器改变接口使两端匹配(接口翻译);代理保持接口不变,附加控制逻辑(权限校验、延迟加载、远程调用、日志)。判断标准:客户端拿到的接口变没变——变了是适配器,没变是代理。
Q4:QSortFilterProxyModel 为什么是适配器模式?
A:它实现 QAbstractItemModel(视图期望的 Target 接口),内部持有源模型(Adaptee),把「排序/过滤后的数据」翻译给视图,视图完全不知道源模型的存在。这是 Qt 框架内置的标准适配器实例,也解释了为什么视图代码可以不感知数据来源。
Q5:什么情况下不应该使用适配器模式?
A:能直接修改 Adaptee 接口时(直接改,YAGNI);接口语义差异过大、翻译逻辑复杂到适配器失控时(考虑重构或改用 Facade);只有一处使用且无扩展预期时。适配器是补救/过渡工具,不是设计目标——它被写出来的目的,是最终可以被删掉。
需求(约 20 分钟):你的团队有一个旧版「三维距离计算库」,只提供全局函数 double dist3D(double x1, double y1, double z1, double x2, double y2, double z2);。公司新平台定义了统一几何接口:
struct Point3D { double x, y, z; };
class GeometryService { // 平台统一接口(Target)
public:
virtual ~GeometryService() = default;
virtual double distance(const Point3D& a, const Point3D& b) const = 0;
};
请完成三步:1) 用对象适配器把旧库接入新接口;2) 让客户端只通过 GeometryService 计算两个点的距离;3) 再实现一个「球面距离服务」(模拟新库,实现同一接口),验证客户端代码零修改即可切换。实现完思考:如果换库时适配器内部要改,哪几行会变?
提示 1:适配器用组合持有旧库——成员可以是函数指针/旧库对象引用,在构造时传入(依赖注入的味道)。
提示 2:客户端用 std::unique_ptr<GeometryService> 持有具体服务,多态调用 distance();写一个 printDistance(const GeometryService&) 辅助函数,体会「只依赖抽象」。
看到以下代码特征时,可以考虑适配器模式:
if (type == 旧设备) ... else if (type == 新设备) ... 式的按来源分发调用;明日预告:Day 7——桥接模式(Bridge Pattern)。适配器是「事后补救」,把已存在的不匹配接口接起来;桥接是「事前设计」,把「抽象」与「实现」两个维度拆开,让它们各自独立变化。明天我们将看到:为什么 Qt 的 QStyle、QPaintEngine 是桥接思想的典型应用,以及「类爆炸」(N 种形状 × M 种画法 = N×M 个类)是怎么被桥接解决的。