一个 C++ 工程需要四样东西同时成立:一份构建描述、一组依赖、一个新到足以编译这份 代码的编译器,以及一个能让产物真正运行起来的环境。C++ 没有任何一个工具同时负责 这四样,它们由四类互不隶属的工具分别承担,而把它们对齐是工程自己的工作。
CMake 是其中第一样的事实标准。事实标准陈述的是采用率,不是使用体验:CMake 不解析 依赖、不安装编译器、也不描述运行期环境,而这三件恰好是新参与者在第一天遇到的。 补齐它们意味着再引入一个包管理器、发行版的软件包,以及 README 里的一段环境说明 —— 三套新的模型,而它们之间的对齐仍然落在工程身上。
最经常失效的是环境这一层,因为只有它没有任何东西在检查。构建描述会在配置阶段 报错,包管理器会解析失败,而「机器上的库不是这份代码构建时所依据的那一份」在 链接之前不产生任何信号,有时到程序运行时才产生。
模块给这个结构再加一条约束。C++20 的模块改变的是一个翻译单元的代价:接口只声明
一次并被 import,而不是被每个需要它的文件从头文件里重新解析一遍;消费者看到的是
作者导出的东西,不是头文件恰好 include 进来的一切。代价是这个语言特性由构建系统
实现:构建要扫描源里的 import、据此给编译定序、缓存编译好的接口并正确地让它
失效,而且要在一个新到具备这个特性的编译器上完成这些。import std 再加一条:
标准库自己的模块必须先被构建出来,别的东西才能用它。
于是采用模块的工程需要上述四层全部成立,而不是其中三层。这个落差是具体的。在写 这一章的这台机器上:
$ g++ --version
g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0这个编译器编不了 import std。工程本身没有任何问题;是这台机器不是这个工程需要
的那台机器。
对人,代价是一次搭建,每台机器一次、每个新参与者一次:装一个更新的编译器、 确定哪些构建旗标把模块打开、取得依赖,然后在链接失败时判断坏的是这三者中的哪一 件。这个代价不随工程成熟而下降,它按人数和机器数重复。
对 agent,代价是上下文,并且在写下第一行代码之前就消耗在三处:读构建描述以 确定它究竟做了什么、重建它所假定的环境,以及顺着一个头文件穿过传递 include 去 确定究竟声明了什么。
模块消掉了第三处,因为接口是显式的,import 陈述了用到什么。mcpp 消掉另外两处,
并把第一处收敛为一条命令。
mcpp = 通用构建系统
+ 构建插件
+ 包管理
+ 工具链管理
+ 环境与运行时(xlings)
多数 C++ 工程要把这五个部分从不同工具里拼起来,而上面那份搭建代价正是拼装本身的 代价:构建文件假定机器上有某个编译器,包管理器假定一份不是它写的构建文件,而环境 是 README 里的一段话。
mcpp 是一个程序,五个部分共用同一套模型。
对于已经在用相应工具的读者:
| 组成部分 | 在 mcpp 中的形式 | 可对照的工具 |
|---|---|---|
| 通用构建系统 | mcpp.toml、模块图、ninja 后端 |
CMake、Meson |
| 构建插件 | build.mcpp、规则包 |
build.zig、xmake rules |
| 包管理 | [dependencies]、mcpp.lock、索引 |
Conan、vcpkg |
| 工具链管理 | 编译器作为被安装并钉住的载荷 | Zig 自带的工具链、rustup、手工安装 GCC / LLVM / MSVC |
| 环境与运行时 | [xlings]、载荷、运行期搜索路径 |
Nix、conda |
整体上最接近的两个类比是 Cargo 与 Zig,二者各对应同一个想法的一半。Cargo 是一个 程序同时充当构建、包管理、锁文件与测试运行器,于是一个 Rust 工程克隆下来直接构建、 没有前置步骤。Zig 把工具链随工具一起发布,并且默认支持交叉编译,于是编译器不是 机器必须先具备的东西。
mcpp 在 C++ 上是这个形状,外加二者都没有的一部分:环境层 —— 工程通过它声明构建 所需的非编译器工具。
这张表为各部分定位,不宣称等价。 表中每个工具在它自己的领域里做的都比 mcpp 多,需要那种深度的工程使用它。
拿到任何一个 mcpp 工程,
mcpp build就能构建 —— 不需要自行安装编译器、 配置环境,也不需要寻找依赖。
这里写下两条边界,以便这句话可以被依赖:面向设备的工程第一次构建仍会下载该设备的 工具包;而这台机器服务不了的目标会被点名拒绝,不会被错误地构建出来。
还是那台机器,唯一的 C++ 编译器是上面那个 GCC 13。
$ mcpp new hello
Created bin package 'hello' at /tmp/zero-demo/hello
Next: cd hello && mcpp build && mcpp run (or `mcpp test`)四个文件,manifest 五行:
[package]
name = "hello"
version = "0.1.0"
description = "A modular C++23 package"
license = "Apache-2.0"没有声明编译器、没有声明语言档位、没有声明任何依赖。 这就是一个读者在改动 这个工程之前需要理解的全部。而源码用的正是这台机器的编译器不具备的那个特性:
import std;
int main() {
std::println("Hello from hello!");
}$ mcpp run
Inferred target hello (bin from src/main.cpp)
Compiling hello v0.1.0 (.)
Finished dev [unoptimized + debuginfo] in 0.64s
Running `target/x86_64-linux-gnu/0946988e9e4b52ba/bin/hello`
Hello from hello!
Built with import std + std::println on modular C++23.墙钟 1.25 秒,含首次运行。完成这次构建的编译器:
$ mcpp self env
default toolchain = gcc@16.1.0mcpp 安装了 GCC 16 并使用它。宿主上没有任何东西被改动,而克隆这个工程的同事 —— 或者 agent —— 得到的是同一个编译器,不是那台机器恰好自带的那个。
增加一个依赖是一行,不需要其他步骤:
[dependencies]
"mcpplibs.cmdline" = "^0.0.1"mcpp build 会解析它、取回它、构建它、链接它。
mcpp 围绕 C++20/23 模块与较新的语言特性建立,它维护的生态由此而来,而不是 来自一个泛化的目标:
| 模块化 C++ | import std 零配置、模块扫描、跨工程 BMI 缓存 |
| 嵌入式与裸机 | freestanding 目标、板级支持包、从源码到一个可运行镜像只需一条命令 |
| 异构计算与 GPU | CUDA、HIP、SYCL、Vulkan/SPIR-V 与 Ascend C,每一条都是规则包而不是引擎特性 |
| 图形 | 着色器作为构建的一部分被编译,并以模块到达 |
| 内核与底层 | 零 libc 档、显式的链接模型、没有隐藏的宿主依赖 |
不需要其中任何一项的工程,同样得到上述保证;需要其中之一的工程,不必离开这个 工具去得到它。
| 运行第一个程序 | 01 —— 快速开始 |
| 判断 mcpp 是否适用于当前工作 | 02 —— 场景 |
| 阅读一个结构相近的工程 | 03 —— 示例项目 |
本章之后的一切都是参考:manifest 可以陈述什么、依赖怎样解析、目标怎样命名。本章 按设计不写任何字段与旗标。