标准 C++ 模块

介绍

术语 module 模棱两可,因为它在 Clang 中用于表示多个事物。对于 Clang 用户来说,模块可能指的是 Objective-C ModuleClang 模块(也称为 Clang Header Module)或 C++20 Module(或 Standard C++ Module)。Clang 中所有这些模块的实现共享大量代码,但从用户的角度来看,它们的语义和命令行界面非常不同。本文档介绍了在 Clang 中使用 C++20 模块。在本文档的其余部分,术语 module 指的是标准 C++20 模块,而术语 Clang module 指的是 Clang 模块扩展。

就 C++ 标准而言,模块包含两个组件:“命名模块”或“头文件单元”。本文档涵盖了这两者。

标准 C++ 命名模块

为了更好地理解编译器的行为,对于不熟悉 C++ 功能的读者来说,了解一些术语和定义很有帮助。本文档不是 C++ 教程;它仅介绍必要的概念,以更好地理解在项目中使用模块。

背景和术语

模块和模块单元

模块由一个或多个模块单元组成。模块单元是一种特殊的翻译单元。模块单元几乎总是应该以模块声明开头。模块声明的语法是

[export] module module_name[:partition_name];

方括号 [] 中的术语是可选的。module_namepartition_name 遵循 C++ 标识符的规则,但它们可能包含一个或多个句点 (.) 字符。请注意,名称中的 . 没有语义含义,不暗示任何层次结构。

在本文件中,模块单元被分类为

  • 主模块接口单元

  • 模块实现单元

  • 模块分区接口单元

  • 内部模块分区单元

主模块接口单元是一个模块单元,其模块声明为 export module module_name;,其中 module_name 表示模块的名称。一个模块应该有一个且只有一个主模块接口单元。

模块实现单元是一个模块单元,其模块声明为 module module_name;。可以在同一个模块中声明多个模块实现单元。

模块分区接口单元是一个模块单元,其模块声明为 export module module_name:partition_name;partition_name 应该在任何给定模块中都是唯一的。

内部模块分区单元是一个模块单元,其模块声明为 module module_name:partition_name;partition_name 应该在任何给定模块中都是唯一的。

在本文件中,我们使用以下术语

  • 一个 module interface unit 指的是 primary module interface unitmodule partition interface unit

  • 一个 importable module unit 指的是 module interface unitinternal module partition unit

  • 一个 module partition unit 指的是 module partition interface unitinternal module partition unit

内置模块接口

一个 Built Module Interface(或 BMI)是可导入模块单元的预编译结果。

全局模块片段

一个 global module fragment(或 GMF)是模块单元中 module; 和模块声明之间的代码。

如何使用模块构建项目

快速入门

让我们来看一个使用模块的“hello world”示例。

// Hello.cppm
module;
#include <iostream>
export module Hello;
export void hello() {
  std::cout << "Hello World!\n";
}

// use.cpp
import Hello;
int main() {
  hello();
  return 0;
}

然后,在命令行上,像这样调用 Clang

$ clang++ -std=c++20 Hello.cppm --precompile -o Hello.pcm
$ clang++ -std=c++20 use.cpp -fmodule-file=Hello=Hello.pcm Hello.pcm -o Hello.out
$ ./Hello.out
Hello World!

在此示例中,我们创建并使用了一个简单的模块 Hello,它只包含一个名为 Hello.cppm 的主模块接口单元。

一个更复杂的“hello world”示例,它使用了 4 种模块单元是

// M.cppm
export module M;
export import :interface_part;
import :impl_part;
export void Hello();

// interface_part.cppm
export module M:interface_part;
export void World();

// impl_part.cppm
module;
#include <iostream>
#include <string>
module M:impl_part;
import :interface_part;

std::string W = "World.";
void World() {
  std::cout << W << std::endl;
}

// Impl.cpp
module;
#include <iostream>
module M;
void Hello() {
  std::cout << "Hello ";
}

// User.cpp
import M;
int main() {
  Hello();
  World();
  return 0;
}

然后,回到命令行,使用以下命令调用 Clang

# Precompiling the module
$ clang++ -std=c++20 interface_part.cppm --precompile -o M-interface_part.pcm
$ clang++ -std=c++20 impl_part.cppm --precompile -fprebuilt-module-path=. -o M-impl_part.pcm
$ clang++ -std=c++20 M.cppm --precompile -fprebuilt-module-path=. -o M.pcm
$ clang++ -std=c++20 Impl.cpp -fprebuilt-module-path=. -c -o Impl.o

# Compiling the user
$ clang++ -std=c++20 User.cpp -fprebuilt-module-path=. -c -o User.o

# Compiling the module and linking it together
$ clang++ -std=c++20 M-interface_part.pcm -fprebuilt-module-path=. -c -o M-interface_part.o
$ clang++ -std=c++20 M-impl_part.pcm -fprebuilt-module-path=. -c -o M-impl_part.o
$ clang++ -std=c++20 M.pcm -fprebuilt-module-path=. -c -o M.o
$ clang++ User.o M-interface_part.o  M-impl_part.o M.o Impl.o -o a.out

我们在接下来的部分解释这些选项。

如何启用标准 C++ 模块

当语言标准模式为 -std=c++20 或更新版本时,标准 C++ 模块会自动启用。

如何生成 BMI

要为可导入模块单元生成 BMI,请使用 --precompile-fmodule-output 命令行选项。

选项 --precompile 将 BMI 作为编译的输出生成,输出路径使用 -o 选项指定。

选项 -fmodule-output 将 BMI 作为编译的副产品生成。如果指定了 -fmodule-output=,BMI 将被发射到指定的位置。如果指定了 -fmodule-output-c,BMI 将被发射到输出文件所在的目录中,文件名与输入文件名相同,扩展名为 .pcm。否则,BMI 将被发射到工作目录中,文件名与输入文件名相同,扩展名为 .pcm

使用 --precompile 生成 BMI 被称为两阶段编译,因为它需要两步才能将源文件编译为目标文件。使用 -fmodule-output 生成 BMI 被称为单阶段编译。单阶段编译模型对于构建系统来说更易于实现,而两阶段编译由于更高的并行性而具有更快的编译潜能。例如,如果存在两个模块单元 AB,并且 B 依赖于 A,单阶段编译模型需要按顺序编译它们,而两阶段编译模型能够在 A.pcm 可用后立即编译,因此能够与 A.pcmA.o 的编译步骤同时进行。

文件名要求

按照惯例,importable module unit 文件应该使用 .cppm(或 .ccm.cxxm.c++m)作为文件扩展名。 Module implementation unit 文件应该使用 .cpp(或 .cc.cxx.c++)作为文件扩展名。

BMI 应该使用 .pcm 作为文件扩展名。 primary module interface unit 的 BMI 文件名应该为 module_name.pcmmodule partition unit 的 BMI 文件名应该为 module_name-partition_name.pcm

如果使用不同的扩展名,Clang 可能无法构建模块。例如,如果 importable module unit 文件名以 .cpp 结尾而不是 .cppm,那么 Clang 就无法使用 --precompile 选项为 importable module unit 生成 BMI,因为 --precompile 选项只会运行预处理器 (-E)。如果对 importable module unit 使用与常规扩展名不同的扩展名,可以在文件之前指定 -x c++-module。例如,

