跳至正文

C++20: Modules

经过多年的标准化进程,在 C++ 开发者社区中呼声极高的 Modules 特性终于被收入 C++20 标准中。本文将对这一特性进行介绍和探讨。

编译器支持

在正式介绍前,我们首先看一看现有的编译器对 Modules 特性的支持情况(截止 2021 年 5 月)。从 cppreference 上的 C++20 编译器支持矩阵可获悉,目前只有 MSVC 提供了对 Modules 的完整支持;GCC 11、Clang 8 以及 Apple Clang 10.0.1 仅包含对 Modules 特性的部分支持。

动机

翻译单元和头文件

在我们熟悉的 C++ 世界中,一个包含多个源代码文件的 C++ 程序的编译过程是这样的:

  1. 首先,编译器将每个翻译单元(Translation Unit)依次编译为相应的对象文件(.o 或 .obj)。一个翻译单元即是程序中的一个 .cpp / .cc 文件;
  2. 然后,链接器将所有生成的对象文件连同程序所依赖的第三方库,链接为可执行文件、静态库或动态库。

翻译单元间大多包含依赖关系。例如,程序中有两个翻译单元 AB,翻译单元 A 需要调用翻译单元 B 中定义的函数。B 为了向其他翻译单元表明自己定义了哪些可供使用的接口,往往提供一个对应的头文件,描述 B 提供的接口信息。其他翻译单元只需要 #include 这个头文件,便能够在代码中直接使用 B 提供的接口,并在链接期最终与 B 的代码实现链接起来。

问题

通过公共的头文件在多个翻译单元之间传递接口信息的做法是上古时期的编程方式,以现代的角度来看有诸多弊端。

首先,该机制会肉眼可见地造成编译速度的下降。我们不妨回顾一下经典的 C++ hello world 程序:

#include <iostream>

int main() {
  std::cout << "hello world" << std::endl;
  return 0;
}

该程序仅包含 6 行(包含空行)。使用如下命令查看如上程序经预处理后的结果:

g++ -E -o hello.i hello.cpp

经过预处理后的 hello.i 文件包含了 28629 行代码,其中除了 main 函数的四行代码以外的其他内容都是因为 iostream 被完全展开而得到的。编译器为了编译一个六行的 hello world 的程序,而不得不解析并处理近三万行代码。更糟糕的是,如果多个翻译单元均引用了 iostream 这一头文件,那么编译器在翻译每个翻译单元的时候都需要从头开始解析由 iostream 展开得到的近三万行代码。大量的编译器 CPU 时间便被浪费在处理展开的头文件内容上了。

其次,头文件并非以一种“稳定”的方式描述接口信息。在预处理阶段,头文件是以文本方式嵌入到翻译单元中,且头文件中出现的预处理命令会被递归地预处理。当某个头文件被分别 #include 到不同的翻译单元中时,有可能出现同一个头文件在不同的翻译单元中被展开为不同的代码的情况。甚至在一些远古代码中,仅仅交换同一个源文件的多个 #include 的次序,便会显著地改变头文件经预处理之后的代码展开结果。

已有缓解措施

为提升处理头文件的速度,现代 C/C++ 编译器大多都实现了一种名为 预编译头文件(PreCompiled Header,PCH) 的缓解措施。简单地来说,PCH 机制允许编译器在启动编译前将某些特定的头文件预先翻译为某种易于编译器解析和使用的中间格式。随后,在正常的编译过程中,当某个翻译单元 #include 了已经预编译的头文件时,编译器可以直接从生成的中间格式中快速加载头文件中包含的所有信息,而无需从头开始解析和编译头文件的源代码。已有项目经验表明,合理地使用 PCH 机制能够极大地提高编译速度。在 Visual Studio 中创建 C++ 项目时,IDE 甚至会自动为你配置一个全局的预编译头文件(即 stdafx.h)。

