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