模块

简介

大多数软件都是使用多个软件库构建的,包括平台提供的库、软件本身构建以提供结构的内部库以及第三方库。对于每个库,都需要访问其接口(API)和实现。在 C 语言家族中,库的接口是通过包含适当的头文件(s)来访问的。

#include <SomeLib.h>

实现是通过链接到相应的库来单独处理的。例如,通过将 -lSomeLib 传递给链接器。

模块提供了一种使用软件库的替代方法,更简单,提供了更好的编译时可扩展性,并消除了使用 C 预处理器访问库 API 的许多固有问题。

当前模型的问题

C 预处理器提供的 #include 机制是一种非常糟糕的方式来访问库的 API,原因有很多。

  • 编译时可扩展性:每次包含头文件时,编译器都必须预处理和解析该头文件以及它包含的所有头文件中的文本,进行传递。此过程必须对应用程序中的每个翻译单元重复执行,这涉及大量冗余工作。在一个包含N个翻译单元和每个翻译单元包含M个头文件的项目中,编译器执行M x N的工作,即使大多数M个头文件在多个翻译单元之间共享。C++ 尤其糟糕,因为模板的编译模型迫使大量代码进入头文件。

  • 脆弱性#include 指令被预处理器视为文本包含,因此受包含时任何活动的宏定义的影响。如果任何活动的宏定义恰好与库中的名称发生冲突,它可能会破坏库 API 或导致库头文件本身的编译失败。例如,#define std "The C++ Standard",然后包含一个标准库头文件:结果是在 C++ 标准库的实现中出现一系列可怕的错误。当两个不同库的头文件由于宏冲突而相互作用时,会出现更微妙的实际问题,用户被迫重新排序 #include 指令或引入 #undef 指令来打破(无意的)依赖关系。

  • 传统解决方案:C 程序员已经采用了许多约定来解决 C 预处理器模型的脆弱性。例如,包含保护对于绝大多数头文件是必需的,以确保多次包含不会破坏编译。宏名称用 LONG_PREFIXED_UPPERCASE_IDENTIFIERS 编写以避免冲突,一些库/框架开发人员甚至在头文件中使用 __underscored 名称以避免与(按照惯例)甚至不应该宏的“正常”名称发生冲突。这些约定对于来自非 C 语言的开发人员来说是一个进入障碍,对于更有经验的开发人员来说是样板代码,并且使我们的头文件比它们应该的要丑陋得多。

  • 工具混淆:在基于 C 的语言中,很难构建与软件库配合良好的工具,因为库的边界并不清晰。哪些头文件属于特定的库,以及应该按什么顺序包含这些头文件才能保证它们正确编译?这些头文件是 C、C++、Objective-C++ 还是这些语言的变体之一?这些头文件中的哪些声明实际上是 API 的一部分,哪些声明只是因为它们必须作为头文件的一部分而存在?

语义导入

模块通过用更强大、更高效的语义模型替换文本预处理器包含模型来改进对软件库 API 的访问。从用户的角度来看,代码看起来只有很小的不同,因为使用的是 import 声明而不是 #include 预处理器指令。

import std.io; // pseudo-code; see below for syntax discussion

但是,此模块导入的行为与相应的 #include <stdio.h> 非常不同:当编译器看到上面的模块导入时,它会加载 std.io 模块的二进制表示,并使其 API 可供应用程序直接使用。在导入声明之前的预处理器定义对 std.io 提供的 API 没有影响,因为模块本身是作为独立的模块编译的。此外,使用 std.io 模块所需的任何链接器标志将在导入模块时自动提供 [1] 此语义导入模型解决了预处理器包含模型的许多问题。

  • 编译时可扩展性std.io 模块只编译一次,将模块导入到翻译单元是一个常数时间操作(独立于模块系统)。因此,每个软件库的 API 只解析一次,将M x N编译问题减少到M + N问题。

  • 脆弱性:每个模块都被解析为一个独立的实体,因此它具有一致的预处理器环境。这完全消除了对 __underscored 名称和类似防御技巧的需求。此外,遇到导入声明时的当前预处理器定义将被忽略,因此一个软件库不能影响另一个软件库的编译方式,消除了包含顺序依赖关系。

  • 工具混淆:模块描述软件库的 API,工具可以推理和呈现模块作为该 API 的表示。因为模块只能独立构建,所以工具可以依赖模块定义来确保它们获得库的完整 API。此外,模块可以指定它们使用的语言,因此,例如,人们不会意外地尝试将 C++ 模块加载到 C 程序中。

模块无法解决的问题

许多编程语言都有模块或包系统,由于这些语言提供的功能多种多样,因此必须定义模块做什么。特别是,以下所有内容都被认为超出了模块的范围。

  • 重写世界上的代码:要求应用程序或软件库进行重大或非向后兼容的更改是不现实的,也不可能完全消除头文件。模块必须与现有的软件库互操作,并允许逐步过渡。

  • 版本控制:模块没有版本信息的概念。程序员仍然必须依赖底层语言的现有版本控制机制(如果存在)来对软件库进行版本控制。

  • 命名空间:与某些语言不同,模块不暗示任何命名空间的概念。因此,在一个模块中声明的结构仍然会与在另一个模块中声明的同名结构发生冲突,就像它们在两个不同的头文件中声明一样。这对于向后兼容性很重要,因为(例如)软件库中实体的混淆名称在引入模块时不能改变。

  • 模块的二进制分发:头文件(尤其是 C++ 头文件)公开了语言的全部复杂性。在跨架构、编译器版本和编译器供应商维护稳定的二进制模块格式在技术上是不可行的。