但是,PCH 机制仍有其局限性。前文已经提到,头文件以源代码方式嵌入到翻译单元中并参与预处理这一过程可能会造成头文件在多个翻译单元中的展开内容的不一致。PCH 并不能很好地解决这个问题,因此许多现代编译器在实现 PCH 时,要么将保证头文件内容一致性的任务交给程序员(例如 MSVC),要么设立一系列的限制条件,限制 PCH 的使用场景(例如 GCC)。

Modules

C++20 Modules (模块)特性为 C++ 程序带来了一种新的程序划分和管理的方式。在以往的 C++ 程序中,程序被划分为若干翻译单元;在 C++20 以后,程序将被划分为若干模块。每个模块负责实现一定的功能,并将访问这些功能的接口 导出(Export) 来允许其他模块使用这些接口。当某个模块需要使用另一个模块导出的接口时,它需要首先 导入(Import) 这个模块,然后便能使用导入的模块提供的接口。

考虑一个简单的例子。我们希望编写一个做加法的程序。这个程序由两个模块组成:第一个模块是实现加法操作的模块,名为 Adder,其包含一个函数 add 用于实现加法;第二个模块包含了程序的主函数。我们可以按照如下方式组织和编写代码:

////////////
// adder.hpp
export module Adder;

export int add(int lhs, int rhs) noexcept;

////////////
// adder.cpp
module Adder;

int add(int lhs, int rhs) noexcept {
  return lhs + rhs;
}

///////////
// main.cpp
#include <iostream>

import Adder;

int main() {
  int lhs, rhs;
  std::cin >> lhs >> rhs;
  std::cout << add(lhs, rhs) << std::endl;

  return 0;
}

为了编写这个程序,我们需要编写三个文件:adder.hppadder.cpp 以及 main.cpp。前两个文件即组成了 Adder 这一模块,main.cpp 中隐式地定义了一个全局模块。

定义模块的基本方式

为了介绍为什么需要两个文件以定义一个 Adder 模块,我们需要首先了解 C++20 Modules 特性所引入的一些基本术语。

虽然 Modules 引入了新的模块划分方式,但对于编译器来说,编译一个 C++ 程序基本仍然是以翻译单元为基本单元进行编译的。对于一个翻译单元,如果它包含模块的声明(即类似于 module XXX 这样的语句),那么称这个翻译单元为 模块单元(Module Unit)。更进一步地,如果这个翻译单元所包含的模块声明以 export 关键字开头(即类似于 export module XXX 这样的语句),那么称这个翻译单元为模块接口单元(Module Interface Unit);否则称这个翻译单元为模块实现单元(Module Implementation Unit)。Modules 规定,任何一个模块都必须有且仅有一个模块接口单元。但一个模块可以有多个模块实现单元。将模块进一步拆分为模块接口单元和模块实现单元也是遵循 C/C++ 的声明与定义相分离的传统。

在之前的简单例子中,adder.hpp 便是 Adder 模块的模块接口单元,adder.cpp 便是 Adder 模块的唯一的一个模块实现单元。这两个翻译单元共同组成了 Adder 模块。

模块接口单元负责将模块提供的功能通过 export 关键字进行导出。在之前的简单例子中,adder.hpp 通过 export 关键字将 Adder 模块实现的 add 函数进行了导出。导出时,export 关键字后面跟一个合法的 C++ 声明即可。但被导出的内容必须具有 External Linkage。具体的导出规则将在稍后详细描述。

模块实现单元负责实现模块的所有功能。adder.cpp 即对 Adder 模块所提供的 add 函数进行了实现。

使用模块的基本方式

在使用某个模块提供的功能前,首先需要导入该模块。导入模块是通过 import 语句实现的。在之前的简单例子中,main.cpp 使用 import 语句对 Adder 模块进行了导入。

