头文件的缺陷

长期以来,#include 指令一直是我们从C++库中导入功能和组织更大项目的主要方式。然而,这是一种过于粗糙的方法,实际上是将所有代码从一个源文件复制并粘贴到另一个源文件中。正如我们之前讨论的,这有一些问题:

  • 一个源文件可以被多次包含进同一个目标文件,这促使我们总是要实现像 #pragma once 这样的头文件保护。
  • 巨大的代码量可以被粘贴到我们的文件中,导致编译时间比必要的时间更长。在更大的项目中,这迫使我们采取更多的手动解决方法来保持编译时间的降低,比如维护预编译头文件。
  • 预处理器的粗糙性可能导致难以追踪的微妙错误,例如我们的程序根据我们的 #include 指令的顺序表现出不同的行为。
  • 减少对预处理器的依赖是C++社区的持续使命。在C++20规范中,引入了 #include 指令的替代品。它们被称为模块。

在C++20中,模块是一种新的代码组织方式,旨在解决传统头文件(使用 #include 指令包含的文件)所带来的一些问题。模块提供了一种更高效、更安全的方式来包含和使用代码,它们通过使用新的 moduleimport 关键字来实现。使用模块可以减少编译时间,避免头文件重复包含的问题,并提供更好的封装和命名空间管理。

Hello modules

在C++20之前,我们使用#include指令导入iostream,如下:

1
2
3
4
5
#include <iostream>

int main() {
std::cout << "Hello World!\n";
}

现在,我们可以使用modules来替换它,如下:

1
2
3
4
5
import <iostream>;

int main() {
std::cout << "Hello World!\n";
}

直接编译是无法通过的,我们可以通过下面的指令来成功编译:

1
2
g++ -std=c++20 -fmodules-ts -xc++-system-header iostream
g++ -fmodules-ts -std=c++2a a.cpp

创建module

创建module,首先我们需要创建一个模块接口文件。通常以.cppm为文件拓展名。

在模块接口文件的第一行,我们需要两个新关键字export和module,接着指定我们的模块名。模块的命名规则与变量名类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Math.cppm
export module Math;
import <iostream>;

namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};

constexpr float Pi{3.14};
void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;

#include不同的是,模块本身支持封装。我们可以决定我们模块的哪些部分是私有的,哪些部分是公共的。

默认情况下,一切是私有的。

Module export

我们可以选择module内的哪些部分公开。我们通过在想要公开的那部分内容前加export关键字来实现。

几乎任何具有标识符的内容都可以被导出。(变量,函数,类,命名空间等等)

在下面这个例子里,我们导出了PI和Math命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Math.cppm
export module Math;
import <iostream>;

export namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};

export constexpr float Pi{3.14};
void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;

在我们的另一个程序中,我们能导入module并且使用它导出的类/命名空间。

1
2
3
4
5
6
7
8
9
10
11
import <iostream>;
import Math;

int main() {
// correct!
std::cout << Pi;
Math::SayHello();

// compiler error
SomeMathFunction();
}

我们能一次性导出多个标识符,通过export块来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Math.cppm
export module Math;
import <iostream>;

export {
namespace Math {
void SayHello() {
std::cout << "Hello!";
};
};
constexpr float Pi{3.14};
}

void SomeMathFunction(){};
class SomeClass {};
using Alias = SomeClass;

Module的依赖关系

#include是无限递归的,我们可以通过#include包含单个头文件,但是这个被包含的头文件可能包含多个其他头文件。如此往复,会有任意多层的包含关系。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// great.h
#pragma once
#include <iostream>

void Great() {
std::cout << "Hello!";
}
// mian.cpp
#include "great.h";

int main() {
Great();
// 可以调用,因为great.h包含了iostrea
std::cout << "\nHello!";
}

C++ modules 可以做到隔离依赖,当我们导入一个模块时,它的子依赖项不会在我们的导入文件中变得可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Greetings.cppm
export module Greetings;
import <iostream>;

