Clang Module
概述
在日常开发中,绝大多数软件都是基于一系列的库(Library)构建而成的,这些库包括平台所提供的库、软件自身的内建库以及第三方库。库的实现主要包括两部分:接口(interface,或称 API)和 实现(Implementation)。在 C 家族的语言中,一般都通过包含(include)头文件(header files)的方式来访问库的接口,如下所示。
1 |
头文件包含后,再通过链接器链接至对应的库,从而完成接口与实现的绑定,比如:通过传递
-lSomeLib
参数给链接器完成接口与实现的绑定。
模块(Module)提供了另一种更为简单的使用库的方式。模块能够提供更好的编译时可伸缩性,并且能够消除使用 C 预处理器访问库的 API 时所遇到的各种问题。
基于预处理的文本包含模型
通过 C 预处理器提供的 #include
方式来访问库的 API
并不是一种优雅的方式,因为它存在着很多问题,主要有以下这些:
- 编译时可扩展性。每当包含一个头文件时,编译器会预处理、解析头文件中的文本,并递归地处理该头文件所包含的头文件。应用程序编译过程中的每一个 编译单元(Translation Unit)都是如此,因此存在着大量的重复工作。假如,一个项目包含 N 个编译单元,每个编译单元包含 M 个头文件,尽管编译单元相互之间共享着一部分头文件,编译器的编译时间复杂度还是会达到 M x N。对于 C++,情况更加糟糕,因为模板的编译模型会将大量代码强制写入头文件,导致编译单元工作量增大。
- 易错性。预处理器将
#include
视为文本包含,因此在包含时接受任何宏定义。如果宏定义与库中的名称产生冲突,那么可能会破坏库 API 或导致库的头文件编译失败。比如,定义#define std "The C++ Standard"
并包含一个标准库的头文件,这会产生一系列编译报错。当两个不同库的头文件之间存在宏定义冲突时,会产生很多细节的问题,这时就需要通过重新排序#include
或者引入#undef
来解决某些依赖关系。 - 常规解决方法:C 语言开发者采用了很多约定来解决 C
预处理器模型的易错性。比如,通过 包含保护(Include
Guard)保证多次包含不会产生编译出错;宏的名称采用
LONG_PREFIXED_UPPERCASE_IDENTIFIERS
的规则进行定义,从而避免冲突;甚至,某些库或框架的开发者通过在头文件中使用带下划线的非常规的宏名称,避免与常规的宏名称产生冲突。总体而言,这些解决方式都不够优雅。
编译单元:当一个 c 或 cpp 文件在编译时,预处理器首先递归地包含头文件,形成一个包含所有必要信息的单个源文件,该源文件就是一个编译单元。
基于模块的语义导入模型
通过将 基于预处理的文本包含模型 替换成
基于模块的语义导入模型(或称
模块导入模型),能够有效改善对库 API
的访问。从开发者的角度看,代码略有不同,仅仅是使用 import
声明和使用 #include
声明的区别。
1 | import std.io |
事实上,使用 import std.io
的模块导入方式和使用
#include <stdio.h>
的文本包含方式完全不同:当编译器发现上面的模块导入时,它会加载
std.io
模块的二进制表示,并将其 API
直接提供给应用程序。在模块导入声明之前的文本包含声明不会对
std.io
提供的 API
产生影响,因为模块本身是作为独立的此外,模块进行编译的。此外,导入
std.io
模块时,会自动提供链接器所需的链接选项。最重要的是,这种语义导入模型能够解决能够预处理包含模型的难以解决的问题:
- 编译时可扩展性:
std.io
模块只会编译一次,并且将模块导入编译单元是一个常量操作(与模块系统无关)。因此,每个库的 API 只会解析一次,从而将 M x N 的时间复杂度降低到 M + N。 - 易错性:每个模块被解析为一个独立实体,因此它们具有一致的预处理器环境,也就不需要通过下划线的方式来避免冲突了。除此之外,如果当前的预处理定义之前存在同一个库的导入声明,那么这里的预处理声明就会被忽略,因此一个库不会影响另一个库的编译,从而消除了包含顺序的依赖关系。
模块的局限性
很多编程语言都有模块系统或包系统,由于这些语言的功能多种多样,因此必须定义模块能力范围。下面列出的所有内容都不在模块的能力范围之中:
- 重写代码:要求应用程序或库进行大幅度的修改或非向后兼容的修改是不现实的,完全消除头文件也是不可行的。模块必须要和现有的库共存,并允许逐步替过渡。
- 版本控制:模块并没有版本信息的概念。开发者必须依赖语言的版本控制机制(如果存在)来对库(模块)进行版本控制。
- 命名空间:与某些语言不通,模块并不意味着命名空间。因此,在一个模块中声明的结构体仍然可能会与不同模块中声明的同名结构体产生冲突,就像在两个不同的头文件中声明它们一样。这方面对于向后兼容非常重要,比如,在引入模块时,库中的实体的 修饰别名(Mangled Name)不能被修改。
- 模块的二进制分发:头文件(特别是 C++ 头文件)暴露了语言的全部复杂性。从技术方面而言,在体系结构、编译器版本、编译器供应商之间维持稳定的二进制模块格式是不可行的。
如何使用模块
要启用模块,需要传递命令行参数
-fmodules
。这将使所有支持模块的库都可作为模块被使用,同时也会引入模块特定语法。
Objective-C 导入声明
Objective-C 支持以 @import declaration
的方式导入模块,如下所示:
1 | @import std; |
上面的 @import
声明会导入 std
模块的所有内容(可能包含完整的 C 或 C++ 标准库),从而使其 API
在当前的编译单元中可见。如果希望导入模块的一部分,可以使用点语法来导入指定的子模块,如下所示:
1 | @import std.io; |
重复的导入声明会被忽略。此外,如果导入声明位于全局范围内,那么可以在编译单元的任何位置导入模块。
目前为止,还没有用于导入声明的 C 或 C++ 语法。Clang 会持续关注 C++ 委员会中关于模块的提议。
include
自动转换为
import
模块的最主要的用户级功能是 导入
操作,模块导入之后即可访问库的 API。但是,现在的应用程序广泛使用
#include
,将这些代码全部改成 import
的方式是不现实的。为此,模块会自动将 #include
指令转换为相应的模块导入。下面例子中的 #include
指令会被自动转换为对模块 std.io
的导入。 1
所有启用了模块的库都能够将 #include
自动转换为
import
,这对向后兼容性非常重要,可以无需修改应用程序的代码,也能够享受到模块的优势。
注:
#include
到import
的自动转换也解决了另一个问题:当导入一个具有某个实体定义(如:struct Point
)的模块后,解析到一个头文件,其包含了另一个struct Point
的定义,即使struct Point
是同一个,那么也会出现重复定义的报错。通过将#include
映射到import
,编译器可以保证它始终只看到模块中已经解析的定义。
除此之外,模块也会对 #include_next
进行转换。#include_next
的一般用法是:在
include
路径列表中查找特定的文件名,从找到的当前文件的下一个路径往后找。在模块中,由于
module maps
中列出的文件并不会通过 include
路径来查找,对于这些文件,#include_next
采取了一种不同的策略:在 include
路径列表中来搜索指定的头文件名,从而找到第一个引用当前文件的
include
路径。如果找到了 module maps
命名的文件,那么 #include_next
会被转换成
import
命令。
Module Maps
本质上,模块和头文件之间的关系是通过
模块映射(module map)来定义的,module map
描述了现有的头文件是如何组成一个模块结构的。比如,一个包含了 C 标砖库的
std
模块。每个 C
标准库头文件(如:<stdio.h>
、<stdlib.h>
、<math.h>
)都可以将它们各自的
API
放入对应的子模块中(如:std.io
、std.lib
、std.math
)。
一般而言,module map
是一个特定的独立文件(比如:module.modulemap
),文件中定义了对应模块所包含头文件。如下所示是
iOS 中 SnapKit
框架的 modulemap
文件
SnapKit.modulemap
。
1 | module SnapKit { |
模块编译模型
编译器会根据需要自动生成模块的二进制表示。当一个模块被导入时(比如:通过
#include
导入模块的一个头文件),编译器会生成一个编译单元来解析模块中的头文件,然后将生成的抽象语法树(Abstract
Syntax
Tree,AST)持久保存在模块的二进制表示中,当遇到模块导入时,在将其加载到编译单元之中。
模块的二进制表示存储在 模块缓存(module cache)中。当导入一个模块时,会首先查找模块缓存,如果所需模块的二进制表示存在,则直接进行加载。因此,每个语言配置只会对模块的头文件进行一次解析,而不会在每个使用该模块的编译单元中都进行解析。
模块包含着模块构建所依赖的头文件。如果对这些头文件中的任何头文件进行修改,或者对模块所依赖的某个模块进行修改,那么编译器会自动重新编译该模块。这个过程不需要开发者进行干预。
模块映射语言
模块映射语言(module map
language)描述了如何将头文件进行映射并组合成模块。为了能够将一个库作为模块来使用,必须要为库定义一个
module.modulemap
文件。如下所示,是为 C 标准库定义
module map
文件的例子。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30module 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 *
命令指定对应子模块包含的所有内容将自动重新导出。
与编程语言一样,模块映射语言也有很多预定义的修饰符,如下所示:
1
2
3
4
5
6config_macros export_as private
conflict framework requires
exclude header textual
explicit link umbrella
extern module use
export
这里主要介绍一下 framework
和 umbrella
关键字。
framework
修饰符指定了模块对应了 Darwin
风格的框架。Darwin 风格的框架(主要用于 macOS 和 iOS)通常是包含在一个
Name.framework
的目录中,其中 Name
是框架的名称(也就是模块的名称)。该目录的结构如下所示:
1
2
3
4
5
6
7Name.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
umbrella
修饰符指定的头文件被称为 umbrella
header。umbrella header
包含了其目录(以及任何子目录)下的全部头文件,通常用于(在
#include
世界中)轻松访问某个库的所有
API。对于模块而言,umbrella header
是一种快捷方式,通过
umbrella header
,就不需要为每个库的头文件定义头文件声明。一个目录只能包含一个
umbrella header
。举个例子,我们可以通过导入
UIKit
的 umbrella header
代替多个导入命令。
1 |
代替 1
2
3
4
总结
本文对比了两种访问接口的方式:文本包含方式
#include
、模块导入方式 import
,介绍了 Clang
如何将 import
的优势向后兼容至
#include
。最后,简单介绍了模块在 iOS 开发中的应用。