模块(或预编译模块,PCM)


文档摘要

模块(或预编译模块,PCM)可以视为预编译头文件进化的下一步,也代表了一种以二进制形式存在的解析过的 AST(抽象语法树),但形成了一种 DAG(有向无环图),意味着一个模块可以包含多个其他模块。 与预编译头文件相比,这是一个重大的改进,在每个编译单元中只能引入一个预编译头文件。 C++20 标准引入了与模块相关的两个概念。第一个是普通模块,描述在 [^1] 的第 10 节中。另一个是所谓的头单元,主要描述在第 15.5 节中。头单元可以视为普通头文件和模块之间的中间步骤,并允许使用导入指令来导入普通头文件。 我们将关注 Clang 模块,这可以视为 C++ 标准中头单元的实现。使用 Clang 模块有兩種不同的选项:显式模块和隐式模块。

模块(或预编译模块,PCM)可以视为预编译头文件进化的下一步,也代表了一种以二进制形式存在的解析过的
AST(抽象语法树),但形成了一种
DAG(有向无环图),意味着一个模块可以包含多个其他模块。

与预编译头文件相比,这是一个重大的改进,在每个编译单元中只能引入一个预编译头文件。

C++20 标准引入了与模块相关的两个概念。第一个是普通模块,描述在 1 的第
10 节中。另一个是所谓的头单元,主要描述在第 15.5
节中。头单元可以视为普通头文件和模块之间的中间步骤,并允许使用导入指令来导入普通头文件。

我们将关注 Clang 模块,这可以视为 C++ 标准中头单元的实现。使用 Clang
模块有兩種不同的选项:显式模块和隐式模块。我们将探索这两种情况,但会从描述我们想要使用模块的测试项目开始。

为了进行模块实验,我们将考虑一个包含两个头文件的示例:header1.h 和
header2.h,分别定义了 void foo1() 和 void foo2() 函数:

#pragma once

void foo1()

头文件:header1.h

#pragma once

void foo2()

头文件:header2.h

图10.6:测试使用的头文件

这些头文件将用于以下源文件:

#include "header1.h" #include "header2.h"

int main() foo1(); foo2(); return 0;

图10.7:源文件:main.cpp

将这些头文件组织成模块。Clang
使用一个特殊文件来描述逻辑结构,这称为模块映射文件。

来看看我们的测试项目的模块映射文件是什么样的。

项目的模块映射文件将命名为 module.modulemap 并具有以下内容:

module header1 header "header1.h" export * module header2 header
"header2.h" export *

图10.8:模块映射文件:module.modulemap

如图 10.8 所示,定义了两个模块,header1 和 header2。

每个模块都只包含一个头文件,并且导出了该头文件中的所有符号。

现在已经收集了所有必要的部分,准备构建和使用模块。模块可以显式或隐式地构建。

让我们从显式构建开始。

模块的结构由模块映射文件描述,如图 10.8
所示。每个模块只有一个头文件,但实际的模块可能包含多个头文件。要构建一个模块,需要指定模块的结构(模块映射文件)和我们想要构建的模块名称。例如,对于
header1 模块,可以使用以下构建命令:

$`<...>/llvm-project/install/bin/clang -cc1
-emit-module -o header1.pcm
-fmodules module.modulemap -fmodule-name=header1
-x c++-header -fno-implicit-modules
\end{shell}

编译命令中有几个重要的方面。第一个是-cc1选项,表示只调用编译器前端。有关更多信息,请参阅第2.3节,"Clang驱动程序概览"。此外,可以通过使用以下选项:-emit-module -o header1.pcm,指定想要创建一个名为header1.pcm的构建工件(模块)。模块的逻辑结构和需要构建的模块在module.modulemap文件中指定,该文件必须作为编译参数与-fmodule-name=header1选项一起指定。启用模块功能是通过使用-fmodules标志完成的,还可以通过-x c++header选项指定我们的头文件是C++头文件。为了显式禁用隐式模块,在命令中包含了-fno-implicit-modules,因为隐式模块(将在稍后的图10.9"隐式模块"中进行研究)默认是启用的,但此刻暂时不使用它们。

第二个模块(header2)有类似的编译命令:

\begin{shell}`$ <...>/llvm-project/install/bin/clang -cc1
 -emit-module -o header2.pcm  -fmodules module.modulemap
-fmodule-name=header2  -x c++-header -fno-implicit-modules

下一步是使用生成的模块编译main.cpp:

$` <...>/llvm-project/install/bin/clang -cc1       
-emit-obj main.cpp                         
-fmodules -fmodule-map-file=module.modulemap 
-fmodule-file=header1=header1.pcm          
-fmodule-file=header2=header2.pcm          
-o main.o -fno-implicit-modules
\end{shell}

模块名称和构建工件(PCM文件)都是使用-fmodule-file编译选项指定的。使用的格式,如header1=header1.pcm,表明header1.pcm对应于header1模块。可以使用-fmodule-map-file选项指定modulemap文件。这里,创建了两个构建工件:header1.pcm和header2.pcm,并将它们一起用于编译。这在预编译头文件的情况下是不可能的,因为只允许使用一个预编译头文件。

我们生成了一个对象文件main.o作为编译命令的结果。对象文件可以按以下方式链接:

\begin{shell}`$ <...>/llvm-project/install/bin/clang main.o -o main -lstdc++

验证在编译期间是否加载了模块。这可以用LLDB完成:

