返回专辑
·Johan·4 分钟阅读

头库与二进制库:模板库分发的取舍

头文件库 vs 编译库:迭代速度、编译时间、ABI 与模板暴露的权衡。

头库与二进制库:模板库分发的取舍

1. 改一行头,CI 三十分钟

机器人产品镜像 40 个 node 各编一份 heavy template,改一行 Eigen helper 触发 800 TU 重编——CI 30 分钟。分发形态是架构决策,不是 #include 图省事就行。

Eigen header-only + 自研算法 .so 插件是常见混合,但边界要清楚:模板库全进 header 方便迭代,二进制库方便 field 热修,两者混用时 API 面不能泄漏 template。分发形态一旦选定,全团队遵守同一套边界纪律,否则 header-only 与 binary 混用会变成两种 ABI 策略打架。上次 nlohmann::json 小版本升级,CI 从 8 分钟涨到 25 分钟,根因是没有 pin 版本。

2. header-only 的利弊

优点:无 link 步骤、模板全可见、优化 aggressive。缺点:编译时间、API 变更传播、难藏实现细节。Eigen、fmt 走 header-only 部署简单,但 consumer 全量重编。

C++17 inline constexpr 全局常数放 header 安全;非 inline 全局放 header 多 TU 链会 ODR 违规。header-only 工具库 review 时查有没有 static 变量藏在 .hpp。vcpkg/conan 包头-only 依赖升级时 consumer 全量重编——lockfile pin 版本,别 @latest 漂移到 breaking template 变更。

3. 显式实例化减编译

把模板全放 header,改一行 helper 触发 800 TU 重编——抽 template void filter<float>(...);.cpp 显式实例化,对外只留非模板声明。实例化集中在少数 TU,链接时间可降一个数量级,仍保留 header 算法可读性。

对外 .h 只声明非模板 facade,链接 libfoo.so。CMake INTERFACE library 适合 header-only 图;CMAKE_UNITY_BUILD 可救急但掩盖 ODR 问题。显式实例化是 header-only 与 binary 之间的折中——算法逻辑仍在 header 可读,但编译防火墙对 consumer 生效。

cpp
// filter.hpp
template<typename T> void filter(span<const T> in, span<T> out);
extern template void filter<float>(span<const float>, span<float>);

// filter.cpp
#include "filter.hpp"
template void filter<float>(span<const float>, span<float>);

4. 二进制库与 ABI 稳定

导出 C API 或 Pimpl C++ class;版本号 + symbol visibility。与 header-only Eigen 混用时,别让 Eigen 类型出现在公开头。升级 .so 不动 consumer .o,适合 field 热修。公开 API 禁止 template 暴露在 plugin 头——否则 ABI 不存在。

robot 镜像 vendored header-only 库锁 git submodule tag,升级跑 ABI smoke 即使 header-only 也可能改 inline 行为。ccache key 含 compiler+flags,升级 header-only 依赖后 cache 命中率会骤降——预期内,别因此跳过编译验证。

cpp
class SLAM_EXPORT Backend {
public:
  void process(const uint8_t* cloud, size_t nbytes);
private:
  struct Impl;
  std::unique_ptr<Impl> impl_;
};

5. 选择 heuristic

  • 仅模板、性能敏感 inner loop → header-only OK。
  • 大实现、要热插拔、要隐藏 IP → 二进制 + stable API。
  • 测试 mock → 虚接口或 link seam,别 template 全 inline 无法替换。

nlohmann::json 升级 3.11→3.12 触发 600+ TU 重编——pin version 在 cmake/Dependencies.cmake 单点声明。header-only 的「方便」是有代价的,代价在 CI 时间和升级风险。算法团队倾向 header-only 因为迭代快,运维团队倾向 binary 因为 field 热修——架构决策要平衡两者。

6. 验收

  • 改一行 template 头,CI 30min——考虑 split 或 extern template。
  • 插件与主程序 Eigen 版本不同——ODR/ABI 裂。
  • ninja -t compdb | jq length 对比 include 前后 TU 数。
  • 公开头新 include 第三方——问 Pimpl 或 PRIVATE 链接。

7. 案例:nlohmann::json 升级触发全量重编

nlohmann::json 升级 3.11→3.12 触发 600+ TU 重编,CI 从 8 分钟涨到 25 分钟——根因是 header-only 依赖没有 pin 版本,某 node 的 package.xml 写了 @latest。之后在 cmake/Dependencies.cmake 单点声明版本,升级走专门 PR 并预估 compile time 影响。header-only 的「方便」是有代价的,代价在 CI 时间和升级风险,团队必须意识到这一点。

8. 与 field 热修的取舍

模板显式实例化减 CI;field 热修走二进制库。算法团队发 .so 补丁不动主程序,前提是 API 面不含 template 和 STL 容器。分发形态是架构决策——不是 #include 图省事就行。

9. 度量:compile time 怎么算

改 header 前后跑 time ninja -j$(nproc) 对比 wall time,用 ninja -t deps libalgo.so | wc -l 看受影响 TU 数。若改一行 internal helper 触发 800 TU 重编,说明 API 面过大——该拆 Pimpl 或显式实例化了。ccache 命中率在 header-only 依赖升级后会骤降,这是预期内的,别因此跳过编译验证。每次 pin 版本升级走专门 PR,正文写清预估 compile time 影响和受影响 package 列表。分发形态是架构决策,一旦选定全团队遵守。模板显式实例化减 CI;field 热修走二进制库,两者边界纪律不能混。升级 header-only 依赖走专门 PR 并预估 compile time 影响。算法团队倾向 header-only,运维团队倾向 binary,架构决策要平衡两者。

← 全部文章

johan's blog