Day 6:适配器模式(Adapter Pattern)

把「别人的接口」翻译成「你想要的接口」——让接口不匹配的类也能协同工作

① 今日主题

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;
}

这段代码有什么问题?随着需求继续增加,问题会越来越明显:

  • 违反开闭原则(OCP):第三方库升级、换供应商、改函数签名,全项目所有调用点都要跟着改——改一个漏一个,编译期还查不出来;
  • 违反依赖倒置原则(DIP):业务模块直接依赖「第三方库的具体函数」,而不是依赖「平台定义的抽象接口」,高层被低层库绑架;
  • 重复代码:把 double 包装成 SensorReading、补时间戳的转换逻辑,散落在几十个调用点,改一次转换规则就要改几十处;
  • 无法统一调度:新设备实现了 Sensor 接口,老设备只认识老库函数,两者没法放进同一个容器轮询,只能写 if (type == 旧设备) ... else if (type == 新设备) ... 的分支,设备种类越多分支越爆炸。

如果你能修改老库源码,直接改接口当然最干净——但现实是:第三方库你改不了,遗留系统不敢改,历史数据格式不能改。适配器模式就是为这种「改不了对方,又不愿妥协自己」的困境准备的:在两者之间加一个翻译官。

③ 核心思想

适配器模式的核心思想一句话:在「客户端期望的接口」与「已有的不兼容接口」之间,插入一个适配器对象,把一方的调用翻译成另一方听得懂的语言。

拆开看,这个模式里有两类「变化」和一类「稳定」:

  • 变化的部分:第三方库、遗留系统的接口——它们已经存在、无法修改、且各不相同;
  • 稳定的部分:客户端依赖的 Target 抽象接口——它由我们定义,由我们掌控,不随任何外部库变动;
  • 适配器:唯一同时认识「两个世界」的类,负责把 Target 的调用翻译成 Adaptee 的操作。

生活类比,你一定用过:

  • 电源转换插头:国标插座(Target)← 转换插头(Adapter)← 英标插头电器(Adaptee)。你不会去拆了电器重焊插头,而是加一个几块钱的转换头;
  • USB-C 转 HDMI 转接头:显示器只认 HDMI,笔记本只有 USB-C,转接头做信号翻译,两边都不需要改动;
  • 变压器:110V 的电器插 220V 的插座,中间加一个变压器。

回到软件工程:适配器「翻译」的不仅是函数签名,还可能是数据格式、单位(摄氏↔华氏)、坐标系统(对角坐标↔中心点+宽高)、异常风格(错误码↔抛异常)、类型体系(std::string ↔ QString)。关键点是:翻译逻辑集中在一个类里,而不是散落在每个调用点——这就是「集中变化」的思想。

💡 记住一个判断口诀:「接口不匹配,但逻辑匹配」→ 用适配器。旧库能算出你要的温度/能画出你要的矩形,只是「说法」不一样,适配器负责把话翻译对。

④ UML / 角色关系

适配器模式的结构非常清晰,只有四个角色:

classDiagram
    class Client {
        +use(target: Target)
    }
    class Target {
        <<interface>>
        +request()*
    }
    class Adapter {
        -adaptee: Adaptee
        +request()
    }
    class Adaptee {
        +specificRequest()
    }
    Client --> Target : 依赖抽象
    Target <|.. Adapter : 实现接口
    Adapter --> Adaptee : 组合(持有)
  • Target(目标接口):客户端所依赖的抽象接口,由「新世界」定义,与旧库无关。它是客户端的唯一视野;
  • Adaptee(被适配者):已有的、接口不兼容的类——第三方库、遗留代码、旧版系统。客户端不能直接使用它;
  • Adapter(适配器):实现 Target 接口,内部组合(持有)一个 Adaptee,把 Target 的调用翻译成 Adaptee 能理解的操作。它是唯一同时认识两个世界的类;
  • Client(客户端):只依赖 Target 抽象,完全不知道 Adaptee 和 Adapter 的具体存在。

谁依赖谁?Client → Target(依赖抽象);Adapter → Adaptee(组合依赖)。注意:Target 与 Adaptee 之间没有任何依赖关系——这正是解耦的关键所在。

谁创建谁?通常由工厂或依赖注入把 Adapter 以 Target 类型交给客户端;Adapter 在构造时接收 Adaptee(指针/引用或数据)。

哪里可以扩展?来了一个新的不兼容库 → 只需新增一个 Adapter 子类,Client、Target、所有已有 Adapter 一行都不用改,完全符合开闭原则。

