适配器模式:类适配器 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接口,完全不关心底层是华氏度还是别的什么。适配器默默做了转换。
小技巧:如果转换逻辑比较复杂,可以把转换函数单独抽出来,不要全塞在适配器里。我曾经在一个项目里把单位转换、数据校验、日志记录全写在适配器里,结果适配器变得臃肿不堪。后来拆成几个小类,清爽多了。
适配器模式的核心逻辑
为了让你更直观地理解适配器模式的结构,我画了一张图:
这张图展示了适配器模式的核心:客户端只跟目标接口打交道,适配器在中间做翻译。被适配者完全不知道适配器的存在,它只管做好自己的事。
避坑指南
适配器模式看着简单,但用不好也会踩坑。我分享几个经验:
- 别滥用适配器。我曾经在一个项目里,为了「优雅」地复用代码,给几乎每个旧类都写了适配器。结果适配器比原始类还多,维护起来痛不欲生。后来重构时发现,大部分适配器其实可以合并,或者直接修改原始接口。
- 注意性能开销。对象适配器有间接调用,类适配器可能引入虚函数。如果适配器在热点路径上频繁调用,性能损耗不可忽视。我遇到过适配器被每秒调用百万次的场景,后来改成了内联函数加模板的方式。
- 接口设计要稳定。适配器一旦写好,目标接口就别轻易改了。否则所有适配器都得跟着改,工作量巨大。
总结一下:适配器模式是解决接口不兼容问题的利器。类适配器适合简单场景,对象适配器更灵活。STL里到处都是适配器的影子,你每天都在用。记住——适配器是为了让旧代码和新系统和平共处,不是为了创造新的复杂性。