1. 接口设计的第一性原理:契约、边界与成本
1.1 先把"接口"这个词掰开揉碎
今年年初我接手了一个数据采集模块,头文件里躺着一千多行代码,公开了四十多个类,编译一次要等三分钟。我花了三天时间梳理调用关系,最后发现业务方真正依赖的入口只有三个。剩下三十七个类全是内部实现细节,却以公开身份暴露在头文件里,等于把整个模块的内脏摊开给所有人看。
这就是C++接口设计最典型的问题:开发者把"接口"理解成了"头文件里写的所有东西",而不是"我承诺给外部调用方的稳定契约"。接口不是代码的组织形式,而是模块与模块之间的边界。这个边界划得好,模块内部怎么折腾都无所谓,调用方永远只面对一个稳定的门面;边界划得差,调用方被迫理解你的实现细节,改一行内部代码都可能引发连锁反应。
C++的接口设计之所以比其它语言更麻烦,是因为它同时存在三层契约:
| 契约层级 | 约束内容 | 被破坏后的表现 |
|---|---|---|
| 源码级 | 函数签名、类成员、模板形参 | 编译期直接报错,还算友好 |
| 链接级 | 符号名称、类内存布局、导出符号 | 链接报错,或者更糟:链接通过但运行期崩溃 |
| 运行时级 | 虚函数表、异常规范、资源所有权 | 崩溃、内存泄漏、行为诡异且极难定位 |
很多初级开发者只关注第一层,导致模块升级的时候库能编过,一跑就崩。这个后面在 ABI 部分详细展开,这里先记住一个观点:接口设计本质上是在回答三个问题——调用方要做什么?实现方可以自由改什么?两边共同遵守的约定是什么?
1.2 最小接口原则:接口是负债,不是资产
我见过太多团队把接口数量当成工作量的证明。"我们模块暴露了五十个API,功能很全",这话在我听来简直是灾难预告。每个公开接口都是一笔持续支付的负债:
- 它是兼容性承诺,你以后改了它就要负责到底;
- 它是ABI锁定点,内存布局一变,所有依赖你编译产物的模块全部重建;
- 它是理解成本的来源,接口越多,调用方越不知道从哪入手;
- 它是测试用例的锚点,每多一个接口就意味着多一批维护测试。
所以好的接口设计过程,第一步永远不是"加接口",而是"删接口"。我在设计新模块时有个习惯:先写出一份完整的接口草案,然后挨个问自己——这个函数如果砍掉,有没有替代方案?如果没有一个调用方会因此写不出代码,就砍掉。有个业界常用的说法:接口是给外人用的,实现细节留给自己的才是好设计。
这个原则在执行层面有一个很直接的检验标准:模块的公开头文件里,应该只包含调用方需要知道的东西。 如果头文件里出现了某个类的私有成员、某个只在cpp内部使用的工具函数、某个实现特有的前置声明,那说明边界已经破了。
1.3 接口设计的两条铁律:不暴露拥有的对象,不暴露实现策略
这两条是我在实践中反复踩坑得出的教训。
不暴露拥有的对象的意思是,不要在公开接口里返回指向内部容器的引用或指针。比如 const std::vector<int>& getData() const 这种写法,表面高效,实际上把内部存储策略写进了接口契约。你哪天想把 std::vector 换成 std::deque 或自定义存储结构,接口就断了。更稳妥的做法是返回 std::span(C++20)或者一对迭代器,甚至直接提供 visit(callback) 让调用方以回调方式消费数据。
不暴露实现策略的意思是,不要在接口上体现"这个东西是怎么实现的",只表达"这个东西能做什么"。比如一个网络模块,你不应该暴露 tcp_send_impl、udp_send_impl 这样的函数,而应该只暴露 send(const std::string& topic, const Payload& payload)。实现策略是模块内部的自由,一旦上了接口就变成所有人的约定,你再也换不掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 头文件与实现分离:Pimpl、前向声明和编译期解耦
2.1 Pimpl惯用法:把私有成员彻底藏进cpp
模块接口设计的第一个实操课题,就是怎么让头文件里的内容无限逼近"纯接口"。
如果你直接在一个类的头文件里声明私有成员变量,比如:
cpp复制// widget.h
#include <string>
#include <vector>
class Widget {
public:
Widget();
void draw() const;
private:
std::string title_;
std::vector<int> data_;
int width_ = 0;
int height_ = 0;
};
头文件就必须包含 <string> 和 <vector>,任何包含 widget.h 的文件都会被拖入这两个头文件的解析成本。更麻烦的是,你每次加一个成员变量,所有依赖这个头文件的编译单元都得重新编译。这在大型项目里是编译时间失控的最大元凶之一。
Pimpl(Pointer to Implementation)惯用法就是把私有成员塞进一个不完整类型指针指向的实现类里:
cpp复制// widget.h
#include <memory>
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
Widget(const Widget& other);
Widget& operator=(const Widget& other);
void draw() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
cpp复制// widget.cpp
#include "widget.h"
#include <iostream>
#include <string>
#include <vector>
struct Widget::Impl {
std::string title_;
std::vector<int> data_;
int width_ = 0;
int height_ = 0;
void draw() const {
std::cout << title_ << " " << width_ << "x" << height_ << "\n";
}
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
Widget::Widget(const Widget& other)
: impl_(std::make_unique<Impl>(*other.impl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*impl_ = *other.impl_;
}
return *this;
}
void Widget::draw() const {
impl_->draw();
}
2.2 Pimpl的几个关键细节
第一次写Pimpl的人最容易踩两个坑。
第一个坑:析构函数必须在cpp里定义。 因为 std::unique_ptr<Impl> 在析构时需要看到 Impl 的完整定义,如果析构函数在头文件里内联(~Widget() = default),编译器生成析构代码时 Impl 还是只声明未定义的类型,直接报 static_assert 错误。所以必须把析构函数声明放头文件,定义放cpp文件。
第二个坑:拷贝语义要自己实现。 unique_ptr 是不可拷贝的,所以 Widget 的拷贝构造和拷贝赋值会被隐式删除。如果你需要深拷贝,必须手动声明并实现。上面的代码里我写了拷贝构造和拷贝赋值,这里是Pimpl类里最重要的一段代码。
Pimpl的收益非常实在:
- 头文件不再include任何实现依赖,编译时间大幅下降;
- 修改
Impl的成员不影响任何调用方编译; - 二进制兼容性增强,成员变量怎么改都不影响类在外的布局。
代价是每次调用多一层间接跳转,以及需要手动管理拷贝语义。在现代CPU面前,这层间接跳转几乎可以忽略,它换来的编译期解耦和ABI稳定性非常值。
2.3 前向声明的正确使用方式
除了Pimpl,另一个编译期解耦的工具是前向声明。核心规则只有一条:能用指针或引用的地方就不用值类型,能用前向声明的地方就不include头文件。
比如:
cpp复制// service.h
class Logger;
class Service {
public:
explicit Service(Logger* logger);
void start();
private:
Logger* logger_;
};
这里 Logger 只需要前向声明,因为 Service 只持有它的指针。只有当你在cpp里调用 logger_->info(...) 的时候,才需要包含 logger.h。这个原则让头文件之间的依赖图保持稀疏,模块之间的耦合自然就降下来了。
但要注意,前向声明不是万能的。继承体系里基类必须是完整类型;按值持有成员时必须是完整类型;标准库容器里存值类型时也需要完整类型。所以前向声明通常和Pimpl搭配使用:外部接口用前向声明保持轻量,内部实现用Pimpl隔绝细节。
2.4 C++20 Modules:下一代接口组织方式
聊头文件与实现分离,绕不开C++20引入的Modules。它试图从语言层面解决头文件的问题,用 export 和 import 替代 #include:
cpp复制// math.cppm
export module math;
export int add(int a, int b) {
return a + b;
}
cpp复制// main.cpp
import math;
int main() {
return add(1, 2);
}
Modules的编译模型和头文件完全不同:模块内部只编译一次,导入方拿到的是经过语义分析的结果,而不是原始的token流。所以编译速度、宏隔离、接口封闭性都远胜传统头文件。
但目前它的生态还不够成熟。编译器支持参差不齐,构建系统支持程度不一,第三方库几乎都还没有提供模块化的接口文件,意味着你依然逃不开头文件。我的建议是:可以把Modules作为追踪方向,在实际工程里,先把Pimpl、前向声明这些传统手段用到极致,已经能解决大部分接口设计问题。
3. 静态派发与动态派发:模板接口和虚函数接口的取舍
3.1 虚函数接口的真实代价
面向对象设计里,接口最常见的载体是抽象基类加纯虚函数:
cpp复制class Reporting {
public:
virtual ~Reporting() = default;
virtual void report(const Metric& metric) = 0;
};
这种方式的好处是运行时多态:你可以把不同的实现塞进同一个容器,在运行时决定调用哪个。容器里放 std::unique_ptr<Reporting>,调用方只需要面对基类接口。
但它是有代价的:
- 每次虚函数调用都要通过虚函数表间接跳转,在热路径上可能影响分支预测;
- 编译器无法内联虚函数调用,很多微小的优化机会直接从窗口溜走;
- 所有实现必须继承同一个基类,这意味着类型之间的耦合被写进了接口;
- ABI极脆弱,虚函数表布局一变,旧二进制全线崩溃。
虚函数接口只适合在两种场景用:模块边界处需要运行时插件化的地方,以及实现数量不多且几乎不变的地方。
3.2 模板接口:编译期多态的隐式契约
模板接口是C++另一个维度的接口设计。它不需要基类,任何满足要求的类型都可以作为参数传入:
cpp复制template <typename Reporter>
void runReport(Reporter& reporter, const Metric& metric) {
reporter.report(metric);
}
这里 Reporter 的类型要求是隐式的:只要能调用 report 就行。C++20之前没有任何约束检查,传进来一个没有 report 方法的类型,报错信息能把人看崩溃。C++20的 concept 解决了这个问题:
cpp复制template <typename T>
concept Reporter = requires(T& t, const Metric& m) {
{ t.report(m) } -> std::same_as<void>;
};
template <Reporter R>
void runReport(R& reporter, const Metric& metric) {
reporter.report(metric);
}
这下接口契约有了显式表达,而且报错清晰得多。模板接口的优势在性能上极其明显:所有调用在编译期完成解析,天然支持内联,没有任何间接跳转。它让"优雅"和"高效"同时成立。
代价是:模板必须写在头文件里(或者显式实例化),实现细节被迫公开;接口一旦定型,修改会产生全局重新编译;不同实例化产生不同代码,二进制体积会膨胀。
3.3 实际项目里怎么选
我个人的决策框架是这样:
- 性能敏感的通用算法,比如容器、排序、数值计算 → 模板接口;
- 模块边界的插件化扩展点,比如日志后端的注册、事件处理器的分发 → 虚函数接口;
- 两者都想要的场景 → 类型擦除。
类型擦除是个折中方案,核心思路是用一个非模板的外部包装类,内部用动态派发转发到任意实例化的模板子类上。最经典的例子是 std::function,它可以用同一个类型存储所有可调用对象。
一个简易版实现:
cpp复制#include <memory>
#include <utility>
class Drawable {
public:
template <typename T>
Drawable(T obj) : self_(std::make_shared<Model<T>>(std::move(obj))) {}
void draw() const {
self_->draw();
}
private:
struct Concept {
virtual ~Concept() = default;
virtual void draw() const = 0;
};
template <typename T>
struct Model final : Concept {
explicit Model(T obj) : data_(std::move(obj)) {}
void draw() const override {
data_.draw();
}
T data_;
};
std::shared_ptr<const Concept> self_;
};
调用方写起来非常干净:
cpp复制struct Circle {
void draw() const { /* ... */ }
};
struct Square {
void draw() const { /* ... */ }
};
std::vector<Drawable> shapes;
shapes.emplace_back(Circle{});
shapes.emplace_back(Square{});
for (const auto& s : shapes) {
s.draw();
}
类型擦除的代价是内存分配(shared_ptr)和一层间接调用,但它换来的是"模板的通用性 + 虚函数的美观接口",在有性能要求但不极致的场景下,这是性价比很高的方案。
4. 模块化边界与依赖方向:命名空间、符号可见性与内部隐藏
4.1 依赖方向决定架构的上限
接口设计到模块级别,真正要操心的是依赖方向。一个模块可以开放多少接口是一回事,这些接口允许谁依赖是另一回事。
我见过最典型的坏味道是环状依赖:模块A的头文件引用了模块B,模块B的头文件又引用了模块A。这种代码在单文件编译阶段没人觉得有问题,但一旦引入并行构建、增量编译,或者模块A和B被拆成不同的动态库,问题就全来了——链接顺序、静态初始化、头文件循环包含,到处是坑。
设计依赖边界时,我通常遵循两个判断标准:
- 依赖方向必须指向抽象方向,具体实现依赖接口,而不是反过来;
- 模块之间的依赖关系必须无环,有环就让重构无限困难。
具体说,如果模块A需要调用模块B的功能,你应该思考:A是真的依赖B这个具体实现,还是依赖B提供的接口?如果是后者,把接口定义抽到一个独立的头文件甚至独立的小模块(通常是几十行接口声明,不带实现),让A依赖接口,B实现接口。这个简单的调整会让整个依赖图清晰很多。
4.2 命名空间:模块的对外门面
命名空间是C++模块接口的第一层标识。命名空间设计得是否合理,直接决定了调用方理解模块的成本。
一些实践建议:
- 命名空间和模块名保持一致,一看名字就知道这是哪个模块的代码;
- 顶层命名空间不要太多,通常一个产品、一个框架对应一到两个顶层命名空间就够了;
- 内部细节不要和公开接口混在一个命名空间里,可以放
detail子命名空间:
cpp复制namespace mylib {
// 公开接口
void run();
namespace detail {
// 内部实现细节
struct Helper { ... };
}
}
- 不要在头文件里写
using namespace mylib;,把所有符号粗暴地倒进全局命名空间,等于放弃了命名空间的边界功能。
4.3 符号可见性:让没必要的符号不被导出
接口设计不能只管头文件,还得管链接层的符号可见性。在Windows上,动态库导出符号需要 __declspec(dllexport),这天然逼着你只导出公开接口。但在Linux的ELF平台上,默认行为是动态库所有非static符号全部导出,导致你cpp文件里定义的内部函数全都成了动态库的导出符号,污染全局符号表。
解决方案是编译选项加 -fvisibility=hidden,然后对真正要导出的公开接口显式标注可见性:
cpp复制#if defined(_WIN32)
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __attribute__((visibility("default")))
#endif
class MYLIB_API Widget {
public:
void draw();
};
这样头文件里没标 API 的实现类方法、内部辅助函数,都不会进入动态库导出列表,既减小了符号表体积,也避免了符号冲突。
4.4 匿名命名空间和静态函数
还有一个经常被忽略的细节:cpp文件内部的辅助函数,加匿名命名空间比加 static 更好。匿名命名空间里的符号会获得内部链接,不会与其它编译单元冲突:
cpp复制// helper.cpp
namespace {
int internal_convert(int value) {
return value * 2;
}
}
int public_function(int x) {
return internal_convert(x);
}
这比把 internal_convert 定义成文件级 static 函数要规范一点,因为匿名命名空间里的类型也可以自由声明,而 static 只能修饰函数和简单对象,不能修饰类型定义。
5. ABI稳定性:接口设计中看不见的隐形炸弹
5.1 从一个真实崩溃说起
有一次我在公司维护一个底层库,头文件里加了一个新的非虚成员函数,重新编译后发布,客户直接替换,没重新编译自己的程序。结果一跑就崩溃,排查了半天,最后发现是两个编译单元对类内部布局的理解不一致:客户那边的代码按旧头文件编译,成员偏移还是旧的;新库代码按新头文件编译,成员偏移变了。同一个对象在两个编译单元里看到的布局不一样,数据全错位。
这就是ABI不兼容的核心问题。ABI是二进制层面的接口约定,它比源码层面的接口更底层、更隐蔽:源码不报错,链接能通过,但运行期的内存布局错位让程序以最诡异的方式崩溃。
5.2 哪些操作会破坏ABI
| 操作 | 是否破坏ABI | 说明 |
|---|---|---|
| 添加/删除/重排非静态数据成员 | 是 | 改变类内存布局 |
| 修改虚函数声明、增加虚函数 | 是 | 改变虚函数表布局 |
| 修改函数签名 | 是 | 改变符号名 |
| 修改默认参数 | 是 | 调用方在编译期已嵌入旧默认值 |
| 在类末尾添加非虚成员函数 | 否 | 不改变已有成员偏移 |
| 修改函数实现逻辑 | 否 | 实现细节不影响二进制接口 |
| 添加非虚成员函数到已有类 | 视情况 | 不改变布局则安全,但类总大小对齐可能变化 |
注意,修改函数实现逻辑不破坏ABI这一点,是"Inn界面与实现分离"能带来稳定性的底层原因。只要头文件不变、类布局不变、符号不变,你内部想怎么重构都行。
5.3 保持ABI稳定的工程策略
要做到长期ABI稳定,我建议按这几条来:
- Pimpl惯用法是ABI稳定之王,成员变量全在
Impl里,外部类布局永远是一个指针大小,怎么改内部成员都不影响外部ABI; - 不要在接口类里直接存储状态,接口类只保留虚函数和一个指向实现的指针;
- 虚函数表是ABI雷区,别在基类中间插入虚函数,只能追加到末尾,且发布后尽量不要动;
- 导出符号要精准控制,用
-fvisibility=hidden加显式导出标注; - 版本化与扩展点,提供版本查询接口,预留扩展槽位,而不是靠改已有接口来演进。
关于第5点,一个务实做法是在接口设计初期预留返回版本信息的能力:
cpp复制class ICore {
public:
virtual ~ICore() = default;
virtual int getVersion() const = 0;
};
调用方可以根据版本走不同的逻辑分支,后期新增功能时,用带版本判断的新接口过渡,不直接破坏旧接口的语义。
6. 错误处理与接口契约:异常、错误码、expected的实战取舍
6.1 错误处理是接口契约的一部分,不是函数细节
接口设计时很多人只关心参数和返回值,忽略了一个关键维度:函数出错时怎么通知调用方。错误处理策略一旦定下来,就是接口契约的一部分,改起来比改函数签名还麻烦。
C++里常见的错误处理方式至少有三种:
- 异常:调用方不处理就向上抛,错误信息丰富,但对实时性要求高的环境不友好,开启
-fno-exceptions的项目直接不可用; - 错误码/枚举:调用方必须显式检查,无法忽略,但容易漏处理,而且错误信息表达能力弱;
std::optional/std::expected:返回值表达"可能没有结果",调用方不检查就无法使用结果,表达能力强,适合纯函数式的接口设计。
我个人的倾向是:模块内部尽量用异常处理异常情况,模块边界处用 expected 或错误码暴露错误。 因为异常跨模块边界传播时,如果动态库是用不同编译选项或者不同编译器构建的,异常处理机制的兼容性可能出问题。
6.2 expected接口的一个示范
C++23 的 std::expected 让接口的错误表达更优雅。考虑一个解析配置文件的模块接口:
cpp复制// config_parser.h
#include <expected>
#include <string>
struct ParseError {
int line;
std::string message;
};
class ConfigParser {
public:
struct Config {
std::string server;
int port = 0;
};
// 返回配置,或者返回错误描述
std::expected<Config, ParseError> parse(const std::string& content) const;
};
调用方必须显式处理错误才能拿到配置:
cpp复制auto result = parser.parse(content);
if (!result) {
fmt::println("parse failed at line {}: {}", result.error().line, result.error().message);
return;
}
auto config = std::move(*result);
这个接口的契约非常清晰:成功时拿到 Config,失败时拿到 ParseError,没有"忘了检查错误"的窗口。相比返回值和 bool ok 加输出参数的做法,阅读代码时一眼就能看出函数行为的意图。
6.3 noexcept与接口承诺
还有一个细节值得注意:noexcept 不是一个性能优化标签,它是接口契约的一部分。它向调用方承诺"这个函数不会抛出异常",这会让调用方少写一个 catch,也让编译器在优化时多一些把握(不需要生成异常展开代码路径)。
但千万不要在不确定的情况下滥用 noexcept。如果函数内部调用了可能抛异常的操作,比如 std::string 的分配、容器的插入,那它就不该标 noexcept。一旦函数实际抛出了异常,程序会直接调用 std::terminate。所以我的经验是:
- 移动构造函数、移动赋值运算符标
noexcept,这是标准库容器能正确选择移动语义的前提; - 析构函数默认不抛异常,标
noexcept没问题; - 其它函数,只有在明确知道不会抛异常时才标,不确定就不标,别为了"看起来高效"去冒险。
7. 一个完整实战:从零设计可扩展的消息总线接口
7.1 需求分析和接口草案
最后用一个完整示例,把前面所有原则串起来。假设我要设计一个异步消息总线,需求很朴素:
- 支持订阅者按主题订阅消息;
- 支持发布者向指定主题发布消息;
- 主题之间天然无耦合;
- 可以异步分发;
- 线程安全。
先忍住写实现的冲动,只看接口。按照最小接口原则和依赖方向原则,先把用户需要什么想清楚:
- 订阅者需要一个
subscribe方法,传入主题和回调; - 发布者需要一个
publish方法,传入主题和消息内容; - 为了让类型安全,消息本身可以用一个可序列化的轻量类型表达,这里用一个
std::string表示payload,真实项目中可以替换成自定义结构体并走序列化协议。
接口草案:
cpp复制// event_bus.h
#ifndef EVENT_BUS_H_
#define EVENT_BUS_H_
#include <functional>
#include <memory>
#include <string>
#include <vector>
class EventBus {
public:
using Handler = std::function<void(const std::string& topic, const std::string& payload)>;
EventBus();
~EventBus();
// 不可拷贝,但可移动
EventBus(const EventBus&) = delete;
EventBus& operator=(const EventBus&) = delete;
EventBus(EventBus&&) noexcept;
EventBus& operator=(EventBus&&) noexcept;
// 订阅指定主题,返回订阅ID,用于取消订阅
uint64_t subscribe(const std::string& topic, Handler handler);
// 取消订阅
void unsubscribe(uint64_t subscription_id);
// 发布消息,会同步调用所有匹配订阅者
void publish(const std::string& topic, const std::string& payload);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
#endif // EVENT_BUS_H_
这个接口体现了几个设计决策:
- 用
unique_ptr<Impl>做Pimpl,外部类布局只有指针大小,实现可随意演进; - 删除拷贝保留移动,因为内部必然持有互斥锁、订阅表,拷贝没有意义;
- 订阅返回
uint64_tID,取消订阅靠ID,而不是让用户保存一个复杂句柄对象; - 回调用
std::function做类型擦除,订阅者那边写lambda、函数指针、可调用对象都可以; - 整个接口只有四个方法,最小且完整。
7.2 实现要点
实现里最重要的部分是线程安全和订阅注销。一个简化实现:
cpp复制// event_bus.cpp
#include "event_bus.h"
#include <map>
#include <mutex>
struct EventBus::Impl {
std::mutex mutex_;
std::map<std::string, std::vector<std::pair<uint64_t, Handler>>> subscriptions_;
std::map<uint64_t, std::string> topic_by_id_;
uint64_t next_id_ = 0;
uint64_t generate_id() {
return ++next_id_;
}
};
EventBus::EventBus() : impl_(std::make_unique<Impl>()) {}
EventBus::~EventBus() = default;
EventBus::EventBus(EventBus&&) noexcept = default;
EventBus& EventBus::operator=(EventBus&&) noexcept = default;
uint64_t EventBus::subscribe(const std::string& topic, Handler handler) {
std::lock_guard<std::mutex> lock(impl_->mutex_);
auto id = impl_->generate_id();
impl_->subscriptions_[topic].emplace_back(id, std::move(handler));
impl_->topic_by_id_[id] = topic;
return id;
}
void EventBus::unsubscribe(uint64_t subscription_id) {
std::lock_guard<std::mutex> lock(impl_->mutex_);
auto it = impl_->topic_by_id_.find(subscription_id);
if (it == impl_->topic_by_id_.end()) {
return;
}
auto& list = impl_->subscriptions_[it->second];
std::erase_if(list, [subscription_id](const auto& entry) {
return entry.first == subscription_id;
});
impl_->topic_by_id_.erase(it);
}
void EventBus::publish(const std::string& topic, const std::string& payload) {
// 先copy一份订阅者列表,避免在持锁状态调用外部回调造成死锁
std::vector<Handler> target;
{
std::lock_guard<std::mutex> lock(impl_->mutex_);
auto it = impl_->subscriptions_.find(topic);
if (it == impl_->subscriptions_.end()) {
return;
}
for (const auto& [id, handler] : it->second) {
(void)id;
target.push_back(handler);
}
}
for (auto& handler : target) {
handler(topic, payload);
}
}
一个非常容易踩的细节:publish 里不能在持锁状态调用回调函数。因为回调是用户代码,它可能反过来调用 subscribe 或 unsubscribe,导致死锁,或者在同一把锁上重入。解决办法是先在锁内拷贝回调列表,锁外执行回调。
7.3 这个接口还能怎么演进
发布一段时间后,需求大概率会变。几个常见的演进方向:
- 从同步分发变成异步分发,把
publish改成把消息投递到队列后立即返回,然后再开个线程消费队列; - 支持通配符主题,比如
"sys.*"匹配"sys.log"和"sys.error"; - 支持优先级订阅,让某些订阅者先收到消息;
- 支持消息过滤,只把满足条件的消息推给订阅者。
好消息是,因为接口已经用Pimpl隔离了实现,这些改动基本只发生在cpp内部,公开头文件的四行方法声明不需要变,调用方也不需要改一行代码。这就是开头说的"接口设计是契约设计"最直观的回报:你为自己争取了实现自由,同时为调用方守住了稳定承诺。
如果你在维护一个长生命周期项目,我建议认真按这个思路做一次接口审查,砍掉冗余的公开符号,把私有细节藏进Pimpl,给依赖方向画一画无环图。做过一次,后面所有迭代的舒适度都会好很多。