// Hello.cpp
module;
#include <iostream>
export module Hello;
export void hello() {
  std::cout << "Hello World!\n";
}

// use.cpp
import Hello;
int main() {
  hello();
  return 0;
}

在这个例子中,module interface 使用的扩展名是 .cpp 而不是 .cppm,因此它无法像前面的例子那样编译,但可以使用以下方法编译:

$ clang++ -std=c++20 -x c++-module Hello.cpp --precompile -o Hello.pcm
$ clang++ -std=c++20 use.cpp -fprebuilt-module-path=. Hello.pcm -o Hello.out
$ ./Hello.out
Hello World!

模块名称要求

[module.unit]p1

所有以标识符开头(由 std 后跟零个或多个数字组成)或包含保留标识符([lex.name])的模块名称都是保留的,并且不应在模块声明中指定;不需要任何诊断。如果保留的模块名称中的任何标识符是保留的标识符,则该模块名称保留供 C++ 实现使用;否则,它保留供将来标准化使用。

因此,以下名称默认情况下均无效

std
std1
std.foo
__test
// and so on ...

强烈建议不要使用保留的模块名称,但可以使用 -Wno-reserved-module-identifier 来抑制警告。

指定依赖的 BMI

有三种方法可以指定依赖的 BMI

  1. -fprebuilt-module-path=<path/to/directory>.

  2. -fmodule-file=<path/to/BMI>(已弃用)。

  3. -fmodule-file=<module-name>=<path/to/BMI>.

-fprebuilt-module-path 选项指定搜索依赖的 BMI 的路径。可以指定多个路径,类似于使用 -I 指定头文件的搜索路径。当导入模块 M 时,编译器会在 -fprebuilt-module-path 指定的目录中查找 M.pcm。同样,当导入分区模块单元 M:P 时,编译器会在 -fprebuilt-module-path 指定的目录中查找 M-P.pcm

-fmodule-file=<path/to/BMI> 选项会导致编译器直接加载指定的 BMI。 -fmodule-file=<module-name>=<path/to/BMI> 选项会导致编译器在必要时为 <module-name> 指定的模块加载指定的 BMI。主要区别在于 -fmodule-file=<path/to/BMI> 会立即加载 BMI,而 -fmodule-file=<module-name>=<path/to/BMI> 仅会在需要时加载 BMI,-fprebuilt-module-path 也是如此。针对命名模块的 -fmodule-file=<path/to/BMI> 选项已弃用,将在 Clang 的未来版本中移除。

当这些选项在编译器的同一调用中指定时,-fmodule-file=<path/to/BMI> 选项优先于 -fmodule-file=<module-name>=<path/to/BMI>,后者优先于 -fprebuilt-module-path=<path/to/directory>

注意:必须显式指定所有依赖的 BMI,包括直接或间接依赖的 BMI。有关详细信息,请参阅 https://github.com/llvm/llvm-project/issues/62707

当编译 module implementation unit 时,必须指定相应 primary module interface unit 的 BMI,因为模块实现单元隐式导入主要模块接口单元。

[module.unit]p8

不包含 export 关键字或模块分区的模块声明隐式导入模块的主要模块接口单元,就像通过模块导入声明一样。

-fprebuilt-module-path=<path/to/directory>-fmodule-file=<path/to/BMI>-fmodule-file=<module-name>=<path/to/BMI> 选项可以指定多次。例如,编译前面示例中的 M.cppm 的命令行可以改写为:

$ clang++ -std=c++20 M.cppm --precompile -fmodule-file=M:interface_part=M-interface_part.pcm -fmodule-file=M:impl_part=M-impl_part.pcm -o M.pcm

当对同一个 <module-name> 有多个 -fmodule-file=<module-name>= 选项时,最后一个 -fmodule-file=<module-name>= 会覆盖之前的 -fmodule-file=<module-name>= 选项。

请记住,模块单元仍然具有 BMI 的对象对应部分

虽然模块接口类似于传统的头文件,但它们仍然需要编译。模块单元是翻译单元,需要编译成目标文件,然后将这些目标文件链接在一起,如下面的示例所示。

例如,头文件的传统编译过程如下:

src1.cpp -+> clang++ src1.cpp --> src1.o ---,
hdr1.h  --'                                 +-> clang++ src1.o src2.o ->  executable
hdr2.h  --,                                 |
src2.cpp -+> clang++ src2.cpp --> src2.o ---'

模块单元的编译过程如下:

              src1.cpp ----------------------------------------+> clang++ src1.cpp -------> src1.o -,
(header unit) hdr1.h    -> clang++ hdr1.h ...    -> hdr1.pcm --'                                    +-> clang++ src1.o mod1.o src2.o ->  executable
              mod1.cppm -> clang++ mod1.cppm ... -> mod1.pcm --,--> clang++ mod1.pcm ... -> mod1.o -+
              src2.cpp ----------------------------------------+> clang++ src2.cpp -------> src2.o -'

如图表所示,我们需要从模块单元编译 BMI 到目标文件,然后链接目标文件。(但是,这无法对来自头文件的 BMI 进行。有关更多详细信息,请参阅关于 头文件 的部分。)

BMI 无法打包到档案中以创建模块库。相反,BMI(*.pcm)被编译成目标文件(*.o),然后这些目标文件被添加到档案中。

clang-cl

clang-cl 支持与 clang++ 相同的模块选项,如上所述;无需使用 /clang: 为这些选项添加前缀。请注意,cl.exe 用于发出/使用 IFC 文件的选项 <https://devblogs.microsoft.com/cppblog/using-cpp-modules-in-msvc-from-the-command-line-part-1/> *不支持*。生成的预编译模块也不兼容 cl.exe 使用。

我们建议构建系统作者使用上述 clang++ 选项与 clang-cl 一起构建模块。

一致性要求

模块可以被视为一种加速编译的缓存。因此,与其他缓存技术一样,维护缓存一致性非常重要,这就是 Clang 对一致性进行严格检查的原因。

选项一致性

与模块单元及其非模块单元使用相关的语言方言的编译器选项需要保持一致。考虑以下示例

// M.cppm
export module M;

// Use.cpp
import M;
$ clang++ -std=c++20 M.cppm --precompile -o M.pcm
$ clang++ -std=c++23 Use.cpp -fprebuilt-module-path=.

Clang 由于语言标准模式不一致而拒绝了该示例。不过,并非所有编译器选项都是语言方言选项。例如

$ clang++ -std=c++20 M.cppm --precompile -o M.pcm
# Inconsistent optimization level.
$ clang++ -std=c++20 -O3 Use.cpp -fprebuilt-module-path=.
# Inconsistent debugging level.
$ clang++ -std=c++20 -g Use.cpp -fprebuilt-module-path=.

虽然优化和调试级别不一致,但这些编译被接受,因为编译器选项不会影响语言方言。

请注意,编译器 **目前** 不拒绝不一致的宏定义(这在将来可能会改变)。例如

