C++20学习之module

头文件存在的一些问题

使用头文件主要存在以下一些问题

头文件被重复处理导致编译速度过慢

宏和全局命名污染的问题

头文件依赖问题

头文件被重复处理主要是因为它是在预处理阶段直接展开的,也就是直接进行文本替换,考虑在B.h中include了A.h,然后在Utils.cppMain.cpp中都include了B.h,那么在这两个翻译单元中都会间接的包含A.h。并且,如果对A.h中的内容做了任何修改,那么其他所有包含A.h的翻译单元都需要重新编译

宏和全局命名污染的问题的原因同上,预处理阶段对头文件进行简单的文本替换导致了这个问题

头文件依赖问题算是最常踩的一个坑,头文件循环依赖,或者include顺序不对应该大部分人都遇到过,虽然利用include guards或者规范include顺序可以尽可能的规避这些问题,但毕竟还是会在一些意想不到的时候出错

推荐的头文件include顺序:

(1)配套的头文件
(2)C语言系统库头文件
(3)C++标准库头文件
(4)第三方库头文件
(5)本项目的其他头文件

module简介

cppreference是这样介绍module的

模块是一个用于在翻译单元间分享声明和定义的语言特性。 它们可以在某些地方替代标头的使用

说白了就是用来取代头文件的东西,简单的,记住以下几个概念即可

(1)含有export的模块是一个接口单元,类似于头文件定义和实现分离,模块也允许有模块实现单元
(2)模块可以分为具名模块(主模块,例如模块X)和模块分区(子模块,例如模块X : Y)
(3)导入的声明可以在接口单元中再次导出

语法

具体的语法如下

(1)export(可选) module 模块名 模块分区 (可选) 属性 (可选) 
(2)export 声明
(3)export声明序列 (可选) }
(4)export(可选) import 模块名 属性 (可选)
(5)export(可选) import 模块分区 属性 (可选)
(6)export(可选) import 头名 属性 (可选)
(7)module
(8)module : private

(1) module声明

就是将当前翻译单元声明为一个模块,例如

1
2
3
4
5
6
7
8
export module MainModule; // 声明一个具名模块接口单元
module MainModule; // 声明一个具名模块实现单元

export MainModule.Module1 // 声明一个具名模块接口单元
module MainModule.Module1 // 声明一个具名模块实现单元

export module MainModule :Moudle2; // 声明一个模块分区接口单元
module MainModule :Moudle2; // 声明一个模块分区实现单元

需要注意的是,X.Y中的.没有内在含义,只是非正式的表达一个层次结构,更推荐的拆分模块的方案是使用模块分区,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 具名模块接口单元 Math.ixx
export module Math;
import :Add;
import :Sub;

// -------------------------------------------------
// ------------- 模块分区 Add.ixx ------------
// -------------------------------------------------

export module Math :Add;

export int add(int a, int b);

// -------------------------------------------------
// ------------- 模块分区 Sub.ixx ------------
// -------------------------------------------------

export module Math :Sub;

export int sub(int a, int b);

需要注意的是

(1)模块分区仅自己所在的具名模块内部可见,其他的翻译单元不能直接导入这些模块分区
(2)模块分区内的所有声明和定义在将它导入的模块单元中均可见,无论它们是否被导出
(3)模块分区可以是模块接口单元

简单解释下

(1)是指其他翻译单元无法直接导入模块分区,只有具名模块和具名模块下的其他模块分区可以直接导入

1
2
3
// Main.cpp
import Math :Add // error, 不能直接导入模块分区,无论该模块分区是否被导出
import Math // ok

(2)是指模块分区中的所有声明和实现,在该具名模块下所有的模块分区中都是可见的(具名模块内也可见)

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
// 具名模块接口单元 Math.ixx
export module Math;
import :Add;
import :Sub;

// -------------------------------------------------
// ------------- 模块分区 Add.ixx ------------
// -------------------------------------------------

export module Math :Add;

int add(int a, int b);

// -------------------------------------------------
// ------------- 模块分区 Sub.ixx ------------
// -------------------------------------------------

export module Math :Sub;

import :Add //ok, Add和Sub都在Math这一具名模块下

export int sub(int a, int b);

// ok, 即使add是不导出的,但依然可见
int calculate(){
int c = add(1, 2);
}

(3)模块分区可以被导出,但需要在具名模块接口单元中导入的同时再导出

1
2
3
4
// 具名模块接口单元 Math.ixx
export module Math;
import :Add; // 仅内部使用
export import :Sub; // 作为接口单元被导入的同时又被导出

(2, 3) 导出声明

导出一个声明或序列内的所有声明

1
2
3
4
5
6
7
export int add(int a, int b);

// 导出序列内的所有声明
export {
int sub(int a, int b);
int mul(int a, int b);
};

(4, 5, 6) 导入声明

导入一个模块单元,模块分区,或标头单元

1
2
3
import MainModule;  // 导入一个模块
import <vector>; // 导入头文件
import :Module1; // 在具名模块内导入一个模块分区

