现代 C++ 的模块





5.00/5 (5投票s)
本文解释了未来 C++ 标准的最新模块 TS 草案,并提供了基于 Visual C++ 2017 中模块实现的示例。
引言
模块 TS 即将获得批准。关于模块的提案可在 [1] 中找到。我们希望它能包含在 C++20 标准中。
模块解决的问题
(1) 减少编译时间。 确保代码只编译一次。目前在 C++ 中,头文件(尤其是包含模板的头文件)需要很长时间来编译,并且如果被多个源文件调用,可能会被编译多次。
(2) 防止代码各项之间的相互依赖。 通常 #include 指令包含的宏可能会相互影响。模块的导入声明顺序无关紧要,只要没有名称冲突即可。模块不导出宏,这确保了宏名称不会发生冲突。
(3) 避免使用库时出现的头文件混淆。 在 C 中,库和头文件是分开存储的,这使得人们很容易混淆哪个头文件属于哪个库。模块的导入声明将自动访问正确的接口。
在问题 (2) 中,可以使用不同的命名空间来导出对象,从而防止名称冲突:例如,一个命名空间可以与它所属的模块同名。
模块声明
模块化程序的整体结构如图 1 所示。
让我们详细看看。每个矩形代表一个翻译单元,它存储在一个文件中并单独编译。每个模块(我指的是“命名模块”,它有一个名称)可以由一个接口单元和零个或多个实现单元组成。接口单元包含以下声明
export module module_name;
项 module_name, 由一个或多个由点分隔的标识符组成,是要导出的模块的名称。每个实现单元(如果可用)应包含以下声明
module module_name;
每个翻译单元最多只能有一个模块声明(“export module module_name;” 或 “module module_name;”)。模块声明不必是模块中的第一个声明。出现在模块声明之前的声明属于不具有任何名称的全局模块。这使得可以使用在模块之间共享但不能被命名模块导出的各种 include。
每个模块通常需要导出一些实体:类、变量、常量、函数、模板等。这些实体应该被导出,这意味着它们的声明应该以“export”关键字开头。
如果需要导入一个模块,以使用它导出的实体,则使用以下结构
import module_name;
可以将一个模块导入并导出其所有内容到另一个模块。在这种情况下,可以使用以下声明
export import module_name;
从编译的角度来看:每个模块只编译一次。模块可能相互依赖。但它们的接口之间不能存在循环依赖。这意味着接口应该是按顺序的:导入模块的接口应该在导出该模块的接口之后编译。实现应该在接口之后编译。
一个简单示例
让我们看一个简单的例子,其中包含一个模块 S1
,该模块仅使用接口单元定义
// interface unit S1. File S1.ixx <span style="display: none;"> </span>import std.core; export module S1; export int n = 25; // exporting a variable export constexpr double p = 3.2; // exporting a constant expression export struct A // exportings a structure { void print() const; }; void A::print() const { std::cout << "A. n:" << n << " p:" << p << std::endl; n = 22; }<span style="display: none;"> </span>
程序文件 Test_S1.cpp 包含以下代码
// File Test_S1.cpp <span style="display: none;"> </span>import std.core; import S1; int main() { A x; std::cout << "n:" << n << " p:" << p << std::endl; n= 27; x.print(); std::cout << "n:" << n << " p:" << p << std::endl; }
程序将打印
n:25 p:3.2 A. n:27 p:3.2 n:22 p:3.2
标准库导入尚未完全修复。我将提供一个基于 Visual C++ 2017 实现的示例。
这个例子表明,模块可以很容易地仅使用接口单元来定义。如你所见,导出声明应该跟在模块声明之后。如果一个实体没有被导出,它就不会被导入。
可以将模块定义拆分到两个单元中——一个接口和一个实现——如下所示
// interface unit S1. File S1.ixx export module S1; // export declaration export int n = 25; // exporting a variable export constexpr double p = 3.2; // exporting a constant expression export struct A // exporting a structure { void print(); }; // implementation unit S1. File S1.cxx import std.core; module S1; void A::print() { std::cout << "A. n:" << n << " p:" << p << std::endl; n = 22; }
但在这里没有多大意义,尽管它会减小接口文件的大小。Test_S1.cpp 将保持不变。
模块之间的相互依赖
只有在模块之间存在相互依赖时,才需要将模块拆分为接口和实现。典型的例子是相互递归函数。考虑以下两个相互递归的整数函数。
函数 f
f(0) = 1
f(n) = 1+q(n-1),如果 n > 0
函数 q
q(0) = 0
q(n) = q(n-1)+n*f(n-1),如果 n > 0
在这种情况下,接口单元可以定义如下
// interface F1. File: F1.ixx export module F1; export int f(int n); // interface Q1. File: Q1.ixx export module Q1; export int q(int n);
接口已存在。在这种情况下,在实现单元中,我们可以导入这些模块
// implementation F1. File F1.cxx import Q1; module F1; int f(int n) { if (n == 0) return 1; return 1+q(n-1); } // implementation Q1. File Q1.cxx import F1; module Q1; int q(int n) { if (n == 0) return 1; return q(n-1)+n*f(n-1); }
程序文件 F1Q1_Test.cpp 可以如下所示
import std.core; import Q1; import F1; int main() { for (int n = 0; n <= 10; n++) { std::cout << "n= " << n << " f(n)= " << f(n) << " q(n)= " << q(n) << std::endl; } }
此程序的输出将是
n= 0 f(n)= 1 q(n)= 1 n= 1 f(n)= 2 q(n)= 2 n= 2 f(n)= 3 q(n)= 6 n= 3 f(n)= 7 q(n)= 15 n= 4 f(n)= 16 q(n)= 43 n= 5 f(n)= 44 q(n)= 123 n= 6 f(n)= 124 q(n)= 387 n= 7 f(n)= 388 q(n)= 1255 n= 8 f(n)= 1256 q(n)= 4359 n= 9 f(n)= 4360 q(n)= 15663 n= 10 f(n)= 15664 q(n)= 59263
一个更复杂的例子:导入和导出
这是一个我在网上找到的例子 [2]。我对其进行了轻微修改,以使用正确的模块 TS 语法,并添加了 pets.cat 模块。
我们从基类开始
// Interface pets.pet. File pets.pet.ixx import std.core; export module pets.pet; export class Pet { public: virtual std::string says() = 0; };
两个派生类 Cat
和 Dog
定义在单独的模块中
// Interface pets.cat. File pets.cat.ixx import std.core; export module pets.cat; import pets.pet; export class Cat : public Pet { public: std::string says() override; }; std::string Cat::says() { return "Miaow"; } // Interface pets.dog. File pets.dog.ixx import std.core; export module pets.dog; import pets.pet; export class Dog : public Pet { public: std::string says() override; }; std::string Dog::says() { return "Woof!"; }
现在我们可以将所有这些定义合并到一个模块中,以便只导入一个模块而不是三个
// Interface pets. File pets.ixx export module pets; export import pets.pet; export import pets.dog; export import pets.cat;
现在主模块可以导入 pets
模块并使用所有三个模块
//File Pets_Test.cpp import pets; import std.core; import std.memory; int main() { std::unique_ptr<Pet> pet1 = std::make_unique<Dog>(); std::cout << "Pet1 says: " << pet1->says() << std::endl; std::unique_ptr<Pet> pet2 = std::make_unique<Cat>(); std::cout << "Pet2 says: " << pet2->says() << std::endl; }
程序的输出将是:
Pet1 says: Woof! Pet2 says: Miaow
避免名称冲突:使用命名空间
总的来说,模块并不能完全消除名称冲突问题。当导入的两个模块具有同名的对象时,会出现问题。如果将导出的对象包含在与模块同名的命名空间中,则可以避免这种情况。这是一个基于我之前展示的 S1
模块的示例
// Interface S2. File S2.ixx import std.core; export module S2; export namespace S2 { int n = 25; constexpr double p = 3.2; struct A { void print() const; }; void A::print() const { std::cout << "A. n:" << n << " p:" << p << std::endl; n = 22; } }
主模块可以如下所示
// File Test_S2.cpp import std.core; import S2; int main() { S2::A x; std::cout << "n:" << S2::n << " p:" << S2::p << std::endl; S2::n= 27; x.print(); std::cout << "n:" << S2::n << " p:" << S2::p << std::endl; }
如果此程序的输出与之前使用模块 S1
的情况相同
n:25 p:3.2 A. n:27 p:3.2 n:22 p:3.2
在 CppModuleBuilder 中运行示例
我创建了一个简单的 IDE 环境,使用 Visual C++ 2017 命令行来编译和构建模块。在 IDE 中尝试模块比执行多个命令行、注意它们的顺序要容易,IDE 会自动构建整个程序,而无需处理各种模块。此工具仅用于演示目的,不适用于严肃的开发。
IDE 用 C# 编写,外观如下
工具栏上的图标不言自明:新建文件、加载文件、保存文件、查找、替换、删除文件、加载项目、保存项目、关闭项目、构建、运行。创建新文件时,用户必须立即为其命名。文件名应对应使用的模块名称。接口模块的扩展名应为 .ixx;实现模块的扩展名应为 .cxx。主模块的扩展名应为 .cpp。每个模块最多只能有一个实现。程序的整体结构如图 2 所示。
可以通过单击工具栏上的“加载项目”按钮并选择扩展名为 .proj 的文件来加载项目。有一些示例项目供您使用。第一次使用构建时,会出现如图 3 所示的对话框。
在这种情况下,您必须选择 VsDevCmd.bat 路径,该路径对应于您想要使用的最新 Visual Studio。在本例中,它将是第一行。
开始新项目时,您可以关闭上一个项目,创建新文件并将它们保存为项目。您必须始终为项目文件提供 .proj 扩展名。
有关 Visual Studio 中 C++ 模块的任何额外信息可在 [3,4] 处找到。
CppModuleBuilder 处理文件并将处理后的文件复制到用户“我的文档”文件夹中的 cpp_module_builder 目录中。在那里进行编译。
我还引入了一些“调整”——对源代码进行了额外的预处理
(1) “export module”实际上还不可用。VC++ 使用“export”。CppModuleBuilder 会移除“export”。
(2) 如果模块的接口和实现都存在并且导出了一个变量,那么如果变量没有在实现文件中定义,编译就会失败。我不得不将其放入一个 .hxx 文件中,该文件在实现文件的开头被导入。
未来,当模块被正确实现时,所有这些调整将不再需要。
其他问题:类的相互依赖
考虑以下示例
// Interface P1. File P1.ixx export module P1; struct SP2; export struct SP1 { SP1(int m):v1(m) {}; SP2* p2; void print(); int v1; }; export struct SP2 { SP2(int m):v2(m) {} SP1* p1; void print(); int v2; }; //Implementation P1. File P1.cxx import std.core; module P1; void SP1::print() { std::cout << "SP1 self:" << v1<< " the other:" << p2->v2 << std::endl; } void SP2::print() { std::cout << "SP2 self:" << v2<< " the other:" << p1->v1 << std::endl; }
主模块可以如下所示
// File P1_Test.cpp import P1; int main() { SP1* s1 = new SP1(10); SP2* s2 = new SP2(5); s1->p2 = s2; s2->p1 = s1; s1->print(); s2->print(); delete s1; delete s2; };
此程序可以在 VC++2017 中编译和运行。您可以使用 CppModuleBuilder。但是,如果我们想将模块 P1 分割成两个模块,以便每个类都在一个单独的模块中,该怎么办?这里的问题是,我们必须在接口中引用另一个模块。我们不能导入它们:不允许循环引用。模块可以按顺序编译,但每个模块只能编译一次。
我们如何才能在不导入模块的情况下引用另一个模块中的对象?所谓的声明所有权声明对此有所帮助。它让我想起了函数转发。我们不完全声明一个对象:只是引用它,说它将在以后声明。这足以定义它的指针。声明所有权声明具有以下语法
extern module module_name: declaration;
以下是我们将接口拆分到模块中的方法。
// Interface SP1M. export module SP1M; extern module SP2M: struct SP2; // proclaimed ownership declaration export struct SP1 { SP1(int m):v1(m) {}; SP2* p2; void print(); int v1; }; // Interface SP2M. export module SP2M; import SP1M; export struct SP2 { SP2(int m):v2(m) {} SP1* p1; void print(); int v2; };
我故意没有给出文件名,因为实现尚未可用。您无法尝试此示例。
更多信息
您可能想看看 Clang [5] 并下载 Clang 5.0.0 [6]。我在 Windows 上尝试过。
参考文献
- www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4689.pdf
- https://schneide.wordpress.com/2017/07/09/c-modules-example/
- https://blogs.msdn.microsoft.com/vcblog/2017/05/05/cpp-modules-in-visual-studio-2017/
- https://blogs.msdn.microsoft.com/vcblog/2015/12/03/c-modules-in-vs-2015-update-1/
- https://clang.llvm.net.cn/docs/ReleaseNotes.html
- http://releases.llvm.org/download.html