适配器模式在 C++ 里有一层特殊待遇:很多面试题喜欢问,很多入门书把它放在“结构型模式”里一笔带过,但真正到了工程现场,你很可能已经写过适配器代码,只是没给它起名。我之前在项目里既要接国产数据库的 C 接口,又要兼容本地旧日志库,七七八八的胶水代码写了不少,一度觉得“模式都是浮云、能跑就行”。直到有次架构评审,leader 盯着我那几百行业务里的 if/else 说:“你不觉得这些该收口成适配层吗”,我才重新回头把适配器模式的各种用法捋了一遍,结果发现 C++ 里的适配器模式远不止教科书上那一张类图。
先说清楚适配器模式到底解决了什么问题:接口不匹配。业务侧想用一套稳定的接口,底层库各自的姿态却五花八门——有的给你函数指针回调,有的要求你手动管理句柄,有的错误码体系跟你的异常框架完全对不上。适配器站在中间,把双方的差异翻译成彼此能理解的语言。这篇文章我会从经典对象适配器讲起,再展开模板适配器、函数式适配器、标准库容器适配器这些变体,最后用一个第三方 C 接口接入 C++ 项目的真实案例收尾。适合刚把 C++ 用于工程化开发、正在为接口对接头疼的读者,也适合自己封装过库、想知道怎么系统化设计适配层的开发者。
1. 适配器的本质是替调用方解决接口不匹配,不是多包一层
1.1 接口不匹配的三种典型形态
接口不匹配看着简单,实际拆开有这么三种情况,处理思路完全不同。
第一种叫语义不匹配。底层给你华氏度,业务要摄氏度;底层返回 error code -1、-2,业务层想抛异常;底层用毫秒时间戳,你上层全用人性化字符串。这种不匹配是最常见的,改一处两头都不合适,只能在中间翻译。
第二种是调用风格不匹配。A 库要求你先 init 再 start,用完了还要 stop 再 deinit;B 库要求你注册回调、等事件通知,过头了还得 cancel;C 库干脆是单例管理器,给你个全局对象直接怼。业务层只想要一个 start()/stop() 的直观接口,不想记忆下层那一整套流程。适配器就是把这套流程收进去,让调用方的代码长成业务该有的样子。
第三种是生命周期不匹配。很多 C/C++ 库会给你一个句柄,要么是 OPEN 状态需要按顺序释放,要么内部持有共享资源要求引用计数。业务代码要时刻担心“我到底该在哪一步释放”、“析构和库的释放顺序对不对”。适配器最擅长消化的就是这类问题——把句柄包装成 RAII 对象,析构逻辑写进适配器,业务侧从此眼不见心不烦。
1.2 适配器与门面、代理、策略的分工误区
我见过不少人把适配器模式当成“万能封装层”,最后代码里啥都往里塞,搞得类名带个 Adapter 但功能其实是门面(Facade)。这俩是有分工的:适配器解决的是两个既有模块之间的接口转译,多半发生在“新旧库切换”或“外部接入”的边界上;门面则是给一个复杂子系统提供一个简化的统一入口,不改变内部的语义,只降低使用难度。代理(Proxy)解决的是访问控制问题,比如懒加载、权限校验、并发安全,它保存的是接口的“形状”,只是偷偷在调用前后插了逻辑。策略(Strategy)则是算法层面的替换,目标是同一个接口换实现,而不是两个接口之间做转换。
换句话说,适配器改变的是接口的“形状”,门面简化的是“入口”,代理控制的是“访问”,策略替换的是“行为”。这几个模式在 C++ 里经常搭配出现,但你在写代码时心里得清楚每一层到底在干什么。要是把内部坐标体系的转换、权限校验、算法选择全写进同一个 Adapter 类里,这层就会越变越臃肿,最终变成谁都不敢动的大泥球。
2. 经典对象适配器:组合与继承两条路,我多数选组合
2.1 继承式适配器的代码很“教科书”,但容易把不变量绑死
教科书里的类适配器长这样:新接口和旧实现各占一个基类,适配器同时继承两边,基类里的公开方法在新接口方法里做转译。用 C++ 写出来大概是:
class LegacyUdpClient { public: void open(int port); int send(const char* data, size_t len); void shutdown(); }; class ITransport { public: virtual ~ITransport() = default; virtual void connect(const Endpoint& ep) = 0; virtual Result send(Bytes data) = 0; }; class UdpAdapter : public ITransport, private LegacyUdpClient { public: void connect(const Endpoint& ep) override { LegacyUdpClient::open(ep.port); } Result send(Bytes data) override { int ret = LegacyUdpClient::send(data.data(), data.size()); if (ret < 0) return Result::Failure; return Result::Ok; } };这种写法在代码结构图里很清爽,一旦上了生产环境我就开始提心吊胆。继承把两个类的生命周期死死绑在一起:LegacyUdpClient 如果有析构副作用,你可能被迫提前调用 shutdown() 或者面对资源没释放的告警;如果旧类里还有别的公开方法,它们也会被继承下来“裸奔”,适配器根本没有办法约束调用方绕过接口直接用底层能力。更麻烦的是多重继承带来的语义重叠,两边一旦有同名成员,编译期冲突就能让人研究半天。
2.2 组合式适配器:持有对方实例,把控制权重握在手里
我实际项目里九成适配器都写组合式,也就是持有一个被适配者的实例,把需要翻译的逻辑包在成员函数里。同上面的场景,组合版是这样的:
class UdpAdapter : public ITransport { public: explicit UdpAdapter(LegacyUdpClient impl) : legacy_(std::move(impl)) {} void connect(const Endpoint& ep) override { legacy_.open(ep.port); } Result send(Bytes data) override { int ret = legacy_.send(data.data(), data.size()); return ret < 0 ? Result::Failure : Result::Ok; } private: LegacyUdpClient legacy_; };优势很明显。第一,适配器对底层实例的控制是显式的——我可以决定暴露哪个方法、屏蔽哪个方法,LegacyUdpClient 的其他能力根本不会污染上层。第二,构造参数允许调用方自己装配,适配器不关心底层实例是怎么来的,这给测试留了口子:我甚至可以传一个 mock 的 LegacyUdpClient 进来。第三,组合天然支持接口的局部适配——我只适配需要的那几个方法,不需要为了“完整”而把所有底层能力都搬一遍。
这里要专门提一个注意点:适配器的接口类,也就是上文的 ITransport,析构函数一定要声明为 virtual。很多人写接口基类不习惯加这个,等到把 Adapter 存成 ITransport 指针、delete 时只析构了基类子对象,整个 Legacy 对象就漏析构了。我在代码评审里见过不止一次,排查半天发现是接口基类没加虚析构,这一类问题靠静态检查工具能查出来一部分,但还是建议形成习惯,凡是要被多态使用的基类,第一行写virtual ~Interface() = default;。
2.3 双向适配器:需求真需要时才做,别为了对称美加代码
双向适配器就是把 A 的接口换成 B,同时把 B 的接口换成 A,两个方向都提供转换。听着很完整,实际场景少得可怜,通常意味着两个模块是互相调用的耦合关系。我在日志框架里做过一次:内部业务模块既可能输出到 spdlog,也可能输出到旧版自研日志,两边要共用一套配置对象,于是写了个配置适配器,能从旧配置对象生成新格式配置,也能反过来转。
做双向适配器有个教训:不要为了“对称”而做,要看业务是否真的有三个方向的数据流转。凡是只有一个方向在用的,就只写一个方向。没被调用的转换代码就是最危险的代码——它表面上是可用能力,实际上从上线第一天起就没有人测试过,等某天有人真去调用,才发现边界场景全没处理。
3. 模板化适配器:把动态多态换成编译期“鸭子接口”
3.1 模板适配器的开胃菜:谁长得像接口,谁就是接口
C++ 模板天然带有一种“鸭子类型”特征:只要类型支持某种操作,模板就能用它。这给了适配器另一种实现思路——不定义一个抽象基类,不搞虚函数表,而是规定“你要有这些成员函数,签名长这样就够了”。写出来的模板适配器大概是:
template <typename Transport> class ClientBase { public: explicit ClientBase(Transport transport) : transport_(std::move(transport)) {} bool DoHandshake(const Endpoint& ep) { // 只要 T 有 connect / send / close 就行 transport_.connect(ep); if (!transport_.send(greeting_)) return false; return true; } private: Transport transport_; };调用方只需要保证传入的类型具备 connect、send、close 这些成员函数,哪怕它跟你的接口基类毫无继承关系都能用。这种适配器在很多底层驱动、设备 SDK 对接场景里特别常见,尤其当你不想让上层代码依赖某个抽象基类、又需要同时对多种第三方库提供支持时,它几乎是最好的选择。
3.2 用 C++20 Concept 给模板适配器加上“显式契约”
裸模板有个痛点是编译期错误信息极其感人。你传了个类型进去,它没有 send 方法,编译器报出来的错误可能是一长串 template instantiation traceback,新手能看懵。C++20 引入 Concept 之后就舒服多了,可以直接声明这个适配器对类型的要求:
template <typename T> concept TransportLike = requires(T& t, const Endpoint& ep, Bytes data) { { t.connect(ep) } -> std::same_as<void>; { t.send(data) } -> std::convertible_to<int>; t.close(); }; template <TransportLike Transport> class ClientBase { // ... };这一层概念约束带来的好处不光是编译期检查更友好,它实际上把适配器的“接口契约”显式写出来了——哪些操作是必须的、返回类型能转成什么,都看得清清楚楚。这跟经典对象适配器的虚函数接口本质是一个目标,只是把运行时多态换成编译期多态,性能更好、可读性也不差。
3.3 模板适配器适合哪里,又在哪里碰壁
模板适配器的最大优势是零虚函数开销,在性能敏感的路径(帧处理、高频通信、数值计算)里优势很明显;同时它不强迫第三方类型继承你的基类,减少了侵入性。
缺点是也很明显:所有适配逻辑都会占用编译时间和代码尺寸;模板报错信息再友好也不如虚函数接口清晰;更关键的是,模板无法直接用于动态库的二进制接口。你如果做一个插件系统,想在运行时加载不同的实现,模板适配器先出局,必须回到虚函数接口上。所以我现在的选择习惯是:需要运行时插件化或跨 DLL 边界,就用对象适配器;只是本地模板拼接、性能敏感、类型在编译期就确定,就用模板适配器。
4. 函数式适配器变体:std::function、lambda 与 C 回调的包装
4.1 把裸函数指针回调包装成带上下文的 lambda
C 风格回调是适配器经常要处理的对象,常见形态是这样的:某个库要你注册一个回调,调用时会传事件码和一个 void* 上下文指针:
void register_legacy_callback(void (*cb)(int code, void* userdata), void* userdata);业务层想要的是一个事件处理器对象,希望事件发生的时候直接调成员函数。适配器要做的,就是把“函数指针对 + 上下文指针”这种别扭的签名,包装成“接收一个回调对象”的现代风格。最省事的写法是用一个 lambda 去适配:
auto handler = std::make_shared<EventHandler>(); register_legacy_callback( [](int code, void* userdata) { auto* h = static_cast<EventHandler*>(userdata); h->OnEvent(code); }, handler.get());这里有两个坑要说清楚。第一,register_legacy_callback最后一个参数是 void*,生命周期必须覆盖到注销回调之前,你用 shared_ptr 保底是明智的,但如果库内部在某个异步线程里持有 userdata,适配器层就必须明确记录注销时机,不能光靠 shared_ptr 撑。第二,lambda 捕获了 userdata 但要转换成函数指针,得保证 lambda 是“无捕获转换”的,所以上面用了双向转换的写法,把 userdata 显式通过参数传递,避免依靠捕获列表。这类适配我一般单独抽一个 manager 类管理生命周期,不让调用方拿着裸指针自己玩。
4.2 std::function 当统一可调用对象的“适配接口”
std::function 本质上就是个运行时多态的“可调用对象容器”,任何 lambda、函数指针、仿函数都能统一装进去。它在适配器场景里的角色很像一个万能插座:你不关心对方原本是什么,只要它调用起来长这样就行。
class EventBus { public: using Handler = std::function<void(const Event&)>; void Subscribe(const char* eventType, Handler handler) { handlers_[eventType].push_back(std::move(handler)); } };用 std::function 做适配接口有个好处:事件订阅方可以传成员函数加 this、传带捕获的 lambda、传普通函数,EventBus 不用为每种情况重载。但也有代价:std::function 对象本身要占内存,调用有间接跳转成本,偶尔还有堆分配开销——标准库实现会针对小对象做小缓冲区优化,但大捕获 lambda 仍然会分配堆。如果你的订阅路径每秒被调用十万次,建议评估直接用模板化适配器,或者限制 Handler 只能是无捕获 lambda。
4.3 把同步 API 适配成异步:promise/future 与回调桥接
这类变体很容易被忽略,但它确实是适配器的常见玩法。比如某个 C 库的接口是“你提交一个异步任务,完成时回调通知”,但你业务层就想要一个阻塞的、返回结果的函数。最直白的适配就是用 promise 把回调桥接起来:
std::future<Result> QueryDataAsync(QueryRequest req) { auto promise = std::make_shared<std::promise<Result>>(); auto future = promise->get_future(); int rc = legacy_submit_task(req, [promise](const char* data, int len) { promise->set_value(ParseResult(data, len)); }); if (rc != 0) { promise->set_exception(std::make_exception_ptr(RuntimeError("submit failed"))); } return future; }这个适配方向解决的核心问题是接口调用风格不匹配——库是事件驱动的,但业务逻辑更需要同步的确定性。反过来也有,把同步调用转成异步,在 IO 密集场景里同样很有用。做这种适配时我有一条原则:适配层只做接口形态的翻译,不要在里面夹带业务逻辑。谁翻译、谁执行任务、谁解析结果,各司其职,否则你的 promise 代码会写到一半就开始处理业务数据,后面想复用就难了。
5. 标准库自带适配器:栈、队列、迭代器反转给我的启发
5.1 std::stack、std::queue 的本质就是“容器适配器”
很多人学 C++ 标准库时没注意,STL 里std::stack、std::queue、std::priority_queue的官方称呼就是 container adapter。这三个容器并不自己实现存储,它们只是把底层容器(默认 deque 或 vector)包装成 LIFO、FIFO、堆序这几种受限接口。所以你可以直接指定底层容器:
std::stack<int, std::vector<int>> s; std::queue<int, std::list<int>> q; std::priority_queue<int, std::vector<int>, std::greater<int>> pq;这给我一个非常直接的启发:适配器的职责本来就是“约束可见接口、转换行为模型”。栈适配器并没有发明栈,它只是把 deque 的所有公开方法挡在门外,只放 push、pop、top 几个口,让用户不能越界操作。你封装第三方库时同样可以这样做——不是把所有底层能力都搬上去,而是按当前业务需要的操作集合,精心暴露最小、最安全的接口。
5.2 std::reverse_iterator 是怎么做接口反转的
拿std::reverse_iterator举例,它把正向迭代器的递增变成递减、递减变成递增,重新定义了迭代器契约。从适配器视角看,一个正向迭代器的“形状”和你需要的反向迭代器“形状”并不一致,反向迭代器通过包装之后,上层就能用一套统一的迭代器语义去遍历了。
这种“接口形状转换”正是适配器模式的核心精髓。它在标准库里的实现干净利落,只调整操作行为,不改变数据本身。我在封装旧接口时也会提醒自己:适配层尽量不要复制数据,优先通过转换访问方式去适配。如果每次适配都要深拷贝一遍内部数据结构,那性能损耗会抵消掉接口统一带来的全部收益。
5.3 从标准库学到的组合原则:适配器应能层层叠加
标准库的容器适配器还告诉我们一个原则:适配器是可以叠起来的。你用 stack 包住一个 deque,你还能再用别的适配器包住 stack,只要每一层保持接口一致,嵌套就没有问题。这其实给了工程上一个很实用的建议:设计适配器时,不要把它做成“只能做一次转换”的死结构。每层适配器最好只解决一个不匹配维度,语义不匹配就只做语义转换,风格不匹配就只做风格转换,让两层适配器可以各司其职、按需组合。否则,你的 Adapter 类会越写越长,到最后里面塞了几十种互不相干的转换逻辑,想拆都难。
6. tdengine C 接口接入 C++:一个完整的适配层设计实例
6.1 C 接口给 C++ 项目带来的三个头疼点
前阵子做一个指标采集服务,数据要落到 TDengine。倒查相关技术资料时,发现 C/C++ 原生接口主要是一堆 C 风格调用:连接库的句柄、建吸收参绑定、每次执行返回错误码。这三个特征写业务时特别难受:
第一,句柄管理全手动。打开连接要 TAOS*,准备语句要 TAOS_STMT*,用完必须 taos_stmt_close、taos_close 一个个释放,中间一旦抛异常就容易漏。第二,错误处理靠返回码。每个 C 函数返回值不是 0 就是负数,业务里要到处判断if (rc != 0),代码一层套一层。第三,绑定参数时要自己维护缓冲区的生命周期。C 接口只认指针,如果传入 std::string 临时对象的 data(),等真正执行语句的时候内存早就没了。
这其实就是教科书级的“接口不匹配”。底层是 C 风格的过程式接口,上层是 C++ 的面向对象 + 异常 + RAII 风格,中间缺一个适配层。
6.2 用 RAII 包装句柄,把错误码统一翻译成异常
我做的第一件事,是把所有 C 句柄包成 RAII 对象。大致结构是这个思路:
class StmtHandle { public: explicit StmtHandle(TAOS* conn) : stmt_(taos_stmt_init(conn)) { if (!stmt_) { throw std::runtime_error("taos_stmt_init failed"); } } ~StmtHandle() { if (stmt_) { taos_stmt_close(stmt_); } } StmtHandle(const StmtHandle&) = delete; StmtHandle& operator=(const StmtHandle&) = delete; TAOS_STMT* get() const noexcept { return stmt_; } private: TAOS_STMT* stmt_; };这样连接管理、语句生命周期的责任就被适配层接管了。业务代码里不会再有TAOS_STMT* stmt裸指针到处传,也不需要记得在析构路径上手动调用 close。真正执行写入时,适配层把 taos_stmt_prepare 之类的调用封装成带异常的成员函数:
class TdEngineWriter { public: void Prepare(std::string_view sql) { int rc = taos_stmt_prepare(GetStmt(), sql.data()); if (rc != 0) { throw DbError(taos_stmt_errstr(GetStmt())); } } void Execute() { int rc = taos_stmt_execute(GetStmt()); if (rc != 0) { throw DbError(taos_stmt_errstr(GetStmt())); } } private: StmtHandle stmt_; };适配层不再返回错误码,而是把每一种出错的可能翻译成异常,异常里带上底层错误字符串。这一转换看似简单,实际价值非常高:业务代码从“每个调用都要查一下返回值”变成“正常路径无脑写,异常路径由 catch 统一兜底”,开发效率和可维护性完全是两个层次。
6.3 参数绑定里的生命周期适配:最容易出内存 bug 的地方
TDengine 的绑定参数要填一个结构体,里面存的是类型、数据指针、长度。传统的思路是把 std::string 的 data() 传进去。但这里有个经典的适配陷阱:C API 的 bind 结构体只保存指针,不保存 std::string 对象本身,如果 std::string 对象在 bind 之后、execute 之前析构了,内部缓冲区就悬空了。
正确做法是让适配层负责把参数的生命周期延伸到 execute 之后。一个做法是在写入器里用成员变量长期持有参数对象:
class TdEngineWriter { // ... std::vector<char> tagBuffer_; int32_t value_; };每次 Prepare 时把字符串拷贝到 tagBuffer_,再让 bind 结构体指向 tagBuffer_.data(),exec 之前不重新分配。这样做表面上多了一次拷贝,但换来了缓冲区的确定性管理。我在写这个适配层时特意加了注释:这条规则不允许绕过,防止后人拿临时 string 的 data() 往 C 接口里塞。这种细节如果不写清楚,合作开发的同事十有八九会踩一遍。
6.4 这类适配层的测试策略
第三方 C 接口适配层最怕的是“裸奔”——没有测试直接上生产,接口又不稳定,出了问题无法定位是底层 bug 还是适配层 bug。我的做法是写一套不连真实服务的形态测试:把 TAOS* 这类句柄用轻量桩件替换,桩件里返回预设的错误码,专门验证适配层的异常翻译逻辑;再跑一小套集成测试,连真实数据库验证句柄释放顺序和生命周期逻辑。适配层是边界代码,值得拥有独立测试。没有测试的适配器就像没人检查的翻译官,两边数据对不上时你根本不知道是该怀疑它还是怀疑数据源。
7. 适配器项目的常见翻车点与我的改进习惯
7.1 为不存在的第三种实现设计适配器,是最典型的过度设计
刚接触模式时很容易犯一个错:一听说要用适配器,马上画一个接口类,再为当前唯一实现写一个适配器,然后继续预留两个空实现等着将来扩展。结果过了半年,那两个空实现连一行代码都没写,接口类倒是因为各种历史原因被钉死,改一个签名要牵动一堆调用方。我的经验是:先写组合式适配器,只为一个现实中的底层对象做转换;只有当第二个接入方真正出现、并且接口明显不匹配时,再引入抽象接口。适配器的价值是它适配的两端都真实存在的时候才体现的,给空气留接口没有意义。
7.2 错误转换时丢了上下文,排查成本成倍增加
适配器必然涉及错误信息的转换,但很多人的转换只写“返回 false”或者“抛 runtime_error”,底层到底是什么原因全部吞掉。到头来线上报错,你只知道写入失败,不知道是连接断了还是参数越界还是权限不足,只能回去翻日志。我的习惯是错误转换必须保留三段信息:底层返回码、底层错误字符串、当前执行的操作。封装成异常时把这三段拼进 what() 里,哪怕只是临时的,也能把排查时间缩短一大半。
7.3 适配器要当黑盒测,不能只看转换逻辑通则
适配器的测试容易只写“接口映射对不对”的乐观用例,忽略异常路径。实际上适配器里最容易出错的就是释放顺序、错误码映射、缓冲区生命周期这些边界逻辑。我的做法是给适配器配一套专门的失败注入测试:构造一个会抛异常的底层桩件,让适配器走到一半就中断,验证它析构时仍然不会泄漏、不会崩溃;再构造一个返回各种错误码的底层桩件,验证每个错误码都能被映射到对应异常。这类测试看起来烦琐,但适配器是所有数据流动的必经之路,一旦这里出问题,影响面是全局性的。
7.4 把“最小适配原则”写进代码评审清单
写了这么多年适配器,我现在给自己定的规矩很简单:只适配两端接口之间真正不一致的最小集合。语义不一致,就翻译语义;生命周期不一致,就管理生命周期;风格不一致,就转换风格。不要顺手把业务逻辑也塞进来,不要为了“统一”把不该拉平的差异也拉平。一个模块如果同时要接三个库,理想的适配层是三个独立的适配器,而不是一个大而全的 God Adapter——每个适配器只做一件事,组合方式留给上层自由选择。
我在实际项目里最受益的习惯,就是每写完一个适配器,都反问自己三句话:它适配的两端各自真实存在吗?它转化的每一行逻辑,是不是都在解决具体的不匹配?如果底层库明天换掉,是不是只动适配器,业务代码一行不改?三句话都答得上来,这个适配器才是值得留下的代码。适配器模式在 C++ 里有这么多变体,但它们的共同信条始终是:让接口的差异停留在边界层,让业务代码安心做业务。