导入模块后,便可以直接引用导入的模块所导出的所有内容。注意,与 Python 等其他语言的模块导入机制不同,C++ 导入模块时并不会自动为被导入的模块所导出的名称加上一个前缀。在之前的简单例子中,main.cpp 导入 Adder 模块后便可以直接访问 Adder 模块所导出的 add 函数,不需要以 Adder.addAdder::add 等类似的名称进行访问。

全局模块

从 C++20 开始,一个程序的所有代码都从属于某个模块。在之前的简单的例子中,add 函数所从属的模块显然为 Adder 模块;但是 main.cpp 中并没有显式地定义模块,那么 main.cpp 中所定义的内容(即 main 函数 以及 iostream 头文件展开后得到的所有内容 )属于哪个模块呢?

答案是这些内容从属于一个隐式定义的 全局模块(Global Module)。全局模块是一个由编译器隐式提供的模块,它没有名字且包含所有的那些不从属于任何一个程序中定义的模块的内容。全局模块是两种允许没有模块接口单元的模块之一(我们实际上也没有办法为全局模块提供模块接口单元),另一种允许没有模块接口单元的模块将在稍后介绍。

模块的编译方式

支持 C++20 Modules 的编译器一般按照如下的方式编译程序。需要注意到的是,编译器仍然是以翻译单元为基本单元对程序进行编译,将所有翻译单元编译为对象文件后使用链接器产生最后的输出结果。但是,在处理一个翻译单元时,编译器会进行一些额外的检查以及代码生成工作。具体地来说,如果当前的翻译单元是一个模块接口单元,那么编译器在编译该翻译单元的同时,也会同时将这个模块接口单元所声明的模块接口以一种方便机器快速加载和读取的方式缓存在某处。当其他的翻译单元中 import 了该模块时以及编译该模块的模块实现单元时,编译器可以从缓存中快速加载模块中包含的所有接口,而无需重新解析模块接口单元。

为编译之前的简单例子,第一步我们需要从 adder.hpp 这个模块接口单元中编译出编译器特定的模块接口文件:

clang++ -Wall -std=c++20 -fmodules \
    -Xclang -emit-module-interface \
    -o adder.pcm \
    adder.hpp

这里需要注意几个点:

  • 我们使用 -fmodules 选项令 clang 开启对 C++20 Modules 特性的支持;
  • 我们使用 -Xclang -emit-module-interface 选项告诉 clang 需要从给定的模块接口单元中生成可供 clang 后续使用的模块接口文件(.pcm 文件)。

然后,我们可以依次编译 adder.cpp 以及 main.cpp 文件:

clang++ -Wall -std=c++20 -fmodules \
    -fmodule-file=adder.pcm \
    -c -o adder.o \
    adder.cpp
clang++ -Wall -std=c++20 -fmodules \
    -fmodule-file=adder.pcm \
    -c -o main.o \
    main.cpp

我们在此使用 fmodule-file=adder.pcm 令 clang 在看到 import 语句时从 adder.pcm 这一模块接口文件中查找对应的模块接口信息。

最后,我们可以使用常规的链接操作生成可执行文件并执行:

clang++ -o main main.o adder.o

./main
1 2
3

Module Partitions

模块提供的功能本身可能被进一步划分为若干子模块分别进行实现和管理。例如,对于一个提供数值计算功能的模块,我们可以将其提供的所有功能大致分为两类:在整数域上面进行计算的功能以及在浮点数域上进行计算的功能。假设这个模块导出了两个函数 div_int 以及 div_float,前者在整数域上计算两个数的商,后者在浮点数域上计算两个数的商。我们可能会按照如下方式编写代码:

//////////////
// numeric.hpp
export module Numeric;

export int div_int(int lhs, int rhs);
export int div_float(double lhs, double rhs);

//////////////
// numeric.cpp
module Numeric;

int div_int(int lhs, int rhs) {
  return lhs / rhs;
}

double div_float(double lhs, double rhs) {
  return lhs / rhs;
}