$ clang++ -std=c++20 M.cppm --precompile -o M.pcm
# Inconsistent optimization level.
$ clang++ -std=c++20 -O3 -DNDEBUG Use.cpp -fprebuilt-module-path=.

目前,Clang 接受了上述示例,尽管如果调试代码依赖于其他翻译单元中 NDEBUG 的一致使用,可能会产生意外的结果。

源文件一致性

Clang 可能会在编译过程中打开 BMI 的输入文件1`。这意味着当 Clang 使用 BMI 时,所有输入文件都必须位于原始路径中,并且具有原始内容。

为了克服这些要求并简化分布式构建和沙箱构建等情况,用户可以使用 -fmodules-embed-all-files 标志将所有输入文件嵌入到 BMI 中,这样 Clang 就无需打开磁盘上的对应文件。

-fmodules-embed-all-files 标志启用时,Clang 会显式地将源代码输出到 BMI 文件中,BMI 文件的内容包含足够详细的表示形式,以便重现原始源文件。

1` 输入文件:参与 BMI 编译的源文件。例如

// M.cppm
module;
#include "foo.h"
export module M;

// foo.h
#pragma once
#include "bar.h"

M.cppmfoo.hbar.hM.cppm BMI 的输入文件。

对象定义一致性

C++ 语言要求在不同翻译单元中对同一个实体的声明具有相同的定义,这被称为单定义规则 (ODR)。如果没有模块,编译器无法执行严格的 ODR 违规检查,因为它一次只能看到一个翻译单元。使用模块后,编译器可以跨翻译单元对 ODR 违规进行检查。

但是,当前的 ODR 检查机制并不完美。存在大量的假阳性 ODR 违规诊断,即编译器错误地将两个相同的声明诊断为具有不同的定义。此外,真正的 ODR 违规并不总是被报告。

为了提供更好的用户体验,提高编译性能,以及与 MSVC 保持一致,默认情况下禁用了对全局模块片段中声明的 ODR 检查。可以通过在编译时指定 -Xclang -fno-skip-odr-check-in-gmf 来启用这些检查。如果启用了检查并且遇到错误或缺失的诊断,请通过 社区问题跟踪器 报告。

隐私问题

BMI 不应该被视为信息隐藏机制。应始终假定它们包含用于创建它们的所有信息,以可恢复的形式。

ABI 影响

本节介绍模块带来的新的 ABI 更改。仅涵盖对 Itanium C++ ABI 的更改。

名称改编

模块单元中不在全局模块片段中的声明具有新的链接名称。

例如,

export module M;
namespace NS {
  export int foo();
}

NS::foo() 的链接名称为 _ZN2NSW1M3fooEv。这不能被以前版本的调试器或改编器改编。从 LLVM 15.x 开始,可以使用 llvm-cxxfilt 来改编它

$ llvm-cxxfilt _ZN2NSW1M3fooEv
  NS::foo@M()

结果应读作模块 M 中的 NS::foo()

ABI 意味着不能在模块单元中声明并在非模块单元中定义 (反之亦然),因为这会导致链接错误。

尽管如此,可以通过使用语言链接说明符在模块单元中实现具有兼容 ABI 的声明,因为语言链接说明符中的声明附加到全局模块片段。例如

export module M;
namespace NS {
  export extern "C++" int foo();
}

现在 NS::foo() 的链接名称将是 _ZN2NS3fooEv

模块初始化程序

所有可导入的模块单元都需要发出一个初始化程序函数,以处理模块单元中非内联变量的动态初始化。即使没有动态初始化,可导入的模块单元也必须发出初始化程序;否则,导入程序可能会调用一个不存在的函数。初始化程序函数首先发出对导入模块的调用,然后发出对当前模块单元中所有动态初始化程序的调用。

显式或隐式导入命名模块的翻译单元必须在翻译单元中动态初始化程序的序列中调用导入的命名模块的初始化程序函数。命名空间范围的实体的初始化是按外观顺序进行的。这(递归地)扩展到导入的模块,在导入声明出现的地方。

如果已知导入的模块为空,则可以省略对它的初始化程序的调用。此外,如果已知导入的模块已被导入,则可以省略对它的初始化程序的调用。

简化的 BMI

为了支持两阶段编译模型,Clang 将生成对象所需的一切都放入 BMI 中。但是,BMI 的其他使用者通常不需要这些信息。这使得 BMI 更大,并且可能会为 BMI 引入不必要的依赖项。为了缓解这个问题,Clang 提供了一个编译器选项来减少 BMI 中包含的信息。这两种格式分别称为完整 BMI 和简化的 BMI。

用户可以使用 -fexperimental-modules-reduced-bmi 选项生成简化的 BMI。

对于单阶段编译模型(CMake 实现了此模型),使用 -fexperimental-modules-reduced-bmi,生成的 BMI 将自动成为简化的 BMI。(BMI 的输出路径由 -fmodule-output= 指定,与单阶段编译模型一样)。

也可以使用两阶段编译模型生成简化的 BMI。当指定 -fexperimental-modules-reduced-bmi--precompile-fmodule-output= 时,由 -o 指定的生成的 BMI 将是完整的 BMI,由 -fmodule-output= 指定的 BMI 将是简化的 BMI。在这种情况下,依赖关系图将如下所示

module-unit.cppm --> module-unit.full.pcm -> module-unit.o
                  |
                  -> module-unit.reduced.pcm -> consumer1.cpp
                                             -> consumer2.cpp
                                             -> ...
                                             -> consumer_n.cpp

-fexperimental-modules-reduced-bmi 与非模块单元一起使用时,Clang 不会发出诊断信息。这种设计允许单阶段编译模型的用户尝试使用简化的 BMI,而无需修改构建系统。两阶段编译模块需要构建系统支持。

在简化的 BMI 中,Clang 不会从全局模块片段发出不可达的实体,或发出非内联函数和非内联变量的定义。这可能不是一个透明的更改。

考虑以下示例

// foo.h
namespace N {
  struct X {};
  int d();
  int e();
  inline int f(X, int = d()) { return e(); }
  int g(X);
  int h(X);
}

// M.cppm
module;
#include "foo.h"
export module M;
template<typename T> int use_f() {
  N::X x;                       // N::X, N, and :: are decl-reachable from use_f
  return f(x, 123);             // N::f is decl-reachable from use_f,
                                // N::e is indirectly decl-reachable from use_f
                                //   because it is decl-reachable from N::f, and
                                // N::d is decl-reachable from use_f
                                //   because it is decl-reachable from N::f
                                //   even though it is not used in this call
}
template<typename T> int use_g() {
  N::X x;                       // N::X, N, and :: are decl-reachable from use_g
  return g((T(), x));           // N::g is not decl-reachable from use_g
}
template<typename T> int use_h() {
  N::X x;                       // N::X, N, and :: are decl-reachable from use_h
  return h((T(), x));           // N::h is not decl-reachable from use_h, but
                                // N::h is decl-reachable from use_h<int>
}
int k = use_h<int>();
  // use_h<int> is decl-reachable from k, so
  // N::h is decl-reachable from k

