长文,预计阅读11分钟,建议收藏
在传统的 C++ 中,使用#include包含头文件进行模块化编程。但是#include是在预处理阶段引入文件里的内容,尤其是涉及到递归引入时,增加编译时长;头文件做出修改,所有引入该头文件的翻译单元均需要重新编译,也会增加编译时间;同时头文件内的宏、全局变量否是在全局命名空间中定义,易导致命名冲突(虽然inline变量可以解决变量的重定义问题)。为彻底解决如上问题,C++20引入了模块。
模块作为C++20的新特性,就是为了改进代码组织和构建过程,提高代码的可维护性和性能。具体的优化如下:
-
改进了编译速度和性能:传统的#include预处理指令可能会导致头文件的重复包含和解析,增加了编译时间。模块可以减少这种重复性工作,因为它们会被编译器预先编译一次,并在需要时直接导入。
-
更清晰的依赖管理:传统的头文件包含方式容易导致头文件的依赖关系混乱,难以维护。模块提供了更清晰的方式来管理依赖关系,模块接口更明确,不需要担心头文件的间接依赖。
-
更快的构建时间:由于模块可以减少头文件的重复解析和编译,因此可以加快整体的构建时间。这对于大型项目尤其有益。
-
避免宏污染:传统的#include预处理指令可能会引入不必要的宏定义,可能导致命名空间污染和意外的行为。使用模块可以减少这种情况的发生,因为模块的导入更为明确。
-
提高代码的可维护性:模块提供了更清晰的接口和依赖关系,使得代码更易于理解、维护和重用。
入门
模块文件代码如下
arduino
// math_functions.ixx
// 模块声明,定义当前文件为一个模块,
//模块全局片段,该部分可选
module;
//包含头文件写于此处
#include<iostream>
// 导出模块接口,模块接口部分
export module math_functions;
// 定义模块接口
export int add(int a, int b);
//模块私有片段,该部分可选
module : private;
int add(int a, int b)//模块实现
{
std::cout << a << "+" << b << "=" << a + b << "\n";
return a + b;
}
//main.cpp
import math_functions;
int main() {
add(3, 4);
return 0;
}
如上代码可作为模块的使用样板文件,上述模块接口文件含有全局模块片段、模块接口部分和模块私有部分。
注意:
-
模块接口文件的后缀名并未有明确的定义,MSVC中使用.ixx,社区中也使用.mxx、.mpp、.cppm。模块实现文件仍旧使用.cpp。如上代码使用的模块接口文件的后缀名为.ixx。
-
除全局模块片段外不能使用#include。全局模块为module和export module module_name中的区域。在模块声明内使用#include会报错。
-
当前的C++头文件支持import导入,但是C语言的头文件并不保证是可导入的,建议使用#include包含。
-
存在私有片段的模块不可分区,同时,模块的实现必须在模块接口文件内,即存在私有片段的模块由这一个文件组成。
进阶
接口和实现分离
通常开发者会将接口的定义和实现书写于头文件和源文件中,模块也可以将模块定义和模块实现分离。一种方式是使用如上的private,在私有片段模块书写模块的实现。另一种方式是将接口和实现分别书写于接口文件和实现中。
如下:
arduino
//math_separate.ixx
//该模块接口文件无模块全局片段
// 导出模块接口,模块接口部分
export module math_separate;
// 定义模块接口
export int add(int a, int b);
//math_separate.cpp
module;
#include<iostream>
//如下一行含义为指明该文件为math_separate的实现文件
module math_separate;
int add(int a, int b)//模块实现
{
std::cout << a << "+" << b << "=" << a + b << "\n";
return a + b;
}
注意:
-
模块接口文件内import和include的内容在模块实现文件内可见,可用。
-
模块接口文件内import或include的文件在导入该接口文件的模块内不可见,不可用。模块做了很好的隔离,导入模块A的模块B内只见模块A主动导出的内容。
-
一个模块可以分割为一个模块接口文件和多个模块实现文件,可以存在一对多的关系,参考如下的代码
arduino
//module_multi_source.ixx
export module module_multi_source;
export void MMS_A();
export void MMS_B();
export void MMS_C();
//module_multi_source_a.cpp
module;
#include<iostream>
module module_multi_source;
void MMS_A()
{
std::cout<<__FUNCTION__<<"\n";
}
//module_multi_source_b.cpp
module;
#include<iostream>
module module_multi_source;
void MMS_B()
{
std::cout<<__FUNCTION__<<"\n";
}
//module_multi_source_c.cpp
module;
#include<iostream>
module module_multi_source;
void MMS_C()
{
std::cout<<__FUNCTION__<<"\n";
}
分区
当模块较大时,可以将模块分区。如下代码。
arduino
//shape_circle.ixx;
module;
constexpr double Pi = 3.1415926;
export module shape:circle;
export class Circle{
public:
Circle(float r):m_radius{r}{};
float GetPerimeter(){
return 2*Pi*m_radius;
}
float GetArea()
{
return Pi*m_radius*m_radius;
}
private:
float m_radius{0.0};
};
//shape_triangle.ixx
module;
export module shape:triangle;
export class Triangle
{
public:
Triangle(float w, float h) :m_width{ w },m_height{h} {};
float GetPerimeter() {
return 2 * (m_width+m_height);
}
float GetArea()
{
return m_height* m_width;
}
private:
float m_width{ 0.0 };
float m_height{0.0};
};
//shape.ixx
module;
export module shape;
export import :circle;
export import :triangle;
如上将模块shape分为两个不同的部分------Circle和Triangle。由上例可知,
-
分区的名称为模块名:分区名。
-
分区可以分别实现各自分区,但是模块主接口必须导入各个分区模块并导出,即出现【export import :分区名】样式的书写。
-
分区内部的所有内容(含非导出)在主接口模块内可见。但是分区对模块使用者是不可见的。
子模块
关于子模块,有的文章认为如下的代码是子模块概念
arduino
//moduleA.ixx
export module A;
export import A.B;
export import A.C;
//moduleAB.ixx
module;
#include<iostream>
export module A.B;
export void AB()
{
std::cout<<"int a.b \n";
return;
}
//moduleAC.ixx
module;
#include<iostream>
export module A.C;
export void AC()
{
std::cout << "int a.c \n";
return;
}
认为A.B和A.C是模块A的子模块,我对此有不同的看法,从模块名称可以主观的认为三者存在父子关系,但本质上仅仅是在模块A内将导出导入的模块A.B和A.C,则在导入模块A时,可以使用模块A.B和A.C的方法。同时翻遍cppreference也没有子模块的概念。综上,我认为没有子模块,两者是独立的模块,只是模块A将模块A.B和A.C导入又导出了。
拓展
export的限制
arduino
module;
#include<string>
export module export_type;
export int a = 1100;
export int multi(int a, int b)
{
return a*b;
}
export class People {
public:
People(std::string name, int age) :m_name{ name }, m_age{ age } {}
//成员函数不可导出
//export int GetAge()const
//{
// return m_age;
//}
//成员变量不可导出
//export int some_info{33};
private:
std::string m_name{ "" };
int m_age{ 0 };
};
export enum class Color
{
kC_Red,
kC_Blue,
kC_Green
};
export namespace FFmpegT {
}
export {
int sub(int a, int b)
{
return a - b;
}
//static int b = 100;//静态变量不可导出
//static int printHello()//静态函数不可导出
//{
// std::cout << "hello world \n";
//}
}
由如上示例代码可知,全局变量,全局函数、类/结构体/联合体/枚举、命名空间/块(被{}包含的部分)都可以导出,但是静态变量、静态函数、成员变量、成员函数不支持导出。
总结
本文引入揭示了传统include存在的问题,并介绍了C++20模块的用法,并着重强调了接口和实现分离、模块分区的用法,同时提出了认为不存在子模块的观点。如上恳请指正。