适配器有两种实现方式,面试必问:

  • 对象适配器(推荐):Adapter 通过组合持有 Adaptee(C++17 用 std::unique_ptr)。灵活、不依赖多重继承、能适配「Adaptee 及其任意子类」(多态);
  • 类适配器(C++ 特有):Adapter 同时继承 Target 和 Adaptee(多重继承)。缺点是编译期静态绑定、与具体 Adaptee 类强耦合、C++ 多重继承易产生歧义。仅当必须覆盖 Adaptee 的虚函数行为时才考虑。

默认永远选对象适配器——这正是「组合优于继承」原则的又一次体现。

⑤ 最小 C++ 示例

场景:绘图程序升级。旧图形库 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 实战示例

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 结构体存在。数据源换掉(比如改成数据库),只要换个适配器,视图零修改。

⑧ 为什么这样设计?

回到今天的代码,从架构角度分析适配器设计带来的收益:

  • 解耦发生在哪里?Client(renderScene / QTableView)与 Adaptee(LegacyRectangle / vector<Employee>)之间不再有任何直接依赖。所有格式转换、坐标映射、类型翻译逻辑从 N 个调用点收敛到唯一一个 Adapter 类——「知道得太多」的代码被隔离了;
  • 扩展点在哪里?来了新的不兼容库(新的 Adaptee),只需新增一个 Adapter 子类。Target 接口、Client、所有已有 Adapter 全部零修改——开闭原则(OCP)的完美体现:对扩展开放,对修改关闭;
  • 依赖倒置(DIP)如何体现?高层业务代码依赖的是我们定义的 Target 抽象,而不是第三方库的具体函数。依赖的方向被反转:不再「业务依赖库」,而是「适配器依赖库、业务依赖抽象」;
  • 组合优于继承:对象适配器用组合持有 Adaptee,比类适配器的多重继承更灵活——它适配的是「Adaptee 的任意子类」(运行时多态),而不是把 Adaptee 焊死成父类型;
  • 对单元测试的帮助:接口边界清晰后,可以写一个 FakeAdaptee(模拟旧库行为)注入适配器,单独测试 Target 接口的行为;也可以对真实旧库写集成测试,测试边界一目了然;
  • 过渡期价值:老库替换期,适配器是天然隔离层。换库时只动 Adapter 内部实现(甚至不动——如果新库接口与 Target 一致),业务代码和历史调用点全部安全。

⑨ 不使用设计模式会怎样?

推演一下:假设没有适配器,采集模块直接到处调用 readTemperature(),随着时间推移会发生什么:

  • 调用点失控:全项目 40 处直接调用老库函数。某天第三方库升级改了签名(比如加了一个超时参数),40 处全部要改,漏改一处编译不过,更糟的是有些是运行期才暴露的问题;
  • 重复转换代码:每处调用都要自己把 double 包装成 SensorReading、补时间戳、做超限判断——20 个调用点就有 20 份几乎一样的胶水代码。改一次转换规则(比如单位从摄氏改华氏),要搜着改 20 处,改漏一处就是线上 bug;
  • if-else 分支爆炸:新老设备接口不同,统一轮询调度只能写 if (type == 老设备) readTemperature(...) else if (type == 新设备) sensor->read()。每接入一种设备,这个分支就膨胀一次,最后变成没人敢动的「魔鬼函数」;
  • 业务层被第三方绑架:业务代码直接 include 第三方头文件,第三方库的坏味道(全局状态、错误码、线程模型)渗透进你的业务层。想换供应商?等于重写业务;
  • 测试寸步难行:逻辑绑死真实库,无法 mock。单元测试必须连真实硬件/真实库,CI 里跑不起来,测试覆盖率形同虚设。

🚨 识别信号:当你发现自己反复写「把 A 的返回值转换成 B 的格式」的代码、或者用 if-else 按设备/厂商/库类型分发调用时——你已经站在适配器模式的门口了。

⑩ 什么时候应该使用?

适合使用(3~5 个场景):

  1. 使用第三方库/遗留系统:其接口与你的架构约定不一致,且不能或不便修改它——这是最典型、最高频的场景;
  2. 多历史系统并存统一接入:多家厂商 PLC 协议、多种数据库驱动、多个旧子系统,需要统一到一个新平台的抽象接口下(如统一 Sensor、统一 DataSource);
  3. 数据格式/单位/坐标转换集中管理:摄氏↔华氏、std::string ↔ QString、毫米↔英寸、对角坐标↔中心宽高——转换逻辑值得收敛成小适配器,而不是散落各处;
  4. 技术债过渡期隔离:老库确定要替换但还没排期,先用适配器包一层,替换时只动一个文件,风险可控;
  5. Qt 中把非 Model/View 数据源显示到视图:写 QAbstractTableModel 子类本质就是在写适配器(今天的 Qt 实战就是这个场景)。