// M-impl.cpp
module M;
int a = use_f<int>();           // OK
int b = use_g<int>();           // error: no viable function for call to g;
                                // g is not decl-reachable from purview of
                                // module M's interface, so is discarded
int c = use_h<int>();           // OK

在上面的示例中,N::g 的函数定义从 M.cppm 的简化 BMI 中省略。然后,M-impl.cpp 中对 use_g<int> 的使用无法实例化。对于此类问题,用户可以在 M.cppm模块范围 中添加对 N::g 的引用,以确保它可达,例如 using N::g;

对简化 BMI 的支持仍然是实验性的,但它将来可能会成为默认设置。截至 Clang 19.x,简化 BMI 的预期路线图是

  1. -fexperimental-modules-reduced-bmi 是 1~2 个版本中的选择加入。此时间段取决于用户反馈,并且可能会延长。

  2. 宣布简化的 BMI 不再是实验性的,并引入 -fmodules-reduced-bmi 作为新的选项,并建议使用新选项。此过渡预计也需要 1~2 个额外的版本。

  3. 最后,-fmodules-reduced-bmi 将成为默认设置。届时,BMI 一词将指代简化的 BMI,而完整的 BMI 仅对选择支持两阶段编译的构建系统有意义。

实验性非级联更改

本节主要面向构建系统供应商。对于最终编译器用户,如果您不想全部阅读,这有助于减少重新编译。我们鼓励构建系统供应商和最终用户尝试一下并提供反馈。

在 Clang 19 之前,任何(传递)依赖项的 BMI 中的更改都会导致 BMI 的输出发生更改。从 Clang 19 开始,对非直接依赖项的更改不应直接影响输出 BMI,除非它们影响编译结果。我们预计,这种优化机会比我们目前意识到的要多得多,我们很高兴收到有关未发现的优化机会的反馈。例如,

// m-partA.cppm
export module m:partA;

// m-partB.cppm
export module m:partB;
export int getB() { return 44; }

// m.cppm
export module m;
export import :partA;
export import :partB;

// useBOnly.cppm
export module useBOnly;
import m;
export int B() {
  return getB();
}

// Use.cc
import useBOnly;
int get() {
  return B();
}

要编译项目(为简洁起见,省略了一些命令)。

$ clang++ -std=c++20 m-partA.cppm --precompile -o m-partA.pcm
$ clang++ -std=c++20 m-partB.cppm --precompile -o m-partB.pcm
$ clang++ -std=c++20 m.cppm --precompile -o m.pcm -fprebuilt-module-path=.
$ clang++ -std=c++20 useBOnly.cppm --precompile -o useBOnly.pcm -fprebuilt-module-path=.
$ md5sum useBOnly.pcm
07656bf4a6908626795729295f9608da  useBOnly.pcm

如果 m-partA.cppm 的接口更改为

// m-partA.v1.cppm
export module m:partA;
export int getA() { return 43; }

并且 useBOnly 的 BMI 重新编译为

$ clang++ -std=c++20 m-partA.cppm --precompile -o m-partA.pcm
$ clang++ -std=c++20 m-partB.cppm --precompile -o m-partB.pcm
$ clang++ -std=c++20 m.cppm --precompile -o m.pcm -fprebuilt-module-path=.
$ clang++ -std=c++20 useBOnly.cppm --precompile -o useBOnly.pcm -fprebuilt-module-path=.
$ md5sum useBOnly.pcm
07656bf4a6908626795729295f9608da  useBOnly.pcm

那么 useBOnly.pcm 的内容保持不变。因此,如果构建系统仅根据直接导入的模块来确定是否重新编译,则可以跳过 Use.cc 的重新编译。这应该没问题,因为更改的接口不会以任何方式影响 Use.cc;更改不会级联。

Clang 生成 BMI 时,它会记录所有可能影响所生成 BMI 的 BMI 的哈希值。这确保构建系统在决定是否重新编译时无需考虑传递导入的模块。

目前尚未指定什么被认为是可能影响 BMI 的 BMI。但是,如果 BMI 在影响其使用者的可观察更改后保持不变,则这是一个严重的错误。

构建系统可以通过对从编译器输出的 BMI 中使用的 BMI 进行更新-如果-更改操作来利用此优化。

我们鼓励构建系统添加一个实验模式,当 **直接** 依赖项没有更改时,即使 **传递** 依赖项发生更改,也会重用缓存的 BMI。

考虑到可能存在编译器错误,我们建议构建系统支持此功能作为可配置选项,以便用户可以随时安全地返回到传递更改模式。

与简化 BMI 的交互

使用简化的 BMI,非级联更改可以更加强大。例如,

// A.cppm
export module A;
export int a() { return 44; }

// B.cppm
export module B;
import A;
export int b() { return a(); }
$ clang++ -std=c++20 A.cppm -c -fmodule-output=A.pcm  -fexperimental-modules-reduced-bmi -o A.o
$ clang++ -std=c++20 B.cppm -c -fmodule-output=B.pcm  -fexperimental-modules-reduced-bmi -o B.o -fmodule-file=A=A.pcm
$ md5sum B.pcm
6c2bd452ca32ab418bf35cd141b060b9  B.pcm

让我们将 A.cppm 的实现更改为

export module A;
int a_impl() { return 99; }
export int a() { return a_impl(); }

并重新编译示例

$ clang++ -std=c++20 A.cppm -c -fmodule-output=A.pcm  -fexperimental-modules-reduced-bmi -o A.o
$ clang++ -std=c++20 B.cppm -c -fmodule-output=B.pcm  -fexperimental-modules-reduced-bmi -o B.o -fmodule-file=A=A.pcm
$ md5sum B.pcm
6c2bd452ca32ab418bf35cd141b060b9  B.pcm

我们应该发现 B.pcm 的内容保持不变。在这种情况下,构建系统可以跳过仅直接依赖模块 B 的 TU 的重新编译。

这只会发生在 BMI 缩减的情况下。使用缩减的 BMI,我们不会在 B 的 BMI 中记录 int b() 的函数体,这样模块 A 不会对 B 的 BMI 产生影响,并且我们有更少的依赖关系。

性能提示

减少重复

虽然在不同模块单元的全局模块片段中包含重复的声明是有效的,但这对于 Clang 来处理重复的声明来说并不免费。如果在翻译单元与其导入的模块之间存在大量重复声明,则翻译单元的编译速度会更慢。例如

// M-partA.cppm
module;
#include "big.header.h"
export module M:partA;
...

// M-partB.cppm
module;
#include "big.header.h"
export module M:partB;
...

// other partitions
...

// M-partZ.cppm
module;
#include "big.header.h"
export module M:partZ;
...

// M.cppm
export module M;
export import :partA;
export import :partB;
...
export import :partZ;

// use.cpp
import M;
... // use declarations from module M.

big.header.h 足够大并且存在大量分区时,use.cpp 的编译速度可能明显慢于以下方法

module;
#include "big.header.h"
export module m:big.header.wrapper;
export ... // export the needed declarations

// M-partA.cppm
export module M:partA;
import :big.header.wrapper;
...

