引言

长期以来,文本包含头文件一直是跨多个翻译单元共享声明的方式。因此,打包库的概念始终涉及“包含文件”,以及二进制库文件本身,这就是库打包用于开发的方式。为了调用库提供的函数,我们必须首先包含声明该函数的头文件,例如

#include <fmt/core.h>

int main() {
  fmt::print("Hello, world!\n");
}

C++ 20 引入了模块作为跨多个翻译单元共享声明(和定义!)的新方法。使用模块,我们在调用方会有类似这样的代码:

import fmt;       // < ---- new!

int main() {
  fmt::print("Hello, world!\n");
}

与仅仅执行引用文件文本包含的 #include 预处理器指令不同,import 关键字是 C++ 语言特性,其行为有所不同。模块通过提供更好的隔离,在传统头文件方面具有优势。

  • 导入者只能看到显式导出的实体。
  • 导入者无法影响正在导入的代码。
  • 导入的代码无法影响导入代码中预处理器的状态。
  • 导入顺序无关紧要,并且“重复”导入不需要特殊处理。

使用模块,我们可以避免诸如头文件 将常用命名标识符定义为宏函数“using namespace” 指令在头文件中的滥用 等问题,并且我们终于可以结束反复出现的 #pragma once 与 include guards 的争论。

二进制模块接口

假设我们有一个已经支持 C++ 模块的库 - 打包方面需要考虑哪些问题?为了回答这个问题,我们首先应该看看编译器最初是如何解析导入的,尝试编译上面的“Hello, World!”示例

clang++ -std=c++20 -o hello_world.cpp.o -c hello_world.cpp   
hello_world.cpp:1:8: fatal error: module 'fmt' not found
import fmt;
~~~~~~~^~~
1 error generated.

为了解析 import fmt,编译器需要找到名为 fmt 的模块的二进制模块接口 (BMI)。BMI 是除了目标文件之外生成的,并对导出声明的信息进行编码的文件。对于 clang,我们需要告诉编译器此命名模块的文件位置,在本例中使用 -fmodule-file=fmt=/path/to/fmt.pcm 标志。请注意,编译器还有其他标志和机制来查找模块,这些标志和机制因编译器而异(参见 GCCmsvc)。为了说明目的,模块接口的示例可能是

fmt.cc(仅供说明!)

export module fmt;

export namespace fmt { 
   void print(const char*);
}

这就是工作流程与旧式头文件显著不同的地方。

  • 二进制模块接口需要在编译任何导入者之前生成(从模块接口单元生成,在本例中为 fmt.cc)。这在涉及模块的 C++ 源文件之间引入了依赖关系。
  • BMI 在编译器之间不兼容(它们是实现特定的),并且也不预期在同一编译器的不同版本之间兼容。不一致的编译器标志也可能使 BMI 对导入者无效 - 也就是说,如果导入者使用与 BMI 首次生成时不同的(可能不兼容的)标志进行编译。

为了解决第一个问题(正确的编译顺序),需要使编译器能够扫描源文件以查找模块依赖项并在 json 文件中表达这些依赖项(参见 p1689r5)。然后,构建系统可以使用此信息来推导出正确的构建顺序。CMake 在 今年早些时候 使用 3.25 版本开始尝试此功能,并且该功能将在 即将发布的 3.28 版本 中可用。

对于第二个问题,请继续阅读下面的内容!

试验打包的模块库

对于具有头文件的传统库,我们知道编译器需要找到包含包含文件的目录 - 隐式(通过将头文件放置在编译器已搜索的位置)或显式(通过 -I 标志)。如今,围绕库使用的构建系统抽象意味着开发人员不必手动将这些传递给编译器。例如,Conan 在 package_info() 方法中为使用者建模 包信息。特别是对于 C++,这在 cpp_info 属性中捕获。然后,Conan 在生成特定构建系统集成时使用此信息。

注意:这些实验的代码可在 GitHub 上获得。

打包 BMI(不要这样做!)

虽然围绕 C++ 模块的编译器文档指定 BMI 仅适用于相同的编译器、编译器版本和标志 - 但我们可以利用 Conan 二进制包模型并将 BMI 与二进制库一起打包。