在这一实现中,我们将 Numeric 模块的所有实现均塞到了同一个模块中。但其实我们知道,Numeric 模块可以天然地划分为两个子模块 integralnumeral,前者负责处理所有在整数域上的计算,后者负责处理所有在浮点数域上的计算。我们希望将 div_int 移动到 integral 子模块中、将 div_float 移动到 numeral 子模块中。

我们可以借助 Module Partition 特性实现这一点。具体的实现代码如下:

//////////////
// numeric.hpp
export module Numeric;
export import :Integral;
export import :Numeral;

//////////////
// numeric_integral.hpp
export module Numeric:Integral;

export int div_int(int lhs, int rhs);

//////////////
// numeric_numeral.hpp
export module Numeric:Numeral;

export int div_float(int lhs, int rhs);

//////////////
// numeric_integral.cpp
module Numeric:Integral;

int div_int(int lhs, int rhs) {
  return lhs / rhs;
}

//////////////
// numeric_numeral.cpp
module Numeric:Numeral;

int div_float(int lhs, int rhs) {
  return lhs / rhs;
}

在上面的代码示例中,模块 Numericpartition 为了两个子模块:Integral 以及 Numeral。表示模块 A 的名为 B 的子模块的语法为 A:B。在子模块 Integral 中,我们定义了 div_int 函数;在子模块 Numeral 中,我们定义了 div_float 函数。最后,在 Numeric 模块中,我们使用 export import 语法,首先导入子模块中导出的所有内容,然后再将这些内容从 Numeral 模块导出。因此,在 Numeric 的用户看来,div_intdiv_float 函数就像是直接在 Numeric 模块中定义的一样。

需要注意到,Module Partition 对于模块的用户来说是 完全不可见的Numeric 模块的用户并不能使用类似于 import Numeric:Integral 的语法来导入 Integral 模块。如何在模块内部使用 Module Partition 机制对模块实现的功能进行再次划分是模块的实现细节,不应被用户所感知。但是,Numeric 模块的模块接口单元和模块实现单元可以通过 import 语句导入这些子模块,就像 numeric.hpp 中所演示的那样。这也是这些子模块唯一的能被直接通过 import 语句所导入的地方。

内部子模块

Module Partition 机制除了可以对模块提供的接口进行进一步的逻辑划分以外,还可以用于提供模块的内部实现。为模块提供内部实现的子模块在标准中没有特定的名称,在本文中我称其为“内部子模块”。这一类子模块不会被模块的模块接口单元所导出,它存在的唯一作用是提供模块实现的内部细节。

考虑如下的例子。我们将要编写一个名为 Data 的模块,它负责为应用程序的其他模块提供数据服务,即从某个数据库中查询出数据。我们可能会这样编写我们的 Data 模块:

///////////
// data.hpp
export module Data;

export class Entity {
  // Some stuff here
};  // class Entity

export Entity fetchData();

///////////
// data.cpp
module Data;

class DatabaseConnection {
  void connect();
  void close();
  Entity query();
};  // class DatabaseConnection

Entity fetchData() {
  DatabaseConnection conn;
  conn.connect();
  auto entity = conn.query();
  conn.close();
  return entity;
}

void DatabaseConnection::connect() {
  // Do something here
}

void DatabaseConnection::close() {
  // Do something here
}

Entity DatabaseConnection::query() {
  // Do something here
}

这里,我们使用一个 DatabaseConnection 类用于处理到数据库的连接以及负责向数据库查询数据。这个类显然是 Data 模块的内部实现细节,不应该被 Data 模块的用户所感知到。因此,我们将其直接实现在了 Data 模块的一个模块实现单元中,并且没有将其导出。

随着程序的生命周期发展,Data 模块提供的功能可能越来越多,单纯仅靠一个 data.cpp 作为其唯一的模块实现单元可能会很快变得难以管理。当 Data 模块开始由多个模块实现单元组成时,我们应该在何处定义并实现 DatabaseConnection 类?