// M-partB.cppm
export module M:partB;
import :big.header.wrapper;
...

// other partitions
...

// M-partZ.cppm
export module M:partZ;
import :big.header.wrapper;
...

// M.cppm
export module M;
export import :partA;
export import :partB;
...
export import :partZ;

// use.cpp
import M;
... // use declarations from module M.

从文本包含中减少重复是提高编译时性能的方法。

为了帮助用户识别此类问题,我们添加了警告 -Wdecls-in-multiple-modules。此警告默认情况下处于禁用状态,需要显式启用或通过 -Weverything 启用。

迁移到模块

对于新代码和库,如果可能,最好从一开始就使用模块。但是,对于现有代码或库,切换到模块可能会是破坏性的更改。因此,许多现有库需要同时提供头文件和模块接口一段时间,以避免破坏现有用户。

本节提供了一些关于如何简化现有库的迁移过程的建议。**请注意,这些信息仅供指导,而不是 Clang 中使用模块的要求。** 它假定项目从无模块依赖项开始。

ABI 非破坏性样式

export-using 样式
module;
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
export module your_library;
export namespace your_namespace {
  using decl_1;
  using decl_2;
  ...
  using decl_n;
}

此示例演示如何包含所有包含需要导出的声明的头文件,并在 export 块中使用 using 声明来生成模块接口。

export extern-C++ 样式
module;
#include "third_party/A/headers.h"
#include "third_party/B/headers.h"
...
#include "third_party/Z/headers.h"
export module your_library;
#define IN_MODULE_INTERFACE
extern "C++" {
  #include "header_1.h"
  #include "header_2.h"
  ...
  #include "header_n.h"
}

头文件(从 header_1.hheader_n.h)需要定义宏

#ifdef IN_MODULE_INTERFACE
#define EXPORT export
#else
#define EXPORT
#endif

并在要导出的声明上放置 EXPORT

此外,建议重构头文件以有条件地包含第三方头文件

#ifndef IN_MODULE_INTERFACE
#include "third_party/A/headers.h"
#endif

#include "header_x.h"

...

这很有用,因为它在修改代码时,如果模块接口单元未正确更新,则会提供更好的诊断消息。

此方法有效,因为具有语言链接的声明附加到全局模块。因此,库的模块化形式的 ABI 不会改变。

虽然这种样式比 export-using 样式更复杂,但它使进一步重构库到其他样式变得更加容易。

ABI 破坏性样式

术语 ABI breaking 可能听起来像是一种不好的方法。但是,这种样式迫使库的使用者以一致的方式使用它。例如,始终包含库的头文件或始终导入模块。这种样式阻止了将库的包含和导入混合使用。

ABI 破坏性样式的模式类似于 export extern-C++ 样式。

module;
#include "third_party/A/headers.h"
#include "third_party/B/headers.h"
...
#include "third_party/Z/headers.h"
export module your_library;
#define IN_MODULE_INTERFACE
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"

#if the number of .cpp files in your project are small
module :private;
#include "source_1.cpp"
#include "source_2.cpp"
...
#include "source_n.cpp"
#else // the number of .cpp files in your project are a lot
// Using all the declarations from third-party libraries which are
// used in the .cpp files.
namespace third_party_namespace {
  using third_party_decl_used_in_cpp_1;
  using third_party_decl_used_in_cpp_2;
  ...
  using third_party_decl_used_in_cpp_n;
}
#endif

(并将 EXPORT 和条件包含添加到头文件中,如 export extern-C++ 样式部分中所建议的那样。)

模块的 ABI 不同,因此我们需要将源文件编译到新的 ABI 中。这是通过接口单元的另一个部分完成的

#if the number of .cpp files in your project are small
module :private;
#include "source_1.cpp"
#include "source_2.cpp"
...
#include "source_n.cpp"
#else // the number of .cpp files in your project are a lot
// Using all the declarations from third-party libraries which are
// used in the .cpp files.
namespace third_party_namespace {
  using third_party_decl_used_in_cpp_1;
  using third_party_decl_used_in_cpp_2;
  ...
  using third_party_decl_used_in_cpp_n;
}
#endif

如果源文件数量很少,则所有内容都可以直接放入私有模块片段中(建议也向源文件添加条件包含)。但是,如果要编译的源文件很多,编译时性能会很差。

请注意,私有模块片段只能位于主模块接口单元中,包含私有模块片段的主模块接口单元应该是相应模块的唯一模块单元。

在这种情况下,源文件(.cpp 文件)必须转换为模块实现单元

#ifndef IN_MODULE_INTERFACE
// List all the includes here.
#include "third_party/A/headers.h"
...
#include "header.h"
#endif

module your_library;

// Following off should be unchanged.
...

模块实现单元将隐式导入主模块。不要在模块实现单元中包含任何头文件,因为它避免了翻译单元之间的重复声明。这就是为什么非导出 using 声明应该从第三方库中添加到主模块接口单元中的原因。

如果库作为 libyour_library.so 提供,则可能还需要提供一个模块化库(例如,libyour_library_modules.so)以实现 ABI 兼容性。

如果存在仅被源文件包含的头文件怎么办

如果仅存在由源文件包含的头文件,则上述做法可能有问题。在使用私有模块片段时,可以通过在私有模块片段中包含这些头文件来解决此问题。虽然在使用实现模块单元时,通过在模块范围内包含实现头文件来解决此问题是可以的,但这可能是次优的,因为主模块接口单元现在包含不属于接口的实体。

可以通过引入模块分区实现单元来改善这一点。内部模块分区单元是可导入的模块单元,它属于模块本身。

提供头文件以跳过解析冗余头文件

翻译单元之间共享的许多重新声明会导致 Clang 的编译时性能变慢。此外,导入后包含 存在已知问题。即使解决该问题,用户仍然可能会遇到更慢的编译速度和更大的 BMI。出于这些原因,建议不要在导入相应模块后包含头文件。但是,如果库被其他依赖项包含,则并非总是容易的,例如

#include "third_party/A.h" // #include "your_library/a_header.h"
import your_library;

import your_library;
#include "third_party/A.h" // #include "your_library/a_header.h"

对于此类情况,如果提供模块和头文件接口的库还提供一个跳过解析的头文件,则该库可以使用以下方法导入,该方法可以跳过冗余重新声明

import your_library;
#include "your_library_imported.h"
#include "third_party/A.h" // #include "your_library/a_header.h" but got skipped

的实现可以是一组控制宏,或者如果使用 #pragma once,则可以是一个总体控制宏。然后可以将头文件重构为

#pragma once
#ifndef YOUR_LIBRARY_IMPORTED
...
#endif

如果库导入的模块提供此类头文件,请记住也将它们添加到 中。

导入模块

如果存在提供模块的依赖库,则也应该在您的模块中导入它们。一旦 std 模块更广泛地可用,许多现有库将属于此类。

所有提供模块的依赖库

当然,如果所有依赖库都提供模块,则大多数复杂性都会消失。

头文件需要转换为有条件地包含第三方头文件。然后,对于 export-using 样式

