掘金 后端 ( ) • 2024-04-15 10:44

长文,预计阅读11分钟,建议收藏

在传统的 C++ 中,使用#include包含头文件进行模块化编程。但是#include是在预处理阶段引入文件里的内容,尤其是涉及到递归引入时,增加编译时长;头文件做出修改,所有引入该头文件的翻译单元均需要重新编译,也会增加编译时间;同时头文件内的宏、全局变量否是在全局命名空间中定义,易导致命名冲突(虽然inline变量可以解决变量的重定义问题)。为彻底解决如上问题,C++20引入了模块。

模块作为C++20的新特性,就是为了改进代码组织和构建过程,提高代码的可维护性和性能。具体的优化如下:

  • 改进了编译速度和性能:传统的#include预处理指令可能会导致头文件的重复包含和解析,增加了编译时间。模块可以减少这种重复性工作,因为它们会被编译器预先编译一次,并在需要时直接导入。

  • 更清晰的依赖管理:传统的头文件包含方式容易导致头文件的依赖关系混乱,难以维护。模块提供了更清晰的方式来管理依赖关系,模块接口更明确,不需要担心头文件的间接依赖。

  • 更快的构建时间:由于模块可以减少头文件的重复解析和编译,因此可以加快整体的构建时间。这对于大型项目尤其有益。

  • 避免宏污染:传统的#include预处理指令可能会引入不必要的宏定义,可能导致命名空间污染和意外的行为。使用模块可以减少这种情况的发生,因为模块的导入更为明确。

  • 提高代码的可维护性:模块提供了更清晰的接口和依赖关系,使得代码更易于理解、维护和重用。

入门

模块文件代码如下

// 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;
}

如上代码可作为模块的使用样板文件,上述模块接口文件含有全局模块片段、模块接口部分和模块私有部分。

注意

  1. 模块接口文件的后缀名并未有明确的定义,MSVC中使用.ixx,社区中也使用.mxx、.mpp、.cppm。模块实现文件仍旧使用.cpp。如上代码使用的模块接口文件的后缀名为.ixx。

  2. 除全局模块片段外不能使用#include。全局模块为module和export module module_name中的区域。在模块声明内使用#include会报错。

  3. 当前的C++头文件支持import导入,但是C语言的头文件并不保证是可导入的,建议使用#include包含。

  4. 存在私有片段的模块不可分区,同时,模块的实现必须在模块接口文件内,即存在私有片段的模块由这一个文件组成。

进阶

接口和实现分离

通常开发者会将接口的定义和实现书写于头文件和源文件中,模块也可以将模块定义和模块实现分离。一种方式是使用如上的private,在私有片段模块书写模块的实现。另一种方式是将接口和实现分别书写于接口文件和实现中。

如下:

//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;
}

注意:

  1. 模块接口文件内import和include的内容在模块实现文件内可见,可用。

  2. 模块接口文件内import或include的文件在导入该接口文件的模块内不可见,不可用。模块做了很好的隔离,导入模块A的模块B内只见模块A主动导出的内容。

  3. 一个模块可以分割为一个模块接口文件和多个模块实现文件,可以存在一对多的关系,参考如下的代码

//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";
}

分区

当模块较大时,可以将模块分区。如下代码。

//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。由上例可知,

  1. 分区的名称为模块名:分区名。

  2. 分区可以分别实现各自分区,但是模块主接口必须导入各个分区模块并导出,即出现【export import :分区名】样式的书写。

  3. 分区内部的所有内容(含非导出)在主接口模块内可见。但是分区对模块使用者是不可见的。

子模块

关于子模块,有的文章认为如下的代码是子模块概念

//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的限制

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模块的用法,并着重强调了接口和实现分离、模块分区的用法,同时提出了认为不存在子模块的观点。如上恳请指正。