前言
本书是一份面向生产环境的现代 C++23 实践指南,不是语言教程,也不是现代化改造手记。目标读者是这样的程序员:熟悉软件的构建、测试、发布和调试流程,但需要一套可靠的方法论,在真实的设计压力下做出合理的 C++ 决策。本书关注的不是语法罗列,而是所有权、生命周期、接口设计、错误边界、并发、数据布局、性能度量与验证。
本书假定你不需要别人教你写循环或配置编辑器。你真正需要的帮助在于:一个函数应该用 std::string_view 还是 std::span<const std::byte> 来借用数据?所有权应该在哪里转移?遇到错误时应该抛异常还是返回 std::expected?什么时候用 range 管道能让算法意图更清晰?什么时候协程帧会变成生命周期隐患?什么证据才足以证明性能确实提升了?如果你想要的是特性巡览、入门教程或按头文件编排的参考手册,那这本书不适合你。
这一前提是有意为之。你不必是 C++ 专家,但你得是一名有经验的工程师——能读懂有一定复杂度的代码,能推敲 API 的行为,能跨子系统追踪控制流,并且日常打交道的就是测试、调试、性能优化和工程取舍。本书正是为这样的读者而写。
全书共分七个部分。第一、二部分建立心智模型和日常概念体系,为写出可审查的代码打下基础。第三部分向外延伸,涵盖接口、多态、库、模块以及 ABI 的现实约束。第四、五部分讨论并发、数据布局、内存分配与性能测量。第六部分关注验证与诊断工具链,确保原生系统的行为可追溯、可验证。第七部分以完整的生产实例收尾:一个服务、一个可复用库,以及一套代码评审工作流。
之所以采用这样的结构,是因为 C++ 中代价高昂的故障很少是局部性的。一个服务用 shared_ptr 延续请求状态的生命,把工作扇出到后台任务,靠副作用记录错误,结果在关停时卡死。一个库在边界处接受借用的 view,却把它留存到调用方的生命周期之外。一次性能重构消除了一次拷贝,却在别处悄悄引入了锁争用和分配器压力。单独看,每个局部决策都可能是合理的;但当所有权、时序、错误传播和成本模型交织在一起时,问题就会暴露出来。
未定义行为(UB)是另一条贯穿全书的压力线,而不是关在某一章里一次性讲完。悬空引用、数据竞争、失效的迭代器,或是一条悄悄活过了数据源的 view 管道——这些都可能让一个看似合理的设计仅在开启优化、承受高负载或切换工具链时才暴露问题。所有权、并发、性能和工具链相关章节各自讨论了与本领域最密切相关的 UB 风险。请把对 UB 的警觉当作串联全书的主线,而不是某处一贴即忘的警告标签。
如何阅读本书
你可以从头到尾顺序阅读。各部分的编排旨在逐层构建判断力:先讲所有权与不变量,再讲日常库和语言工具,然后是架构、并发、性能、验证,最后是完整的生产模式。不过,如果你已经明确要解决的问题类别,也完全可以直接跳到对应章节:
| 起点 | 推荐章节 |
|---|---|
| 设计接口或库边界 | 第 4、9、10、11 和 22 章 |
| 构建或修复原生服务 | 第 1、3、12、13、14、20 和 21 章 |
| 编写泛型且可复用的实现代码 | 第 5、6、7、8 和 22 章 |
| 处理热路径、内存行为或测量 | 第 15、16 和 17 章 |
| 强化验证与构建诊断 | 第 18、19 和 20 章 |
| 评审生产级 C++ 变更 | 第 1、3、4、14、19、22 和 23 章 |
每章开头都会列出前置知识要求,所以你可以直接从问题所在处切入,只在需要时回头补充背景。附录提供了精炼的辅助材料:面向决策的特性索引、工具链与诊断配置基线、简明的代码评审检查清单,以及本书核心术语的规范词汇表。
本书以 C++23 为主要基线。只有当 C++26 会改变你当下的决策,或能显著降低现有模式的开销时,才会提及它。引入这些前瞻性内容,是为了让建议经得起近期发展的检验,而非把本书变成标准演进的评论文章。
如果本书达到了预期效果,你应该能审视一个设计或一份 diff,讲清其中的权衡:它换来了什么,冒了什么风险,给调用方或运维带来了哪些承诺,以及需要什么证据才能证明这个选择站得住脚。这就是”知道现代 C++ 有哪些特性”与”真正用好现代 C++”之间的差距。
全书导图
本书的组织线索是生产环境中 C++ 代码在设计不够清晰时最容易付出代价的那些环节。全书从所有权、不变量、失败边界和 API 形态讲起,因为后续的架构决策能否经受评审取决于这些基础是否扎实。在此之上依次展开改变日常设计方式的库与语言工具、接口与打包、并发与性能、验证与可观测性,最终汇聚为完整的生产模式。
未定义行为是一条贯穿全书的主线,而非路线图上的独立章节。所有权部分讨论生命周期与别名风险;并发部分讨论数据竞争、关闭缺陷以及生命周期超出所有者的工作;数据与性能部分讨论失效、布局、局部性,以及薄弱测量带来的虚假信心;验证部分介绍相关的工具链与运行时信号,它们能捕获单靠代码评审发现不了的问题。
第一部分:核心心智模型
第一部分建立全书赖以展开的核心词汇:所有权、生命周期、值语义、不变量、失败边界,以及函数签名如何传达成本与持有语义。如果这些概念还不够清晰,后续章节的讨论容易沦为风格偏好之争,而非工程决策的推演。
第二部分:编写现代 C++ 代码
第二部分介绍足以改变 C++23 日常编码方式的标准库与语言工具:借用类型、结果类型与替代类型、概念(concepts)、范围(ranges)、生成器(generators),以及适度使用的编译期计算。重点不在逐一罗列语言特性,而在辨明哪些工具真正影响了契约设计、评审负担和成本模型。
第三部分:接口、库与架构
第三部分从局部代码扩展到子系统与包的边界。核心问题是一旦引入回调、类型擦除、模块、打包和 ABI 约束,接口能否继续如实兑现承诺。这也是局部设计的优雅与长期可组合性开始产生张力的地方。
第四部分:并发与异步系统
第四部分将并发视为跨越时间维度的所有权问题。共享状态、协程挂起、取消与背压,都作为生命周期与吞吐量问题来讨论,而非原语的罗列。目标是让异步工作做到有界、有主、可停止。
第五部分:数据、内存与性能
第五部分关注数据表示的决策如何转化为运行时行为。数据布局、容器选择、分配策略、局部性和测量纪律在这里整合为一条完整的成本链条,而非彼此孤立的优化技巧。
第六部分:验证与交付
第六部分介绍在设计方案确定之后如何让原生系统持续保持可信:针对边界失败的测试、sanitizer 与静态分析流水线、构建诊断,以及运行中系统的可观测性。重点在于证据的质量,不是为了打勾而打勾。
第七部分:生产模式
第七部分将前面各章的内容落实到完整的工程场景中。第 21 章展示服务边界——所有权、准入控制、优雅关闭和遥测在这里必须协调一致。第 22 章聚焦可复用库,库的契约必须经得起其他团队的检验。第 23 章以一套评审者工作流收尾,将全书内容转化为日常代码评审的实践方法。
附录
附录刻意保持精简,提供面向决策的特性索引、参考工具链与诊断基线、简版评审检查清单,以及统一全书核心术语的术语表。目的是加速实际工作,不是充当第二本教科书。
所有权、生命周期与 RAII
写现代 C++ 时,首先要面对的生产问题不是”这该不该做成一个类”,也不是”这里能不能零拷贝”。问题更简单,也更危险:谁拥有这个资源?它能保持有效多久?当正常流程走不通了,谁来保证清理?
这个问题首先针对内存,但内存只是冰山一角。真实系统需要管理的资源远不止于此:socket、文件描述符、互斥量、线程 join、临时目录、遥测注册、进程句柄、映射文件、事务作用域、关闭钩子……C++ 给了你充分的自由,也给了你充分的机会把这些全都搞砸。之所以把所有权放在第一章,是因为所有权一旦不清晰,后面的设计就根本无法放心地做评审。
在生产环境中,代价高昂的故障往往不会在调用点上表现得很显眼。一个服务启动了后台 flush,捕获了指向请求状态的裸指针,部署时偶尔崩一下。连接池在错误的线程上被关闭,因为最后一个 shared_ptr 恰好在一个没人当成关闭流程的回调里被释放。初始化路径构建了三个资源才建了一半,第四个抛异常时第二个就泄漏了。这些都不是语法问题——它们是所有权问题,最终演变成了运维事故。
RAII(资源获取即初始化)至今仍是现代 C++ 能把这些情况处理干净的核心原因。它让资源生命周期与作用域、异常、提前返回和部分构造自然组合。用好了,RAII 会让清理变得毫无存在感。
所有权必须一目了然
所有权是一种契约,不是实现细节。评审者应该能指着任意一个类型或成员,迅速回答三个问题:
- 这个对象拥有哪些东西?
- 它暂时借用了什么?
- 什么事件标志着所拥有资源的生命周期结束?
如果回答这些问题需要翻好几个辅助函数,说明设计已经过于隐式了。
现代 C++ 偏爱所有权语义一目了然的类型。std::unique_ptr<T> 表示独占所有权;std::shared_ptr<T> 表示引用计数的共享所有权;普通对象成员表示外层对象直接拥有这个子对象;std::span<T> 或 std::string_view 表示借用而非持有。这些是程序表达生命周期的手段,不是风格偏好。
反面的风格很常见:一个裸指针成员,可能代表拥有、可能代表观察,有时候还会因为正在关闭而变成 null。写起来省事,理解起来要命。
RAII 关心的是资源,而不是 new
很多程序员第一次接触 RAII,是通过”用智能指针代替手写 delete”这句话。方向没错,但范围太窄。
RAII 的本质是:把资源绑定到一个对象的生命周期上,由该对象的析构函数负责释放。资源可以是内存,但也完全可以是文件描述符、内核事件、事务锁,或者必须在关闭完成前注销的指标注册。
没有 RAII 会发生什么
在展示 RAII 模式之前,先完整看一遍手工方式。下面这个反面示例是故意写得有缺陷的,因为生产代码库里至今仍有长得一模一样的代码。
socket_t create_server_socket(std::uint16_t port) {
socket_t server = ::socket(AF_INET, SOCK_STREAM, 0);
if (server == invalid_socket) {
throw NetworkError{"socket failed"};
}
int opt = 1;
if (::setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
::close_socket(server);
throw NetworkError{"setsockopt failed"};
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (::bind(server, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
::close_socket(server);
throw NetworkError{"bind failed"};
}
if (::listen(server, 16) < 0) {
::close_socket(server);
throw NetworkError{"listen failed"};
}
return server; // RISK: caller now owns the raw descriptor by convention
}
void serve_once(std::uint16_t port) {
socket_t server = create_server_socket(port);
socket_t client = invalid_socket;
try {
sockaddr_in client_addr{};
socket_length addr_len = sizeof(client_addr);
client = ::accept(server,
reinterpret_cast<sockaddr*>(&client_addr),
&addr_len);
if (client == invalid_socket) {
::close_socket(server); // BUG: server will be closed twice (here + in catch)
throw NetworkError{"accept failed"};
}
std::array<char, 8192> buffer{};
auto n = read_from_socket(client, buffer.data(), buffer.size());
if (n <= 0) {
::close_socket(client);
::close_socket(server);
return;
}
process_request(client, std::string_view{buffer.data(), static_cast<std::size_t>(n)}); // RISK: any throw must preserve cleanup correctness
::close_socket(client);
::close_socket(server);
} catch (...) {
if (client != invalid_socket) {
::close_socket(client);
}
::close_socket(server);
throw;
}
}
问题会迅速叠加起来:
-
清理逻辑会重复。
::close_socket(server)同时出现在设置 helper、正常路径、提前返回路径和异常路径里。退出点越多,重复就越多。 -
重复最终会变成 bug。
accept失败路径在抛异常前已经关闭了server,而catch块又会再关一次。手工所有权逻辑在维护过程中很容易就会漂移成这样。 -
异常安全依赖纪律。
process_request可能抛异常。以后只要有人在“获取资源”和“手工清理”之间多插一段代码,就必须重新想一遍当时哪些描述符还活着。 -
转移是隐式的。
create_server_socket()返回的是裸socket_t,所以所有权只能靠调用方和被调方之间的约定维持,而不是由类型系统表达。 -
评审变成全局推理。 想确认代码正确,评审者就得检查整段函数,确认每一条退出路径都把每个描述符恰好关闭一次。
RAII 方案从结构上消除了这些问题:每个资源都由一个拥有它的对象持有,析构函数负责释放,剩下的交给栈展开。
本书配套的 web-api 示例项目里已经有我们真正想讲的例子。examples/web-api/src/modules/http.cppm 中的 Socket 类包装了一个文件描述符,并把所有权规则直接写进了类型里:
// From examples/web-api/src/modules/http.cppm
class Socket {
public:
Socket() = default;
explicit Socket(socket_handle fd) noexcept : fd_{fd} {}
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
Socket(Socket&& other) noexcept
: fd_{std::exchange(other.fd_, invalid_socket_handle)} {}
Socket& operator=(Socket&& other) noexcept {
if (this != &other) {
close(); // release what this object currently owns
fd_ = std::exchange(other.fd_, invalid_socket_handle);
}
return *this;
}
~Socket() { close(); } // automatic release on every exit path
[[nodiscard]] socket_handle fd() const noexcept { return fd_; }
[[nodiscard]] bool valid() const noexcept { return fd_ != invalid_socket_handle; }
explicit operator bool() const noexcept { return valid(); }
void close() noexcept {
if (fd_ != invalid_socket_handle) {
close_socket(fd_);
fd_ = invalid_socket_handle;
}
}
private:
socket_handle fd_{invalid_socket_handle};
};
这个类就足够说明 RAII 的核心:
- 获取发生在构造时:
Socket sock{::socket(...)}; - 所有权独占,复制被禁用。
- 转移显式,移动通过
std::exchange把源对象清空。 - 释放自动,析构函数总会调用
close()。
同一个模块中的周边代码还展示了它在真实使用中是如何工作的。下面是一段局部摘录:只保留了与所有权相关的代码,辅助声明和无关的错误处理细节为了便于阅读被省略了。
[[nodiscard]] Socket create_server_socket() const {
Socket sock{::socket(AF_INET, SOCK_STREAM, 0)}; // ownership starts here
if (!sock) return {};
int opt = 1;
if (::setsockopt(sock.fd(), SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
return {}; // sock is destroyed here, so the descriptor closes automatically
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port_);
if (::bind(sock.fd(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
return {}; // same: failure path still releases the descriptor
}
if (::listen(sock.fd(), 16) < 0) {
return {};
}
return sock; // move or copy elision transfers ownership to the caller
}
Socket client{::accept(server_sock.fd(), ...)}; // 为简洁起见,省略了客户端地址相关参数
handle_connection(std::move(client)); // explicit ownership transfer
void handle_connection(Socket client) const {
std::array<char, 8192> buf{};
auto n = read_from_socket(client.fd(), buf.data(), buf.size());
if (n <= 0) return;
Response resp = handler_(req); // request parsing omitted here
auto data = resp.serialize();
(void)write_to_socket(client.fd(), data.data(), static_cast<int>(data.size()));
} // client goes out of scope here and closes automatically
在 handle_connection(std::move(client)) 之后,调用方那边的 client 就不再拥有这个描述符了。移动构造函数已经把它的文件描述符交换成 invalid_socket_handle,所以之后这个被移动过的对象析构时也不会出事。任意时刻,所有权都只存在于一个对象里。
注意消失掉的东西:没有清理阶梯,没有主要职责是收尾的 try/catch,也没有”这个描述符现在到底归谁管”的口头约定。类型本身就携带了策略。
同样的模式适用于各种非内存资源。作用域注册令牌在析构时注销;事务对象不显式提交就回滚;joined-thread 包装器在析构时 join,或者拒绝在仍可 join 的状态下被销毁。一旦代码库建立起这种思维方式,清理路径就重新回归局部,不再散落于各处错误处理中。
反模式:靠约定清理
RAII 的替代方案通常不是”手工清理但做到完美无缺”,而是靠约定来清理——这就意味着一有压力,清理就会被跳过。
// Anti-pattern: ownership and cleanup are split across control flow.
void publish_snapshot(Publisher& publisher, std::string_view path) {
auto* file = ::open_config(path.data());
if (file == nullptr) {
throw ConfigError{"open failed"};
}
auto payload = read_payload(file);
if (!payload) {
::close_config(file); // BUG: one exit path remembered cleanup
throw ConfigError{"parse failed"};
}
publisher.send(*payload); // BUG: if this throws, file leaks
::close_config(file);
}
问题不在于手工清理难看,而在于它根本就是错的——清理策略被拆散到了每条退出路径里。函数一旦要管第二个、第三个资源,控制流就会变得比函数实际要做的工作更难审计。
RAII 版本消除了每一次手工释放和每一条条件清理路径:
void publish_snapshot(Publisher& publisher, std::string_view path) {
auto file = ConfigFile::open(path); // RAII: destructor calls ::close_config
if (!file) {
throw ConfigError{"open failed"};
}
auto payload = read_payload(*file);
if (!payload) {
throw ConfigError{"parse failed"};
// file releases automatically -- no manual cleanup needed
}
publisher.send(*payload);
// file releases automatically at scope exit, whether normal or exceptional
}
现在函数只需关心业务逻辑本身。清理不可见,因为它已经被保证了。哪怕再加第三、第四、甚至第十条退出路径,资源安全性也不受影响。RAII 的价值在于可组合性:在持续维护的压力下依然正确。
RAII 把释放策略移入了拥有资源的对象,从根源上消除了”靠约定清理”的隐患。错误路径于是可以回归本职:描述失败本身,而不是操心拆除过程。
独占所有权应当是默认选择
在设计良好的系统中,大多数资源在任意时刻都只有一个显而易见的所有者。请求对象拥有解析后的载荷,连接对象拥有它的 socket,批处理拥有它的 buffer。独占所有权理应成为默认的思维方式。
落实到代码中,就是优先使用普通对象成员,无法直接内嵌时再用 std::unique_ptr。unique_ptr 并不意味着设计有多复杂,它只是说明所有权的转移和销毁都是显式的。它与容器、工厂和错误路径的组合也很自然,因为 moved-from 状态是良定义的,单一所有权自始至终保持单一。
共享所有权应该是有意为之的例外。合理的场景确实存在:异步扇出中多个组件需要让同一份不可变状态保持存活,图状结构中存在共享生命周期,缓存条目在多个使用者持有期间必须有效。但 shared_ptr 不是安全毯。它会改变销毁时机,在许多实现中带来原子引用计数的开销,还经常掩盖真正的问题:为什么没有任何一个组件能明确充当所有者?
评审中如果在边界处发现了 shared_ptr,应该追问一个具体的问题:究竟是什么样的生命周期关系,让独占所有权行不通?如果答案含糊不清,那这个共享所有权多半只是在给一个从未想清楚资源归属的设计打补丁。
一个常见的症状是关闭时机的不确定性。当最后一个持有资源的 shared_ptr 从某个不可预期的回调或线程中被释放,析构函数就会在不可预期的时间和位置执行:
// Risky: destruction timing depends on which callback finishes last.
void start_fanout(std::shared_ptr<Connection> conn) {
for (auto& shard : shards_) {
shard.post([conn] { // each lambda extends lifetime
conn->send(shard_ping()); // last lambda to finish destroys conn
});
}
// conn may already be destroyed here, or may live much longer --
// depends on thread scheduling. Destructor side effects (logging,
// metric flush, socket close) now happen at an uncontrolled point.
}
当销毁顺序很重要时——在生产环境中几乎总是如此——应优先使用 unique_ptr 配合显式的生命周期作用域,把非拥有的裸指针或引用传给那些确保在所有者存活期内完成的工作。
借用比拥有需要更严格的纪律
所有权关系再清晰的系统,也少不了非拥有式的访问。算法要检查调用方拥有的 buffer,验证逻辑要读取请求元数据,迭代器和视图要在不拷贝的前提下遍历存储。借用本身没问题,错误在于让借用的状态活过了所有者,或者让借用关系变得不可见。
现代 C++ 提供了表达借用的类型工具:引用、明确用作观察者的指针、std::span 和 std::string_view。它们很有帮助,但光靠类型本身不能保证设计正确。一个长生命周期对象里的 view 成员,如果真正的所有者在别处,仍然是生命周期隐患。回调捕获了栈上状态的引用,延后执行时照样出问题。
并发场景下风险更大。被捕获进后台工作的裸指针或 string_view 绝非无害的小优化——它是跨越时间的借用,有效性取决于调度时序和关闭顺序。
一条简明的准则:拥有类型可以自由跨越时间边界;借用类型只有在所有者明显比使用方活得更久时,才可以跨越时间边界。如果你无法迅速证明这一点,就该拷贝或转移所有权。
移动语义定义的是转移,而非单纯的优化
移动语义(move semantics)通常被当作性能话题来讲,但实践中它首先是一个所有权话题。
对一个对象执行移动,就是宣告资源换了主人:源对象依然有效,但不再对原来的资源负责。工厂、容器和流水线各阶段因此能在不为每个类型另造一套转移 API 的前提下组合起来。
对于资源拥有类型,良好的移动行为是其正确性保障的一部分:
- 移动目标成为新的所有者。
- 移动源仍然可以析构和赋值。
- 不会发生重复释放。
所以专门写一层薄的资源包装器是值得的。所有权转移规则内化到类型中,调用方就不用再手工转移裸句柄、然后祈祷约定能对上号了。
不是所有类型都该可移动,也不是每次移动都便宜。互斥量通常既不可复制也不可移动,因为移动会让不变量和平台语义变得复杂。一个直接持有大 buffer 的聚合类型可能是可移动的,但在热路径上开销仍然不小。设计时该问的不是”能不能给移动操作加 = default”,而是”这个类型应该允许怎样的所有权语义”。
生命周期 Bug 往往藏在关闭和部分构造中
程序员习惯在正常工作路径上思考生命周期,但生产中的 Bug 却往往出现在启动失败和关闭阶段。
部分构造就是典型的例子。如果一个对象需要获取三个资源,第二个获取时抛了异常,第一个资源仍然必须正确释放。只要把所有权分层到各个成员中,而不是在构造函数体内靠清理标志手动处理,RAII 就能自动搞定这件事。
手工方式的脆弱性一目了然:
// Anti-pattern: manual multi-resource construction with cleanup flags.
class Pipeline {
public:
Pipeline(const Config& cfg) {
db_ = ::open_db(cfg.db_path().c_str());
if (!db_) throw InitError{"db open failed"};
cache_ = ::create_cache(cfg.cache_size());
if (!cache_) {
::close_db(db_); // must remember to clean up db_
throw InitError{"cache alloc failed"};
}
listener_ = ::bind_listener(cfg.port());
if (listener_ == invalid_socket) {
::destroy_cache(cache_); // must remember both prior resources
::close_db(db_);
throw InitError{"bind failed"};
}
}
~Pipeline() {
::close_listener(listener_);
::destroy_cache(cache_);
::close_db(db_);
}
private:
db_handle_t db_ = nullptr;
cache_handle_t cache_ = nullptr;
socket_t listener_ = invalid_socket;
};
每往构造函数里多加一个资源,前面所有失败分支都得跟着改。一旦有人调整了获取顺序,清理逻辑就会悄悄坏掉。
RAII 版本用成员包装器解决问题,依赖的是一条语言规则:构造函数抛出异常时,已经构造好的成员会被自动销毁:
class Pipeline {
public:
Pipeline(const Config& cfg)
: db_(DbHandle::open(cfg.db_path())) // destroyed automatically if
, cache_(Cache::create(cfg.cache_size())) // a later member throws
, listener_(Listener::bind(cfg.port())) {}
private:
DbHandle db_;
Cache cache_;
Listener listener_;
};
没有清理标志,没有级联 if,没有依赖顺序的手工拆除。语言替你完成了这一切。
本书配套的 web-api 示例项目中,main.cpp 展示了这一原则在完整服务启动中的应用。每一层都作为 main() 中的局部变量构造,栈的自然析构顺序负责拆除:
// 摘自 examples/web-api/src/main.cpp(简化)
int main() {
webapi::TaskRepository repo; // 1. 领域对象
webapi::Router router; // 2. 路由表
router.get("/tasks", webapi::handlers::list_tasks(repo));
auto handler = webapi::middleware::chain( // 3. 中间件
pipeline, router.to_handler());
webapi::http::Server server{port, std::move(handler)}; // 4. 服务器
server.run_until(shutdown_requested);
// 析构按反序展开:server, handler, router, repo
}
整段代码中没有任何显式的拆除逻辑。如果任何一步构造抛出异常,之前已构造的对象都会按反序自动销毁——这正是 RAII Pipeline 模式所依赖的保证。
关闭阶段是另一个主要压力点。析构函数运行时,系统往往已经处于状态切换之中——后台工作可能还持有引用,日志基础设施可能已经部分拆除。如果一个析构函数会无限阻塞、回调到不稳定的子系统、或者依赖某种从未写进文档的线程亲和性,就可能把原本整洁的所有权模型变成部署期故障。
教训不是害怕析构函数,而是让析构函数的职责尽可能窄。释放你拥有的资源,不要搞出意外的跨子系统操作。如果拆除工作需要比单纯析构更复杂的协议,就提供显式的 stop 或 close 方法,把析构函数作为最后的安全兜底。
验证与评审
所有权设计需要有意识地纳入评审流程,因为很多生命周期 Bug 在跑任何工具之前,就已经能从结构上看出端倪。
评审时值得关注的问题:
- 每个资源是否都有一个单一、明确的所有者?
- 借用的引用和视图,生命周期是否明显短于所有者?
shared_ptr是在解决真实的共享生命周期需求,还是在回避所有权决策?- 移动操作是否保持了单一所有权和安全销毁?
- 关闭流程是否依赖了超出资源释放范围的析构副作用?
动态工具同样重要。AddressSanitizer 能捕获大量 use-after-free 问题;Leak Sanitizer 和平台诊断工具能发现遗漏的释放路径;ThreadSanitizer 在生命周期错误因关闭期间的竞态条件而暴露时尤其有用。但只有当类型系统本身已经把所有权表达清楚时,这些工具才最有效。
要点
- 把所有权当作契约来对待——它必须在类型和对象结构中清晰可见。
- 对每一种有意义的资源都使用 RAII,而不仅仅是堆内存。
- 默认优先独占所有权;选择共享所有权时,要给出明确的理由。
- 先把移动语义理解为所有权转移规则,再把它当作性能优化手段。
- 对关闭路径和部分构造路径的审查力度,应该和对稳态运行路径一样。
如果一个资源可能泄漏、重复释放、销毁后仍被访问,或在错误的线程上销毁,那问题往往早在崩溃发生之前就埋下了——埋在所有权被留为隐式的那一刻。
值、身份与不变量
所有权理清之后,接下来的问题就从机械层面转向了语义层面:这个对象究竟应该是什么?
如果把每个类型都当成”附带方法的可变状态”来处理,现代 C++ 代码就会比它本该有的更难写。有些对象是值,代表一段自包含的含义,通常可以整体复制或替换,也应该便于比较和测试。有些对象则携带身份(identity),对应一个特定的会话、账户、worker 或连接,即使字段随时间变化,其连续性仍然重要。在一个含糊的类型里混用这两种角色,就会冒出一堆看似无关的 bug:错误的相等性判断、不稳定的缓存键、意外的别名、糟糕的并发行为,以及说不清”改完之后到底还是不是同一个东西”的 API。
本章讨论如何让这两种分类保持清晰,并通过不变量(invariant)的强制来保证类型在压力下仍然可信。参数传递和所有权转移机制留给后续章节。这里的重点是建模:什么时候把类型设计成值,什么时候必须显式保留身份,以及不变量如何防止对象图退化为一堆松散的字段。
值与实体解决的是不同问题
值由其内容定义,而非由来源定义。两个表示同一份配置、同一时间窗口或同一金额的值,通常可以互换使用。你可以随意复制、比较,也可以在线程之间传递,不用费心区分哪个才是”真正的”实例。
实体(即携带身份的对象)则不同。一个活跃的客户端会话,不能和恰好在某一时刻字段相同的另一个会话互换。一个连接对象可能会重连、累积统计信息、持有同步状态,但在系统看来它始终是同一个连接。身份之所以存在,就是为了让程序能表达“跨越时间的连续性“。
道理说出来都懂。但团队一旦没有明确决定某个类型属于哪一类,设计上的伤害就会悄然出现。
假设一个 Order 类型既可变、又共享、还按全部字段做相等比较,同时又被用作缓存键,程序实际上就在同时讲几个互相矛盾的故事。又假设一个配置快照被塞进了引用计数的可变对象里,而调用方只需要一组不可变的值,代码平白为别名和生命周期的复杂度买了单,却没有换来任何语义上的好处。
一条默认原则:如果一个类型不需要跨越时间的连续性,就优先设计成值。
值类型能减少意外耦合
值语义消除了隐式共享。调用方拿到的是自己的副本或 move 过来的实例,修改只影响自身,相等性通常可以按结构判定。写测试时可以直接构造小例子,不必搭建对象图或 mock 基础设施。
配置是个典型例子。很多系统因为产品中某处需要更新配置,就把配置建模成全局共享的可变对象。这个选择会殃及那些只需要稳定快照的代码。
更好的设计通常是:
- 把原始配置解析成一个经过验证的值对象。
- 配置变化时发布一个新的快照。
- 让消费者持有它们收到的那份快照。
这种设计让每个读取者面对的世界都是确定的。处理某个请求的代码只需针对手中那一份配置值做判断,没有更新到一半的对象图,不用为了读一个超时值就加锁,也不会搞不清两个调用方看到的是不是同一个可变实例。
没有值语义会出什么问题
当配置被建模成共享可变对象而非值快照时,别名 bug 就会随之而来:
// Anti-pattern: shared mutable configuration.
struct AppConfig {
std::string db_host;
int db_port;
std::chrono::seconds timeout;
};
// A single global mutable instance, shared by reference.
AppConfig g_config;
void handle_request(RequestContext& ctx) {
auto conn = connect(g_config.db_host, g_config.db_port);
// ... long operation ...
// BUG: another thread calls reload_config(), mutating g_config
// mid-request. conn was opened with the old host, but now
// ctx uses the new timeout. The request operates against
// an incoherent mix of old and new configuration.
conn.set_timeout(g_config.timeout);
}
采用值语义后,每个请求都持有自己的不可变快照。读取字段无需加锁,中途也不可能出现不一致的状态:
void handle_request(RequestContext& ctx, const ServiceConfig& config) {
// config is a value -- it cannot change during this call.
auto conn = connect(config.db_host(), config.db_port());
conn.set_timeout(config.timeout());
// Entire request sees a single consistent configuration.
}
值天然易于组合:可以放进容器、跨线程传递、用于确定性测试,也能充当稳定的哈希或比较输入。携带身份的对象也能做到这些,但需要更多规则和更多谨慎。只在模型真正需要的时候,才值得承担这份复杂性。
不变量是拥有类型的根本理由
一个允许无效状态组合的类型,说到底就是披着 struct 外衣的 bug 载体。
不变量是对象在任何可被外部观察到的时刻都应成立的条件。时间窗口要求 start <= end;金额要求携带货币并使用有界整数表示;批处理策略要求 max_items > 0 且 flush_interval > 0ms;连接状态对象禁止”已认证但未连接”。
不变量的意义在于大幅缩减后续代码需要防御的无效状态空间。
考虑一个调度子系统。
class RetryPolicy {
public:
static auto create(std::chrono::milliseconds base_delay,
std::chrono::milliseconds max_delay,
std::uint32_t max_attempts)
-> std::expected<RetryPolicy, ConfigError>;
auto base_delay() const noexcept -> std::chrono::milliseconds {
return base_delay_;
}
auto max_delay() const noexcept -> std::chrono::milliseconds {
return max_delay_;
}
auto max_attempts() const noexcept -> std::uint32_t {
return max_attempts_;
}
private:
RetryPolicy(std::chrono::milliseconds base_delay,
std::chrono::milliseconds max_delay,
std::uint32_t max_attempts) noexcept
: base_delay_(base_delay),
max_delay_(max_delay),
max_attempts_(max_attempts) {}
std::chrono::milliseconds base_delay_;
std::chrono::milliseconds max_delay_;
std::uint32_t max_attempts_;
};
错误传递的细节留到下一章,但建模的要点此处已经够清楚:RetryPolicy 不应该以荒谬的状态存在。一旦创建成功,使用它的代码就不必再检查延迟是否倒置、尝试次数是否为零。
如果类型自身不强制不变量,这份负担就会外溢到每一个调用方和每一次代码评审中。
不强制不变量时会发生什么
把上面那个通过工厂验证的 RetryPolicy 与下面这个把验证留给调用方的普通聚合体做个对比:
// Anti-pattern: invariants left to the caller.
struct RetryPolicy {
std::chrono::milliseconds base_delay;
std::chrono::milliseconds max_delay;
std::uint32_t max_attempts;
};
void schedule_retries(const RetryPolicy& policy) {
// Caller forgot to validate. base_delay is negative, max_attempts is 0.
// This loop does nothing, silently dropping work.
for (std::uint32_t i = 0; i < policy.max_attempts; ++i) {
auto delay = std::min(policy.base_delay * (1 << i), policy.max_delay);
enqueue_after(delay); // never executes when max_attempts == 0
}
}
这样一来,每个接收 RetryPolicy 的函数都得自行检查荒谬值,要么就寄希望于上游某层已经做过检查。实际情况往往是有的调用方查了,有的没查,行为随调用路径不同而不一致。前面的工厂做法从结构上杜绝了这类 bug:只要你手里有一个 RetryPolicy,它一定是有效的。
示例项目的领域模型采用了同样的模式。Task::validate() 是一个返回 Result<Task> 的静态工厂,在边界处拒绝空标题或超长标题:
// examples/web-api/src/modules/task.cppm
[[nodiscard]] static Result<Task> validate(Task t) {
if (t.title.empty()) {
return make_error(ErrorCode::bad_request, "title must not be empty");
}
if (t.title.size() > 256) {
return make_error(ErrorCode::bad_request, "title exceeds 256 characters");
}
return t;
}
每条存储 Task 的路径都必须先经过 validate(),包括更新操作——仓库在突变后会重新验证。不变量由类型自己拥有,而不是由各个调用方分头负责。
反模式:把实体语义偷偷塞进值类型
一个反复出现的问题:某个类型看上去像值,会被复制、会被比较,但内部藏着带身份的可变状态。
// Anti-pattern: one type tries to be both a value and a live entity.
struct Job {
std::string id;
std::string owner;
std::vector<Task> tasks;
std::mutex mutex; // RISK: identity-bearing synchronization hidden inside data model
bool cancelled = false;
};
这个对象没法当值用,复制一个互斥量和一个活跃的取消标志毫无意义。它也没法当严格的实体模型,因为整个可变表示都是 public 的。这种类型会把含糊性传染给代码库的其余部分。
更清晰的拆分通常是:
- 用
JobSpec或JobSnapshot之类的值类型承载稳定的领域数据, - 再用
JobExecution之类携带身份的运行时对象去拥有同步、进度和取消状态。
这样拆分之后,哪些部分可以序列化、比较、缓存、安全地跨线程传递,哪些部分是系统中一个正在运行的过程,就一目了然了。
示例项目清晰地展示了这种分离。Task 是纯粹的值类型,可复制、可比较、可序列化;TaskRepository 则是携带身份的实体,拥有 shared_mutex、ID 生成器以及可变集合。值承载领域数据,实体管理生命周期和同步,两者互不越界。
相等性应当匹配含义
检验一个类型的语义角色是否清晰,最好的办法是看它的相等性是否不言自明。
对于大多数值类型,相等性应该是结构性的:两个 host、port、TLS 模式都相同的 endpoint 配置,就是同一个值;两个货币和最小单位都相同的金额,就是同一个值;两个起止点相同的时间范围,就是同一个值。
而对于携带身份的对象,结构性相等反而容易误导。两个 user id 和远端地址都相同的活跃会话,并不是同一个会话;两个指向同一 shard 的连接,如果各自处于不同的生命周期阶段、各有各的待处理工作,就不能互换。
如果团队说不清某个类型的相等性该怎么定义,十有八九是这个类型把值数据和带身份的运行时关注点搅在了一起。
示例项目在这一点上处理得很直接。Task 声明了默认三路比较,相等性完全基于结构:
// examples/web-api/src/modules/task.cppm
[[nodiscard]] auto operator<=>(const Task&) const = default;
因为 Task 是值类型,结构性相等就是正确答案。而携带身份的 TaskRepository 根本没有相等运算符——比较两个仓库毫无意义。
影响是实实在在的。相等性会波及缓存键、去重逻辑、diff 生成、测试断言和变更检测。语义模糊的类型产生语义模糊的相等性,进而拖垮多个子系统。
浅拷贝与别名:一个具体陷阱
如果一个类型看起来像值,内部却通过指针或引用共享状态,那么拷贝得到的就不是独立值,而是别名:
// Anti-pattern: shallow copy creates aliasing bugs.
struct Route {
std::string name;
std::shared_ptr<std::vector<Endpoint>> endpoints; // shared, not owned
};
void reconfigure(Route primary) {
Route backup = primary; // looks like a copy, but endpoints are shared
backup.name = "backup-" + primary.name;
backup.endpoints->push_back(fallback_endpoint()); // BUG: mutates primary too
// primary.endpoints and backup.endpoints point to the same vector.
// The caller who passed primary now sees an endpoint they never added.
}
解决方法是让该类型拥有真正的值语义:把 vector 直接作为成员存储(拷贝即深拷贝),或者采用 copy-on-write 策略,又或者把类型设计成不可变的,使共享本身就是安全的:
struct Route {
std::string name;
std::vector<Endpoint> endpoints; // owned, copied on assignment
auto with_endpoint(Endpoint ep) const -> Route {
Route copy = *this;
copy.endpoints.push_back(std::move(ep));
return copy;
}
};
现在 Route 就是一个真正的值:拷贝彼此独立,with_endpoint 会生成新值而不影响原值,别名意外也就无从谈起了。
修改应当尊重建模选择
值和实体对修改的容忍方式不同。
对于值类型,最干净的做法通常是验证后即不可变,至少也应该限制只能通过保持不变量的窄操作来修改。整体替换配置快照或生成新路由表,往往比原地修改一个共享实例更容易理解和推理。
对于实体,修改是天经地义的,因为对象建模的就是跨时间的连续性。但这不等于 public 可写字段或不受约束的 setter 就合理了。实体同样需要受控的状态机。一个 Connection 可以从 connecting 转到 ready、再到 draining、再到 closed;不能仅仅因为各个字段单独看都合法,就允许任意组合。
真正的设计问题不是”能不能改”,而是”在哪儿能改”以及”改完之后还剩下哪些保证”。
如果两次字段赋值之间就可能破坏不变量,说明该类型需要更强的操作边界。如果调用方必须先加锁、更新三个字段、再记得重算一个派生标志,那不变量就从来不曾真正属于这个类型。
// Anti-pattern: public fields allow invariant-breaking mutation.
struct TimeWindow {
std::chrono::system_clock::time_point start;
std::chrono::system_clock::time_point end;
};
void extend_deadline(TimeWindow& window, std::chrono::hours extra) {
window.end += extra; // fine
}
void shift_start(TimeWindow& window, std::chrono::hours shift) {
window.start += shift;
// BUG: if shift is large enough, start > end.
// Every consumer of TimeWindow must now defend against this.
}
封装良好的类型从根本上杜绝了这类 bug——外部根本无法打破不变量:
class TimeWindow {
public:
static auto create(system_clock::time_point start,
system_clock::time_point end)
-> std::optional<TimeWindow>
{
if (start > end) return std::nullopt;
return TimeWindow{start, end};
}
auto start() const noexcept { return start_; }
auto end() const noexcept { return end_; }
auto with_extended_end(std::chrono::hours extra) const -> TimeWindow {
return TimeWindow{start_, end_ + extra}; // always valid: end moves forward
}
private:
TimeWindow(system_clock::time_point s, system_clock::time_point e)
: start_(s), end_(e) {}
system_clock::time_point start_;
system_clock::time_point end_;
};
调用方不可能构造出无效的 TimeWindow。start <= end 这条不变量只在类型内部集中强制一次,无需在每个修改点重复检查。
小型领域类型值得这点形式成本
有经验的程序员有时会排斥小型包装类型,觉得跟普通整数或字符串比起来像是多余的仪式。但在生产级 C++ 中,这些类型很快就能收回成本。
AccountId、ShardId、TenantName、BytesPerSecond、Deadline 这样的类型,能杜绝参数传反、让日志更清晰,也让无效组合更难写出来。不变量和转换逻辑可以集中在类型内部,而不是散落在解析、存储和格式化代码各处。
但也要警惕:包装类型只有在真正使含义更精确时才有价值。如果只是在 std::string 外面套了个壳,所有无效状态照样存在,也没增加任何语义操作,那就只是噪音。该问的是:这个类型是否在强制或传达系统真正关心的某种区分?
当值保持为值时,并发会更容易
很多并发问题的根源是建模问题。共享可变状态之所以难对付,很大程度上是因为程序在本可使用不可变值的地方,用了携带身份的对象。
在流水线中传递一份经过验证的快照,理解起来很容易;而在同一条流水线上共享一个带内部锁的可变配置服务对象,就难得多了。把面向值的请求描述符投入工作队列,也远比传入一个带隐藏别名和同步机制的活跃会话对象简单。
不是说每个并发系统都能完全消除实体,但值语义是减少需要共享和同步的状态量的有效手段。一旦代码能用快照发布或值的消息传递来取代就地修改,正确性和可审查性都会提升。
验证与评审
声称具有特定语义角色的类型,评审时就应该直接拿这些角色来检验。
有用的评审问题:
- 这个类型主要是值,还是主要是携带身份的对象?
- 它的相等性、复制规则和修改规则是否匹配这种选择?
- 哪些不变量是由类型自己强制的?
- 把稳定的领域数据和活跃的运行时状态拆开,是否会让设计更简单?
- 共享可变状态之所以存在,是因为模型确实需要身份,还是因为从未尝试过值语义?
测试遵循同样的思路。值类型适合用性质测试来验证不变量是否始终成立、相等性是否正确、以及序列化是否稳定。携带身份的类型则更适合做生命周期和状态机测试——验证合法转换能走通,非法转换会被拒绝。
要点
- 如果跨越时间的连续性不属于领域含义,就默认采用值语义。
- 当对象代表的是一个特定的、活着的东西而非可互换的数据时,身份必须显式化。
- 在类型内部强制不变量,免得调用方反复被动地重新发现它们。
- 相等性、复制和修改规则应当与类型的语义角色保持一致。
- 当一个对象试图身兼两职时,把稳定的领域值和运行时控制状态拆开。
当一个类型能清楚地回答”我到底是什么”时,其余设计都会变得顺畅:所有权更明确,API 更精练,测试更简单,并发也不用再和隐藏别名纠缠。
错误、结果与失败边界
大型 C++ 系统出问题,往往不是因为选错了某一种错误机制,而是因为多种机制并存却缺少分层策略。一个子系统抛异常,另一个返回状态码,第三个记完日志就继续跑,第四个把所有失败一律转成 false。孤立来看,每个选择似乎都说得通;合在一起,调用方就陷入了困境:搞不清哪些操作可能失败、哪些失败可恢复、诊断信息到底有没有发出去,清理和回滚是否还在正常执行。
生产中真正要回答的问题不是”异常还是 std::expected?”而是:每种错误模型该放在哪一层,失败信息怎样跨越边界,以及由系统的哪个部分负责转换、记日志和决定是否崩溃。
错误处理本质上是架构问题。解析层、领域层、存储适配层和进程入口点各有各的约束,混为一谈只会让代码既混乱又在运维上不堪一击。
本章把这些区分讲清楚。我们不取缔异常,也不宣称 std::expected 是万能替代品,而是主张一种策略:保留有用的失败信息,同时防止底层机制泄漏到整个代码库。
从对失败进行分类开始
并非所有失败都该用同一种方式传递。
生产代码至少应当区分以下几类:
- 无效输入或验证失败。
- 环境或边界失败,例如文件 IO、网络错误或存储超时。
- 契约违反或不可能的内部状态。
- 进程级启动或关闭失败。
这几类失败同时影响恢复策略和可观测性。无效输入在系统边缘往往可以预料,通常应返回一个局部错误结果,带上足够的细节来干净地拒绝请求或配置。环境失败可能需要做边界转换、制定重试策略或逐级上报。契约违反通常说明程序或子系统已丢失了某个不变量,这更接近于需要崩溃的场景,而不是”返回错误然后继续跑”。启动失败比较特殊,因为系统可能根本没有可用的降级模式,快速失败反而才是正确行为。
这些类别定义清楚之后,API 设计就轻松多了。不是每个函数都需要暴露所有类型的失败。如果唯一可操作的结果只有 not_found、conflict 和 temporarily_unavailable,高层领域函数就没必要去理解某个厂商特有的 SQL 错误枚举。
纯错误码方式及其陷阱
在 std::expected 出现之前、异常尚未被广泛采用的年代,C++ 代码库(以及从 C 继承来的代码库)主要依赖整数错误码和哨兵返回值。这种做法至今仍很常见,值得具体剖析一下它的问题。
// Error-code-only style: caller must check, but nothing enforces it.
enum ConfigErrorCode { kOk = 0, kFileNotFound = 1, kParseError = 2, kInvalidValue = 3 };
ConfigErrorCode load_service_config(const std::string& path, ServiceConfig* out);
void startup() {
ServiceConfig cfg;
load_service_config("/etc/app/config.yaml", &cfg); // BUG: return code silently ignored
// cfg may be uninitialized garbage -- the program continues anyway.
listen(cfg.port); // binds to nonsense port or zero
}
核心问题在于:错误码只是“建议性“的,编译器不强制调用方检查。即便标注了 [[nodiscard]],一个 void 强转或者一次无意遗漏就足以让警告消失。对大型代码库中 C 风格错误码 API 的研究反复表明,有 30%~60% 的错误返回值在某些调用点压根没被检查过。
其次是信息丢失。一个整数码承载不了结构化上下文——解析失败的是哪个文件、哪个配置值不合法、底层操作系统到底报了什么错。就算调用方检查了返回码,也往往只记一条笼统的消息就丢掉细节,最终在事故排查时留下一堆毫无用处的诊断。
std::expected 同时解决了这两个问题。调用方必须显式地取值或取错误;如果结果里装的是错误却硬要当值用,这一行为在代码评审中一眼可见(粗心这么干就是未定义行为,Sanitizer 会帮你抓住)。错误类型还可以直接承载结构化诊断信息,无需借助旁路日志。
示例项目在整个代码库中贯彻了这一做法。error.cppm 中定义了统一的 Result<T> 别名,使模式在所有模块间保持一致:
// examples/web-api/src/modules/error.cppm
template <typename T>
using Result = std::expected<T, Error>;
[[nodiscard]] inline std::unexpected<Error>
make_error(ErrorCode code, std::string detail) {
return std::unexpected<Error>{Error{code, std::move(detail)}};
}
Error 携带一个类型化的 ErrorCode 枚举和一条人类可读的详情字符串,既能支撑程序化分支,也能提供诊断信息。不会向调用方暴露任何整数错误码;每条失败路径都经由 Result<T>。
异常适合展开和局部清晰性
异常在 C++ 中依然有其价值,因为栈展开天然与 RAII 配合。当构造函数在资源拥有对象图构建到一半时失败,异常可以让语言自动驱动析构,省去手写的清理阶梯。当一段局部实现里有多层嵌套的辅助调用、且它们都可能以同样的方式失败时,异常也能让主路径保持清爽可读。
但这并不意味着异常可以充当通用的边界模型。
优点:
- 正常流程与失败流程分离,
- 跨越多层调用时代码仍然简洁,
- 与 RAII 搭配良好,清理自动完成。
缺点:
- 失败信息不体现在函数签名中,
- 可能穿越那些根本没有为异常做过设计的边界,
- 底层异常类型一旦泄漏到上层代码,就会破坏分层。
结论是保持克制:异常通常在层内部很好用。但除非整个代码库已经统一约定了异常模型并有手段强制执行,否则异常通常不适合作为跨子系统边界的通用语言。
std::expected 擅长决策边界
std::expected<T, E> 在抽象层面并不比异常更优。它的优势在于:当调用方需要根据失败做出分支决策时,expected 把决策点摆到了台面上。
解析、验证、边界转换和请求级操作经常属于这类场景。调用点通常需要分支处理、发出结构化拒绝、选择重试策略或附加上下文信息。返回 expected 让这个决策点一目了然。
以一个配置加载器为例:
enum class ConfigErrorCode {
file_not_found,
parse_error,
invalid_value,
};
struct ConfigError {
ConfigErrorCode code;
std::string message;
std::string source;
};
auto load_service_config(std::filesystem::path path)
-> std::expected<ServiceConfig, ConfigError>;
这个签名直接告诉读者:在这个边界上,失败属于正常控制流。调用方必须做出决定,中止启动、回退到默认环境,还是输出一条清晰的诊断信息。这跟深层内部的辅助函数不同,后者面对失败时唯一合理的做法往往是向上展开,交给真正能拍板的边界去处理。
把这个基于 expected 的加载器和传统的”输出参数 + bool”方式放在一起比较,就能看出旧风格丢掉了多少信息:
// Old style: bool return, output parameter, no structured error.
bool load_service_config(const std::filesystem::path& path,
ServiceConfig* out,
std::string* error_msg = nullptr);
void startup() {
ServiceConfig cfg;
std::string err;
if (!load_service_config("/etc/app/config.yaml", &cfg, &err)) {
// What kind of failure? File missing? Parse error? Permission denied?
// err is a free-form string -- no programmatic branching possible.
LOG_ERROR("config load failed: {}", err);
std::exit(1); // only option: cannot distinguish retriable from fatal
}
}
换用 std::expected<ServiceConfig, ConfigError> 后,调用方可以根据 ConfigErrorCode::file_not_found 和 ConfigErrorCode::parse_error 分别走不同的恢复策略,同时照样能拿到适合记日志的可读消息。决策所需的信息由类型系统承载,而不是埋在一个字符串里。
expected 的风险在于过度传播。如果每个细小的辅助函数仅仅因为 public 边界用了 expected 就跟着返回 expected,实现里就会堆满重复的转发逻辑,把主算法淹没掉。expected 应当放在设计上确实需要暴露错误的地方,不要硬塞进每个 private 函数,除非那样确实能提升局部可读性。
反模式:没有边界策略的副作用式错误处理
生产环境中常见的翻车方式:同一个子系统里同时存在”记日志””返回部分状态”和”偶尔抛异常”三种做法。
// Anti-pattern: side effects and transport are mixed.
bool refresh_profile(Cache& cache, DbClient& db, UserId user_id) {
try {
auto row = db.fetch_profile(user_id);
if (!row) {
LOG_ERROR("profile not found for {}", user_id);
return false;
}
cache.put(user_id, to_profile(*row));
return true;
} catch (const DbTimeout& e) {
LOG_WARNING("db timeout: {}", e.what());
throw; // RISK: some failures logged here, some rethrown, signature hides both
}
}
这个函数用起来代价很高:调用方既不知道 false 到底代表什么,也不知道哪些失败已经记过日志了,更不知道自己还需不需要补充上下文。如果好几层都是这个套路,事故排查时就会同时面对一片噪音和一堆信息空白。
边界代码应当只选择一种传递方式、一种日志策略。要么函数返回结构化失败,把日志交给能附加请求上下文的上层来写;要么就在本层彻底处理失败,并在契约中写明白。两者一混用,重复日志和遗漏决策就会混入系统。
反模式:未检查返回值导致静默失败
副作用问题还有一种更隐蔽的变体:把失败悄悄转成默认值,不给调用方留下任何线索。
// Anti-pattern: failure becomes a silent default.
int get_retry_limit(const Config& cfg) {
auto val = cfg.get_int("retry_limit");
if (!val) {
return 3; // silent fallback -- no log, no metric, no trace
}
return *val;
}
这种写法很有诱惑力,因为代码永远不会崩。但当配置文件里有拼写错误(写成了 retry_limt 而不是 retry_limit),系统就会悄悄用上硬编码的默认值。事故期间运维人员改了配置、指望行为跟着变,结果什么都没发生。这个 bug 之所以无迹可寻,恰恰是因为错误被吞掉了。
更好的做法是让默认值显式化,并让回退行为可观测:
auto get_retry_limit(const Config& cfg) -> std::uint32_t {
constexpr std::uint32_t default_limit = 3;
auto val = cfg.get_uint("retry_limit");
if (!val) {
LOG_INFO("retry_limit not configured, using default={}", default_limit);
return default_limit;
}
return *val;
}
或者,如果调用方应该决定缺失值是否可接受,就直接返回 expected 或 optional,让边界去做策略选择。
在易变依赖附近做转换
边界转换是错误设计的主战场。
存储适配层可能收到驱动抛出的异常、状态码、重试提示或平台错误。系统的其余部分不想直接面对这些细节,它们想要的是与决策相关的分类,外加刚好够用于诊断的上下文。
转换应当发生在靠近不稳定依赖的地方,而不是在三层之外的业务逻辑里。
auto AccountRepository::load(AccountId id)
-> std::expected<AccountSnapshot, AccountLoadError>
{
try {
auto row = client_.fetch_account(id);
if (!row) {
return std::unexpected(AccountLoadError::not_found(id));
}
return to_snapshot(*row);
} catch (const DbTimeout& e) {
return std::unexpected(AccountLoadError::temporarily_unavailable(
id, e.what()));
} catch (const DbProtocolError& e) {
return std::unexpected(AccountLoadError::backend_fault(
id, e.what()));
}
}
这样做没有抹掉有用信息,只是把它们包装成了调用方能直接使用的形式。业务逻辑现在可以区分 not-found 和暂时不可用,而无需去学存储客户端自己的错误体系。
同样的原则适用于网络边界、文件系统边界和第三方库:在靠近边缘的地方统一转换一次,不要让原始后端错误一路渗透。
示例项目在 HTTP 边界展示了同样的模式。handlers.cppm 中的 result_to_response() 在边缘处将领域 Result<T> 一次性转换为 HTTP 响应:
// examples/web-api/src/modules/handlers.cppm
template <json::JsonSerializable T>
[[nodiscard]] http::Response
result_to_response(const Result<T>& result, int success_status = 200) {
if (result) {
return {.status = success_status, .body = result->to_json()};
}
return http::Response::error(result.error().http_status(),
result.error().to_json());
}
领域逻辑只和 Result<T> 打交道。到 HTTP 状态码和 JSON 错误体的转换集中在 handler 边界的这一个函数里,领域代码不引入 HTTP 概念,handler 代码也不窥探错误内部,只调用这个转换函数。
构造函数、析构函数和启动需要不同规则
错误策略应当因生命周期阶段而异。
构造函数通常适合用异常,因为”部分构造 + RAII”是 C++ 最强的组合之一。一个持有资源却无法进入有效状态的对象,不该被创建出来。返回一个半初始化的对象再附带一个状态码,几乎不会更好。
析构函数恰好相反。析构过程中抛异常通常要么是灾难性的,要么从设计上就被禁止。如果清理操作可能失败且后果不可忽视,类型设计上应该提供显式的 close、flush 或 commit 方法,趁对象还处于受控状态时报告错误。析构函数退化为尽力清理的最后一道防线。
启动又有所不同。进程启动期间的配置加载、依赖初始化和端口绑定,往往只有一种合理的失败策略:输出清晰的诊断信息,然后让进程退出。不是说每个启动辅助函数都该调 std::exit,而是说顶层启动边界应当掌握这个决策权,底层只需返回足够结构化的信息,让失败原因一目了然。
诊断必须丰富,但不能传染
好的错误处理保留上下文,差的错误处理则把拼装上下文的代码塞进每个分支,直到主逻辑被淹没。
有用的失败信息通常包括:
- 一个稳定的类别或代码,
- 一条人类可读的消息,
- 文件路径、tenant、shard 或 request id 等标识符,
- 有时包括在确实有助于调试时才保留的后端细节或栈追踪数据。
错误对象应当有意义,但不要沦为内部细节的垃圾桶。面向领域的错误类型应当暴露调用方做决策所需的信息和运维排障所需的信息,而不是一路上遇到的每一条底层异常字符串。
命名良好的错误类型很重要。expected<T, std::string> 写起来快,但作为系统设计很弱。字符串适合做最终的诊断输出,不适合当架构契约。
在哪里记录日志
最干净的默认做法:在那些拥有足够上下文、能让日志条目具备运维价值的边界上记录。
通常是请求边界、后台作业监控器、启动入口点和外层重试循环,而不是每个察觉到失败的辅助函数。日志打得太早会丢掉上下文,每层都打会制造重复噪音,一直不打到进程挂了才发现则会丧失证据。
核心规则:由哪一层来判定”这个失败在运维上意味着什么”,那一层就是记日志的正确位置。
这条规则无论和 expected 风格的边界还是异常转换都能很好地配合。底层负责保留信息,边界层负责分类、附加上下文、决定恢复方式,并且只记录一次。
契约违反不只是另一条错误路径
有些失败说明程序收到了坏输入,另一些则说明程序自身违背了自己的假设。
如果某个不变量本应在更早阶段就已成立,此刻却不成立,或者代码走到了一个理论上不可达的状态,那么把它当成又一种可恢复的业务错误来处理,往往只会掩盖更深层的 bug。这不一定要求立刻终止进程,但确实需要用不同于普通验证失败的方式来对待。
好的代码库会把这些区分显式化:输入失败就建模为输入失败,后端不可用就建模为环境失败,内部不变量被破坏则作为 bug 暴露出来,而不是塞进”操作失败”的通用代码路径里一笔带过。
验证与评审
失败处理应当作为系统级属性来评审,而不是逐个函数孤立地看。
有用的评审问题包括:
- 在这个边界上,哪些失败是预期内的,并且与决策相关?
- 异常是在层内部服务于代码清晰性,还是在层间不可控地泄漏?
expected承载的是真正的决策信息,还是只不过把异常换成了样板代码?- 后端特有的失败是在哪一层被转换成稳定分类的?
- 日志是否只在那个拥有足够上下文的层记录了一次?
测试应当有意覆盖非正常路径:解析无效输入、模拟超时和 not-found、验证后端失败到领域失败的转换、演练启动失败路径,以及显式 close 或 commit 操作。一个只测正常路径的代码库,迟早会在生产环境里被迫直面自己真正的错误模型。
要点
- 根据层次和边界来选择错误传递方式,而不是凭信条。
- 在栈展开和局部可读性有帮助的地方使用异常,尤其是层内部和构造阶段。
- 在调用方必须根据失败做出明确决策的地方使用
std::expected。 - 在依赖边界附近,把不稳定的后端错误转换成稳定的、面向决策的分类。
- 在能理解失败运维含义的那一层记录日志。
如果调用方搞不清到底出了什么错、日志是否已经记过、自己接下来该怎么办,失败边界的设计就有问题。这在演变成线上故障之前,就已经是设计缺陷了。
参数传递、返回类型与 API 接口设计
在 C++ 中,函数签名承载的信息往往超出作者的本意。它说明了被调用方是借用还是保留数据,通常还隐含着 null 是否有意义、能否修改、会不会发生拷贝、失败算不算正常控制流等语义。签名一旦传达了错误的语义,哪怕实现本身在局部是正确的,API 用起来仍然代价高昂,审查起来仍然困难。
本章聚焦的是这层语义接口。目标不是死记”X 一律按 Y 传递”之类的教条,而是学会选择恰当的参数和返回形式,在调用方需要做决策的边界上,如实传达所有权(ownership)、生命周期(lifetime)、可变性、可空性与开销。
第 1 章讨论了谁拥有资源,第 2 章讨论了模型中有哪些对象,第 3 章讨论了错误应当如何跨越边界。本章的问题更具体:在这些设计决策已经确定的前提下,函数签名该怎么写,才能让调用契约一目了然?
签名是契约,不是类型检查仪式
很多糟糕的 C++ API,根源在于把签名仅仅看作”能通过编译的最小类型集合”。参数和返回值的选择本身就是带有约束力的文档。
以一个解析器边界为例。
auto parse_frame(std::span<const std::byte> bytes)
-> std::expected<Frame, ParseError>;
仅这一行就传达了几层含义:
- 函数借用一段连续的只读字节。
- 不需要获取源 buffer 的所有权。
- 产出一个拥有所有权的
Frame值。 - 失败是预期内的,且显式表达。
对比 Frame parse_frame(const std::vector<std::byte>&);,后者强加了解析器根本不需要的容器选择,隐藏了失败策略,也没有说明返回的 Frame 究竟借用了输入数据的视图,还是持有独立的数据副本。
示例项目中的 HTTP 解析器遵循了同样的模式。在 examples/web-api/src/modules/http.cppm 中,parse_request 借用输入并返回一个拥有所有权的结果:
[[nodiscard]] inline std::optional<Request>
parse_request(std::string_view raw);
函数接受一个指向栈 buffer 的 string_view,解析出 method、path、headers 和 body,返回一个成员全部为 std::string 的 Request——完全拥有自己的数据。调用方的 buffer 函数返回后即可复用或销毁。这种“借用输入、拥有输出“的契约,仅从签名就能看清。
差别在于调用方能否不翻看实现就理解契约。
借用参数应当看起来就是借用
如果函数在调用期间只读取调用方的数据、不做保留,签名就应该直接体现借用语义。
对于文本,当不需要 null 终止符、也不涉及所有权转移时,std::string_view 通常是最合适的参数类型。对于连续的二进制或元素序列,std::span<const T> 是常见的只读形式。对于可变借用,std::span<T> 或非 const 引用都可以,取决于抽象对象更像序列还是更像独立实体。
这样做有两个好处:
- 调用方保持灵活——可以传入字符串、切片、数组、vector、内存映射 buffer 等,无需强制分配或转换容器。
- 契约诚实透明——借用就是借用。
最常见的误用是让借用参数泄漏到长期状态中。一个接受 string_view 却把它缓存到调用结束之后的函数,是在对契约撒谎。
悬空借用:把这件事做错的代价
当借用参数的生存期超过了源对象,就会引发未定义行为——而且往往表现为间歇性的数据损坏,而非一次干脆利落的崩溃:
class Logger {
public:
void set_prefix(std::string_view prefix) {
prefix_ = prefix; // BUG: stores a view, not a copy
}
void log(std::string_view message) {
fmt::print("[{}] {}\n", prefix_, message); // reads dangling view
}
private:
std::string_view prefix_; // non-owning -- lifetime depends on caller
};
void configure_logger(Logger& logger) {
std::string name = build_service_name();
logger.set_prefix(name); // name is destroyed at end of scope
} // name destroyed here -- logger.prefix_ is now dangling
修复方法很简单:如果成员的生存期需要超过单次调用,就必须持有数据的所有权。
class Logger {
public:
void set_prefix(std::string prefix) { // takes ownership by value
prefix_ = std::move(prefix);
}
// ...
private:
std::string prefix_; // owning -- no lifetime dependency on caller
};
由此得出一条简洁实用的审查准则:凡是参数类型标明了借用,实现中若要保留数据,就必须通过显式拷贝或转换为拥有类型来完成。
当被调用方反正需要自己的副本时,就按值传递
现代 C++ 中一个实用的模式是:被调用方需要存储或拥有参数时,直接按值传递。对于被灌输了”拷贝能省则省”观念的开发者来说,这可能令人意外。
以一个需要存储租户名的请求对象为例:
class RequestContext {
public:
explicit RequestContext(std::string tenant)
: tenant_(std::move(tenant)) {}
private:
std::string tenant_;
};
这个构造函数通常优于 const std::string& 和 std::string_view。
- 明确表达了对象会拥有一份字符串。
- 传入左值时付出一次拷贝——但这本来就无法避免。
- 传入右值时可以直接 move。
- 不会引发误将借用视图意外保留的风险。
准则不是”昂贵类型一律按 const 引用传递”,而是”当所有权转移本身就是契约的一部分,且额外的 move/copy 开销可以接受时,按值传递即可。”
错误的参数选择及其成本
参数传递选错了,后果未必立竿见影,但在热路径和大对象场景下不断累积。
当需要所有权时,const std::string& 导致不必要的拷贝:
class Registry {
public:
void register_name(const std::string& name) {
names_.push_back(name); // always copies, even if caller passed a temporary
}
private:
std::vector<std::string> names_;
};
// Caller:
registry.register_name(build_name()); // builds a temporary string, copies it,
// then destroys the temporary. The move
// that pass-by-value would have enabled
// is lost.
改用”按值传入 + move”的写法后,临时对象可以直接 move 进容器,免去不必要的拷贝:
void register_name(std::string name) {
names_.push_back(std::move(name)); // rvalue callers: 1 move. lvalue callers: 1 copy + 1 move.
}
示例项目的错误模块也运用了同样的手法。在 examples/web-api/src/modules/error.cppm 中,make_error 按值接收 std::string,然后 move 进错误对象:
[[nodiscard]] inline std::unexpected<Error>
make_error(ErrorCode code, std::string detail) {
return std::unexpected<Error>{Error{code, std::move(detail)}};
}
传入字符串字面量或临时对象的调用方零拷贝;传入左值的调用方付出一次拷贝——而这次拷贝本来就无法避免。签名诚实地传达了 detail 将被结果错误对象所拥有。
当 std::span 已经足够时,const std::vector<T>& 强迫分配:
// Anti-pattern: forces callers to allocate a vector even if data is in an array or span.
double average(const std::vector<double>& values);
// Caller with a C array or std::array must construct a vector just to call this:
std::array<double, 4> readings = {1.0, 2.0, 3.0, 4.0};
auto avg = average(std::vector<double>(readings.begin(), readings.end())); // pointless heap allocation
改用 std::span<const double> 后,函数可以接受任意连续来源,而不必强迫调用方选择特定容器:
double average(std::span<const double> values);
// Now works with vector, array, C array, span -- no allocation required.
auto avg = average(readings);
当然也有不适合按值传递的情况:多态类型、极少需要拷贝左值的超大聚合体,以及仅在特定条件下才保留参数的 API。一如既往,语义契约优先。
非 const 引用表达的不只是可修改性
非 const 引用参数语义很强。它意味着调用方必须提供一个存活的对象,null 没有意义,被调用方可以就地修改这个对象。有时这恰好是正确的契约,但也常常被滥用。
只有在修改是操作的核心目的,且调用方应当将其视为此次调用的主要意图时,才适合使用非 const 引用。典型场景:原地排序一个 vector、填充调用方提供的输出 buffer、推进解析器状态对象。
不要仅仅为了省掉一个返回值,或者因为 C 风格的 out 参数用着顺手,就使用非 const 引用。如果结果在概念上就是函数的输出,而不是调用方有意交出来让你修改的对象,那么 out 参数反而会降低可读性。
在现代 C++ 中,主要结果通常直接返回更为清晰。非 const 引用参数应当留给真正的原地修改场景,或者多对象协调且修改本身就是契约核心的情况。
裸指针主要用于可空性和互操作
裸指针在接口中仍有其正当用途。在现代 C++ 中,最清晰的用法是表示一个可选的借用对象,或用于与底层 API 互操作。
但这个角色比很多代码库实际赋予裸指针的要窄得多。
T* 参数通常只应表达两种含义之一:
- 被调用方可能收到空指针——即不提供对象。
- 接口需要跨越指针层面的互操作或底层数据结构,指针身份本身就承载语义。
如果 null 没有意义,引用通常更清晰。如果在转移所有权,std::unique_ptr<T> 或其他拥有类型更清晰。如果对象是数组或连续序列,std::span<T> 通常更清晰。一个裸指针如果同时被解读为”非空、借用、可能是单个也可能是多个、也许会被保留”,那就是语义债务。
同样的原则适用于返回类型。在现代 C++ API 中,返回拥有所有权的裸指针几乎总是错误的信号。返回一个观察者裸指针则可以接受,前提是”缺失”本身有意义,且对象的生命周期由其他机制管理。
返回有意义的值,而非存储的副产品
返回类型应当和参数一样严谨。核心问题是:调用方应该得到一个拥有所有权的值、一个借用访问,还是一个像 expected 或 optional 那样承载决策信息的包装类型?
对于很多 API 来说,即使涉及一次 move,返回拥有所有权的值仍然是最干净的设计。它让生命周期保持局部化,让组合更容易,也避免了调用方对内部存储的依赖。C++23 的 move 语义已经让值返回在大多数场景下足够廉价。
借用返回类型只在源对象的生命周期显而易见、足够稳定,且确实属于契约一部分时才适合。返回指向内部存储的 std::string_view,前提是该存储的生存期明显长于 view,且调用方可以安全地依赖这一点。在较宽的接口边界上,这通常不是好的权衡,因为它把生命周期推理的负担推给了调用方。
可选性和失败也应当在返回类型中显式表达,而不是靠哨兵值偷偷传递。搜索操作返回”也许找到了”,适合用 std::optional<T>,或者在生命周期语义需要时用观察者指针。解析或加载操作的失败如果关乎后续决策,适合用 std::expected<T, E>。失败时返回空字符串或 -1 的函数,通常是把 API 设计得比实际需要更弱了。
反模式:一个签名背后藏着多个故事
这类 API 在很多代码库中依然常见,因为它看起来很灵活。
// Anti-pattern: signature hides ownership, failure, and buffer contract.
bool encode_record(const Record& record,
std::vector<std::byte>& output,
std::string* error_message = nullptr);
这一个函数身上就隐含着好几条未说明的规则:
- 是追加到
output,还是覆盖output? error_message设为可选,是因为诊断信息不重要,还是因为日志记录在别处进行?- 失败时
output是否会被部分修改? false代表的是校验失败、编码 bug、容量不足,还是内部异常被转换了?
这些问题,签名本身一个都答不上来。
更好的做法是把语义拆分开来。
auto encode_record(const Record& record)
-> std::expected<std::vector<std::byte>, EncodeError>;
auto append_encoded_record(const Record& record,
ByteAppender& output)
-> std::expected<void, EncodeError>;
现在调用方可以在”生成拥有所有权的结果”和”追加式写入”之间明确选择,失败契约也是显式的。两种本质不同的操作不再伪装成一个万能的”灵活”接口。
工厂和获取函数必须提前说明所有权
创建函数是所有权不清晰代价最高的地方。返回 T* 的工厂让调用方不得不追问:谁负责 delete?通过 out 参数加 bool 返回值的工厂,往往隐藏了部分构造的规则。默认返回 shared_ptr<T> 的工厂,则可能在设计尚未证明需要共享所有权时就引入了共享。
对于普通的独占所有权,std::unique_ptr<T> 通常是最清晰的返回类型。对于值语义的对象,直接返回值即可,如果失败发生在边界上则用 expected<T, E>。只有当创建出的对象确实需要共享生命周期时,才返回 shared_ptr<T>。
来看具体的对比:
// Anti-pattern: raw pointer factory -- caller does not know who owns the result.
Widget* create_widget(const WidgetConfig& cfg);
void setup() {
auto* w = create_widget(cfg);
// Does the caller own w? Does a global registry own it?
// Must the caller call delete? delete[]? A custom deallocator?
// Nothing in the signature answers these questions.
use(w);
// If the caller guesses wrong, the result is a leak or a double-free.
}
// Clear: unique_ptr states exclusive caller ownership unambiguously.
auto create_widget(const WidgetConfig& cfg)
-> std::expected<std::unique_ptr<Widget>, WidgetError>;
void setup() {
auto result = create_widget(cfg);
if (!result) { /* handle error */ }
auto widget = std::move(*result); // ownership transferred, no ambiguity
// widget is destroyed automatically when it leaves scope
}
示例项目展示了面向值类型的同类模式。在 examples/web-api/src/modules/task.cppm 中,Task::validate 是一个工厂风格的函数,按值接收 Task,返回 Result<Task>(即 std::expected<Task, Error> 的别名):
[[nodiscard]] static Result<Task> validate(Task t) {
if (t.title.empty()) {
return make_error(ErrorCode::bad_request, "title must not be empty");
}
return t;
}
而在 examples/web-api/src/modules/repository.cppm 中,TaskRepository::create 与之配合——按值接收 Task,校验后分配 ID,返回存储结果或校验错误:
[[nodiscard]] Result<Task> create(Task task);
两个函数都没有使用 out 参数或 bool 返回码。所有权故事与上面的 unique_ptr 工厂如出一辙,只是适配了值类型:调用方 move 一个值进去,拿回一个有效的拥有型结果或一个显式的错误。
具体选哪种词汇类型不是重点,重点是:创建边界恰恰是所有权必须表达得毫无歧义的地方。
API 接口也是成本接口
签名的选择会以调用方切实感受到的方式影响开销。
std::function 参数即使回调只在同步场景中使用,也可能带来堆分配和类型擦除的开销。std::span<const T> 能避免强迫调用方转换为特定容器。按值接收 std::string 的 sink 构造函数可以让临时对象高效 move 进来。返回拥有所有权的 vector 虽然分配一次内存,却能消除长期的生命周期隐患。这些是设计层面的权衡,不是微优化。
正确的做法是把调用方需要知道的成本摆在明面上,避免无从推断的隐性开销。好的签名不承诺零成本,而是让重要的成本不会令人意外。
过于宽泛的”便利”重载集合反而有害。当一个 API 同时接受指针、字符串、span、vector、view 等各种组合时,重载接口本身可能比原始问题还难以理解。应当优先保留少数几个语义清晰的形式。
验证与评审
函数签名是发现设计缺陷成本最低的环节。
审查时可以问自己:
- 每个参数是否如实传达了借用、所有权转移、可变性或可选性?
- 按值传递是否用在了被调用方确实需要所有权的场合,而非出于习惯或教条?
- 裸指针是否仅限于可选的借用访问或互操作,而非充当含糊的万能契约?
- 返回类型是否清楚地表达了拥有所有权的结果、借用访问还是显式失败?
- API 是否把重要的开销暴露出来,而只隐藏了无关紧要的实现细节?
测试应当验证签名所蕴含的语义,不仅仅是核心功能。要验证是追加还是覆盖行为;验证返回的 view 在文档承诺的生命周期内有效,过期后确实失效;验证失败后输出参数或状态保持在承诺的条件下。再清晰的签名,也需要测试作为证据来支撑。
要点
- 把签名当作语义契约来设计,而不仅仅是编译器能接受的类型组合。
- 被调用方只读取调用方数据时,使用借用参数类型。
- 被调用方需要获取所有权、且这一契约应当显而易见时,按值传递。
- 通过引用、指针和返回包装器有意识地表达可变性、可选性和失败。
- 保持 API 接口足够精简,让调用方无需翻看实现代码就能理解生命周期和开销。
优秀的 C++ API 不只是能通过编译,而是让调用方第一次阅读签名就能正确使用。
改变设计的标准库类型
标准库真正发挥作用的地方,不是当工具层用,而是它改变了 API 能表达什么含义。在生产级 C++ 中,这体现在一小组词汇类型(vocabulary types)上。它们让借用关系变得显式,把缺失和失败区分开来,表达封闭的备选集合,避免所有权信息散落在函数签名中。
本章不逐一浏览头文件。问题更聚焦:在 C++23 代码库中,哪些标准类型应该改变你日常的设计方式?它们又在哪些场景下会产生误导或带来额外开销?
问题在边界处最突出。典型服务的流程是:从网络解析字节,将借来的文本交给校验环节,构造领域值,记录局部失败,最后写入存储或发往下游。如果这些步骤全靠裸指针、哨兵值和重容器的函数签名来表达,代码能编译,但契约是模糊的。读者只能从实现细节中猜测所有权、生命周期、可空性和错误含义,而这些信息最不该藏在实现里。
借用类型会改变 API 形状
std::string_view 和 std::span 是现代 C++ 中最常用的设计类型,因为它们将访问与所有权分离。一旦代码库统一采用借用类型,函数签名就不再暗示不必要的内存分配,也不再假装拥有那些只是查看一下的数据。
以一个遥测数据采集层为例,它需要解析按行组织的文本记录和二进制属性 blob:
struct MetricRecord {
std::string name;
std::int64_t value;
std::vector<std::byte> attributes;
};
auto parse_metric_line(std::string_view line,
std::span<const std::byte> attribute_bytes)
-> std::expected<MetricRecord, ParseError>;
这个签名一目了然:
- 函数借用了两个输入,不取得所有权。
- 文本输入不要求以 null 结尾。
- 二进制输入是一段连续的只读序列。
- 解析结果的所有权仅通过返回值转移。
- 失败与缺失是两码事。
老式写法会模糊这些信息。const std::string& 暗示某处持有字符串所有权,即便调用方手里只是更大 buffer 的一个切片。const std::vector<std::byte>& 无理由地排斥了栈 buffer、std::array、内存映射区域和 packet 视图。const char* 则悄悄带回了生命周期歧义和 C 风格字符串的隐含假设。
为了更直观地感受差异,来看看在借用类型出现之前,同一个边界是怎么写的:
// Pre-C++17: raw pointer + length, no type safety on the binary side
auto parse_metric_line(const char* line, std::size_t line_len,
const unsigned char* attr_bytes, std::size_t attr_len,
MetricRecord* out_record) -> int; // 0 = success, -1 = error
// Or the "safe" version that forces callers into specific containers
auto parse_metric_line(const std::string& line,
const std::vector<unsigned char>& attribute_bytes)
-> MetricRecord; // throws on failure, no way to distinguish absence from error
“指针加长度”版本完全没有类型系统的支持,无论是连续性、只读访问,还是”这段二进制 buffer 是字节而非字符”。调用方必须为每个参数手动维护两个裸值,参数错位 bug(比如把 attr_len 传到 line_len 的位置)能悄无声息地通过编译。容器引用版本则强迫所有调用方分配 std::string 和 std::vector,哪怕数据本来就在内存映射文件或栈 buffer 里。两种版本都无法通过类型系统表达所有权契约。
借用类型需要使用者自律。std::string_view 仅在数据源仍然存活且未被修改时才安全;std::span 仅在其引用的存储仍然有效时才安全。这就是设计意图:它们迫使接口在类型系统中声明,这里发生的是借用而非所有权转移。
典型的出错方式是:生命周期保证仅在局部范围内成立,却把借用存了下来。
class RequestContext {
public:
void set_tenant(std::string_view tenant) {
tenant_ = tenant; // BUG: borrowed view may outlive caller storage
}
private:
std::string_view tenant_;
};
这不是回避 std::string_view 的理由,而是说它的使用范围应限于:函数参数、局部算法中的临时拼接、生命周期契约清晰且便于评审的返回类型。对象需要持久保存数据就用 std::string;子系统需要稳定的二进制数据所有权就用容器或专用 buffer 类型。
示例项目演示了这种“借用到拥有“的转换。在 examples/web-api/src/modules/task.cppm 中,Task::from_json 接受 string_view 借用原始 JSON body,但返回的 optional<Task> 的 std::string 成员独立拥有数据:
[[nodiscard]] static std::optional<Task> from_json(std::string_view sv);
函数从借来的输入中提取字段值,move 进拥有型字符串,返回自包含的 Task。调用方的 buffer 在函数返回后可以立即复用或销毁。检查时借用,存储时拥有。
评审时有个简单准则:这个对象只是看一下调用方的数据,还是需要长期持有?如果是后者,用借用类型做成员变量就该警惕了。
optional、expected 和 variant 解决的是不同问题
把某一种词汇类型当万能方案,维护成本会迅速攀升。std::optional、std::expected 和 std::variant 各自建模的语义不同。在它们之间做选择是设计决策,不是风格偏好。
值的缺失属于正常情况、本身不构成错误时,用 std::optional<T>。缓存查找可能未命中,配置覆盖项可能没有设置,HTTP 请求可能带幂等键也可能不带。调用方只需根据”有还是没有”来分支,无需额外解释原因,optional 就是正确的选择。
失败信息对控制流、日志记录或用户可见行为有实际影响时,用 std::expected<T, E>。解析、校验、协议协商和边界 I/O 通常属于这一类。如果这些操作返回 optional,失败原因就丢掉了,只能另辟蹊径传递诊断信息。
结果是若干合法领域状态之一,而非成功与失败的二元对立时,用 std::variant<A, B, ...>。消息系统可能把命令建模为若干种 packet 形态之一;调度器可能用 std::variant<TimerTask, IoTask, ShutdownTask> 表示不同类型的工作。这不是失败,是一个显式的封闭集合。
常见的错误是把这三者混为一谈,当成”不确定性的通用包装”。
optional用于“也许有”。expected用于“成功,或者附带解释的失败”。variant用于“若干有效形式之一”。
示例项目体现了这种区分。在 examples/web-api/src/modules/repository.cppm 中,一次可能找不到结果的查找使用 optional:
[[nodiscard]] std::optional<Task> find_by_id(TaskId id) const;
没有需要报告的错误,task 要么存在要么不存在。如果返回 expected,就迫使调用方检查一个它根本无法采取行动的错误。optional 才是“普通缺失“的正确信号。
定位理清楚之后,很多 API 层面的争论自然消解。
这些类型出现之前,设计是什么样的
在 std::optional 之前,“可能有一个值”的标准习惯用法是哨兵值或输出参数:
// Sentinel: -1 means "not found." Every caller must know the convention.
int find_port(const Config& cfg); // returns -1 if unset
// Out-parameter: success indicated by bool return, value written through pointer.
bool find_port(const Config& cfg, int* out_port);
// Nullable pointer: caller must check for null, and ownership is ambiguous.
const Config* find_override(std::string_view key); // null means absent... or error?
每种写法都迫使调用方记住一套口头约定。-1 或 nullptr 之类的哨兵值在类型系统中毫无痕迹,没有机制阻止调用方拿哨兵值做算术运算。输出参数颠倒了数据流方向,链式调用很别扭。std::optional<int> 把”可能缺失”的语义编码进类型本身,编译器也能帮你强制检查。
在 std::variant 出现之前,封闭备选集合通常靠 union 加 enum 判别字段再加人工纪律来实现:
// C-style tagged union: no automatic destruction, no compiler-checked exhaustiveness
enum class ValueKind { Integer, Float, String };
struct Value {
ValueKind kind;
union {
std::int64_t as_int;
double as_float;
char as_string[64]; // fixed buffer, truncation risk
};
};
void process(const Value& v) {
switch (v.kind) {
case ValueKind::Integer: /* ... */ break;
case ValueKind::Float: /* ... */ break;
// Forgot String? Compiles fine. UB at runtime if String arrives.
}
}
union 能存放数据,但语言本身不保证 kind 与当前激活成员保持同步。每新增一个备选项就得手动更新所有 switch 分支,编译器也未必对遗漏发出警告。std::variant 将当前激活的备选项纳入类型的运行时状态,重新赋值时自动析构旧值;配合 std::visit,编译器能在分支遗漏时给出警告。
假设一个配置加载器有三种可能结果:没找到覆盖项、解析出有效覆盖项、拒绝了格式错误的输入。三者语义不同。硬塞进 optional<Config> 就丢掉了”格式错误为何被拒绝”的信息。返回 expected<optional<Config>, ConfigError> 看起来有点重,但它精确表述了契约:缺失是正常的,格式错误才是失败。
服务边界上同样如此。如果内部客户端库返回 variant<Response, RetryAfter, Redirect>,调用方可以对合法的协议结果做模式匹配。而如果返回 expected<Response, Error>,重试和重定向即便属于正常控制流,也会被错误地归入异常路径。
示例项目在领域边界上用了这种方式。在 examples/web-api/src/modules/error.cppm 中,一个项目级类型别名让模式在整个代码库中保持一致:
template <typename T>
using Result = std::expected<T, Error>;
然后在 examples/web-api/src/modules/repository.cppm 中,可能因有意义的原因而失败的创建操作返回 Result<Task>:
[[nodiscard]] Result<Task> create(Task task);
校验拒绝输入时,调用方会收到包含错误码和人类可读详情的 Error,而非光秃秃的 false 或空 optional。examples/web-api/src/modules/handlers.cppm 中的 create_task handler 在边界处把 Result 翻译为 HTTP 响应,无需 out 参数或异常处理:
auto result = repo.create(std::move(*parsed));
return result_to_response(result, 201);
expected 也会改变异常策略。在很少使用异常或禁止异常跨越特定边界的代码库中,expected 让错误处理保持局部化和显式化,无需退化到状态码加输出参数的老路。但取舍是真实的:如果把 expected 一路传递到每个私有辅助函数,直线式代码会变成重复的错误传播样板。把它留在错误信息确实有用的边界上。封闭实现内部,局部异常边界或更细粒度的函数拆分,往往更清晰。
容器不应该假装自己是契约
C++ 代码中最顽固的设计错误之一:函数明明只需要一个序列,参数类型却用了拥有型容器。签名中出现 std::vector<T> 很少是中立选择,它隐含了分配策略、连续性和调用方的数据表示方式。有时是故意的,更多时候纯属偶然。
如果函数只读取序列,就接受 std::span<const T>;如果需要对调用方的连续存储做可变访问,就接受 std::span<T>;如果需要接管所有权,就显式使用拥有型类型;如果需要特定关联容器(因为查找复杂度或键稳定性本身就是契约的一部分),那就直接写明。
这一区分在库设计中尤为明显。如果压缩库暴露的接口是 compress(const std::vector<std::byte>&),就等于替所有调用方决定了输入 buffer 的存储方式。更好的边界几乎总是一个字节借用 range,通常是 std::span<const std::byte>。怎么持有数据留给调用方决定,可以是池化 buffer、内存映射文件区域、栈数组或 vector。
反方向的错误同样常见:函数生成了拥有型数据,返回类型却是视图。解析器内部构造了局部 vector 却返回 std::span<const Header>,这就错了。正确做法是返回 std::vector<Header> 或拥有型领域对象。借用类型在如实反映数据关系时提升 API 质量;如果只是用来逃避契约本就要求的那次拷贝,反而让 API 变差。
还有可变性的问题。传入可变容器,往往暴露了远超算法所需的操作权限。一个只做追加的函数没理由接受整个可变 map,如果真正的契约只是”往里面插入输出”。遇到这种情况,应考虑更窄的抽象:回调 sink、专门的 appender 类型,或下一章讨论的受约束泛型接口。类型应该表达被调用方可以做什么假设,而非”什么写法碰巧能编译通过”。
一个真实边界:解析,同时不让契约走样
一个从 socket 接收类 protobuf frame 的原生服务通常分三层:
- 传输层:拥有 buffer,负责重试读取。
- 解析器:借用字节,校验 framing。
- 领域层:拥有经过规范化的值。
标准库类型应当强化这些层次划分,而非模糊它们。
传输层可能需要暴露拥有型 frame 存储,因为它管理部分读取、容量复用和背压。解析器通常应接受 std::span<const std::byte>,查看调用方拥有的字节,产出领域对象或解析错误。领域层应返回普通值而非指向 packet buffer 的 span,因为业务逻辑不应无意间继承传输层的生命周期。
写出来时,这些道理似乎显而易见。但一旦某次以性能为名的重构开始把 string_view 和 span 往系统更深处渗透,”为了省掉拷贝”,就没那么显而易见了。那次拷贝有时候恰恰是将稳定的领域对象与易变的传输 buffer 解耦的必要代价。省掉它,可能只是把成本转嫁到了生命周期复杂性、延迟暴露的解析 bug 和更高的评审难度上。
一条实用的原则是:检查边界用借用,语义边界用拥有。而解析代码恰好坐落在两者的交汇点上。
这些类型在哪里会帮倒忙
词汇类型只有在语义保持清晰时才能改进代码。
把 std::string_view 当廉价字符串替代品而非借用来用就会出问题。代码真正需要非连续遍历或稳定所有权时,std::span 反而添乱。std::optional 一旦抹掉失败原因就变成信息黑洞。备选集合是开放式的或经常跨模块扩展时,std::variant 会越用越痛苦。std::expected 如果被深埋在实现内部,本来用局部异常边界或简单的函数拆分就能写得更清楚,它也只会增加噪音。
另一个常见问题是包装类型层层嵌套,直到 API 难以阅读。expected<optional<variant<...>>, Error> 这样的类型偶尔是正确的,但对读者永远不轻松。如果理解契约需要这么多“解包“工作,通常说明早该引入一个具名领域类型了。
词汇类型的意义不在于不计语法代价追求最大精度,而在于让核心语义一目了然,评审者无需反推实现就能看懂所有权、缺失和失败的处理方式。
验证与评审
这里的验证重点主要在契约层面:
- 将存储在成员变量中的
string_view和span视为潜在的生命周期 bug 加以审查。 - 用短生命周期 buffer、截断输入、空输入和格式错误的 payload 来测试解析器和边界 API。
- 检查
optional返回值是否在悄悄吞掉运维层面需要关注的错误信息。 - 审查容器参数是否无意间绑定了特定的所有权模式或数据表示。
- 将借用视图到拥有值的转换当作有意义的设计决策点来对待,而非偶然的实现细节。
Sanitizer 很有帮助,特别是借用视图跨越异步或延迟执行边界时。但它们无法替代 API 评审,许多误用模式在运行时暴露之前,从逻辑上就已经错了。
要点
- 检查边界优先用借用类型,存储边界优先用拥有型类型。
optional、expected和variant对应三种不同语义:缺失、失败和封闭备选集合,不要混用。- 不要让容器的实现选择泄漏到 API 中,除非该实现本身就是契约的一部分。
- 省掉一次拷贝如果会把生命周期复杂性推给无关层次,那就算不上收益。
- 当词汇类型的嵌套让契约变得更难读时,该引入具名领域类型了,不要继续堆包装。
使用概念与约束编写泛型代码
问题中确实存在”家族相似性”时,泛型代码才有价值;用模板来推迟接口决策只会带来破坏。生产中真正要回答的问题不是”怎么让它可复用”,而是”怎样消除重复的同时,不让调用契约、诊断信息和失败行为变得不透明”。
concepts(概念约束)是 C++ 很长时间以来第一个能在边界处直接改善局面的特性。它不会让泛型代码自动变简单,但你可以用更贴近实际设计的方式说明模板对使用者的期望。与”先实例化再祈祷编译器报错能指到正确的行”的旧时代相比,这是很大的进步。
本章关注普通产品团队也能维护的受约束泛型代码:可复用的变换、窄小的扩展点、策略对象,以及假设条件必须随时可供评审的算法族。
从变化开始,而不是从模板开始
大多数糟糕的泛型代码始于一个错误前提:”这些函数看起来很像,应该模板化。”表面语法相似远远不够。真正要问的是:设计中哪些部分允许变化,哪些不变量必须保持固定。
设想一个内部可观测性库,需要把指标批次写入不同的 sink:内存中的测试收集器、本地文件、网络导出器。不变的部分很明确:批次有 schema,一次 flush 内时间戳必须单调递增,序列化失败必须上报,关闭时不能丢掉已确认的数据。变化的部分只有一个,字节最终写到哪里。
只需要一条窄小的泛型接缝,没有理由把整条流水线都模板化。
从解析到重试逻辑再到传输机制全部模板化,你写的就不再是可复用代码,而是在代码库里造了一门新语言。concept 只有在变化边界本身划得合理时才帮得上忙。
约束好边界,实现就能保持平凡
concept 在实践中的主要用途不是花哨的重载排序,而是告诉调用方和编译器:你的算法可以假设哪些操作存在。
考虑一个 batching 辅助函数,它把已经序列化好的记录写入某个 sink:
template <typename Sink>
concept ByteSink = requires(Sink sink,
std::span<const std::byte> bytes) {
{ sink.write(bytes) } -> std::same_as<std::expected<void, WriteError>>;
{ sink.flush() } -> std::same_as<std::expected<void, WriteError>>;
};
template <ByteSink Sink>
auto flush_batch(Sink& sink,
std::span<const EncodedRecord> batch)
-> std::expected<void, WriteError>
{
for (const auto& record : batch) {
if (auto result = sink.write(record.bytes); !result) {
return std::unexpected(result.error());
}
}
return sink.flush();
}
这段代码有几个优点:
- concept 名描述的是角色,不是实现技巧。
- 要求的操作少,每个都有明确的业务含义。
- 失败行为是契约的一部分。
- 函数体就是普通代码,抽象程度没有超出问题本身的需要。
另一种写法是经典的无约束模板:
template <typename Sink>
auto flush_batch(Sink& sink, const auto& batch) {
for (const auto& record : batch) {
sink.push(record.data(), record.size()); // RISK: hidden, undocumented assumptions
}
sink.commit();
}
这个版本更短,但在每个生产相关的维度上都更差:假设没有明说,错误契约不清楚,对记录结构的要求纯属偶然。类型不匹配时,编译器在使用点抛出一堆噪声,不会清楚说明接口要什么。
错误信息的实际差异
把错误类型传给无约束版本时会发生什么:
struct BadSink {};
BadSink sink;
std::vector<EncodedRecord> batch = /* ... */;
flush_batch(sink, batch);
没有 concept 时,编译器直接实例化模板体,然后在实现深处报错。典型错误大致如下:
error: 'class BadSink' has no member named 'push'
in instantiation of 'auto flush_batch(Sink&, const auto&) [with Sink = BadSink; ...]'
required from here
note: in expansion of 'sink.push(record.data(), record.size())'
error: 'class BadSink' has no member named 'commit'
note: in expansion of 'sink.commit()'
这还只是简单例子就已经报了两个错误。生产中模板很少这么浅,sink 可能经过三层 adapter 传递,每一层又都是模板。真正的错误出现在实例化栈底部,程序员得在脑子里把调用链倒着拆开才能搞清楚问题。叠上深层嵌套模板和标准库类型,这种诊断信息动辄几十行。
有了 ByteSink 概念后,同样的错误只会在调用点产生一条精准的诊断:
error: constraints not satisfied for 'auto flush_batch(Sink&, ...) [with Sink = BadSink]'
note: because 'BadSink' does not satisfy 'ByteSink'
note: because 'sink.write(bytes)' would be ill-formed
错误信息点明了 concept 名称和未满足的具体要求,位置指向调用点而非实现内部。程序员一眼就能看出 BadSink 缺了什么接口。
concept 替代了什么:SFINAE
concept 出现之前,约束模板的标准技术是 SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)。思路是:让模板签名对不合适的类型变得不合法,使编译器在重载决议中静默排除,而非报硬错误。
C++20 之前的代码里,与 ByteSink 约束等价的写法大致如下:
// SFINAE approach (using enable_if to constrain the same interface)
template <typename Sink,
std::enable_if_t<
std::is_same_v<
decltype(std::declval<Sink&>().write(
std::declval<std::span<const std::byte>>())),
std::expected<void, WriteError>
> &&
std::is_same_v<
decltype(std::declval<Sink&>().flush()),
std::expected<void, WriteError>
>,
int> = 0>
auto flush_batch(Sink& sink, std::span<const EncodedRecord> batch)
-> std::expected<void, WriteError>;
同样的约束,换成了没人想读的写法。std::enable_if_t 搭配 decltype 和 std::declval,表达的不是设计意图而是在利用编译器机制。SFINAE 拒绝某个重载时,典型错误信息往往只有一句”没有匹配 flush_batch 的函数调用”,不会告诉你哪条要求没满足。如果有多个受 SFINAE 保护的重载,编译器甚至会把所有被拒绝的候选逐一列出,却依然不解释各自失败的原因。concept 版本在可读性、错误质量和可维护性上全面胜出。
主动约束公共接口,让实现保持朴素。这才是正确的取舍。
concept 应该描述语义,不只是语法
只检查成员名是否存在的 concept 比没有强,但算不上好设计。生产中的泛型代码要想可维护,concept 必须对应系统中的某个语义角色。
SortableRange 比 HasBeginEndAndLessThan 好,ByteSink 比 HasWriteAndFlush 好,RetryPolicy 比 CallableWithErrorAndAttemptCount 好。concept 的名字越像团队日常使用的设计术语,它在代码评审和诊断中就越有用。
concept 服务于两类受众:编译器用它们选择和拒绝实例化,人类用它们理解算法期待哪一类东西。名字和结构只满足编译器的话,价值就丢掉了一半。
但这也不意味着 concept 要试图证明所有语义规则。大多数有用的不变量更适合用测试来验证,而非靠编译期静态强制。RetryPolicy concept 可以要求调用签名和结果类型,却无法证明该策略对某个具体操作是幂等安全的。接受这个局限:能在接口里检查的就在接口里说清楚,其余交给测试和评审。
窄定制点优于模板蔓延
很多可复用组件不需要庞大的 concept 层级体系,一两个精心选定的扩展点就够了。
假设一个存储子系统要支持多种记录类型,它们都可以序列化到 wire buffer。常见的坏做法是定义一个主模板,让特化散布在整个代码库,靠 ADL 或隐式转换决定实际行为。行为难以发现,也容易被无意破坏。
更干净的做法通常是一个窄定制点加显式的签名要求。行为属于类型本身就用成员函数;类型应当与序列化库解耦就用非成员操作。无论哪种方式,都应该用 concept 约束其形状和结果。
配套项目 examples/web-api/ 中有个例子。JsonSerializable concept 只要求一个操作,返回值可转换为 std::string 的 to_json() 成员:
// examples/web-api/src/modules/json.cppm
template <typename T>
concept JsonSerializable = requires(const T& t) {
{ t.to_json() } -> std::convertible_to<std::string>;
};
这个 concept 设计上就是窄的:命名的是一种单一能力,没有把序列化、反序列化和校验全塞进一个庞大的要求列表。类型只需提供一个符合签名的 to_json() 成员即可接入,无需注册、无需基类、无需散落的特化。
项目中还定义了 TaskUpdater 来约束传给仓储 update 方法的 callable 参数:
// examples/web-api/src/modules/repository.cppm
template <typename F>
concept TaskUpdater = std::invocable<F, Task&> &&
requires(F f, Task& t) {
{ f(t) } -> std::same_as<void>;
};
这防止了调用方传入返回意外值或接受错误参数的任意 callable。concept 在边界处记录了契约,而非把约束检查推迟到实现深处的模板实例化错误。
评审者应该能快速回答三个问题:
- 到底哪些东西可以变?
- 哪些不变量必须保持?
- 新的模型类型在哪里接入?
如果答案散落在十个头文件里还依赖偶然形成的重载决议规则,这个泛型设计就过于隐式了。
泛型代码不是隐藏成本的许可证
模板以漂亮的调用点掩盖内存分配、拷贝和代码膨胀,这不是新闻。concept 解决不了这个问题,它只能让”允许哪些形状”更清晰。
设计泛型代码时,要把两类成本说清楚:所有模型上都固定的成本,以及随模型而变化的成本。算法是否要求连续存储?是否会物化中间 buffer?某个策略类型会不会被内联进每个翻译单元?某个 concept 是否同时接受抛异常和不抛异常的操作,导致失败处理在接口上到处渗透?
这些是设计问题,不是优化琐事。一个模板看起来很抽象,实际上只在某一类类型上表现良好,这往往意味着它是个不稳定的抽象。要么收窄 concept,要么为成本模型差异显著的场景分别提供重载。
大型代码库中广泛引用的头文件尤其要注意这一点。每多一次实例化就多一份编译成本,代码体积也可能膨胀。组件需要跨越共享库或插件边界时,普通虚接口或类型擦除 callable 往往更划算,即便要牺牲一些内联机会。稳定的边界通常比理论上的”零开销纯度”更有价值。
普通重载何时优于 concept
总有一种诱惑想用 concept 来证明设计”够现代”。要抵制这种冲动。如果只有三种已知输入类型且没有迹象表明未来会增长,重载往往更清晰。需要运行时多态边界就直接用。变化只在测试中才体现时,一个函数对象或可 mock 的小接口往往比泛型子系统更好维护。
同时满足以下条件时,concept 才能发挥最大作用:
- 算法确实适用于一族类型。
- 所需操作能精确、窄小地描述。
- 调用方能从编译期拒绝中获益。
- 实现的成本模型清晰可读。
这些条件不满足时,模板就会迅速变成负担。
失败模式与边界条件
泛型代码的失败模式往往反复出现。
无约束渗漏。 一个泛型函数接受”任何像 range 的东西”,另一个期待”任何可写的东西”,很快代码库中原本无关的组件之间出现偶然兼容。concept 的作用是收窄这些接缝,不是放大它们。
约束重复。 多个辅助函数之间复制着几乎一样的 requires 子句,直到接口再也无法演进。反复出现的要求应优先提取为具名 concept。具名 concept 本身就是文档,不只是语法压缩。
语义漂移。 原本为清晰角色设计的 concept,因为又有调用方需要又一个操作,逐渐积累越来越多无关的要求。出现这种苗头时要么拆 concept 要么拆算法,不能让一个抽象沦为”差不多相关”用例的杂物筐。
诊断表演。 concept 层叠得很讲究,编译器输出却依然不可读。使用者搞不清楚自己的类型为什么不满足某个 concept 时,就该简化设计。好的泛型设计应当让失败信息具备可操作性。
验证与评审
泛型代码的验证不止”实例化一次”。
- 如果某个 concept 足够核心、值得配备稳定示例,就为有代表性的正向和反向模型添加
static_assert检查。配套项目中task.cppm展示了这一做法,在编译期验证领域类型满足其序列化契约:
// examples/web-api/src/modules/task.cppm
static_assert(json::JsonSerializable<Task>,
"Task must satisfy JsonSerializable");
static_assert(json::JsonDeserializable<Task>,
"Task must satisfy JsonDeserializable");
这些断言是活文档:如果有人修改了 Task 导致 JSON 契约被破坏,编译器会立即拒绝构建,而非把错误推迟到运行时测试甚至生产环境。
- 用少量本质不同的模型类型测试算法,不要罗列一堆只有表面差异的变体。
- 审视 concept 是否命名了系统中的真实角色,而非一堆操作的打包。
- 泛型组件位于频繁引用的头文件路径上时,要测量编译时间和代码体积。
- 确认错误行为、内存分配行为和所有权假设在受约束的边界处清晰可见。
编译期拒绝只有在被拒绝的程序确实违反了团队能说清楚的设计规则时才有帮助。否则只是在一个不清晰的接口外面加了一道花哨的门禁。
要点
- 只有问题中的变化真实且持久时,才值得写泛型代码。
- 用 concept 约束公共边界,让实现保持朴素可读。
- 按语义角色给 concept 命名,而非按偶然的语法特征。
- 窄定制点优先,避免开放式特化方案。
- 编译期多态反而让成本、诊断或边界变得更差时,改用重载或运行时抽象。
范围、视图与生成器
ranges(范围)和 generator(生成器)吸引人,是因为它们能把迭代压缩成类似数据流的写法。这种写法有时确实是生产代码需要的;但有时它也会把生命周期 bug、隐性开销和难以调试的惰性行为引入原本简单直白的代码。
真正要问的不是 range pipeline 是否优雅,而是:惰性组合在什么场景下比普通循环更能揭示工作的结构?又在什么场景下反而掩盖了所有权、错误处理和性能开销?C++23 提供了 range 机制和用于拉取式序列的 std::generator,但它们不应成为一切迭代的默认写法。
本章的示例取自真实场景:导出前过滤日志、在批处理作业中转换数据行、将分页或协程驱动的数据源包装为拉取式序列。这些场景面临的设计压力相同,工作随时间陆续到达,代码需要表达一连串转换,而风险在于延迟执行、借用状态,以及”数据究竟存放在哪里”变得模糊。
只有当数据流是主线时,pipeline 才值得使用
很多任务用普通循环就够了。循环的执行顺序一目了然,副作用清楚,单步调试方便。Range pipeline 真正有价值的场景是:工作的本质结构可以概括为”取一个序列,筛掉部分元素,对留下的做转换,最后物化或消费结果”。
假设日志导出 worker 收到一批已解析的记录,需要挑出与安全相关的条目,投影到导出 schema 后发送。先看 C++20 之前手写迭代器的写法:
// Pre-C++20: manual iterator loop with filter + transform
std::vector<ExportRow> export_rows;
for (auto it = records.begin(); it != records.end(); ++it) {
if (it->severity >= Severity::warning && !it->redacted) {
ExportRow row;
row.timestamp = it->timestamp;
row.service = it->service;
row.message = it->message;
export_rows.push_back(row);
}
}
这个版本能用,但过滤逻辑和转换逻辑揉在了同一个循环体里。读者得先看懂 if 才知道保留了什么,再看循环体才知道产出了什么。场景一复杂,这类循环就会堆满嵌套条件、提前 continue、索引运算和手工记账,数据流看不清了。基于索引的变体(for (size_t i = 0; i < records.size(); ++i))更是 off-by-one 错误的重灾区,尤其循环体还要修改容器或索引身兼多职时。
Range 版本在结构上把关注点分离开了:
auto export_rows = records
| std::views::filter([](const LogRecord& r) {
return r.severity >= Severity::warning && !r.redacted;
})
| std::views::transform([](const LogRecord& r) {
return ExportRow{
.timestamp = r.timestamp,
.service = r.service,
.message = r.message,
};
})
| std::ranges::to<std::vector>();
这就是好的 range 代码。Pipeline 本身就是业务逻辑,没有棘手的就地修改,源对象的生命周期不存在歧义,末尾有一个清晰的物化点。不需要中间存储,省掉它既提升了清晰度也降低了开销。
再和另一类循环比较:那种循环要更新共享计数器、发送 metric、原地修改记录,还要有条件地重试下游写入。pipeline 反而会把执行顺序和副作用藏起来。计算本身有状态、副作用又多时,range 语法不能提升可读性。
C++20 之前还有一个常见模式值得一看,就是用于原地过滤的 “erase-remove” 惯用法:
// Pre-C++20 erase-remove idiom
records.erase(
std::remove_if(records.begin(), records.end(),
[](const LogRecord& r) {
return r.severity < Severity::warning || r.redacted;
}),
records.end());
这段代码正确,但写错它太容易了。漏掉传给 erase 的 .end() 参数是经典坑,编译能过但”已删除”的元素还留在容器里。逻辑也反直觉:你指定的是”要移除什么”而非”要保留什么”,谓词很容易写反。C++20 引入的 std::erase_if 简化了这个问题,range pipeline 则通过生成新视图而非原地修改,从根本上绕开了它。
配套项目 examples/web-api/ 中有个例子。在 repository.cppm 中,find_completed 使用 views::filter 按完成状态过滤任务:
// examples/web-api/src/modules/repository.cppm
[[nodiscard]] std::vector<Task> find_completed(bool completed) const {
std::shared_lock lock{mutex_};
auto view = tasks_
| std::views::filter([completed](const Task& t) {
return t.completed == completed;
});
return {view.begin(), view.end()};
}
Pipeline 本身就是业务逻辑,按谓词过滤,在跨越 API 边界之前物化为拥有型结果。不存在生命周期歧义:锁在整个过程中保持源数据存活,返回值是独立的 vector。
原则很简单:对序列做线性数据流处理时,用 range pipeline;如果重点是控制流、就地修改或操作步骤,就用循环。
views(视图)是借用,惰性会让 bug 延迟暴露
生产环境中 range 最棘手的 bug 不在算法,而在生命周期。视图通常不持有数据所有权,惰性 pipeline 把实际工作推迟到迭代时才执行。视图的构造位置和 bug 的暴露位置可能相距甚远。
来看一个请求处理代码中常见的反模式:
auto tenant_ids() {
return load_tenants()
| std::views::transform([](const Tenant& t) {
return std::string_view{t.id};
}); // BUG: returned view depends on destroyed temporary container
}
代码看着干净,但它是错的。load_tenants() 返回一个拥有数据的临时容器,视图 pipeline 借用了这个容器。函数返回时临时对象已经销毁,视图变成了延时炸弹。
视图的核心原则:数据拥有者必须比视图活得更久,这层关系必须让读者一眼看出来。如果生命周期关系需要仔细推敲才能理清,说明抽象已经过度了。
几种安全的做法:
- 在同一个局部作用域内构建并消费 pipeline,确保拥有者显然还活着。
- 跨越边界之前,先物化为拥有型结果。
- 当调用方无法合理地追踪源对象的生命周期时,直接返回拥有型 range 或领域对象。
- 当序列是逐步生成而非借用自已有存储时,使用
std::generator或其他拥有型抽象。
借用本身没有问题,隐蔽的借用才是隐患。
不要随意把深层惰性 pipeline 暴露为公共 API
模块内部,range 往往是很好的胶水代码。到了子系统边界就得格外谨慎。公共 API 返回层层嵌套的视图栈,暴露给调用方的就不只是一个序列,而是一整套生命周期假设、迭代器类别行为、求值时机,甚至出人意料的失效规则。
对于只想拿到”过滤后的记录”的调用方,这些语义负担太重了。
库或大型服务的边界上,先想清楚调用方需要什么。需要拥有型结果就直接返回。需要对昂贵或分页数据做拉取式遍历就考虑用 generator。需要可定制的遍历并保持调用侧控制权就提供基于回调的 visitor 或专门设计的迭代器抽象。
返回惰性组合视图最合适的场景是在一个局部实现区域内,同一个团队拥有接口两侧,生命周期链条短到一眼就能看清。
配套项目中有两个保持在安全边界内的 range 设计。json.cppm 定义了一个函数模板,接受任何元素满足 JsonSerializable 的 input_range,将 range 约束与 concept 结合:
// examples/web-api/src/modules/json.cppm
template <std::ranges::input_range R>
requires JsonSerializable<std::ranges::range_value_t<R>>
[[nodiscard]] std::string serialize_array(R&& range) {
std::string result = "[";
bool first = true;
for (const auto& item : range) {
if (!first) result += ',';
result += item.to_json();
first = false;
}
result += ']';
return result;
}
函数返回拥有型的 std::string,没有惰性视图逃逸出边界。Range 约束确保只有可序列化类型的集合才被接受,约束违反时错误信息指向 concept 名称而非循环体内部。
middleware.cppm 使用 std::ranges::rbegin 和 std::ranges::rend 反向迭代中间件集合,确保列表中第一个中间件包裹在最外层:
// examples/web-api/src/modules/middleware.cppm
template <std::ranges::input_range R>
requires std::same_as<std::ranges::range_value_t<R>, Middleware>
[[nodiscard]] http::Handler
chain(R&& middlewares, http::Handler base) {
http::Handler current = std::move(base);
for (auto it = std::ranges::rbegin(middlewares);
it != std::ranges::rend(middlewares); ++it)
{
current = apply(*it, std::move(current));
}
return current;
}
这是在局部算法内善用 range 工具的好例子:用 rbegin/rend 清晰表达了反向迭代意图而非依赖索引运算,函数产出的也是拥有型结果。
同样的谨慎适用于 pipeline 中的 string_view 和 span。转换步骤产生的借用切片,只要源对象的生命周期局部且明显就没有问题。但如果这些切片被跨线程传递、排入队列留待后续处理、或者被缓存起来就很危险了。
std::generator 适用于拉取式数据源,不是用来替代每一个循环的
C++23 的 std::generator 有用,因为有些序列天生不适合”先全部存下来再遍历”。它们是增量产生的:分页数据库扫描、目录遍历、分块文件读取、带重试的轮询,以及随着字节到达而逐条产出完整消息的协议解码器。
这正是 generator 能改变设计的场景。它让生产者可以在产出元素之间保留状态,不需要把调用方推入回调反转的泥潭,也不需要手写迭代器类。
从远程 API 分页读取数据的批处理作业就是个典型例子。generator 出现之前,要表达增量式的分页获取序列,要么用回调反转要么手写迭代器类:
// Pre-C++23: hand-written iterator for paged results
class PagedResultIterator {
public:
using value_type = Row;
using difference_type = std::ptrdiff_t;
PagedResultIterator() = default; // sentinel
explicit PagedResultIterator(Client& client)
: client_(&client) { fetch_next_page(); }
const Row& operator*() const { return rows_[index_]; }
PagedResultIterator& operator++() {
if (++index_ >= rows_.size()) {
if (next_token_.empty()) { client_ = nullptr; return *this; }
fetch_next_page();
}
return *this;
}
bool operator==(const PagedResultIterator& other) const {
return client_ == other.client_;
}
private:
void fetch_next_page() {
auto page = client_->fetch(next_token_);
rows_ = std::move(page.rows);
next_token_ = std::move(page.next_token);
index_ = 0;
}
Client* client_ = nullptr;
std::vector<Row> rows_;
std::string next_token_;
std::size_t index_ = 0;
};
大约四十行样板代码就为了表达”逐页获取,逐行产出”。换成 std::generator,同样的逻辑只需要:
std::generator<Row> paged_rows(Client& client) {
std::string token;
do {
auto page = client.fetch(token);
for (auto& row : page.rows)
co_yield std::move(row);
token = std::move(page.next_token);
} while (!token.empty());
}
Generator 版本把所有状态(page token、当前 buffer、当前位置)隐含在协程帧里,控制流一目了然。手写迭代器版本的 sentinel 比较、索引记账、边界处的页面抓取逻辑全靠手工维护,稍有不慎就引入微妙错误。
先把所有行全部物化再处理,既浪费内存又推迟了第一条有用数据的产出。Generator 可以逐行产出,同时把 page token、buffer 和重试状态封装在生产者内部。
话虽如此,generator 是协程机制,随之而来的是挂起点、帧生命周期管理,以及因实现和优化策略不同而变化的分配开销。并非零成本,调试也比”一个局部 vector 加一个循环”更麻烦。增量生产确实是问题的本质结构时才值得使用,不要因为时髦就拿它替代普通容器。
还有一个边界问题:generator 产出的是拥有型值还是指向内部 buffer 的借用引用?产出借用引用并非不可以,但前提是跨越挂起点的生命周期关系足够显式、易于推理。很多时候产出较小的拥有型值更安全。
如果目标工具链对 std::generator 的标准库支持尚不完整,上述设计指导同样适用于任何等价的协程驱动 generator 类型。关键在于结构设计,不是具体的厂商实现。
惰性虽好,但可观测性和错误处理可能更重要
惰性 pipeline 把工作推迟到真正需要时才执行,这通常有用。但副作用是:插桩、异常或 expected 的传播、失败归因,都可能比读者预期的要晚得多。
以日志处理路径为例:一个按需过滤、解析、充实和序列化的 pipeline 看上去很优雅,但从运维角度看它可能把故障扩散到最终消费者身上。解析失败时错误该归到哪一步?需要统计被丢弃的记录数时计数器该在哪里递增?链路追踪需要分阶段计时时,一个融合的惰性 pipeline 里“阶段“怎么界定?
这就是物化点存在的意义。把一条长 pipeline 拆成若干具名阶段,中间用拥有型结果衔接,能让系统更易观察、更好推理,哪怕代价是多一点内存。并非所有临时分配都是浪费,有些正是你挂载指标、隔离故障、让调试器停在正确位置的基础。
不要把惰性等同于高效。融合操作有时能减少工作量,但有时也会阻碍并行化、干扰分支预测,或者让最昂贵的环节更难被发现。对整条路径做 benchmark,不要想当然地认为 pipeline 形式一定更快。
Range 和 generator 不再适用的场景
Range 不是好选择的场景:就地修改是核心逻辑、控制流不规则、提前退出伴随重要副作用,或算法已被外部 I/O 和锁操作主导。Generator 不是好选择的场景:普通容器返回结果既便宜又简单、序列需要反复遍历,或协程跨子系统的生命周期比局部 buffer 更难理清。
还有一种常见误区:把 pipeline 当成性能秀场。在不稳定的借用状态上串五个 adaptor,并不比一个用三个好名字变量写成的循环更高明。真正胜出的设计是所有权、成本和失败行为都能轻松说清楚的设计。
验证与评审
Range 和 generator 在代码评审时值得关注以下问题:
- 视图遍历的数据由谁拥有?在整个迭代过程中,拥有者是否确定还存活?
- 惰性工作实际在哪里执行?这个执行时机对错误处理和指标采集是否可接受?
- 是否有刻意设置的物化点,用于让所有权或可观测性变得显式?
- 换成普通循环,控制流和副作用是否会更清晰?
- Generator 产出的是拥有型值还是借用值?该生命周期跨越挂起点后是否仍然有效?
动态工具在这方面很有价值。AddressSanitizer 和 UndefinedBehaviorSanitizer 能在运行时暴露视图的生命周期错误。以吞吐量为由引入 pipeline 的场景需要做 benchmark。但代码评审仍然是最主要的防线,很多惰性相关的生命周期 bug 只要沿着所有权链条认真追踪一遍,在结构上就是显而易见的。
要点
- 当线性数据流是主线、副作用只是次要因素时,使用 range pipeline。
- 始终把视图当借用抽象看待,数据拥有者必须明确可见且保持存活。
- 生命周期契约不清晰时,不要把深层惰性 pipeline 暴露到宽泛的 API 边界之外。
- 用 generator 表达增量产生的序列,不要拿它当普通容器的时髦替代品。
- 可观测性、错误隔离或生命周期清晰度比极致惰性更重要时,果断插入物化点。
不失控的编译期编程
编译期编程是 C++ 中最容易把专业能力变成自我伤害的领域之一。你可以把计算移到常量求值阶段、按类型分派、提前拒绝无效配置,还能在程序运行前合成表格或元数据。但代价同样真实:更长的构建时间、更差的诊断信息、逻辑向头文件扩散,以及诱使工程师把业务规则写成没人愿意调试的形式。
生产环境中正确的问题不是”这能不能在编译期做”,而是”放到编译期做之后,什么会变得更安全、更省事、更难被误用?构建时间和可维护性成本值不值得?”
带着这个思路,编译期技术就能各就其位:它们是工程工具,用来消除无效状态、验证固定配置、在变化确实是静态的地方特化底层行为。不比运行时代码高人一等。
优先选择看起来仍像普通代码的 constexpr(常量表达式)代码
最理想的现代编译期编程往往就是普通代码,只不过恰好也能在常量求值期间运行。一个解析辅助函数、小型查找表构建器或单位转换例程加上 constexpr 后依然清晰易读,通常就是最好的状态。
过去 C++ 元编程的大部分痛苦源于不得不把逻辑塞进类型层编码或模板递归,能用运行时代码没人会自愿这么写。C++20 和 C++23 大幅缓解了这种压力:现在可以直接在 constexpr 函数里写循环、分支和小型局部数据结构。
这改变了设计取舍。编译期例程读起来跟普通代码差不多,评审和调试就还在可接受的范围内。但如果为了在编译期运行不得不写出更怪异的算法,收益就必须大到足以弥补代价。
旧世界:递归模板和类型层算术
看一个 C++11 之前的经典任务就能感受 constexpr 带来的变化:编译期计算阶乘。没有 constexpr 的年代,唯一的办法是递归模板实例化:
// Pre-C++11: compile-time factorial via template recursion
template <int N>
struct Factorial {
static const int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static const int value = 1;
};
// Usage: Factorial<10>::value
能跑,但逻辑全编码在类型系统里而非写成代码。没有循环、没有变量、没有调试器支持。递归深度超限时报错就是一长串模板实例化的回溯。更复杂的场景(比如编译期字符串处理或表生成)需要的技巧更晦涩:把变参模板包当值列表用,用递归 struct 层级模拟数组,靠 SFINAE 技巧模拟条件分支。
现代写法就是一个普通函数:
constexpr auto factorial(int n) -> int {
int result = 1;
for (int i = 2; i <= n; ++i)
result *= i;
return result;
}
// Usage: constexpr auto f = factorial(10);
结果一样,同样在编译期求值,但写出来就是任何 C++ 程序员都看得懂的普通代码;需要时还可以在运行时用调试器单步跟踪。编译期编程不再需要一套截然不同的心智模型。
更贴近实际的例子:编译期查找表的构造。旧写法中生成一张 CRC 值表需要递归模板,每个表项实例化一次自身,通过嵌套类型别名逐步累积结果,几乎无法扩展或调试。用 constexpr 只需要一个循环填充 std::array:
constexpr auto build_crc_table() -> std::array<std::uint32_t, 256> {
std::array<std::uint32_t, 256> table{};
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t crc = i;
for (int j = 0; j < 8; ++j)
crc = (crc >> 1) ^ (0xEDB88320u & (~(crc & 1u) + 1u));
table[i] = crc;
}
return table;
}
constexpr auto crc_table = build_crc_table();
一个恰好在编译期运行的普通函数,就替代了原本可能长达几百行的模板机制。
适合这种做法的场景:固定翻译表、协议字段布局辅助函数、小型 enum 的经过验证的查找映射,以及根据常量输入组装的命令元数据。共同特征是输入天然是静态的,提前算好能减少启动开销,也让无效组合在源头上不可能出现。
配套项目 examples/web-api/ 中有几个实例。在 error.cppm 中,一个 constexpr 函数把错误码映射为 HTTP 状态码:
// examples/web-api/src/modules/error.cppm
[[nodiscard]] constexpr int to_http_status(ErrorCode code) noexcept {
switch (code) {
case ErrorCode::not_found: return 404;
case ErrorCode::bad_request: return 400;
case ErrorCode::conflict: return 409;
case ErrorCode::internal_error: return 500;
}
return 500;
}
这就是“恰好也能在编译期运行“的普通代码。读起来像运行时函数,可以在运行时测试,也可以在 constexpr 上下文中求值,比如在验证映射一致性的 static_assert 中使用。配套函数 to_reason() 对人可读的原因字符串做了同样的事,返回 std::string_view 字面量。
http.cppm 提供了解析和格式化 HTTP 方法字符串的 constexpr 函数:
// examples/web-api/src/modules/http.cppm
[[nodiscard]] constexpr Method parse_method(std::string_view sv) noexcept {
if (sv == "GET") return Method::GET;
if (sv == "POST") return Method::POST;
if (sv == "PUT") return Method::PUT;
if (sv == "PATCH") return Method::PATCH;
if (sv == "DELETE") return Method::DELETE_;
return Method::UNKNOWN;
}
两个函数都是用普通控制流表达的编译期查找表,不需要模板机制,误用时诊断信息清晰,运行时也可以正常调试。这是 constexpr 的甜蜜点:输入来自一个小的静态集合,映射关系足够稳定,编译期求值增加了安全性而没有带来复杂性。
只有当延迟失败本身就是设计 bug 时,才使用 consteval(强制编译期求值)
consteval 比 constexpr 更严格:强制在编译期求值。如果允许运行时回退会导致某些绝不该混进生产环境的配置错误被掩盖,就应该用 consteval。
举个例子:某个线路协议子系统有一组固定的消息描述符,每个描述符必须有唯一的 opcode 和受限的 payload 大小。这些约束不是动态业务逻辑,是程序静态结构的一部分。编译期发现重复 opcode 显然好过启动时暴露问题,更别说等到集成测试中因为路由 bug 才发现。
struct MessageDescriptor {
std::uint16_t opcode;
std::size_t max_payload;
};
template <std::size_t N>
consteval auto validate_descriptors(std::array<MessageDescriptor, N> table)
-> std::array<MessageDescriptor, N>
{
for (std::size_t i = 0; i < N; ++i) {
if (table[i].max_payload > 64 * 1024) {
throw "payload limit exceeded";
}
for (std::size_t j = i + 1; j < N; ++j) {
if (table[i].opcode == table[j].opcode) {
throw "duplicate opcode";
}
}
}
return table;
}
constexpr auto descriptors = validate_descriptors(std::array{
MessageDescriptor{0x10, 1024},
MessageDescriptor{0x11, 4096},
MessageDescriptor{0x12, 512},
});
具体的错误文本和机制还可以打磨,但设计思路站得住脚:这些描述符是程序的静态结构,编译期拒绝无效表格值得付出相应成本。
常见的误用是对本质上非静态的逻辑也用 consteval 强制求值。某个值完全可能来自部署配置、用户输入或外部数据,硬拖进编译期通常只会得到一个别扭又脆弱的设计。
if constexpr(编译期条件分支)应当区分真正的类型族群,不是塞进任意业务逻辑
if constexpr 是现代泛型代码中最有用的工具之一,让基于类型的分支既局部又清晰。用得好,一套实现就能适配少量真正有意义的模型差异,不必拆成满地开花的特化版本。
用得不好,它会把一个函数模板变成无关行为的垃圾堆。
适用场景举例:trivially copyable 的 payload 与非平凡领域对象之间的存储策略差异;或者格式化辅助函数在对外保持统一接口的同时对字节 buffer 和结构化记录分别处理。这些变化是表示形式或类型能力的差异。
if constexpr 出现之前,这种按类型分支的需求只能靠 tag dispatch 或 SFINAE 重载集来实现:
// Pre-C++17 tag dispatch: two overloads selected by a type trait
template <typename T>
void serialize_impl(const T& val, Buffer& buf, std::true_type /*trivially_copyable*/) {
buf.append(reinterpret_cast<const std::byte*>(&val), sizeof(T));
}
template <typename T>
void serialize_impl(const T& val, Buffer& buf, std::false_type /*trivially_copyable*/) {
val.serialize(buf); // requires a member function
}
template <typename T>
void serialize(const T& val, Buffer& buf) {
serialize_impl(val, buf, std::is_trivially_copyable<T>{});
}
可行,但会把逻辑上完整的函数打散到多个重载里。读者必须顺着 tag dispatch 追踪才能理清分支。有了 if constexpr,同样的逻辑可以写在一处:
template <typename T>
void serialize(const T& val, Buffer& buf) {
if constexpr (std::is_trivially_copyable_v<T>) {
buf.append(reinterpret_cast<const std::byte*>(&val), sizeof(T));
} else {
val.serialize(buf);
}
}
两条分支都在同一个函数里。未命中的分支不会被实例化,因此不需要对当前类型也能编译通过。
反面案例:仅仅因为”编译器能把它优化掉”就把每条产品特定规则都塞进编译期分支。这样做把应用层策略绑死在类型结构上,每多加一个条件函数就更难审查。分支真正关心的是运行时业务含义而非静态类型能力时,普通运行时代码通常更清晰。
编译期分支最适合表达稳定的类型族群关系。用它只是为了省掉另写一个同样简单的函数,多半是用错了地方。
主要成本:构建开销、诊断质量和组织拖累
运行时代码的成本体现在执行开销上,编译期代码的成本体现在团队身上。
大段常量求值表、大量实例化的模板、定义在头文件中的辅助框架,都会拖慢增量构建、让依赖图更脆弱。常量求值失败时的诊断信息依然可能难以理解,尤其多层模板和 concept 叠加时。编译期机制往往放在头文件里,实现细节的泄漏范围比运行时代码大得多。
生产环境中的编译期编程应当紧贴少数几个验证过的收益点。
- 尽早拒绝静态上无效的程序结构。
- 为固定数据消除少量启动工作。
- 基于静态能力专门化低层操作。
- 让生成的表和元数据与声明的类型保持一致。
超出这些范围,投入产出比会迅速下降。
还有组织成本。一旦团队把复杂的编译期基础设施视为常态,更多工程师就会在上面继续搭建,不是因为”这是最清晰的解法”而仅仅因为”它已经在那儿了”。抽象的表面积不断膨胀,能放心审查的人越来越少。到最后项目里就出现了两层复杂性:运行时代码,以及塑造运行时代码的编译期框架。
现代 C++ 中,几乎没有哪个领域比这里更需要克制。
代码生成有时比元编程更好
数据源来自外部或者规模很大时,代码生成通常是更划算的工程选择。协议 schema、遥测目录、SQL 查询清单,或者从外部定义提取的命令注册表,用生成器来验证和演进往往比搭一座模板高塔外加 constexpr 解析器更容易管理。
这不是认输,是认清现实:有些复杂性放在构建工具链里管理比塞进 C++ 类型系统更合适。生成出来的 C++ 照样可以暴露干净的强类型接口,区别只是复杂性放在哪里、失败模式有多容易被看见。
经验法则:源数据规模小、本身是静态的、天然适合直接写在代码里,优先在 C++ 内做编译期编程。源数据规模大、来自外部、本来就维护在另一种格式中,优先用代码生成。两者的平衡点通常比模板爱好者愿意承认的来得更早。
失败模式与边界
编译期编程的失败模式往往大同小异。
常见的情况:为了节省一个从未测量过的启动开销,用密密麻麻的模板机制取代了本来可读性很好的运行时代码。或者把部署期配置拉进编译期,结果本应是运维层面的调整变成了必须重新构建才能生效。或者把“constexpr 能求值通过“当成整体设计更优的证据,哪怕构建时间和诊断质量都已明显恶化。
编译期能证明的东西有边界。它可以验证固定形状、常量关系和类型层面的能力,但无法替代集成测试、资源边界测试或运维验证。一张在编译期通过校验的分发表,其指向的 handler 在运行时照样可能产生错误的副作用。
让编译期逻辑紧贴设计中真正静态的部分,不要让它蔓延成一种通用架构风格。
验证与评审
验证既关乎正确性也关乎成本。
- 对核心编译期辅助函数中不希望退化的规则,用
static_assert守护。 - 即使表格和元数据是在编译期构建的,也要保留有代表性的运行时测试,常量求值不能证明动态行为的正确性。
- 引入以头文件为中心的编译期基础设施时,关注增量构建时间的变化。
- 审查失败场景下的错误信息。报错信息让人看不懂说明抽象还没准备好投入生产。
- 问自己一句:同样的效果能不能用更简单的运行时代码或代码生成来达到?
最后这个问题是团队最常略过的,往往也是最有价值的。
要点
constexpr代码应该看起来跟普通代码没什么两样。- 只有当运行时回退本身就意味着设计错误时,才使用
consteval。 if constexpr用在稳定的类型能力差异上,不要拿来编码任意的业务分支。- 构建时间、诊断质量和可审查性,都是一等成本。
- 一旦编译期机制不再让程序的静态结构更清晰,就退回到更简单的运行时代码或代码生成工具链。
接口设计与依赖方向
本章假定你已经读过函数签名设计、所有权、不变量和失败边界的相关内容。这里要讨论的不是怎么写好一个函数,而是怎么设计一条经得起团队扩张和系统演进压力的边界。
生产问题
大多数接口问题不是来自一眼就能看出的烂代码,而是来自那些局部看来合理、却逐渐固化为系统级耦合的决定。存储层直接返回数据库行类型——反正手头就有嘛。服务边界接收一个硕大的配置对象——万一将来要加选项呢。库里所有回调一律用 std::function——看着挺灵活的。半年之后,测试要拉起半张依赖图,调用点到处泄漏传输层细节,改个实现细节就成了破坏性变更。
本章讨论的是源码层面的接口设计:边界暴露什么、依赖应当朝哪个方向指、如何防止策略、所有权和表示形式跨层泄漏。运行时分派机制是下一章的事,二进制兼容性和分发是再下一章的事。关注点很窄:决定程序的一部分可以知道另一部分的哪些事实。
核心规则很简单:依赖应当指向稳定的策略和领域含义,而非易变的实现细节。换句话说,接口应该用调用方已经理解的概念来构建,而不是围绕被调用方的存储、传输、框架或日志方案来设计。
为什么这会变得昂贵
错误的依赖方向会以代码评审很难察觉的方式悄悄放大成本。
一旦领域逻辑直接依赖了 SQL 行类型、protobuf 生成类、HTTP 请求包装器或文件系统遍历状态,每次测试、benchmark 和重构都得把这些细节一并拖进来。依赖图会比设计实际需要的宽得多。传递性的头文件包含和模板实例化把实现细节散播到各处,拖慢构建速度。边界违规一旦成为家常便饭,评审质量也随之下滑。那些本应局部化的设计决策,再也局部不了了。
代价不只是编译时间,还有概念层面的稳定性。好的接口扛得住数据库变更、队列替换或日志系统重写;坏的接口会迫使代码库的其他部分去重新学习那些本就与它们无关的内部细节。
从边界问题开始
动手写接口之前,先强迫自己把生产问题压缩成一句话。
对于原生服务,问题通常不是”repository 怎么暴露?”而是”订单工作流怎样获取客户的信用状态?”对于共享库,问题不是”解析器的内部实现怎么公开?”而是”调用方需要怎样的契约才能校验并转换输入记录?”
这个视角转换直接影响类型的形状。围绕实现名词设计的接口,容易泄漏机制;围绕工作职责和不变量设计的接口,往往能保持精简。
一个接口应当能清楚回答四个问题:
- 调用方需要什么能力?
- 数据和生命周期归哪一侧所有?
- 失败在哪里被转换为第 3 章的错误模型?
- 哪些策略在这里已经定死,哪些留给调用方自行决定?
如果这些问题答不清楚,接口多半是在混层。
依赖方向意味着策略方向
依赖倒置常被机械地解释为”依赖抽象,而非具体实现”。没错,但光这样说不够。真正管用的判据是:依赖箭头是否指向稳定的策略。
在一个服务里,业务规则的变化速度通常远慢于传输胶水代码。欺诈策略不应依赖 HTTP handler,订单校验不应依赖 SQL 记录包装器。领域逻辑可以定义自己需要的 port,让数据库或网络 adapter 去实现它。
但不是说每个边界都得有一个抽象基类。很多时候根本不需要。有时候正确的边界就是一个接收领域数据的自由函数;有时候是内部库里一个受 concept 约束的模板;有时候是一对值类型的请求/结果对象,完全不涉及虚派发。真正要问的不是”接口类型放在哪?”,而是”哪一侧有资格给契约命名?”
答案通常是:拥有更稳定词汇的那一侧。
反模式:接口由依赖项来定义
一旦契约由实现细节来命名,依赖箭头就已经指反了。
// Anti-pattern: domain code now depends on storage representation.
struct AccountRow {
std::string id;
std::int64_t cents_available;
bool is_frozen;
std::string fraud_flag;
};
class AccountsTable {
public:
virtual std::expected<AccountRow, DbError>
fetch_by_id(std::string_view id) = 0;
virtual ~AccountsTable() = default;
};
std::expected<PaymentDecision, PaymentError>
authorize_payment(AccountsTable& table, const PaymentRequest& request);
乍一看好像可测试——毕竟用了抽象基类。但接缝本身就是错的。支付工作流不应该知道可用额度是以“分“为单位存储的,更不应该知道旁边还躺着一个从表行加载出来的欺诈标记字符串。这个抽象保住了依赖关系,却丝毫没有改善依赖方向。
更好的做法是让工作流来定义 port,只返回工作流所需的最少稳定事实。
struct CreditState {
Money available;
bool frozen;
RiskLevel risk;
};
class CreditPolicyPort {
public:
virtual std::expected<CreditState, PaymentError>
load_credit_state(AccountId account) = 0;
virtual ~CreditPolicyPort() = default;
};
std::expected<PaymentDecision, PaymentError>
authorize_payment(CreditPolicyPort& credit, const PaymentRequest& request);
现在工作流依赖的是领域含义,而非存储形状。与 SQL 打交道的 adapter 负责做转换。这确实有额外工作量,但这才是正确的工作量——把易变性收束在易变事物附近。
反模式:会把周围一切都吸进来的胖接口
臃肿的接口不只是不好看。它会形成耦合引力场:每个新功能都往现有接口上加,因为加个方法总比重新审视边界来得省事。
// Anti-pattern: a "god interface" that mixes query, mutation, lifecycle,
// metrics, and configuration concerns in one surface.
class UserService {
public:
virtual std::expected<UserProfile, ServiceError>
get_profile(UserId id) = 0;
virtual void update_profile(UserId id, const ProfilePatch& patch) = 0;
virtual void ban_user(UserId id, std::string_view reason) = 0;
virtual std::vector<AuditEntry>
get_audit_log(UserId id, TimeRange range) = 0;
virtual void flush_cache() = 0;
virtual MetricsSnapshot get_metrics() const = 0;
virtual void set_rate_limit(RateLimitConfig config) = 0;
virtual ~UserService() = default;
};
这个接口至少混杂了四条互不相关的变化轴:用户数据访问、审核策略、运维可观测性、运行时配置。一个只想读 profile 的调用方,却被迫传递性地依赖审计、缓存、metric 和限流的类型。测试替身为了伪造一个行为,得实现全部七个方法。加一个审核动作,只读消费者也得重新编译。这个接口是一个依赖黑洞,每次变更都很贵,每个测试都很脆。
解决办法是沿职责边界拆分:
class UserProfileQuery {
public:
virtual std::expected<UserProfile, ServiceError>
get_profile(UserId id) = 0;
virtual ~UserProfileQuery() = default;
};
class ModerationActions {
public:
virtual void ban_user(UserId id, std::string_view reason) = 0;
virtual std::vector<AuditEntry>
get_audit_log(UserId id, TimeRange range) = 0;
virtual ~ModerationActions() = default;
};
这样一来,只读消费者只依赖 UserProfileQuery,审核工具只依赖 ModerationActions,运维相关的东西住在另一个接口里。各自独立演化,测试替身也很简单。
反模式:通过接口泄漏实现细节
接口再小,只要暴露了不该暴露的类型,照样能伤害整个系统。
// Anti-pattern: interface leaks the JSON library into every consumer.
#include <nlohmann/json.hpp>
class RetryConfigProvider {
public:
virtual nlohmann::json load_retry_config() = 0;
virtual ~RetryConfigProvider() = default;
};
这样一来,每个包含这个头文件的编译单元都依赖了 JSON 库,不管它自己用不用 JSON。想换成 TOML、YAML 或二进制配置格式?整个代码库都得跟着改。JSON 库带来的编译开销、宏定义和传递性头文件也跟着扩散到无关组件里。调用方还得在 JSON 树上手动提取重试参数(初始退避时间、最大退避时间、最大重试次数),隐式的 schema 知识因此散落在代码库各处。
解决办法是返回有领域含义的类型:
struct RetryConfig {
std::chrono::milliseconds initial_backoff;
std::chrono::milliseconds max_backoff;
std::uint32_t max_attempts;
};
class RetryConfigProvider {
public:
virtual std::expected<RetryConfig, ConfigError>
load_retry_config() = 0;
virtual ~RetryConfigProvider() = default;
};
现在 JSON 依赖被关在 adapter 实现内部。消费者拿到的是强类型、已校验的值。接口传达的是领域含义,而非序列化格式。
反模式:抽象层级错误
抽象层级不对的接口,要么逼调用方去做本应被封装的工作,要么拦着调用方做它真正需要做的事。
// Anti-pattern: too low-level. Caller must assemble SQL semantics
// even though this is supposed to abstract away storage.
class DataStore {
public:
virtual std::expected<RowSet, DbError>
execute_query(std::string_view sql) = 0;
virtual std::expected<std::size_t, DbError>
execute_update(std::string_view sql) = 0;
virtual ~DataStore() = default;
};
这个接口号称抽象了存储,实际上却把 SQL 当作字符串协议直接暴露出来。调用方仍然得了解 schema、拼正确的 SQL、解析 RowSet 结果。SQL 注入防不住,schema 耦合也甩不掉。它不过是个直通层——多了一层间接,依赖一点没少。
反过来,接口也可能抽象得太高,反而妨碍了正常使用:
// Anti-pattern: too high-level. No way to paginate, filter,
// or control what gets loaded.
class OrderRepository {
public:
virtual std::vector<Order> get_all_orders() = 0;
virtual ~OrderRepository() = default;
};
正确的抽象层级应当贴合调用方实际要做的操作,使用领域词汇,同时提供足够的控制力来保证效率。
通过分离命令与查询来保持接口小巧
臃肿接口的根源通常是把毫不相关的变更理由混到了一起。一个边界如果既读状态、又改状态、还发审计事件、开事务、暴露 metric 快照,那它不是灵活,而是又一个依赖汇点。
把命令和查询分开,往往就能恢复清晰度。查询路径要的是值类型的请求和结果、可预测的开销、没有隐藏的副作用。命令路径要的是显式的所有权转移、明确的副作用和严格的失败语义。硬塞成一个接口,就是在纵容偶然耦合,调用方迟早会依赖上个季度顺手塞进去的某个方法。
接口越小,评审也越容易。评审者可以直接问:这里的每个函数到底属不属于同一个边界?而一旦接口沦为”附近操作的大杂烩”,这个问题就很难回答了。
examples/web-api/src/modules/repository.cppm 中的 TaskRepository 就是一个保持聚焦的窄接口。它的公共面只有 CRUD 操作:create、find_by_id、find_all、find_completed、update、remove 和 size。没有日志方法,没有配置开关,没有 metric 快照,没有缓存刷新。锁策略(std::shared_mutex)、存储表示(std::vector<Task>)、ID 生成(std::atomic<TaskId>)全部是 private 的。调用方依赖的是领域操作,而不是 repository 碰巧如何实现它们。
数据形状:接受稳定视图,返回拥有型领域值
第 4 章讨论的是局部的签名选择。到了接口边界,同样的规则就升格为架构规则。
如果被调用方不需要保留数据,输入通常应接受非拥有视图:std::string_view、std::span<const std::byte>、领域对象的 span,或者引用调用方所持数据的轻量请求结构体。这样调用点既便宜又坦诚。
输出通常应返回拥有型值或生命周期明确的领域对象。如果返回的是指向 adapter 内部存储的视图、指向 cache line 的借用指针、指向内部状态的迭代器,那就把边界变成了生命周期谜题,很少值得这么做。
这种不对称是刻意的。开销敏感而数据无需留存时,就从调用方借用;跨边界往回传时,则交出所有权,因为被调用方掌控着自己的内部实现,不应该强迫调用方操心这些实现能活多久。
也有例外。热路径解析器、零拷贝数据流水线、内存映射处理阶段可能有意返回视图。但即便如此,生命周期边界也必须写进接口契约,而不能靠口口相传。一个与特定 buffer 拥有者绑定的 ParsedFrameView 类型,远比泄漏裸 std::string_view 或原始指针、然后指望评审者自己发现这层耦合,要安全得多。
不要通过可选参数偷运策略
想让接口迅速变得含混,最简单的办法就是用配置对象或默认参数把策略决策塞到调用方根本推理不了的地方。
如果一个函数挂着 skip_cache、best_effort、emit_audit、allow_stale、retry_count 之类的标志,它多半在干太多事。问题不在美观,在于调用方现在可以拼出语义不清、未经测试甚至运维上危险的参数组合。
替代方案:
- 拆成几个命名更清晰的独立操作。
- 把策略提升为显式类型,使无效状态要么不可能出现,要么一眼可见。
- 把策略选择上移一层,让低层接口保持确定性行为。
策略被显式命名,而不是埋在参数乱炖里,接口才容易演化。
可测试性是结果,不是目标
团队经常用”方便测试”来为引入接口辩护。因果倒了。先问:边界是否反映了真实的设计意图?如果是,测试自然会变简单;如果不是,测试替身只是在帮你维护一个错误。
仅仅为了在单元测试里伪造数据库访问就引入一个 repository 接口,理由站不住,尤其当领域层仍然依赖表结构的数据和传输层的错误类型时。测试也许确实更好写了,但设计照样是错的。
好的边界之所以能产出好的测试,是因为它把策略和机制分离了。你可以用简单的 fake 测试业务逻辑,因为业务逻辑要的是领域事实,不是框架对象。你可以单独对 adapter 做集成测试,因为转换逻辑被收拢在一处。这比”现在我们能 mock 它了”强得多。
在内部使用概念和模板,而不要把它们当成公共逃生口
现代 C++ 让你很容易用约束而非虚类来表达接口。在组件内部或严格受控的代码库里,这往往是正确选择。受 concept 约束的模板可以做到零分配、可内联,表达力也常常胜过深层继承体系。
然而,一个试图用模板包打天下的公共接口,往往到最后已经不像接口了。它同时是策略配置面、编译期集成机制和文档负担。报错信息劣化,构建依赖膨胀,调用点的预期也变得模糊不清。
只有同时满足以下条件时,才适合使用 concept 约束的接口:
- 调用方和被调用方一起编译。
- 定制点对性能或数据表示至关重要。
- 你能把语义契约讲清楚,而不仅仅是语法契约。
条件不满足的话,一个更小的、以值为中心的 API 或运行时边界通常是更好的选择。
失败转换属于边界
接口也是失败语义显式化的地方。adapter 内部说的可能是 SQL 异常、gRPC 状态码或平台错误值,但系统其他部分不必讲同样的”方言”。
尽可能在靠近易变依赖的位置完成失败转换。面向领域的接口应暴露调用方真正能据以决策的失败类别。这能防止业务逻辑对传输层或厂商错误分类体系的依赖,也让日志和重试逻辑更好理解。
但别把错误过度泛化到毫无信息量。”操作失败”算不上边界模型。要暴露稳定的、与决策相关的类别,同时把不稳定的后端细节封装起来。
examples/web-api/ 示例项目给出了一个具体示范。handlers.cppm 中的 result_to_response() 恰好坐落在领域逻辑与 HTTP 传输之间的边界上:
// examples/web-api/src/modules/handlers.cppm
template <json::JsonSerializable T>
[[nodiscard]] http::Response
result_to_response(const Result<T>& result, int success_status = 200) {
if (result) {
return {.status = success_status, .body = result->to_json()};
}
return http::Response::error(result.error().http_status(),
result.error().to_json());
}
领域代码始终只与 error 模块中的 Result<T> 和 ErrorCode 打交道。HTTP 状态码映射在 error.cppm 的 to_http_status() 中一次定义,转换为 HTTP 响应的工作则发生在 handler 层。领域类型不知道 HTTP 响应长什么样,handler 也不向传输层泄漏领域错误的内部结构。边界负责翻译,两侧各说各的词汇。
什么时候不该抽象
有些代码就该直接依赖具体类型。过度抽象只会制造间接层,隐藏开销,让简单路径变得难读。
如果一个类型只在单个子系统里用、只有一种显而易见的实现、换掉它也不会带来不同的部署或测试策略,直接依赖它通常就是对的。内部辅助类型、解析器、作用域限于组件内的分配器、单后端的 pipeline 阶段,不会因为套上了 port 就自动变好。
判断标准不是”理论上能不能抽象”,而是”这个边界是否隔离了一条真实的变化轴或策略”。如果答案是否定的,就让依赖保持具体、保持局部。
验证与评审问题
接口设计应当像性能和并发一样接受评审。
评审时可以问:
- 接口暴露的是领域含义还是实现细节?
- 边界处的所有权和生命周期是否一目了然?
- 失败类型是否已转换为调用方能据以决策的形式?
- 调用方能否在不了解存储、传输或框架内部的前提下正确使用这个 API?
- 依赖箭头是否指向更稳定的策略词汇?
- 这里的抽象是有真实变化轴支撑的,还是纯粹为了能 mock?
验证不只靠代码评审。集成测试应当覆盖真正发生转换的 adapter 边界。构建性能分析也有价值:如果一个看似干净的接口仍然把大量传递性依赖拖得到处都是,设计很可能只是给源码级耦合披了层伪装。
要点
接口设计的核心,就是决定什么东西绝不能泄漏出去。
依赖方向要对齐稳定策略,而非一时方便的实现。无需留存数据时,接受廉价的借用输入;跨边界返回时,交出拥有型领域值。按职责拆分接口,而非堆砌一堆操作。在易变依赖进入系统的位置完成失败转换。只在真实的设计接缝处做抽象。
如果调用方想正确使用你的 API,却不得不了解你的数据库 schema、传输包装类型、框架句柄或内部存储的生命周期,那这个边界承载的东西就已经太多了。趁耦合还没变成常态,赶紧重新设计。
运行时多态、类型擦除与回调
本章假定你已经知道如何定义良好的源码级边界。接下来的问题是:如何在边界上表达运行时的可变性,同时如实反映成本、生命周期和所有权。
生产问题
生产环境中的 C++ 代码迟早都要在运行时做出行为选择。服务要根据配置挑选重试策略;调度器要存储来自各个调用方的工作;库要接受遥测、过滤或认证的 hook;插件宿主要从单独编译的模块中加载行为。这些问题仅靠模板无法解决——在真正需要做决策的地方,具体类型在编译期是未知的。
这时候团队往往抓住一个熟悉的抽象到处套用。有人什么都用虚函数,有人什么都包进 std::function,还有人因为怕堆分配而围绕 void* 和函数指针手搓类型擦除(type erasure,即隐藏具体类型、只暴露行为契约的技术)。结果通常是:接口能跑,却掩盖了一系列信息——callable 是否被持有、是否会分配内存、是否会抛异常、是否可能比所捕获的状态活得更久,以及分派开销在实际热路径上是否值得关注。
本章把现代 C++23 中主要的运行时间接机制逐一拆解:经典虚派发(virtual dispatch,通过 vtable 实现的动态调用)、类型擦除,以及各种回调形式。目的不是评出哪个最好,而是帮你针对所构建边界的生命周期、所有权和性能要求,选出最合适的工具。
先决定你需要哪一种可变性
并不是所有运行时灵活性都一样。
至少有四种常见情况:
- 拥有多个长生命周期实现的稳定对象协议。
- 提交后延迟执行的 callable。
- 在一次操作中同步调用的短生命周期 hook。
- 跨越打包或 ABI 边界的插件 / 扩展点。
从远处看它们很相似,因为都是在调用”某种动态的东西”。但往细处看,差异很大:被调用方需要持有什么、行为要存活多久、哪些成本才真正关键。
跳过这一步分类,错误的抽象就可能安安稳稳地用上好几年。一个同步 hook 仅仅因为被建模为 std::function,就莫名其妙地要求堆分配。一个后台任务系统因为 API 用起来方便,就存下了借来的回调状态。一个插件系统跨编译器边界暴露 C++ 类层级,还称之为“架构“。这些错误是结构性的,不是写法上的。
虚派发:适合稳定对象协议
如果你需要为一组长生命周期对象定义稳定的交互协议,虚函数仍然是最清晰的选择。存储后端接口、消息 sink、选定一次后反复使用的策略对象,都适合这个模型。
优点很直接:
- 所有权通常会在周围的对象图中显式体现。
- 协议容易以具名操作的形式文档化。
- 接口可以通过新增方法谨慎演进。
- 工具链、调试器和评审者都能立刻理解它。
缺点同样明确。层级结构容易诱导过度泛化;每次调用都要走间接派发,难以内联;哪怕一个简单的 callable 就够用,接口也必须承诺对象身份和变更语义。公开继承还把调用方锁定在一种特定的定制方式上:带 vtable(虚函数表)的对象。
当抽象天然就是对象协议时,使用虚派发。不要只是因为行为会变化就使用它。
反模式:深层继承层级与脆弱基类
一旦虚派发膨胀为又深又宽的层级结构,基类随时间不断积累新义务,它就变成了负担。
// Anti-pattern: a growing base class that every derived type must satisfy.
class Widget {
public:
virtual void draw(Canvas& c) = 0;
virtual void handle_input(const InputEvent& e) = 0;
virtual Size preferred_size() const = 0;
virtual void set_theme(const Theme& t) = 0;
virtual void serialize(Archive& ar) = 0; // added in v2
virtual void animate(Duration dt) = 0; // added in v3
virtual AccessibilityInfo accessibility() = 0; // added in v4
virtual ~Widget() = default;
};
每新增一个虚方法,所有派生类要么实现它,要么继承一个可能根本不对的默认实现。只需要绘制能力的类也得应付输入、序列化、动画和无障碍。测试一个最简单的叶子 widget,却不得不构造 Canvas、InputEvent、Theme、Archive 和 AccessibilityInfo 等一堆对象。基类成了变更放大器,往 Widget 加一个方法,整个代码库的派生类都要重新编译,甚至逐个修改。
这就是脆弱基类问题。层级结构看起来可扩展,实际上却很脆弱,因为基类接口会不断膨胀,以服务每个消费者。
菱形继承与语义歧义
接口层级中的多重继承会引入菱形问题,而 virtual 继承只能部分缓解它。
class Readable {
public:
virtual std::expected<std::size_t, IoError>
read(std::span<std::byte> buffer) = 0;
virtual void close() = 0; // close the read side
virtual ~Readable() = default;
};
class Writable {
public:
virtual std::expected<std::size_t, IoError>
write(std::span<const std::byte> data) = 0;
virtual void close() = 0; // close the write side
virtual ~Writable() = default;
};
// Diamond: what does close() mean here? Read side? Write side? Both?
class ReadWriteStream : public virtual Readable, public virtual Writable {
public:
// Single close() must now serve two different semantic contracts.
// Callers holding a Readable* expect close() to close the read side.
// Callers holding a Writable* expect close() to close the write side.
// There is no way to satisfy both through one override.
void close() override { /* ??? */ }
};
虚继承解决了内存布局的重复问题,却解决不了语义上的冲突。代码能编译过,但运行行为取决于调用方手里拿的是哪个基类指针。这种歧义是结构性的,实现得再小心也无济于事。
对照:类型擦除避开了这些问题
类型擦除完全绕开了层级结构。每个擦除包装器都定义自己的最小契约,而不会强迫不相关的类型进入共同基类。
// No base class. No hierarchy. No diamond.
// Any type that is callable with the right signature works.
using DrawAction = std::move_only_function<void(Canvas&)>;
using InputHandler = std::move_only_function<bool(const InputEvent&)>;
struct WidgetBehavior {
DrawAction draw;
InputHandler handle_input;
};
// A simple widget only provides what it needs.
// No obligation to implement serialize, animate, or accessibility.
WidgetBehavior make_label(std::string text) {
return {
.draw = [t = std::move(text)](Canvas& c) { c.draw_text(t); },
.handle_input = [](const InputEvent&) { return false; }
};
}
这里不存在会不断膨胀的基类。新增动画支持不会逼着 label widget 改动,测试 draw 也不需要构造 InputEvent。各关注点可以独立组合。代价是失去了类层级所带来的具名对象协议的清晰度,如果协议确实稳定且丰富,这一点可能很关键。取舍需要逐案权衡。
类型擦除:适合拥有型的运行时灵活性
如果你需要存储或传递运行时选定的行为,既不想暴露具体类型,也不需要继承层级,那么类型擦除就是正确的工具。
std::function 是大家最熟悉的例子,但在 C++23 中,如果可拷贝性不是契约的一部分,std::move_only_function 往往是更好的默认选择。许多被提交的任务、完成处理器和延迟操作天然就是 move-only 的,因为它们持有 buffer、promise、文件句柄或取消状态。这时候要求可拷贝不是“更灵活“,而是在误导。
类型擦除带来三件事:
- 调用方可以提供任意 callable 类型。
- 被调用方可以在当前栈帧结束后仍然拥有这个 callable。
- 公共契约可以谈调用语义,而不是具体类设计。
成本也明确,必须作为设计约束而非实现细节来对待:可能发生堆分配、间接调用开销、更大的对象表示,有时还会丢失 noexcept 或 cv/ref 限定信息,除非你在建模时专门考虑了这些。
对大多数系统而言这些成本完全可以接受。但对热分派循环或高频调度器内部,它们可能是决定性的。先拿真实负载量一量,再谈审美偏好。
examples/web-api/src/modules/middleware.cppm 中的中间件系统展示了类型擦除组合的实际用法。Middleware 被定义为 std::function<http::Response(const http::Request&, const http::Handler&)>——一个类型擦除的 callable,它包装一个 handler 并产出新 handler。apply() 将一个中间件与一个 handler 组合;chain() 通过逆序折叠,将一系列中间件组合到基础 handler 之上:
// examples/web-api/src/modules/middleware.cppm
template <std::ranges::input_range R>
requires std::same_as<std::ranges::range_value_t<R>, Middleware>
[[nodiscard]] http::Handler
chain(R&& middlewares, http::Handler base) {
http::Handler current = std::move(base);
for (auto it = std::ranges::rbegin(middlewares);
it != std::ranges::rend(middlewares); ++it)
{
current = apply(*it, std::move(current));
}
return current;
}
这里没有任何继承层级。每个中间件(日志、CORS、Content-Type 强制检查)都是独立的 callable,通过 chain() 组合,彼此毫不知情。最终结果是一个可以直接交给 server 的 http::Handler。这正是类型擦除的强项:行为可组合,实现却无需耦合进同一棵类继承树。
常见陷阱:std::function 会把 move-only 状态强行变成可拷贝
std::function 要求其 target 可拷贝。这个限制看起来人畜无害,直到真实的回调状态登场。
// This will not compile. std::function requires CopyConstructible.
auto handler = std::function<void()>{
[conn = std::make_unique<DbConnection>()](){ conn->heartbeat(); }
};
团队常见的做法是把 unique_ptr 换成 shared_ptr 来绕过编译错误,结果给原本天然属于单一所有者的代码引入了引用计数和共享可变性。编译通过了,所有权模型却被破坏了。
// Workaround: shared_ptr "fixes" compilation but lies about ownership.
auto conn = std::make_shared<DbConnection>();
auto handler = std::function<void()>{
[conn]() { conn->heartbeat(); }
};
// Now conn is shared. Who shuts it down? When? The ownership story is gone.
std::move_only_function 完全绕开了这个问题。如果你的回调会被提交、入队或延后执行,而且不会被拷贝,那么它就是 C++23 中正确的默认选择。
回调形式:借用、拥有与一次性
“回调”这个词掩盖了三种不同契约。
借用型同步回调
这类回调在当前调用期间执行,用完即弃。日志 visitor、逐记录校验 hook、遍历回调通常属于此类。核心特征是:被调用方不会存储这个 callable。
这时候强行让 API 经过拥有型包装器,通常没有必要。如果调用不出组件边界,模板化的 callable 参数可能是最简单的做法。如果需要非模板接口,轻量的借用型 callable view 也可行,但标准 C++23 尚未提供 function_ref。因此许多团队在组件内部对同步 hook 使用受约束模板,只在真正需要所有权时才动用类型擦除。
拥有型延后回调
队列、调度器、时间轮、异步客户端——这些组件往往需要把工作保留到当前栈帧之后。std::move_only_function 或带有显式分配规则的自定义擦除任务类型,天然适合这类场景。
这里的问题很具体:
- 队列是否拥有这个 callable?
- 可拷贝是 API 的一部分,还是只允许 move?
- 提交时能否分配?
- callable 是否必须是
noexcept? - 关闭时未运行的回调会怎样处理?
这些都是接口问题,不是实现细节。
一次性完成处理器
只会触发一次的完成路径,从一开始就应该建模为 move-only。这样类型本身就反映了“只用一次“的事实,也能防止有状态的 handler 被意外共享。
class TimerQueue {
public:
using Task = std::move_only_function<void() noexcept>;
void schedule_after(std::chrono::milliseconds delay, Task task);
};
这个签名说明了:队列接管所有权,可能延后运行,且要求任务不得跨调度器边界抛异常。比笼统的 std::function<void()> 参数精确得多。
反模式:表面灵活,生命周期含糊
很多回调 bug 都来自那些看似灵活、却没有说明谁拥有谁的 API。
// Anti-pattern: ambiguous retention and capture lifetime.
class EventSource {
public:
void on_message(std::function<void(std::string_view)> handler);
};
void wire(EventSource& source, Session& session) {
source.on_message([&session](std::string_view payload) {
session.record(payload);
});
// RISK: if EventSource stores the handler past Session lifetime, this dangles.
}
问题不只是引用捕获本身,而在于 API 根本没交代 handler 的去向:是只在一次操作中借用,还是存到注销为止?会不会被并发调用,或者在析构期间调用?
更好的设计会把所有权和生命周期模型写在明面上。如果回调会被保留,注册就应返回一个 handle 或注册对象,用它的生命周期来管控订阅关系。如果回调是同步的,API 就不该为了显得通用而接收拥有型的擦除 callable。
examples/web-api/ 的 main.cpp 展示了对引用捕获的作用域生命周期纪律。TaskRepository 在 router 和 handler 之前声明,server 最后声明。因为 C++ 按构造的逆序销毁局部变量,所以 repository 一定比所有捕获了其引用的 handler 和中间件活得更久。这个顺序不是偶然的——它是确保擦除 callable 中捕获的 repo 引用永远不会悬垂的结构性保证。当回调以引用方式捕获外部对象时,包围作用域必须保证被引用对象比 callable 活得更久。main() 中的声明顺序,正是这一保证的落脚点。
虚协议还是擦除 callable?
一条实用的判断原则:
如果行为天然可以描述为一组具名操作或有意义的状态转换,就选虚接口。带有 record_counter、record_histogram 和 flush 的 metrics sink 是对象协议;带有 get、put 和 remove 的存储后端也是对象协议。
如果抽象的本质是”这里有一段工作或逻辑,留着以后调用”,而非”这里有一个承担语义角色的对象”,就选擦除 callable。重试策略、完成处理器、任务提交、谓词 hook,通常都更契合这种模式。
把两者搞混,代码很快就会别扭。只有单个方法的虚类型,往往说明用回调更合适。反过来,参数列表又长又复杂的 callable 签名,往往说明这里该有一个正经的协议对象。
examples/web-api/ 示例项目在同一个代码库中同时展示了这两种选择。router.cppm 中的 Router 是一个对象协议:它有流式注册 API(get、post、put_prefix 等),管理着一张路由表,包含多个具名操作。这天然就是一个有状态的具名对象,而非单个 callable。相比之下,http::Handler 被定义为 std::function<Response(const Request&)>——一个类型擦除的 callable。每个 handler 表达的是“请求匹配时要执行的工作“。Router 的 to_handler() 方法将整张路由表折叠为一个擦除 callable,干净地桥接了两种模型。
成本模型很重要,但通常只在特定位置重要
运行时间接总有成本,但不能脱离场景空谈。
与 IO、解析、加锁、内存分配或 cache miss 相比,虚派发的开销通常微不足道。但在热数值内层循环或每秒处理数百万包的分类器里,影响可能很实在。类型擦除可能会分配内存;这是否要紧取决于提交速率、分配器行为和尾延迟敏感度。小缓冲优化或许有用,但依赖未明确规定的阈值不是稳定的契约。
不要猜。如果分派在热路径上,就用有代表性的场景实测分支行为、指令足迹、分配速率和端到端吞吐量。如果不在热路径上,就选那个最容易理清所有权和生命周期的抽象。
异常、取消与关闭语义
运行时间接往往恰好处于那些容易在错误处理上偷懒的边界。回调 API 如果没有说明异常能否跨越边界,就是没做完。任务队列如果接受了工作却没定义关闭语义,也是没做完。插件 hook 如果能在宿主不变量只更新了一半时重入,同样没做完。
要明确:
- callable 能抛异常吗?如果不能,尽量在类型上体现出来。
- 调用失败时,谁负责转换或记录错误?
- 回调会被并发调用吗?
- 关闭期间,尚未执行的回调怎么取消或排空?
- 是否允许重入?
这些决定的重要性,远超抽象底层用的是 vtable 还是擦除函数包装器。
打包边界会改变答案
在作为一个整体构建的程序内部,三种技术都可以自由使用。一旦跨越插件边界或公共 SDK,情况就不同了。跨二进制边界暴露 C++ 运行时多态会连带引入 ABI(Application Binary Interface,应用二进制接口)假设;携带分配器或异常行为的擦除 callable 在模块或共享库边界上同样可能变得不稳定。
第 11 章将打包和 ABI 作为独立问题讨论。进程内的运行时灵活性是一类设计问题,分别构建的工件之间的二进制契约是另一类。别让一个顺手的进程内抽象不知不觉变成了你的分发契约。
验证与评审问题
评审运行时间接机制时,要问的是“类型承诺了什么“,而不是“实现目前做了什么“。
- 这个边界拥有 callable,还是只借用它?
- callable 会被保留、跨线程移动,还是在关闭期间被调用?
- 可拷贝性是契约要求的,还是只是从某个方便的包装器里顺带继承来的?
- 异常和取消语义是否已明确定义?
- 分派开销在此工作负载中真的重要吗?还是说隐藏的分配才是真正的问题?
- 用具名协议对象是否比庞大的 callable 签名更清晰,还是反过来?
验证手段应包括:对回调密集路径做分配追踪,用 sanitizer 覆盖捕获状态的生命周期 bug,以及在确认为热路径时做有针对性的 benchmark。单元测试在这里能力有限,因为代价最高的故障往往是生命周期竞争、关闭时序 bug,以及持续负载下的吞吐量崩塌。
要点
运行时间接不是单一技术,而是一组工具,分别对应不同的生命周期和所有权模型。
稳定对象协议用虚派发。需要持有运行时选定的行为、又不想暴露层级结构时,用类型擦除。根据调用是同步、延后还是一次性的,选择对应的回调形式。所有权是单一的、可拷贝性会歪曲契约时,优先用 std::move_only_function 而非 std::function。
把生命周期、保留策略、异常行为和关闭语义摆在边界的明面上。隐藏的分配令人烦恼,隐藏的生命周期规则才是系统真正崩溃的根源。
模块、库、打包与 ABI 现实
本章假定源码级接口的设计已经到位。接下来讨论:代码在真实工具链中经历构建、分发、版本迭代和被其他项目使用时,这些接口会如何表现。
生产问题
团队经常把模块、库和 ABI 混为一谈。它们确实相关,但绝不是一回事。
模块(C++20 modules)主要解决源码组织、依赖治理和构建可扩展性问题。库的打包方式决定代码如何分发和链接:静态库、共享库、header-only 包、源码分发、内部 monorepo 组件,还是插件 SDK。ABI(Application Binary Interface,应用二进制接口)则关系到独立构建的二进制文件,能否在内存布局、调用约定、异常行为、内存分配归属、符号命名和对象生命周期等方面取得一致。
混为一谈的代价高昂。有团队引入 C++20 模块后就以为公共二进制边界从此稳了;有团队发布共享库时在公共头文件里跨编译器暴露了 std::string、std::vector、异常和大量内联模板,然后发现”在我们的 CI 上能跑”根本不是兼容性策略;还有插件宿主直接导出 C++ 类继承体系,等到编译器版本一升级就是一次部署事故时,已经晚了。
本章严格区分这三者。源码整洁性自有其价值,分发方式是架构层面的抉择,而 ABI 稳定性是一种契约,要么精心设计并主动提供,要么干脆不承诺。
模块解决的是源码问题,不是二进制问题
C++ 模块能降低解析开销、隔离宏、管控依赖关系。这些好处对头文件负担沉重的大型代码库尤其明显。设计良好的模块接口能减少实现细节的意外泄露,让对外暴露的导入面更清晰。
但模块并不能创造可移植的二进制契约,也无法抹平编译器之间的 ABI 差异。不同厂商之间的布局规则、异常互操作性、标准库二进制兼容性,模块一概不保证。模块替代不了打包策略。
模块替代了什么:头文件包含模型及其隐患
在没有模块的时代,C++ 的编译就是文本粘贴。每个 #include 都会把头文件全文逐字插入编译单元。这带来了三类问题。
包含顺序依赖。 如果头文件 A 定义了头文件 B 所依赖的宏或类型,交换 #include 的顺序就可能悄然改变行为甚至导致编译失败。大型代码库中总会不知不觉积累起无人记录的隐式顺序依赖。
// order_matters.cpp
#include <windows.h> // defines min/max as macros
#include <algorithm> // std::min/std::max are now broken
auto x = std::min(1, 2); // compilation error or wrong overload
宏污染。 所有被间接包含进来的头文件中定义的宏,对后续代码一律可见。某个库只要 #define 了 ERROR、OK、TRUE、CHECK 或 Status,就可能和完全无关的代码产生冲突。经典防御手段(include guard、#undef、NOMINMAX)既脆弱,又必须在每个包含点都记得使用。
// some_vendor_lib.h
#define STATUS int
#define ERROR -1
// your_code.cpp
#include "some_vendor_lib.h"
#include "your_domain.h" // any enum named ERROR or type named STATUS is now broken
enum class Status { ok, error }; // fails to compile: STATUS expands to int
传递依赖爆炸。 包含一个头文件,就可能连锁引入数百个其他头文件。内部头文件看似微小的一处改动,便能触发数千个编译单元的重新编译。构建时间取决于传递包含的总深度,而非程序的实际依赖图。
模块同时解决了这三个问题:不泄漏宏,import 语义与顺序无关且定义明确,只导出显式声明的内容。不涉及二进制兼容性,但对源码整洁性的提升是实打实的。
模块语法实践
examples/web-api/ 示例项目由七个 C++20 模块接口单元构成。每个 .cppm 文件声明一个具名模块,显式导入其依赖,并只导出公共接口。以下是一个典型模块的结构:
// examples/web-api/src/modules/error.cppm
module;
// 全局模块片段:尚未模块化的标准头文件在此包含
#include <cstdint>
#include <expected>
#include <format>
#include <string>
#include <string_view>
export module webapi.error; // 模块声明——为此模块命名
export namespace webapi {
enum class ErrorCode : std::uint8_t { not_found, bad_request, conflict, internal_error };
struct Error { /* ... */ };
template <typename T>
using Result = std::expected<T, Error>;
} // 只有 'export' 内的内容对导入方可见
依赖其他模块的模块使用 import 声明,而非 #include:
// examples/web-api/src/modules/handlers.cppm
module;
#include <format>
#include <string>
// ...
export module webapi.handlers;
import webapi.error; // 类型化的错误模型
import webapi.http; // Request, Response, Handler
import webapi.json; // JsonSerializable concept
import webapi.repository; // TaskRepository
import webapi.task; // Task, TaskId
几点说明。全局模块片段(module; 和 export module ...; 之间的部分)是标准库头文件的归属,这些头文件早于模块系统,必须以文本方式包含。每个 import 指定的是具体模块,不存在传递性包含:handlers.cppm 导入了 webapi.error,但不会连带拉入 error.cppm 本身包含的所有东西。export 关键字精确控制可见性,只有被导出的名字才能被导入方使用,私有辅助函数、内部实现细节和未导出的类型对外不可见。
使用方同样简洁。在 main.cpp 中,六条 import 声明取代了本该存在的一长串 #include 指令及其所有传递性依赖:
// examples/web-api/src/main.cpp
import webapi.handlers;
import webapi.http;
import webapi.middleware;
import webapi.repository;
import webapi.router;
import webapi.task;
不需要 include guard,没有宏冲突,不受包含顺序影响。构建系统可以直接看到模块依赖图,并按正确的顺序编译各模块。这就是模块在源码层面带来的改善。
首要问题不是”我们要不要用模块?”,而是”我们对使用者做出了什么承诺?”
如果答案是在同一个仓库内、基于同一套工具链做内部源码复用,模块可能是很好的选择。如果答案是”我们要发布一个公共 SDK,供未知的构建系统和编译器版本使用”,模块仍然有助于改善你自己的构建流程,但它无法免除你在二进制边界上严格自律的责任。
打包选择表达运维意图
打包是架构决策落地为部署方案的地方。
Header-only 或源码分发库
使用者自行将代码编译进自己的程序,因此规避了大部分 ABI 承诺。代价是更长的编译时间、更大的依赖面,以及更多的实现细节暴露。模板、concept 和内联函数天然适合这种形式。对内部泛型工具库,或者对性能和优化器可见性要求高于分发便捷性的小型公共库,这通常是不错的选择。
静态库
静态链接简化部署,也回避了部分运行时兼容性问题。公共接口设计不当时,仍然会引发 ODR 和分配器边界问题,但总体上能降低跨版本运维的复杂度。静态库适合作为整体部署的内部组件,也适合偏好自包含二进制产物的使用者。
共享库与 SDK
共享库在部署和热补丁方面有优势,但代价是你从此拥有了一条真正的二进制边界。符号可见性、版本策略、异常规则、分配器归属、数据布局,这些都不再是内部工程决策,而是产品行为的一部分。
插件边界
这是约束最严苛的场景:宿主和插件可能分开构建、动态加载、独立升级,有时甚至使用不同的编译标志乃至不同的编译器。最安全的公共边界往往是 C ABI 配合不透明句柄和显式函数表,即使内部实现全程使用现代 C++ 也不例外。
打包方式应由运维约束驱动,而不是由“哪种写法在本地代码里更好看“来决定。
内部库与公共二进制契约
很多库根本不需要稳定 ABI。这很正常。
如果生产方和使用方始终基于同一 commit、同一工具链一起重建,那么源码兼容性远比 ABI 稳定性重要。在这种环境下,现代 C++ API 可以充分发挥表达力:返回词汇类型、使用模板、引入模块、依赖内联——都是合理的取舍。
一旦需要支持二进制的独立升级,规则就变了。哪怕看起来无害的公共类型也可能变成定时炸弹:私有成员顺序调整、换了标准库实现、换了编译器,甚至仅仅是异常模型不同,都可能在源码级签名毫无变化的情况下让使用方的程序崩溃。
别因为发布过一次 DLL,就无意间背上了稳定 ABI 的承诺。
ABI 稳定性需要有意收窄
维持稳定 ABI 的代价很高,因为它迫使你在边界处放弃许多便利的语言用法。
这些是 ABI 脆弱性的常见来源:
- 在公共二进制接口里暴露标准库类型。
- 暴露大小或成员可能变化的类布局。
- 让异常跨编译器或运行时边界传播。
- 没有共享分配器契约时,在一侧分配、另一侧释放。
- 把大量内联模板或虚层级作为二进制扩展机制导出。
标准库类型本身没问题,但把它们放在公共二进制边界上,往往是错误的选择。
具体的 ABI 破坏场景
这些都是真实场景,不是理论风险。
新增私有成员导致类大小变化。 使用方基于库 v1 编译,按当时的 sizeof(Widget) 分配对象。v2 新增了一个私有成员后,库的方法会写越使用方分配的空间。结果不是链接报错,而是静默的内存踩踏。
// v1: shipped in libwidget.so
class EXPORT Widget {
int x_;
int y_;
public:
void move(int dx, int dy); // accesses x_, y_
};
// sizeof(Widget) == 8 for the consumer
// v2: added a z-index member
class EXPORT Widget {
int x_;
int y_;
int z_; // sizeof(Widget) is now 12
public:
void move(int dx, int dy); // same signature, same symbol
};
// Consumer still allocates 8 bytes. Library writes 12. Corruption.
标准库实现不同。 共享库用 libstdc++ 构建,API 中暴露了 std::string;使用方用 libc++ 构建后链接该库。两套标准库的 std::string 内部布局完全不同(SSO buffer 大小、指针排列等)。跨边界调用会破坏字符串状态,而编译期和链接期都不会有任何诊断信息。
编译标志不匹配。 库用 -fno-exceptions 构建,使用方却开启了异常,二者的栈展开行为可能不兼容。-std= 标志不同可能改变标准类型的布局。struct packing 或对齐标志不同,同样会悄无声息地破坏 ABI。
Header-only 库引发的 ODR 违规
Header-only 库之所以流行,正是因为免去了二进制分发的麻烦。但它们会引入另一类问题:单一定义规则(ODR)违规。
如果两个编译单元包含了同一个 header-only 库,但使用的编译标志、预处理器定义或模板实参不同,从而导致内联函数行为各异,链接器就可能悄悄选取其中一个定义、丢弃另一个。最终,基于两套不同假设编译出的代码被链接进了同一个二进制文件。
// translation_unit_a.cpp
#define LIBRARY_USE_SSE 1
#include "header_only_math.hpp" // vector ops use SSE intrinsics
// translation_unit_b.cpp
// LIBRARY_USE_SSE not defined
#include "header_only_math.hpp" // vector ops use scalar fallback
// Both define the same inline functions with different bodies.
// Linker picks one. Half the program uses the wrong implementation.
// No diagnostic. Possible wrong results or crashes.
这不是人为构造的极端案例。凡是用 #ifdef 选择代码路径、或根据 NDEBUG、_DEBUG、平台宏而表现各异的库,在编译设置不统一的项目中都可能触发 ODR 违规。Sanitizer(尤其是带 ODR 违规检测的 -fsanitize=undefined)和 ld 的 --detect-odr-violations 等链接期工具能捕获一部分,但无法全覆盖。
对于需要稳定的共享库或插件契约,优先采用不透明句柄、精简的 C 风格值类型、显式所有权函数、带版本号的结构体,以及清晰的生命周期规则。内部尽管放手使用现代 C++,但在边界处必须保守。二进制接口上的含糊不清,最终要由使用方来承担后果。
反模式:公共二进制接口照搬内部 C++ 类型
// Anti-pattern: fragile ABI surface for a shared library.
class EXPORT Session {
public:
virtual std::string send(const std::string& request) = 0;
virtual ~Session() = default;
};
std::unique_ptr<Session> create_session();
在同一个构建内部,这个接口看起来很有吸引力。但一旦作为公共 SDK 的边界,风险就很高了。
std::string 的内部表示和分配器行为都是实现细节;std::unique_ptr 内嵌了 deleter 和运行时假设;跨边界的虚派发把宿主和使用方都绑定在对象模型的兼容性细节上;异常也可能泄漏出去。实际上,这个接口已经把你所用的编译器、标准库和编译标志都变成了契约的一部分。
对于真正的跨二进制边界,带版本的 C ABI 往往更安全。
struct session_v1;
struct request_buffer {
const std::byte* data;
std::size_t size;
};
struct response_buffer {
const std::byte* data;
std::size_t size;
};
struct session_api_v1 {
std::uint32_t struct_size;
session_v1* (*create)() noexcept;
void (*destroy)(session_v1*) noexcept;
status_code (*send)(session_v1*, request_buffer, response_buffer*) noexcept;
void (*release_response)(response_buffer*) noexcept;
};
写法上不那么漂亮,但诚实得多。这种边界把内存分配归属、版本化接口面和错误传递方式全都摆在明面上。内部实现照样可以用 std::expected、std::pmr、协程、模块,以及任何 C++23 技术。
Pimpl 权衡依然存在
对于使用同一工具链的 C++ 使用方,pimpl(pointer to implementation,将实现隐藏在前向声明指针后面的模式)仍有用。它能减少重建波及范围、隐藏私有成员,并在部分实现变更时保持类大小不变。但它也带来了额外的间接寻址、堆分配和代码复杂度。
只在以下条件同时成立时才考虑使用:
- 确实需要隐藏内部表示,或减少头文件暴露的编译期依赖。
- 对象不在热路径上——多一次指针间接寻址不会成为可测量的瓶颈。
- 库确实需要在实现演进过程中保持类布局稳定。
不要仅仅因为头文件凌乱就搬出 pimpl。内部构建中,模块可能更适合解决这类源码组织问题。Pimpl 是控制表示和兼容性的工具,不是代码风格要求。
真实构建系统中的模块
以 C++23 为基础的建议必须立足现实。模块确实有价值,但各工具链、包管理器和混合语言构建系统对模块的支持程度参差不齐。
在可控的构建环境中(GCC 14+、Clang 18+ 或 MSVC 17.10+),模块能有效降低解析开销,让依赖意图更加明晰。但在异构环境中,模块产物模型、构建图集成和包管理器支持仍可能带来阻力。这些阻力不构成拒绝模块的理由,只是在提醒我们:采用模块是构建架构层面的决策,不能仅凭对新语言特性的热情。
务实的默认策略是:
- 内部组件统一构建时,优先使用模块。
- 除非使用方生态可控,否则不要让公共包的使用依赖于模块支持。
- 二进制契约的决策与模块的决策要分开考虑。
examples/web-api/ 项目正是这种务实策略的体现。它的七个 .cppm 文件(error、task、json、http、repository、handlers、middleware、router)构成了一张清晰的模块依赖图,统一由一份 CMakeLists 构建。标准库头文件仍然放在全局模块片段中,因为并非所有工具链都已将其模块化。项目没有试图把自己的模块作为公共包导出——它只是用模块来组织自己的源码。这才是正确的起步方式。
版本策略是接口的一部分
打包而不定义版本策略就是自欺欺人。使用方需要知道两个版本之间允许哪种程度的变更:仅保证源码兼容?在同一主版本内保证 ABI 兼容?还是除了精确的构建匹配什么都不承诺?
版本策略直接影响技术设计。如果要在同一主版本内保持 ABI 兼容,公共类型就必须大幅精简,发布流程中也必须加入 ABI 审查环节。如果使用方总是从源码重新构建,策略可以放宽,接口也可以更加地道。
版本化不只是语义版本号,还涉及符号版本化(在平台支持的情况下)、源码级 API 的 inline namespace 策略、特性检测机制、废弃窗口期,以及能准确描述编译器和运行时要求的包元数据。
跨边界的内存、异常与所有权
大多数跨库故障没什么戏剧性,根源往往就是所有权不匹配。
一侧分配、另一侧释放?必须明确约定分配器契约。异常允许跨越边界?运行时和编译器的假设必须一致。边界处使用回调?必须写明回调的持有规则和线程亲和性。卸载时后台任务仍在运行?那打包设计本身就已经埋下了隐患。
// Anti-pattern: cross-boundary allocation mismatch.
// Library (built with MSVC debug runtime, uses debug heap):
EXPORT char* get_name() {
char* buf = new char[64];
std::strcpy(buf, "session-001");
return buf;
}
// Consumer (built with MSVC release runtime, uses release heap):
void use_library() {
char* name = get_name();
// ...
delete[] name; // CRASH: freeing debug-heap memory on release heap
}
解决办法是绝不让分配和释放跨越边界。谁分配谁释放。库负责分配就必须同时提供释放函数,或者改用调用方预先提供的 buffer。
// Safe: library owns both allocation and deallocation.
EXPORT char* get_name();
EXPORT void free_name(char* name);
// Also safe: caller provides the buffer.
EXPORT status_code get_name(char* buffer, std::size_t buffer_size);
这些细节决定了一个在本地写得干净的接口能否真正成为可靠运行的库。
验证与评审问题
打包和 ABI 问题应当与源码级 API 质量分开评审。
- 这个库的使用场景是什么——与生产方一同构建、源码级引用,还是作为可独立升级的二进制发布?
- 我们是否在用模块改善源码整洁性的同时,错误地以为它们也解决了 ABI 问题?
- 公共边界上是否暴露了我们实际上无法控制其布局或运行时行为的类型?
- 跨边界的内存分配归属是否已显式约定?
- 版本化策略和兼容性承诺是否已文档化,并且可以通过测试验证?
- 对于这个插件或 SDK,C ABI 加不透明句柄是否比导出 C++ 类更安全?
验证工作应包括:在所有支持的编译器和标准库组合上做构建矩阵测试、符号可见性检查、必要时使用 ABI 比对工具,以及模拟真实使用方集成流程的打包测试。仅靠生产方仓库中的单元测试,不足以证明一份公共二进制契约的可靠性。
要点
模块、打包和 ABI 是三个独立的设计维度。
用模块来改善源码边界和构建可扩展性。根据部署需求和使用方约束选择打包方式。只有在你愿意大幅收窄公共边界、并持续加以验证的前提下,才承诺稳定 ABI。实现内部尽管放心使用 C++23 的各种现代特性;而在真正的二进制边界上,坚持显式所有权、显式版本化和尽可能小的接口面。
库设计中最致命的错误,就是把内部的优雅原封不动地导出为公共二进制策略。源码写得再漂亮也无法让 ABI 风险自动消失,只有刻意设计的边界才能做到。
共享状态、同步与争用
本章假定你已经能在单线程代码中准确推理所有权与不变量。现在的问题是:当多个线程都能观察和修改同一份状态时,哪些结论还靠得住?
生产问题
大多数并发故障的根源不是缺少同步原语,而是共享策略不清晰。
缓存”基本都是读操作”。连接池”只有一把 mutex(互斥锁)”。metrics registry 为了”提速”用了 atomics(原子操作)。请求协调器把几个计数器和一个队列放在锁后面,代码评审时看起来没什么问题。然后生产负载来了。锁持有时间把本不相关的工作耦合在一起;读线程看到了只更新了一半的状态,因为不变量横跨了两个字段;后台清理路径和热路径共用同一把 mutex。还没等谁看到崩溃,吞吐量就先垮了;等崩溃真正出现时,也不是干净的失败,而是数据竞争导致的未定义行为、死锁或线程饥饿。
本章讨论生产级 C++23 系统中共享可变状态的设计取舍。核心问题不是该记住哪种 mutex 类型,而是:究竟有多少状态需要共享?哪些不变量必须同步保护?真实流量下争用会如何表现?当共享不可避免时,怎样的设计才经得起评审?
注意与后续章节的边界。本章聚焦于共享可变状态的并发访问。第 13 章讨论协程在挂起点上的生命周期管理,第 14 章讨论任务组编排、取消与背压。这些主题互有关联,但不可混为一谈。
共享可变状态是成本中心
共享状态换来的是协调上的便利,付出的是耦合的代价。
一旦两个线程可以修改同一个对象图,局部推理就不够用了。每次访问都牵涉同步策略、锁顺序、内存序、唤醒行为和析构时序。评审者要考虑的不只是某个操作自身是否正确:两步操作之间,状态会不会被别人看到?回调会不会在持锁时发生重入?等待中的代码是不是正好持有别人推进所需的资源?争用会不会把一个正确的设计拖成慢设计?
既然代价如此之高,并发设计的第一个决定就应该是结构性的:
- 这份状态能不能改为线程私有(thread-confined)?
- 更新能不能批处理、分片、快照化,或者走消息传递?
- 如果共享不可避免,哪些不变量必须原子地维护?
很多团队上来就选锁,这是本末倒置。真正昂贵的抉择在于共享拓扑的设计,而不是 std::mutex 怎么写。
先缩小共享面
最安全的共享状态,就是压根不共享的状态。
在服务端代码中,很多看似需要共享的结构其实可以按流量键、请求生命周期或所有权角色拆分。metrics 采集可以先按线程各自聚合再定期合并。会话表可以按会话 ID 分片。缓存可以把不可变的值 blob 和一个小型可变索引分离。队列消费者可以持有自己的本地工作缓冲区,只对外发布已完成的快照。
这些做法比换用不同的同步原语更有价值,因为它们从根本上减少了“正确性依赖于线程交错顺序“的代码位置。
在生产系统中,以下三种缩减方式尤其常见:
线程私有化
让一个线程或一个 executor 独占修改权,其他方通过消息、快照或所有权移交来通信。对请求调度器、连接管理器和事件循环来说,这往往是最简单的方案。好处不只是减少了锁,更在于不变量始终保持在局部范围内。
分片
按稳定的键对状态做分区,让争用程度与热点键的集中度成正比,而非与总流量成正比。分片不会消除同步需求,但能缩小每个临界区的影响范围。
快照化
如果读远多于写,且读方能容忍轻微的数据滞后,就发布不可变快照,在旁路完成更新。读方获得低开销且稳定的访问,写方只需承担一次性的复杂度成本。
这些方式都有代价。线程私有化可能制造瓶颈,分片让跨分片操作更复杂,快照化增加内存分配和拷贝开销。但这些都是看得见的成本,远好过在各处暗中承受意外争用。
没有同步时会发生什么
在讨论该用哪种原语之前,先看看完全没有同步保护会怎样。下面的代码存在数据竞争,在 C++ 中属于未定义行为。
// BUG: data race — two threads read and write counter without synchronization.
struct metrics {
int request_count = 0;
int error_count = 0;
};
metrics g_metrics;
void record_request(bool success) {
++g_metrics.request_count; // unsynchronized read-modify-write
if (!success) ++g_metrics.error_count; // same
}
这不只是正确性风险,按照标准这就是未定义行为。编译器和硬件完全可以对这些操作进行重排、撕裂或省略。计数器可能丢失更新、报告不可能的值,甚至在不支持原子字存储的架构上破坏相邻内存。Sanitizer 能立刻检测到这类问题,但 sanitizer 不一定在生产环境中开启。
一个更隐蔽的变体涉及多字段不变量:
// BUG: readers can observe state_ == READY while payload_ is half-written.
struct shared_result {
std::string payload_;
enum { EMPTY, READY } state_ = EMPTY;
};
// Writer thread:
result.payload_ = build_payload(); // not yet visible to readers
result.state_ = READY; // may be reordered before payload_ write
// Reader thread:
if (result.state_ == READY)
process(result.payload_); // may see partially constructed string
即使 state_ 是原子的,如果内存序不对,payload_ 的写入仍可能被重排到 state_ 之后。这里的教训是:数据竞争不只是单个变量的问题,更是相关修改之间可见性顺序的问题。
裸 mutex 操作的误用与 RAII guard
手动 lock/unlock 是最经典的 mutex bug 来源。看这个例子:
// BUG: exception between lock and unlock leaks the lock.
std::mutex mtx;
std::vector<int> data;
void push(int value) {
mtx.lock();
data.push_back(value); // may throw (allocation failure)
mtx.unlock(); // never reached if push_back throws — deadlock on next access
}
一旦 push_back 抛出异常,unlock() 就被跳过了。之后所有试图获取 mtx 的线程都将永久阻塞。内存压力下的分配失败,或者一个会抛异常的拷贝构造函数,都足以触发它。
修复方式很机械:使用 RAII guard。
void push(int value) {
std::scoped_lock lock(mtx);
data.push_back(value); // if this throws, ~scoped_lock releases the mutex
}
std::scoped_lock 可以处理单个或多个 mutex,自带死锁规避。std::unique_lock 在此基础上增加了延迟加锁、转移所有权和配合 condition variable(条件变量,用于线程间等待/通知的同步原语)的能力。除非确实需要这些额外灵活性,否则优先选择 scoped_lock。
// unique_lock: needed when the lock must be released before scope exit.
void transfer_expired(registry& reg, std::vector<session>& out) {
std::unique_lock lock(reg.mutex_);
auto expired = reg.extract_expired(); // modifies registry under lock
lock.unlock(); // release before expensive cleanup
for (auto& s : expired)
s.close_socket(); // no lock held — safe to block
// out is caller-owned, no synchronization needed
out.insert(out.end(),
std::make_move_iterator(expired.begin()),
std::make_move_iterator(expired.end()));
}
不一致的加锁顺序导致死锁
当代码需要获取多把 mutex 时,加锁顺序不一致就是最经典的死锁根源。
// BUG: deadlock if thread 1 calls transfer(a, b) while thread 2 calls transfer(b, a).
struct account {
std::mutex mtx;
int balance = 0;
};
void transfer(account& from, account& to, int amount) {
std::lock_guard lock_from(from.mtx); // locks 'from' first
std::lock_guard lock_to(to.mtx); // then 'to' — opposite order on another thread
from.balance -= amount;
to.balance += amount;
}
线程 1 锁住 a.mtx,等着拿 b.mtx;线程 2 锁住 b.mtx,等着拿 a.mtx——谁也走不了。std::scoped_lock 内部使用 std::lock 同时获取两把 mutex 来规避死锁:
void transfer(account& from, account& to, int amount) {
std::scoped_lock lock(from.mtx, to.mtx); // deadlock-free acquisition
from.balance -= amount;
to.balance += amount;
}
这不只是方便,是正确性保障。任何需要多把 mutex 的设计,要么用 std::scoped_lock 一次性获取,要么严格执行有文档记录的全局加锁顺序。靠口头约定的加锁规矩很少能挺过一轮重构。
过度同步的性能成本
争用不仅关乎正确性,过度加锁还会把本可并行的工作强制串行化。
// Over-synchronized: every stat update contends on one lock.
class request_stats {
std::mutex mtx_;
uint64_t total_requests_ = 0;
uint64_t total_bytes_ = 0;
uint64_t error_count_ = 0;
public:
void record(uint64_t bytes, bool error) {
std::scoped_lock lock(mtx_);
++total_requests_;
total_bytes_ += bytes;
if (error) ++error_count_;
}
};
在一台每秒处理数百万请求的 64 核机器上,所有线程都在同一条 cache line 上排队。锁获取、cache line 反复弹跳和调度器唤醒成了主要开销。更好的设计取决于你能容忍什么:
- 如果字段之间不需要精确一致性,就让每个线程各自计数,定期合并。
- 如果只需要近似总数,就对每个计数器独立使用
std::atomic<uint64_t>配合memory_order_relaxed。 - 如果需要跨字段一致性(例如错误率 = errors / total),就保留 mutex,但按线程或请求键分片。
重点不是 mutex 慢,而是一把所有核心共享的 mutex 会把并行负载变成串行瓶颈。分开测量锁持有时间和等待时间。等待时间高、持有时间低,就是过度同步的典型信号。
围绕不变量设计,而不是围绕字段设计
锁保护的不是变量,而是不变量。
这个区分很重要。生产环境中的对象很少在单个字段层面出错。真正出问题的场景是:多个字段必须一起修改,而某个线程恰好在修改之间观察到了中间状态。
连接池的正确性不在于 available_count 是不是原子的,而在于并发访问下这些关系始终成立:已借出的连接不会同时出现在空闲列表中;已关闭的连接不会被重新分发;当有可用资源时,等待者能被唤醒。这些才是不变量。设计没有明确列出这些不变量,同步边界就已经定义不清了。
粗粒度加锁有时反而更优。如果一把 mutex 能干净地覆盖一个完整的不变量域,它可能比几把细粒度锁更好,后者容易导致不可能的中间状态或者要求脆弱的锁顺序。细粒度加锁不见得更优,往往只是更难审查。
反模式:不断膨胀的服务对象外面包一把锁
最常见的失败模式不是”没有同步”,而是”一把起初看着合理的锁,随着功能迭代逐渐变成了整个服务的瓶颈”。
// Anti-pattern: one mutex protects unrelated invariants and long operations.
class session_registry {
public:
std::optional<session_info> find(session_id id) {
std::scoped_lock lock(mutex_);
auto it = sessions_.find(id);
if (it == sessions_.end()) {
return std::nullopt;
}
return it->second;
}
void expire_idle_sessions(std::chrono::steady_clock::time_point now) {
std::scoped_lock lock(mutex_);
for (auto it = sessions_.begin(); it != sessions_.end();) {
if (it->second.expires_at <= now) {
close_socket(it->second.socket); // RISK: blocking work under the lock.
it = sessions_.erase(it);
} else {
++it;
}
}
}
private:
std::mutex mutex_;
std::unordered_map<session_id, session_info> sessions_;
};
这个对象在早期测试中可能毫无问题,因为局部看起来很简单。后来出问题,是因为不相关的工作被迫共享了同一个排队点。一次读操作被清理任务阻塞;清理任务在持有 mutex 的同时做 I/O;后续新功能还会不断往同一个临界区里塞 metrics、回调和日志——因为“锁已经在那了“。
问题不只是临界区太长,还在于这个对象没有清晰的不变量边界。生命周期管理、查找、过期和副作用全部塞进了同一个同步域。
更好的做法通常是把状态变更与外部动作分离:在锁内确定哪些会话该过期,将它们移出共享结构,释放锁,然后再关闭 socket。这样既缩短了锁的范围,也让不变量更好表述:受保护区域只负责更新 registry;外部清理在所有权转移之后进行。
最小化锁作用域,但不要盲目拆分逻辑
“让锁的范围尽量小”这句话对,但不完整。
临界区应当恰好包含维护不变量所需的操作,一步不多、一步不少。具体而言:
- 不要在持锁时做阻塞 I/O。
- 不要在持锁时回调外部代码。
- 如果能移到锁外,就别在临界区里跑大量分配或写日志的慢路径。
- 不要仅仅为了让临界区看起来更短,就拆散逻辑上必须原子完成的状态更新。
最后一点恰恰是团队最容易踩坑的地方。保护多步不变量的锁可能确实需要横跨多个操作。如果为了追求”更快”的观感而在中间释放锁,就可能引入不可能出现的中间状态。先弄清楚什么必须是原子的,再谈优化。
Atomics 适合狭窄事实,不适合复杂所有权
Atomics 很关键,也很容易被误用。
适合用 atomics 的场景是共享信息确实很简单:停止标志、版本计数器、环形缓冲区索引、所有权模型已经完善的引用计数,或者只需要 relaxed 语义的统计计数器。不要把 atomics 当结构化所有权的替代方案,也不要用它取代多字段不变量的保护。
示例项目的 TaskRepository(examples/web-api/src/modules/repository.cppm)清楚地展示了这一区分。ID 生成器是一个单调递增计数器——教科书式的狭窄事实——因此使用 std::atomic<TaskId> 配合 memory_order_relaxed。而任务集合本身是一个多字段不变量(vector 内容必须与已发放的 ID 保持一致),所以由 shared_mutex 保护。在同一个类中混用这两种策略完全正确,因为它们的作用域互不重叠:atomic 负责一个独立的事实,mutex 负责其余所有状态。
一个原子计数器不能让队列变安全。一个原子指针也不能让对象生命周期变简单。几个 memory_order 参数修不好一个本来就允许线程看到半成品状态的设计。
C++23 提供了 std::atomic::wait、notify_one 和 notify_all,可以为简单的状态转换省去一些 condition variable 的样板代码。但根本前提不变:你仍然必须先把状态机设计清楚。
如果评审者说不清哪些值转换是合法的、为什么所选的内存序就够用,那这段原子代码就还没写完。
读多写少的数据需要不同的设计
争用的根源与其说是写入频率高,不如说是读取路径设计不当。
配置表、路由映射或特性策略快照,每个请求都可能读取但很少更新。用一把中心 mutex 保护它们功能上没问题,却会引入本可避免的尾延迟。这类场景下,不可变快照或 copy-on-write 式发布往往比更细粒度的加锁效果更好。
权衡很明确:
- 读方获得稳定、低争用的访问。
- 写方承担拷贝和发布的开销。
- 由于新旧版本共存,内存压力可能上升。
- 业务上必须能容忍一定的数据滞后。
这在请求路由、鉴权策略和读多写少的元数据场景中,通常是正确的取舍。但对于写入密集的订单簿或频繁变更的共享索引,就不适用了。
当两种极端都不适用时(读取频繁,但每次 create、update 或 delete 请求都会写入),std::shared_mutex(读写锁)配合读端 std::shared_lock、写端 std::scoped_lock 就是务实的折中方案。示例项目的 TaskRepository(examples/web-api/src/modules/repository.cppm)正是这种模式的体现:
// repository.cppm — 读写锁的实际用法
class TaskRepository {
mutable std::shared_mutex mutex_;
std::vector<Task> tasks_;
std::atomic<TaskId> next_id_{1};
public:
// 读操作使用 shared_lock — 多个读者可以并行执行。
[[nodiscard]] std::optional<Task> find_by_id(TaskId id) const {
std::shared_lock lock{mutex_};
auto it = std::ranges::find(tasks_, id, &Task::id);
if (it == tasks_.end()) return std::nullopt;
return *it;
}
// 写操作使用 scoped_lock — 独占访问以维护不变量。
[[nodiscard]] Result<Task> create(Task task) {
std::scoped_lock lock{mutex_};
// 校验、分配 id、存储……
}
};
所有读路径(find_by_id、find_all、find_completed、size)获取 shared_lock,允许并发读取。所有写路径(create、update、remove)获取 scoped_lock,独占访问。mutex 保护的是不变量域(tasks_ 的内容与已分配 ID 之间的关系),而非单个字段。
对应的压力测试(examples/web-api/tests/test_repository.cpp)在并发负载下验证了这一设计:
void test_concurrent_access() {
webapi::TaskRepository repo;
constexpr int num_threads = 8;
constexpr int ops_per_thread = 100;
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&repo, i]() {
for (int j = 0; j < ops_per_thread; ++j) {
auto title = std::format("Task-{}-{}", i, j);
auto result = repo.create(webapi::Task{.title = std::move(title)});
assert(result.has_value());
}
});
}
threads.clear(); // jthread 在析构时自动 join
assert(repo.size() == num_threads * ops_per_thread);
}
八个线程并发执行 create,然后校验总数与预期一致。这是基线正确性测试,不是争用基准测试,但它能在 ThreadSanitizer 下暴露数据竞争和丢失更新。
Condition variable 与唤醒规范
Condition variable 是很多原本严谨的设计开始变得模糊的地方。
规则本身简单:等待谓词是不变量的一部分,不是随手写的便利表达式。等待线程必须重新检查一个谓词,而这个谓词必须受到同一个同步域的保护,正是这个同步域赋予了谓词意义。通知只是“请重新检查“的信号,不是“一定有进展“的保证。
要点:
- 明确命名谓词:队列非空、已请求关闭、容量可用、版本已变更。
- 先更新谓词对应的状态,再发通知。
- 等待代码要能正确应对虚假唤醒和关闭时的竞争。
- 唤醒一个等待者还是全部等待者,要与进展模型匹配。
大多数有问题的 condition variable 代码,病根不在于作者忘了写循环,而在于谓词本身定义不清,或者被分散到了多条代码路径中、由不同逻辑各自更新。
隐藏的共享状态仍然是共享状态
并发 bug 经常潜伏在那些从调用方角度看不出是共享的对象中。
常见的例子:
- 多线程共用的分配器或 memory resource。
- 内部带缓冲区的日志 sink。
- 带共享控制块的引用计数句柄。
- 隐藏在看似无副作用的辅助函数背后的缓存。
- 用于插件发现、metrics 或 tracing 的全局 registry。
这些对象应该和显式共享的 map、queue 接受同等审查。”这个辅助类是线程安全的”远远不够。它会不会把所有调用方都串行化?它在争用下会不会触发内存分配?它有没有可能在持有内部锁时回调用户代码?它是否在热路径上引入了 API 表面看不出来的争用开销?
测量争用会改变设计
正确性只是第一关。过了这关之后,共享状态的设计就变成了测量问题。
争用很少在源码层面一眼看出。它表现为排队延迟、锁持有时间分布、convoy 效应、cache line 乒乓和调度器层面的停顿。验证工作必须包括运行时证据:
- 在热路径上分别测量锁持有时间和等待时间。
- 关注尾延迟,而不仅仅是吞吐量均值。
- 使用分片时,观察热点键的倾斜程度。
- 分析临界区内是否存在内存分配。
- 用 ThreadSanitizer 做竞争检测,用有针对性的压力测试覆盖死锁和饥饿场景。
一个逻辑上正确、但在 P99 就扛不住的设计,仍然是不合格的并发设计。
共享状态的评审问题
在审批涉及并发共享状态的代码之前,问自己:
- 每把锁、每个 atomic 保护的不变量到底是什么?
- 这份状态能否改为线程私有、分片或快照,而不是直接共享?
- 有没有临界区在执行 I/O、大量分配、写日志或回调?
- 站在观察者角度,跨字段更新是真正原子的吗?
- Condition variable 的谓词是否准确,并在正确的同步域下更新?
- 突发流量或热点键倾斜时,争用会出现在哪里?
- 除了”跑过测试”之外,还有什么证据?
如果这些问题没有明确答案,这个设计就还没准备好上生产。
要点
共享可变状态不是并发设计的默认选择,而是代价高的选择。
当共享不可避免时,先定义不变量,再选原语。优先考虑线程私有化、分片和快照,而非堆砌越来越巧妙的锁。用 mutex 保护不变量域,用 atomics 表达简单事实,condition variable 只在谓词清晰定义后才使用。在真实负载下测量结果。即便同步本身是正确的,如果争用主导了系统行为,这个设计仍然是失败的。
协程、任务与挂起边界
本章假定你已经理解错误传递和并发共享状态设计。关注点很具体:协程(coroutine,一种可暂停和恢复的函数)拥有什么、什么能在挂起后继续存活,以及异步控制流掩盖生命周期时会出什么问题。
生产中的问题
协程让异步代码更好读,也更容易产生误导。
原本层层嵌套回调的请求处理器,可以写成顺序执行的直线代码。流式解析器可以逐个产出值。后台刷新任务可以用 co_await(协程挂起等待)等定时器和 I/O,不必再手写状态机。收益是真实的。但底层机制并没有消失,状态机依然存在,只是搬进了协程帧(coroutine frame,编译器为协程分配的存储区),而协程帧的生命周期、所有权和恢复上下文都需要刻意设计。
生产环境中协程引发的故障通常有四类:
- 借用的数据在挂起后比其来源存活得更久。
- 任务没有明确的所有者,导致工作比启动它的组件存活得更久。
- 失败和取消路径是隐式的,挂起的工作恢复时所依赖的假设已经失效。
- 执行在线程或 executor 之间跳转,而代码中看不出来。
本章的讨论范围限于局部。如何在取消压力下管理整棵任务树是第 14 章的内容。本章要回答的问题是:每个协程到底是什么?答案是一个拥有资源的对象,其挂起点划定了生命周期的边界。
协程替代了什么:回调地狱与手写状态机
要理解协程的设计取舍,先看看它们替代了什么。协程出现之前,异步代码依赖 continuation-passing style(续体传递风格),每一步都把回调串到下一步。一个简单的”获取、校验、存储”序列写出来是这样的:
// Continuation-passing style — correct but unreadable at scale.
void handle_request(request req, std::function<void(response)> done) {
fetch_profile(req.user_id, [req, done](std::expected<profile, error> prof) {
if (!prof) { done(error_response(prof.error())); return; }
validate_access(prof->role, req.resource,
[req, prof = *prof, done](std::expected<bool, error> ok) {
if (!ok || !*ok) { done(denied_response()); return; }
store_audit_log(req, prof,
[req, prof, done](std::expected<void, error> result) {
if (!result) { done(error_response(result.error())); return; }
done(success_response(prof));
});
});
});
}
每一步都嵌套更深一层。错误处理在每一层重复。捕获变量的生命周期必须手动管理:按值捕获会带来大量拷贝,按引用捕获则可能悬空。加上超时、取消或重试逻辑,嵌套只会更深。这就是协程出现之前生产代码中异步 C++ 的真实面貌。
对应的协程版本:
task<response> handle_request(request req) {
auto prof = co_await fetch_profile(req.user_id);
if (!prof) co_return error_response(prof.error());
auto ok = co_await validate_access(prof->role, req.resource);
if (!ok || !*ok) co_return denied_response();
auto result = co_await store_audit_log(req, *prof);
if (!result) co_return error_response(result.error());
co_return success_response(*prof);
}
代码可以顺序阅读,每一步只有一条错误路径,没有嵌套。改进是真实的。但状态机并没有消失,它搬进了协程帧。本章剩余部分讨论的,就是这对所有权和生命周期意味着什么。
示例项目的 handler 层(examples/web-api/src/modules/handlers.cppm)用同步代码展示了同样的结构优势。每个 handler 接收 const http::Request&(借用)并返回 http::Response(拥有),控制流是直线式的:解析路径参数、校验输入、调用 repository、把结果转换为 HTTP 响应。错误处理在每一步本地完成,无需嵌套回调。这些 handler 不是协程,但体现了同一条原则:每一步顺序执行、错误路径扁平,业务逻辑就更易读、更好审查。
协程是带存储的状态机
把协程当语法糖来用,是最快制造生命周期 bug 的捷径。
函数变成协程后,部分状态会搬进协程帧。参数可能被拷贝或移动到帧中。跨越挂起点存活的局部变量驻留在帧中。awaiter(等待器,控制挂起和恢复行为的对象)的状态可能决定执行何时恢复、在哪里恢复。析构可能发生在成功、失败、取消,或者持有该任务对象的外部对象被销毁时。
基于栈的常规直觉在这里不再可靠。非协程函数中,局部变量在控制流离开作用域时销毁。协程中,一个跨越挂起点的局部变量可能比调用方预想的活得久得多;反过来,一个借用调用方存储的 view 可能在恢复之前早已失效。
核心评审问题很简单:从一个挂起点到下一个挂起点之间,哪些数据必须保持有效?
挂起点就是生命周期边界
每个 co_await 都是一道边界,跨过它时应当重新审视之前的假设。
挂起之前,先问自己:
- 哪些引用、span、string view、迭代器和指针在恢复后仍然需要?
- 它们所引用的存储归谁所有?
- 被等待的操作有没有可能比发起它的调用方、请求或组件活得更久?
- 恢复会发生在哪个 executor 或线程上?
这相当于把 API 生命周期评审搬到了局部层面。协程在挂起期间保留了借用数据,你就必须证明数据的所有者比协程活得更久,否则就让协程接管所有权。
这也解释了为什么协程 API 在参数选择上比同步 API 更严格。同步的辅助函数可以放心地接收 std::string_view,因为它立即返回。会挂起的异步任务通常不该保留这个 view,除非所有权契约非常严格且有明确文档。
示例项目的 Request::path_param_after()(examples/web-api/src/modules/http.cppm)展示了满足这条边界约束时的安全做法。它返回 std::optional<std::string_view>,指向请求对象的 path 成员。当前设计中这是安全的,因为 handler 同步执行,Request 对象在整个 handler 调用期间都存活。但如果这些 handler 变成会在执行中途挂起的协程,同一个 string_view 就会在请求缓冲区被回收时变为悬空引用。设计成立的前提很简单:请求在 handler 执行期间存活,handler 不会挂起。
反模式:借用的请求状态跨越挂起存活
// Anti-pattern: borrowed data may dangle after suspension.
task<parsed_request> parse_and_authorize(
std::string_view body,
const auth_context& auth) {
auto token = co_await fetch_access_token(auth.user_id());
co_return parse_request(body, token); // BUG: body may refer to caller-owned storage.
}
这段代码看上去很高效,省去了一次拷贝。但它只在调用方保证 body 在协程完成前始终有效时才正确。在服务端代码中,“协程完成“意味着网络 I/O、认证查找、重试和超时处理全部结束。这个承诺很难兑现,往往也是错误的。
更安全的默认做法是:凡是挂起后还要用的数据,都把所有权移交给协程。
task<parsed_request> parse_and_authorize(
std::string body,
auth_context auth) {
auto token = co_await fetch_access_token(auth.user_id());
co_return parse_request(body, token);
}
拷贝或移动的开销清晰可见,便于评审。协程帧现在拥有了它所需的一切。分配开销确实构成问题时,去测量,再围绕消息边界或存储复用来重新设计。不要悄悄地跨越时间借用数据。
更多生命周期陷阱:局部变量、临时对象和 lambda 捕获
借用参数反模式是最常见的情况,但协程生命周期 bug 还有其他几种常见形式。
指向调用方局部变量的悬空引用
// BUG: coroutine captures a reference to a local that dies when the caller returns.
task<void> start_processing(dispatcher& d) {
std::vector<record> batch = build_batch();
co_await d.schedule([&batch] { // lambda captures batch by reference
process(batch); // batch may be destroyed if start_processing
}); // is suspended and its caller exits
}
当 start_processing 在 co_await 处挂起时,协程帧会让 batch 继续存活,但前提是帧本身还活着。一旦任务被 detach 或父作用域退出,协程帧随之销毁,lambda 中的引用便悬空。修复方法:按值捕获,或通过结构化所有权确保父作用域的生命周期长于被调度的工作。
临时对象生命周期坍塌
// BUG: temporary string destroyed before coroutine body executes.
task<void> log_message(std::string_view msg);
void caller() {
log_message("request started"s + request_id()); // temporary std::string
// temporary is destroyed here, before the coroutine even begins if lazy-start
}
如果是 lazy-start 协程,这个临时 std::string 在分号处就销毁了,而协程甚至还没开始执行。即使是 eager-start 协程,如果帧把 msg 存为 string_view,第一次挂起之后它就指向了已释放的内存。解决方案是在协程签名中按值接收 std::string,让帧持有自己的副本。
跨越挂起的 this 指针
// BUG: 'this' may dangle if the object is moved or destroyed while suspended.
class connection {
std::string peer_addr_;
public:
task<void> run() {
auto data = co_await read_socket(); // suspended here
log("received from " + peer_addr_); // 'this' may be invalid
}
};
如果 connection 对象在 run() 挂起期间被移动到另一个容器或被销毁,恢复时 this 就失效了。成员协程只有在对象生命周期保证长于协程时才安全。实践中,这意味着协程应持有 shared_ptr<connection>,或者所有者作用域做结构化设计,防止对象在挂起期间被销毁。
这些不是边角案例,而是生产环境中协程生命周期最常见的失败模式。每个挂起点都是调用方的世界可能已经变了样的时刻。
任务类型就是所有权契约
协程的返回类型定义了所有权、结果传递和析构语义。
一个合格的任务类型至少要回答以下问题:
- 销毁任务时会取消工作、detach 工作、阻塞等待,还是泄漏?
- 结果会被消费一次、多次,还是完全不消费?
- 异常如何传递?
- 任务是立即启动(eager),还是等到被 await 时才开始(lazy)?
- 取消是否有显式表示?
很多协程 bug 本质上是任务类型 bug。detached 的”发后即忘”协程不是异步风格偏好,而是一个所有权声明:后续代码不需要知道工作何时完成、是否失败、是否该在关闭时取消。这个声明在生产服务中很少站得住脚。
保守的默认做法很简单:每个已启动的任务都应有明确的所有者和可见的完成路径。如果你说不出所有者是谁,那你就是在制造孤儿工作。
立即启动与延迟启动影响的是正确性
协程是创建后立刻运行,还是等到被 await 时才启动,这影响的是正确性,不只是性能。
eager task(立即启动的任务)可能在调用方保存 handle 之前就已产生副作用。lazy task(延迟启动的任务)把工作推迟到编排代码决定何时、何地运行时才开始。两种方式都有合理的场景,行为必须一致且有文档说明。
这直接影响失败边界。构造任务就可能触发工作的话,异常和取消可能在父作用域认为任务”已激活”之前就可以被观察到。工作只在第一次 await 或显式调度时启动的话,所有权关系更容易推理。
建议不是一刀切地偏向某种策略,而是:任务抽象必须让启动策略足够明显,明显到评审者无需深入 promise type 的实现就能知道副作用何时开始。
恢复上下文是正确性的一部分
协程代码读起来往往像是一直在同一个线程上执行。这是错觉。
一个被等待的操作,恢复时可能跑在 I/O 线程、调度器线程池、UI 亲和线程,或 awaiter 选定的 executor(执行器,负责调度任务运行的组件)上。恢复后的代码如果会访问线程绑定的状态,或者必须在特定 executor 上继续执行,这个要求必须在抽象层面显式体现。
这正是团队用更漂亮的语法重蹈回调时代覆辙的地方。控制流看起来是顺序的,评审者便不再追问 continuation 在哪个线程上运行。结果协程在线程池线程上恢复,访问了只在发起 executor 上才安全的请求局部状态。
恢复策略应通过以下三种方式之一变得可见:
- 任务类型自身携带明确的调度器或 executor 契约。
- 代码在使用线程亲和资源前显式切换上下文。
- 组件的设计使得 await 之后的代码不依赖特定 executor。
如果三者都不满足,这个协程就在依赖隐式的环境行为。而环境行为一旦重构就会失效。
协程不会消除错误边界设计
co_await 并不能回答”失败应该抛异常、返回 std::expected、请求取消,还是终止更大操作”这个问题。它只改变了控制流的形状。
第 3 章讨论的错误边界决策在任务 API 中依然适用,而且要保持一致:
- 在内部层间栈展开可接受且被充分理解的领域,使用异常。
- 当失败是预期中的、可组合的、属于正常业务流程时,使用结果类型。
- 明确取消以何种形式呈现——在值空间、异常空间,还是任务状态中。
- 让超时处理保持显式,而不是把它埋进一个行为出人意料的 awaiter 里。
失败模型应当在调用点可读。”这个协程可能挂起”远远不够,调用方还需要知道完成意味着什么、失败时会是什么样子。
生成器与任务解决的是不同问题
C++23 的协程支持同时涵盖了 pull 风格的生成器(generator,通过 co_yield 逐个产出值的协程)和异步任务,不要混淆二者。
生成器解决的是向本地消费者分阶段产出值的问题,适用于流式解析管线、分词、批量遍历或增量转换。核心关注点是迭代器有效性、生产者生命周期,以及 co_yield 出去的引用是否仍然有效。
任务解决的是异步工作最终完成的问题。核心关注点是所有权、调度、取消和结果传递。
二者共享底层机制,但需要不同的评审视角。生成器 bug 往往是”这个 yield 出去的引用到底指向哪里?”任务 bug 往往是”挂起之后这份工作归谁管?”区分这两类问题,代码评审就能更有针对性。
析构与取消必须能组合
协程的清理路径很容易被忽略,因为正常路径看起来就是一条直线。
试想:拥有者作用域在协程挂起时退出了,会怎样?析构会请求取消吗?会等待子操作完成吗?会不会和正常完成产生竞争?未完成的注册、文件描述符、定时器或缓冲区能否被恰好释放一次?
这些是任务抽象的语义契约,不是实现细节。
如果协程析构只是丢掉了 handle,而底层操作仍在别处继续,那就是“析构即 detach“。有时这确实是有意为之,但更多时候,它只是一个等着在关闭时爆发的 bug。
协程代码的验证
正常路径的单元测试不足以验证协程逻辑。验证应着重覆盖边界行为:
- 生命周期测试——强制让调用方拥有的数据在恢复前消失。
- 取消测试——在多个挂起点中断协程。
- 调度器测试——让协程在非预期的 executor 上恢复,以暴露线程亲和性假设。
- 失败路径测试——覆盖异常、错误结果和超时竞争场景。
- Sanitizer 运行——当协程状态与共享对象交互时,检测 use-after-free 和数据竞争。
高价值组件通常值得编写确定性的测试 awaiter 或 fake scheduler,让恢复顺序可控。
协程的评审问题
在批准协程代码之前,问一问:
- 协程帧里存了什么,由谁拥有?
- 哪些借用的 view 或引用会跨越挂起点存活?
- 任务启动后归谁所有?
- 副作用何时开始——构造时、调度时,还是首次 await 时?
- 恢复发生在什么上下文中?
- 失败、超时和取消分别如何表达?
- 如果发起方在挂起期间关闭了,会怎样?
如果这些问题的答案都含含糊糊,那这个协程并不比回调代码更简单,只是更容易被误读。
要点
协程改善的是控制流的清晰度,而非免除生命周期设计的责任。
把每个挂起点都视为一道边界,跨过它之后,所有权、恢复上下文和失败语义都必须依然成立。优先选择所有权和完成行为明确的任务类型。数据需要跨越挂起存活时,就把它移入协程帧。在概念上把生成器和异步任务区分开来。不要把看似顺序的源码等同于顺序的生命周期。协程的正确性取决于什么会跨越时间持续存在,而不是 co_await 链写得有多整齐。
结构化并发、取消与背压
本章假定你已经理解局部协程生命周期和挂起风险。关注点转向系统整体形态:在真实负载下,一组任务如何启动、如何结束、如何失败,又如何彼此施加压力。
生产问题
很多异步系统会失败,即便其中每个任务单独看都没什么问题。
一个请求扇出(fan-out)到四个后端,三个返回后就给出响应,第四个在客户端断开后仍在运行。一条 worker pipeline 的输入速度快于下游存储的提交速度,内存一路攀升,直到进程被杀。关闭路径永远等不到结束,因为后台任务被 detach 了,没挂在受监督的任务树下。重试风暴吞掉了系统恢复本身所需的容量。这些归根结底不是局部协程 bug,而是编排 bug。
结构化并发(structured concurrency)是一种工程纪律:让并发工作遵循词法作用域和所有权结构。任务归属于父作用域,生命周期有明确边界,失败传播到确定的位置。取消不是口头约定,背压(backpressure,下游对上游的流量反馈)是准入策略的一部分,不是上线后仪表盘上冒出来的意外。
本章讨论这些系统级规则。第 12 章讲共享可变状态,第 13 章讲单个协程跨挂起点的所有权。本章的分析单元是一组任务,它们共同构成一条请求路径、一个流处理器或一个有界服务阶段。
非结构化工作放大的不只是吞吐量,还有故障
启动并发工作最简单的做法:哪里需要就在哪里发起任务,指望最终都能自行收场。这种风格把眼前的协调成本降到了最低,但也是系统暗中积累不可见工作的方式。
Detached task、临时线程池和 fire-and-forget 重试,带来三个可预见的后果:
- 生命周期变得非局部。 启动工作的代码不再证明这项工作何时结束。
- 失败变成被动观察。 错误只有在有人记得去记录或轮询时才浮现。
- 容量变成空中楼阁。 系统不断接受新工作,因为没有父作用域掌控准入压力。
流量不大、很少重启时,服务靠这种做法也许能撑几个月。一旦遇到突发负载、频繁部署或缓慢的下游依赖,那些隐藏的工作就会喧宾夺主。
Fire-and-forget:一份失败清单
在与结构化并发做对比之前,先看清非结构化工作到底怎么出问题。”Fire-and-forget”不是一种反模式,而是好几种,每种有各自的失败方式。
无主工作的资源泄漏
// Anti-pattern: detached task leaks a database connection on cancellation.
void on_request(request req) {
std::jthread([req = std::move(req)] {
auto conn = db_pool.acquire(); // acquired, never returned on some paths
auto result = conn.execute(req.query);
send_response(req.client, result);
}).detach(); // no owner, no cancellation, no cleanup guarantee
}
进程一旦开始关闭,detached thread 不会收到 stop request,数据库连接也不会归还连接池。滚动部署期间成千上万的在途请求都碰上这个问题:数据库连接耗尽,旧进程卡在 std::thread 的析构函数里;更糟的是,进程在这些线程仍引用已销毁的全局对象时就退出了。
未被观察的异常会静默消失
// Anti-pattern: exception in detached task is never observed.
void start_background_sync() {
auto handle = std::async(std::launch::async, [] {
auto data = fetch_remote_config(); // throws on network error
apply_config(data);
});
// handle is destroyed here — std::async's destructor blocks,
// but if this were a custom fire-and-forget task, the exception
// would be silently swallowed.
}
std::async 的析构函数会阻塞等待(本身就可能出乎意料)。但大多数支持 detach 的自定义任务类型,销毁 handle 而不观察结果,异常就会无声消失。系统继续带着过期配置运行,直到数小时后出现行为退化,失败才被注意到。
孤儿工作导致关闭挂起
// Anti-pattern: shutdown cannot complete because background tasks were never tracked.
class ingestion_service {
void ingest(message msg) {
// "just kick off enrichment in the background"
pool_.submit([msg = std::move(msg), this] {
auto enriched = enrich(msg); // calls external service, may block
store_.write(enriched);
});
}
void shutdown() {
store_.close(); // closes storage
pool_.shutdown(); // waits for in-flight tasks
// BUG: in-flight tasks may call store_.write() after store_ is closed
// BUG: enrich() may block indefinitely — pool shutdown hangs
}
};
线程池里确实有任务,但服务对这些任务需要什么资源、该如何取消完全没有建模。关闭要么挂起(卡在阻塞的外部调用上),要么产生竞态(任务还在使用依赖,依赖已经被关掉了)。生产环境里,一次本该干净的重启变成进程 kill,进程 kill 又演变成数据丢失。
结构化替代方案概述
上述三个问题,结构化的回答都指向同一个原则:谁创建了工作,谁就负责它的完成。
// Structured: parent scope owns child tasks, propagates cancellation, awaits completion.
task<void> on_request(request req, std::stop_token stop) {
auto conn = co_await db_pool.acquire(stop); // respects cancellation
auto result = co_await conn.execute(req.query, stop);
co_await send_response(req.client, result);
// conn returned to pool when coroutine frame is destroyed
// if stop is triggered, co_await points observe it and unwind cleanly
}
父请求作用域可以在客户端断开或 deadline 到期时取消 token。协程的 awaitable 在每个挂起点检查 token,资源通过 RAII 释放,没有工作能比拥有者活得更久。与 fire-and-forget 的差别不在于风格,在于系统能否干净地关闭。
结构化并发意味着父作用域拥有子工作
核心思想很简单:某个作用域启动了子任务来完成自己的工作,那么在该作用域被视为完成之前,这些子任务就应当完成、失败或被取消。
由此获得三个属性,临时拼凑的异步代码默认不具备这些属性:
- 工作的生命周期受父操作约束。
- 失败可以在同一个地方被聚合或升级。
- 取消和关闭沿着任务树传播,而不必在进程里到处搜索散落的尾巴。
这不要求使用某个特定库,要求的是设计纪律。一个向多个后端扇出的请求处理器,不应该在后端调用仍在运行时就返回,除非业务契约明确允许 detached 的后续工作并且指定了它的所有者。一个 batch consumer 不应该把下游任务入队却不决定关闭时由谁来排空、过载时由谁来吸收压力。
结构化并发就是把所有权规则应用到时间维度。第 1 章教的是每个资源都需要所有者,本章把同样的原则应用到并发工作上。
取消必须是一等契约
取消常被当成一种客气的建议。生产环境里,它就是负载控制。
客户端断开、deadline 到期、父任务失败后,继续执行子工作就是在浪费 CPU、内存、数据库容量和重试预算。更严重的是,未取消的工作会与有效工作争抢资源。系统在高压下频频崩溃,往往就是因为它还在忙着做那些已经没有意义的事。
C++ 提供了 std::stop_source、std::stop_token 和 std::jthread 等构件。示例项目的 Server::run(std::stop_token)(examples/web-api/src/modules/http.cppm)用了这些构件:accept 循环在每次迭代时检查 stop_token.stop_requested(),并通过带一秒超时的 select() 确保即使没有客户端连接,检查也能及时进行。这就是协作式取消:token 是契约,基于超时的轮询是机制。
但原语本身不够。更难回答的是语义层面的问题:
- 哪些操作是可取消的?
- 在哪些边界上观察取消?
- 在报告完成之前,保证了哪些清理?
- 部分进度会被提交、回滚,还是通过补偿机制显式可见?
如果这些问题没有答案,把 stop token 透传几个函数也不过是做做样子。
取消有方向性。从父到子的传播应当是默认行为;从子到父的升级取决于策略:一个子任务失败,可能需要取消兄弟任务,可能需要降级结果,也可能只是记录下来让其余工作继续。这条规则必须在拥有该任务组的作用域上写明。
反模式:没有有界所有权的 fan-out
// Anti-pattern: child work outlives the request and overload has no admission limit.
task<aggregate_reply> handle_request(request req) {
auto a = fetch_profile(req.user_id);
auto b = fetch_inventory(req.item_id);
auto c = fetch_pricing(req.item_id);
co_return aggregate_reply{
co_await a,
co_await b,
co_await c,
};
}
这段代码很整洁,但定义严重不足。
客户端在第一次 await 之后超时了,谁来取消这三个子操作?什么机制阻止一万个并发请求瞬间启动三万个后端调用?fetch_inventory 卡住了,另外两个还继续跑吗?某个调用快速失败后,其余的应该被取消还是继续完成?
问题不在于扇出本身,而在于代码里看不到监督策略。
在结构化设计中,请求作用域持有一个取消 source 或 token,子任务在该作用域内启动并附带 deadline,对下游依赖的并发度则通过 permit 或 semaphore 做有界控制。具体用什么抽象因代码库而异,但核心性质不变:请求不得创建匿名工作。
Deadline 与预算优于尽力而为的 timeout
Timeout 往往各处各设,毫无一致性。某个依赖用 200 ms,另一个用 500 ms,调用方自身的 deadline 是 300 ms,却没人把它传下去。结果就是白做工加上混乱的遥测数据。
更好的做法是预算传播。父操作携带 deadline 或剩余预算,子操作从中派生自己的限制,而非各自发明一套无关联的超时值。取消意图和延迟预期才能保持一致。
代价是下游 API 必须显式接收 deadline 或取消上下文,timeout 行为会在函数签名或任务构造器中暴露出来。这个代价值得付。隐藏的 timeout 策略比显式的 timeout 策略更危险。
背压是准入控制,不是抱怨日志
背压意味着系统对”工作到来的速度快于处理速度时该怎么办”有明确答案。
没有这个答案,工作就堆积在队列、buffer、重试循环和协程帧里。先涨的是内存,然后是延迟,最后故障才变得不可忽视。无界队列不是弹性,只是把过载变成了延时爆炸。
真正的背压机制都是具体的:
- 有界队列——满了就拒绝或延后新工作。
- Semaphore 或 permit——限制对稀缺依赖的并发访问数。
- 生产者节流——下游阶段饱和时自动减速。
- 负载丢弃——当服务全部流量会拖垮所有人的延迟时,主动放弃一部分。
- Batch 大小和 flush 策略——与下游提交成本匹配。
每种机制背后都是业务策略:哪些工作可以丢?哪些必须等?哪些客户端会收到显式的过载信号?这些是用并发控制手段表达的产品决策。
有界并发通常优于更大的线程池
依赖变慢时,很多团队的第一反应是加大线程池或加深队列。这往往适得其反。
一个数据库只能承受五十个有效并发请求,放进去两百个在途操作,多出来的部分基本只会加剧争用和 timeout 重叠。CPU 密集的解析阶段、压缩任务、有内部瓶颈的远程服务调用,道理都一样。
应该在稀缺资源实际所在的位置约束并发,让上限在代码和遥测中都清晰可见,然后再决定触及上限后该怎么办:等待、快速失败、降级还是重定向。盲目扩大线程池只会把过载隐藏起来,直到整个系统一起饱和。
Pipeline 需要让压力向上游传播
Pipeline 是背压纪律最无法回避的场景。
假设有一个消息消费者:解析记录、用远程查找做 enrich、最后把 batch 写入存储。解析快过存储时,总得有个阶段降速。enrich 快过解析时,它也不该仅仅因为“能做“就继续制造更多在途请求。系统开始关闭时,所有阶段都需要协调好的 drain 或 cancel 策略。
好的 pipeline 设计会明确定义:
- 每个阶段允许的最大在途工作量。
- 阶段之间的最大队列深度。
- 队列满时,生产者会被阻塞、丢弃输入,还是触发负载削减。
- 取消时,是排空部分完成的 batch,还是丢弃它们。
- 哪些 metrics 能在内存压力变得致命之前揭示饱和。
这不是锦上添花。它决定了系统是能优雅降级,还是一路积压工作直到崩溃。
失败传播需要策略,而不是希望
工作一旦被组织成任务组,失败处理就从碰运气变成了设计选择。
常见策略包括:
- Fail-fast 任务组:一个子任务失败就取消同级任务,因为没有所有部分结果就毫无意义。
- Best-effort 任务组:允许某些子任务失败,并把它们记录下来。
- Quorum 任务组:只要足够多的子任务成功就满足操作,其余任务会被取消。
- Supervisory loop:在速率限制和预算约束下,重启隔离的子工作。
四种在各自场景中都是正确的。代码和抽象必须让所选策略一目了然。子任务失败后悄悄继续运行,不叫韧性,叫含糊不清。
关闭是照妖镜
并发结构薄弱的系统平时看起来往往一切正常,直到关闭时才原形毕露。
干净的关闭路径会把所有隐藏问题逼到台面上:还有哪些任务在跑?哪些可以安全中断?哪些队列必须排空?关闭开始后哪些副作用仍可能被提交?哪些后台循环持有 stop source,又由谁来等待它们完成?
关闭测试的价值远超一般测试。它们能暴露 detached 工作、缺失的取消点、无界队列和无主任务。一个子系统如果说不清自己在负载下如何停止,说明它还没有真正掌控自己的并发模型。
示例项目实现了一条完整的结构化关闭链,将上述原则付诸实践。这条链横跨三个文件:
信号处理器设置标志位(examples/web-api/src/main.cpp):
std::atomic<bool> shutdown_requested{false};
extern "C" void signal_handler(int /*sig*/) {
shutdown_requested.store(true, std::memory_order_release);
}
Server::run_until 将标志位桥接到 jthread 的 stop token(examples/web-api/src/modules/http.cppm):
void run_until(const std::atomic<bool>& should_stop) {
std::jthread server_thread{[this](std::stop_token st) {
run(st);
}};
while (!should_stop.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
server_thread.request_stop();
// jthread 在析构时自动 join
}
Server::run 在每次 accept 循环迭代时检查 token:
void run(std::stop_token stop_token) {
// ...
while (!stop_token.stop_requested()) {
// 带一秒超时的 select(),然后再次检查 stop
// accept 并处理连接……
}
std::cout << "Server shutting down gracefully\n";
}
所有权链清晰可见:main 拥有 Server,Server 拥有 jthread,jthread 拥有 stop_source。Ctrl+C 到来时,信号处理器设置 atomic 标志,run_until 观察到它并调用 request_stop(),accept 循环在下一次迭代退出,jthread 析构函数 join 线程。没有 detached 工作,没有孤立连接,关闭和在途请求之间没有竞态。这就是结构化并发应用于真实服务生命周期的样子。
结构化异步系统的验证与遥测
单元测试无法验证结构化并发和背压。你需要实际证据表明系统在压力下行为正常。
有效的验证手段:
- 负载测试:把系统推到标称容量以上,确认内存仍然有界。
- 取消测试:注入断连、deadline 到期和部分子任务失败。
- 关闭测试:启动工作、触发停止,验证系统能迅速归于静止。
- 监控指标:在途任务数、队列深度、permit 利用率、拒绝率、deadline 到期次数、取消延迟。
- 链路追踪或日志:展示父子关联关系,让孤儿工作无所遁形。
如果可观测性无法揭示工作堆积在哪里、由哪个父作用域负责,那么所谓的结构就只存在于作者脑中。
结构化并发的评审问题
在批准一个异步编排方案之前,问问自己:
- 每组子任务归谁管?
- 什么事件触发取消:父任务完成、失败、deadline、关闭,还是过载?
- 用什么机制限制对每个稀缺依赖的并发访问?
- 当队列或 permit 达到上限时会发生什么?
- 失败会取消同级任务、优雅降级,还是等待 quorum?
- 在峰值负载下,关闭能否迅速完成?
- 哪些 metrics 能证明背压真的在工作?
如果答不上来,系统多半就是在靠乐观和余量撑着。
要点
结构化并发,就是把所有权延伸到时间和任务树上。
不要发起匿名工作。让父作用域掌管子任务的生命周期,有意识地传播取消,在资源真正稀缺的地方限制并发。把背压当准入策略来设计,而非事后调参的补救措施。说不清工作何时停止、由谁取消、过载如何限制的系统,算不上拥有并发模型,它只是拥有异步代码。
数据布局、容器与内存行为
生产问题
C++ 中的许多性能问题,表面上看是算法选择之争,实则是数据表示的失败。团队还在讨论某次查找该是 $O(1)$ 还是 $O(\log n)$,真正的问题早已暴露在别处:热路径上堆节点的逐一遍历、过多缓存行(cache line,CPU 缓存的最小读写单位,通常 64 字节)的触碰、每次迭代中冷字段的无谓搬运。编译器无法弥补糟糕的数据形状,只能在既有的形状上做有限优化。
本章关注的是决定数据如何驻留在内存中的一系列决策:容器选择、元素布局、迭代顺序、失效行为,以及视图如何在不掩盖生命周期的前提下暴露存储数据。不是抽象地讨论”哪个容器最快”,而是一个更具体的问题:在你实际构建的系统中,什么样的数据表示能让核心操作足够高效?
回答这个问题时,以真实的负载压力为参照。管理数百万路由条目的请求路由器、持续摄取高密度时序样本的遥测 pipeline、每帧扫描组件状态的游戏或模拟更新循环、逐批遍历事件的分析作业。在这些系统中,容器选择是性能契约的一部分。
当 Big-O 不再解释运行时间
渐近复杂度是必要的,但远远不够。它能排除明显低效的设计,却无法描述内存访问流量、分支预测命中率、prefetch 行为、false sharing 以及指针间接跳转的代价。std::list 的插入是常数时间,但如果每个有意义的操作都要在冷内存里追指针,这一点几乎毫无意义。对于中等规模的数据,排序后的 std::vector 常常能打赢哈希表,因为连续内存上的二分查找和廉价迭代带来的优势盖过名义上的查找复杂度差异。
这种错位很重要,因为生产环境的负载很少均匀。某些操作主导了总体开销,另一些操作对延迟敏感,多出几次缓存未命中可能比多做一次比较影响更大。选择容器之前,先用直白的语言描述清楚实际的负载特征:
- 主导操作是什么——全量遍历、单点查找、尾部追加、中间删除,还是批量重建?
- 数据构建完成后主要是只读的,还是会被持续修改?
- 是否需要稳定地址、稳定的迭代顺序,或可预测的失效规则?
- 数据集是小到能放进私有缓存,还是大到 TLB 和内存带宽已成为主要瓶颈?
如果这些问题没有答案,容器选择就只能靠经验传闻。
默认优先连续存储
对于频繁遍历的数据,连续存储应当是默认起点。std::vector、std::array、std::span、std::mdspan、flat buffer 和列式数组屡屡胜出,因为硬件偏爱可预测的访问模式。顺序扫描使处理器能高效 prefetch(预取)、摊薄 TLB 开销,并保持简单的分支行为。硬件层面的优势往往比理论上”更高级”的数据结构所带来的算法优势更大。
差距十分明显。一个简单例子:对元素数量相同的 std::vector<int> 和 std::list<int> 分别求和:
#include <list>
#include <vector>
#include <numeric>
#include <cstdint>
// Contiguous: hardware prefetcher has a good day.
std::int64_t sum_vector(const std::vector<int>& v) {
return std::accumulate(v.begin(), v.end(), std::int64_t{0});
}
// Scattered: every node is a pointer chase. Each dereference is
// a potential cache miss if nodes were allocated at different times
// and landed on different cache lines or pages.
std::int64_t sum_list(const std::list<int>& l) {
return std::accumulate(l.begin(), l.end(), std::int64_t{0});
}
在典型硬件上处理一百万个元素时,纯遍历场景下 vector 版本通常比 list 版本快 10-50 倍。list 有每节点开销(大多数实现中每个元素附带两个指针加 allocator 元数据),但主导成本不在空间上。真正的瓶颈在于:推进到下一个节点时需要加载一个指针,而该指针指向的地址对 prefetcher 完全不可预测。每一步都可能触发一次 last-level cache miss,代价 50-100 ns;反观 vector 扫描,硬件 prefetcher 能识别顺序访问模式,几乎每次都命中 L1 或 L2 缓存。
这不是刻意构造的最坏场景,而是 std::list 在通用 allocator(分配器)下随时间分配节点的常态。换用 pool allocator 使节点在内存中更连续,效果也有限:next-pointer 的间接跳转和每节点的额外开销依然无法消除。
许多高性能设计初看起来都很“朴素“:把记录存进 std::vector,排序一次,之后通过二分查找或批量扫描来响应查询;热数据保持紧凑;用粗粒度批处理重建索引,而不是增量维护 pointer-rich 结构;尽可能将随机访问转化为规则性的顺序访问。
std::vector 不是放之四海而皆准,但举证责任通常在非连续方案一侧。需要 node-based 或 hash-based 结构时,理由应当具体:变更过程中需要迭代器稳定、确实存在大量中间位置的插入、需要并发所有权模式、外部 handle 必须在容器变更后仍然有效,或者查找模式的规模和稀疏度确实大到让 hashing 物有所值。
容器编码的是权衡,不只是操作
有经验的评审者应当把每个容器选择解读为一组承诺与代价。
std::vector 承诺连续存储、高效尾部追加和快速遍历。代价在于偶发的重分配、扩容时的迭代器失效,以及昂贵的中间删除。对于批处理、索引、稠密状态,以及可以重建或压缩的表,它通常是正确选择。
std::deque 牺牲严格的连续性,换来更高效的双端增长和避免整块缓冲区搬迁。对队列式负载可能有价值,但遍历时的局部性弱于 std::vector。扫描密集的场景中,把它当成”基本等同于 vector”是错误的。
std::map 和 std::set 等有序关联容器,提供的是稳定的排列顺序以及在频繁变更下仍然稳定的引用,代价则是节点分配、间接访问和分支密集的遍历。只有当排序在语义上不可或缺,或者变更模式使得”一次构建、定期重建”的策略不可行时,使用它们才有充分理由。对于读多写少数据上的热查找,它们不是好的默认选项。
std::unordered_map 和 std::unordered_set 放弃顺序,换取平均情况下更快的查找速度。但它们有实在的内存开销:bucket 数组、load factor 管理、许多实现中的节点存储,以及不可预测的 probe 行为。键空间较大且需要频繁精确查找时,它们有价值。但如果迭代是主要操作、内存占用很重要,或工作集小到排序后的连续数据完全放得进缓存,优势就不明显了。
C++23 新增了 std::flat_map 和 std::flat_set(分别在 <flat_map> 和 <flat_set> 头文件中),将生产代码库多年来的通行做法标准化:用排序后的连续键值数组做索引,读多写少的场景中往往表现更佳。C++23 之前,团队只能依赖 Boost.Container、Abseil 或自行实现的等价物。标准版本接受底层容器作为模板参数,可以根据局部性和分配需求选用 std::vector(默认)、std::deque 或 std::pmr::vector 作为后端。std::flat_map 的迭代器失效规则与 std::vector 一致,中间位置的插入由于元素搬移仍是 O(n)。它是为读优化设计的,不适合频繁写入。
元素内部的布局与容器本身同样重要
选对容器(比如 std::vector<Order>)还不够,Order 本身的结构照样可能浪费带宽。每次迭代只需要读取 price、quantity 和 timestamp,但每个对象还附带一个大 symbol 字符串、审计元数据、重试策略和调试状态,扫描这个 vector 时大量冷字节仍然会被拖进缓存。
这正是 hot/cold splitting(冷热分离)发挥作用的地方。原则很简单:时间上经常一起访问的字段在物理上也放到一起。不常用的状态移到单独的表、side structure 或 handle 背后能显著降低扫描成本,就应当这样做。不必过度抽象成通用的”entity system”,除非代码库确实需要。很多时候正确做法更直接:一个紧凑的热记录加一个存放冷元数据的辅助存储。
同样的性能压力也驱动着 array-of-structures(AoS,结构体数组)与 structure-of-arrays(SoA,数组结构体)之间的抉择。对象作为整体在系统中流转时,AoS 更易于理解和维护。处理本质上是列式的(过滤所有 timestamp、对所有 price 做计算、聚合所有计数器、为向量化 kernel 提供输入),SoA 则更具优势。数据表示应当匹配主导访问模式,而非迁就想象中的对象模型。
比较同一批数据的两种表示方式:
// Array of Structures (AoS): natural object-oriented layout.
// Each Tick is self-contained. Good when you routinely need all
// fields of a single tick (e.g., serializing one record, looking
// up a specific event).
struct Tick {
std::int64_t timestamp_ns;
std::int32_t instrument_id;
double bid;
double ask;
char exchange[8]; // cold: rarely used in hot aggregation
std::uint32_t seq_no; // cold
std::uint16_t flags; // cold
// sizeof(Tick) ~ 48 bytes with padding on most ABIs
};
double mid_price_sum_aos(std::span<const Tick> ticks) {
double total = 0.0;
for (const auto& t : ticks) {
// Each iteration loads a full 48-byte Tick, but only
// reads bid and ask (16 bytes). The remaining 32 bytes
// pollute cache lines and reduce effective bandwidth.
total += (t.bid + t.ask) * 0.5;
}
return total;
}
// Structure of Arrays (SoA): columnar layout.
// Each field lives in its own contiguous array.
struct TickColumns {
std::vector<std::int64_t> timestamp_ns;
std::vector<std::int32_t> instrument_id;
std::vector<double> bid;
std::vector<double> ask;
std::vector<std::array<char, 8>> exchange;
std::vector<std::uint32_t> seq_no;
std::vector<std::uint16_t> flags;
void append(std::int64_t ts, std::int32_t id, double b, double a) {
timestamp_ns.push_back(ts);
instrument_id.push_back(id);
bid.push_back(b);
ask.push_back(a);
// ...other columns omitted for brevity
}
};
double mid_price_sum_soa(const TickColumns& ticks) {
double total = 0.0;
// Only bid[] and ask[] are touched. Each cache line is 100%
// useful payload. The compiler can auto-vectorize this loop,
// and the prefetcher has two clean sequential streams.
for (std::size_t i = 0; i != ticks.bid.size(); ++i) {
total += (ticks.bid[i] + ticks.ask[i]) * 0.5;
}
return total;
}
处理一百万条 tick 时,在现代 x86 硬件上做列式聚合,SoA 版本通常快 2-4 倍。根本原因是带宽利用率:AoS 循环每个元素加载约 48 字节却只用到 16 字节,每次缓存行读取有三分之二是浪费。SoA 循环只访问所需的两个 8 字节数组,两者都是完美的顺序访问。编译器也更容易为 SoA 版本生成 SIMD 指令,因为没有交错步长妨碍向量化。
SoA 的代价在别处。新增一条 tick 意味着要向每个列 vector 都追加一次,写起来繁琐也容易出错。想把”一条 tick”传给函数,要么传索引加整张表的引用,要么从各列临时拼装一个 struct。系统的主要操作是逐条记录处理且需要访问大部分字段时,AoS 布局既避免这种拼装开销,也让相关数据在内存中紧挨着。
一个实用的折中方案是 hot/cold splitting,而非完全转向 SoA:保留一个只包含热路径所需字段的紧凑”热” struct,冷字段放进按相同下标索引的并行 side table。
这种表示不因为”看起来更现代”就天然更好。优势只在特定条件下成立:负载确实会反复独立处理各列,或者紧凑的数值列能显著改善内存行为。下游逻辑频繁需要包含多个相关字段的完整 tick 对象时,数据拼装的成本或代码清晰度的损失完全可能抵消布局上的收益。
稳定 handle 很昂贵;先问你是否真的需要它们
许多糟糕的数据表示,根源在于一个未被明言的隐含需求:地址稳定性。代码把裸指针或引用存入容器元素,容器的选择不再由访问成本驱动,而被生命周期和失效问题绑架。这往往把设计推向 node-based 结构,地址是稳定了,其他路径上的局部性却全面恶化。
有时这种权衡合理。但更多时候,深层问题在于系统把对象身份与物理地址耦合了。组件需要持久的外部引用时,应当使用显式 handle、带 generation counter 的索引,或指向稳定间接层的 key。这样底层存储保持紧凑和可移动,外部引用也安全。
handle 不是零成本的,会引入查找步骤、校验逻辑和过期引用的错误处理。但这些成本显式且局部化,通常好过为了少数长生命周期的 alias 就让整个数据层被迫使用 pointer-stable 容器。
失效规则是 API 设计的一部分
容器不仅是存储机制,它还隐含地创造或破坏 API 保证。返回一个指向 std::vector<T> 内部的 std::span<T>,等于告诉调用方:只有底层存储存活且未发生导致失效的变更时,这个视图才有效。返回哈希表中的迭代器,把 rehash 敏感性暴露给了调用方。返回 node 容器中的引用,暴露了关于对象生命周期和同步的隐含假设。
数据表示与接口设计无法彻底分开。一个模块如果希望保留在内部压缩、排序、重建或重新分配的自由,就不该轻易向外暴露原始迭代器或长生命周期引用。实现需要移动数据的自由时,应优先采用返回值、短生命周期的回调式访问、拷贝出的摘要,或 opaque handle。
ranges 让非拥有访问的表达更整洁,但也更容易被误用。一条 view pipeline 表面上看起来是纯函数式的,背后可能引用着生命周期比 pipeline 更短的存储。底层数据位于临时 buffer、query object 或请求级 arena 中,一个写得再漂亮的 range 表达式仍然是生命周期 bug。存储模型才是第一位的。
在热路径上,稠密数据胜过聪明的对象模型
团队过于字面地对问题领域建模时,数据密集型系统的性能往往急剧退化。比如一个日志处理阶段,因为领域“听起来很面向对象“,就被建成了由对象图、virtual method 和分散所有权构成的体系。高负载下,profiler 暴露出的瓶颈不是昂贵的算术运算,而是大量的缓存未命中和 allocator 抖动。
热路径上,应优先选择能让主要遍历操作既简单又稠密的数据表示。数据包分类器可以把解析后的头字段存入紧凑数组,罕见的扩展数据另行存放;推荐引擎可以把不可变的 item 特征与请求级的评分 buffer 分离;订单簿在价格区间有界的情况下,可以把价格档位放入按归一化 tick 偏移量索引的连续数组,而非使用堆节点构成的树。
这些设计看上去可能不如对象图“优雅“,但它们更诚实地反映了硬件的工作方式。硬件执行的是内存访问,不是类图。
实际中的缓存不友好结构
仔细看看缓存不友好结构到底是什么样子、为什么会拖垮性能。这种失败模式很常见,而且在代码评审中难以发现。
// A naive priority queue built from scattered heap nodes.
// Each node is individually allocated and linked by pointer.
struct Job {
int priority;
std::string description; // may allocate on heap
Job* next;
};
class NaivePriorityQueue {
Job* head_ = nullptr;
public:
void insert(int priority, std::string desc) {
auto* node = new Job{priority, std::move(desc), nullptr};
// Sorted insert: walk the list to find position.
// Each step dereferences a pointer to a random heap location.
Job** pos = &head_;
while (*pos && (*pos)->priority <= priority)
pos = &(*pos)->next;
node->next = *pos;
*pos = node;
}
// Find highest-priority job. Cheap -- it is at the head.
// But any operation that scans (e.g., "remove by description",
// "count jobs above threshold") pointer-chases through
// potentially thousands of cold cache lines.
Job* top() const { return head_; }
~NaivePriorityQueue() {
while (head_) { auto* n = head_; head_ = head_->next; delete n; }
}
};
再来看一个将相同数据连续存放的缓存友好版本:
struct Job {
int priority;
std::string description;
};
class FlatPriorityQueue {
std::vector<Job> jobs_;
public:
void insert(int priority, std::string desc) {
jobs_.push_back({priority, std::move(desc)});
// Could maintain sorted order with std::lower_bound + insert,
// or just push_back and sort/partial_sort when needed.
}
// Rebuild top in O(n) but with contiguous memory access.
// For scan-heavy workloads this dominates pointer-chasing.
const Job& top() const {
return *std::min_element(jobs_.begin(), jobs_.end(),
[](const auto& a, const auto& b) {
return a.priority < b.priority;
});
}
};
flat 版本调用 top() 时比较次数可能更多,但所有比较都是在连续内存上流式完成的。实践中,对于几千个元素以内的集合,flat scan 往往比链表版本的”O(1) 头访问 + O(n) 插入”更快,因为链表插入过程中每访问一个节点就可能触发一次缓存未命中。对于更大的集合,std::priority_queue(底层用 std::vector 维护一个连续堆)正是出于同样的原因而成为标准工具。
总结规律:pointer-linked 结构在每一步遍历时都要缴纳一次“节点税“——这在算法复杂度分析中完全不可见,却往往主导了实际的执行时间。
常见失败模式
有几类反复出现的错误。
第一类:根据操作速查表而非实际负载特征选容器。”需要快速查找”太笼统了。多少元素?键的分布如何?遍历多频繁?重建多频繁?没有这些数字,”快”毫无意义。
第二类:因为“放在一个 struct 里更整洁“就把热字段和冷字段混在一起。当代码每秒要遍历数百万个元素时,紧凑布局绝不是过早优化。
第三类:让偶然产生的 alias 决定整个数据表示。少量长生命周期的指针不应逼迫整个系统转向 node-based 存储——如果一个 handle 层就能把需求隔离开来,就应当这样做。
第四类:把视图误当作生命周期管理工具。它们不是。std::span 和 ranges 只是让非拥有式访问变得显式,但并不能让这种访问自动变得安全。
第五类:对单个 microbenchmark 过拟合。一种数据表示在孤立的查找测试中胜出,不代表它在解码、过滤、聚合和序列化相互交织的完整 pipeline 中同样出色,事实上可能差距悬殊。
在真实代码中要验证什么
数据表示层面的决策应当在代码评审和测量计划中有所体现,不只是埋在实现里。
评审者应当追问:
- 哪些操作主导了时间开销和内存流量?
- 所选容器是在优化这些主导操作,还是只为了让某个局部调用点写起来更方便?
- 地址稳定性是真正的需求,还是代码通过 alias 无意间泄露了内部的表示约束?
- 哪些失效规则已经成为 API 契约的组成部分?
- 热字段在物理上是否紧密相邻,还是每次扫描都在把冷状态拖进缓存?
- 如果返回了视图,是哪些存储条件和变更操作限定了视图的有效生命周期?
下一章将把这些问题转化为具体的成本模型。本章的核心观点很简单:数据表示往往是影响性能的第一要素。在讨论 allocator、内联策略或基准测试方法论之前,先把数据的形状设计对。
要点总结
- 从主导访问模式出发,而非从容器的经验传闻出发。
- 对扫描密集和读多写少的负载,默认优先选择连续存储。
- 将稳定地址和稳定迭代器视为昂贵的需求,使用时须有充分理由。
- 当反复遍历使带宽成为瓶颈时,将热数据与冷数据分离。
- 当对象身份需要在存储移动后依然有效时,有意识地引入 handle 或间接层。
- 将失效规则和视图生命周期视为数据表示在 API 层面的直接后果。
分配、局部性与成本模型
生产问题
数据形状确定之后,下一个性能问题是:字节从哪里来,又要搬动多少次。很多团队一看到”这条路径很慢”,就直接跳去调 allocator(分配器)或设计对象池,连成本模型都没建立。在动分配策略之前,你得先弄清楚瓶颈到底在哪:是分配延迟、allocator 内部的锁同步、page fault、对象散落引发的缓存和 TLB miss、过大值类型的拷贝开销,还是抽象层叠加出来的指令成本。
本章就是建立这样一个模型。重点不是背 allocator API,而是具体推演某个设计到底会让机器干什么。”std::function 零开销””arena 总是更快””小分配现在很便宜”,这些都不算工程论证。真正的论证从这里开始:你能说清楚实际的对象图长什么样、分配发生在何时何处有多少次、相关对象的所有权跨度有多长,以及每多一层间接访问对局部性有怎样的影响。
和上一章划清界线。第 15 章问”该用什么表示”,本章问”选定了某种表示之后,它在运行过程中会带来哪些成本”。容器仍然会出现,但侧重点是分配频率、生命周期聚类、对象图深度和局部性,而非容器语义本身。
从分配清单开始
第一个有用的性能模型简单到令人不好意思说出口:把你关心的路径上所有会触发分配的地方列出来。
绝大多数代码库在这方面做得都比自以为的差。请求解析器为每个 header value 分配字符串;路由层用类型擦除 wrapper 存回调;JSON 转换过程物化出中间对象;日志路径往临时 buffer 里做格式化。单看每个决策可能都说得过去,加在一起,一个请求还没开始做真正的业务工作,就已经执行了几十甚至上百次堆分配。
示例项目中的 parse_request()(examples/web-api/src/modules/http.cppm)就是这种模式的体现。每次调用都会为每个 header 的名称和值各分配一个 std::string(第 191 行:headers.emplace_back(std::string(name), std::string(value))),再加上 path 和 body 各一个 std::string。一个带十个 header 的请求,在任何 handler 开始执行之前就至少需要十二次堆分配。这是 PMR(polymorphic memory resource,多态内存资源)优化的天然候选场景:用一个以栈上 buffer 为后端的 std::pmr::monotonic_buffer_resource,可以让所有这些字符串都从同一个 arena 中获取内存,消除逐 header 的 allocator 调用,并在请求作用域结束时一次性批量析构。
做清单时要区分三个层面的问题:
- 稳态热路径上,哪些操作会触发分配?
- 哪些分配属于一次性初始化或批量重建的成本?
- 哪些分配可以通过调整所有权或数据流来消除,而不是靠换一个更好的 allocator?
第三个问题最关键。系统频繁分配的根因在于它硬要把一段密集处理拆成大量短命的堆对象,换 allocator 只是止痛,治不了病。杠杆最大的改进往往是从根本上消除对这些分配的需求。
下面用一个具体例子来展示热路径上分配密集型代码的样子,以及如何改写成低分配版本:
// Allocation-heavy: every event creates a temporary string,
// a vector, and a map entry. Under load this path may perform
// 5-10 heap allocations per event.
struct Event {
std::string type;
std::string payload;
std::vector<std::string> tags;
std::unordered_map<std::string, std::string> metadata;
};
void process_batch_heavy(std::span<const RawEvent> raw,
std::vector<Event>& out) {
for (const auto& r : raw) {
Event e;
e.type = parse_type(r); // allocates
e.payload = parse_payload(r); // allocates
e.tags = parse_tags(r); // allocates vector + each string
e.metadata = parse_meta(r); // allocates map buckets + nodes
out.push_back(std::move(e)); // may reallocate out's buffer
}
}
// Allocation-light: pre-sized arena, string views into stable
// input buffer, fixed-capacity inline storage.
struct EventView {
std::string_view type;
std::string_view payload;
// Use a small fixed-capacity container for tags.
// boost::static_vector or a similar stack-allocated small vector.
std::array<std::string_view, 8> tags;
std::uint8_t tag_count = 0;
};
void process_batch_light(std::string_view input_buffer,
std::span<const RawEvent> raw,
std::vector<EventView>& out) {
out.clear();
out.reserve(raw.size()); // one allocation, amortized
for (const auto& r : raw) {
EventView e;
e.type = parse_type_view(r, input_buffer);
e.payload = parse_payload_view(r, input_buffer);
e.tag_count = parse_tags_view(r, input_buffer, e.tags);
out.push_back(e);
}
// Zero heap allocations per event if input_buffer is stable
// and out has sufficient capacity.
}
轻量版有一系列约束:输入 buffer 的生存期必须覆盖所有 view,tag 数量有上限,metadata 也要换种方式处理。这些约束就是免去分配所付出的代价。值不值得取决于具体负载,但把代价摆到明面上,这才是关键。
分配成本不只是调用 new
工程师谈到分配时,常常觉得成本就是 allocator 函数调用那一下。生产环境里,这往往只是总账的一小部分。分配还影响缓存局部性、同步行为、碎片化、页面工作集和后续的销毁开销。对象图把逻辑上相邻的数据散落到不相关的堆地址上,之后每次遍历都在为这个决定买单。每个请求的分配都从多个线程撞进同一个全局 allocator,allocator 争用就会变成延迟抖动的来源。大量短命对象逐个销毁时,burst 期间光是清理流量就可能主导尾延迟。
“我们上了 pool,问题就解决了”这种说法往往站不住脚。pool 或许能降低 allocator 调用开销甚至减少争用,但对象图依然指针满天飞、布局零散的话,遍历照样很贵。反过来,把请求局部状态都放到连续 buffer 里的设计,哪怕用默认 allocator,也可能分配极少、局部性极好。
生命周期聚类通常胜过聪明的复用
同生共死的对象,最好也一起分配。这就是 arena(区域分配器)和 monotonic resource 背后的直觉:一批数据共享同一个生命周期边界,为逐个释放付费就是白花功夫。请求局部的 parse tree、临时 token buffer、图搜索的 scratch state、一次性编译元数据,都是经典适用场景。
C++23 在这方面的标准词汇仍以 std::pmr 为主。它的价值在于架构而非风格。memory resource 让你能表达“这一族对象属于同一个生命周期区域“,而无需把自定义 allocator 类型硬编码到每一个模板实例里。
struct RequestScratch {
std::pmr::monotonic_buffer_resource arena;
std::pmr::vector<std::pmr::string> tokens{&arena};
std::pmr::unordered_map<std::pmr::string, std::pmr::string> headers{&arena};
explicit RequestScratch(std::span<std::byte> buffer)
: arena(buffer.data(), buffer.size()) {}
};
这个设计表达了一件事:这些字符串和容器不是各自独立的堆上居民,而是请求作用域内的临时工作区。分配开销降低了,析构也变成一次性的批量操作。
用一个更完整的例子展示差异。对比请求处理路径上标准分配与带栈上 buffer 的 pmr 方案:
#include <memory_resource>
#include <vector>
#include <string>
#include <array>
// Standard allocation: every string, every vector growth, and the
// map internals go through the global allocator. Under contention
// from many threads, this serializes on allocator locks.
void handle_request_standard(std::span<const std::byte> input) {
std::vector<std::string> tokens;
std::unordered_map<std::string, std::string> headers;
parse(input, tokens, headers); // many small allocations
route(tokens, headers);
// Destruction: each string freed individually, each map node freed.
}
// PMR with stack buffer: small requests never touch the heap.
// The monotonic_buffer_resource first allocates from the stack buffer.
// If the request is large enough to exhaust it, it falls back to
// the upstream resource (default: new/delete).
void handle_request_pmr(std::span<const std::byte> input) {
std::array<std::byte, 4096> stack_buf;
std::pmr::monotonic_buffer_resource arena{
stack_buf.data(), stack_buf.size(),
std::pmr::null_memory_resource()
// null_memory_resource: fail loudly if buffer is exceeded.
// Replace with std::pmr::new_delete_resource() to allow
// fallback to heap for oversized requests.
};
std::pmr::vector<std::pmr::string> tokens{&arena};
std::pmr::unordered_map<std::pmr::string, std::pmr::string>
headers{&arena};
parse_pmr(input, tokens, headers);
route_pmr(tokens, headers);
// Destruction: arena destructor releases everything in one shot.
// No per-string, per-node deallocation calls.
}
只要请求能装进栈上 buffer,pmr 版本就能完全消除逐对象释放,也绕开全局 allocator 争用。高吞吐的小请求服务里,allocator 开销可以降低一个数量级。代价是 std::pmr 容器多带一个指向 memory resource 的指针(sizeof 略大),且 monotonic resource 不回收单次释放的空间,只会一直增长直到 resource 本身被销毁。用在请求作用域的临时工作区上完全合适;用在会随时间反复增缩的长生命周期容器上就是错误选择。
但 monotonic allocation 不是万能升级。对象需要选择性释放时不适用;某个病态请求引发的内存尖峰不应该撑大稳态占用时不适用;只要误留一个对象就会把整个 arena 拖住不放时也不适用。区域分配让生命周期假设变得更刚性,一旦假设错了,后果比逐对象管理更严重。
局部性关注的是图形状,不只是原始字节数
分配次数低不代表局部性就好。少量大块分配里装的若是指向各自独立分配节点的指针数组,遍历时不断跨页跳转,可能比大量小分配还糟。成本模型还得多问一个问题:热代码走查这个数据结构时,要经过多少次指针解引用才能碰到有用的数据?
指针密集的设计在语义上很诱人,因为它直接映射领域关系:树指向子节点,多态对象指向实现,pipeline 串着一连串堆分配的 stage。有时确实别无他法,但更多时候不过是给偷懒披上了建模的外衣。
出路不是”永远别用指针”,而是把身份与拓扑关系同底层存储分开。图可以用连续节点数组加索引邻接表来存储。操作集合已知时,多态 pipeline 通常可以表示成一个小而封闭的 std::variant step 类型。字符串密集的解析器可以对重复 token 做 intern,或保留指向稳定输入 buffer 的 slice,而不必为每个字段分配一份自有字符串。
这些是图形状层面的设计决策,目的是减少真正有用的工作开始前需要追着指针跑的次数。
std::shared_ptr 的隐藏成本
std::shared_ptr 值得单独说,因为它的实际成本被低估得太频繁了。最容易看到的是分配成本:std::make_shared 把控制块和托管对象合并为一次分配,从裸指针构造则要分配两次。但分配只是冰山一角。
更深层的成本来自引用计数。每拷贝一次 std::shared_ptr 就做一次原子递增,每销毁一次就做一次带 acquire-release 语义的原子递减。x86 上单次原子递增并不贵(一条 locked 指令,无争用时约 10-20 ns),但一旦跨核共享,控制块所在的缓存行就会在核心之间来回弹跳。争用激烈时,本该并行的工作会被串行化。
// Looks innocent: passing shared_ptr by value into a thread pool.
// Each enqueue copies the shared_ptr (atomic increment), and each
// task completion destroys it (atomic decrement + potential dealloc).
void submit_work(std::shared_ptr<Config> cfg,
ThreadPool& pool,
std::span<const Request> requests) {
for (const auto& req : requests) {
// Copies cfg: atomic ref-count increment per task.
pool.enqueue([cfg, &req] {
handle(req, *cfg);
});
}
// If 10,000 requests are enqueued, that is 10,000 atomic
// increments on submission and 10,000 atomic decrements
// on completion, all contending on the same cache line.
}
// Fix: cfg outlives all tasks, so pass a raw pointer or reference.
void submit_work_fixed(const Config& cfg,
ThreadPool& pool,
std::span<const Request> requests) {
for (const auto& req : requests) {
pool.enqueue([&cfg, &req] {
handle(req, cfg);
});
}
// Zero reference-counting overhead. Caller guarantees
// cfg lives until all tasks complete.
}
规则不是”永远别用 std::shared_ptr”,而是不要用共享所有权来逃避生命周期思考。对象有明确的所有者和借用者时,用唯一所有者加引用或视图来表达。std::shared_ptr 应该留给确实需要共享且生命周期无法静态确定的场景。当 const& 或裸引用就够用时,别按值传 std::shared_ptr,每次拷贝都是一次毫无收益的原子往返。
示例项目的实践值得参考。examples/web-api/src/modules/handlers.cppm 中,每个 handler 工厂(如 list_tasks()、get_task()、create_task())通过引用接收 TaskRepository&,并在返回的 lambda 中以引用方式捕获。repository 由 main() 持有,生命周期覆盖所有 handler,完全不需要 std::shared_ptr<TaskRepository>。每次请求都避免了原子引用计数的开销,handler 的捕获也更紧凑,只是一个普通指针,而不是两指针宽的 shared_ptr 外加控制块。
还有一些容易忽视的开销:std::shared_ptr 自身就有两个指针宽(对象指针 + 控制块指针),是裸指针的两倍大。容器里装 std::shared_ptr 时缓存密度自然更差。弱引用计数又多出一个原子变量。控制块里存放的自定义 deleter 会在析构时引入一层类型擦除间接调用。
隐式分配是设计异味
C++ 提供了大量抽象,但只有成本模型在代码评审中仍然可见时,这些抽象才用得安心。问题不在抽象本身,在于有些抽象的分配行为是隐式的、随负载变化的,或以团队从未关注过的实现定义方式发生。
std::string 可能分配堆内存,也可能靠 small-string 优化放在栈上。std::function 对大 callable 可能分配,对小的则未必。类型擦除 wrapper、协程帧、regex 引擎、locale 相关的格式化,以及基于 stream 的组合,都可能在调用点完全看不出来的情况下触发分配。
这些类型本身没有问题。危险在于把它们用在热路径上却缺乏明确的成本依据。一个服务每收到一条消息都构造一个 std::function,或者仅仅因为下游 API 默认要求所有权就反复把稳定的字符串 slice 复制成自有 std::string,真正的症结不只是”分配太多”,而是 API 接口把成本的入口藏了起来。
审视热路径上的抽象,应该像审视线程同步一样认真:
- 这个 wrapper 会不会触发堆分配?
- 它是否强制增加了一层间接访问?
- 它会不会把对象撑大到明显降低打包密度?
- 同样的行为能不能用封闭方案替代,比如
std::variant、模板边界或借用视图?
最终答案取决于代码体积、ABI、编译时间和替换灵活性。关键是把这笔权衡账算到明面上。
池、freelist 复用及其失败模式
池化的吸引力在于它许诺了复用和可预测性。有时这个许诺兑现了。固定大小对象池在以下条件下有效:分配尺寸一致、对象生命周期短、复用频繁、allocator 争用确实是问题。类似 slab 的设计也可能比完全通用的堆分配提供更好的空间局部性。
但 pool 的失败方式有迹可循。
对象大小差异大到需要多个池、或内部碎片把收益吃掉,失败。pool 掩盖了无界保留,对象名义上”以后会复用”实际复用率低到内存永远回不去系统,失败。每线程 pool 在负载倾斜时让均衡变得更复杂,失败。代码开始围绕 pool 的可用性来编排生命周期而不是围绕领域所有权,失败。开发者觉得”有 pool 了”就不再测量,同样失败。
池化是为已知的负载形状服务的,不是一种通用性能姿态。说不清分配分布和复用模式,就还没到设计 pool 的时候。
值大小与参数表面仍然重要
分配只是成本模型的一部分。在 API 之间被随手拷来拷去的大值类型杀伤力同样不小。一个”图方便”的 record 类型塞了好几个 std::string、可选 blob 和 vector,靠 move semantics 或许能省掉部分堆流量,但工作集照样膨胀、缓存压力照样增大、按值传递的代价照样变高。
回到第 4 章的 API 指导。真正的契约是所有权转移或廉价移动时,按值传递很好。但一条路径反复拷贝或移动大型聚合对象仅仅图个方便,那就很糟。成本模型必须把对象大小、移动开销和数据穿越各层边界的频率都纳入考量。
小值天然容易在系统中流转。大值更适合做稳定存储,再通过借用访问、提取摘要或拆分冷热部分来使用。如果这些做法让 API 变复杂了,这种复杂度往往也值得。性能设计里有太多案例表明,某一层追求”干净”的接口,结果在其他所有层面制造了本可避免的开销。
用于评审的实用成本模型
生产实践中,非形式化但写清楚的模型通常就够了。对于正在评审的路径,把以下内容写下来:
- 每次操作在稳态下的分配次数。
- 这些分配是线程局部的、全局争用的,还是藏在抽象背后的。
- 分配出的对象按生命周期如何分组。
- 热遍历路径上要经过多少次指针间接访问。
- 热工作集大约多大。
- 遍历模式是连续、跨步、哈希查找还是图遍历。
- 销毁方式是逐个、批量还是按区域整体释放。
这份清单给不出精确到时钟周期的预测,但能终结空口白话。它让评审者有能力区分”这看起来有成本”和”这个设计在 burst 负载下必然导致 allocator 流量激增、读取分散、teardown 行为恶化”。
边界条件
成本模型不是过度特化的通行证。有些时候堆分配就是对的,对象确实活过了局部作用域,确实参与了共享所有权。有些时候类型擦除就是正确的权衡,为了跨库边界的可替换性。有些时候 arena 分配反而不合适,保留风险或调试复杂度可能比吞吐收益更值得担心。
目标不是把局部速度压榨到极致,而是让成本在真实系统压力下可预测、可解释。某个设计稍微抬高了稳态开销却在非热点边界上大幅改善了正确性或可演进性,这完全可能是对的选择。成本模型是为了支撑权衡决策,不是消灭权衡。
在调优之前要验证什么
在引入自定义 allocator、对象池或大面积铺设 pmr 之前,先验证四件事。
第一,确认这条路径确实够热,分配和局部性在量级上真的构成问题。第二,确认当前设计的分配行为和数据布局确实如你所想。第三,确认相关对象确实共享你设想的 allocator 策略所依赖的生命周期形状。第四,确认新方案不会只是把成本搬到别处——更大的驻留内存、更差的调试体验、更复杂的所有权边界。
下一章直接进入证据环节。成本模型终究只是假设,要靠基准测试和性能剖析去验证正确的假设,才能把它变成真正的工程。
要点总结
- 动用 allocator 技术之前,先做分配清单。
- 分配成本包括延迟、局部性、争用、保留和 teardown,而不只是调一次
new的代价。 - 对象确实同生共死时,就按生命周期聚类分配。
- 当区域内存策略与所有权模型匹配时,用
std::pmr来表达,别把它当装饰。 - 对热路径上隐藏分配和间接访问的抽象保持警惕。
- pool 要为已测明的负载形状而设计,否则就不要设计。
不自欺的基准测试与性能剖析
生产问题
团队一旦把数字误当成证据,性能讨论的代价就会急剧上升。基准测试号称有 20% 的收益,生产服务却纹丝未动。Profiler 指向某个热点函数,真正的瓶颈却是锁争用或 off-CPU 等待。回归悄悄混进主干——要么因为基准测试选错了输入形状,要么因为有人”优化”了编译器早已删掉的无用计算。
本章的主题是测量纪律。前几章谈的是数据表示与成本建模,这里要回答的问题不一样:怎样才能拿到够硬的证据,来决定是否改代码、是否值得引入复杂度、或是否该否决一个看似有效的优化?这需要严谨的基准测试设计、对 profiler 的基本功,以及不被漂亮图表蒙蔽因果判断的定力。
现代 C++ 的性能工作尤其容易自欺欺人,因为语言给了你太多局部优化旋钮:容器类型、所有权模型、内联边界、allocator 策略、range pipeline、协程结构、类型擦除方案,都可以随手调整。有些改动确实有效,很多根本不起作用。测量就是用来区分两者的。
先看问题,再选工具
并不是每个性能问题都该从 microbenchmark 入手。
如果要在一个紧凑循环中比较两种数据表示,受控基准测试可能恰到好处。如果要排查请求延迟在突发负载下飙升的原因,对真实系统做性能剖析或收集生产 trace 才更合适。如果怀疑是锁争用,scheduler 行为和阻塞时间远比单独跑一个吞吐循环有说服力。如果回归只在端到端服务流量中才出现,人为隔离出来的合成基准测试反而会把你引向歧途。
可以按以下层级来选择:
- 范围窄、隔离充分的问题,用 microbenchmark。
- 想知道时间或采样点实际流向了哪里,用 profiler。
- 想观察队列、线程、I/O、缓存和争用之间的相互作用,用类生产负载测试。
- 想确认改动在真正跑业务的环境里是否有效,用生产可观测性。
搞混这些层次,是浪费数周时间的最快方式之一。
基准测试必须先说清要验证什么
可信的基准测试从一句话开始,而不是从代码开始。比如:”在 1k、10k、100k 条目、真实 key 长度条件下,比较读多写少路由表中排序连续存储与 hash 查找的延迟差异。”这就是一个明确的主张。而”benchmark containers”则不是。
这句话应当包含:
- 被测操作是什么。
- 数据规模与分布如何。
- 读写比例是多少。
- 有哪些重要的机器或环境假设。
- 这次测试要为哪个决策提供依据。
如果你写不出这句话,说明你还没想清楚这个基准测试到底在测什么。
性能天然受负载形状制约。用随机整数键跑的基准测试,对一个使用 string_view 且前缀局部性很强的生产路由器可能毫无参考价值。只测均匀 hash 命中的基准测试,会掩盖真实倾斜键下的冲突行为。每次迭代都重建容器的基准测试,会不公平地惩罚”一次构建、反复查找”的设计。
测量完整的相关操作
一个常见的自欺手法是只测方便测的片段,绕过真正的决策边界。比如,一个解析 pipeline 号称被”优化”了,但测的只是 token 转换,输入早已在缓存中、内存分配也早已预留好。又比如,容器对比只测了查找,把构建、排序、去重和内存回收全部排除在外,而生产负载恰恰会频繁重建这个结构。
基准测试不必规模很大,但必须涵盖设计在实际使用中带来的成本。如果某个 API 选择会在你测量的那行代码之前强制触发分配、拷贝、hash 或校验,除非有充分理由排除,否则这些成本就该纳入测量范围。
这正是第 16 章成本模型该发挥作用的地方:沿着成本真正累积的边界去测量。否则,结果在技术上也许没错,但对实际决策毫无帮助。
实际项目中,值得做基准测试的位置往往不像 vector<int> 那么明显,但也更有价值。以 examples/web-api/ 示例项目为例:
json::serialize_array()(json.cppm)遍历一个 range 并通过反复拼接字符串来构建 JSON 数组。用不同集合规模(10、100、1000 个 task)benchmark 这个函数,可以看出是拼接策略还是逐元素to_json()占主导,以及预分配结果字符串是否有意义。TaskRepository::find_by_id()(repository.cppm)在shared_lock下用std::ranges::find做线性扫描。如果要和unordered_map<TaskId, Task>方案做对比,就必须把加锁成本包含在内,并在真实的 repository 规模下测试,而不只是测裸 find 操作。Router::to_handler()(router.cppm)通过值捕获路由表,返回一个每次请求分发时做线性扫描的 lambda。在 5、50、500 条注册路由下 benchmark 路由分发,可以看出线性扫描是否仍可接受,还是需要换成排序 vector 或 trie。这个 benchmark 必须涵盖完整的分发路径:匹配 method、比较 pattern、调用 handler,而不只是循环本身。
这就是值得在写代码之前用一句话说清楚的 benchmark 主张:“在 5、50、500 条路由、真实 HTTP method 和 path 分布条件下,比较线性扫描与排序 vector 的路由分发延迟。”
警惕编译器和测试框架引入的假象
C++ 的 microbenchmark 特别容易产生误导,因为优化器会毫不犹豫地删除、折叠、外提和向量化没有锚定到可观测行为上的代码。基准测试框架(harness)部分就是为了防止这些问题,但不意味着可以放松警惕。
至少要做到以下几点:
- 确保计算结果以编译器无法消除的方式被使用。
- 刻意将一次性初始化与每轮迭代的工作分开。
- 充分预热,避免无意中测到 first-touch 效应。
- 控制数据初始化方式,保证每次迭代都能命中目标分支和缓存行为。
- 结果出乎意料时,检查生成的汇编代码。
如果某个基准测试声称复杂操作几乎不耗时,在排除其他原因之前,先假定是优化器把计算删掉了。如果基准测试结果波动极大,先假定是环境不稳定或负载定义不够明确。
典型反面教材:死代码消除
这是 microbenchmark 中最常见的陷阱。编译器发现某个结果从未被使用,就直接把整段计算删掉了:
// BROKEN: the compiler may eliminate the entire loop because
// 'total' is never observed.
static void BM_bad_dce(benchmark::State& state) {
std::vector<double> data(1'000'000, 1.0);
for (auto _ : state) {
double total = 0.0;
for (double d : data)
total += d * d;
// total is dead. Optimizer removes the loop.
// Benchmark reports ~0 ns/iteration.
}
}
// FIXED: benchmark::DoNotOptimize prevents the compiler from
// proving the result is unused.
static void BM_good_dce(benchmark::State& state) {
std::vector<double> data(1'000'000, 1.0);
for (auto _ : state) {
double total = 0.0;
for (double d : data)
total += d * d;
benchmark::DoNotOptimize(total);
}
}
benchmark::DoNotOptimize 并非万能。在大多数实现中,它本质上是对目标值做一次不透明读取(通常通过 inline asm 让编译器认为该变量”可能被观察”)。只在最终结果上使用,不要对每个中间步骤都加,否则可能抑制生产代码同样能受益的正常优化。如果拿不准 DCE 是否影响了结果,用 -S 编译后检查汇编即可。
典型反面教材:测的是初始化,不是真正的工作
// BROKEN: construction cost dominates. The benchmark is
// measuring vector allocation and initialization, not lookup.
static void BM_bad_lookup(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v(1'000'000);
std::iota(v.begin(), v.end(), 0);
auto it = std::lower_bound(v.begin(), v.end(), 500'000);
benchmark::DoNotOptimize(it);
}
}
// FIXED: setup goes outside the timing loop.
static void BM_good_lookup(benchmark::State& state) {
std::vector<int> v(1'000'000);
std::iota(v.begin(), v.end(), 0);
for (auto _ : state) {
auto it = std::lower_bound(v.begin(), v.end(), 500'000);
benchmark::DoNotOptimize(it);
}
}
典型反面教材:选错了比较基线
拿两个设计跟一个不公平的基线做比较,问题更隐蔽,危害也更大:
// MISLEADING: comparing hash lookup against linear scan.
// Concludes "hash map is 100x faster" -- but the real alternative
// in production is sorted vector with binary search, which may
// be within 2x and uses half the memory.
static void BM_linear_scan(benchmark::State& state) {
std::vector<std::pair<int,int>> data(100'000);
// ... fill with random kv pairs, unsorted ...
for (auto _ : state) {
auto it = std::find_if(data.begin(), data.end(),
[](const auto& p) { return p.first == 42; });
benchmark::DoNotOptimize(it);
}
}
正确的基线应当是实际可能采用的替代方案,不是最差的选项。每次做比较都要说清楚:在跟什么比?为什么选它作为对照?
典型反面教材:热缓存幻觉
// MISLEADING: data fits in L2 cache and is hot from the previous
// iteration. Production accesses the same structure after
// processing unrelated data that evicts it from cache.
static void BM_warm_cache(benchmark::State& state) {
std::vector<int> v(1'000); // ~4 KB, fits in L1
std::iota(v.begin(), v.end(), 0);
for (auto _ : state) {
int sum = 0;
for (int x : v) sum += x;
benchmark::DoNotOptimize(sum);
}
// Reports ~50 ns. In production, with cache-cold data,
// the same operation takes 10-50x longer.
}
如果生产环境中实际会遇到冷数据,要么把工作集做大到超出缓存容量,要么在迭代之间显式刷新缓存行(依赖平台、比较脆弱,但有时为了拿到真实结果别无选择)。
Google Benchmark 常见陷阱
Google Benchmark(benchmark::)使用广泛、整体可靠,但有几个反复出现的错误值得说明:
- 忘记
benchmark::ClobberMemory():DoNotOptimize能阻止编译器消除某个值的 dead store,但不会让编译器认为内存内容已改变。如果基准测试在原地修改数据结构,编译器可能将读取提到写入之前,甚至跨迭代重排。修改之后需要调用benchmark::ClobberMemory()来强制重新加载:
static void BM_modify(benchmark::State& state) {
std::vector<int> v(10'000, 0);
for (auto _ : state) {
for (auto& x : v) x += 1;
benchmark::ClobberMemory();
// Without ClobberMemory, the compiler could theoretically
// observe that v is never read and eliminate the writes,
// or combine multiple iterations into one.
}
}
-
没有调用
state.SetItemsProcessed():缺了它,输出只有每轮迭代耗时,很难横向比较不同 batch 大小的测试。记得始终调用state.SetItemsProcessed(state.iterations() * num_items),让输出多一列吞吐量。 -
忽视
state.PauseTiming()/state.ResumeTiming()自身的开销:这两个调用内部要读取时钟,在很多平台上本身就需要 20-100 ns。如果被测操作耗时不到一微秒,pause/resume 的开销反而成了大头。对于亚微秒级的工作,要么把初始化完全移到循环外面,要么把开销摊到大量迭代中去。 -
只测单一规模:用
->Range(8, 1 << 20)或->DenseRange()覆盖多种规模。1K 元素上胜出的设计,到 1M 时可能反而更慢。性能不是一个标量。
条件允许时,用正规的测试框架。具体选哪个库不重要,重要的是纪律:稳定的重复执行、清晰的初始化边界、显式防止死代码消除。如果项目尚未统一使用某个框架,而某个基准测试又刻意只覆盖部分场景,就要明确说明并记录省略了哪些部分。
别把噪声当信号
即使基准测试本身没问题,环境噪声也可能盖过真正的结果。CPU 频率缩放、热降频、后台进程、NUMA 效应、中断合并,这些都会注入与代码改动毫无关系的波动。
几条实用对策:
- 测试期间锁定 CPU 频率(Linux 上用
cpupower frequency-set -g performance,或关闭 turbo boost)。如果一次跑在 4.5 GHz、下一次跑在 3.2 GHz,你测的是调频策略,不是代码。 - 隔离 CPU 核心(
isolcpus内核参数,或用taskset/numactl),避免调度器干扰。 - 多跑几轮,报告中位数而不是均值。中位数不受中断或 page fault 引起的偶发尖峰影响;均值则容易被离群值拉偏。
- 先确认统计显著性,再宣称有收益。提升 3%,但变异系数达到 5%,那就是噪声。Google Benchmark 支持
--benchmark_repetitions=N并报告标准差,务必使用。 - 尽量在同一台机器、同一次开机、同一个二进制上做对比。跨机器比较需要极其谨慎的归一化,通常可信度也打折扣。
如果在一台空闲机器上连续跑两次同样的测试,结果变化超过 1-2%,那就该先修好测试环境,再讨论结果的含义。
分布比单个数字更重要
平均运行时间是一个粗糙指标。许多生产系统真正关心的是分位数、方差,以及负载倾斜时的最坏表现。一个改动如果提升了平均吞吐,却在突发分配或锁争用场景下恶化了尾延迟,仍然算是回归。只报告”每迭代纳秒数”的基准测试,可能掩盖 rehash、page fault、分支预测翻转或偶发大分配造成的双峰分布。
看性能数据要像看可用性或延迟监控一样,关注分布,不要只盯着均值。要追问:离群值是噪声、环境波动,还是设计本身的真实行为?基准测试的场景设置是否让那些罕见但代价高昂的事件出现得足够频繁?
对 CI 回归检测而言,阈值和趋势分析都需要谨慎设置。噪声大的基准测试会频繁误报,时间一长团队就会习惯性忽略警报。阈值太宽松,又会让有意义的回归悄悄积累。归根结底,稳定的基准测试设计比花哨的报表更有价值。
Profiler 回答的是另一类问题
Profiler(性能剖析器)不是“跑得更慢的基准测试“。它是采样或插桩工具,用来搞清楚真实进程中的时间、内存分配、缓存未命中或等待发生在哪里。当你还不知道瓶颈在哪,或者需要在完整系统中验证某个 microbenchmark 结论时,就该用它。
不同类型的 profiler 回答不同类型的问题:
- CPU sampling profiler:CPU 活跃时间花在了哪里。
- Allocation profiler:哪些代码路径在分配和持有内存。
- 硬件计数器感知工具:缓存未命中、分支预测失败或 stalled cycle 集中在何处。
- 并发与 tracing 工具:线程在哪里阻塞、等待或争用。
不要指望一个工具回答它根本看不到的问题。CPU profiler 解释不了“为什么线程大部分时间在等锁“。Allocation flame graph 告诉不了你“如果遍历成本仍是大头,换个更快的 allocator 到底有没有用“。Wall-clock trace 也许能看到某个请求很慢,却分不清是 CPU 计算慢还是调度延迟。
在 Linux 上,这往往意味着把 perf、allocator profiling 和 tracing 组合使用。在 Windows 上,可能要用基于 ETW 的工具、Visual Studio Profiler 或 Windows Performance Analyzer。在 macOS 上,Instruments 承担类似角色。具体选哪个工具是次要的,关键是养成习惯:让问题和真正能回答它的工具配对。
让基准测试与性能剖析相互印证
基准测试和性能剖析应当互为校验。
如果 microbenchmark 说某个改动有效、因为减少了分配,那么在真实进程中 profiler 也应当显示分配减少,或分配密集路径上的耗时下降。如果 profile 显示某个循环因指针密集遍历的缓存未命中而成为热点,基准测试就应当隔离出这种遍历模式,并测试替代方案。如果两边结论矛盾,不要折中成一种心理安慰,去查清不一致的原因。
常见的不一致原因:
- 基准测试的数据形状与生产环境不符。
- 基准测试隔离出的开销,在端到端场景中被其他开销淹没。
- Profiler 指向的是表面症状,而非根本原因。
- 改动在完整二进制中对代码体积、内联或分支行为的影响,与隔离测试中不同。
优秀的性能工作致力于缩小这些差距;糟糕的性能工作选择无视它们。
警惕名不副实的”代表性”输入
团队常常用过于整洁的合成输入自毁测量。Key 全是均匀随机的,消息大小整齐划一,队列永远不突发,哈希表从不遇到真实负载因子,解析器也从不碰到格式错误或恶意构造的数据。这类输入容易生成、容易稳定,但往往也是错的。
“有代表性”不等于盲目复制生产流量,而是要保留真正影响成本的特征:大小分布、数据倾斜、重复模式、读写比例、工作集规模、异常路径出现的频率。缓存可能需要 Zipf 式访问模式而非均匀分布。解析器可能需要长短字段混合再加上少量格式错误的记录。调度器或队列可能需要突发到达而非匀速到达。
如果因数据隐私或运维限制无法使用真实 trace,至少也要有意识地构造具有代表性的分布。基于失真输入的基准测试不是中立的——它会把团队引向错误的优化方向。
性能主张必须经得起代码评审
性能改动应当作为可评审的设计工作来对待,不是个人英雄式的实验。一个可信的改动应当附带一份简明的证据包:
- 要回答的性能问题是什么。
- 基准测试或性能剖析是怎么设置的。
- 负载假设是什么。
- 改动前后的对比结果,必要时包含方差或分位数数据。
- 引入了哪些权衡:代码复杂度、内存占用、API 限制、可移植性、维护成本。
这会形成一种良性约束。它能防止”在我机器上好像更快了”变成代码库里的既定事实,也留下了可复现的素材,方便未来评审者在编译器、标准库或负载特征变化后重新验证结论。
回归控制是工程体系,不是仪表盘
往 CI 里加一个 benchmark job、宣布“性能问题已解决“,做起来很容易。但回归控制只有在以下条件同时满足时才真正有效:被测基准足够稳定、运行成本能匹配合理的执行频率、而且确实覆盖到团队关心的代码路径。一个经常波动、没人信任的 nightly benchmark 套件,提供的不是安全保障,而是一种仪式。
务实的做法通常分三层:一小组高度稳定的 microbenchmark 覆盖已知热点路径;一套更重的独立性能工作流用于更广泛的负载测试;上线后的生产可观测性,持续跟踪延迟、吞吐、CPU 时间和内存表现。三层在成本和保真度上各有侧重,没有哪一层能独自胜任。
什么才是诚实的测量
诚实的测量是克制的。不试图从一个基准测试中提炼普适结论,不把 profile 上的热度直接等同于问题根源,不仅因为某个优化在汇编里看得见就认定它有意义。它做的事情很简单:把一个数字和特定的负载、特定的问题、特定的决策绑定在一起。
这种态度比任何具体的工具链都重要。硬件在换代,编译器在进步,标准库实现在迭代,生产流量也在演变。一个 C++ 团队真正需要养成的习惯是:在证据与决策不匹配时,绝不轻率地做出性能声明。
要点总结
- 根据问题选择匹配的测量手段:benchmark、profile、负载测试或生产遥测。
- 写代码之前,先用一句话说清这个 benchmark 要验证什么。
- 测量完整的相关操作,而非只测最方便的片段。
- 默认怀疑优化器假象、测试框架错误和失真输入。
- 关注方差和分位数,不要只看均值。
- 要求每个性能改动都附带可评审的证据包。
面向资源与边界缺陷的测试策略
C++ 中代价最高的 bug,大多不是”算法算错了数”。真正昂贵的是资源与边界缺陷:错误路径上忘关的文件描述符、提交失败后残留的临时文件、取消时泄漏了后台工作、解析器在某个特定字节模式下平时没事一到高负载就炸,又或者某个库边界悄悄把业务错误变成了进程崩溃。
只测 happy path 的单元测试,对这些设计施加不了多少压力。它们只验证了正常行为,把生命周期转换、清理保证和边界契约晾在一边。在现代 C++ 中,这笔账很不划算。所有权和错误处理已经足够显式,完全可以围绕它们来设计测试,而且理应如此。一个组件如果持有稀缺资源、跨越进程或 API 边界、或者在超时、取消、畸形输入、部分失败等场景下有不同行为,它的测试策略就应该围绕这些事实来构建。
本章谈的是测试设计,不是工具选型。目标是在代码上线之前想清楚需要哪些证据。Sanitizer、静态分析和构建诊断是下一章的内容;运行时日志、metrics、trace 和崩溃证据是再下一章的内容。这里的问题更简单:哪些测试能证明,在系统承压时所有权、清理和边界行为依然正确?
从失败形状开始,而不是从“测试金字塔”口号开始
泛泛的测试建议到了 C++ 这里很快就不够用了,因为高代价故障的分布极不均匀。如果一个服务的风险集中在关闭、取消、临时文件替换、buffer 生命周期和外部协议转换上,测试套件就应该在这些地方投入最多精力。
所以要从失败形状入手。
针对每个组件,问四个问题:
- 哪些资源必须被恰好一次地释放、回滚或提交?
- 哪些边界会在子系统之间转换错误、所有权或表示?
- 哪些输入或调度情况大到无法枚举,但生成起来很便宜?
- 哪些行为依赖时间、并发或取消,而不是简单的调用顺序?
这些问题会自然引出不同的测试形式。资源清理通常需要确定性的故障注入加后置条件检查;边界转换需要基于真实 payload 和错误类别的契约测试;输入空间巨大的场景需要属性测试(property testing,即基于数学性质而非具体用例的自动化测试)和模糊测试(fuzzing,即用随机/半随机输入自动探测缺陷);时间敏感的并发需要可控的时钟、executor 和关闭编排,而非基于 sleep 的测试。
覆盖率数字回答不了这些问题。一行代码跑到了,不能证明回滚确实发生了、所有权依然有效、或者关闭路径在排空后台工作时没有 use-after-free 风险。把覆盖率视为一个滞后的完整性信号,不要当作组织测试套件的核心原则。
在业务关心的层级上测试资源生命周期
针对资源 bug 的正确测试,几乎不会去断言某个 helper 被调用了。它断言的是获取、提交、回滚和释放这些环节的可观测契约。
假设有一个服务需要原子地重写磁盘快照。真正的生产规则不是”先调 write,再调 rename,失败了再 remove”,而是”要么新快照变得可见,要么旧快照保持不变且临时文件被清理掉”。有用的测试应该直接验证这条规则。
有意保留为 partial:一个让回滚可测试的接缝
struct file_system {
virtual ~file_system() = default;
virtual auto write(std::filesystem::path const& path,
std::span<char const> bytes)
-> std::expected<void, std::error_code> = 0;
virtual auto rename(std::filesystem::path const& from,
std::filesystem::path const& to)
-> std::expected<void, std::error_code> = 0;
virtual void remove(std::filesystem::path const& path) noexcept = 0;
};
enum class snapshot_error {
staging_write_failed,
commit_failed,
};
auto write_snapshot_atomically(file_system& fs,
std::filesystem::path const& target,
std::span<char const> bytes)
-> std::expected<void, snapshot_error>
{
auto staging = target;
staging += ".tmp";
if (auto r = fs.write(staging, bytes); !r) {
return std::unexpected(snapshot_error::staging_write_failed);
}
if (auto r = fs.rename(staging, target); !r) {
fs.remove(staging);
return std::unexpected(snapshot_error::commit_failed);
}
return {};
}
TEST(write_snapshot_atomically_cleans_up_staging_file_on_commit_failure)
{
fake_file_system fs;
fs.fail_rename_with(make_error_code(std::errc::device_or_resource_busy));
auto result = write_snapshot_atomically(
fs,
"cache/index.bin",
std::as_bytes(std::span{"new snapshot"sv.data(), "new snapshot"sv.size()}));
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), snapshot_error::commit_failed);
EXPECT_FALSE(fs.exists("cache/index.bin.tmp"));
EXPECT_EQ(fs.read("cache/index.bin"), "old snapshot");
}
这个 seam 选得恰到好处,因为它正好落在业务边界上。测试没有把半个标准库都 mock 掉,只在外部副作用周围建了一个可替换接口,然后检查调用方真正关心的后置条件。
这个权衡很重要。过度 mock 基础设施只会产出脆弱的测试,验证的是 syscall 调用顺序,而非操作的安全属性。但如果设计中完全不留 seam,失败路径就只能靠大型集成测试来覆盖。折中之道是在资源边界做一次隔离,然后围绕提交与回滚行为来写测试。
过度 mock 何时会掩盖真实 bug
想想“检查实现细节的测试“和“检查安全属性的测试“之间的差别。团队常常会写出这样的测试:
// BAD: This test passes, but proves nothing about cleanup.
TEST(write_snapshot_calls_remove_on_rename_failure)
{
strict_mock_file_system fs;
EXPECT_CALL(fs, write(_, _)).WillOnce(Return(std::expected<void, std::error_code>{}));
EXPECT_CALL(fs, rename(_, _)).WillOnce(Return(
std::unexpected(make_error_code(std::errc::device_or_resource_busy))));
EXPECT_CALL(fs, remove("cache/index.bin.tmp")).Times(1);
write_snapshot_atomically(fs, "cache/index.bin", as_bytes("data"sv));
}
这个测试验证了 remove 被调用了,但没有验证临时文件真的消失了,也没有验证原文件保持完好。一旦有人把清理逻辑重构成 std::filesystem::remove_all,或者改了 staging 路径约定,测试就会挂掉。然而 remove 静默失败、临时文件残留这种真实 bug 反而能通过。前面基于 fake_file_system 的测试更强,因为它断言的是可观测的后置条件,而非调用序列。
资源泄漏测试:验证清理,而不只是 happy-path 所有权
当错误路径跳过了构造,或者所有权被错误地 move 走,单靠 RAII 的作用域管理是不够的。一种常见的模式是:资源只在某条特定的失败路径上才会泄漏:
class connection_pool {
public:
auto acquire() -> std::expected<pooled_connection, pool_error>;
void release(pooled_connection conn) noexcept;
};
// This function has a leak on the second acquire failure.
auto transfer(connection_pool& pool, transfer_request const& req)
-> std::expected<receipt, transfer_error>
{
auto src = pool.acquire();
if (!src) return std::unexpected(transfer_error::no_connection);
auto dst = pool.acquire();
if (!dst) {
// BUG: forgot to release src back to the pool.
return std::unexpected(transfer_error::no_connection);
}
// ... perform transfer, release both on success ...
pool.release(std::move(*src));
pool.release(std::move(*dst));
return receipt{};
}
只走成功路径的测试看不到这个泄漏;只在失败时检查返回值的测试同样会漏掉。能抓住它的测试,断言的是连接池的状态:
TEST(transfer_releases_source_connection_when_dest_acquire_fails)
{
counting_connection_pool pool{.max_connections = 1};
auto result = transfer(pool, make_request());
ASSERT_FALSE(result.has_value());
EXPECT_EQ(pool.available(), 1); // Source connection must be returned.
}
规律就是:如果你持有稀缺资源,失败路径的测试就该断言资源已被归还,而不只是断言返回了一个错误。
异常安全:从“能编译”到“正确”之间的差距
即便代码路径没有标 noexcept,只要异常安全性很重要,就值得写测试。提供强异常保证的容器或缓存尤其如此:
TEST(cache_insert_preserves_existing_entries_on_allocation_failure)
{
lru_cache<std::string, std::string> cache(/*capacity=*/4);
cache.insert("key1", "value1");
cache.insert("key2", "value2");
failing_allocator::arm_failure_after(1); // Fail during insert internals.
auto result = cache.insert("key3", "value3");
EXPECT_FALSE(result.has_value());
// Strong guarantee: pre-existing entries are intact.
EXPECT_EQ(cache.get("key1"), "value1");
EXPECT_EQ(cache.get("key2"), "value2");
EXPECT_EQ(cache.size(), 2);
}
如果缓存只提供基本保证,测试仍然应当验证没有资源泄漏,且缓存处于有效状态(哪怕内容已变)。最坏的情况是压根没有测试:缓存在抛异常时悄悄破坏了内部结构,直到生产环境的内存分配压力下才被发现。
同样的思路适用于 socket、事务、受锁保护的 registry、临时目录、子进程 handle,以及持有线程的服务。问自己:在成功、部分失败、重试和关闭各种场景下,稳定的契约分别是什么?把这些契约测出来。
边界测试应证明转换,而不只是解析
现代 C++ 代码的复杂度往往集中在边界上:网络协议、文件格式、进程边界、插件 API、数据库客户端,以及 C 接口。边界上的 bug 代价高,因为它们会同时破坏两侧的假设。一个边界测试应当验证三件事。
第一,合法输入能正确映射到内部表示,不依赖生命周期上的取巧。如果解析器把 std::string_view 存进了生命周期更长的状态里,边界测试就要证明这个 view 指向的数据有稳定的所有权,或者在必要时做了拷贝。第二,非法或不完整的输入要以正确的错误类别失败。解析失败、传输失败、业务规则拒绝,除非 API 明确就是这么约定的,否则不应混为一谈、走同一条笼统的错误路径。第三,从组件内部格式化输出或转换回外部表示时,排序、转义、单位、版本等不变量要得到保持。
这里要用真实的测试工件。配置加载器,把真实的示例文件放在测试旁边。HTTP 或 RPC 边界,保留有代表性的 payload,包括畸形 header、超大 body、重复字段、错误编码和不支持的版本号。带 C API 的库,在 ABI 层面写测试,不只是测内部的 C++ wrapper。如果边界承诺不抛异常,就要在 allocator 压力和非法输入下验证这个承诺。
这些测试不必很大,但必须足够具体。”能 round-trip 一个 JSON 对象”——太弱。”遇到重复主键字段时以 schema error 拒绝,并保持旧配置继续生效”——这才有力。
手工精选示例容易遗漏的边界情况
要留意那些单独看上去无害、组合起来却会出问题的边界条件:
// A parser that stores string_view into a longer-lived config object.
// This test passes because the input string outlives the config.
TEST(config_parser_reads_server_name)
{
std::string input = R"({"server": "prod-01"})";
auto cfg = parse_config(input);
EXPECT_EQ(cfg.server_name(), "prod-01"); // PASSES -- but fragile.
}
// This test exposes the dangling view.
TEST(config_survives_input_destruction)
{
auto cfg = []{
std::string input = R"({"server": "prod-01"})";
return parse_config(input);
}();
// input is destroyed. If server_name() holds a string_view into it,
// this is use-after-free. It may still "pass" without sanitizers.
EXPECT_EQ(cfg.server_name(), "prod-01");
}
大多数团队只会写第一个测试,但真正能暴露 bug 的是第二个。配合 AddressSanitizer(简称 ASan,一种内存错误检测工具,下一章介绍),就能把悄无声息的内存损坏变成确定性的测试失败。
其他常被遗漏的边界极端情况:
- 空输入、单字节输入,以及恰好落在 buffer 边界上的输入。
- 字符串字段中包含嵌入式 null 的 payload——
std::string_view::size()和 C 的strlen()对此会给出不同结果。 - 依赖方返回的错误响应:协议帧本身是合法的,但状态码出人意料,不只是简单的连接失败。
- 在某个 schema 版本里合法、在另一个版本里非法的输入,尤其是涉及版本协商的场景。
示例项目中有这几种模式的具体实践。在 examples/web-api/tests/test_http.cpp 中,test_parse_request_malformed() 向解析器喂入字符串 "not a valid request",断言 parse_request() 返回 std::nullopt 而非崩溃或产出半初始化的 Request。这就是能抓住“默认假设输入格式正确“的解析器 bug 的畸形输入边界测试。同一文件还测试了 header 缺失的情况(test_header_missing()),确认返回 std::optional 的 header() 方法在 header 不存在时能正确处理缺失,而非返回悬空 view 或默认构造的字符串。
在 examples/web-api/tests/test_task.cpp 中,test_task_validation_rejects_empty_title() 和 test_task_validation_rejects_long_title()(后者构造了一个 257 字符的字符串)验证了领域不变量在极端值处依然成立。这些测试断言的是业务规则:task 标题必须非空且在长度上限内,错误通过 std::expected 以正确的 ErrorCode::bad_request 报告,而非被吞掉或转化为异常。
失败注入比更多 mock 更有价值
C++ 的错误路径是所有权错误演变为生产事故的温床。只测成功路径,等于默认错误处理代码无需审查。
务实的做法是确定性故障注入。在组件跨越资源边界或调度边界的地方引入故障:文件打开、rename、有界组件内部的内存分配、任务提交、定时器到期、下游 RPC 调用、持久化提交。然后验证操作结束后系统仍然有效。
关键词是”确定性”。随机让 syscall 失败在混沌测试环境里或许有用,但作为回归测试太弱了。回归测试应当能精确说明:是哪个操作失败了,以及失败之后系统必须保持什么状态。
据此来设计 seam:
- 文件和网络适配器应当在操作边界上可替换。
- 时钟和定时器源应当可注入,让 timeout 测试无需 sleep。
- 任务调度应当允许测试用 executor 按步推进工作。
- 关闭和取消应当暴露一个完成点,供测试等待。
这种设计压力是良性的。如果一个组件非得靠全局 monkey-patching 才能走进各种失败模式,说明它和运行环境耦合得太紧了。
有一种常见的过度做法要避免:到处模拟 allocator 失败。分配失败测试对硬实时系统或有强恢复保证的基础设施组件或许有意义,但在大多数代码库里只会制造噪声和不切实际的控制流。只在契约确实依赖于低内存存活能力时才做。对大多数服务代码,I/O 失败、超时、取消和部分提交行为才是更值得投入的目标。
属性测试与 fuzzing 适用于输入丰富的边界
有些边界的输入空间太大,光靠精选示例远远不够。解析器、解码器、压缩器、类 SQL 查询片段、二进制消息读取器、路径规范化器、命令行解释器,它们的输入空间都极为庞大。在这些场景下,属性测试和 fuzzing 物有所值。
关键是把那些在海量输入下应当始终成立的不变量明确编码出来。
好的属性示例:
- 合法配置经过“解析 -> 序列化 -> 再解析“后,语义保持一致。
- 非法 UTF-8 永远不会产出一个成功归一化的标识符。
- 消息解码器要么返回一个完全构造好的值,要么返回结构化错误;绝不允许部分初始化的输出被外部观察到。
- 对已规范化且位于接受域内的相对路径,路径规范化是幂等的。
Fuzzing 对 native code 尤其有效,因为畸形输入经常把控制流引入极少被测试到的分支,生命周期错误和未定义行为往往就藏在那里。Fuzzing 仍然属于测试策略的范畴,它的价值在于对契约和不变量施加压力。下一章会介绍 sanitizer 如何把悄无声息的内存损坏变成可定位的失败,从而让 fuzzing 的效果大幅提升。
Seed corpus 要尽量贴近生产流量,而不是随机字节。否则 fuzzer 会把大量时间浪费在探索那些真实系统在外层就会拒掉的输入上。对协议读取器,种子应包括截断消息、重复字段、错误长度、不支持的版本号,以及压缩边界情况。对文本格式,应包括超长 token、非法转义序列和混合换行符。
并发与取消测试需要可控时间
许多 C++ 团队明知基于 sleep 的测试不稳定,却还是照写不误,原因是生产代码把真实时钟和线程池写死了。结果是一种虚假的节约:测试在本地能过,CI 上就挂,真正的关闭 bug 照样漏过去。
如果组件依赖 deadline、重试、stop request 或后台排空(drain),就应该在设计上让测试能控制时间和调度。std::stop_token 和 std::jthread 有助于表达取消意图,但不能取代确定性编排。运行在可注入 executor 上的任务队列,远比一来就 spawn detached 工作的队列容易验证;接受时钟和 sleep 策略注入的重试循环,也远比直接调用 std::this_thread::sleep_for 的好测。
好的并发测试通常断言以下某类行为:
- 发出 stop request 后,新工作不再启动。
- 正在执行的工作能在预定义的挂起点感知到取消。
- 关闭流程会等待自身持有的工作完成,完成后不再访问已释放的状态。
- 背压机制能限制队列增长,而非把过载变成无上限的内存膨胀。
- 超时路径返回一致的错误类别,并释放所持有的资源。
注意,以上没有一条是”在 callback Y 之前调用了 callback X”。它们都是生命周期保证。并发 bug 代价高昂,正是因为出问题的地方在这里。
示例项目中 examples/web-api/tests/test_repository.cpp 的 test_concurrent_access() 是一个简洁的例子。它启动 8 个 std::jthread,每个线程并发创建 100 个 task,最后断言 repository 的最终大小等于 800。测试的是一个不变量:受 shared_mutex 保护的 TaskRepository 在并发写入下既不丢数据也不产生重复。同一文件中的 test_update_validates() 也值得注意:它在 update() 回调内把 task 标题改为空字符串,断言 repository 以 ErrorCode::bad_request 拒绝了这次修改。这是一个边界与并发交叉的测试,验证了写锁保护下的 update() 路径中,即使 updater callable 由调用方提供,重新验证步骤仍能捕获不变量违反。
项目的 CMake 配置(examples/web-api/CMakeLists.txt)通过 ENABLE_ASAN 和 ENABLE_TSAN 选项支持在 sanitizer 下运行这些测试。在 ThreadSanitizer(线程竞争检测工具,简称 TSan)下跑并发测试,能提供锁协议正确性的机械化证据,而非依赖”在某种特定调度交错下恰好通过”的运气。
集成测试应验证完整的清理故事
并非每种资源 bug 都能靠隔离测试来证明。有些故障只有在真实文件系统、进程模型、socket 或线程调度参与时才会浮现。聚焦的单元测试和属性测试仍然需要,但也需要一小组集成测试来验证端到端的清理行为。
对服务而言,可能意味着用临时数据目录启动进程,发送真实请求,在存储层强制注入故障,然后验证重启行为和磁盘状态。对库而言,可能意味着写一个小型宿主程序调用公共 API,加载配置、启动后台工作、取消它、干净卸载。对命令行工具,可能意味着用 fixture 目录树调用真实可执行文件,检查退出码、stderr 和文件系统后置条件。
这类测试要以场景为核心,数量上保持克制。它们比单元测试慢,也更难排查问题。职责是验证完整的清理流程:部分写入不会变成已提交状态,重复启动不会继承上次失败关闭留下的垃圾,外部契约在故障下依然稳定。
什么测试该停掉
弱测试消耗评审时间,却不能提升信心。
不要再写只是重述当前实现结构的测试:
- 逐个验证 helper 是否被调用,却从不检查对外有意义的后置条件。
- mock 太重,合并两个内部函数就会挂,哪怕契约完全没变。
- 基于 sleep 的异步测试,真正断言的不过是”今天机器恰好够空闲”。
- 对日志或错误字符串做 snapshot 的测试。实际契约是错误类别和结构化字段,不是措辞本身。
- 用大而全的集成测试替代精确的失败路径测试。
核心原则是:把测试预算花在 bug 高发区。在 C++ 中,这些高发区集中在所有权、边界、取消和畸形输入上。要为它们做有针对性的设计。
要点总结
现代 C++ 的测试策略应当跟着失败的经济学走,不是跟着泛泛的分层口号走。持有资源的代码需要确定性的失败路径测试;边界密集的代码需要基于真实工件的契约测试;输入丰富的代码需要属性测试和 fuzzing;并发代码需要可控的时间与调度。集成测试应验证完整的清理流程,不是替代聚焦测试。
用本章来确定:上线前哪些行为必须得到验证。用下一章来确定:在这些测试运行时,应当让哪些编译器、sanitizer、分析器和构建诊断自动帮你找 bug。
评审问题:
- 这个组件对资源的提交、回滚和释放保证是什么?
- 哪些边界转换需要配合真实 fixture 的具体契约测试?
- 目前有哪些故障点可以做确定性注入,哪些需要重构之后才可测?
- 哪些输入面更适合属性测试或 fuzzing,而非仅靠手写示例?
- 目前有哪些涉及时间、取消或关闭的行为,还在靠 sleep 而非受控调度来测试?
Sanitizer、静态分析与构建诊断
测试策略决定了要覆盖哪些行为。本章讨论的是应当与测试并行运行的一套机械化查错手段:编译器警告、sanitizer(运行时缺陷检测工具)、静态分析(static analysis,即不运行程序就能发现潜在缺陷的代码扫描),以及能保留有用诊断信息的构建配置。这些工具不会告诉你设计对不对,但能告诉你程序是否踩进了人类在评审中经常漏掉的某类 bug。
这个区分很重要。测试可以断言取消操作不会留下可见的部分状态。AddressSanitizer(ASan,内存错误检测器)能发现清理路径在履行该契约时访问了已释放的内存。契约测试可以证明解析器会拒绝畸形输入。UndefinedBehaviorSanitizer(UBSan,未定义行为检测器)能发现某条拒绝路径在计算 buffer 大小时发生了有符号溢出。可观测性则能揭示生产构建正在某条你从未用 sanitizer 执行过的路径上崩溃。每一层回答的问题各不相同。
对原生系统来说,跳过这一层的代价完全可以预见:bug 发现得更晚、复现更不稳定、诊断依据更差。如果构建只能产出经过优化、符号被剥离、警告级别很低、又没有 analyzer 或 sanitizer job 的二进制文件,团队实际上是在制度层面选择了更慢的调试方式。
把诊断当作构建产物,而不是开发者偏好
第一个错误出在组织层面,而非技术层面。团队常把警告和分析当成本地可选工具,结果它们就随编译器、机器和个人心情各自漂移。生产级 C++ 需要的恰恰相反:诊断保真度应当是构建契约的一部分。
仓库至少应当定义几个具名构建模式,让每个模式回答不同的问题。
| 构建模式 | 主要问题 | 典型特征 |
|---|---|---|
| 快速开发构建 | 我能快速迭代逻辑吗? | 调试信息、断言、无优化或低优化 |
| Address/UB sanitizer 构建 | 执行过程中是否触发了内存或未定义行为 bug? | -O1、调试信息、frame pointer、ASan 和 UBSan |
| Thread sanitizer 构建 | 并发执行时是否触发了 data race 或锁顺序问题? | 独立 job、降低并行度、仅 TSan |
| 静态分析构建 | 代码是否在运行前就触发了警告模式或可分析缺陷? | 编译器警告、clang-tidy、analyzer job |
| 带符号的发布构建 | 生产行为是否仍然可诊断? | 发布优化、外部符号、build ID、稳定的源码映射 |
试图把这些压缩成一个通用配置往往行不通。TSan(ThreadSanitizer,线程竞争检测器)开销太大,不适合每次构建都跑。ASan 和 UBSan 会改变内存布局与时序。深度分析 job 比日常的“编辑-编译-运行“循环慢得多。正确答案不是找到一个万能构建配置,而是刻意设计一个构建矩阵。
这个矩阵应当存放在版本化的构建脚本或 preset 中,不能靠口口相传。如果仓库无法明确告诉新工程师如何产出 sanitized binary 或带符号的发布工件,说明工作流还不够成熟。
示例项目 examples/web-api/ 用具名的 CMake 选项直接对应了上面这些构建通道:
# examples/web-api/CMakeLists.txt
option(ENABLE_ASAN "Enable AddressSanitizer + UBSan" OFF)
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)
add_library(project_sanitizers INTERFACE)
if(ENABLE_ASAN)
target_compile_options(project_sanitizers INTERFACE
-fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(project_sanitizers INTERFACE
-fsanitize=address,undefined)
endif()
if(ENABLE_TSAN)
target_compile_options(project_sanitizers INTERFACE -fsanitize=thread)
target_link_options(project_sanitizers INTERFACE -fsanitize=thread)
endif()
新工程师克隆仓库后,只需执行 cmake -G Ninja -DENABLE_ASAN=ON 或 -DENABLE_TSAN=ON 即可。通道可发现、受版本控制,并且产出不同的二进制。这就是“具名构建模式“在实践中的样子。
警告是策略表面
编译器警告是最廉价的分析手段,团队却经常白白浪费。一种常见的失败模式是警告泛滥:成千上万条历史遗留警告让所有人习惯性忽略。另一种是警告不足:因为怕噪声,团队开启的警告太少,可疑代码得以悄悄通过。
务实的做法是范围更窄、要求更严。
- 在所有支持的编译器上开启一组认真挑选的警告。
- 警告基线清理干净后,对自有代码启用“警告即错误“。
- suppression 要保持局部化、纳入版本管理,并附上说明。
- 新增 suppression 要像评审代码变更一样评审——因为它本身就是代码变更。
这不是追求审美整洁。警告经常暴露真正的问题:窄化转换、缺失的 override、被忽略的返回值、因变量遮蔽而隐藏的所有权状态、switch 穷举性缺口,以及热路径上的意外拷贝。有些警告确实偏风格化,关掉就好。关键是让已启用的警告集合经得起推敲,并保持稳定。
尤其要警惕在 target 级别做大面积 suppression。如果某个第三方头文件或生成代码噪声太多,应该把它隔离出来,不要在整个仓库里关掉同一条诊断。团队常常为了解决一个第三方库的问题,用项目级 suppression 给未来埋下盲区。
Sanitizer 把静默损坏变成可操作的失败
Sanitizer 之所以有价值,是因为它们改变了失败的表现形式。内存 bug 不再表现为远处的崩溃或匪夷所思的状态,而是在紧邻违规点的地方停下来,给出栈跟踪和 bug 类别说明。
对大多数生产级 C++ 代码库来说,有三类 sanitizer 配置价值最高。
AddressSanitizer 与泄漏检测
ASan 是标准的一线工具,因为它能发现一大类原本会耗费大量调试时间的 bug:use-after-free、heap buffer overflow、某些配置下的 stack use-after-return、double free,以及其他内存生命周期违规。泄漏检测(在支持的平台上)还能为测试进程和短生命周期工具提供额外信号。
ASan 与上一章介绍的测试策略搭配使用时尤其有效。失败路径测试、fuzzer 和集成场景会把执行推入所有权错误潜伏的分支,ASan 则把这些错误转化为可复现的失败。
不开 ASan 时“看起来能工作”的 bug
这是跳过 sanitizer 构建的代码库里,最经典、也最浪费调试时间的案例:
auto get_session_name(session_registry& registry, session_id id)
-> std::string_view
{
auto it = registry.find(id);
if (it == registry.end()) return {};
return it->second.name(); // Returns view into the session object.
}
void log_and_remove_session(session_registry& registry, session_id id)
{
auto name = get_session_name(registry, id);
registry.erase(id); // Session destroyed. name is now dangling.
audit_log("removed session: {}", name); // Use-after-free.
}
没有 ASan 时,这段代码通常能通过测试,甚至在生产环境里正确运行好几个月。被释放的内存仍然保留着旧的字符串数据,直到被其他内容覆盖。测试通过了,代码评审也未必能发现问题,函数看起来很简单。等到真正出问题时,症状是乱码日志或某个毫不相干的分配处崩溃,离实际 bug 很远。
在 ASan 下,它会立刻报出精确的错误:
==41032==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 12 at 0x6020000000d0 thread T0
#0 0x55a3c1 in log_and_remove_session(session_registry&, session_id)
src/session_manager.cpp:47
#1 0x55a812 in handle_disconnect src/connection.cpp:103
0x6020000000d0 is located 0 bytes inside of 32-byte region
freed by thread T0 here:
#0 0x4c1a30 in operator delete(void*)
#1 0x55a7f1 in session_registry::erase(session_id)
src/session_manager.cpp:31
previously allocated by thread T0 here:
#0 0x4c1820 in operator new(unsigned long)
#1 0x55a620 in session_registry::insert(session_id, session_info)
src/session_manager.cpp:22
报告精确指出了读取位置、释放位置和分配位置。对比一下:三周后出现一条损坏的日志,谁也不会联想到是这条代码路径的问题。
典型构建特征大致如下:
clang++ -std=c++23 -O1 -g -fno-omit-frame-pointer \
-fsanitize=address,undefined
具体 flag 因工具链而异,但原则不变:保留足够的优化以维持真实的代码结构,保留调试信息,保留 frame pointer 以确保栈回溯可用。
UndefinedBehaviorSanitizer
UBSan 是 ASan 的搭档,专门捕获不一定表现为内存损坏的危险行为:未对齐访问、非法位移、无效枚举值、某些上下文中的空指针解引用、有符号溢出(取决于配置),以及其他未定义或可疑操作。未定义行为往往对输入和构建环境都很敏感。同一段代码可能连续几个月都能通过测试,换了编译器或者发生一次内联变化就暴露出来。UBSan 的价值在于趁 bug 还局限在局部、还能从容修复时就把隐患揭示出来。
不要过度解读。UBSan 不是形式化证明系统,它只能报告当前执行实际走到的、且已启用检查能覆盖到的行为。
举个具体例子:尺寸计算中的有符号溢出是安全漏洞的常见来源,而编译器完全有权基于“有符号溢出属于未定义行为“这一前提做优化。
auto compute_buffer_size(std::int32_t width, std::int32_t height, std::int32_t channels)
-> std::int32_t
{
return width * height * channels; // Signed overflow if product exceeds INT32_MAX.
}
当 width=4096, height=4096, channels=4 时,乘积为 67,108,864,安全。但当 width=32768, height=32768, channels=4 时,乘积为 4,294,967,296,超出了 32 位有符号整数的范围。没有 UBSan 时,编译器甚至可能把下游的边界检查整个优化掉,因为有符号溢出本身就是未定义行为。UBSan 则会在乘法处直接报错:
runtime error: signed integer overflow: 32768 * 32768 cannot be
represented in type 'int'
修复方式是改用无符号运算,或在乘法前做溢出检查。这类 bug 毫无征兆、对优化器敏感、而且往往涉及安全,正是 UBSan 最能发挥作用的场景。
ThreadSanitizer
TSan 开销很大,在自定义同步原语、lock-free 代码以及某些协程或外部运行时集成的场景下往往噪声较多。但它仍然值得跑,因为 data race(数据竞争)至今仍是事后诊断成本最高的原生 bug 之一。
测试永远抓不到的 data race
没有 TSan 的话,data race 对测试来说是不可见的,因为它取决于线程调度。来看一个请求处理线程和后台报告线程共享的 metrics counter:
struct service_stats {
std::int64_t requests_handled = 0; // No synchronization.
std::int64_t bytes_processed = 0;
};
// Thread 1: request handler
void handle_request(service_stats& stats, request const& req) {
process(req);
stats.requests_handled++; // Data race: unsynchronized write.
stats.bytes_processed += req.size();
}
// Thread 2: periodic reporter
void report_stats(service_stats const& stats) {
log_metrics("requests", stats.requests_handled); // Data race: unsynchronized read.
log_metrics("bytes", stats.bytes_processed);
}
这段代码能通过你写的所有测试,在 x86 上甚至可以正确运行好几个月,因为 x86 的内存模型相对宽容。问题会在编译器重排写入、优化器把读取提升到寄存器、或代码被移植到 ARM 时才暴露出来。bug 今天就在那里,只不过症状被推迟了。
TSan 会立刻抓住它:
WARNING: ThreadSanitizer: data race (pid=28511)
Write of size 8 at 0x7f8e3c000120 by thread T1:
#0 handle_request(service_stats&, request const&)
src/handler.cpp:24
#1 worker_loop src/server.cpp:88
Previous read of size 8 at 0x7f8e3c000120 by thread T2:
#0 report_stats(service_stats const&)
src/reporter.cpp:12
#1 reporter_loop src/server.cpp:102
Location is global 'g_stats' of size 16 at 0x7f8e3c000120
Thread T1 (tid=28513, running) created by main thread at:
#0 pthread_create
#1 start_workers src/server.cpp:71
Thread T2 (tid=28514, running) created by main thread at:
#0 pthread_create
#1 start_reporter src/server.cpp:76
修复方式是使用带合适内存序的 std::atomic<std::int64_t>;如果这些字段必须作为整体一致读取,则用 mutex 保护整个 struct。无论写多少传统测试都抓不到这个问题,必须借助 TSan 才能把一个依赖调度的数据损坏转化为确定性失败。
TSan 的运行方式通常和 ASan 不同。把它放在更窄的 CI lane 或 nightly job 中运行,喂给它那些刻意对共享状态路径、关闭流程、重试和取消施压的测试。suppression file 要保持简短,每条都要有充分理由。如果 TSan 在看似无害的统计代码中报告了 race,不要下意识地忽略,所谓“无害 race“往往会在下一个功能上线后变成真正的问题。
不要在同一个构建里把 TSan 和其他重量级 sanitizer 叠加使用。分开跑可以让失败更容易定位,时序失真也更可控。
示例项目中的 TaskRepository(见 examples/web-api/src/modules/repository.cppm)是 TSan 验证正确同步模式的典型案例。该仓储用 std::shared_mutex 保护内部的 std::vector<Task>,读路径(find_by_id、find_all)使用 std::shared_lock,写路径(create、update、remove)使用 std::scoped_lock。用 -DENABLE_TSAN=ON 构建并对并发读写施压,即可确认加锁纪律不存在 data race,这是常规测试无法提供的证据。
静态分析会放大评审注意力
静态分析最有价值的状态是精准且平淡无奇。如果 analyzer 一次吐出好几页风格噪声,团队很快就不看了。但如果它专注于代码库中真正重要的模式,就能成为评审效率的倍增器。
现代 C++ 中值得关注的典型检查目标包括:
- 因临时对象生命周期错误导致的悬空 view 或悬空引用。
- API 契约依赖
override、noexcept或[[nodiscard]]时,这些标注的缺失或误用。 - 涉及裸指针、moved-from 对象或智能指针别名的可疑所有权转移。
- 错误处理方面的疏漏,比如忽略返回结果、吞掉状态值、或在边界处做了不一致的错误转换。
- 热路径或高流量接口上代价高昂的意外拷贝。
- 加锁不一致或不安全地捕获共享状态等并发隐患。
编译器集成分析、clang-tidy、Clang static analyzer 以及 MSVC /analyze 等平台专属工具,各自擅长的检查领域不同。工具链支持的话可以同时用多个,但要保持输出整洁。一套规模小、强制执行、能稳定抓到真实问题的规则集,远胜于一个人人绕道走的庞杂配置。
这也是把项目专属知识注入分析的好地方。如果服务代码绝不应忽略来自 transport adapter 的 std::expected 结果,就加上检查和封装,让这种忽略难以悄无声息地发生。如果库在 ABI 边界上禁止异常,就直接针对这条策略做分析,或通过构建与 API 结构来强制执行。静态分析了解了你的契约之后,效果会好得多。
在发布工件中保留诊断质量
原生开发中一个有害的习惯是把可调试性视为 debug 构建的专属。生产故障发生在 release 构建中。如果发布工件没有保留足够的信息来把崩溃和延迟问题追溯到代码,后续的可观测性就会被严重削弱。
发布工件通常至少应保留以下属性:
- 外部符号文件或 symbol server,确保部署后仍可对栈做符号化。
- build ID 或等价的版本指纹,确保 dump 或 trace 能无歧义地对应到具体二进制。
- 源码版本信息,嵌入工件本身或附在部署元数据中。
- 足够的 unwind 支持,以获得可用的原生栈回溯。
- 编译器和 linker 设置,以可复现的方式记录下来。
视平台与安全敏感程度不同,还可能需要 frame pointer、split DWARF 或 PDB 处理、map file 以及归档的链接命令。具体机制因工具链而异,但策略是通用的:无法重现已发布二进制的诊断条件,事故响应速度会立刻下降。
这也是构建诊断被放在 sanitizer 和分析这一章、而非可观测性章节的原因。可观测性在后续阶段会消费这些工件,但决定是否生产它们,本质上是构建层面的决策。
CI 应当分阶段承担成本,而不是假装成本不存在
成熟的 pipeline 不会每次编辑都运行全部昂贵检查,而是按成本和 bug 类别分阶段安排。
例如:
- Pull request gate:快速构建、严肃警告、针对性测试,以及至少一个针对变更目标的 ASan/UBSan 配置。
- Scheduled 或 nightly job:更广的 sanitizer 覆盖、TSan、更深的静态分析,以及开启 sanitizer 的 fuzz target。
- 发布资格验证:干净的带符号发布构建、打包检查,以及对符号发布与构建元数据成功产出的验证。
取舍很明显:检查越慢,bug 被发现得就越晚。解决办法不是砍掉这些检查,而是把它们安排在可持续、可见的位置上。
不要让 sanitizer 或 analyzer 的失败沦为”仅供参考”的噪声。如果某条 lane 不稳定到无法作为门禁,就修掉不稳定的根因,或缩小它的覆盖范围。一条永远亮红灯的分析 job,组织效果上等于没有。
这些工具做不到什么
这套工具很有效,但边界也需要明确认识。
- Sanitizer 不能证明正确性,它们只对实际执行到的路径做插桩检测。
- 静态分析无法理解每一个项目特定的不变量,除非你把它们编码到代码和配置中。
- 警告全部消除不代表 API 设计或错误处理就是好的。
- 构建完全可诊断,不等于它不会产生错误行为。
这正是第六部分分为三章而非一章的原因。测试策略定义了必须覆盖哪些行为;机械化工具在执行过程中捕获特定类别的 bug;可观测性则解决最后一个问题:那些仍然逃逸到生产环境的故障,该如何理解和诊断。
要点总结
在生产级 C++ 中,诊断能力必须经过设计,不能寄希望于运气。维护一个版本化的构建矩阵,为快速迭代、sanitizer、静态分析和带符号的发布分别设置独立 job。把警告当作策略面来管理。ASan 和 UBSan 要例行运行,TSan 要有针对性地运行,静态分析则要收敛到团队仍然会认真阅读输出的程度。在发布工件中保留符号化能力和构建标识。
核心权衡在于成本与收益。Sanitizer 和静态分析会拖慢 pipeline,偶尔也需要 suppression。但不用它们就发布,等原生 bug 逃逸到生产环境后,代价只会更大。趁代码还在本地,主动承担这份成本。
评审问题:
- 对当前目标而言,哪些 sanitizer 配置是必须的?它们是否被有意义的测试真正覆盖了?
- 哪些警告在仓库范围内强制执行?suppression 在哪里接受评审?
- 哪些 analyzer 检查反映的是真实的项目契约,而非泛泛的风格偏好?
- 发布版崩溃或 dump 能否追溯到确切的二进制、符号集和源码版本?
- 哪些昂贵检查是有意推迟运行的?这种分阶段是刻意设计的,还是偶然形成的?
原生系统的可观测性
测试和 sanitizer 能减少缺陷逃逸到生产环境的概率,但无法杜绝线上故障,也无法解释系统在真实负载、真实数据、真实依赖故障和真实发布条件下的实际行为。可观测性(observability)就是为此而生。
在原生系统中,可观测性不足的代价格外高。托管服务卡住时,好歹还有运行时提供异常信息、堆快照和标准化的追踪钩子。而 C++ 系统面对的可能是:符号化残缺的崩溃转储、被某个阻塞依赖卡死的线程池、无法对应到逻辑归属的 RSS 增长,以及只知道“某次部署后延迟涨了三倍“的运维人员。如果日志、指标、追踪和转储制品不是在事故发生之前就设计进系统的,调查就只能靠猜。
本章的主题是运行时证据。测试回答的是代码在发布前是否满足契约。Sanitizer 和静态分析在开发与 CI 阶段机械地搜索已知缺陷类型。可观测性要回答的是另一个问题:当原生系统实际运行时,哪些信号能让工程师快速定位故障、过载或性能劣化的原因?
从运维问题出发
如果可观测性的起点是“加几条日志“,那它注定是薄弱的;只有从运维问题出发,才能真正做好。
对于一个服务,这些问题可能是:
- 为什么请求延迟升高了,而 CPU 占用却保持平稳?
- 哪些依赖故障正在引发重试、队列膨胀或工作只完成了一半?
- 关闭时卡住了,是因为任务没有响应取消信号,还是因为下游依赖始终没有排空?
- 内存增长是泄漏、碎片化、缓存,还是积压导致的?
对于一个库,问题则会转变:
- 哪个宿主操作触发了失败,输入或版本上下文是什么?
- 库的时间消耗在解析、等待、加锁、分配还是 I/O 上?
- 宿主能否将库的失败与自身的请求或任务标识符相关联?
这些问题决定了哪些字段、指标和 span 值得记录。缺少这些问题的指引,团队往往产出大量但价值低的遥测数据:堆满字符串的日志、缺乏维度的计数器,或者什么都记了唯独漏掉排队、重试和取消的追踪。
没有可观测性时调试是什么样子
举个具体的例子。假设有一个处理文件上传的服务,某用户反映上传超时。以下是没有结构化可观测性时的处理函数:
void handle_upload(const http_request& req) {
std::cout << "INFO: Processing upload" << std::endl;
std::cout << "INFO: Starting validation" << std::endl;
auto validation = validate(req);
if (!validation) {
std::cerr << "ERROR: Validation failed: "
<< validation.error().message() << std::endl;
return;
}
std::cout << "INFO: Storing file" << std::endl;
auto store_result = store(req);
if (!store_result) {
std::cerr << "ERROR: Store failed: "
<< store_result.error().message() << std::endl;
return;
}
notify(req);
std::cout << "INFO: Upload complete" << std::endl;
}
哪次上传失败了?是同一个用户还是不同用户?在等什么,磁盘 I/O、下游服务还是锁?store 调用花了多长时间?失败后有没有重试?这些问题从这个函数产生的日志中一个都答不上来。值班工程师只能按时间戳范围 grep 日志,靠猜测建立关联,然后请用户重现问题。
再看同一个函数加上结构化日志、关联 ID 和维度指标之后的效果:
void handle_upload(upload_context& ctx) {
auto span = ctx.tracer().start_span("handle_upload", {
{"request_id", ctx.request_id()},
{"user_id", ctx.user_id()},
{"file_size", ctx.file_size()},
{"shard", ctx.shard_id()},
});
ctx.log(severity::info, "upload_started", {
{"request_id", ctx.request_id()},
{"file_name", ctx.file_name()},
{"file_size", std::to_string(ctx.file_size())},
});
auto validation = validate(ctx);
if (!validation) {
ctx.log(severity::warning, "validation_failed", {
{"request_id", ctx.request_id()},
{"reason", validation.error().category()},
});
ctx.metrics().increment("upload_failures", 1,
{{"reason", "validation"}, {"shard", ctx.shard_id()}});
return;
}
auto store_result = store(ctx);
if (!store_result) {
ctx.log(severity::error, "store_failed", {
{"request_id", ctx.request_id()},
{"dependency", "blob_store"},
{"error_class", store_result.error().category()},
{"latency_ms", std::to_string(store_result.elapsed_ms())},
});
ctx.metrics().increment("upload_failures", 1,
{{"reason", "store"}, {"shard", ctx.shard_id()}});
return;
}
ctx.metrics().observe_latency("upload_duration_ms", span.elapsed_ms(),
{{"shard", ctx.shard_id()}});
ctx.log(severity::info, "upload_complete", {
{"request_id", ctx.request_id()},
{"latency_ms", std::to_string(span.elapsed_ms())},
});
}
现在值班工程师按 request_id 过滤,立刻看到超时发生在 store 阶段、dependency=blob_store,再查看按 shard 分组的 upload_duration_ms 直方图,发现 shard-3 延迟在 09:40 飙升。blob store 仪表盘印证了该依赖当时确实在劣化。整个调查从数小时缩短到数分钟。
两个版本的 handle_upload 做的事情一样:校验、存储、通知。差别在于写代码时就把运维问题考虑在内了。
日志应当解释决策与状态转换
日志最有价值的时候,是在记录系统做了什么决策、当时的关键状态是什么,而不是流水账式地记录每个函数调用。在原生系统中,这种克制尤其重要,因为日志的数量和开销很容易变成性能瓶颈。
良好的生产日志是结构化的、稀疏的、稳定的。
- 结构化:重要字段以机器可读的键值对输出,不要藏在大段文字里。
- 稀疏:正常路径保持安静,只在异常路径上输出详细信息。
- 稳定:字段名称和含义不会每个迭代周期都变。
当操作失败时,日志通常应记录身份标识、分类信息和当时的局部运行上下文。
- 请求或任务标识符。
- 操作名或路由名。
- 失败类别,而不仅仅是格式化消息。
- 可重试性或永久性(如果代码能判断的话)。
- 对诊断有帮助的资源指标,例如队列深度、shard、对端、重试次数等。
- 在发布状态可能相关时,还应包含版本或构建元数据。
避免两个常见错误。
第一,不要把日志当成指标类问题的唯一信息来源。如果需要了解重试率或队列深度,直接发指标,不要逼运维人员从文本里重新拼凑。第二,不要仅仅因为某次事故需要就把高基数数据或敏感内容全部记下来,放在专门的采样或调试路径中按需开启。
std::source_location 在低频的内部诊断或基础设施代码中很实用,特别是需要一个稳定的调用点标签又不想手工维护字符串的时候。但它不能替代有意义的操作名称。一条 source=foo.cpp:412 的日志,远不如 operation=manifest_reload phase=commit 有用。
示例项目中的 request_logger() 中间件(见 examples/web-api/src/modules/middleware.cppm)展示了一个最小但结构化的逐请求日志起点:
// examples/web-api/src/modules/middleware.cppm — request_logger()
return [](const http::Request& req, const http::Handler& next) -> http::Response {
auto start = std::chrono::steady_clock::now();
auto resp = next(req);
auto elapsed = std::chrono::steady_clock::now() - start;
auto ms = std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
std::cout << std::format("[{}] {} {} → {} ({} μs)\n",
"LOG",
http::method_to_string(req.method),
req.path,
resp.status,
ms
);
return resp;
};
每条响应都携带了 method、path、状态码和延迟。这还不是生产级的结构化日志,字段嵌在格式化文本里,而非以机器可查询的键值对输出。但它体现了正确的直觉:在一个横切点上捕获身份标识(路由)、结果(状态码)和时序(延迟)。将其演化为结构化 JSON 或键值格式输出是自然的下一步。
非结构化日志与结构化日志的实际差异
非结构化日志与结构化日志的差异在事故中体现得最明显。此时读日志的人时间紧迫,而且往往不是写这段代码的人。
// 非结构化:人类可读,但对机器不友好。
log("Failed to connect to database server db-prod-3 after 3 retries "
"(last error: connection refused), request will be dropped");
这行日志包含有用信息,但提取它们就得解析自然语言文本。不靠脆弱的正则表达式,根本没法按重试次数、依赖名称或错误类型过滤。在整个服务集群范围内从这类日志中聚合失败模式,费力又容易出错。
// 结构化:相同信息,机器可查询。
ctx.log(severity::error, "dependency_connect_failed", {
{"dependency", "db-prod-3"},
{"attempts", "3"},
{"last_error", "connection_refused"},
{"action", "request_dropped"},
{"request_id", ctx.request_id()},
});
现在 dependency_connect_failed 事件可以计数、按依赖名称过滤、与特定请求关联。字段名跨代码变更保持稳定,所以即使有人改写了日志措辞,仪表盘和告警也不会失效。
指标应当追踪吞吐量、饱和度和失败形态
指标能回答日志答不好的问题:速率、分布、长期趋势漂移,以及跨实例的对比。对原生系统,最有价值的指标通常分三类。
第一类是吞吐量与延迟:请求速率、任务完成率、重试率,以及关键阶段的延迟直方图。延迟务必用直方图,不能只看均值,原生系统的性能问题往往出在长尾。
第二类是饱和度:队列深度、工作线程利用率、打开的文件描述符数、连接池占用率、分配器压力、待触发的定时器,以及未完成的后台任务。这些信号能说明系统是在健康地忙碌,还是在不断积压消化不了的工作。
第三类是失败形态:按错误类别统计的计数、超时次数、取消次数、解析失败、丢弃的工作、崩溃重启,以及降级模式的激活次数。这些指标能揭示系统失败的根源:是依赖变慢了,是输入质量变了,还是内部背压被触发了。
示例项目中的 ErrorCode 枚举(见 examples/web-api/src/modules/error.cppm)展示了失败形态指标的基础。这个封闭集合——not_found、bad_request、conflict、internal_error——配合 constexpr to_http_status() 映射,为每种失败赋予了稳定的类别。在生产演进中,你可以在 handler 边界按 ErrorCode 维度递增计数器,把类型系统的分类直接变成可查询的指标标签,而无需发明临时的字符串标签。
标签要谨慎使用。高基数标签是拖垮指标系统的最快途径。请求 ID、用户 ID、文件路径、任意异常文本和原始对端地址通常不该做标签。地域、路由、依赖名称、结果类别和有限范围的 shard 标识符通常是合适的。
Gauge 也需要警惕,加起来容易,读起来容易误判。队列深度 gauge 突然跳升,到底是短暂峰值还是持续恶化?尽量把 gauge 和速率或直方图搭配使用,这样运维人员才能判断情况是在好转还是在恶化。
追踪需要跟随异步所有权,而非仅跟随同步调用
在分布式服务和异步原生系统中,追踪(tracing)是弄清端到端时间花在哪里的唯一实用手段。但 C++ 代码往往因为没有在执行器、回调、线程跳转和协程挂起等边界处保持上下文,白白损失了追踪的价值。
如果一个请求进入服务、入队后台工作、等待下游调用,然后在另一个 worker 上恢复执行,追踪仍应呈现为一次连贯的操作。这就要求在工作所有权发生交接的边界处显式传播追踪上下文。
前面章节的设计决策在这里体现出价值。结构化并发和显式取消作用域天然让追踪更清晰,因为父子关系本身就是有意义的。而脱管的工作和随意派生的线程则会让追踪碎片化为一堆互不相关的 span。
应当为那些对应实际等待或服务边界的阶段创建 span:
- 工作开始前在队列中等待的时间。
- 执行本地 CPU 工作的时间。
- 等待下游 I/O 的时间。
- 重试或退避的时间。
- 因取消、关闭或过载丢弃造成的时间损失。
不要为每个辅助函数都创建 span,那只会制造噪声。追踪的目的是揭示延迟的结构和依赖的形态,不是把调用图再描述一遍。
示例项目中的中间件管道(见 examples/web-api/src/modules/middleware.cppm 中的 middleware::chain())天然适合作为追踪上下文的传播载体。每个中间件包装下一个 handler,同时能访问请求和响应。向管道中插入一个追踪中间件,在调用 next 前启动 span,附加到请求上下文,响应返回后关闭 span,就足够了。管道已经以 std::function 包装器链的形式组合,添加追踪阶段不需要修改 handler 签名,这正是让追踪传播切实可行的横切插入点。
没有追踪上下文:隐形的排队时间
异步原生服务中有一个常见问题:延迟其实花在了排队上,而不是执行上。如果追踪上下文没有跨执行器边界传播,这部分时间就是不可见的:
// 没有追踪上下文传播。span 只覆盖执行阶段,不覆盖等待阶段。
void enqueue_work(thread_pool& pool, request req) {
pool.submit([req = std::move(req)] {
auto span = tracer::start_span("process_request"); // 工作运行时才开始计时。
process(req);
});
// submit() 到 lambda 实际执行之间的时间丢失了。
// 如果线程池饱和,请求在队列中等待 500ms,
// 但追踪显示 2ms 的执行时间。运维人员在追踪中
// 看到低延迟,而用户体验到高延迟。排队时间是
// 一个盲点。
}
正确传播上下文后,完整图景一目了然:
void enqueue_work(thread_pool& pool, request req, trace_context ctx) {
auto enqueue_time = steady_clock::now();
pool.submit([req = std::move(req), ctx = std::move(ctx), enqueue_time] {
auto queue_span = ctx.start_span("queued", {
{"queue_ms", std::to_string(duration_cast<milliseconds>(
steady_clock::now() - enqueue_time).count())},
});
queue_span.end();
auto exec_span = ctx.start_span("process_request");
process(req);
});
}
现在追踪中显示两个 span——排队和执行——都挂在父请求下面。一旦排队时间占了大头,在追踪瀑布图中一眼就能看出来。这恰恰是只靠指标(比如平均处理时间)会系统性掩盖的那类延迟。
崩溃诊断是可观测性的一部分,不是独立的应急工作
原生服务和工具必须在第一次崩溃发生之前就准备好崩溃处理方案,不能止步于“开启转储“。你需要知道转储存在哪里、怎么符号化、怎么映射到精确的构建版本、运维人员如何把它和日志与追踪关联起来,以及上面附带了哪些进程元数据。
至少,一个崩溃事件应当能够关联到:
- 精确的二进制或构建 ID。
- 匹配构建生成的符号文件。
- 部署元数据,例如版本、环境和上线环。
- 失败操作前后最近的结构化面包屑(breadcrumb)。
- 线程标识,以及尽可能提供的相关线程栈回溯。
上一章讨论的构建工作正是这一切的基础。符号服务器、构建 ID 和发布元数据属于构建层面的事务,但它们的运维价值在凌晨三点出事故、值班工程师需要看到有效栈回溯而不是一堆裸地址的时候才体现出来。
崩溃上报还需要策略上的考量。有些组件应该快速失败,继续运行可能导致数据损坏。另一些组件可以隔离失败的请求或插件,让宿主进程继续存活。可观测性应该让这种决策在事后清晰可查。如果进程因不变量被违反而主动中止,终止前应输出足够的上下文,让这次崩溃能和随机的段错误区分开来。
原生系统中资源可见性更为重要
C++ 服务中有一个反复出现的运维难题:逻辑工作量增长和内存缺陷分不清。RSS 在涨、延迟在升,所有人都在问“是不是有泄漏?“有时确实是,但更多时候原因没那么干净:分配器保留、过大的缓存、无界队列、停滞的消费者、mmap 增长、文件描述符泄漏,或新流量模式下的内存碎片化。
靠单一指标解决不了这个问题。需要一组资源信号,把运行时行为和可能的原因串联起来。
- RSS 和虚拟内存,用于粗略了解进程形态。
- 分配器特有统计数据(在可用时)。
- 队列深度和积压时龄,反映内存中工作的堆积情况。
- 打开的文件描述符或句柄计数。
- 活跃线程数和阻塞线程指标。
- 连接池占用率和超时计数。
- 那些有意保留内存的组件的缓存大小和淘汰指标。
目的不是把每个分配器 bin 或内核计数器都暴露出来,而是让各种可能的失败模式能区分开。内存上升的同时队列深度和积压时龄也在涨,那过载比泄漏更可能是原因。内存上升但队列深度平稳、句柄计数在涨,资源泄漏就变得更可信。可观测性的作用就是缩小排查范围。
库需要宿主拥有的遥测边界
可复用库不应假定存在全局日志框架、指标后端或追踪 SDK。这和本书前面讨论过的依赖反转问题一样,只不过这次以运维的形式表现出来。库应当暴露一个窄小的诊断接口,由宿主来实现。
局部示例:一个面向库的诊断接收器
enum class severity { debug, info, warning, error };
struct diagnostic_field {
std::string_view key;
std::string_view value;
};
struct diagnostics_sink {
virtual ~diagnostics_sink() = default;
virtual void record_event(severity level,
std::string_view event_name,
std::span<diagnostic_field const> fields) noexcept = 0;
virtual void increment_counter(std::string_view name,
std::int64_t delta,
std::span<diagnostic_field const> dimensions) noexcept = 0;
};
这类接口让库保持本分。库可以上报解析失败、重试、缓存淘汰或重加载耗时,不必把某个厂商 SDK 硬编码进每个使用它的二进制文件。怎么附加请求 ID、怎么导出指标、怎么接入追踪系统,都由宿主决定。
代价是需要额外的抽象设计。对于仅限内部使用的小型组件,直接集成也无妨。但对于可复用库,由宿主掌控遥测通常是更清晰的长期方案。
应避免的做法
原生可观测性常见的几种走偏方式:
- 每次分配、加锁、函数入口都打日志,以为数据越多越保险。
- 用高基数标识符做指标维度。
- 追踪跟着同步辅助函数走,却在执行器或协程边界处丢掉了异步上下文。
- 发布时符号化做得很弱,出了事又指望崩溃分析能行。
- 把日志当成唯一的运维手段,而不是把日志、指标、追踪和转储组合使用。
- 让库的遥测绑死在特定的服务日志栈上。
以上做法都会增加成本,却带不来相应的诊断价值。
要点总结
原生系统的可观测性是运行时证据的设计工作。从具体的运维问题出发,用日志记录决策和状态转换,用指标追踪速率和饱和度,用追踪揭示端到端延迟结构,用崩溃制品支撑事后调试。保持异步和所有权边界的完整性,这些信号才有意义。暴露足够的资源信号,让运维人员能分清泄漏、积压、碎片化和依赖停滞。
核心权衡在于开销与解释质量。遥测会增加 CPU、内存、存储和设计复杂度;太稀疏则拉长事故处理时间,让原生故障变得模棱两可。选择能回答真实运维问题的最小信号集,然后保持它的稳定性。
评审问题:
- 这个服务或库今天不靠猜测能回答哪些运维问题?
- 哪些日志字段足够稳定、足够结构化,能支撑自动化处理,而不只是给人看的文本?
- 哪些指标能把吞吐量、饱和度和失败形态区分开,而不是混为一谈?
- 追踪上下文能否跨越执行器跳转、回调和协程挂起边界存活下来?
- 生产环境的崩溃能否关联到精确的构建标识、符号文件以及附近的运行上下文?
在现代 C++ 中构建一个小型服务
小型服务往往是 C++ 团队养成工程纪律或栽跟头的分水岭。代码库尚小,人们容易即兴行事,但流程中已经埋着不少真实的失败模式:过载、启动时配置加载不全、部分写入、依赖超时、队列无限增长、关闭时的竞态条件,以及证据不足时的生产环境排障。语言本身帮不了你。
本章不是框架教程。要回答的问题更具体:如果你希望六个月后回头看,所有权、失败处理、并发和运维依然清晰可审,那么一个小型 C++23 服务该是什么样子?答案不是”把最新特性全用上”,而是选择一种服务形态,让生命周期一目了然、异步工作归属明确、资源限制显式可控,在高压下仍能快速定位问题。
本章的示例是一个配置驱动的小型服务:接收请求、校验请求、执行有界的后台工作、持久化状态,对外暴露指标和健康信息。细节刻意选得很普通。大多数生产服务在概念上并无新意,之所以出问题,是因为基本的工程边界没有划清。
先定义所有权单元,再定义部署单元
小型服务最常见的架构错误,是按端点、处理器或框架回调来组织代码,而非按它拥有的资源来组织。一个可部署的服务拥有一组固定的长生命周期对象:配置、监听器、执行器、连接池、存储适配器、遥测汇聚点、关闭协调机制。如果不把这些对象显式建模出来,代码就会慢慢滑向全局变量、共享单例、脱管的后台工作,以及”听天由命”的关闭流程。
服务对象应当直接体现所有权关系,由它来构造、启动和停止所有长生命周期的依赖。目的不是造一个无所不包的上帝对象,而是要有一个清晰的根,管辖那些生命周期必须一同结束的组件。
刻意只展示片段:掌控时序与关闭流程的服务根
struct service_components {
config cfg;
request_router router;
storage_client storage;
bounded_executor executor;
telemetry telemetry;
http_listener listener;
};
class service {
public:
explicit service(service_components components)
: components_(std::move(components)) {}
auto run(std::stop_token stop) -> std::expected<void, service_error>;
void request_stop() noexcept;
private:
auto start() -> std::expected<void, service_error>;
auto drain() noexcept -> void;
service_components components_;
std::atomic<bool> stopping_{false};
};
这段代码故意写得很朴素。服务只有一个所有权根、一条停止路径、一个可以推导启动和排空顺序的地方。小型服务不需要花哨的架构。
示例项目 examples/web-api/ 就是这一模式的具体实现。其 main.cpp 在一个连续的作用域中构造所有长生命周期资源——仓储、路由、中间件管道、服务器——并在进程开始接受请求前完成组装:
// examples/web-api/src/main.cpp — scoped multi-resource construction
webapi::TaskRepository repo;
// ... seed data ...
webapi::Router router;
router
.get("/health", webapi::handlers::health_check(repo))
.get("/tasks", webapi::handlers::list_tasks(repo))
// ... remaining routes ...
std::vector<webapi::middleware::Middleware> pipeline{
webapi::middleware::request_logger(),
webapi::middleware::require_json(),
};
auto handler = webapi::middleware::chain(pipeline, router.to_handler());
webapi::http::Server server{port, std::move(handler)};
server.run_until(shutdown_requested);
所有权根只有一个(main),关闭协调点也只有一个(shutdown_requested),每个资源都有明确的作用域。当 run_until 返回后,析构按声明的逆序进行——先是 server,再是 handler、router,最后是 repository。没有共享指针,没有全局注册表,没有脱管工作。
这个根通常应当直接持有具体的基础设施类型,而不是一张堆分配接口拼成的对象图再靠共享所有权勉强缝合。依赖反转仍然重要,但反转点通常落在存储、传输或遥测适配器这类边界上。进程内部,静态所有权比到处散落的 std::shared_ptr 更简单也更廉价,后者往往让真正的所有者在纸面上无从追溯。
反模式:shared_ptr 大杂烩式的请求状态
一种常见的失败模式:用 std::shared_ptr 把请求的生命周期横跨回调、队列和重试逻辑一路延长,却始终没有显式的所有权模型。代码编译没问题,看上去也安全,但没人说得清请求资源什么时候释放、取消信号能不能到达每个持有者、关闭流程能否确定性地完成。
// BAD: shared_ptr soup — every callback extends lifetime indefinitely
void handle_request(std::shared_ptr<http_request> req) {
auto ctx = std::make_shared<request_context>(req->parse_body());
ctx->db_future = db_.async_query(ctx->query, [ctx](auto result) {
ctx->result = result;
cache_.async_store(ctx->key, ctx->result, [ctx](auto status) {
ctx->respond(status); // when does ctx die? who knows
});
});
// ctx is now kept alive by two lambdas, the future, and possibly
// a retry timer. cancellation cannot reach it. shutdown cannot
// drain it. memory profile is non-deterministic.
}
正确做法是提取一个有明确归属的工作项,让它沿着流水线在清晰的交接点之间传递。
// BETTER: owned work item with explicit lifetime boundaries
struct request_work {
parsed_query query;
std::stop_token stop;
response_sink sink; // move-only, writes exactly once
};
void handle_request(http_request& req, std::stop_token stop) {
auto work = request_work{
.query = req.parse_body(),
.stop = stop,
.sink = req.take_response_sink(),
};
executor_.submit(std::move(work));
// work is now owned by the executor. cancellation reaches it
// through stop_token. shutdown drains the executor.
}
有明确归属的工作项让设计问题浮出水面:哪些数据需要跨越请求边界存活、谁有权取消它,以及关闭时它最终归向何处。
启动要么交出一个可运行的服务,要么干净地失败
很多服务故障在第一个请求到达之前就已经埋下。配置只加载了一半;一个子系统正常,另一个还没起来;线程已经启动,健康状态却尚未建立;后台定时器在依赖校验完成前就开始跑了。进程之所以报告”ready”,仅仅是因为某个构造函数碰巧返回了。
启动要回答的核心问题不是每个组件能否单独初始化,而是进程能否达到一个整体一致的运行状态。启动应当按阶段组织,围绕依赖校验和显式的失败边界展开。
常见且有效的启动顺序如下:
- 加载并验证不可变配置。
- 构造带有显式限制的资源拥有型适配器。
- 校验就绪性所依赖的下游服务。
- 只有在进程整体一致之后,才启动监听器和后台工作。
- 只有前面步骤全部成功后,才发布就绪状态。
在 C++23 中,启动路径用 std::expected 往往比异常更合适,因为启动过程天然会积累各种基础设施故障,这些故障需要归入稳定的运维类别。配置文件格式错误、端口被占用、存储 schema 不兼容,这些信息应当作为设计好的启动失败暴露出来,而不是把实现内部碰巧泄漏的异常文本原样抛到最上层。
代价是代码更啰嗦。std::expected 要求你逐一写出错误转换点。但在服务启动场景下,这笔开销通常值得,因为隐藏的异常路径会让进程状态更难推理。叶子函数或内部辅助函数,如果包裹它们的边界足够清晰,用异常仍然没问题。关键在于启动阶段对外暴露的必须是一个连贯统一的契约。
请求处理中,借用数据就该是短命的
小型服务的一个典型错误是把临时的请求数据悄悄变成长生命周期的内部状态。请求头变成了异步任务中的 std::string_view 成员;解析后的载荷视图被存进缓存;回调捕获的引用指向的对象早已随请求一起销毁。服务之所以”看起来没问题”,只是因为慢路径、重试路径或队列延迟还没来得及暴露这个错误。
规则很简单:借用视图适合做同步检查,不适合用作隐式存储。在请求路径内部,只要生命周期局部且明确,尽管放心使用 std::string_view、std::span 和范围视图。一旦数据要跨越时间、线程、队列或重试边界,就必须在那之前将其转换为拥有所有权的表示。
这也是服务代码受益于显式请求模型的原因。先把输入解析、校验成一个值类型,由它持有后台工作必须保留的数据。保持这个模型足够小,让复制成为一个可感知的成本;当设计上需要时间解耦时,再将它 move 进异步工作。
示例项目中的 handler 函数(见 examples/web-api/src/modules/handlers.cppm)遵循了这一原则。每个 handler 工厂返回一个 std::function<Response(const Request&)>,即一个值类型,仅捕获对 repository 的引用。handler 通过 const 引用接收请求、提取所需数据(路径参数、解析后的 body)、执行操作、返回响应值。没有请求状态泄漏到函数调用之外:
// examples/web-api/src/modules/handlers.cppm — 值类型 handler 设计
[[nodiscard]] inline http::Handler
get_task(TaskRepository& repo) {
return [&repo](const http::Request& req) -> http::Response {
auto segment = req.path_param_after("/tasks/");
if (!segment) {
return http::Response::error(400, R"({"error":"missing task id"})");
}
auto id_result = parse_task_id(*segment);
if (!id_result) {
return http::Response::error(
id_result.error().http_status(), id_result.error().to_json());
}
auto task = repo.find_by_id(*id_result);
if (!task) {
return http::Response::error(404,
std::format(R"({{"error":"task {} not found"}})", *id_result));
}
return http::Response::ok(task->to_json());
};
}
path_param_after 返回的借用 string_view 绝不会超出同步 handler 调用的范围。repository 返回的 Task 是值拷贝。响应以值构造并返回。没有生命周期歧义。
这也是很多 C++ 服务代码库过度使用 std::shared_ptr<request_context> 的原因。共享所有权看起来像是应对异步生命周期的便捷后门,但它遮蔽了真正需要做出的设计决策:请求的哪些部分需要存活、归谁管、什么时候可以丢弃。对小型服务而言,提取一个有明确归属的工作项并 move 进队列,通常比把整个请求对象图的生命周期一股脑延长要好得多。
并发必须有界、有主、可取消
服务的并发模型远比具体用了哪些并发原语更重要。小型服务很少需要大型自定义调度器,但有三样东西必须到位。
第一,并发工作量必须有上限。如果过载能直接转化为无限制的队列增长,你设计的就不是服务,而只是把故障推迟了。有界执行器、信号量、准入控制以及按请求分配的时间预算,比精巧的线程池内部实现更有价值。
第二,工作必须有归属。分离线程和 fire-and-forget 任务很诱人,因为它们让局部代码显得更短;但它们同时也摧毁了关闭语义。既然服务能往队列里塞工作,它就应当知道工作何时开始、何时结束、取消信号如何送达。
第三,取消必须是常规模型的一部分,不能事后补救。std::jthread 和 std::stop_token 在这方面很有帮助,因为它们把停止传播提升到了类型级契约。它们并非万能,你仍然需要让工作单元在合理的边界点检查 token,也需要让存储或网络操作把取消映射为一致的错误。但它们至少把这个问题强制写进了代码,而不是留在注释里。
反模式:阻塞事件循环
最常见的服务故障之一,是在本该驱动 I/O 或分发请求的线程上做同步阻塞操作。轻负载时一切看起来正常;流量一上来,事件循环卡在数据库调用、DNS 解析或文件读取里,整个服务随之崩塌。
// BAD: synchronous blocking on the listener thread
void on_request(http_request& req) {
auto record = db_.query_sync(req.key()); // blocks for 5-200ms
auto enriched = enrich(record); // CPU work, fine
auto blob = fs::read_file(enriched.path()); // blocks again
req.respond(200, serialize(blob));
}
// Under 50 concurrent requests, the listener thread is blocked
// for the entire duration of each request. Tail latency explodes.
// New connections queue at the OS level with no backpressure signal.
正确做法是把阻塞工作分派到有界执行器,让监听线程始终保持非阻塞。
// BETTER: dispatch blocking work off the listener thread
void on_request(http_request& req) {
auto work = request_work{req.key(), req.take_response_sink()};
if (!executor_.try_submit(std::move(work))) {
req.respond(503, "overloaded"); // explicit rejection
metrics_.increment("request.rejected.overload");
}
// listener thread returns immediately, ready for next connection
}
// In the executor's worker threads:
void process(request_work work) {
auto record = db_.query_sync(work.key);
auto enriched = enrich(record);
auto blob = fs::read_file(enriched.path());
work.sink.respond(200, serialize(blob));
}
反模式:没有优雅关闭
缺乏显式关闭逻辑的服务会导致 use-after-free、部分写入、孤儿连接,以及只能靠编排器发 SIGKILL 强杀的僵死进程。开发环境里这类问题往往看不见,因为进程退出太快。到了生产环境,正在处理中的工作和后台定时器会带来真实的竞态条件。
// BAD: shutdown by destruction order and hope
class service {
http_listener listener_;
database_pool db_;
std::vector<std::jthread> workers_;
public:
~service() {
// listener_ destructor closes the socket (maybe)
// workers_ destructors request stop and join (maybe)
// db_ destructor closes connections (maybe)
// but workers_ may still be using db_ when db_ destructs
// destruction order is reverse-of-declaration, so db_
// is destroyed BEFORE workers_ — use-after-free
}
};
正确做法是把关闭变成一个显式的、有序的操作,先排空在途工作,再销毁资源。
// BETTER: explicit drain-then-destroy shutdown
class service {
database_pool db_; // destroyed last
http_listener listener_;
bounded_executor executor_; // owns worker threads
std::atomic<bool> stopping_{false};
public:
void shutdown() noexcept {
stopping_.store(true, std::memory_order_relaxed);
listener_.stop_accepting(); // 1. stop new work
executor_.drain(std::chrono::seconds{5}); // 2. finish in-flight
db_.close(); // 3. release deps
metrics_.flush(); // 4. final telemetry
}
// destructor now only releases already-drained resources
};
关键在于:析构顺序是语言层面的机制,不是关闭策略。两者必须协同设计。凡是在途工作仍然依赖的资源,都必须先通过显式的排空逻辑处理完毕,然后才能开始拆除。
示例项目展示了这一模式的干净版本。在 examples/web-api/src/main.cpp 中,信号处理函数设置一个原子标志;Server::run_until()(见 examples/web-api/src/modules/http.cppm)轮询该标志并将停止请求转发给 std::jthread:
// examples/web-api/src/main.cpp — signal handler
namespace {
std::atomic<bool> shutdown_requested{false};
extern "C" void signal_handler(int) {
shutdown_requested.store(true, std::memory_order_release);
}
}
// examples/web-api/src/modules/http.cppm — Server::run_until()
void run_until(const std::atomic<bool>& should_stop) {
std::jthread server_thread{[this](std::stop_token st) {
run(st); // checks st.stop_requested() each accept loop iteration
}};
while (!should_stop.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
server_thread.request_stop();
// jthread auto-joins on destruction
}
流程是:SIGINT/SIGTERM 设置标志 → run_until 将停止转发到 jthread 的 stop_source → accept 循环退出 → jthread join → run_until 返回 → main 按声明的逆序销毁资源。每一步都是显式的、有序的、协作式的。没有脱管线程,不需要 SIGKILL。
如果服务本身适合做异步组合,协程确实能改善代码结构,尤其是 I/O 密集的请求路径。但如果引入协程只是为了省掉回调,而生命周期模型依旧模糊,那就是亏本买卖。当一个协程帧捕获了借用的请求数据、执行器引用和取消状态,却找不到一个明确的所有者时,你只是把 bug 压缩了,并没有消除它。协程值得用来简化设计的前提是所有权模型本身已经站得住脚。
背压是产品决策,不是队列细节
在小型服务里,背压(backpressure,即下游向上游反馈负载信号的机制)恰恰是局部技术选择变成用户可感知策略的临界点。系统饱和时该怎么办?让请求排队等待、立刻快速失败、丢掉非必要的工作、降级到陈旧数据,还是在有限等待后超时?如果答案是”队列继续涨”,说明这个服务在运维层面还缺一个决策。
现代 C++ 能帮你落地这些决策,但不会替你拍板。std::expected 可以把过载表达为稳定的错误类别;值类型工作项让队列开销一目了然;基于 std::chrono 的截止时间可以显式贯穿整个调用链;结构化取消让请求在调用方不再需要结果时及时放弃子任务。但这一切都不能替代“过载时该怎么办“这个决策。
对于小型服务,一般建议优先选择显式拒绝,而非默默地把延迟越拖越高。一个有界队列配合清晰的拒绝指标,比一个”好心”吞下突发流量的队列更好运维,后者会一直吸收请求,直到内存和尾延迟变成别人的线上事故。代价是在高负载下更早地向用户暴露失败。这通常仍是正确的取舍:它保住了系统的整体形态,也让容量问题变得可度量。
中间件管道:横切关注点的组合
示例项目的中间件系统(见 examples/web-api/src/modules/middleware.cppm)展示了横切关注点如何在彼此解耦、也不与 handler 逻辑耦合的前提下完成组合。Middleware 是一个 std::function,接受请求和下一个 handler,返回响应。middleware::chain() 将一组中间件折叠到基础 handler 之上:
// examples/web-api/src/modules/middleware.cppm
template <std::ranges::input_range R>
requires std::same_as<std::ranges::range_value_t<R>, Middleware>
[[nodiscard]] http::Handler
chain(R&& middlewares, http::Handler base) {
http::Handler current = std::move(base);
for (auto it = std::ranges::rbegin(middlewares);
it != std::ranges::rend(middlewares); ++it)
{
current = apply(*it, std::move(current));
}
return current;
}
在 main.cpp 中,管道以声明式方式组装:
std::vector<webapi::middleware::Middleware> pipeline{
webapi::middleware::request_logger(),
webapi::middleware::require_json(),
};
auto handler = webapi::middleware::chain(pipeline, router.to_handler());
每个中间件都是独立的、可单独测试的,以值组合。添加新的横切关注点(限流、认证、追踪传播)只需向 vector 追加元素,无需修改 handler 签名。这是函数式装饰器模式应用于 HTTP 处理的实践:运维需求增长时,服务结构仍能保持扁平。
保持依赖边界狭窄且负责转换
小型服务的内部代码往往要依赖数据库、RPC 客户端、文件系统、时钟和遥测厂商。常见的两种错误做法:一是上来就把它们全部抽象化,二是任由厂商类型在整个代码库里到处流窜。两种做法长期后果都很糟。
窄边界适配器是务实的中间路线。服务层应当依赖用自身语言表达的契约:持久化这条记录、获取这个快照、发送这个指标、发布这个事件。由适配器负责将其翻译为外部 API 调用、错误模型和内存分配策略。
这为服务提供了一个统一的位置来规范超时策略、归类失败、补充可观测性字段,以及控制分配与复制决策。它同时也防止了传输层细节渗透到业务逻辑中。处理器拿到的应当是与领域相关的、服务能一致应对的失败类别。
不要把这些接口过度泛化。小型服务需要的是薄端口,不是企业级的抽象帝国。适配器的目的是守住所有权和失败边界,不是在进程内部模拟一个平台团队。
可观测性应当跟随服务形态
当服务根、请求模型和并发模型都是显式的,可观测性自然水到渠成。请求标识、队列深度、活跃工作数、依赖延迟、取消计数、启动失败和关闭时长,都能映射到代码中的具名边界上。反过来,如果代码库充斥着隐藏的全局变量和脱管的后台工作,遥测数据也会跟着含混不清,没人说得清工作从哪里开始、在哪里结束。
一个小型服务通常至少应当暴露以下信号:
- 按类别划分的启动成功或失败。
- 请求速率、延迟直方图和失败类别。
- 有界工作队列的深度和拒绝计数。
- 下游依赖的延迟和超时计数。
- 关闭时长,以及被取消的在途操作数量。
超出这个范围的指标,都应当有对应的运维问题来支撑,而非出于“万一用得上“的恐惧。目标是在服务过载、配置出错或卡在关闭流程中时能尽快定位问题,不是采集尽可能多的遥测数据。
测试应针对生命周期,而不仅仅是行为
对小型服务最有价值的测试,很少是”合法输入返回 200”这一类。真正有价值的是那些在压力下验证生命周期行为的测试:非法配置能否阻止就绪、过载是否触发显式拒绝、已取消的工作是否不会提交半成品状态、关闭流程是否能排空在途任务且不引入 use-after-free 风险、依赖故障是否仍被正确归类。
这类测试通常包括:针对适配器和边界转换的聚焦单元测试、围绕启动与关闭场景的集成测试、配合 sanitizer 运行以排查内存和并发隐患,以及在运维契约要求严格时对可观测性输出的断言。例如,如果选定的策略是”过载即拒绝”,那服务就应当暴露一个指标或结构化事件,用以证明该策略在线上确实生效。
本章刻意没有重复的内容:测试分类体系、sanitizer 的用法、遥测流水线的搭建,这些在前面的章节已经讲过。本章的综合要点在于服务的形态决定了这些工具能否产出有价值的证据。
这种形态在什么地方开始不够用
本章的建议适用于真正意义上的小型服务:单进程、少量长生命周期依赖、有界的后台工作,以及团队仍然能把完整运行时模型装在脑子里的代码规模。再往上走一步,你可能就需要更显式的子系统所有权划分、更强的组件隔离、服务级准入控制,或者一个对生命周期管理已有成熟方案的专用异步框架。
当领域主要受限于本章只略微提及的某个约束时,你也应选择不同的架构形态:超低延迟交易、硬实时系统、需要防范恶意扩展的插件宿主,或者拥有专用协议栈的公网服务器。同样的原则依然适用,但工程的重心会有所偏移。
要点总结
一个好的小型 C++23 服务,围绕有明确归属的资源、显式的启动与关闭、有界的并发、短命的借用、窄边界的依赖适配器,以及与真实生命周期边界对齐的可观测性来构建。代码应当让人一眼看出进程拥有什么、工作如何被准入、取消如何传播、故障期间哪些状态仍然有效。
这些取舍是刻意为之的。显式边界会带来更多样板代码;有界队列会更早拒绝请求;值类型工作项可能比满是视图的设计多一些复制开销;窄适配器会增加边界转换代码。但和调试一个生命周期与过载行为全藏在实现细节里的服务相比,这些成本通常微不足道。
复习问题:
- 长生命周期服务资源的唯一所有权根在哪里?
- 哪些请求数据跨越了时间或线程边界?在跨越之前,它们是否已转为拥有所有权的数据?
- 并发工作在哪里被限定为有界?由此对应的显式过载策略是什么?
- 取消信号如何送达在途工作?关闭完成后保证了哪些状态?
- 哪些依赖故障被转换成了稳定的服务级错误类别,而非把供应商内部细节直接暴露出去?
在现代 C++ 中构建可复用库
应用代码靠着局部便利,往往能撑很久。库代码不行。API 一旦流出最初的调用者圈子,每一条含糊的所有权契约、每一个滥用的错误通道、每一次不经意的分配、每一处不稳定的类型依赖,都会变成别人的麻烦。库也许还能编译通过,但别人已经不敢信任它了。
本章要回答的实际问题不是”怎么写出漂亮的 API”,而是”一个可复用的现代 C++23 库,到底要把哪些东西讲明白,其他团队才能放心接入,不至于连带继承那些隐藏耦合、不稳定行为和无从验证的承诺?”答案要从范围说起。好的可复用库只做一个精确的承诺,用经得起真实使用的类型和契约来表达它,坚决不让自身的实现成本渗透到整张依赖图中。
本章选用的示例是一个供多个服务和命令行工具共同使用的解析与转换库。之所以选这个领域,是因为它几乎涵盖了所有棘手的现实压力:输入边界、分配行为、诊断信息、性能预期、可扩展性,以及打包分发。
从一个精确的承诺开始
很多糟糕的库,还没写出第一行公开 API 就已经注定失败了,它们的定位是”凡是跟 X 相关的东西都放这里”。后果不难预见:职责不断堆积,为各种不相干的需求长出扩展点,任何改动都牵一发动全身,版本演进举步维艰。
可复用库应该只做一个精确的承诺:解析并验证某种格式;归一化一批记录;暴露一层存储抽象;计算一组派生值。承诺本身可以很有分量,但必须有一个清晰的核心。
这道理听起来显而易见,却会立刻改变接口设计的走向。精确的承诺意味着更少的公开类型、更少的失败类别、更少需要调用者自行定制的地方。含糊的承诺只会把复杂度往外推,推成泛滥的模板、回调丛林、配置映射表,以及读起来像谈判停火协议的文档。
库设计中最重要的决定不是该不该用模块、concept 还是 std::expected,而是公开契约到哪里为止。
公开类型应直接编码所有权与不变量
调用者不应该非得翻代码仓库,才能搞清楚所有权、生命周期、可变性或有效性这些基本问题。库返回一个视图,调用者就该知道底层数据归谁所有。库接收一个回调,回调的生命周期和线程要求就该清楚。一个配置对象可能处于无效状态,那这个无效状态只应在验证完成前短暂存在。
值类型通常是库 API 的最佳重心,因为它们在跨团队和跨测试场景中传递起来最可靠。值类型让复制成本显而易见,让 move 语义成为有意识的选择,也让不变量可以绑定在构造或验证的边界上。std::string_view 和 std::span 这类借用型输入在调用边界上仍然好用,前提是库能在借用的生命周期内完成工作,或者及时复制需要保留的数据。
有意保留为局部片段:面向调用者、契约显式的 API
enum class parse_error {
invalid_syntax,
unsupported_version,
duplicate_key,
resource_limit_exceeded,
};
struct parse_options {
std::size_t max_document_bytes = 1 << 20;
bool allow_comments = false;
};
struct document {
std::pmr::vector<entry> entries;
};
[[nodiscard]] auto parse_document(std::string_view input,
parse_options const& options,
std::pmr::memory_resource& memory)
-> std::expected<document, parse_error>;
这段代码做了几件事:把调用者可控的策略与输入字节分开;让分配策略对外可见,但不强制统一使用某个全局分配器;返回领域层面的错误类型,而非传输层或解析器内部的类型;通过 [[nodiscard]] 和 std::expected,让调用者更难忽略返回值。
代价是函数签名不再像 document parse(std::string_view) 那样简洁。这完全没问题。可复用库靠的不是在幻灯片上看起来紧凑,而是让成本和契约清晰可读。
让失败形态保持稳定
应用代码有时还能容忍异常和内部错误类别到处漂移,毕竟两头都是同一个团队在管。库就不能这样。调用者必须分得清:哪些失败属于契约的一部分、哪些属于编程错误、哪些只是实现层面的意外。
这通常会引向三种设计之一。
- 对调用者本就需要处理的常规领域失败,使用
std::expected或类似的结果类型。 - 把异常留给不变量被破坏、环境出现异常故障,或者所在生态本来就以异常为主的 API。
- 在边界处把底层错误翻译成稳定的公开错误词汇。
具体怎么选取决于领域。解析、验证、可恢复的业务规则失败,一般都适合 std::expected。嵌入异常式应用框架的底层基础设施库,用异常也合情合理。但最关键的是保持一致。如果一个库对部分可恢复失败返回 std::expected,对另一些却抛异常,某个后端泄漏出 std::system_error 而另一个后端不会,那它就是在逼调用者从实现细节里反推策略。
公开错误不要切得太细。调用者需要的是能指导自己下一步行动的区分,二十种只有库内部才看得懂的解析器状态对他们毫无用处。稳定的错误类别应保持精简,更丰富的诊断信息可以通过独立通道按需提供。
分离机制与策略,但不要把一切都抽象掉
可复用库往往确实需要一些定制能力:分配、日志钩子、宿主侧的诊断、时钟源、I/O 适配器、用户自定义的处理器。问题出在这里,团队要么把整个接口层过度模板化,要么把库包裹在运行时多态接口里,搞得到处都是分配和虚分派。
更好的做法是只开放少量显式的策略接缝(policy seam,即可由调用者替换的定制点),核心机制保持具体实现。需要零开销且编译期可检查的定制点时,concept 很有用;二进制边界、插件模型或运维解耦比模板透明性更重要时,类型擦除或回调接口更合适。
一个内部解析引擎通常没必要做成同时参数化日志、分配、诊断和错误格式化的庞大策略模板。它完全可以用具体代码做解析、接收一个 std::pmr::memory_resource&、再通过一个窄接口按需输出诊断信息。这样大多数调用点保持简洁,宿主仍然能控制那些代价高昂或与环境强相关的部分。
库作者还必须在依赖管理上保持自律。如果公开头文件为了几个可选功能就引入了网络栈、格式化库、指标 SDK 和文件系统抽象,可移植性和构建整洁度就已经丢了。可选的运维功能应当藏在窄接缝后面或放进配套的适配器里,不要塞进 API 核心。
错误:在公开头文件中暴露内部类型
最常见的库设计失误是让实现类型泄漏到公开 API 中。由此产生的隐藏耦合后患无穷:调用者被迫传递性地依赖自己从未主动引入的头文件,构建时间膨胀,库内部的重构也会变成对外的破坏性变更。
// BAD: public header pulls in implementation details
#pragma once
#include <boost/asio/io_context.hpp> // transport detail
#include <spdlog/spdlog.h> // logging detail
#include "internal/parser_state_machine.h" // implementation detail
class document_parser {
public:
document_parser(boost::asio::io_context& io,
std::shared_ptr<spdlog::logger> log);
auto parse(std::string_view input) -> document;
private:
boost::asio::io_context& io_; // caller now depends on Boost.Asio
std::shared_ptr<spdlog::logger> log_; // caller now depends on spdlog
internal::parser_state_machine fsm_; // caller now depends on internal layout
};
// Every caller's translation unit now includes Boost.Asio and spdlog headers.
// Changing the logging library is a breaking change for all consumers.
修正方式是让公开头文件保持最小化,把实现类型推到前向声明、PIMPL 或狭窄回调接口之后。
// BETTER: public header exposes only the library's own vocabulary
#pragma once
#include <string_view>
#include <expected>
#include <memory>
namespace mylib {
enum class parse_error { invalid_syntax, resource_limit_exceeded };
struct diagnostic_event {
std::string_view message;
std::size_t line;
};
using diagnostic_sink = std::function<void(diagnostic_event const&)>;
class document_parser {
public:
struct options {
std::size_t max_bytes = 1 << 20;
diagnostic_sink on_diagnostic = {}; // optional, no spdlog dependency
};
explicit document_parser(options opts = {});
~document_parser();
document_parser(document_parser&&) noexcept;
document_parser& operator=(document_parser&&) noexcept;
[[nodiscard]] auto parse(std::string_view input)
-> std::expected<document, parse_error>;
private:
struct impl;
std::unique_ptr<impl> impl_; // Boost, spdlog, FSM all hidden here
};
} // namespace mylib
// Callers include only standard headers. Internal deps are invisible.
// Changing from spdlog to another logger requires zero caller changes.
错误:糟糕的错误报告
如果库用裸整数、裸 std::string 消息或平台特有的异常类型来报告错误,调用者就只能从实现细节里反推失败语义。最终结果是脆弱的错误处理,库内部一改,调用者的处理逻辑就跟着崩。
// BAD: error reporting through mixed, unstable channels
auto parse(std::string_view input) -> document {
if (input.empty())
throw std::runtime_error("empty input"); // string-based
if (input.size() > max_size)
return {}; // default-constructed "null" document — is this an error?
if (!validate_header(input))
throw parser_exception(ERR_INVALID_HEADER); // internal enum leaked
// caller must catch two exception types AND check for empty documents
}
// BETTER: single, stable error channel with actionable categories
[[nodiscard]] auto parse(std::string_view input)
-> std::expected<document, parse_error>
{
if (input.empty())
return std::unexpected(parse_error::invalid_syntax);
if (input.size() > max_size)
return std::unexpected(parse_error::resource_limit_exceeded);
if (!validate_header(input))
return std::unexpected(parse_error::invalid_syntax);
// one return type, one error vocabulary, no exceptions for routine failures
}
如果需要比错误类别更丰富的诊断信息,应通过独立通道提供(诊断 sink、错误详情访问器或结构化日志),而不是把调用者无法程序化处理的实现细节硬塞进主错误类型。
版本与 ABI 需要策略,不能靠乐观
哪怕只是内部共享库,把版本管理当作设计的一部分也远比当作发布流程的一道手续有价值。真正要回答的问题是:库向调用者承诺了哪些变更可以安全承受。源码兼容、ABI 兼容、线协议稳定、序列化数据稳定、语义兼容,这些概念彼此相关但并不等价。
对大多数 C++ 库而言,最诚实也最可行的策略是:在一个主版本内保证源码兼容,不对跨任意工具链的 ABI 做笼统承诺。这种姿态看似保守,实际上比口头宣称 ABI 稳定、公开表面却到处是标准库类型、内联密集模板和平台相关布局要靠谱得多。
如果 ABI 稳定性确实重要,整个设计就得跟着变。这通常意味着更窄的导出面、opaque 类型、PIMPL 式的边界、更严格的异常策略、更少的模板暴露,以及对编译器和标准库版本的明确约束。这些是影响整个 API 形态的基础性决策,不是收尾修饰。
错误:通过内联变更破坏 ABI
源码层面看起来完全安全的改动,可能悄无声息地破坏二进制兼容性。给类加一个成员、改一个内联函数的默认参数值、调整字段顺序,这些都会改变 ABI,编译器却不会发出任何警告。
// v1.0 — shipped as shared library
struct document {
std::pmr::vector<entry> entries;
// sizeof(document) == N, known to callers at compile time
};
// v1.1 — "just added a field"
struct document {
std::pmr::vector<entry> entries;
std::optional<metadata> meta; // sizeof(document) changed
// callers compiled against v1.0 still assume size N
// stack allocations, memcpy, placement new — all wrong
};
对 ABI 稳定库来说,修正方式是把布局藏在 PIMPL 边界之后,让调用者永远不要依赖 sizeof 或字段偏移。
// ABI-stable public header
class document {
public:
document();
~document();
document(document&&) noexcept;
document& operator=(document&&) noexcept;
[[nodiscard]] auto entries() const -> std::span<entry const>;
[[nodiscard]] auto metadata() const -> std::optional<metadata_view>;
private:
struct impl;
std::unique_ptr<impl> impl_;
};
// In the .cpp file (not visible to callers):
struct document::impl {
std::pmr::vector<entry> entries;
std::optional<metadata> meta;
// add fields freely — callers see only the pointer
};
PIMPL 会给每个对象多带来一次堆分配和一层间接访问。对于创建不频繁的类型(文档、连接、会话),这几乎总是可以接受的代价。但热路径上每秒创建数百万次的类型就不行了,这时候该重新考虑的是这类类型是否真的需要 ABI 稳定。
版本化模式:用 inline namespace 做源码版本化
当库必须同时支持多个 API 版本(例如迁移期间)时,inline namespace 可以在不强迫调用者改代码的前提下,对符号进行版本化。
namespace mylib {
inline namespace v2 {
struct document { /* v2 layout */ };
auto parse_document(std::string_view input) -> std::expected<document, parse_error>;
}
namespace v1 {
struct document { /* v1 layout, kept for compatibility */ };
auto parse_document(std::string_view input) -> std::expected<document, parse_error>;
}
}
// Callers using `mylib::document` get v2 by default.
// Callers that need v1 use `mylib::v1::document` explicitly.
// Linker symbols are distinct, so v1 and v2 can coexist in one binary.
模块能改善构建结构和分发流程,但抹不掉 ABI 层面的现实。concept 能改善诊断和约束表达,但不会自动让一个模板密集的库变得易于版本管理。这些工具应被视为局部改进手段,而非策略层面的替代方案。
文档应回答集成问题
面向有经验程序员的库文档,重点应放在帮助对方做出采用决策,而非罗列功能。正在评估一个可复用库的调用者需要知道的是:
- 库解决什么问题,明确不管什么。
- 哪些输入是借用的,哪些输出自带存储。
- 哪些失败属于常规情况,报告方式是什么。
- 正常使用时会产生哪些分配、复制或后台开销。
- 有没有线程安全保证,如果有,保证到什么程度。
- 版本和兼容性方面,哪些承诺是真的。
简短、看起来像生产代码的示例在这里最有用,尤其是同时展示了错误处理路径和配置边界的示例。大而全的教程式演练通常帮助不大。文档的目的是让另一个团队不用打听内部”口耳相传”的知识也能顺利集成。
性能方面的说法同样需要克制。不要笼统地说库”很快”,而要讲清楚:测了什么场景、在什么负载下、跟什么基线对比、成本模型对哪些因素敏感。解析库的性能往往与分配策略、输入大小分布和失败率密切相关,这些应当直说。
验证应当匹配库的公开承诺
可复用库需要比末端应用组件更严格的验证纪律,因为调用者没办法审查你的全部假设。测试应当直接对应公开承诺:
承诺对某个文档化的格式版本保持稳定解析行为?那就保留基于 fixture 的契约测试。承诺畸形输入只会返回结构化错误而不触发未定义行为?那就在公开解析入口上跑 fuzzing 和 sanitizer。宣称在特定模式下分配量有上界?那就用 benchmark 或埋点来验证。暴露了宿主侧的诊断通道?那就测试 sink 收到的确实是稳定的事件类别,而不是实现层的噪音。
兼容性检查同样属于这个环节。承诺次版本间源码兼容,就要有集成测试或样例客户端来覆盖旧的调用模式。ABI 很重要,就要用能真正检验符号和布局预期的方式来测试产物。”在我机器上还能编译”不是兼容性策略。
知道什么时候还不该做成库
团队经常过早地把代码做成共享库。如果只有一个应用在用,领域词汇每周都在变,或者所谓的”复用”更多只是组织层面的美好愿望,强行冻结出一个稳定的公开接口往往只会把错误的假设固化下来。有时正确的做法是在契约真正稳定之前先把组件留在内部。
这不是反对复用,而是要求认真计算公开 API 的真实成本。其他团队一旦依赖上这个库,修改语义、所有权或错误策略的代价就远高于改内部代码。只有当问题本身和契约都已成熟到值得承担这笔代价时,复用才有意义。
要点总结
好的可复用 C++23 库只做一个精确的承诺,在公开类型中直接编码所有权与不变量,保持失败形态稳定,只开放少量经过设计的定制接缝,如实陈述版本与性能方面的承诺。它应尽量减少依赖拖拽,让有经验的调用者能轻松判断是否采用。
这些权衡并不陌生,只是在库的语境下要付得更明确。更丰富的签名可能不够优雅;稳定的错误类别需要在边界做转换;面向 ABI 稳定的设计会限制公开模板的自由度;窄接缝要求你有纪律地决定哪些东西不开放定制。这些成本可以接受,因为库是一份长期契约,不只是一堆可复用的代码。
复习问题:
- 这个库做出的核心承诺是什么?它明确拒绝承担哪些职责?
- 哪些公开类型在不依赖隐藏假设的前提下,清晰地传达了所有权、生命周期和不变量?
- 公开的错误类别是否足够精简、足够稳定,且能让调用者据此采取行动?
- 哪些定制接缝确实必要?哪些依赖应当推到适配器后面,而不是出现在公开头文件中?
- 实际做出的兼容性承诺到底是什么——源码兼容、ABI 兼容、序列化格式稳定、语义行为不变,还是其中几项的组合?
现代 C++ 代码评审检查清单
C++ 代码评审中的大多数失误,根源不在能力,而在评审方式。评审者机械地顺着 diff 读下去,对命名或格式提几句意见,也许能发现一个局部 bug,却漏掉了这次改动真正引入的系统级问题:跨线程的新所有权关系、热路径上的隐藏分配、异常边界泄漏、被悄悄放宽的 API 契约、没有准入控制的队列、再也无法覆盖风险路径的 sanitizer lane。
现代 C++ 让许多高成本决策变得可见,但前提是评审者问对了问题。作为本书的收官章节,这里要做的是把前面的内容收束成一套实用评审流程。它不是附录检查表的替代品,也不是风格指南,而是一组问题,用来引导有经验的评审者在审查生产级 C++ 变更时该怎么读代码。
核心思想很简单:先审失败代价,再审局部优雅。一行看起来整洁的代码仍然可能延长生命周期、削弱不变量或抬高运维成本。下面的检查清单围绕这些失败最常潜伏之处来组织。
第一遍:先识别这次改动真正属于哪一类
逐行阅读之前,先给改动分类。分类错了,后面问的问题就全跑偏了。
这次改动主要是:
- 一条新的所有权或生命周期路径?
- 一次 API 或契约变更?
- 一次并发或取消语义变更?
- 一次与数据布局或性能敏感路径相关的变更?
- 一次工具链、验证或构建流水线变更?
- 一次带有运维后果的服务行为变更?
很多 pull request 同时涉及多类变化,但通常有一类占主导地位,先从那里入手。新增了后台队列的改动本质上就不是重构。函数返回值改成 std::string_view 就不只是微优化。库开始在公开头文件中暴露模板化回调类型,就不只是”方便了一点”。评审应当先围绕主导风险展开。
分类的同时也就确定了应该期待什么证据。API 变更应附带契约与兼容性方面的论证;并发变更应附带取消与关闭行为的证据;性能声明应附带实测数据;工具链变更应说明它让哪类 bug 更容易或更难被发现。
所有权与生命周期问题
在生产级 C++ 中,所有权审查仍然是回报最高的一遍,生命周期 bug 天生擅长伪装成”局部看起来无害”。这些问题应当尽早问。
每个新资源由谁拥有?所有权在哪里终止?如果回答这些问题需要翻五个文件、再猜测框架行为,那设计本身就已经有问题了。所有权通常应当能从类型、对象图和构造点直接看出来。
改动是否引入了跨时间的借用?即把 std::string_view、std::span、迭代器、引用或范围视图存进了可能比源对象、请求、栈帧或容器 epoch 活得更久的状态。代码一旦跨越异步边界、队列、回调、协程挂起点或脱离控制的线程,就必须重新严格审查每一种借用类型。
改动是否只是图方便,就把清晰所有权换成了共享所有权?std::shared_ptr 有时确实是正确工具,但更多时候只是把设计决策往后拖。评审者应该追问:这里的共享所有权到底解决了什么具体的生命周期问题?换成 moved value、带所有权的工作项或显式的父级所有者,是不是更容易推理?
move 和 copy 的成本变化是刻意为之,还是无心之过?值语义固然强大,但评审者仍应甄别:新增的复制到底是契约的一部分,还是接口设计附带的意外开销?
这个领域的评审意见必须具体。”这看起来有风险”太弱了;”这个队列现在存的是来自请求局部存储的 std::string_view,排队中的工作可能比缓冲区活得更久”才够有力。
评审者应当标出的内容:悬空引用
悬空引用是评审者最值得抓住的一类 bug。类型系统对它无能为力,而且它往往能一路安然通过测试,直到竞态条件或重分配才把问题暴露出来。
// FLAG THIS: dangling reference from temporary
auto& config = get_configs()["database"];
// if get_configs() returns by value, the temporary map is destroyed
// at the semicolon. config is now a dangling reference.
// fix: auto config = get_configs()["database"]; (copy the value)
// FLAG THIS: reference into a vector that may reallocate
auto& first = items.front();
items.push_back(new_item); // may reallocate, invalidating first
use(first); // undefined behavior
// FLAG THIS: string_view outliving its source
std::string_view name = get_user().name();
// if get_user() returns by value, the std::string inside the
// temporary is destroyed. name now points to freed memory.
// fix: std::string name = std::string{get_user().name()};
// FLAG THIS: lambda capturing reference to local
auto make_callback(request& req) {
auto& headers = req.headers(); // reference to req's member
return [&headers]() { // captures by reference
log(headers); // dangling if req is destroyed
};
// fix: capture by value, or capture a copy of headers
}
评审者要问的始终是同一个问题:被引用的对象,是否可以被证明在每次使用时都仍然存活?如果回答这个问题需要推导框架调度、队列时序或调用者的行为规范,那代码就应该复制,而不是借用。
评审者应当标出的内容:move 操作缺少 noexcept
未标记 noexcept 的 move 构造函数或 move 赋值运算符会悄然拖慢标准库容器的性能。一旦 move 构造可能抛异常,std::vector 在重分配时就会退回到 copy 而非 move,因为强异常保证要求如此。
// FLAG THIS: move constructor without noexcept
class connection {
std::unique_ptr<socket> sock_;
std::string endpoint_;
public:
connection(connection&& other) // missing noexcept!
: sock_(std::move(other.sock_))
, endpoint_(std::move(other.endpoint_))
{}
// std::vector<connection> will COPY during reallocation
// instead of moving. For large vectors, this is a silent
// performance cliff — and may fail to compile if the type
// is move-only.
};
// CORRECT:
connection(connection&& other) noexcept
: sock_(std::move(other.sock_))
, endpoint_(std::move(other.endpoint_))
{}
// now vector::push_back uses move during reallocation
move 赋值同理。std::move_if_noexcept 和容器实现都会在编译期检查 noexcept。只要任一 move 操作可能抛异常,回退路径的开销就一定更大。
评审者应当标出的内容:异常不安全的资源获取
在缺少 RAII 保护的前提下连续获取多个资源,且获取动作之间夹杂着可能抛异常的操作,这就是资源泄漏最爱潜伏的地方。
// FLAG THIS: raw acquire/release with throwing code between
void setup_pipeline(config const& cfg) {
auto* buf = allocate_buffer(cfg.buffer_size); // raw allocation
auto fd = open_file(cfg.path); // may throw
auto conn = connect_to_db(cfg.db_url); // may throw
register_pipeline(buf, fd, conn);
// if open_file throws, buf leaks
// if connect_to_db throws, buf leaks AND fd leaks
}
// CORRECT: RAII from the first acquisition
void setup_pipeline(config const& cfg) {
auto buf = std::unique_ptr<std::byte[]>(
allocate_buffer(cfg.buffer_size));
auto fd = owned_fd{open_file(cfg.path)}; // RAII wrapper
auto conn = connect_to_db(cfg.db_url); // already RAII (or should be)
register_pipeline(buf.release(), fd.release(), std::move(conn));
// every intermediate throw is safe — destructors clean up
}
要盯住的模式是:资源获取和 RAII 接管之间的任何空档。哪怕中间只有一行可能抛异常的代码,就足以造成泄漏。评审者还应标出把 new 直接写在函数参数里的情况,如果另一个参数求值时抛了异常,智能指针还没来得及构造,这次分配就泄漏了。
不变量与失败边界问题
下一遍审查的重点是无效状态与失败形态。这个改动是否让无效状态更容易出现、更容易暴露给外部,或更难恢复?构造路径、配置对象、部分初始化和 mutation API,都是不变量容易被悄悄削弱的地方。
接着问:失败是怎么上报的?可恢复的业务错误是否仍然用统一方式表达,还是新增了第二条错误通道?之前被收口在底层的依赖错误,现在是否泄漏到了更上层?标了 [[nodiscard]] 或返回 std::expected 的函数,是否新增了忽略返回值的调用点?如果涉及异常,改动是否无意中扩大了异常边界?
评审者还应检查回滚与清理行为,尤其是涉及资源所有权的操作。如果新路径中途失败了,哪些状态仍然成立?部分写入的文件有没有删掉?事务有没有取消?临时状态有没有丢弃?后台工作有没有停下来?遥测指标有没有归到正确的分类里?
一个有效的做法是要求作者用一句话把失败场景讲清楚。比如:”如果依赖 X 在状态 Y 已预留后超时,请求返回 dependency_timeout,预留随即释放,且没有后台重试能存活到关闭流程之后。”如果作者说不清这句话,失败边界多半还不够明确。
接口与库表面问题
任何公开接口或广泛共享的接口都值得单独审一遍。局部实现质量再高,也弥补不了契约本身的薄弱。
参数和返回类型是变得更诚实了,还是更含糊了?返回 std::span<const std::byte> 也许能清晰表达借用语义,但返回内部可变状态的引用,就可能在暗中引入耦合。对只读的解析调用来说,接受 std::string_view 可能是对的;但如果对象会保留这个字符串,那就可能埋下隐患。评审的焦点在于:函数签名现在对所有权、成本与失败做出了什么样的承诺。
如果新增了模板、concept、回调或类型擦除,为什么选这种形式?concept 能改善诊断信息并阻止无意义的实例化,但也会扩大编译期表面积。类型擦除能稳定调用点,但可能带来额外的分配或间接调用开销。新引入的泛型机制必须值回票价。
对库变更,还要追问公开表面是否泄漏了实现细节。新头文件是否暴露了调用者本不该知道的传输类型、分配策略、同步原语或错误类型?一个看似无害的 inline helper,有没有改变 ABI 或源码兼容性?文档和示例有没有跟着契约一起更新,还是说新行为只有读 diff 才知道?
审好接口,就是要站在下一个调用者的角度思考,而不是站在当前作者的角度。
评审者应当标出的内容:API 中的隐式转换与窄化
如果公开接口会静默接受错误类型或窄化数值,bug 就很容易埋进去——单元测试里未必暴露,往往要等到生产数据才出问题。
// FLAG THIS: implicit conversion hides a bug
class rate_limiter {
public:
rate_limiter(int max_requests, int window_seconds);
};
// caller writes:
rate_limiter limiter(30, 60); // OK: 30 requests per 60 seconds
rate_limiter limiter(60, 30); // compiles fine, but the arguments
// are swapped — 60 req per 30s
// no type safety distinguishes max_requests from window_seconds
// BETTER: use distinct types or a builder
struct max_requests { int value; };
struct window_seconds { int value; };
rate_limiter(max_requests max, window_seconds window);
// rate_limiter limiter(window_seconds{60}, max_requests{30}); // compile error
// FLAG THIS: narrowing conversion in initialization
void set_buffer_size(std::size_t bytes);
int user_input = get_config_value("buffer_size"); // may be negative
set_buffer_size(user_input); // silent narrowing: -1 becomes SIZE_MAX
// fix: validate before conversion, or use std::size_t throughout
并发、时间与关闭问题
并发评审本质上是加了”时间”维度的生命周期评审。关键不在于代码用了哪些并发原语,而在于工作在流转过程中所有权是否始终明确、规模是否始终可控。
要问的是:改动有没有引入脱管的工作、隐藏线程、执行器跳转,或归属不明的协程挂起点?停止请求怎么传播?超时和重试是显式设定的,还是藏在辅助层里?队列增长有没有上限,过载了怎么处理?
如果改动涉及锁或共享状态,就要把争用和不变量放在一起审查。锁保护的是完整的不变量,还是只管了几个字段?有没有在持锁时调用回调?统计更新或缓存更新有没有引入一场日后会被解释成”良性”的数据竞争?ThreadSanitizer 也许能捕获其中一部分,但评审仍应在运行之前尽量消除这类歧义。
几乎所有服务或工具的改动都值得单独追问关闭流程:打完这个 diff 之后,当析构开始时还有哪些工作可能仍在运行?它们怎么停下来?如果答案不清楚,评审就还没结束。
数据布局与成本模型问题
很多性能 bug 披着”无害抽象”的外衣混进代码库。评审者该问的是成本转移到了哪里,而不只是代码”看起来是不是高效”。
改动有没有在热路径上新增分配、增大容器中对象的体积、加深间接层次,或把局部值变成堆上的共享状态?ranges 管道的改写是在保住生命周期的前提下提升了可读性,还是暗中引入了隐藏迭代、临时物化或悬空视图的风险?容器选型的变化是否改变了内存和迭代器失效模型,而作者却没有提及?
评审标准是:声明多大,证据就应该多充分。宣称提升了性能,就要拿出 benchmark 或 profile 数据。说新增分配可以忽略不计,就要说清楚在什么负载下可以忽略。如果答案只是”应该没事”,那就得判断这段代码所在位置是否真的容得下”应该”二字。
不是每个改动都需要 benchmark,但性能敏感的改动必须有一个经得住基本追问的成本模型。
验证与交付问题
好的 C++ 评审不止于源码 diff。最后一遍要确认:仓库是否仍然有可信的手段来证明这次改动是可靠的。
哪些测试覆盖了风险路径?如果改动新增了回滚分支、过载行为或宿主-库边界交互,有没有测试在刻意触发它?如果改动影响了内存、并发或输入处理,sanitizer 或 fuzzing lane 还能覆盖到吗?如果构建或 CI 配置变了,诊断矩阵是变强了,还是变弱了?
运维层面的变化也需要可观测性评审。服务现在更早拒绝请求了,运维人员能把它和依赖故障区分开吗?库新增了诊断信息,这些信息是否足够稳定,能让宿主程序放心使用?崩溃处理或符号处理变了,交付出去的产物以后还能不能正常诊断?
这也是评审者该直接要求补充证据、而不是自己去翻历史代码的时候。评审不是义务考古。如果某个改动缺少必要的测试、benchmark、sanitizer 运行结果或迁移说明,就应当明确提出来。
如何写出有用的评审意见
好的评审意见会指出风险所在、点明被违反或不清楚的契约,并说明需要什么证据才能消除疑虑。它不是在表达个人口味。
强评审意见通常长这样:
- “这个回调捕获了
this,而它又被存进了可能活过关闭流程的工作里。request_stop()之后,这个生命周期由谁拥有?” - “公开 API 现在返回的是指向解析器拥有存储的
std::string_view。这块存储在什么地方被保证活得比调用者的使用更久?” - “这个队列是有界的,但过载行为仍然是隐式的。我们是拒绝、阻塞,还是丢弃可选工作?运维上又如何体现?”
- “改动声称降低了延迟。哪次 benchmark 或 profile 运行证明新的分配模式在现实输入规模下更好?”
弱评审意见往往含混笼统、纠结于风格,或者明明问题出在语义层面,却包装成个人偏好。
评审者也应该在证据充分时明确表态。如果所有权清楚、测试覆盖了风险路径、契约确实变得更好了,就应当说出来。好的评审不只是为了拦住有问题的变更,也是为了让”为什么可以接受”变成一个显式的结论。
什么时候应当阻止变更
不是每个未解决的问题都值得强行拦下,但有些必须拦。
以下情况应该阻止变更:所有权不清楚;借用的状态可能比来源活得更久;失败契约前后不一致;并发无界或关闭语义未定义;公开接口变更缺少兼容性论证;性能声明缺乏证据;验证体系已经覆盖不到风险路径。
不要仅仅因为”换作是我会写成另一个样子”就拦住改动。现代 C++ 本身的偶然复杂度已经够高了,评审不应再叠加一层口味驱动的阻力。
要点总结
高效的 C++ 评审者会先审查所有权、失败形态、接口诚实性、并发生命周期、成本流向和验证证据,然后才看局部优雅。他们先给改动分类,围绕生产风险提问,在仅靠代码本身撑不起结论的地方坚持索要证据。
这种审查态度才能把本书的内容转化为日常工程实践。精确的所有权模型、显式的失败边界、有界并发、诚实的 API 和严格的诊断纪律之所以重要,不是因为它们”看起来现代”,而是因为它们让评审者能用具体的语言说清楚:这次改动为什么安全、为什么有风险,或者为什么还不完整。
复习问题:
- 这次改动引入的主导性生产风险是什么,评审是否先聚焦在那里?
- 哪些所有权、生命周期或借用假设现在跨越了时间、线程或 API 边界?
- 这次改动如何改变了失败报告、回滚保证或不变量保持方式?
- 哪个成本模型发生了变化,又有什么证据支持任何性能或效率声明?
- 现在由哪些测试、sanitizer lane、诊断或运维信号,来证明风险行为仍然可靠?
附录:术语对照表与翻译约定
本附录统一简体中文译稿的术语、语气与表达方式。所有章节均应遵守这里的约定,保证译文在技术含义、措辞风格和读者体验上一致。
翻译约定
- 面向有经验的程序员,语气直接、克制、技术化,不加鼓励式口吻或教程式铺垫。
- 保留设计压力、约束、权衡、失败模式和工程后果,不把原文改写成松散的概念介绍。
- 代码、类型名、库名、标准术语、文件名、链接路径和 Markdown 结构保持原样;只翻译读者可见的自然语言内容。
- 中文技术语境中已有稳定译法的术语,采用常见且精确的译名;容易歧义的术语,首次出现时使用本表定义的固定译法。
- 涉及风险、边界或审查判断时,表述简洁、确定,不弱化原文判断力度。
- 以”信”和”达”为先:先确保语义准确,再追求行文自然。
核心术语对照
| 英文 | 统一译法 | 说明 |
|---|---|---|
| ownership | 所有权 | 对资源有效性与释放负责。 |
| borrowing | 借用 | 不接管生命周期的使用。 |
| lifetime | 生命周期 | 统一译为“生命周期”,避免与“作用域”混用。 |
| contract | 契约 | 类型或函数在边界上的承诺。 |
| invariant | 不变量 | 保持数学与工程语义一致。 |
| value semantics | 值语义 | 标准译法。 |
| identity | 身份 | 当对象“作为其自身”重要时使用。 |
| failure boundary | 失败边界 | 不译为”故障边界”,贴近软件错误处理语境。 |
| boundary translation | 边界转换 | 在边界处转换失败表示。 |
| undefined behavior | 未定义行为 | 缩写仍可写作 UB。 |
| reviewable | 可评审 | 足够清晰,便于审查者推理。 |
| structured concurrency | 结构化并发 | 采用并发领域常用译法。 |
| cancellation | 取消 | 主动停止无效工作的请求。 |
| suspension boundary | 挂起边界 | 保留与协程语义的一致性。 |
| contention | 争用 | 用于锁、核心时间、缓存行等竞争。 |
| backpressure | 背压 | 采用业界常用译法。 |
| throughput | 吞吐量 | 标准译法。 |
| locality | 局部性 | 硬件访问局部性。 |
| cost model | 成本模型 | 时间、内存、同步等具体成本来源。 |
| hot path | 热路径 | 标准译法。 |
| vocabulary type | 词汇类型 | 用于表达边界契约的标准类型。 |
| observability | 可观测性 | 标准译法。 |
| diagnostic build | 诊断构建 | 以发现和解释问题为目标的构建配置。 |
| type erasure | 类型擦除 | 标准译法。 |
| call site | 调用点 | 根据上下文可写作“调用处”,默认使用“调用点”。 |
| shutdown | 关闭 | 系统或组件进入停止过程。 |
| tradeoff | 权衡 | 统一不用“折中”。 |
| failure mode | 失败模式 | 设计在什么情况下出错。 |
| ownership transfer | 所有权转移 | 与 move 语义配套使用。 |
| shared state | 共享状态 | 并发章节统一译法。 |
| tooling | 工具链 / 工具 | 结合上下文判断,涉及构建与诊断体系时优先“工具链”。 |
章标题固定译名
为避免目录、页内标题与交叉引用不一致,章标题统一采用 SUMMARY.md 中的译名。