不适合使用(2~4 个场景):

  1. 你能直接修改 Adaptee 的接口——直接改,别绕一层(YAGNI:用不到就别加);
  2. 接口本就不该统一:把「打印机」适配成「发动机」只会让接口语义失真,强行统一比不统一更糟;
  3. 翻译逻辑复杂到适配器失控:两层接口差异巨大(数据模型都不同)时,适配器会膨胀成「上帝翻译类」,此时应考虑重构数据层,或改用外观模式提供全新简化入口;
  4. 只有一处调用、无扩展预期:一个函数直接做转换就够了,为一个调用点建类属于过度设计。

过度设计提醒:适配器是「补救型」模式——它的存在本身就说明接口不统一。不要为了「统一接口」把所有类都包一层。动手前先问自己:这个接口值得统一吗?翻译逻辑未来会稳定吗?两个问题都回答「是」,才值得写适配器。

⑪ 与其他模式的区别

  • 适配器 vs 外观(Facade,Day 10 会学):这是最容易被混淆的一对。适配器改变接口——让 Adaptee「变成」客户端期望的样子(接口匹配);外观不改任何接口——它为整个子系统提供一个全新、简化的门面入口,客户端本没有预先约定。一句话:适配器是「补丁」,外观是「门面」。Qt 例子:QNetworkAccessManager 是外观(封装整个网络栈),QSortFilterProxyModel 是适配器(翻译模型接口);
  • 适配器 vs 代理(Proxy,Day 12 会学):结构相似(都持有另一个对象),但目的完全不同。适配器解决「接口不兼容」,代理解决「访问控制 / 延迟加载 / 远程调用 / 日志」——代理保持接口不变,适配器改变接口。判断标准:客户端拿到的接口变没变?Qt 的 QNetworkProxy 是代理思想(它不改变 QNetworkAccessManager 的接口);
  • 适配器 vs 桥接(Bridge,明天 Day 7 学):适配器是「事后补救」——把两个已存在的、不匹配的接口接起来;桥接是「事前设计」——把抽象与实现两个维度分离,让它们各自独立变化。适配器连接「别人家的」东西,桥接分离「自己家的」东西。

⑫ Qt 源码 / Qt 框架中的体现

适配器思想在 Qt 里无处不在,最值得记住的四个例子:

  • QSortFilterProxyModel(标准适配器):继承 QAbstractItemModel(Target),构造时接收 sourceModel(Adaptee),把「排序/过滤后的数据」翻译给视图。源码关键点:所有查询函数(data、rowCount)都先经 mapToSource() 把视图坐标映射回源模型坐标——这就是适配器的「翻译」动作;
  • QAbstractItemModel 家族(数据源适配器群):QFileSystemModel(文件系统→模型)、QSqlTableModel(SQL 表→模型)、QStringListModel(字符串列表→模型)——每一个都是「把一种外部数据源适配成 Model/View 接口」的适配器。这是 Qt 面试高频考点:视图只认识 QAbstractItemModel,任何数据想进视图都要适配;
  • QString::fromStdString() / toStdString()、QByteArray::fromStdString():C++ 标准库类型与 Qt 类型之间的小型转换适配器,每天都会用到;
  • Qt5 的 QTextCodec(历史案例):字节流与 Unicode 文本之间的编码适配器。Qt6 中移出核心模块,由 QStringConverter 接替——连「适配器本身」都会随时代演进被替换。

没有强行关联的部分: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 == 新设备) ... 式的按来源分发调用;
  • 想把已有类塞进「统一接口容器」(如 vector<Shape*> / QAbstractItemModel)但接口对不上;
  • 新老系统并存,需要「统一平台」接入多种历史数据源;
  • 某个类的接口与客户端期望只有「签名/格式」差异,逻辑本身是匹配的。

明日预告:Day 7——桥接模式(Bridge Pattern)。适配器是「事后补救」,把已存在的不匹配接口接起来;桥接是「事前设计」,把「抽象」与「实现」两个维度拆开,让它们各自独立变化。明天我们将看到:为什么 Qt 的 QStyle、QPaintEngine 是桥接思想的典型应用,以及「类爆炸」(N 种形状 × M 种画法 = N×M 个类)是怎么被桥接解决的。