使用模块

要启用模块,请传递命令行标志 -fmodules。这将使任何启用模块的软件库可用作模块,以及引入任何特定于模块的语法。其他 命令行参数 在后面的单独部分中描述。

标准 C++ 模块

注意

模块被采用到 C++20 标准中。其语义和命令行接口与 Clang C++ 模块非常不同。有关详细信息,请参阅 StandardCPlusPlusModules

Objective-C 导入声明

Objective-C 提供了通过@import 声明导入模块的语法,该声明导入名为模块。

@import std;

上面的 @import 声明导入 std 模块的全部内容(其中将包含,例如,整个 C 或 C++ 标准库),并使其 API 可用于当前的翻译单元。要仅导入模块的一部分,可以使用点语法指定特定子模块,例如

@import std.io;

冗余导入声明将被忽略,并且可以随意在翻译单元的任何位置导入模块,只要导入声明在全局范围内即可。

目前,没有 C 或 C++ 语法用于导入声明。Clang 将跟踪 C++ 委员会中的模块提案。请参阅部分 包含作为导入 以查看模块今天如何导入。

包含作为导入

模块的主要用户级功能是导入操作,它提供对软件库 API 的访问。但是,今天的程序广泛使用 #include,并且假设所有这些代码都会在一夜之间改变是不现实的。相反,模块会自动将 #include 指令转换为相应的模块导入。例如,包含指令

#include <stdio.h>

将自动映射到模块 std.io 的导入。即使在语言中使用特定的 import 语法,此特定功能对于采用和向后兼容性也很重要:将 #include 自动转换为 import 使得应用程序可以在没有任何更改的情况下获得模块的优势(对于所有支持模块的库)。因此,用户可以使用一个编译器轻松使用模块,同时使用其他编译器回退到预处理器包含机制。

注意

#include 自动映射到 import 也解决了实现问题:导入一个包含某些实体(例如,一个 struct Point)定义的模块,然后解析包含另一个 struct Point 定义的头文件会导致重新定义错误,即使它是相同的 struct Point。通过将 #include 映射到 import,编译器可以确保它始终只看到模块中已解析的定义。

在构建模块时,也支持 #include_next,但有一个警告。 #include_next 的通常行为是在包含路径列表中搜索指定的文件名,从找到当前文件所在路径的下一个路径开始。由于在模块映射中列出的文件不是通过包含路径找到的,因此对于此类文件中的 #include_next 指令,使用了不同的策略:在包含路径列表中搜索指定的头文件名,以找到第一个会引用当前文件的包含路径。 #include_next 被解释为好像当前文件是在该路径中找到的。如果此搜索找到一个由模块映射命名的文件,则 #include_next 指令将被转换为导入,就像 #include 指令一样。``

模块映射

模块和头文件之间的关键链接由模块映射描述,它描述了现有头文件集合如何映射到模块的(逻辑)结构。例如,可以想象一个模块 std 涵盖了 C 标准库。C 标准库中的每个头文件(<stdio.h><stdlib.h><math.h> 等)将贡献到 std 模块,通过将它们各自的 API 放入相应的子模块(std.iostd.libstd.math 等)。拥有一个包含 std 模块的一部分的头文件列表,允许编译器将 std 模块构建为一个独立的实体,并且拥有从头文件名到(子)模块的映射,允许自动将 #include 指令转换为模块导入。

模块映射被指定为单独的文件(每个文件命名为 module.modulemap),与它们描述的头文件并排,这使得它们可以添加到现有的软件库中,而无需更改库头文件本身(在大多数情况下 [2])。实际的 模块映射语言 在后面部分描述。

注意

要真正看到模块带来的任何好处,首先必须为底层的 C 标准库以及它所依赖的库和头文件引入模块映射。部分 模块化平台 描述了编写这些模块映射所需的步骤。

可以在没有模块的情况下使用模块映射来检查头文件使用的完整性。为此,使用 -fimplicit-module-maps 选项而不是 -fmodules 选项,或者使用 -fmodule-map-file= 选项显式指定要加载的模块映射文件。

编译模型