module;
import modules_from_third_party;
#define IN_MODULE_INTERFACE
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
export module your_library;
export namespace your_namespace {
  using decl_1;
  using decl_2;
  ...
  using decl_n;
}

或者,对于 export extern-C++ 样式

export module your_library;
import modules_from_third_party;
#define IN_MODULE_INTERFACE
extern "C++" {
  #include "header_1.h"
  #include "header_2.h"
  ...
  #include "header_n.h"
}

或者,对于 ABI-breaking 样式,

export module your_library;
import modules_from_third_party;
#define IN_MODULE_INTERFACE
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"

#if the number of .cpp files in your project are small
module :private;
#include "source_1.cpp"
#include "source_2.cpp"
...
#include "source_n.cpp"
#endif

如果使用实现模块单元,则不需要非导出的 using 声明。相反,可以在实现模块单元中直接导入第三方模块。

部分提供模块的依赖库

如果库必须在其模块中混合使用 includeimport,则首要目标仍然是尽可能地消除翻译单元中的重复声明。如果导入的模块提供头文件以跳过解析其头文件,则应在导入后包含这些头文件。如果导入的模块不提供此类头文件,则可以手动创建一个以提高编译时性能。

内部分区单元的可达性

内部分区单元有时在其他文档中被称为实现分区单元。但是,这个名称可能会造成混淆,因为实现分区单元不是实现单元。

根据 [module.reach]p1[module.reach]p2(来自 N4986)

如果翻译单元 U 是包含 P 的翻译单元在 P 之前具有接口依赖关系的模块接口单元,或者包含 P 的翻译单元导入 U,则翻译单元 U 必然可以从点 P 访问。

所有必然可以访问的翻译单元都是可以访问的。程序中该点具有接口依赖关系的其他翻译单元可以被视为可以访问的,但哪些翻译单元以及在什么情况下可以访问是未指定的。

例如,

// a.cpp
import B;
int main()
{
    g<void>();
}

// b.cppm
export module B;
import :C;
export template <typename T> inline void g() noexcept
{
    return f<T>();
}

// c.cppm
module B:C;
template<typename> inline void f() noexcept {}

内部分区单元c.cppm不一定能被a.cpp访问,因为c.cppm不是模块接口单元,而a.cpp没有导入c.cppm。这将由编译器决定c.cppm是否可以被a.cpp访问。Clang 的行为是间接导入的内部分区单元不可访问。

在 Clang 中使用内部分区单元的建议方法是在实现单元中导入它们。

已知问题

以下描述了当前模块实现中的问题。请参阅模块问题列表以获取问题列表,或者如果您没有找到现有问题,请提交新问题。在为标准 C++ 模块创建新问题时,请在标题开头添加[C++20] [Modules](或[C++23] [Modules]等)并在可能的情况下添加标签clang:modules

可以在C++ 功能状态页面上找到对包括模块在内的标准功能支持的高级概述。

在导入后包含头文件不受良好支持

以下示例被接受

#include <iostream>
import foo; // assume module 'foo' contain the declarations from `<iostream>`

int main(int argc, char *argv[])
{
    std::cout << "Test\n";
    return 0;
}

但是,如果#include <iostream>import foo;的顺序相反,则当前代码会被拒绝

import foo; // assume module 'foo' contain the declarations from `<iostream>`
#include <iostream>

int main(int argc, char *argv[])
{
    std::cout << "Test\n";
    return 0;
}

以上两个示例都应该被接受。

这是实现的一个限制。在第一个示例中,编译器将首先看到并解析<iostream>,然后它将看到import。在这种情况下,ODR 检查和声明合并将在反序列化器中进行。在第二个示例中,编译器将首先看到import,然后看到#include,这会导致 ODR 检查和声明合并发生在语义分析器中。这是由于实现路径中的差异。这是由#61465跟踪的。

忽略的preferred_name 属性

当 Clang 编写 BMI 时,它将忽略使用该属性的声明上的preferred_name属性。因此,首选名称不会按预期在调试器中显示。这是由#56490跟踪的。

不要发出有关模块声明的宏

这是由P1857R3涵盖的。在这里提到它,因为我们希望用户了解我们还没有实现它。

编写可以由模块和非模块构建都编译的代码的直接方法可能如下所示

MODULE
IMPORT header_name
EXPORT_MODULE MODULE_NAME;
IMPORT header_name
EXPORT ...

这样做是为了使该文件可以作为模块单元或非模块单元进行编译,具体取决于某些宏的定义。但是,P1857R3 禁止这种用法,而 Clang 尚未实现 P1857R3。这意味着有可能编写无效的模块,这些模块在实现 P1857R3 后将不再被接受。这是由#54047跟踪的。

在此之前,建议不要将宏与模块声明混合使用。

可导入模块单元的命名约定不一致

当前,Clang 要求importable module unit的文件名具有.cppm(或.ccm.cxxm.c++m)作为文件扩展名。但是,该行为与其他编译器不一致。这是由#57416跟踪的。

不正确的 ODR 违规诊断

ODR 违规是使用模块时的常见问题。Clang 有时会产生误报诊断或无法产生真阳性诊断。一个经常被报告的例子是

// part.cc
module;
typedef long T;
namespace ns {
inline void fun() {
    (void)(T)0;
}
}
export module repro:part;

// repro.cc
module;
typedef long T;
namespace ns {
    using ::T;
}
namespace ns {
inline void fun() {
    (void)(T)0;
}
}
export module repro;
export import :part;

当前编译器错误地诊断了两个模块单元中fun()的不一致定义。因为fun()的两个定义具有相同的拼写,并且T引用的是同一个类型实体,所以不存在 ODR 违规。这是由#78850跟踪的。

在其他单元中使用 TU 本地实体

模块单元是翻译单元,因此应该对模块单元本身保持本地的实体永远不应该被其他单元使用。

C++ 标准在basic.link/p14basic.link/p15basic.link/p16basic.link/p17basic.link/p18中定义了TU-localexposure的概念。

但是,Clang 没有正式支持这两个概念。这会导致不清楚或混乱的诊断消息。此外,Clang 可能会将TU-local实体导入到其他单元中,而不会产生任何诊断信息。这是由#78173跟踪的。

头文件单元

如何使用头文件单元构建项目

警告

对头文件单元的支持(包括相关的命令行选项)是实验性的。关于工具如何与头文件单元交互,还有许多未解决的问题。此处描述的细节将来可能会发生变化。

快速入门

以下示例

import <iostream>;
int main() {
  std::cout << "Hello World.\n";
}

可以使用以下方法编译

$ clang++ -std=c++20 -xc++-system-header --precompile iostream -o iostream.pcm
$ clang++ -std=c++20 -fmodule-file=iostream.pcm main.cpp

如何生成 BMI

与命名模块类似,--precompile可用于生成 BMI。但是,这需要使用-xc++-system-header-xc++-user-header指定输入文件是头文件。

-fmodule-header={user,system}选项也可以用于生成具有类似.h.hh的文件扩展名的头文件单元的 BMI。-fmodule-header的参数指定用户搜索路径或系统搜索路径。-fmodule-header的默认值为user。例如

// foo.h
#include <iostream>
void Hello() {
  std::cout << "Hello World.\n";
}