对于允许传递标志以指定特定命名模块的 BMI 位置的编译器(如 Clang 和 msvc),我们可以依靠现有抽象将此信息传播给使用者,例如 - 在 Conan 配方的 package_info() 方法中(完整配方 在此

if is_msvc(self):
    bmi_dir = os.path.join(self.package_folder, "bmi").replace('\\','/')
    self.cpp_info.cxxflags = ["/reference fmt=fmt.cc.ifc", f"/ifcSearchDir{bmi_dir}"]
elif self.settings.compiler == "clang":
    self.cpp_info.cxxflags = [f"-fmodule-file=fmt={self.package_folder}/bmi/fmt.pcm"]

虽然包内容大致如下(适用于 Linux 上的 Clang)

|-- bmi
|   └-- fmt.pcm
└-- lib
    └-- libfmt.a

为了使此方法有效 - 我们需要以一种强制严格兼容性的方式使用 Conan。像上面这样的包将 *仅* 与该特定编译器和版本兼容。大致来说,这意味着确保对于 Conan,使用 GCC 13.1 编译的包与使用 GCC 13.2 编译的包不同且 *不兼容*,并且使用 C++20 构建的包与使用 C++ 23 构建的包不同且 *不兼容*。为此,我们需要

  • 确保 compiler.version 设置指定编译器的确切版本,而不仅仅是主版本。对于 msvc,这也需要 compiler.update 设置(参见 文档)。

  • 禁用 Conan 2.0 附带的 compatibility 插件的默认逻辑,该逻辑编码了将其他二进制包视为兼容候选者的行为(文档 在此)。

这有效吗?嗯,有效!但有些情况下它不起作用。

  • 对于像 {fmt} 这样使用 -fvisibility=hidden 构建的库,当导入者未启用此标志并使用默认可见性时,Clang 会拒绝 BMI。
  • Clang 将拒绝原始源文件在本地文件系统中不存在的 BMI - 这将使得在一台机器(例如 CI)上构建包并在另一台机器上使用它变得不可能。对于我们的许多用户来说,这是一个障碍。严格来说,导入者只需要 BMI,而不需要原始源文件。但是我们需要考虑到,编译器在报告错误时仍然会引用源文件。
  • GCC 目前不支持用于指定特定模块的 BMI 位置的编译器标志,而是支持全局模块映射器。虽然我们可以使用 Conan 创建它,但我们仍然需要与使用者端的构建系统合作。

另一方面,msvc 似乎更宽容,并且在我们的测试场景中,重用重新打包的 BMI 似乎工作正常。

虽然这种方法可能对那些对依赖项有严格且完全控制的团队有用,但使用的确切编译器和编译器版本(在所有环境中!)- 我们不建议打包 BMI 以使用模块。

打包模块接口

从上面的实验可以清楚地看出,我们需要将模块接口与库二进制文件一起打包。从打包的角度来看,这与打包头文件没有太大区别:它仍然是包含 C++ 源代码的文本文件。

我们将从以下内容开始

|-- include/foo
|   └-- foo.hpp          ---> this is a header file
└-- lib
    └-- libfoo.a

到以下内容

└-- lib
    |-- cxx
    |   └-- foo.cppm     ---> this is a module interface (does `export module foo`)
    └-- libfoo.a

但是,这改变了使用者端的一切。如果我们有一个从外部库导入模块的项目,那么我们现在需要构建系统的全面合作:它需要知道模块接口,并且需要在正确的时间调用编译器才能在导入者需要它们之前生成 BMI。

在即将发布的 CMake 3.28 版本中已实现了对 IMPORTED 目标中 C++ 模块的支持 - 你可以在这里看到一个正在使用 CMake 生成的 fmt-config.cmakefmt-targets.cmake 工作的实验 这里

如果导出的目标也包含模块,则 CMake 现在包含此信息。

add_library(fmt::fmt SHARED IMPORTED)

# ... 

target_sources(fmt::fmt
  INTERFACE
  FILE_SET "fmt_module"
  TYPE "CXX_MODULES"
  BASE_DIRS "${_IMPORT_PREFIX}/lib/cxx/miu"
  FILES "${_IMPORT_PREFIX}/lib/cxx/miu/src/fmt.cc"
)

到目前为止,CMake 3.28(在撰写本文时仍处于候选发布阶段)是唯一实现依赖项扫描和使用提供模块的外部库的功能的构建系统,并且 BMI 是在本地构建而不是分发的。还需要一个相当现代的设置!编译器必须支持 p1689r5(Clang >= 16.0、来自 14.34 工具集的 msvc 以及尚未发布的 gcc 14),以及构建工具(Ninja 1.11.1 或 MSBuild)。所有这些都非常前沿!

下一步是什么

我们目前正在努力更新 cpp_info 属性以容纳与 C++ 模块相关的信息,以便 Conan 生成器可以将其包含在支持 C++ 模块的构建系统中。在此处描述的情况下,这最初意味着 CMake 3.28。将来,需要扩展 C++ 模块信息以告知使用者在生成 BMI 时应使用哪些构建标志或宏定义。但今天,希望这对目前渴望尝试 C++ 模块的用户有所帮助,因为越来越多的库开始支持它们(参见 此处 获取列表)。这应该会有所帮助并推动采用!

资源

  • “C++20 模块:打包和二进制再分发故事”(Cppcon 2023 幻灯片
  • 使用 C++ 模块打包库(实验) - GitHub 存储库