模块的二进制表示形式由编译器根据需要自动生成。当导入一个模块时(例如,通过一个模块的头文件的 #include),编译器将生成自身的第二个实例 [3],使用一个新的预处理上下文 [4],来解析该模块中的头文件。然后将生成的抽象语法树 (AST) 持久化到模块的二进制表示中,然后加载到遇到模块导入的翻译单元中。

模块的二进制表示形式保存在模块缓存中。对模块的导入将首先查询模块缓存,如果所需的模块的二进制表示形式已存在,则将直接加载该表示形式。因此,模块的头文件只会被解析一次,而不是每个使用该模块的翻译单元都解析一次。

模块维护对每个构成模块构建部分的头文件的引用。如果这些头文件中的任何一个发生变化,或者如果模块所依赖的任何模块发生变化,那么该模块将被(自动)重新编译。此过程永远不需要任何用户干预。

命令行参数

-fmodules

启用模块功能。

-fbuiltin-module-map

加载 Clang 内置模块映射文件。(等效于 -fmodule-map-file=<resource dir>/include/module.modulemap)

-fimplicit-module-maps

启用对名为 module.modulemap 和类似文件的模块映射文件的隐式搜索。此选项由 -fmodules 隐式启用。如果使用 -fno-implicit-module-maps 禁用此选项,则只有在通过 -fmodule-map-file 显式指定或由另一个模块映射文件传递使用时,才会加载模块映射文件。

-fmodules-cache-path=<directory>

指定模块缓存的路径。如果未提供,Clang 将选择一个系统适当的默认值。

-fno-autolink

禁用对与导入模块相关的库的自动链接。

-fmodules-ignore-macro=macroname

指示模块在选择适当的模块变体时忽略命名宏。将此用于在命令行定义的宏,这些宏不会影响模块的构建方式,以提高编译模块文件的共享性。

-fmodules-prune-interval=seconds

指定尝试清理模块缓存之间最短的延迟(以秒为单位)。模块缓存清理尝试清除旧的、未使用的模块文件,以使模块缓存本身不会无限增长。默认延迟很大(604,800 秒,或 7 天),因为这是一个昂贵的操作。将此值设置为 0 以关闭清理。

-fmodules-prune-after=seconds

指定模块缓存中的文件必须在模块清理将其删除之前处于未使用状态(根据访问时间)的最短时间(以秒为单位)。默认延迟很大(2,678,400 秒,或 31 天),以避免过度重建模块。

-module-file-info <module file name>

调试辅助工具,打印有关给定模块文件(扩展名为 .pcm)的信息,包括构建特定模块变体的语言和预处理器选项。

-fmodules-decluse

启用对模块 use 声明的检查。

-fmodule-name=module-id

将源文件视为给定模块的一部分。

-fmodule-map-file=<file>

如果加载了来自其目录或其子目录之一的头文件,则加载给定的模块映射文件。

-fmodules-search-all

如果未找到符号,则搜索当前模块映射中引用的但未导入以查找符号的模块,以便错误消息可以按名称引用该模块。请注意,如果在之前没有构建全局模块索引,这可能需要一些时间,因为它需要构建所有模块。请注意,此选项不适用于模块构建,以避免递归。

-fno-implicit-modules

构建使用的所有模块都必须使用 -fmodule-file 指定。

-fmodule-file=[<name>=]<file>

指定模块名称到预编译模块文件的映射。如果省略了名称,则无论是否实际需要,都会加载模块文件。如果指定了名称,则映射被视为另一种预构建模块搜索机制(除了 -fprebuilt-module-path 之外),并且只有在需要时才会加载模块。请注意,在这种情况下,指定的文件还会覆盖可能嵌入在其他预编译模块文件中的此模块的路径。

-fprebuilt-module-path=<directory>

指定预构建模块的路径。如果指定,我们将在此目录中查找给定顶级模块名称的模块。我们不需要模块映射来加载此目录中的预构建模块,编译器也不会尝试重建这些模块。可以多次指定此选项。

-fprebuilt-implicit-modules

启用预构建隐式模块。如果在预构建模块路径(通过 -fprebuilt-module-path 指定)中找不到预构建模块,我们将尝试在预构建模块路径中查找匹配的隐式模块。

-cc1 选项

-fmodules-strict-context-hash

启用对隐式构建中可能影响模块语义的所有编译器选项进行哈希处理。这包括诸如头文件搜索路径和诊断信息等。如果命令行参数在整个构建过程中不一致,使用此选项可能会导致构建过多的模块。

使用预构建模块

以下是一些示例,说明如何通过不同的选项使用预构建模块。

首先,让我们为示例设置文件。

/* A.h */
#ifdef ENABLE_A
void a() {}
#endif
/* B.h */
#include "A.h"
/* use.c */
#include "B.h"
void use() {
#ifdef ENABLE_A
  a();
#endif
}
/* module.modulemap */
module A {
  header "A.h"
}
module B {
  header "B.h"
  export *
}

在以下示例中,use.c 的编译无需 -cc1,但用于预构建模块的命令需要更新以考虑传递给 clang -cc1 的默认选项。(参见 clang use.c -v)还要注意,由于我们使用 -cc1,因此我们显式指定 -fmodule-map-file=-fimplicit-module-maps 选项。当使用 clang 驱动程序时,-fimplicit-module-maps-fmodules 隐式包含。

首先,让我们使用显式映射从模块到文件。

rm -rf prebuilt ; mkdir prebuilt
clang -cc1 -emit-module -o prebuilt/A.pcm -fmodules module.modulemap -fmodule-name=A
clang -cc1 -emit-module -o prebuilt/B.pcm -fmodules module.modulemap -fmodule-name=B -fmodule-file=A=prebuilt/A.pcm
clang -cc1 -emit-obj use.c -fmodules -fmodule-map-file=module.modulemap -fmodule-file=A=prebuilt/A.pcm -fmodule-file=B=prebuilt/B.pcm

除了手动指定映射之外,使用 -fprebuilt-module-path 选项也很方便。让我们也使用 -fimplicit-module-maps 而不是手动指向我们的模块映射。

rm -rf prebuilt; mkdir prebuilt
clang -cc1 -emit-module -o prebuilt/A.pcm -fmodules module.modulemap -fmodule-name=A
clang -cc1 -emit-module -o prebuilt/B.pcm -fmodules module.modulemap -fmodule-name=B -fprebuilt-module-path=prebuilt
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt

在一条命令中预构建源文件所需的所有模块的一个技巧是在使用 -fdisable-module-hash 选项的同时生成隐式模块。

rm -rf prebuilt ; mkdir prebuilt
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt -fdisable-module-hash
ls prebuilt/*.pcm
# prebuilt/A.pcm  prebuilt/B.pcm

请注意,使用显式或预构建模块,我们需要对模块的兼容性特别注意。使用不匹配的编译选项和模块可能会导致问题。

clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -DENABLE_A
# use.c:4:10: warning: implicit declaration of function 'a' is invalid in C99 [-Wimplicit-function-declaration]
#   return a(x);
#          ^
# 1 warning generated.

因此,我们需要维护多个版本的预构建模块。我们可以使用手动模块映射或指向不同的预构建模块缓存路径来实现。例如

rm -rf prebuilt ; mkdir prebuilt ; rm -rf prebuilt_a ; mkdir prebuilt_a
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt -fdisable-module-hash
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt_a -fdisable-module-hash -DENABLE_A
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt
clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt_a -DENABLE_A

除了手动管理不同的模块版本之外,我们可以在给定的缓存路径中构建隐式模块(使用 -fmodules-cache-path),并通过传递 -fprebuilt-module-path-fprebuilt-implicit-modules 将它们重新用作预构建隐式模块。

rm -rf prebuilt; mkdir prebuilt
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt -DENABLE_A
find prebuilt -name "*.pcm"
# prebuilt/1AYBIGPM8R2GA/A-3L1K4LUA6O31.pcm
# prebuilt/1AYBIGPM8R2GA/B-3L1K4LUA6O31.pcm
# prebuilt/VH0YZMF1OIRK/A-3L1K4LUA6O31.pcm
# prebuilt/VH0YZMF1OIRK/B-3L1K4LUA6O31.pcm
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -fprebuilt-implicit-modules
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -fprebuilt-implicit-modules -DENABLE_A

最后,我们希望为未预构建的配置允许隐式模块。当使用 clang 驱动程序时,会隐式选择模块缓存路径。使用 -cc1,我们只需使用 -fmodules-cache-path 选项即可。

clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -fprebuilt-implicit-modules -fmodules-cache-path=cache
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -fprebuilt-implicit-modules -fmodules-cache-path=cache -DENABLE_A
clang -cc1 -emit-obj -o use.o use.c -fmodules -fimplicit-module-maps -fprebuilt-module-path=prebuilt -fprebuilt-implicit-modules -fmodules-cache-path=cache -DENABLE_A -DOTHER_OPTIONS

这样,就可以准备并重复使用包含多个模块变体的单个目录。配置模块缓存的选项与其他选项无关。

模块语义

模块被建模为每个子模块都是一个单独的翻译单元,模块导入使来自其他翻译单元的名称可见。每个子模块都从一个新的预处理器状态和一个空的翻译单元开始。

注意

当使用子模块构建模块时,此行为目前仅被近似地实现。当构建模块中后面的子模块时,已构建子模块中的实体是可见的。这会导致依赖于模块子模块构建顺序的脆弱模块,不应依赖于此。此行为可能会发生变化。

例如,在 C 中,这意味着如果在不同的子模块中定义了两个具有相同名称的结构,这两个类型是不同的类型(但如果它们的定义匹配,则可能是兼容类型)。在 C++ 中,在不同的子模块中定义了相同名称的两个结构是相同类型,并且必须在 C++ 的一个定义规则下是等效的。

注意

Clang 目前仅对违反一个定义规则的情况进行最少的检查。

如果模块的任何子模块被导入到程序的任何部分,则整个顶级模块被认为是程序的一部分。因此,Clang 可能会诊断未导入子模块中声明的实体与当前翻译单元中声明的实体之间的冲突,并且 Clang 可能会根据来自未导入子模块的知识进行内联或去虚拟化。

C 和 C++ 预处理器假设输入文本是一个单独的线性缓冲区,但在模块中并非如此。可以导入两个模块,它们对宏的定义存在冲突(或者一个模块 #define 了宏,而另一个模块 #undef 了宏)。在存在模块的情况下处理宏定义的规则如下

  • 宏的每个定义和取消定义都被认为是不同的实体。

  • 如果实体来自当前子模块或翻译单元,或者是从已导入的子模块中导出的,则这些实体是可见的。

  • 一个 #define X#undef X 指令将覆盖在指令处可见的所有 X 的定义。

  • 一个 #define#undef 指令是活动的,如果它是可见的,并且没有可见的指令覆盖它。

  • 一组宏指令是一致的,如果它只包含 #undef 指令,或者如果集合中的所有 #define 指令将宏名称定义为相同的标记序列(遵循宏重新定义的常用规则)。

  • 如果使用宏名称并且活动指令集不一致,则程序格式不正确。否则,将使用宏名称的(唯一)含义。

例如,假设

  • <stdio.h> 定义了宏 getc(并导出其 #define

  • <cstdio> 导入 <stdio.h> 模块并取消定义宏(并导出其 #undef

#undef 覆盖 #define,并且导入这两个模块(以任何顺序)的源文件将不会看到 getc 被定义为宏。

模块映射语言

警告

模块映射语言目前不能保证在 Clang 的主要版本之间保持稳定。

模块映射语言描述了从头文件到模块逻辑结构的映射。要启用对将库用作模块的支持,必须为该库编写一个 module.modulemap 文件。module.modulemap 文件与头文件本身一起放置,并使用下面描述的模块映射语言编写。

注意

为了与以前的版本兼容,如果找不到名为 module.modulemap 的模块映射文件,Clang 也会搜索名为 module.map 的文件。此行为已弃用,我们计划最终将其删除。

例如,C 标准库的模块映射文件可能看起来像这样

module std [system] [extern_c] {
  module assert {
    textual header "assert.h"
    header "bits/assert-decls.h"
    export *
  }

  module complex {
    header "complex.h"
    export *
  }

  module ctype {
    header "ctype.h"
    export *
  }

  module errno {
    header "errno.h"
    header "sys/errno.h"
    export *
  }

  module fenv {
    header "fenv.h"
    export *
  }

  // ...more headers follow...
}

在这里,顶级模块 std 包含整个 C 标准库。它有许多包含标准库不同部分的子模块:complex 用于复数,ctype 用于字符类型,等等。每个子模块都列出了一个或多个提供该子模块内容的头文件。最后,export * 命令指定该子模块包含的任何内容都将自动重新导出。

词法结构

模块映射文件使用 C99 词法分析器的简化形式,具有相同的标识符、标记、字符串文字、/* */// 注释规则。模块映射语言具有以下保留字;所有其他 C 标识符都是有效的标识符。

config_macros export_as  private
conflict      framework  requires
exclude       header     textual
explicit      link       umbrella
extern        module     use
export

模块映射文件

模块映射文件由一系列模块声明组成

module-map-file:
  module-declaration*

在模块映射文件中,模块由模块 ID 来引用,它使用句点来分隔模块名称的每个部分

module-id:
  identifier ('.' identifier)*

模块声明

模块声明描述一个模块,包括贡献给该模块的头文件、它的子模块以及模块的其他方面。

module-declaration:
  explicitopt frameworkopt module module-id attributesopt '{' module-member* '}'
  extern module module-id string-literal

模块 ID 应该只包含单个标识符,它提供正在定义的模块的名称。每个模块都应该有一个单独的定义。

explicit 限定符只能应用于子模块,即嵌套在另一个模块中的模块。显式子模块的内容只有在显式地在导入声明中命名了子模块本身或从导入的模块中重新导出了子模块时才会可用。

framework 限定符指定此模块对应于 Darwin 样式的框架。Darwin 样式的框架(主要用于 macOS 和 iOS)完全包含在 Name.framework 目录中,其中 Name 是框架的名称(因此也是模块的名称)。该目录具有以下布局

Name.framework/
  Modules/module.modulemap  Module map for the framework
  Headers/                  Subdirectory containing framework headers
  PrivateHeaders/           Subdirectory containing framework private headers
  Frameworks/               Subdirectory containing embedded frameworks
  Resources/                Subdirectory containing additional resources
  Name                      Symbolic link to the shared library for the framework

system 属性指定该模块是系统模块。当重新构建系统模块时,将考虑该模块的所有头文件为系统头文件,这将抑制警告。这等效于在该模块的每个头文件中放置 #pragma GCC system_header。属性的形式在下面的 属性 部分中描述。

extern_c 属性指定模块包含可在 C++ 中使用的 C 代码。当为在 C++ 代码中使用而构建此类模块时,所有模块头文件都将被视为包含在隐式 extern "C" 块中。具有此属性的模块的导入可以出现在 extern "C" 块中。但是,没有其他限制被解除:模块目前不能在命名空间中的 extern "C" 块中导入。

no_undeclared_includes 属性指定模块只能访问非模块头文件和已使用模块的头文件。由于某些头文件可能存在于多个搜索路径中,并且在每个路径中映射到不同的模块,因此此机制有助于 clang 找到正确的头文件,即,优先考虑当前模块或子模块中的头文件,而不是搜索路径中第一个匹配项。

模块可以具有多种不同类型的成员,下面将分别描述每种成员。

module-member:
  requires-declaration
  header-declaration
  umbrella-dir-declaration
  submodule-declaration
  export-declaration
  export-as-declaration
  use-declaration
  link-declaration
  config-macros-declaration
  conflict-declaration

外部模块引用由模块 ID字符串字面量给出的文件中定义的模块。该文件可以通过绝对路径或相对于当前映射文件的路径来引用。

需要声明

需要声明指定导入的翻译单元必须满足的才能使用模块的要求。

requires-declaration:
  requires feature-list

feature-list:
  feature (',' feature)*

feature:
  !opt identifier

需求子句允许特定模块或子模块指定它们只能在某些语言方言、平台、环境和目标特定特性可用时才能访问。特性列表是一组标识符,定义如下。如果给定翻译单元中任何特性都不可用,则该翻译单元不得导入模块。在为编译构建模块时,需要不可用特性的子模块将被忽略。可选的 ! 表示特性与模块不兼容。

定义了以下特性

altivec

目标支持 AltiVec。

可用“块”语言特性。

协程

可用协程 TS 的支持。

cplusplus

可用 C++ 支持。

cplusplus11

可用 C++11 支持。

cplusplus14

可用 C++14 支持。

cplusplus17

可用 C++17 支持。

cplusplus20

可用 C++20 支持。

cplusplus23

可用 C++23 支持。

c99

可用 C99 支持。

c11

可用 C11 支持。

c17

可用 C17 支持。

c23

可用 C23 支持。

自立

可用自立环境。

gnuinlineasm

可用 GNU 内联 ASM。

objc

可用 Objective-C 支持。

objc_arc

可用 Objective-C 自动引用计数 (ARC)

opencl

可用 OpenCL

tls

可用线程本地存储。

目标特性

可用特定目标特性(例如,sse4avxneon)。

平台/操作系统

可用操作系统/平台变体(例如 freebsdwin32windowslinuxiosmacosiossimulator)。

环境

可用环境变体(例如 gnugnueabiandroidmsvc)。

示例: std 模块可以使用需要声明扩展为还包括 C++ 和 C++11 头文件

module std {
   // C standard library...

   module vector {
     requires cplusplus
     header "vector"
   }

   module type_traits {
     requires cplusplus11
     header "type_traits"
   }
 }

头文件声明

头文件声明指定特定头文件与封闭模块关联。

header-declaration:
  privateopt textualopt header string-literal header-attrsopt
  umbrella header string-literal header-attrsopt
  exclude header string-literal header-attrsopt

header-attrs:
  '{' header-attr* '}'

header-attr:
  size integer-literal
  mtime integer-literal

不包含 excludetextual 的头文件声明指定有助于封闭模块的头文件。具体来说,在构建模块时,将解析命名头文件,并且其声明将(逻辑上)放置到封闭子模块中。

具有 umbrella 说明符的头文件称为伞形头文件。伞形头文件包含其目录(以及任何子目录)中的所有头文件,通常在(#include 世界中)用于轻松访问特定库提供的完整 API。对于模块,伞形头文件是一个方便的快捷方式,它消除了为每个库头文件编写 header 声明的需要。给定目录只能包含一个伞形头文件。

注意

任何不包含在伞形头文件中的头文件都应具有显式 header 声明。使用 -Wincomplete-umbrella 警告选项来要求 clang 针对未被伞形头文件或模块映射覆盖的头文件发出警告。

具有 private 说明符的头文件不能从模块本身外部包含。

具有 textual 说明符的头文件在构建模块时不会被编译,并且如果由 #include 指令命名,则会按字面包含。但是,它被认为是模块的一部分,用于检查使用声明,并且仍然必须是词法上有效的头文件。将来,我们打算对这些头文件进行预标记,并将标记序列包含在预构建的模块表示中。

具有 exclude 说明符的头文件被排除在模块之外。在构建模块时,它不会被包含,也不会被认为是模块的一部分,即使伞形头文件或目录否则会使其成为模块的一部分。

示例: C 头文件 assert.h 是文字头文件的绝佳候选,因为它旨在多次包含(可能具有不同的 NDEBUG 设置)。但是,其中的声明通常应该拆分为单独的模块化头文件。

module std [system] {
  textual header "assert.h"
}

给定头文件不得被多个头文件声明引用。

如果路径解析到同一个文件,并且指定的头文件属性(如果有)与该文件的属性匹配,则两个头文件声明,或者一个头文件声明和一个 #include,被认为引用同一个文件,即使该文件的命名方式不同(例如,通过相对路径或通过符号链接)。

注意

使用头文件属性可以避免 clang 对模块映射引用的每个头文件进行推测性 stat。建议仅在机器生成的模块映射中使用头文件属性,以避免属性值与相应文件之间出现不匹配。

伞形目录声明

伞形目录声明指定指定目录中的所有头文件都应包含在模块中。

umbrella-dir-declaration:
  umbrella string-literal

字符串字面量引用一个目录。在构建模块时,该目录(及其子目录)中的所有头文件都包含在模块中。

伞形目录声明不得引用与伞形头文件声明位置相同的目录。换句话说,对于给定目录,只能指定一种类型的伞形。

注意

伞形目录对于具有大量头文件但没有伞形头文件的库很有用。

子模块声明

子模块声明描述嵌套在其封闭模块中的模块。

submodule-declaration:
  module-declaration
  inferred-submodule-declaration

作为模块声明子模块声明是一个嵌套模块。如果模块声明具有 framework 说明符,则封闭模块应具有 framework 说明符;子模块的内容应包含在子目录 Frameworks/SubName.framework 中,其中 SubName 是子模块的名称。

作为推断子模块声明子模块声明描述一组子模块,这些子模块对应于作为模块一部分但未被头文件声明显式描述的任何头文件。

inferred-submodule-declaration:
  explicitopt frameworkopt module '*' attributesopt '{' inferred-submodule-member* '}'

inferred-submodule-member:
  export '*'

包含推断子模块声明的模块应具有伞形头文件或伞形目录。应用于推断子模块声明的头文件正是由伞形头文件(传递性)包含的头文件,或者由于它们驻留在伞形目录(或其子目录)中而包含在模块中的头文件。

对于由伞形头文件或伞形目录包含但未被头文件声明命名的每个头文件,将从推断子模块声明隐式生成模块声明。模块将

  • 具有与头文件相同的名称(不含文件扩展名)

  • 如果推断子模块声明具有 explicit 说明符,则具有 explicit 说明符

  • 如果推断子模块声明具有 framework 说明符,则具有 framework 说明符

  • 具有由推断子模块声明指定的属性

  • 包含命名该头文件的单个头文件声明

  • 如果推断子模块声明包含推断子模块成员 export *,则包含单个导出声明 export *

示例: 如果子目录“MyLib”包含头文件 A.hB.h,那么以下模块映射

module MyLib {
  umbrella "MyLib"
  explicit module * {
    export *
  }
}

等效于(更详细的)模块映射

module MyLib {
  explicit module A {
    header "A.h"
    export *
  }

  explicit module B {
    header "B.h"
    export *
  }
}

导出声明

导出声明指定哪些导入的模块将自动作为给定模块 API 的一部分重新导出。

export-declaration:
  export wildcard-module-id

wildcard-module-id:
  identifier
  '*'
  identifier '.' wildcard-module-id

导出声明命名一个模块或一组模块,这些模块将被重新导出到导入封闭模块的任何翻译单元。每个与通配符模块 ID 匹配的导入模块(直到但不包括第一个 *)将被重新导出。

示例: 在以下示例中,导入 MyLib.Derived 还提供了 MyLib.Base 的 API

module MyLib {
  module Base {
    header "Base.h"
  }

  module Derived {
    header "Derived.h"
    export Base
  }
}

请注意,如果 Derived.h 包含 Base.h,则可以使用通配符导出重新导出 Derived.h 包含的所有内容。

module MyLib {
  module Base {
    header "Base.h"
  }

  module Derived {
    header "Derived.h"
    export *
  }
}

注意

通配符导出语法 export * 重新导出实际头文件中导入的所有模块。因为 #include 指令会自动映射到模块导入,所以 export * 提供了与 C 预处理器提供的传递包含行为相同的行为,例如,导入给定模块会隐式导入它所依赖的所有模块。因此,export * 的广泛使用为依赖传递包含的程序提供了良好的向后兼容性(即所有程序)。

重新导出声明

导出为声明 指定当前模块将通过命名模块重新导出其接口。

export-as-declaration:
  export_as identifier

导出为声明 指定当前模块将被重新导出的模块。只有顶级模块可以被重新导出,并且任何给定模块只能通过单个模块被重新导出。

示例: 在以下示例中,模块 MyFrameworkCore 将通过模块 MyFramework 重新导出。

module MyFrameworkCore {
  export_as MyFramework
}

使用声明

使用声明 指定当前顶级模块打算使用的另一个模块。当指定选项 -fmodules-decluse 时,模块只能使用以这种方式显式指定的其他模块。

use-declaration:
  use module-id

示例: 在以下示例中,从 C 中使用 A 没有声明,因此会触发警告。

module A {
  header "a.h"
}

module B {
  header "b.h"
}

module C {
  header "c.h"
  use B
}

在编译实现模块的源文件时,使用选项 -fmodule-name=module-id 来指示源文件在逻辑上是该模块的一部分。

目前,编译器只对正在构建的直接模块应用限制。

配置宏声明

配置宏声明 指定影响包含模块的 API 的一组配置宏。

config-macros-declaration:
  config_macros attributesopt config-macro-listopt

config-macro-list:
  identifier (',' identifier)*

配置宏列表 中的每个 标识符 指定宏的名称。编译器需要为不同定义的任何命名宏维护给定模块的不同变体。

配置宏声明 只能出现在顶级模块上,即不在包含模块中嵌套的模块。

exhaustive 属性指定 配置宏声明 中的宏列表是详尽的,这意味着没有其他宏定义打算影响该模块的 API。

注意

exhaustive 属性意味着,在构建模块时,应该完全忽略未列为配置宏的任何宏定义。作为优化,编译器可以通过不考虑这些非配置宏来减少唯一模块变体的数量。这种优化尚未在 Clang 中实现。

翻译单元不得在配置宏的不同定义下导入相同的模块。

注意

Clang 实现此要求的弱形式:用于配置宏的定义是根据命令行提供的定义固定的。如果出现导入并且任何配置宏的定义发生改变,编译器将生成警告(在 -Wconfig-macros 的控制下)。

示例: 日志记录库可能会根据 NDEBUG 宏设置提供不同的 API(例如,以日志记录宏的不同定义的形式)。

module MyLogger {
  umbrella header "MyLogger.h"
  config_macros [exhaustive] NDEBUG
}

冲突声明

冲突声明 描述了在同一个翻译单元中存在两个不同模块可能会导致问题的情况。例如,两个模块可能会提供类似但并不兼容的功能。

conflict-declaration:
  conflict module-id ',' string-literal

冲突声明模块 ID 指定与包含模块发生冲突的模块。在导入包含模块时,指定的模块不得在翻译单元中导入。

字符串文字 提供一条消息,当两个模块发生冲突时,将其作为编译器诊断的一部分提供。

注意

Clang 在发现模块冲突时发出警告(在 -Wmodule-conflict 的控制下)。

示例

module Conflicts {
  explicit module A {
    header "conflict_a.h"
    conflict B, "we just don't like B"
  }

  module B {
    header "conflict_b.h"
  }
}

属性

属性在语法中的许多地方使用,以描述其他声明的特定行为。属性的格式相当简单。

attributes:
  attribute attributesopt

attribute:
  '[' identifier ']'

任何 标识符 都可以用作属性,每个声明都指定可以应用到它的属性。

私有模块映射文件

模块映射文件通常命名为 module.modulemap,并且位于它们描述的头文件旁边,或者位于它们描述的头文件的父目录中。这些模块映射通常描述库的所有 API。

但是,在某些情况下,特定头文件的存在或不存在用于区分特定库的“公共”和“私有” API。例如,库可能包含头文件 Foo.hFoo_Private.h,分别提供公共 API 和私有 API。此外,Foo_Private.h 可能只在某些版本的库中可用,而在其他版本中则不存在。不能使用库中的单个模块映射文件轻松表达这一点

module Foo {
  header "Foo.h"
  ...
}

module Foo_Private {
  header "Foo_Private.h"
  ...
}

因为头文件 Foo_Private.h 并不总是可用。模块映射文件可以根据 Foo_Private.h 是否可用进行定制,但这样做需要自定义构建机制。

私有模块映射文件,其名称为 module.private.modulemap(或为了向后兼容,module_private.map),允许用户使用其他模块来增强主模块映射文件。例如,我们将上面的模块映射文件拆分为两个模块映射文件

/* module.modulemap */
module Foo {
  header "Foo.h"
}

/* module.private.modulemap */
module Foo_Private {
  header "Foo_Private.h"
}

当在 module.modulemap 文件旁边找到 module.private.modulemap 文件时,将在 module.modulemap 文件之后加载它。在我们的示例库中,当 Foo_Private.h 可用时,module.private.modulemap 文件将可用,从而更容易沿着头文件边界拆分库的公共 API 和私有 API。

在将私有模块编写为 框架 的一部分时,建议

  • 此模块的头文件位于 PrivateHeaders 框架子目录中。

  • 私有模块定义为 顶级模块,其名称为公共框架的前缀,如上面的 Foo_Private。Clang 有额外的逻辑来处理这种命名,使用 FooPrivateFoo.Private(子模块)会触发警告,并且可能无法按预期工作。

模块化平台

要从模块中获得任何好处,需要从堆栈底部开始为软件库引入模块映射。这通常意味着引入一个涵盖操作系统头文件和 C 标准库头文件(在 Unix 系统的 /usr/include 中)的模块映射。

模块映射将使用 模块映射语言 编写,该语言提供了描述头文件和模块之间映射的必要工具。由于头文件集因系统而异,因此模块映射可能需要针对特定分发版和操作系统版本进行定制。此外,系统头文件本身可能需要进行一些修改,如果它们表现出任何破坏模块的反模式。这些常见模式将在下面描述。

宏保护的复制粘贴定义

系统头文件为用户提供核心类型,例如 size_t。这些类型通常在许多系统头文件中需要,而且写起来很简单。因此,在整个头文件中看到如下复制粘贴的定义相当常见。

#ifndef _SIZE_T
#define _SIZE_T
typedef __SIZE_TYPE__ size_t;
#endif

不幸的是,当模块将所有 C 库头文件编译成单个模块时,只有第一个实际的 size_t 类型定义将可见,并且只在对应于第一个幸运头文件的子模块中可见。任何其他包含此模式的复制粘贴版本的头文件将没有 size_t 的定义。因此,导入对应于其中一个头文件的子模块不会将 size_t 作为 API 的一部分,因为它在解析头文件时不存在。解决此问题的办法是将复制的声明拉入一个公共头文件,该头文件在 size_t 是 API 的一部分的所有地方都被包含,或者消除 #ifndef 并重新定义 size_t 类型。后者适用于 C++ 头文件和 C11,但会导致非模块 C90/C99 出现错误,因为不允许重新定义 typedefs

冲突定义

不同的系统头文件可能为各种宏、函数或类型提供冲突的定义。这些冲突的定义在模块前世界中不会造成问题,除非有人碰巧在一个翻译单元中包含了这两个头文件。由于修复通常是“不要这样做”,因此此类问题仍然存在。模块要求消除冲突定义或将它们放在单独的模块中(前者通常是更好的答案)。

缺少包含

头文件通常缺少 #include 指令,而这些指令实际上是它们所依赖的头文件。与冲突定义的问题一样,这只会影响那些没有按正确顺序包含头文件的倒霉用户。在模块中,特定模块的头文件将被隔离解析,因此如果缺少包含,则模块可能无法构建。

提供不同时间多个 API 的头文件

某些系统具有包含多种不同类型的 API 定义的头文件,其中只有一部分在给定包含时可用。例如,头文件可能只在宏 __need_size_t 在包含头文件之前被定义时才提供 size_t,并且只在宏 __need_wchar_t 被定义时才提供 wchar_t。这样的头文件通常在一个翻译单元中包含多次,并且没有包含保护。没有合理的方法将此头文件映射到子模块。可以消除头文件(例如,通过将其拆分成单独的头文件,每个头文件对应一个实际的 API),或者简单地在模块映射中 exclude 它。

为了检测和帮助解决其中一些问题,clang-tools-extra 存储库包含一个 modularize 工具,该工具解析一组给定的头文件,并尝试检测这些问题并生成报告。有关如何检查系统或库头文件的详细信息,请参阅工具的源代码文档。

未来方向

模块支持正在积极开发中,还有很多改进它的机会。以下是一些想法

检测未使用的模块导入

#include 指令不同,跟踪直接导入的模块是否被使用应该相当简单。通过这样做,Clang 可以发出 unused importunused #include 诊断,包括删除无用导入/包含的 Fix-It。

缺少导入的 Fix-It

在编写代码时使用某个 API 然后遇到编译器错误“未知类型”或“没有名为”的函数,因为相应的头文件没有被包含,这种情况很常见。Clang 可以检测到这种情况并自动导入所需的模块,但应该提供一个 Fix-It 来添加导入。

改进 modularize

modularize 工具既非常重要(用于部署),也极其粗糙。它需要更好的 UI,更好的问题检测(尤其是对于 C++),以及可能的一种辅助模式来帮助您编写模块映射。

在哪里可以了解更多关于模块的信息

Clang 源代码提供有关模块的更多信息

clang/lib/Headers/module.modulemap

Clang 编译器特定头文件的模块映射。

clang/test/Modules/

专门与模块功能相关的测试。

clang/include/clang/Basic/Module.h

此头文件中的 Module 类描述了一个模块,并在整个编译器中用于实现模块。

clang/include/clang/Lex/ModuleMap.h

此头文件中的 ModuleMap 类描述了完整的模块映射,它由所有已解析的模块映射文件组成,并提供用于查找模块映射以及在模块和头文件之间映射(双向)的功能。

PCHInternals

有关用于预编译头文件和模块的序列化 AST 格式的信息。实际实现位于 clangSerialization 库中。