内部子模块即是为这种场景所服务的。我们可以考虑将 DatabaseConnection 类放入 Data 模块的一个内部子模块中,这样 Data 模块的实现可以调用 DatabaseConnection 类并且 Data 模块的用户无法感知到内部子模块的存在。我们可以按照如下方式定义内部子模块:

///////////
// data.hpp
export module Data;

export class Entity {
  // Some stuff here
};  // class Entity

export Entity fetchData();

////////////////////
// data_internal.cpp
module Data:Internal;

class DatabaseConnection {
  void connect();
  void close();
  Entity query();
};  // class DatabaseConnection

void DatabaseConnection::connect() {
  // Do something here
}

void DatabaseConnection::close() {
  // Do something here
}

Entity DatabaseConnection::query() {
  // Do something here
}

///////////
// Data.cpp
module Data;
import :Internal;

Entity fetchData() {
  DatabaseConnection conn;
  conn.connect();
  auto entity = conn.query();
  conn.close();
  return entity;
}

可以看到,内部子模块 Data:Internal 与之前定义的 Numeric:Numeral 等子模块最大的不同在于内部子模块 Data:Internal 没有模块接口单元。内部子模块可以被其父模块中的模块实现单元使用 import 语句导入,但在其父模块外部将没有任何方法能够访问到内部子模块。因此,内部子模块实现了与外部模块的隔绝,达到了为模块提供内部实现的效果。

模块导出规则

模块的模块接口单元可以使用多种语法对模块定义的功能进行导出。但是,被导出的项必须满足一些条件。C++20 规定,由 export 语句所导出的内容必须满足如下几个条件:

  • 被导出的项应至少具有一个符号;
  • 被导出的项应该具有 external linkage。

第一条意味着如下的导出是非法的:

export module Foo;

// Illegal: does not export any names
export namespace {}

// Illegal: does not export any names
namespace bar {}
export using namespace bar;

第二条意味着如下的导出也是非法的:

export module Foo;

// Illegal: export names with internal linkage
export static int b;

namespace {
  // Illegal: export names with internal linkage
  export int c;
}

export 的内容可以是多种多样的(只要是一个合法的 C++ 声明即可被导出)。下面的代码展示了多种能够被 export 的内容:

export module Foo;

// Export a global function
export int foo();

// Export a global variable with external linkage
export int b;

// Export a template function
export template &lt;typename T&gt; void bar() {
  // Implementation of `bar`
}

// Export a type alias
export using i32 = int;
export typedef unsigned u32;

// Export a class
export class Data {
  Data();
  ~Data();

  void doSomething();
};

// Export a namespace
export namespace foo {
  int bar();
  int c;
}

// Export a using derivative
export using bar::Something;

注意,当导出模板时,除非显式地使用显式模板特例化,否则模板的实现也需要在 export 处可见。Modules 并没有解决模板只能通过源码进行分发的问题。

头文件导入

C++20 Modules 特别规定允许“导入”一个头文件。例如:

import <iostream>

int main() {
  std::cout << "hello world" << std::endl;
  return 0;
}

在上面的例子中,我们使用 import 语句导入了 iostream 头文件中的所有内容。在 main 函数中,我们可以向以往一样使用 iostream 头文件提供的功能。

需要注意到,C++20 标准并没有规定导入头文件的行为。因此,导入头文件的行为是 implementation defined 的。另外,C++20 标准并没有规定所有的头文件都能够被导入;相反地,C++20 标准规定了一个 implementation defined 的 可导入头文件 列表,只有在这个列表中的头文件才能够被导入。

总结

C++20 引入了 Modules 机制来弥补传统的基于头文件在翻译单元之间传递接口信息的方式的诸多弊端。本文介绍了 Modules 机制的基本使用方法和已有实现现状,但还没有完全覆盖 Modules 机制的具体设计细节。有关 Modules 机制进一步的使用方式和设计细节请参考 C++20 标准。

标签:

发表回复