// use.cpp
import "foo.h";
int main() {
  Hello();
}

可以使用以下方法编译

$ clang++ -std=c++20 -fmodule-header foo.h -o foo.pcm
$ clang++ -std=c++20 -fmodule-file=foo.pcm use.cpp

对于没有文件扩展名的头文件,必须使用-xc++-header(或-xc++-system-header-xc++-user-header)指定该文件为头文件。例如

// use.cpp
import "foo.h";
int main() {
  Hello();
}
$ clang++ -std=c++20 -fmodule-header=system -xc++-header iostream -o iostream.pcm
$ clang++ -std=c++20 -fmodule-file=iostream.pcm use.cpp

如何指定依赖的 BMI

-fmodule-file可用于指定依赖的 BMI(或多次指定多个依赖的 BMI)。

在现有实现中,-fprebuilt-module-path不能用于头文件单元(因为它们本质上是匿名的)。对于头文件单元,使用-fmodule-file包含每个头文件单元的相关 PCM 文件。

预计这将在 Clang 的未来版本中解决,方法是编译器自动查找和指定-fmodule-file,或者通过使用了解如何将头文件名称映射到其 PCM 的模块映射器来解决。

将头文件单元编译为目标文件

由于头文件单元的语义,头文件单元无法编译为目标文件。例如

$ clang++ -std=c++20 -xc++-system-header --precompile iostream -o iostream.pcm
# This is not allowed!
$ clang++ iostream.pcm -c -o iostream.o

包含翻译

C++ 标准允许供应商将#include header-name转换为import header-name;(在可能的情况下)。当前,Clang 对全局模块片段中的#include执行此翻译。例如,以下示例

module;
import <iostream>;
export module M;
export void Hello() {
  std::cout << "Hello.\n";
}

与以下示例相同

module;
#include <iostream>
export module M;
export void Hello() {
    std::cout << "Hello.\n";
}
$ clang++ -std=c++20 -xc++-system-header --precompile iostream -o iostream.pcm
$ clang++ -std=c++20 -fmodule-file=iostream.pcm --precompile M.cppm -o M.cpp

在后面的示例中,Clang 可以找到<iostream>的 BMI,因此它尝试自动将#include <iostream>替换为import <iostream>;

Clang 模块和头文件单元之间的差异

头文件单元具有与 Clang 模块相似的语义。两者的语义都类似于头文件。因此,可以通过 Clang 模块模拟头文件单元,如以下示例所示

module "iostream" {
  export *
  header "/path/to/libstdcxx/iostream"
}
$ clang++ -std=c++20 -fimplicit-modules -fmodule-map-file=.modulemap main.cpp

使用 libc++ 时,此示例会简化

$ clang++ -std=c++20 main.cpp -fimplicit-modules -fimplicit-module-maps

因为 libc++ 已经提供了一个模块映射

这就引出了一个问题:为什么头文件单元没有通过 Clang 模块实现?

这主要是因为Clang模块在将多个头文件包装成一个模块时具有更层次化的语义,而标准C++头文件单元不支持这种语义。我们不希望给人的印象是这些额外的语义被解释为标准C++的行为。