需要注意的是,在模块单元里不能使用#include,因为所有被包含的声明和定义都会被当作模块的一部分,这不符合模块显式控制接口是否对外暴露的设计理念 (MSVC会提示一个警告)

#include <vector> in the purview of module Math appears erroneous. Consider moving that directive before the module declaration, or replace the textual inclusion with import <vector>;.

1
2
3
export module MainModule;
#include <vector> // error
import <vector> // ok

不过import <标头单元>并不是一个好的解决方案,可能会遇到一些奇奇怪怪的问题,最佳解决方案我们后续给出

(7) 全局模块片段

模块单元可以使用module前缀来表明后续是一个全局模块片段(准确来说应该是module之后,模块声明之前的内容),并且,一旦使用module,那么它必须是首个声明。它的作用是什么呢,就是为了解决我们之前提到的#include的问题,直接上例子

1
2
3
4
5
6
7
module;                 // 必须是文件内的首个声明
// begin
# define PI 3.14159 // |
# include <windows.h> // 全局模块片段
# include <vector> // |
// end
export module MainModule; // 具名模块声明

通过该方法就很好的兼容了老文件,避免import <标头单元>出现的一些神奇的问题

(8) 私有模块片段

具名模块接口单元可以在最后申请一个私有模块片段,这样可以在不把所有内容暴露给导入方的情况下将模块表示为一个翻译单元。需要注意的就下面两点

(1)仅具名模块接口单元可以包含私有模块片段,也就是说具名模块的实现单元不允许包含,模块分区接口单元和实现单元也不允许包含
(2)私有模块片段提供了一种轻量的声明和定义分离的方法,所以我们即可以在具名模块接口单元中导出声明,在私有模块片段中提供定义,也可以像传统方案一样,在接口单元导出声明,在实现单元中提供定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 具名模块接口单元 Math.ixx
export module Math;
import :Add;
export import :Sub

export void calculate(); // 导出声明

module : private;

// 私有模块片段内
void calculate(){
int result = add(1, 2) + sub(3, 4);
cout << result << "\n";
}

模块所有权

cppreference中这样描述模块的所有权

(1)通常来说,在模块单元中的模块声明后出现的声明都附着于该模块
(2)如果一个实体的声明附着于一个具名模块,该实体只能在该模块中定义。每个这种实体的所有声明都必须附着于同一模块
(3)如果一个声明附着于一个具名模块,并且该声明没有被导出,那么声明的名字具有模块链接
(4)如果同一实体的两个声明附着于不同的模块,那么程序非良构;如果两个声明都无法从对方可及,那么不要求诊断
(5)具有外部链接的命名空间定义和在语言链接说明中的声明不附着于任何具名模块(因此声明的这些实体可以在模块外定义)

(1, 2, 3)比较容易理解,直接用cppreference中的例子说明,函数f没有被导出,所以具有模块连接,并依附于自身所在的模块,所以lib_A和lib_B中的函数f是不同的实体,它们的定义也必须在各自的模块单元中提供

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// -------------------------------------------------
// ------- 具名模块接口单元 lib_A.ixx ---------
// -------------------------------------------------
export module lib_A;
 
int f() { return 0; } // f 具有模块链接
export int x = f(); // x 等于 0

// -------------------------------------------------
// ------- 具名模块接口单元 lib_B.ixx ---------
// -------------------------------------------------

export module lib_B;
 
int f() { return 1; } // OK,lib_A 中的 f 和 lib_B 中的 f 指代不同的实体
export int y = f(); // y 等于 1

(4)还是cppreference中的例子

1
2
3
// decls.h
int f(); // #1,附着于全局模块
int g(); // #2,附着于全局模块
1
2
3
4
5
6
7
8
9
10
11
12
// M 的模块接口
module;

#include "decls.h"

export module M;
export using ::f; // OK,不声明实体,导出 #1

int g(); // 错误:与 #2 匹配,但附着于 M

export int h(); // #3
export int k(); // #4
1
2
3
4
// 其他翻译单元
import M;
static int h(); // 错误:与 #3 匹配
int k(); // 错误:与 #4 匹配