export void Greet() {
std::cout << "\nHello!";
}
// main.cpp
import Greetings;

int main() {
Greet();

// Compilation error
std::cout << "Hello!";
}

在这里我们需要引入一些概念,可达性与可见性。对于std::cout,是可达的,所以在调用Greet()时,能够正确输出,而std::cout对于main.cpp是不可见的,所以直接调用会报错。

导入Module中的宏

宏定义可以在模块内部使用,但通常不能从模块中导出。以下代码只会输出一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export module Greetings;
import <iostream>;

#define SayHello

export void Greet() {
#ifdef SayHello
std::cout << "Hello\n";
#endif
}
// main.cpp
import Greetings;
import <iostream>;

int main() {
Greet();
#ifdef SayHello
std::cout << "Hello from main!";
#endif
}

使用Module构建项目

CMake 3.28已全面支持modules特性。

Clang-17 已支持大部分modules特性,可以进行项目开发。

同时,我们还需要使用ninja 1.11以及更高的版本。

以一个项目简单的项目为例,项目目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
├── CMakeLists.txt
├── example
│ ├── CMakeLists.txt
│ └── SegmentTreeExample.cpp
├── include
│ ├── algorithms
│ │ └── CMakeLists.txt
│ ├── CMakeLists.txt
│ ├── data_structure
│ │ └── CMakeLists.txt
│ └── stl.cppm
├── README.md
├── src
│ ├── algorithms
│ │ └── CMakeLists.txt
│ ├── CMakeLists.txt
│ └── data_structures
│ ├── CMakeLists.txt
│ └── Tree
│ ├── CMakeLists.txt
│ ├── SegmentTree.cpp
│ └── SegmentTree.cppm
└── test
└── CMakeLists.txt

STL模块

现在对import ;等标准库module的导入支持还不完善,直接导入会报错,需要使用额外的命令对iostream生成对应的模块缓存。我们可以通过一种间接的方式,来进行伪STL模块的导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// stl.cppm
module;

#include <algorithm>
#include <atomic>
#include <bit>
// ...需要使用的头文件

export module stl;

export namespace std {

using std::shared_ptr;
using std::make_shared;
using std::unique_ptr;
using std::make_unique;
// 需要使用的stl函数等

} // namespace std

我们新建一个STL的模块,并且导出std命令空间,在模块内#include我们需要的头文件,在命令空间内using。这样就可以将标准库内的库函数通过import stl;的方式无缝衔接使用了。

CMakeLists编写

AlgoBox/include/CMakeLists.txt

1
2
3
4
5
6
7
# Assuming we're using the STL module across the project
add_library(StlModule)
target_sources(StlModule
PUBLIC
FILE_SET CXX_MODULES TYPE CXX_MODULES FILES
stl.cppm
)

AlgoBox/src/data_structures/Tree/CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_library(SegmentTreeModule)
target_sources(SegmentTreeModule
PUBLIC
FILE_SET CXX_MODULES TYPE CXX_MODULES FILES
SegmentTree.cppm
)
target_sources(SegmentTreeModule
PUBLIC
SegmentTree.cpp
)

target_link_libraries(SegmentTreeModule
PUBLIC
StlModule
)

AlgoBox/example/CMakeLists.txt

1
2
3
4
5
add_executable(SegmentTreeExample SegmentTreeExample.cpp)
set_target_properties(SegmentTreeExample PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
target_link_libraries(SegmentTreeExample PUBLIC SegmentTreeModule StlModule)

很简单对吧?与普通CMakeLists文件唯一的不同就是,在我们搜索模块文件时,需要指定CXX_MODULES。

编译命令

1
2
3
4
mkdir build
cd build/
CXX=clang++-17 CC=clang-17 cmake -GNinja ..
ninja -v

然后就可以进入到/build/bin/内运行可执行文件了!