65.9K
CodeProject 正在变化。 阅读更多。
Home

现代 C++ 的模块

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2017 年 11 月 18 日

CPOL

8分钟阅读

viewsIcon

46290

downloadIcon

531

本文解释了未来 C++ 标准的最新模块 TS 草案,并提供了基于 Visual C++ 2017 中模块实现的示例。

引言

模块 TS 即将获得批准。关于模块的提案可在 [1] 中找到。我们希望它能包含在 C++20 标准中。

模块解决的问题

(1) 减少编译时间。 确保代码只编译一次。目前在 C++ 中,头文件(尤其是包含模板的头文件)需要很长时间来编译,并且如果被多个源文件调用,可能会被编译多次。

(2) 防止代码各项之间的相互依赖。 通常 #include 指令包含的宏可能会相互影响。模块的导入声明顺序无关紧要,只要没有名称冲突即可。模块不导出宏,这确保了宏名称不会发生冲突。

(3) 避免使用库时出现的头文件混淆。 在 C 中,库和头文件是分开存储的,这使得人们很容易混淆哪个头文件属于哪个库。模块的导入声明将自动访问正确的接口。

在问题 (2) 中,可以使用不同的命名空间来导出对象,从而防止名称冲突:例如,一个命名空间可以与它所属的模块同名。

模块声明

模块化程序的整体结构如图 1 所示。

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

两个派生类 CatDog 定义在单独的模块中

// 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 所示。

图 2. CppModuleBuilder 中的程序结构

可以通过单击工具栏上的“加载项目”按钮并选择扩展名为 .proj 的文件来加载项目。有一些示例项目供您使用。第一次使用构建时,会出现如图 3 所示的对话框。

图 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 上尝试过。

参考文献

  1. www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4689.pdf
  2. https://schneide.wordpress.com/2017/07/09/c-modules-example/
  3. https://blogs.msdn.microsoft.com/vcblog/2017/05/05/cpp-modules-in-visual-studio-2017/
  4. https://blogs.msdn.microsoft.com/vcblog/2015/12/03/c-modules-in-vs-2015-update-1/
  5. https://clang.llvm.net.cn/docs/ReleaseNotes.html
  6. http://releases.llvm.org/download.html
© . All rights reserved.