简单解释一下,我们首先在decls.h中声明了函数f()和函数g(),它们是依附于全局模块的(预处理阶段直接展开,只要#include "decls.h"就可见),所以当我们在具名模块接口单元M中导入了decls.h时,就已经存在了函数f()和函数g()的声明,它们依附于全局模块,而后续的int g();依附于模块M,因此导致了错误

对于export int h();export int k();,由于使用了export,所以它们并不是模块链接,而是外部链接,当我们import M;之后,后续的函数static int h()int k()就是可及的(即从当前翻译单元到达模块M中的声明),因此也出现了和上面一样的错误

(5)实际就是 (1) 中的例外,同样是cppreference中的例子,命名空间这个比较容易理解,简单连说就是命名空间是不附着于模块的,这也是为什么cppreference中说

模块和命名空间是正交的

函数f()和函数g()也容易理解,即它们是语言链接说明中的声明,而函数h()是依附于模块lib_A的,必须在当前模块中提供定义

1
2
3
4
5
6
7
8
9
10
export module lib_A;
 
namespace ns // ns 不附着于 lib_A
{
export extern "C++" int f(); // f 不附着于 lib_A
extern "C++" int g(); // g 不附着于 lib_A
export int h(); // h 附着于 lib_A
}
// ns::h 必须在 lib_A 中定义,但 ns::f 和 ns::g 可以在其他地方定义
// (例如在传统源文件中)

简单的module用例

介绍完module之后就上个简单的例子吧

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// -------------------------------------------------
// ------ Character.ixx 具名模块接口单元 ------
// -------------------------------------------------

module; // 声明全局模块片段,用于导入头文件

#include <memory>
#include <string>
#include <iostream>

export module Character; // 声明具名模块接口单元Character

export import :CharacterBase; // 导出模块分区接口单元CharacterBase
export import :Ranger; // 导出模块分区接口单元Ranger
export import :Warrior; // 导出模块分区接口单元Warrior

export std::unique_ptr<CharacterBase> create_character(
const std::string& type,
const std::string& name,
float health = 100,
float base_speed = 20,
float base_attack = 10
);

module :private; // 声明私有模块片段

std::unique_ptr<CharacterBase> create_character(
const std::string& type,
const std::string& name,
float health,
float base_speed,
float base_attack
) {
if (type == "Warrior") {
return std::make_unique<Warrior>(name, health, base_speed, base_attack);
}
else if (type == "Ranger") {
return std::make_unique<Ranger>(name, health, base_speed, base_attack);
}
else {
std::cout << "Error Character Type;";
return nullptr;
}
}
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
30
31
32
33
34
35
36
// -------------------------------------------------
// ---- CharacterBase.ixx 模块分区接口单元 ----
// -------------------------------------------------

module;

#include <string>
#include <iostream>

export module Character :CharacterBase; // 声明模块分区接口单元CharacterBase

export class CharacterBase {
public:
CharacterBase(std::string name, float health, float base_speed, float base_attack) noexcept
: name_(name), health_(health), base_speed_(base_speed), base_attack_(base_attack) {}

virtual ~CharacterBase() noexcept {}

virtual void show_attack() noexcept {
std::cout << "attack is: " << base_attack_ << "\n";
}

virtual void show_speed() noexcept {
std::cout << "speed is: " << base_speed_ << "\n";
}

void show_name() noexcept {
std::cout << name_;
}

protected:
std::string name_;
float health_;
float base_speed_;
float base_attack_;
};
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
// -------------------------------------------------
// ------- Ranger.ixx 模块分区接口单元 --------
// -------------------------------------------------

module;

#include <string>
#include <iostream>

export module Character :Ranger; // 声明模块分区接口单元Ranger

import :CharacterBase; // 导入模块分区接口单元CharacterBase

export class Ranger : public CharacterBase {
public:
Ranger(std::string name, float health, float base_speed, float base_attack) noexcept
: CharacterBase(name, health, base_speed, base_attack){}

virtual ~Ranger() noexcept override {

}

virtual void show_speed() noexcept override {
std::cout << "speed is: " << base_speed_ + speed_buff_ << "\n";
}

private:
float speed_buff_ = 5;
};
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
// -------------------------------------------------
// -------- Warrior.ixx 模块分区接口 ---------
// -------------------------------------------------

module;

#include <string>
#include <iostream>

export module Character :Warrior; // 声明模块分区接口单元Warrior

import :CharacterBase; // 导入模块分区接口单元CharacterBase

export class Warrior : public CharacterBase {
public:
Warrior(std::string name, float health, float base_speed, float base_attack) noexcept
: CharacterBase(name, health, base_speed, base_attack) {}

virtual ~Warrior() noexcept override {

}

virtual void show_attack() noexcept override {
std::cout << "attack is: " << base_attack_ + attack_buff_ << "\n";
}

private:
float attack_buff_ = 10;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Main.cpp

import Character;

int main()
{
auto player1 = create_character("Warrior", "jugg");
auto player2 = create_character("Ranger", "windrunner");

player1->show_name();
cout << " info: " << "\n";

player1->show_attack();
player1->show_speed();

cout << "-------------------------------\n";

player2->show_name();
cout << " info: " << "\n";
player2->show_attack();
player2->show_speed();
}

输出为

jugg info:
attack is: 20
speed is: 20
——————————-
windrunner info:
attack is: 10
speed is: 25

module的一些问题

总体使用感觉还是很不错的,如果是从零开始的新项目,其实完全可以使用module进行开发了,只有一些小问题

  • IDE支持有时候会失效,这个有点奇怪,偶尔会出现没有智能提示,或者是跳转到函数定义失败的问题
  • 和宏混用容易出错
  • 报错的支持还有待提升,这个应该是最麻烦的问题,有些奇怪的报错几乎是无法阅读的