| title | 为什么 C++ 包管理这么难? | |||||
|---|---|---|---|---|---|---|
| description | 从下载、ABI、构建系统碎片化到 modules——讲透为什么 C++ 至今没有统一顺手的包管理器 | |||||
| chapter | 1 | |||||
| order | 1 | |||||
| tags |
|
|||||
| difficulty | intermediate | |||||
| platform | host | |||||
| reading_time_minutes | 12 |
我们随便挑一个稍微像样的 C++ 项目,最先撞上的十有八九不是语法,也不是性能,而是——“Shift! 怎么依赖跑不起来。编译又炸了,运行怎么起手崩溃了?!”
在 Python 里,pip install 一把梭;在 Node 里,npm install 等进度条跑完;到了 Go,go get 加一句 go mod tidy,干净利落;Rust 更不用说,Cargo 几乎就是 Rust 体验本身,你写 cargo add 的时候甚至感觉不到"包管理"这件事的存在。
可一到 C++,画风就变了。于是几乎所有写 C++ 的人,迟早都会问出同一个问题:为什么都 2026 年了,C++ 还没有一个像 Cargo、npm、pip 那样统一、顺手、开箱即用的包管理器?
这看着像个工具问题,但你真往深里挖就会发现,它其实是 C++ 这门语言几十年工程现实攒下来的一笔账——而且是要你一次性结清的那种。
很多人一听"包管理",第一反应都是"把依赖下载下来嘛"。这个解决方案挺好,但是很遗憾,笔者写C++,从稍微小的项目到相对较大的项目而言,发现下载反而是整条链路里最不费劲的一环。真正的麻烦从下载完那一刻才刚刚开始:
- 我去,编译炸了,编译器编译不了这个老古董!
- 链接怎么又找不到符号了???
- 运行的时候怎么有崩溃了啊!
- 完蛋了怎么项目说要升级依赖了???怎么一升级编译器又炸了?
我可能让您回忆起来一些不太友好的记忆了。我们回归正题——在别的语言里,一个包大致可以堪称:名称(这个包叫什么名字,就像我叫CharlieChen114514,我下的包叫fmt),版本号(这个库是什么时候通过了测试和审查,是那个阶段认为较为安全的软件里程碑)和一坨至少看起来能直接用的代码(源码 / 字节码 / 模块)
C++呢?很不幸,他所应用的场景,往往至少但是不限于面对这些场景——除开我上面谈到的,还要有:
- 您老啥构建系统啊,我是CMake哈哈,握握手握握双手。。。啥?你是autotools? 你是Make仙人?你是meson那边的???
- 什么叫我用的 gcc 16.1 但是你分发的二进制是gcc 4编译的?
- 什么叫两边用的标准库不一样导致符号找不到?ABI不一致把我程序干炸了?
- 什么叫你的分发的库居然是Debug库
- 什么叫目标平台跟我的不一样?
- 什么你当时开发默认依赖的ubuntu 14.04??
看到了嘛,C++因为本身就在这里,导致他天然必须直接面对这类问题:同一个 fmt、boost、openssl、protobuf,换个环境就可能是完全不同的形态:编译器是 GCC、Clang 还是 MSVC?标准库链接的是 libstdc++、libc++ 还是 MSVC STL?Debug 还是 Release?静态库还是动态库?目标架构是 x86_64、arm64 还是 armhf?跑在 Windows、Linux、macOS 还是某个裁过的嵌入式 Linux?异常和 RTTI 开没开?用的是 C++ 哪个标准?系统里那版 OpenSSL 又是几?——这些问题里随便挑一个答错,依赖就接不上, 比起来笔者还真祈祷他编译和链接报错,而不是上线了送我两个大逼斗运行时崩溃(血的教训。。。
所以 C++ 的"装包"从来不是一句 install package 能了事的。笔者认为,他至少要能处理这个更加棘手的问题:在当前这套工具链、这个平台、这组编译选项、这种链接模型下,从源码或预编译产物里,重新拼出一个能被你这个具体工程消费的依赖。
我相信你头大了,这也是笔者认为C++ 真正意义上的,好用的包管理非常难以诞生的原因。
凭什么 Python、JS、Go 用着就这么顺? 笔者自己没写过go,这些更多是道听途说,如果我在胡说八道,欢迎批评指正。
拿 Python 说,纯 Python 的包基本就是一堆 .py 文件,解释器负责执行,包和包之间交换的是运行时对象,不是 native 的二进制布局。所以你 pip install requests、flask、pytest 的时候,丝滑得像没事人一样。但只要一只脚迈进 native extension,画风立刻就变了——numpy、scipy、opencv-python、pytorch 这些,马上就是 Python 版本、平台、CPU 架构、系统库、CUDA、ABI 一大堆。那 Python 生态是怎么扛下来的?靠 wheel:那些 native 的复杂度,被人提前打包成了覆盖各种平台矩阵的预编译二进制。你看到的是一句轻飘飘的 pip install numpy,背后其实是一群人替你把脏活全干完了。
JavaScript / TypeScript 是同一个故事。绝大多数 npm 包就是 JS 文件,跑在 Node.js 或浏览器运行时里,包之间交换的是 JS 对象,不是 C++ 对象的内存布局。可一旦碰到 Node 的 native addon——sharp、sqlite3、canvas 这种——立刻又是 Node ABI、预编译包、本机编译、系统库一堆事儿。换句话说,JS 也不是没有这些坑,只是普通 npm 包大多压根碰不到 native 这条边界。
Go 走的是另一条路(PS下,问的是别人 + LLM补充,谨慎!谨慎参考!)。它舒服,是因为有一套统一的官方工具链:Go 模块通常以源码形式进入同一个 Go 构建体系,然后由 Go 工具链统一编译、链接。这就直接绕开了 C++ 世界里那一堆拷问——这个库是 CMake 还是 Meson?是 GCC 还是 Clang?链接 libstdc++ 还是 libc++?编译选项怎么传过去?find_package 到底找不找得到?但 Go 一旦用上 cgo,真刀真枪地碰进 C/C++ 世界,系统库、ABI、交叉编译、链接参数这些问题又全部杀回来了。
读到这里,我想我们可以确认了:其他语言不是消灭了 native 这堆烂账,而是大多数普通包压根不用走到 native 那条边界上。而 C++ 没有这条边界可以躲——它从第一天起,就一脚踏在 native 里。
笔者对ABI的理解比较粗浅,我大致认为:ABI 是二进制之间互相调用时,必须共同遵守的一套暗号。
API 停留在源码层面, 他长这样,很和蔼可亲:
std::string get_name();而 ABI 是它被编译成二进制之后,调用双方必须心照神会的整套规矩:
- 大哥,你的mangling出来的符号名是啥啊?
- 你的参数是塞到哪里去了?怎么塞的?
- 返回值赛那里去了?
- 用到的标准库对象,你们双方是不是字节排列一致的?
- 虚函数,你们怎么实现的?
- 异常怎么从一个库跨边界抛到另一个库?
- Debug 编译出来的和 Release 编译出来的能不能混着链?
- 大家到底都链接的是哪个 C++ 标准库? 你是哪个libc++?
这套规矩里只要有一条对不上,你就会收获 C++ 世界里最让人抓狂的一类 bug:编译能过,链接挂了;链接过了,跑起来崩了;跑着看着不崩,内存却已经悄悄坏掉,等你某天排查一个莫名其妙的 crash,一路追到八百里之外才发现根因在这里——笔者的查出来是这类问题的时候,可谓是红光满面——红温完了!
别的语言当然也有 ABI,但通常不会让普通包依赖直接去面对它。Python、JS、Java、C# 靠解释器、虚拟机或统一运行时,把大多数包依赖维持在运行时对象或字节码层面;Go、Rust 则更多依赖统一工具链,把源码依赖拉进同一个构建过程里重新编译一遍。而 C++ 呢——它没有统一解释器,没有统一虚拟机,也没有统一工具链和统一构建系统,所以它天生就要把这套复杂度扛在自己肩上。
Rust 的 Cargo 好用,是因为 Rust 生态从语言、编译器、包管理、构建系统、crate registry 到版本解析,基本就是一套统一的、自洽的世界观。Go 也类似,官方工具链对工程结构、模块系统、构建流程有很强的控制力,说一不二。
但 C++ 完全是另一个剧本。
它没有官方包管理器,没有官方构建系统,没有统一 ABI,没有统一的标准库实现,没有统一的工程布局,也没有统一的二进制分发模型。一个库,可能是 header-only,也可能是静态库,还可能是动态库;它可能用 CMake,可能用 Autotools,可能用 Meson,也可能就是一坨手写的 Makefile;它可能靠 pkg-config 暴露自己,可能硬依赖系统里那版 OpenSSL,也可能干脆把 zlib 一起 vendoring 进自己仓库;它对外暴露的可能是规规矩矩的 C API,也可能是一堆带着 STL 类型、带着模板的 C++ API——而后者的 ABI 几乎注定不可移植。
这就是 C++ 包管理器接手的真实世界。它管理的不是一个干净、统一、新建起来的生态,而是一个把几十年里不同历史时期、不同平台哲学、不同构建系统、不同二进制约束的项目,硬塞进你当前工程环境的烂摊子。
讲到这里,你肯定会问:那 C++20 的 modules 呢?模块不是号称要终结头文件地狱、大幅提升编译速度、重塑依赖管理的范式吗?它能不能顺手把包管理这块也一起救了?
答案挺残酷的:modules 不但没有让包管理变简单,反而往这口锅里又添了一把柴。
原因在于,模块的依赖关系不再像头文件那样,靠预处理器的文本展开就能搞定。一个 import 到底依赖哪个模块接口、依赖顺序怎么排,必须由构建系统先去扫描每个翻译单元,搞清楚它导出了什么、又导入了什么,然后据此给所有编译任务排出一个正确的拓扑顺序。为了把这件事标准化,有了 P1689 这份提案:它规定了一种统一的模块依赖扫描格式,三大编译器(GCC、Clang、MSVC)各自不同程度地实现了它,CMake 到 3.28 才把这个能力稳定下来。
你看出来问题在哪了吗?头文件时代,C++ 的"依赖"至少还能假装是个文本问题;到了 modules 时代,它彻底成了一个构建系统必须真正理解的拓扑问题。而 C++ 偏偏又没有一个统一构建系统——每个构建系统对 modules 的支持程度、扫描方式、产物管理都不一样。原本就碎片化的构建生态,这下被顶到了台面上,躲都没处躲。所以 modules 是好事,但它救不了包管理。它只是把"构建系统必须懂依赖"这个 C++ 的老痛点,从隐性的变成了显性的、从软的变成了硬的。你必须面对他。
也正因为上面这一长串,所以 vcpkg、Conan、FetchContent、xmake、系统包管理器、vendoring 这些工具,真不是简单的"谁比谁先进"的关系——它们回答的根本是不同层级的问题。
FetchContent 在问的是:我能不能把这个依赖的源码直接拉进来,和我的项目一起编?
vcpkg 在问的是:我能不能把常见的 C++ 开源库自动拉取、打补丁、编出来,然后比较自然地接进 CMake 或 Visual Studio?它的哲学是简单直接,默认按 triplet把所有依赖统一成同一套静态/动态、同一套 CRT、同一种链接方式,省心,代价是灵活度被这块模板压住了。
Conan 在问的则是更严肃的事:我能不能在多平台、多编译器、多组构建选项的矩阵里,严肃地管理二进制包,甚至私有包?它允许你对每一个依赖单独指定 shared 还是 static、fPIC 开不开,再通过 settings 和 options 把 ABI 钉死——为灵活付出的代价,就是学习曲线和配置复杂度。
系统包管理器问的是另一回事:这个库能不能作为整个操作系统生态的一部分,被统一分发、统一升级、统一打安全补丁?而 vendoring 则是另一种心态的极致——我不信任任何外部环境,我把依赖源码死死钉在自己仓库里,从编译到维护,我自己负责到底。
所以你看, C++ 的包管理从来不是一道工具单选题,而是一道工程控制权怎么分配的题:这个依赖,你到底打算交给谁?交给系统发行版?交给 vcpkg 或 Conan?交给 FetchContent 拉源码自己编?交给 vendoring 固定在仓库里?交给 CI?还是干脆甩给板厂那套 SDK?每一个答案,都对应着完全不同的可控性、可复现性和长期维护成本。这才是 C++ 包管理背后真正要回答的问题。
如果上面这些你看着还觉得"好像也就那样",那就把它搬到嵌入式 Linux、BSP、交叉编译的场景里看看——复杂度能再翻几番。
因为在这里,问题已经不止是"装一个库"了。你还要跟交叉工具链、目标 CPU 架构、libc 版本、内核头文件、板厂 SDK、根文件系统、Buildroot 或 Yocto、体积裁剪、运行时依赖在不在板子上这些事死磕,最后还要能在那块板子上稳定复现、能调试。在这种场景里幻想一个 C++ 包管理器能包打天下,是不现实的。
更靠谱的做法是分层:系统级的依赖,交给发行版、Buildroot 或 Yocto;项目内部那些小型的 C++ 依赖,可以用 vendoring、FetchContent、Conan profile 或者手动固定源码来管。核心目标不是像 npm 那样丝滑,而是构建可控、可复现、可解释——在嵌入式这条赛道上,这三个词比"顺手"值钱得多。
兜兜转转说了一大圈,我们可以把开头那个问题收个尾了。
C++ 包管理难,难的不是把依赖下载下来,而是下载完之后,要把这个依赖变成适合当前工程的那个二进制形态。而 C++ 的依赖,总是不可避免地绑死在编译器、标准库、编译选项、目标平台、系统库、链接方式和 ABI 这一长串东西上。C++几乎没有统一过这些!
别的语言之所以看着舒服,是因为它们要么有统一运行时、要么有统一工具链,要么干脆把 native ABI 这摊事打包藏进了特殊通道里。而 C++ 偏偏没有统一构建系统、没有统一包管理器、没有统一 ABI、也没有统一工程结构,所以它的包管理器,注定要面对一个高度历史化、高度碎片化、又异常底层的世界——想要有好的包管理注定艰难啊!