九十年代写 C++。
环境比今天朴素很多。
库没有现在这么全。
工具链也没现在这么顺。
大家嘴上骂“祖传代码”。
手上还得靠它跑生产。
那会儿工程现场最常吵的词。
一个叫“复用”。
一个叫“兼容”。
你接了一个新库。
它说自己更强。
更现代。
更像“未来”。
可它不肯跟你老接口说话。
于是你开始写翻译。
开始写包装。
开始写“将就一下”。
这活儿一点也不浪漫。
它就像桌上那堆转接头。
HDMI 转 Type‑C。
国标转欧标。
丑。
但能救命。
后来 1994 年。
四个老哥把这些年大家现场写出来的“将就一下”。
收拾成一本书。
起了名字。
他们把这种翻译叫 Adapter。
你可以不背定义。
但你躲不开接口不匹配。
书先合上。
我们回到你手里这个项目。
如果你写 C++ 写得够久。
多半就见过下面这种场面。
老系统里有一套接口。
大家用得很顺手。
也没人敢动它。
然后新库来了。
功能更强。
文档更“现代”。
发布节奏也更快。
但接口完全对不上。
老世界可能只要这样:
void log(Level lvl, const std::string& msg);
新世界却甩给你这么一坨:
void write(LogRecord record);
我见过的“第一反应”通常有两种。
一种是到处写转换。
一种是在业务里塞一个开关。
if (useNewBackend) {
// ...
} else {
// ...
}
当年写得挺快。
上线也挺快。
然后你就会在某个夜里。
接到一通电话。
要你“顺手把日志字段也统一一下”。
你翻开代码。
看见一片复制粘贴的转换。
再看见一片复制粘贴的 if。
这时候你多半会想起一句老话。
“最难维护的代码,往往不是复杂算法。 是那些到处复制粘贴的‘将就一下’。”
适配器模式不玄乎。
它就是一个“转换头”。
你别去改老接口。
把新东西接上去。
让两边都以为自己在跟熟人说话。
下面我按一个老项目的时间线走一遍。
看看我们当年怎么糊。
以及后来为什么非得把这种“糊”收拢成一个模式。
1. 先说痛点:没有适配器之前,我们怎么糊这摊子?
还是从一个老项目的日常场景讲起:换日志库。
你接手的是一个活了十几年的服务。
日志这块早就有一套“祖传”接口。
大家写得已经不带脑子了。
struct Logger {
virtual void log(Level lvl, const std::string& msg) = 0;
virtual ~Logger() = default;
};
业务代码里到处都是这种一眼扫过去就略过的调用:
logger->log(Level::Info, "user logged in");
谁都知道这东西有点“上了年纪”。
但它简单。
直接。
大家也习惯了。
某一天。
运维同学看着线上一地纯文本日志。
叹了口气:
“这年头还在文件里瞎打印字符串? 要不要上个结构化、可检索、带 traceId 的新日志后端?”
你去看了一眼新库文档。
感觉确实挺香。
不仅支持异步刷盘。
还有各种字段、标签。
典型现代日志系统。
新库给你的接口,大概长这样:
struct NewBackend {
void write(LogRecord rec);
};
LogRecord 里除了 level 和文本。
还有一堆东西。
traceId。
业务 tag。
额外字段。
总之比你那俩参数“能说会道”多了。
问题也随之而来:
老代码到处都是 logger->log(lvl, msg)。
新后端只认识一坨 LogRecord。
产品已经在耳边小声提醒:
“下个大版本最好就把新日志上了哈。”
这时候如果没人跟你提“适配器”三个字。
你大概率会走一条非常常见的进化路线。
第一阶段通常就很朴素。
先把东西接上。
老接口还在那儿。
logger.log(Level::Info, "user logged in");
你想试试新后端。
就在旁边拼一下。
newBackend.write(LogRecord{Level::Info, "user logged in"});
这时候你会觉得。
挺好。
一眼看懂。
没啥负担。
然后它就开始复制粘贴了。
登录一份。
下单一份。
退款一份。
批量任务再来一份。
每一份都“差不多”。
但又都“不完全一样”。
有的地方多塞了 traceId。
有的地方给了 tag。
有的地方忘了。
等你想统一加一个字段。
或者改一下字段名。
你就会第一次体会到。
“胶水”这种东西。
流得到处都是。
才最难擦。
等你开始觉得“这有点糟糕”的时候。
第二阶段通常已经悄悄开始了:
void foo(Logger& logger) {
if (useNewBackend) {
LogRecord rec;
rec.level = Level::Info;
rec.text = "user logged in";
// ... 一些新世界才懂的字段
newBackend.write(std::move(rec));
} else {
logger.log(Level::Info, "user logged in");
}
}
刚写出来的时候。
你会觉得自己做了一个很“灵活”的抽象。
一个开关。
控制走老后端还是新后端。
看起来还能灰度发布。
再过几个月。
if (useNewBackend) 会像春天的蒲公英一样。
到处飘。
到处落地。
每一个日志调用点。
都被你认真地包上一层判断。
哪天老板说。
“老后端可以下线了。”
你打开工程。
发现要删的不是一个配置。
而是满工程的 if-else。
如果项目再活久一点。
很容易迈向第三阶段。
干脆把新世界的概念。
直接塞进老接口里。
struct Logger {
virtual void log(Level lvl,
const std::string& msg,
const std::string& traceId = "") = 0;
};
你在心里对自己说:“没事,就多加一个可选参数而已。”
然后你发现。
所有实现 Logger 的类。
都要重写一遍这个函数签名。
所有调用点。
要么多传一个 traceId。
要么假装什么都没发生。
再过两年你再看这个接口。
已经能从参数列表里读出项目考古史:
第三个参数是当年引入链路追踪时加的。
第四个参数是后来支持多租户时加的。
第五个参数没人记得是什么时候、为了啥需求塞进去的了。
到这一步,你会隐约感觉到哪儿不太对劲:
接口不匹配明明只是新老后端之间的问题。
结果整个系统里到处飘着转换代码和开关逻辑。
老接口本来简单干净,现在变成了一个“历史博物馆”。
没有适配器的年代。
大家都是这么一点一点“糊”出来的。
谁先碰到接口不对头的问题。
谁就在自己脚下打一坨补丁。
时间一长。
整个项目里究竟有几套风格。
几种写法。
谁也说不清楚。
适配器模式想做的事情。
其实就一句话。
把“接口不对头”的补丁收起来。
别让它满地都是。
把它关进少数几个专门干这活儿的类里。
让大部分业务代码继续装作。
“世界上就只有这一种接口”。
2. GoF 口味的 Adapter:把“转换”变成一等公民
GoF 对 Adapter 的定义很教科书:
将一个类的接口转换成客户希望的另外一个接口。
Adapter 模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
这段话你背不背都行。
但它说的那件事。
你大概率早就干过。
关键不是句子。
关键是把它落到代码上。
你可以把它想成三个人物。
一个是老世界。
一个是新世界。
中间那位负责翻译。
第一个是 Target。
也就是客户认的接口。
老代码只认它。
struct Logger {
virtual void log(Level lvl, const std::string& msg) = 0;
virtual ~Logger() = default;
};
第二个是 Adaptee。
你手上有个新东西。
能干活。
但接口不合群。
我先把例子写短。
但我会顺手把“真实世界会发生什么”也讲清楚。
我们先把新后端能理解的“记录”摆出来。
struct LogRecord {
Level level{};
std::string text;
};
struct NewBackend {
void write(LogRecord rec);
};
你注意这个 write(LogRecord rec)。
它是按值收的。
很多新人第一次看到会皱眉。
“这不是复制吗?”
但老项目里你见多了就知道。
日志后端十有八九会异步。
进队列。
批量刷盘。
甚至跨线程。
如果它只拿 string_view 或引用。
调用方那一行 msg 一出栈。
后端晚一点再写。
你就写进去了空气。
有些坑。
真的是靠凌晨两点的 core dump 教会人的。
第三个就是 Adapter。
它对外装作 Logger。
对内拿着 NewBackend。
你可以把它当成“门口那个翻译”。
先看最瘦的一版。
class NewLogAdapter final : public Logger {
public:
explicit NewLogAdapter(NewBackend& backend)
: backend_(backend) {}
void log(Level lvl, const std::string& msg) override {
backend_.write(LogRecord{lvl, msg});
}
业务代码里到处都是这种一眼扫过去就略过的调用:
logger->log(Level::Info, "user logged in");
谁都知道这东西有点“上了年纪”。
但它简单。
直接。
大家也习惯了。
某一天。
运维同学看着线上一地纯文本日志。
叹了口气:
“这年头还在文件里瞎打印字符串? 要不要上个结构化、可检索、带 traceId 的新日志后端?”
你去看了一眼新库文档。
感觉确实挺香。
不仅支持异步刷盘。
还有各种字段、标签。
典型现代日志系统。
新库给你的接口,大概长这样:
struct NewBackend {
void write(LogRecord rec);
};
LogRecord 里除了 level 和文本。
还有一堆东西。
traceId。
业务 tag。
额外字段。
总之比你那俩参数“能说会道”多了。
问题也随之而来:
老代码到处都是 logger->log(lvl, msg)。
新后端只认识一坨 LogRecord。
产品已经在耳边小声提醒:
“下个大版本最好就把新日志上了哈。”
这时候如果没人跟你提“适配器”三个字。
你大概率会走一条非常常见的进化路线。
第一阶段通常就很朴素。
先把东西接上。
老接口还在那儿。
logger.log(Level::Info, "user logged in");
你想试试新后端。
就在旁边拼一下。
newBackend.write(LogRecord{Level::Info, "user logged in"});
这时候你会觉得。
挺好。
一眼看懂。
没啥负担。
然后它就开始复制粘贴了。
登录一份。
下单一份。
退款一份。
批量任务再来一份。
每一份都“差不多”。
但又都“不完全一样”。
有的地方多塞了 traceId。
有的地方给了 tag。
有的地方忘了。
等你想统一加一个字段。
或者改一下字段名。
你就会第一次体会到。
“胶水”这种东西。
流得到处都是。
才最难擦。
等你开始觉得“这有点糟糕”的时候。
第二阶段通常已经悄悄开始了:
void foo(Logger& logger) {
if (useNewBackend) {
LogRecord rec;
rec.level = Level::Info;
rec.text = "user logged in";
// ... 一些新世界才懂的字段
newBackend.write(std::move(rec));
} else {
logger.log(Level::Info, "user logged in");
}
}
刚写出来的时候。
你会觉得自己做了一个很“灵活”的抽象。
一个开关。
控制走老后端还是新后端。
看起来还能灰度发布。
再过几个月。
if (useNewBackend) 会像春天的蒲公英一样。
到处飘。
到处落地。
每一个日志调用点。
都被你认真地包上一层判断。
哪天老板说。
“老后端可以下线了。”
你打开工程。
发现要删的不是一个配置。
而是满工程的 if-else。
如果项目再活久一点。
很容易迈向第三阶段。
干脆把新世界的概念。
直接塞进老接口里。
struct Logger {
virtual void log(Level lvl,
const std::string& msg,
const std::string& traceId = "") = 0;
};
你在心里对自己说:“没事,就多加一个可选参数而已。”
然后你发现。
所有实现 Logger 的类。
都要重写一遍这个函数签名。
所有调用点。
要么多传一个 traceId。
要么假装什么都没发生。
再过两年你再看这个接口。
已经能从参数列表里读出项目考古史:
第三个参数是当年引入链路追踪时加的。
第四个参数是后来支持多租户时加的。
第五个参数没人记得是什么时候、为了啥需求塞进去的了。
到这一步,你会隐约感觉到哪儿不太对劲:
接口不匹配明明只是新老后端之间的问题。
结果整个系统里到处飘着转换代码和开关逻辑。
老接口本来简单干净,现在变成了一个“历史博物馆”。
没有适配器的年代。
大家都是这么一点一点“糊”出来的。
谁先碰到接口不对头的问题。
谁就在自己脚下打一坨补丁。
时间一长。
整个项目里究竟有几套风格。
几种写法。
谁也说不清楚。
适配器模式想做的事情。
其实就一句话。
把“接口不对头”的补丁收起来。
别让它满地都是。
把它关进少数几个专门干这活儿的类里。
让大部分业务代码继续装作。
“世界上就只有这一种接口”。
3. 类适配器 vs 对象适配器:多继承这口锅,C++ 背还是不背?
这节容易把人带沟里。
因为它牵扯到一个老话题。
多继承。
GoF 的年代。
“继承”这根工具用得很猛。
所以它会告诉你。
适配器有两种写法。
一种写法很硬。
靠继承把两边焊在一起。
在 C++ 里,基本就长成多继承。
你当然可以这么写。
我当年也写过。
先把目标接口摆出来。
struct Logger {
virtual void log(Level, const std::string&) = 0;
};
再把新后端摆出来。
struct NewBackend {
void write(LogRecord);
};
然后用多继承把两边焊在一起。
// 类适配器:多继承
struct NewLogAdapter : Logger, NewBackend {
void log(Level lvl, const std::string& msg) override {
LogRecord rec{lvl, msg};
write(std::move(rec));
}
};
这段写法的“爽点”很直接。
你连 backend_ 成员都不用存。
因为你自己就是 NewBackend。
调用方也很顺。
老代码只要能拿到一个 Logger&。
它就能跑。
void process(Logger& logger) {
logger.log(Level::Info, "user logged in");
}
NewLogAdapter logger;
process(logger);
你把执行流程在脑子里过一遍。
也很清晰。
process 调 Logger::log。
虚函数落到 NewLogAdapter::log。
然后 NewLogAdapter 直接调用基类 NewBackend::write。
记录就进了新后端。
如果这世界只有你一个人写代码。
到这儿就结束了。
挺美。
但真实项目不这么温柔。
多继承最大的问题。
往往不是“能不能写”。
而是你把后厨的门。
也一起给顾客打开了。
因为 NewLogAdapter 公开继承了 NewBackend。
所以它把 write 也公开出去了。
NewLogAdapter logger;
logger.log(Level::Info, "old style");
logger.write(LogRecord{Level::Info, "new style"});
第二行那种写法。
在你做迁移的时候。
特别容易出现。
有人嫌 Logger::log 不够用。
“我就临时绕一下吧。”
有人想塞字段。
“我就直接构一个 LogRecord 吧。”
然后你就回到了第一节那种地狱。
新世界的细节开始渗漏。
开始到处长触手。
而且你还很难统一收口。
这就是我前面说的。
适配器真正值钱的地方。
是边界。
不是技巧。
多继承还有一个老毛病。
你不一定今天就撞上。
但项目一活久。
它就会来敲门。
名字冲突。
语义冲突。
甚至是“你以为你在改适配器”。
其实你在改后端。
比如哪天 Logger 也加了一个 write。
或者 NewBackend 也恰好有个 log。
你会开始看到一堆歧义。
开始写 Logger::log(...) 这种限定。
然后你的适配器就不再“像一个转换头”。
它开始像一把瑞士军刀。
所以我才会说。
类适配器不是不能用。
它是有代价。
而且这个代价通常不是你一个人的代价。
是整个团队未来几年一起付。
看起来少写了一个成员变量。
但代价也很明显:
继承层次变复杂。
多继承一不小心就踩菱形继承那些坑。
NewLogAdapter 暴露了 NewBackend 的全部公有接口。
调用方一不留神。
就绕过了 Adapter 提供的目标接口。
所以在现代 C++ 里,对象适配器基本是默认选项。
对象适配器写起来也不复杂。
就是把后厨收进来。
别让顾客看见按钮。
struct NewLogAdapter : Logger {
explicit NewLogAdapter(NewBackend& b) : backend_(&b) {}
void log(Level lvl, const std::string& msg) override {
backend_->write(LogRecord{lvl, msg});
}
NewBackend* backend_;
};
你把它想成。
我开一家小店。
门口挂的是“老接口”这块招牌。
老顾客进来。
还是按老规矩下单。
后厨我当然可以换。
我也可以用更现代的设备。
但后厨的那些按钮。
我不想让顾客看到。
对象适配器就是这个意思。
Adapter 对外只承诺 Logger。
对内拿着 NewBackend。
你不想暴露的东西,就别暴露。
生命周期也更好管。
依赖注入也更自然。
我自己的经验很朴素。
除非你确实有、而且能驾驭多继承那套家底。
否则优先考虑“继承目标 + 组合被适配者”。
4. 再看几个更接地气的例子
上面讲的是日志。
下面我换成更小、更直观的例子。
每个都短。
短到你读完不会忘。
把 FILE* 适配成“拿到就会自己关”的对象
老 C 时代开文件。
就是两步。
你开。
你记得关。
FILE* f = std::fopen("a.txt", "r");
// ...
std::fclose(f);
问题也很老。
你一早退。
你一 return。
你一 throw。
你就忘了关。
后来我们学会了一件很朴素的事。
把“成对出现的函数”。
翻译成“一个会自己收尾的对象”。
using File = std::unique_ptr<FILE, decltype(&std::fclose)>;
File open_file(const char* path) {
return File(std::fopen(path, "r"), &std::fclose);
}
调用方从此轻松。
auto f = open_file("a.txt");
// ...
你没写 fclose。
但它还是会发生。
这就是 Adapter。
它把老世界的玩法。
适配成了新世界更想要的形状。
把“返回码 + out 参数”适配成 std::optional
我见过很多祖传库。
喜欢用返回码说话。
成功给你 1。
失败给你 0。
值塞到 out 参数里。
int parse_int(const char* s, int* out);
写业务的人看着就烦。
因为他要记住太多规矩。
后来我们换个说法。
让返回值自己表达“有没有”。
std::optional<int> to_int(const char* s) {
int v{};
if (!parse_int(s, &v)) {
return std::nullopt;
}
return v;
}
这层适配器很薄。
但它很关键。
它把 out 参数和返回码。
关在门里面。
业务侧以后只处理 optional。
不再被老规矩牵着走。
STL 里最像“转接头”的一位:reverse_iterator
我喜欢拿它当例子。
因为它真的像。
你本来只有一个“往前走”的迭代器。
STL 给你拧上一个头。
你就能倒着走。
std::vector<int> v{1, 2, 3};
for (auto it = v.rbegin(); it != v.rend(); ++it) {
use(*it);
}
rbegin() 不是魔法。
它就是把 end() 那根迭代器。
适配成一个“反向迭代器”。
你不需要改 vector。
也不需要改 use。
你只是在中间加了一个转接头。
世界就通了。
这三个例子看着不搭。
一个是资源。
一个是错误。
一个是遍历方向。
但它们干的事很像。
老东西你不想动。
新写法你又想要。
中间就放一个小角色。
专门负责翻译。
下一篇我们聊装饰器模式 (Decorator)。
适配器讲完了。
你大概已经有那种感觉。
接口不对。
就做翻译。
别硬改老世界。
但还有一种更常见的现场。
接口明明对得上。
你只是想在外面“包一层”。
多做一点事。
加一点料。
还希望别人当它还是原来的那个对象。
这时候你要的就不是 Adapter。
是 Decorator。
下一篇就讲它。
讲那种“包一层但别露馅”的手艺。
5. 小结:下次再遇到“接口不对头”,先问问有没有机会做个 Adapter
回头看适配器这件事。
其实挺朴素。
早年大家就是凭直觉写一堆“胶水类 / 工具函数”。
写完能跑。
也就算了。
GoF 出来之后。
帮你把这种“专门干接口转换活儿的角色”。
拉出来。
单独起了个名字。
现代 C++ 里则充斥着各种
“RAII 包装器 / 函数包装器 / 迭代器适配器”。
它们不一定自称 Adapter。
但做的都是适配器的工作。
真正的关键,其实不是“我会写一个 Adapter”。
而是你愿不愿意把它当成一条边界。
新库的类型。
新协议的细节。
新世界那些今天看着很香、明天可能就换一茬的概念。
尽量别往老世界里灌。
让它们停在适配器这一层。
你以后做替换。
做灰度。
做重构。
才会有那种“拔插式”的从容。
对工程来说,真正值钱的不是
“我背得出 Adapter 的定义”。
而是当你下次准备在业务代码里写:
if (useNew) {
// ...
} else {
// ...
}
或者在好几个模块里复制粘贴同一坨“转换逻辑”时,
脑子里会先闪一下:
这玩意儿是不是该收拢成一个小 Adapter?
让大部分代码继续开心地活在自己的世界里。
把“接口不对头”的烦心事。
都塞进那一个类里去。
等你第一次在老项目里,
用两三个 Adapter 把一整片“胶水 if-else”剃干净。
你大概就会理解为什么 GoF 要专门给它单开一章。
这不是高大上的架构魔法。
只是把“接口不对头”这件破事儿,
当回事儿。
设计了一次。