$`lldb <...>/llvm-project/install/bin/clang -- -cc1 -emit-obj main.cpp -fmodules -fmodule-map-file=module.modulemap -fmodule-file=header1=header1.pcm -fmodule-file=header2=header2.pcm -o main.o -fno-implicit-modules
 ...
 (lldb) b clang::CompilerInstance::findOrCompileModuleAndReadAST
 ...
 (lldb) r
 ...
 Process 135446 stopped
 * thread #1, name = 'clang', stop reason = breakpoint 1.1
     frame #0: ... findOrCompileModuleAndReadAST(..., ModuleName=(Data = "header1", Length = 7), ...
 ...
 (lldb) c
 Process 135446 stopped
 * thread #1, name = 'clang', stop reason = breakpoint 1.1
     frame #0: ... findOrCompileModuleAndReadAST(..., ModuleName=(Data = "header2", Length = 7), ....
 ...
 (lldb) c
 Process 135446 resumed
 Process 135446 exited with status = 0 (0x00000000)
\end{shell}

\begin{center}
图 10.9:显式模块加载
\end{center}

在clang::CompilerInstance::findOrCompileModuleAndReadAST处设置了断点,如图10.9中的第3行所示。命中了两次断点:第一次是在第9行,针对名为header1的模块;然后是在第14行,针对名为header2的模块。

使用显式模块时,必须在所有编译命令中明确定义构建文件,并指定它们将存储的路径。然而,所有必需的信息都存储在modulemap文件中(参见图10.8)。编译器可以利用这些信息自动创建所有必要的构建文件。对于这个问题,答案是肯定的,这个功能由隐式模块提供。

\mySamllsection{隐式模块}

modulemap文件包含了构建所有模块(header1和header2)并用于依赖文件(main.cpp)构建所需的所有信息。因此,必须指定modulemap文件的路径,以及构建文件将存储的文件夹。这可以按以下方式完成:

\begin{shell}`$ <...>/llvm-project/install/bin/clang -cc1  -emit-obj main.cpp                   -fmodules                            -fmodule-map-file=module.modulemap   -fmodules-cache-path=./cache         -o main.o

没有指定-fno-implicit-modules,还使用-fmodules-cache-path=./cache指定了构建工件的路径。如果检查该路径,就能够看到创建的模块:

$`tree ./cache
./cache
|-- 2AL78TH69W6HR
|-- header1-R65CPR1VCRM1.pcm
|-- header2-R65CPR1VCRM1.pcm
|-- modules.idx
2 directories, 3 files
\end{shell}

\begin{center}
图 10.10:Clang为隐式模块生成的缓存
\end{center}

Clang将监视缓存文件夹(在我们的例子中是./cache),并删除长时间未使用的构建工件。如果依赖项(例如,包含的头文件)发生了变化,它还将重新构建模块。

模块是一个非常强大的工具,但像所有强大的工具一样,它们可能会引入一些复杂的问题。让我们探讨由模块引起的一个最有趣的问题。

与模块相关的一些问题,使用模块的代码可能会在程序中引入一些复杂的行为。考虑一个由两个头文件组成的项目,如下所示:

\begin{cpp}
#pragma once

int h1 = 1;
\end{cpp}

头文件:header1.h

\begin{cpp}
#pragma once

int h2 = 2;
\end{cpp}

头文件:header2.h

\begin{center}
图 10.11:将用于测试的头文件
\end{center}

只有header1.h被包含在main.cpp中,如下所示

\begin{cpp}
#include "header1.h"

int main() {
int h = h1 + h2;
return 0;
}
\end{cpp}

\begin{center}
图 10.12:源文件:main.cpp
\end{center}

代码将无法编译:

\begin{shell}`$ <...>/llvm-project/install/bin/clang main.cpp -o main
-lstdc++ main.cpp:4:16: error: use of undeclared identifier ’h2’ int h =
h1 + h2; ^ 1 error generated.

图 10.13:由于缺少头文件而产生的编译错误

错误很明显,因为没有包含包含h2变量定义的第二个头文件。

如果使用隐式模块,情况将有所不同。考虑以下module.modulemap文件:

module h1 header "header1.h" export * module h2 header "header2.h"
export *

图 10.14:引入隐式依赖的modulemap文件

这个文件创建了两个模块,h1和h2。第二个模块包含在第一个模块中。

如果我们按以下方式编译,编译将成功:

$<...>/llvm-project/install/bin/clang -cc1 \ -emit-obj main.cpp \ -fmodules \ -fmodule-map-file=module.modulemap\ -fmodules-cache-path=./cache \ -o main.o$ <...>/llvm-project/install/bin/clang main.o -o
main -lstdc++

图 10.15:对于缺少头文件的文件,但在启用隐式模块的情况下成功编译

编译完成且没有错误,因为modulemap文件隐式地将header2.h添加到了使用的模块(h1)中。我们还使用了export
*指令导出了所有符号,当Clang遇到#include
"header1.h"时,它会加载相应的h1模块,因此隐式地加载了在h2模块和header2.h头文件中定义的符号。

这个例子说明了当项目中使用模块时,可见性范围可能会泄露。这可能会导致项目构建时出现意外行为,尤其是当项目启用和禁用模块时。当模块启用时,由于隐式依赖关系,可能会导致原本不应该直接可见的符号变得可见,这可能会隐藏真正的依赖关系问题。而当模块禁用时,由于缺少显式的头文件包含,可能会导致编译错误。使用模块时,需要仔细管理模块间的关系和符号的导出,以确保项目的一致性和可预测性。

  1. 国际标准化组织. 国际标准 ISO/IEC 14882:2020(E) – 编程语言 – C++.
    国际标准化组织,2020. URL https://www.iso.org/standard/73560.html


发布者: 作者: 转发
评论区 (0)
U