C++ Module详解:模块化编程终极指南
注意:目前,还没有为模块接口文件标准化的扩展名。然而,大多数编译器支持 .cppm(C++ 模块)扩展名,这也是本书所使用的。请检查你的编译器文档,了解应使用哪种扩展名。
2.导出与模块接口
模块需要明确声明要导出什么,即客户端代码导入模块时应该可见的内容。从模块导出实体(例如,类、函数、常量、其他模块等)是通过 export 关键字完成的。模块中未导出的任何内容只在模块内部可见。所有导出实体的集合称为模块接口。
以下是一个名为 Person.cppm 的模块接口文件示例,定义了一个 person 模块并导出了一个 Person 类。注意它导入了
export module person; // 模块声明
import ; // 导入声明
export class Person // 导出声明
{
public:
Person(std::string firstName, std::string lastName)
: m_firstName { std::move(firstName) }, m_lastName { std::move(lastName) } { }
const std::string& getFirstName() const { return m_firstName; }
const std::string& getLastName() const { return m_lastName; }
private:
std::string m_firstName;
std::string m_lastName;
};
3.使用模块
这个 Person 类可以通过导入 person 模块在以下代码中使用(test.cpp):
import person; // 导入 person 模块声明
import ;
import ; // 用于 std::string 的 operator<<
using namespace std;
int main() {
Person person { "Kole", "Webb" };
cout << person.getLastName() << ", " << person.getFirstName() << endl;
}
所有 C++ 头文件,如
例如,如果你需要使用
module; // 开始全局模块片段
#include // 包含传统头文件
export module person; // 命名模块声明
import ;
export class Person {
};
4.标准术语和导出声明
在标准术语中,从命名模块声明开始直到文件末尾的一切称为模块视野。几乎任何东西都可以从模块中导出,只要它有一个名称。示例包括类定义、函数原型、类枚举类型、使用声明和指令、命名空间等。如果命名空间使用 export 关键字显式导出,那么该命名空间内的所有内容也会自动导出。例如,以下代码片段导出了整个 DataModel 命名空间;因此,无需显式导出各个类和类型别名:
export module datamodel;
import ;
export namespace DataModel {
class Person { };
class Address { };
using Persons = std::vector;
}
你还可以使用导出块导出一整块声明。以下是一个示例:
export {
namespace DataModel {
class Person { };
class Address { };
using Persons = std::vector;
}
}
二、模块实现文件
1.分割接口与实现
一个模块可以被分割为模块接口文件和一个或多个模块实现文件。模块实现文件通常使用 .cpp 作为扩展名。你可以自由决定将哪些实现移至模块实现文件,以及保留哪些实现在模块接口文件中。
一种选择是将所有函数和方法的实现都移至模块实现文件中,而只在模块接口文件中保留函数原型、类定义等。另一种选择是将小型函数和方法的实现保留在接口文件中,同时将其他函数和方法的实现移至实现文件。在这里,你有很大的灵活性。
模块实现文件同样包含一个命名模块声明,以指定实现是为哪个模块服务的,但没有 export 关键字。例如,之前的 person 模块可以被分割为接口和实现文件,如下所示。这里是模块接口文件:
export module person; // 模块声明
import ;
export class Person {
public:
Person(std::string firstName, std::string lastName);
const std::string& getFirstName() const;
const std::string& getLastName() const;
private:
std::string m_firstName;
std::string m_lastName;
};
实现现在放在 Person.cpp 模块实现文件中:
module person; // 模块声明,但没有 export 关键字
using namespace std;
Person::Person(string firstName, string lastName)
: m_firstName { move(firstName) }, m_lastName { move(lastName) } { }
const string& Person::getFirstName() const { return m_firstName; }
const string& Person::getLastName() const { return m_lastName; }
2.实现文件的特点
请注意,实现文件没有为 person 模块的导入声明。module person 声明隐含地包括了 import person 声明。同样值得注意的是,尽管在方法实现中使用了 std::string,实现文件也没有对
相比之下,向 test.cpp 文件添加 import person 声明并不会隐含地继承
注意:模块接口和模块实现文件中的所有导入声明都必须位于文件顶部,在命名模块声明之后,但在任何其他声明之前。与模块接口文件类似,如果在模块实现文件中需要任何传统头文件的 #include 指令,你应该将它们放在全局模块片段中,其语法与模块接口文件相同。
警告:模块实现文件不能导出任何内容;只有模块接口文件可以。
三、从实现中分离接口
1.使用头文件时的建议
当使用头文件(.h)而非模块时,强烈建议只在头文件中放置声明,并将所有实现移至源文件(.cpp)。这样做的一个原因是为了提高编译时间。如果将实现放在头文件中,任何更改,即使只是修改一个注释,也需要重新编译包含该头文件的所有其他源文件。对于某些头文件,这可能会导致整个代码库的全面重新编译。通过将实现放在源文件中,不触及头文件的情况下对这些实现进行修改,意味着只需要重新编译那个单独的源文件。
2.模块的不同工作方式
模块的工作方式不同。模块接口仅包括类定义、函数原型等,但不包括任何函数或方法的实现,即使这些实现直接位于模块接口文件中。因此,更改模块接口文件内的函数或方法实现,只要不触及接口部分(例如,函数头 = 函数名、参数列表和返回类型),就不需要重新编译使用该模块的用户。
有两个例外:使用 inline 关键字标记的函数/方法,以及模板定义。对于这两者,编译器需要在编译使用它们的客户端代码时了解它们的完整实现。因此,对 inline 函数/方法或模板定义的任何更改都可能触发客户端代码的重新编译。
注意:当头文件中的类定义包含方法实现时,这些方法即使没有标记 inline 关键字,也会被隐式地视为内联。但这对于模块接口文件中类定义中的方法实现不成立。如果这些需要被内联,它们需要被显式地标记为此。
尽管从技术上讲,不再需要将接口与实现分离,但在某些情况下,我仍然建议这样做。主要目标应该是拥有清晰易读的接口。只要函数的实现不会遮蔽接口,使用户难以快速理解公共接口提供了什么,就可以保留在接口中。例如,如果一个模块有一个较大的公共接口,最好不要用实现来遮蔽该接口,这样用户可以更好地了解所提供的内容。然而,小的 getter 和 setter 函数可以保留在接口中,因为它们对接口的可读性影响不大。
从实现中分离接口可以通过几种方式完成。一种选择是将模块分为接口和实现文件,如前一节所讨论的。另一种选择是在单个模块接口文件内分离接口和实现。例如,以下是在单个模块接口文件(person.cppm)中定义的 Person 类,但将实现与接口分离:
export module person;
import ;
// 类定义
export class Person {
public:
Person(std::string firstName, std::string lastName);
const std::string& getFirstName() const;
const std::string& getLastName() const;
private:
std::string m_firstName;
std::string m_lastName;
};
// 实现
Person::Person(std::string firstName, std::string lastName)
: m_firstName { std::move(firstName) }, m_lastName { std::move(last
Name) } { }
const std::string& Person::getFirstName() const { return m_firstName; }
const std::string& Person::getLastName() const { return m_lastName; }
四、可见性与可达性
1.引入模块的影响
正如之前提到的,当你在非 person 模块的另一个源文件中导入 person 模块(例如在 test.cpp 文件中),你并没有隐含地继承 person 模块接口文件中的
import person;
int main() {
std::string str;
Person person { "Kole", "Webb" };
const std::string& lastName { person.getLastName() };
}
然而,即使没有向 test.cpp 添加
const auto& lastName { person.getLastName() };
auto length { lastName.length() };
2.为什么这样工作?
在 C++ 中,实体的可见性和可达性是不同的。通过导入 person 模块,
要使 std::string 名称在 test.cpp 中可见,需要显式导入
cout << person.getLastName() << endl;