适配器模式:STL里的“万能转接头”
说实话,适配器模式是我在项目中用得最多的设计模式之一。为什么?因为现实世界里的代码,从来不会按照教科书上写的那么完美对接。你写了一个接口,别人写了一个接口,两个接口长得不一样,但功能又差不多——这时候你就需要一个“转接头”。
在STL里,适配器模式被玩出了花。我印象最深的是有一次接手一个老项目,里面用了大量的std::vector,但新来的同事非要改成std::list,说插入删除快。结果改到一半发现,很多地方用了operator[],list不支持。最后怎么解决的?用了一个适配器,把list包装成支持随机访问的样子。嗯,虽然性能上有点代价,但至少代码能跑了。
适配器模式的核心思想
说白了,适配器模式就是“接口转换”。你有一个类A,它提供的接口是push()、pop(),但你的客户代码只认识insert()、remove()。怎么办?写一个适配器类,把A包装一下,对外暴露客户想要的接口。
在STL里,适配器模式主要分两种:
- 类适配器:通过多重继承实现,适配器同时继承目标接口和待适配类。
- 对象适配器:通过组合实现,适配器内部持有待适配对象的引用或指针。
我个人更倾向于对象适配器。为什么?因为组合比继承更灵活,而且不会遇到C++多重继承的那些坑——比如菱形继承、虚继承,想想就头疼。
STL里的适配器三兄弟
STL里最经典的适配器有三个:stack、queue、priority_queue。它们本质上都是对底层容器的包装。
| 适配器 | 默认底层容器 | 对外接口 | 实际调用的底层操作 |
|---|---|---|---|
| std::stack | std::deque | push(), pop(), top() | push_back(), pop_back(), back() |
| std::queue | std::deque | push(), pop(), front(), back() | push_back(), pop_front(), front(), back() |
| std::priority_queue | std::vector | push(), pop(), top() | push_back(), pop_back(), front()(经堆调整) |
你看,这三个适配器做的事情一模一样:把底层容器的接口“翻译”成另一种语义。比如stack的push(),底层调的是push_back(),但对外暴露的是“压栈”的概念。这就是适配器模式的精髓——语义转换。
类适配器 vs 对象适配器:代码说话
咱们直接看代码。假设你有一个老旧的OldPrinter类,它只有一个printText(const std::string&)方法。现在新系统要求用print(const std::string&)接口。你不想改老代码,那就写个适配器。
类适配器版本
// 老接口
class OldPrinter {
public:
void printText(const std::string& text) {
std::cout << "[OldPrinter] " << text << std::endl;
}
};
// 新接口(抽象基类)
class Printer {
public:
virtual void print(const std::string& text) = 0;
virtual ~Printer() = default;
};
// 类适配器:多重继承
class PrinterAdapter : public Printer, private OldPrinter {
public:
void print(const std::string& text) override {
// 接口转换:print -> printText
printText(text);
}
};
这个写法很直接,但有个问题:如果OldPrinter的构造函数需要参数怎么办?多重继承的初始化顺序容易搞乱。我曾经在一个项目里见过有人用类适配器,结果因为基类初始化顺序的问题,调试了整整两天。从那以后,我基本只用对象适配器。
对象适配器版本
// 对象适配器:组合方式
class PrinterAdapter : public Printer {
private:
OldPrinter oldPrinter; // 持有老对象的实例
public:
void print(const std::string& text) override {
oldPrinter.printText(text);
}
};
你看,代码更简洁,而且没有继承的耦合。你想换底层实现?直接换成员变量就行。比如你想把OldPrinter换成NewPrinter,只需要改适配器内部,对外接口完全不变。
核心区别一句话总结:类适配器用继承,对象适配器用组合。我推荐对象适配器,因为组合比继承更灵活,更不容易出幺蛾子。
STL源码里的适配器实现
咱们看看STL里std::stack是怎么实现的。源码简化后大概长这样:
template <class T, class Container = std::deque<T>>
class stack {
public:
// 适配器核心:把底层容器的接口重新包装
void push(const T& value) {
c.push_back(value); // 底层调用deque的push_back
}
void pop() {
c.pop_back(); // 底层调用deque的pop_back
}
T& top() {
return c.back(); // 底层调用deque的back
}
bool empty() const {
return c.empty();
}
size_t size() const {
return c.size();
}
private:
Container c; // 组合一个底层容器对象
};
这就是一个标准的对象适配器。它内部组合了一个Container对象,然后把push_back、pop_back、back这些操作,重新包装成push、pop、top。语义完全变了,但底层操作没变。
一个小技巧:你可以给stack指定不同的底层容器。比如用std::stack<int, std::vector<int>>,底层就变成vector了。但要注意,vector没有pop_front(),所以queue不能用vector做底层。STL在编译期会检查这些约束,如果底层容器不支持所需操作,编译直接报错。
适配器模式在STL里的其他应用
除了stack、queue、priority_queue这三个明显的适配器,STL里还有一些不那么明显的适配器应用:
- std::reverse_iterator:把正向迭代器适配成反向迭代器。底层调的是
operator--,对外暴露的是operator++。 - std::back_insert_iterator:把容器的
push_back()适配成迭代器的operator=。这样你就可以用std::copy往容器尾部插入数据。 - std::function:把各种可调用对象(函数指针、lambda、函数对象)适配成一个统一的类型。
我记得有一次写一个日志系统,需要支持多种输出目标:控制台、文件、网络socket。每个目标的接口都不一样。我就写了一个适配器层,把不同的输出接口统一成log(const std::string&)。这样上层代码完全不用关心底层是写文件还是发网络,换输出目标只需要换适配器就行。
适配器模式的设计原则
用适配器模式的时候,有几个原则我建议你记住:
- 接口隔离:适配器只暴露客户需要的接口,不要把所有底层接口都透传出去。比如stack只暴露push/pop/top,不暴露底层deque的insert/erase。
- 单一职责:适配器只做接口转换,不要在里面加业务逻辑。我曾经见过有人在适配器里加缓存、加日志,结果适配器变得又臭又硬,完全失去了“转接头”的灵活性。
- 优先组合,少用继承:对象适配器比类适配器更灵活,更容易测试,也更容易维护。
避坑指南:我曾经在一个项目里看到有人用适配器模式来“掩盖”设计缺陷。比如一个类的接口设计得很糟糕,他们不是去重构那个类,而是写一个适配器来“修复”接口。结果适配器越写越多,最后整个系统变成了一团乱麻。适配器模式是用来对接外部系统的,不是用来给自己擦屁股的。如果你的代码内部接口设计不合理,请直接重构,不要用适配器来打补丁。
适配器模式的知识图谱
下面这张图展示了适配器模式在STL中的核心逻辑和关系:
什么时候该用适配器模式?
我总结了几种典型场景:
- 你想复用已有的类,但它的接口和你的系统不匹配。比如你有一个排序算法库,它只接受
std::vector,但你的数据存在std::list里。写一个适配器,把list包装成vector的样子(当然,性能上要付出拷贝的代价)。 - 你想让几个不相关的类协同工作。比如你的系统需要支持多种日志输出,每个输出类的接口都不一样。写一个适配器层,统一接口。
- 你想给现有类增加一些功能,但又不想修改它的源码。比如你想给
std::vector增加一个push_front()方法,但你不能修改STL源码。写一个适配器,包装vector,提供push_front()接口(内部用insert(begin(), value)实现)。
个人经验:适配器模式最适合用在系统边界上。比如你的系统要对接第三方库,或者要对接老系统的遗留代码。在这些边界上,适配器模式能帮你把“脏活累活”隔离在一个小范围内,不让它污染你的核心业务逻辑。
好了,关于STL里的适配器模式,就聊这么多。记住一句话:适配器是转接头,不是万能胶。用对了地方,它能帮你省下大把时间;用错了地方,它只会让你的代码变得更复杂。
公众号:蓝海资料掘金营,微信deep3321