适配器模式:STL里的“万能转接头”

说实话,适配器模式是我在项目中用得最多的设计模式之一。为什么?因为现实世界里的代码,从来不会按照教科书上写的那么完美对接。你写了一个接口,别人写了一个接口,两个接口长得不一样,但功能又差不多——这时候你就需要一个“转接头”。

在STL里,适配器模式被玩出了花。我印象最深的是有一次接手一个老项目,里面用了大量的std::vector,但新来的同事非要改成std::list,说插入删除快。结果改到一半发现,很多地方用了operator[],list不支持。最后怎么解决的?用了一个适配器,把list包装成支持随机访问的样子。嗯,虽然性能上有点代价,但至少代码能跑了。

适配器模式的核心思想

说白了,适配器模式就是“接口转换”。你有一个类A,它提供的接口是push()pop(),但你的客户代码只认识insert()remove()。怎么办?写一个适配器类,把A包装一下,对外暴露客户想要的接口。

在STL里,适配器模式主要分两种:

  • 类适配器:通过多重继承实现,适配器同时继承目标接口和待适配类。
  • 对象适配器:通过组合实现,适配器内部持有待适配对象的引用或指针。

我个人更倾向于对象适配器。为什么?因为组合比继承更灵活,而且不会遇到C++多重继承的那些坑——比如菱形继承、虚继承,想想就头疼。

STL里的适配器三兄弟

STL里最经典的适配器有三个:stackqueuepriority_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()(经堆调整)

你看,这三个适配器做的事情一模一样:把底层容器的接口“翻译”成另一种语义。比如stackpush(),底层调的是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_backpop_backback这些操作,重新包装成pushpoptop。语义完全变了,但底层操作没变。

一个小技巧:你可以给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&)。这样上层代码完全不用关心底层是写文件还是发网络,换输出目标只需要换适配器就行。

适配器模式的设计原则

用适配器模式的时候,有几个原则我建议你记住:

  1. 接口隔离:适配器只暴露客户需要的接口,不要把所有底层接口都透传出去。比如stack只暴露push/pop/top,不暴露底层deque的insert/erase。
  2. 单一职责:适配器只做接口转换,不要在里面加业务逻辑。我曾经见过有人在适配器里加缓存、加日志,结果适配器变得又臭又硬,完全失去了“转接头”的灵活性。
  3. 优先组合,少用继承:对象适配器比类适配器更灵活,更容易测试,也更容易维护。

避坑指南:我曾经在一个项目里看到有人用适配器模式来“掩盖”设计缺陷。比如一个类的接口设计得很糟糕,他们不是去重构那个类,而是写一个适配器来“修复”接口。结果适配器越写越多,最后整个系统变成了一团乱麻。适配器模式是用来对接外部系统的,不是用来给自己擦屁股的。如果你的代码内部接口设计不合理,请直接重构,不要用适配器来打补丁。

适配器模式的知识图谱

下面这张图展示了适配器模式在STL中的核心逻辑和关系:

STL适配器模式知识图谱 适配器模式 类适配器(继承) 对象适配器(组合) std::stack 底层: deque std::queue 底层: deque std::priority_queue 底层: vector std::reverse_iterator 底层: 正向迭代器 接口隔离 单一职责 优先组合 核心:接口转换,语义不变

什么时候该用适配器模式?

我总结了几种典型场景:

  • 你想复用已有的类,但它的接口和你的系统不匹配。比如你有一个排序算法库,它只接受std::vector,但你的数据存在std::list里。写一个适配器,把list包装成vector的样子(当然,性能上要付出拷贝的代价)。
  • 你想让几个不相关的类协同工作。比如你的系统需要支持多种日志输出,每个输出类的接口都不一样。写一个适配器层,统一接口。
  • 你想给现有类增加一些功能,但又不想修改它的源码。比如你想给std::vector增加一个push_front()方法,但你不能修改STL源码。写一个适配器,包装vector,提供push_front()接口(内部用insert(begin(), value)实现)。

个人经验:适配器模式最适合用在系统边界上。比如你的系统要对接第三方库,或者要对接老系统的遗留代码。在这些边界上,适配器模式能帮你把“脏活累活”隔离在一个小范围内,不让它污染你的核心业务逻辑。

好了,关于STL里的适配器模式,就聊这么多。记住一句话:适配器是转接头,不是万能胶。用对了地方,它能帮你省下大把时间;用错了地方,它只会让你的代码变得更复杂。


公众号:蓝海资料掘金营,微信deep3321