头文件的缺陷
长期以来,#include
指令一直是我们从C++库中导入功能和组织更大项目的主要方式。然而,这是一种过于粗糙的方法,实际上是将所有代码从一个源文件复制并粘贴到另一个源文件中。正如我们之前讨论的,这有一些问题:
一个源文件可以被多次包含进同一个目标文件,这促使我们总是要实现像 #pragma once
这样的头文件保护。
巨大的代码量可以被粘贴到我们的文件中,导致编译时间比必要的时间更长。在更大的项目中,这迫使我们采取更多的手动解决方法来保持编译时间的降低,比如维护预编译头文件。
预处理器的粗糙性可能导致难以追踪的微妙错误,例如我们的程序根据我们的 #include
指令的顺序表现出不同的行为。
减少对预处理器的依赖是C++社区的持续使命。在C++20规范中,引入了 #include
指令的替代品。它们被称为模块。
在C++20中,模块是一种新的代码组织方式,旨在解决传统头文件(使用 #include
指令包含的文件)所带来的一些问题。模块提供了一种更高效、更安全的方式来包含和使用代码,它们通过使用新的 module
和 import
关键字来实现。使用模块可以减少编译时间,避免头文件重复包含的问题,并提供更好的封装和命名空间管理。
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 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 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 () { std::cout << Pi; Math::SayHello (); SomeMathFunction (); }
我们能一次性导出多个标识符,通过export块来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 #pragma once #include <iostream> void Great () { std::cout << "Hello!" ; } #include "great.h" ; int main () { Great (); std::cout << "\nHello!" ; }
C++ modules 可以做到隔离依赖,当我们导入一个模块时,它的子依赖项不会在我们的导入文件中变得可见。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export module Greetings;import <iostream>;export void Greet () { std::cout << "\nHello!" ; } import Greetings;int main () { Greet (); 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 } 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 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的模块,并且导出std命令空间,在模块内#include我们需要的头文件,在命令空间内using。这样就可以将标准库内的库函数通过import stl;的方式无缝衔接使用了。
CMakeLists编写
AlgoBox/include/CMakeLists.txt
1 2 3 4 5 6 7 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 buildcd build/CXX=clang++-17 CC=clang-17 cmake -GNinja .. ninja -v
然后就可以进入到/build/bin/内运行可执行文件了!