Marvin's Blog【程式人生】

Ability will never catch up with the demand for it

22 Dec 2019

C++20的模块系统

C++20标准引入了一个大家都比较期盼的慨念: Modules(模块)。模块的慨念在其他语言中,尤其是解释型语言中(比如Python、JavaScript)非常常见。可能是因为解释型语言实现Module比较容易,解释型语言有解释器,里面存着当前程序的整体符号列表,引入Module,无非是在这个符号列表中插入Module导出的符号。对于编译型语言,往往要经过链接的过程,合并符号表可能会变成一个很复杂的过程,因为符号表可以有不同的来源,有的是系统系统的,有的是应用提供的,有的是第三方库提供的。

但是,对于存在中间指令语言,链接比较简单的译型的语言,比如C#以及Java,也有跟module类型的慨念,比如C#中的assembly以及Java中的Package。

Java 9中似乎引入了对module系统的支持。

C/C++的头文件

C或者C++是编译型语言,也就是说,编译器直接把程序源代码转化为机器码,可以让CPU读取执行。由于在编译的过程中,编译器对程序做大量的处理(比如分析、优化等等),过程会非常耗时。所以编译一般是在程序发布之前执行。不过有些语言也支持即时编译(JIT),可以在程序要被使用的时候(比如第一次运行)将源代码或者中间代码编译成机器码。

编译器处理的对象称为一个编译单元,通常是一个源代码文件。我们可以将程序的所有的源代码,放在一个文件中,让编译器处理。这样这个程序只有一个编译单元。比如sqlite在发布源代码的时候就把所有的源代码通过amalgamation的方式整合成一个大的源文件来发布。

可是对于现代的程序,是不可能只由一个编译单元构成的。即便采用像sqlite这种amalgamation,最后生成程序执行文件的时候,也是需要链接多个编译单元。因为sqlite使用了一部分语言的运行时库提供的功能,这运行时库又使用了操作系统提供的服务。不管是语言的运行时库还是操作系统的服务,大都是通过预先编译好的库文件提供的。而这些库文件,也是通过源代码编译而来的。

C/C++编译器一定会涉及多个编译单元,并需要处理不同编译单元之间的链接问题,也就是如何将不同编译单元中定义的符号(全局变量,函数,类型等等)整合在一起。对此,C语言提供的解决方案是将声明和定义分开。比如,一个函数只能被定义一次,但是可以被声明多次。为此,C语言将源代码文件分成了两类,一类是头文件,用来放置符号的声明;另一类是实现文件,用来放置符号的定义。一个实现文件构成一个编译单元,并且可以包含不同的头文件,引入不同的符号声明。

但C语言做的比较粗放:

  • 头文件和实现文件的分类其实只是一个约定,C语言不阻止你放置符号的定义在头文件中。与此相反,使用C++模板的时候,常常很多时候定义要放置在头文件中,结果同一个符号的定义会出现在不同的编译单元中,需要靠链接器在链接的时候消除。
  • 头文件的包含,其实是通过预处理器实现的,跟编译器(特别是链接过程)其实没有太大的关系。也就是说,链接器看不到一个编译单元都包含过什么头文件。链接器无法对包含到编译单元中的符号进行控制。

由于这些缺点的存在,导致通过头文件来组织代码的方式在组织性和可管理性上较差。工程一大,人多手杂的时候容易出错。

C++20 Modules

解决问题的关键是让链接器能够更多参与符号表的操作。这需要让链接器知晓编译单元导入了符号,以及导出哪些符号。C++20的Modules为这些在语法层面提供了一些机制。

一个编译单元可以使用export module的方式来声明自己是一个模块接口单元(Module interface Unit):

export module my.module

int rest() { sleep(...); }

export {
  int visitors = 1000;
  void hello();
}

如上所示,一个模块接口单元可以导出一些符号(原先这些符号是要在头文件中声明的)。导出的符号可以被其他模块导入,未导出的符号是私有的,其他模块不可见。除了模块接口单元,同一模块还可以有若干模块实现单元(Module Implementation Unit):

module my.module

void hello() {
  // welcome
}

模块接口单元定义的符号在模块实现单元内皆可见。使用模块也很简单:

import my.module;
import <iostream>

int main() {
  hello();
  std::cout << visitors << std::endl;
}

如果在使用module的时候不带名字,那么就属于全局模块:

module;
#include<vector>
export module my.module;

全局模块的默认名字是universe。如果你导出命名空间中的符号,则命名空间会属于全局模块,而不是你导出的模块:

export module my.module;

export namespace A {
int bar() { ... }
}

namespace B {
export int foo() { ... }
}

上述的::A::bar和::B::foo都是附着到全局模块。另外需要注意的是,foo被导出,foo所在的命名空间B也会被默认导出。

大概命名空间和模块是两种平行的操作。

所有具有外部链接性的符号都可以被导出。 module关键字不能由宏定义生成。

C++20对Module还引入了partition,也就是分片的概念。一个模块可以有一个主模块接口单元,这个模块接口单元可以导入来自于模块分片接口单元中的符号。默认情况下,模块分片单元不导入主接口单元中的符号。

传统的预编译头文件,在C++20中变为顶头模块(Header Module)。不是所有的头文件都可以转化成顶头模块,只有那些被称为importable header的头文件可以,而importable header是实现相关的。C++标准规定标准库的头文件必须都是importable header,并且可以隐式从#include转化为#import。

其他

comments powered by Disqus