简介

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

#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++ 语言功能,行为不同。模块比传统头文件有优势,因为它们提供更好的隔离性

  • 导入者只能看到明确导出的实体
  • 导入者不能影响正在导入的代码
  • 导入的代码不能影响导入代码中预处理器的状态
  • 导入顺序无关紧要,并且“两次”导入不需要特殊照顾

使用模块,我们可以避免诸如头文件将常用命名标识符定义为宏函数头文件中的“rogue using namespace”指令以及我们可以最终解决反复出现的#pragma once 与包含保护之争。

二进制模块接口

假设我们有一个已经支持 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 与二进制库一起打包。

对于像 Clang 和 msvc 这样的编译器,这些编译器允许传递标记来指定特定命名模块的 BMI 位置,我们可以依靠现有的抽象将此信息传播给消费者,例如 - 在 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、msvc 来自 14.34 工具集,以及即将发布的 gcc 14),以及构建工具(Ninja 1.11.1 或 MSBuild)。所有这些都是最前沿的技术!

接下来是什么

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

资源

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