另一个原因是,有一些提议要将模块映射器引入C++标准(例如,https://wg21.link/p1184r2)。如果我们需要引入另一个模块映射器,重用Clang的modulemap可能会更加困难。

发现依赖项

在没有使用模块的情况下,项目中的所有翻译单元都可以并行编译。但是,模块单元的存在要求以拓扑顺序编译翻译单元。

the clang-scan-deps 工具可以提取依赖信息并生成一个符合P1689规范的JSON文件。目前只支持命名模块。

在使用clang-scan-deps时,需要一个编译数据库。有关编译数据库的更多信息,请参阅JSON编译数据库格式规范。请注意,output JSON属性对于clang-scan-deps使用P1689格式进行扫描是必要的。例如

//--- M.cppm
export module M;
export import :interface_part;
import :impl_part;
export int Hello();

//--- interface_part.cppm
export module M:interface_part;
export void World();

//--- Impl.cpp
module;
#include <iostream>
module M;
void Hello() {
    std::cout << "Hello ";
}

//--- impl_part.cppm
module;
#include <string>
#include <iostream>
module M:impl_part;
import :interface_part;

std::string W = "World.";
void World() {
    std::cout << W << std::endl;
}

//--- User.cpp
import M;
import third_party_module;
int main() {
  Hello();
  World();
  return 0;
}

这是编译数据库

[
{
    "directory": ".",
    "command": "<path-to-compiler-executable>/clang++ -std=c++20 M.cppm -c -o M.o",
    "file": "M.cppm",
    "output": "M.o"
},
{
    "directory": ".",
    "command": "<path-to-compiler-executable>/clang++ -std=c++20 Impl.cpp -c -o Impl.o",
    "file": "Impl.cpp",
    "output": "Impl.o"
},
{
    "directory": ".",
    "command": "<path-to-compiler-executable>/clang++ -std=c++20 impl_part.cppm -c -o impl_part.o",
    "file": "impl_part.cppm",
    "output": "impl_part.o"
},
{
    "directory": ".",
    "command": "<path-to-compiler-executable>/clang++ -std=c++20 interface_part.cppm -c -o interface_part.o",
    "file": "interface_part.cppm",
    "output": "interface_part.o"
},
{
    "directory": ".",
    "command": "<path-to-compiler-executable>/clang++ -std=c++20 User.cpp -c -o User.o",
    "file": "User.cpp",
    "output": "User.o"
}
]

要以P1689格式获取依赖信息,请使用

$ clang-scan-deps -format=p1689 -compilation-database P1689.json

获取

{
  "revision": 0,
  "rules": [
    {
      "primary-output": "Impl.o",
      "requires": [
        {
          "logical-name": "M",
          "source-path": "M.cppm"
        }
      ]
    },
    {
      "primary-output": "M.o",
      "provides": [
        {
          "is-interface": true,
          "logical-name": "M",
          "source-path": "M.cppm"
        }
      ],
      "requires": [
        {
          "logical-name": "M:interface_part",
          "source-path": "interface_part.cppm"
        },
        {
          "logical-name": "M:impl_part",
          "source-path": "impl_part.cppm"
        }
      ]
    },
    {
      "primary-output": "User.o",
      "requires": [
        {
          "logical-name": "M",
          "source-path": "M.cppm"
        },
        {
          "logical-name": "third_party_module"
        }
      ]
    },
    {
      "primary-output": "impl_part.o",
      "provides": [
        {
          "is-interface": false,
          "logical-name": "M:impl_part",
          "source-path": "impl_part.cppm"
        }
      ],
      "requires": [
        {
          "logical-name": "M:interface_part",
          "source-path": "interface_part.cppm"
        }
      ]
    },
    {
      "primary-output": "interface_part.o",
      "provides": [
        {
          "is-interface": true,
          "logical-name": "M:interface_part",
          "source-path": "interface_part.cppm"
        }
      ]
    }
  ],
  "version": 1
}

有关字段的含义,请参阅P1689文档。

可以获取更细粒度的控制(例如扫描生成的源文件)的每个文件的依赖信息。例如

$ clang-scan-deps -format=p1689 -- <path-to-compiler-executable>/clang++ -std=c++20 impl_part.cppm -c -o impl_part.o

将生成

{
  "revision": 0,
  "rules": [
    {
      "primary-output": "impl_part.o",
      "provides": [
        {
          "is-interface": false,
          "logical-name": "M:impl_part",
          "source-path": "impl_part.cppm"
        }
      ],
      "requires": [
        {
          "logical-name": "M:interface_part"
        }
      ]
    }
  ],
  "version": 1
}

可以在--之后指定单个命令行选项。clang-scan-deps将从指定的选项中提取必要的信息。请注意,需要显式指定编译器可执行文件的路径,而不是直接使用clang++

用户可能希望扫描器获取头文件的过渡依赖信息。否则,项目必须扫描两次,一次扫描头文件,一次扫描模块。为了解决这个问题,clang-scan-deps将识别给定命令行中指定的预处理器选项并生成相应的依赖信息。例如

$ clang-scan-deps -format=p1689 -- ../bin/clang++ -std=c++20 impl_part.cppm -c -o impl_part.o -MD -MT impl_part.ddi -MF impl_part.dep
$ cat impl_part.dep

将生成

impl_part.ddi: \
  /usr/include/bits/wchar.h /usr/include/bits/types/wint_t.h \
  /usr/include/bits/types/mbstate_t.h \
  /usr/include/bits/types/__mbstate_t.h /usr/include/bits/types/__FILE.h \
  /usr/include/bits/types/FILE.h /usr/include/bits/types/locale_t.h \
  /usr/include/bits/types/__locale_t.h \
  ...

clang-scan-deps检测到-MF选项时,它将尝试将头文件的依赖信息写入由-MF指定的路径。

可能的问题:无法找到系统头文件

如果遇到类似fatal error: 'stddef.h' file not found的错误,指定的<path-to-compiler-executable>/clang++可能指的是符号链接而不是真实二进制文件。这个问题有四种可能的解决方案

  1. 将指定的编译器可执行文件指向真实的二进制文件,而不是符号链接。

  2. 调用<path-to-compiler-executable>/clang++ -print-resource-dir以获取编译器的相应资源目录,并在构建脚本中手动将该目录添加到包含搜索路径中。

  3. 对于将编译数据库用作clang-scan-deps输入的构建系统,构建系统可以在执行clang-scan-deps时添加--resource-dir-recipe invoke-compiler选项来动态计算资源目录。对于唯一的<path-to-compiler-executable>/clang++,计算只执行一次。

  4. 对于逐文件调用clang-scan-deps的构建系统,重复计算资源目录可能会效率低下。在这种情况下,构建系统可以缓存资源目录并显式指定-resource-dir <resource-dir>,例如

    $ clang-scan-deps -format=p1689 -- <path-to-compiler-executable>/clang++ -std=c++20 -resource-dir <resource-dir> mod.cppm -c -o mod.o
    

使用clang-repl导入模块

clang-repl支持导入C++20命名模块。例如

// M.cppm
export module M;
export const char* Hello() {
    return "Hello Interpreter for Modules!";
}

命名模块仍然需要提前编译。

$ clang++ -std=c++20 M.cppm --precompile -o M.pcm
$ clang++ M.pcm -c -o M.o
$ clang++ -shared M.o -o libM.so

请注意,模块单元需要作为动态库编译,以便clang-repl可以加载模块单元的对象文件。然后,就可以在clang-repl中导入模块M

$ clang-repl -Xcc=-std=c++20 -Xcc=-fprebuilt-module-path=.
# We need to load the dynamic library first before importing the modules.
clang-repl> %lib libM.so
clang-repl> import M;
clang-repl> extern "C" int printf(const char *, ...);
clang-repl> printf("%s\n", Hello());
Hello Interpreter for Modules!
clang-repl> %quit

可能的问题

模块如何加快编译速度

关于模块为什么能加快编译速度的一个经典理论是:如果有n个头文件和m个源文件,并且每个头文件都被每个源文件包含,那么编译的复杂度是O(n*m)。但是,如果有n个模块接口和m个源文件,那么编译的复杂度是O(n+m)。因此,在规模上使用模块将是一个显著的改进。更简单地说,使用模块会导致许多冗余的编译不再必要。

虽然从高层次上看这是准确的,但这在很大程度上取决于优化级别,如下所示。

首先是-O0。编译过程在下面的图表中描述。

├-------------frontend----------┼-------------middle end----------------┼----backend----┤
│                               │                                       │               │
└---parsing----sema----codegen--┴----- transformations ---- codegen ----┴---- codegen --┘

├---------------------------------------------------------------------------------------┐
|                                                                                       │
|                                     source file                                       │
|                                                                                       │
└---------------------------------------------------------------------------------------┘

            ├--------┐
            │        │
            │imported│
            │        │
            │  code  │
            │        │
            └--------┘

在这种情况下,源文件(可以是非模块单元或模块单元)将被整个管道处理。但是,导入的代码只会在语义分析中参与,而语义分析主要是名称查找、重载解析和模板实例化。所有这些过程都比整个编译过程快。更重要的是,导入的代码只需要在前端代码生成期间处理一次,以及整个中间端和后端。所以,我们可以从-O0的编译时间中获得很大的收益。

但是,在优化的情况下,情况有所不同(由于空间有限,省略了每个端的code generation部分)

├-------- frontend ---------┼--------------- middle end --------------------┼------ backend ----┤
│                           │                                               │                   │
└--- parsing ---- sema -----┴--- optimizations --- IPO ---- optimizations---┴--- optimizations -┘

├-----------------------------------------------------------------------------------------------┐
│                                                                                               │
│                                         source file                                           │
│                                                                                               │
└-----------------------------------------------------------------------------------------------┘
              ├---------------------------------------┐
              │                                       │
              │                                       │
              │            imported code              │
              │                                       │
              │                                       │
              └---------------------------------------┘

如果使用模块后最终导致性能下降,那将非常不幸。主要的问题是,当编译源文件时,编译器需要看到导入的模块单元的代码体,以便它可以执行IPO(过程间优化,在实践中主要是内联)来利用导入的模块单元提供的信息来优化当前源文件中的函数。换句话说,导入的代码会在导入单元中被优化(包括IPO本身)反复处理。IPO之前的优化和IPO本身是整个编译过程中最耗时的部分。所以,从这个角度来看,可能无法实现所描述的编译时间改进,但在IPO后的优化和整个后端可能会节省时间。

总的来说,在-O0下,模块中定义的函数的实现不会影响模块用户,但在更高的优化级别上,这些函数的定义会被提供给用户编译,用于优化目的(但这些函数的定义仍然不会包含在用户的目标文件中)。这意味着在更高的优化级别上,构建速度提升可能低于-O0体验所预期的,但它确实提供了更多优化机会。

与Clang模块的互操作性

我们希望同时支持Clang模块和标准C++模块,但将它们混合使用还没有得到很好的使用/测试。如果您发现互操作性问题,请提交新的GitHub问题。