适配器模式:类适配器 vs 对象适配器,STL中的适配器应用

适配器模式,说白了就是「让不兼容的东西能一起工作」。我刚开始接触这个模式时,觉得它就是个包装器——把已有的接口包装成客户端期望的样子。后来在项目中用多了才发现,这玩意儿远不止「包装」那么简单。

举个例子。你手头有个老旧的充电器,接口是Micro USB的,但新手机用的是Type-C。怎么办?买个转接头。这个转接头就是适配器。它不改变充电器本身,也不改变手机,只是让两者能连上。

在C++里,适配器模式解决的就是这类问题。你有一个现成的类,它的接口和客户端需要的不匹配。你不想改这个类(可能它是第三方库,也可能是遗留代码),那就写个适配器。

两种适配方式:类适配器 vs 对象适配器

适配器模式有两种实现方式:类适配器和对象适配器。它们各有千秋,我分别说说。

类适配器:用多重继承硬接

类适配器通过多重继承来实现。它同时继承目标接口和待适配的类,然后在目标接口的方法里调用待适配类的方法。

// 待适配的类:老式打印机
class OldPrinter {
public:
    void printOld(const std::string& text) {
        std::cout << "[OldPrinter] " << text << std::endl;
    }
};

// 目标接口:现代打印机
class ModernPrinter {
public:
    virtual void print(const std::string& text) = 0;
    virtual ~ModernPrinter() = default;
};

// 类适配器:继承两边
class PrinterAdapter : public ModernPrinter, private OldPrinter {
public:
    void print(const std::string& text) override {
        // 内部调用老式接口
        printOld(text);
    }
};

这种方式的优点是代码少,直接继承就能用。但缺点也很明显——它把适配器和被适配者绑死了。你想想看,如果OldPrinter有多个子类,类适配器就无能为力了。

注意:C++的多重继承容易引发菱形继承问题。我建议只在适配器这种简单场景下使用,别搞复杂的继承链。

对象适配器:用组合解耦

对象适配器不继承被适配者,而是持有它的指针或引用。这样更灵活,也符合「组合优于继承」的设计原则。

// 对象适配器:持有被适配者的实例
class ObjectAdapter : public ModernPrinter {
private:
    OldPrinter* printer_;  // 组合方式
public:
    explicit ObjectAdapter(OldPrinter* p) : printer_(p) {}
    
    void print(const std::string& text) override {
        printer_->printOld(text);
    }
};

我个人习惯优先使用对象适配器。为什么?因为你可以传入OldPrinter的任何子类,甚至可以在运行时切换被适配对象。这在类适配器里是做不到的。

核心区别:类适配器是「我是谁」——我既是目标接口,又是被适配者。对象适配器是「我有什么」——我持有被适配者,调用它的能力。

STL中的适配器:你每天都在用

其实STL里到处都是适配器,只是你可能没意识到。我列几个最常见的:

适配器 作用 底层容器
std::stack 适配容器为栈(LIFO) deque(默认)
std::queue 适配容器为队列(FIFO) deque(默认)
std::priority_queue 适配容器为优先队列 vector(默认)
std::reverse_iterator 反转迭代器方向 任意双向迭代器
std::back_inserter 适配迭代器为尾部插入 支持push_back的容器

拿std::stack来说,它本质上就是一个适配器。它把deque的接口包装成只有push、pop、top这几个方法。你想想看,如果没有适配器,每次用栈都得自己控制deque的push_back和pop_back,多麻烦。

// STL stack 的简化实现(对象适配器)
template <typename T, typename Container = std::deque<T>>
class MyStack {
private:
    Container c_;  // 组合底层容器
public:
    void push(const T& val) { c_.push_back(val); }
    void pop() { c_.pop_back(); }
    T& top() { return c_.back(); }
    bool empty() const { return c_.empty(); }
};

这就是典型的对象适配器。它不改变deque,只是限制了deque的接口,让使用者只能按栈的方式操作。

一个完整的C++实现示例

下面我写一个更贴近实际场景的例子。假设我们有一个老式的温度传感器,返回华氏度。但新系统需要摄氏度。我们写个适配器来解决。

#include <iostream>
#include <memory>

// 老式传感器:返回华氏度
class FahrenheitSensor {
public:
    double getTemperatureF() const {
        return 98.6;  // 华氏度
    }
};

// 目标接口:摄氏度传感器
class CelsiusSensor {
public:
    virtual double getTemperatureC() const = 0;
    virtual ~CelsiusSensor() = default;
};

// 对象适配器
class TemperatureAdapter : public CelsiusSensor {
private:
    std::shared_ptr<FahrenheitSensor> sensor_;
    
    double fToC(double f) const {
        return (f - 32.0) * 5.0 / 9.0;
    }
    
public:
    explicit TemperatureAdapter(std::shared_ptr<FahrenheitSensor> s)
        : sensor_(s) {}
    
    double getTemperatureC() const override {
        double f = sensor_->getTemperatureF();
        return fToC(f);
    }
};

// 客户端代码
void showTemperature(const CelsiusSensor& sensor) {
    std::cout << "当前温度: " << sensor.getTemperatureC() 
              << " °C" << std::endl;
}

int main() {
    auto oldSensor = std::make_shared<FahrenheitSensor>();
    TemperatureAdapter adapter(oldSensor);
    
    showTemperature(adapter);  // 输出:当前温度: 37 °C
    return 0;
}

这个例子很直观。客户端只认识CelsiusSensor接口,完全不关心底层是华氏度还是别的什么。适配器默默做了转换。

小技巧:如果转换逻辑比较复杂,可以把转换函数单独抽出来,不要全塞在适配器里。我曾经在一个项目里把单位转换、数据校验、日志记录全写在适配器里,结果适配器变得臃肿不堪。后来拆成几个小类,清爽多了。

适配器模式的核心逻辑

为了让你更直观地理解适配器模式的结构,我画了一张图:

客户端 Client 目标接口 Target 适配器 Adapter 实现 被适配者 Adaptee 调用/持有 客户端只依赖目标接口 适配器实现目标接口 适配器调用被适配者 被适配者无需修改

这张图展示了适配器模式的核心:客户端只跟目标接口打交道,适配器在中间做翻译。被适配者完全不知道适配器的存在,它只管做好自己的事。

避坑指南

适配器模式看着简单,但用不好也会踩坑。我分享几个经验:

  • 别滥用适配器。我曾经在一个项目里,为了「优雅」地复用代码,给几乎每个旧类都写了适配器。结果适配器比原始类还多,维护起来痛不欲生。后来重构时发现,大部分适配器其实可以合并,或者直接修改原始接口。
  • 注意性能开销。对象适配器有间接调用,类适配器可能引入虚函数。如果适配器在热点路径上频繁调用,性能损耗不可忽视。我遇到过适配器被每秒调用百万次的场景,后来改成了内联函数加模板的方式。
  • 接口设计要稳定。适配器一旦写好,目标接口就别轻易改了。否则所有适配器都得跟着改,工作量巨大。

总结一下:适配器模式是解决接口不兼容问题的利器。类适配器适合简单场景,对象适配器更灵活。STL里到处都是适配器的影子,你每天都在用。记住——适配器是为了让旧代码和新系统和平共处,不是为了创造新的复杂性。

专注资料整理