C++20之Module(浅析)

时间:2022-10-21 22:55:42

「前置说明」

由于C++20标准中对于module的标准定义存在一些有争议的地方,并且还不够完善(这个期待23标准的补充),而各个编译期对这个特性的支持也是参差不齐、各有千秋,甚至连编译参数、文件扩展名等等定义都不相同。本文尽量屏蔽掉这些编译器差异,重点介绍语言本身。而本文所使用的是clang-14编译器,所有的参数、特性和行为都是建立在这个版本上的,如果读者使用其他编译器,或是clang的其他版本,可能参数、行为会有不同。

为什么要引入Module?

在解释module引入原因之前,我们先来看一下传统的C/C++程序的编译行为。

声明

「声明」这个行为可以算得上是C/C++语言的特色行为了,之所以要进行声明,这跟C/C++的编译过程有关。C/C++的编译是单文件行为,因此对于跨文件使用的内容,我们就需要向编译器表明“某一个东西,会在外部有所定义”,这样的语法叫做「声明」。也就是说,编译期会按照声明内容来进行静态检查,并完成对文件的编译。

在每个文件都单独编译完成之后,把各个文件中的内容“连通”起来的工作叫做「链接」,它是与「编译」不同的行为。

因此,对于源代码来说,「声明」就是一个很重要的环节,例如:

extern void f1(void); // 函数声明

void Demo() {
  f1(); // 调用了一个外部函数
}

上面的extern void f1();就是函数声明,我们需要声明函数的名称、签名类型以及一些静态属性。这里翻译成大白话就是:「编译期君你好!我现在需要在本文件中使用一个函数,名为f1,它的参数是空的,返回值是也是空的。这个函数会在外部(的某个地方)实现,请你按照我的声明来进行静态检查,如果OK的话,麻烦帮我通过编译。」

声明还有另一个作用,就是做内存布局,比如:

class A1; // 声明类型A1

class T1 {
  A1 a; // ERR
};

上面例子中,虽然我们声明了类型A1,但是却不能直接使用,原因就是无法知晓它的长度,那么也就无法进行T1中的内存布局。但如果它不影响内存布局的话,也可以直接单独声明:

class A1;

class T1 {
  A1 *a; // OK
};

这里由于A1 *的长度固定,与A1的长度无关,因此可以这样使用。

只有在确定了长度之后才能参与内存布局,在加上我们会需要知晓内部方法的类型签名来调用,因此,要完整声明一个类型中的所有属性,才能正常被使用,比如:

class A1 {
 publicA1();
  ~A1();
  void f1();
 private:
  int m1_, m2_;
};

class T1 {
  A1 a; // OK
};

这时,在T1中使用A1时,由于内存布局已经知晓,所以就OK了。当然,如果A1中含有虚函数、虚基类之类的,也可以通过virtual关键字判断出来,并且用于虚函数表、虚基表的内存布局计算,因此这些内容同样需要声明出来。

头文件

「声明」这种工作有一个非常明显的问题,那就是对于所有使用到的地方都需要进行一次声明,举个简单的例子:

f1.cpp:

void f1() {} // 实现

t1.cpp:

extern void f1(); // 声明
void t1() {
  f1();
}

t2.cpp:

extern void f1(); // 声明
void t2() {
  f1();
}

我们在t1t2中都用到了f1,因此在t1.cpp和t2.cpp中都需要声明f1。试想如果f1是个很复杂的类型,那么这里代码会膨胀成什么样?而如果这时f1有某种变动,那岂不是所有的声明都要跟进变动?

这种巨大而无意义的工作,显然是需要其他方式来解决的,那么这里的方式就是「头文件」。头文件本身不参与链接,它的意义就是“被其他文件所包含”。而「包含」其实就是简单的文本复制的过程。我们把声明的代码放到一个单独的头文件中,然后所有需要用到声明的文件中「包含」这个头文件,然后让编译器在预编译时,把“包含语句”改成“实际文件的内容”即可。

f1.h:

extern void f1(); // 声明

t1.cpp:

#include "f1.h" // 包含头文件
void t1() {
  f1();
}

t2.cpp:

#include "f1.h" // 包含头文件
void t2() {
  f1();
}

#include是预处理指令,其作用就是,在预编译期,会把对应的文件中所有内容拷贝过来,替换这一行语句。用上面的例子来解释,就是说t1.cpp中的#include "f1.h"会在预编译阶段替换成f1.h中的内容,也就是extern void f1();,那么也就拿到了f1的声明。其他的也是同理。这样,即便我们对f1有修改,那也只需要修改头文件中的内容就好了。

从C语言诞生,一直到C++20版本诞生之前,C/C++一直都通过这种方式来进行工程的编译和构建,以至于C++程序员对头文件这件事已经变成了一种脊髓反射了,甚至被突然问「头文件是什么?」「include语句是做什么的?」「为什么声明要写在头文件里?」等问题时可能都反应不过来。

那用了这么多年的编译方式不是挺好的吗?为什么C++20要尝试颠覆它?当然是因为它还是存在一些无法规避问题,同时也存在很多使用上的不便。

传统构建方式的缺陷

引入不需要的依赖

直接上例子:

t1.h

class T1 {};

t2.h

class T2 {
  T1 t1_;
};

main.cpp

#include "t2.h"

int main() {
  T2 t2; // 这里只用到了T2
  return 0;
}

在main.cpp中,其实我们只希望用到T2,而对其内部的实现是不关心的(在封装代码功能时,也时常会遇到不希望外露的实现),因此照理说,main.cpp应当只获得T2的声明就好。但因为t2.h中包含了t1.h,这就强迫main.cpp获得了T1的声明。并且这个问题无论如何无法规避。

头文件自包含问题

头文件的另一个问题就是,不强制要求自包含。头文件自包含算是一种业界的“道德规定”,但并不是语言本身要求的。举例来说:

t1.h:

struct T1 {};

t2.h:

struct T2 {
  T1 t1;
};

注意,在t2.h中,虽然T2使用了T1,但并没有声明T1。头文件本身并不会自身编译,所以也不会强制要求编译通过。但对于这样的头文件来说,如果需要使用,那么就必须按照依赖顺序进行包含:

#include "t1.h"
#include "t2.h" // 必须要在t2.h之前包含t1.h,否则t2.h的内容会编译报错。

我们管这种必须要包含依赖头文件的这种称为「不自包含」,换句话说,就是不能自身通过编译,使用时而必须按照一定顺序包含一组头文件才能正常通过编译的。

可以发现,这里的问题和上一节的问题是相互矛盾的。如果我们要求头文件自包含,那就一定会引入可能不需要的依赖,而如果希望不引入不需要的依赖的话,就需要编写不自包含的头文件。

模板

模板也是个头痛的问题,因为模板是静态语句,所以需要“编译期完整性”,不能指望链接。所以模板代码只能全挤在头文件中,无法做到声明和实现的文件级分离。

t1.h:

template <typename T>
void f(T); // 函数声明

template <typename T>
void f(T t) {} // 模板函数实现也要写在头文件里

所以说,对于头文件中声明的内容,可能一部分实现在头文件中,一部分在源文件中,这还是引入了不少麻烦事的。

什么是Module?

为了解决传统编译方式的这些问题,C++20引入了module的概念。其实这个概念在其他很多语言中早就已经用上了。

module翻译成中文就是「模块」,在一个模块中,我们可以定义哪些是对外的功能,然后可以在另一个模块中引用,并使用这些功能。

Module的现状和简单示例

笔者认为,module的终极目标就是跟其他语言一样,摆脱头文件,摆脱声明。然而C++20仅仅刚提出这个概念,想达成这样的终极目标还是需要不少时间和迭代工作的,我们拭目以待。

但是目前来说,module在实际使用时还是非常鸡肋的,虽然说在语法层面上的确简单了,但由于它还不支持工程内模块的自动扫描,因此,就需要我们在配置文件中手动按照顺序来进行模块的预编译。以下示例使用的是clang-14编译器。

t1.cppm:

#include <iostream>
export module test1; // 这是一个可以对外使用的模块
// 要注意,头文件需要再module语句之前,否则会把头文件内容也包含到module中

export void f1() { // 这是一个外部可以使用的函数
  std::cout << 123 << std::endl;
}

main.cpp:

import test1; // 导入模块

int main(int argc, const char * argv[]) {
  f1(); // 使用模块中的函数
  return 0;
}

这样做确实回避掉的头文件,并且可以通过export关键字来标记是否能够对外使用。这样做同样可以解决头文件“爆炸”的问题,因为如果模块A中使用了模块B,但又不希望引入模块A的代码感知模块B,那么可以用import B;即可,而如果需要传递,则可以使用export import B;

然而编译工作会极度困难,下面是针对上述工程编写的makefile:

CLANG = clang++
 
out: test.pcm
	$(CLANG) -std=c++20 -fmodules-ts main.cpp -fprebuilt-module-path=./modules ./modules/*.pcm -lstdc++

test.pcm: t1.cppm
	$(CLANG) -std=c++20 -fmodules-ts t1.cppm --precompile -Xclang -fmodules-embed-all-files -o ./modules/t1.pcm

clear:
	rm -f *.o *.pcm *.out

所以说,module做的事情其实是,首先从cppm文件中进行一次预编译,把这个里面所有的module信息、对外接口信息等盘查出来(可以理解为生成了一个头文件)。然后,对于使用了这个module的文件,要配合上预编译的结果来进行编译(相当于在这个过程中得到了声明)。最后链接的时候还要把module文件一起链接,相当于在这个阶段获取实现。(由于这个原因,笔者就不在此列举更复杂的实例了,待编译期支持完善后会再出module系列的文章。module同样支持模板,但编译指令会更加复杂,因此也不在此举例了,但希望读者能够体会,module的出现是解决这些问题的方案,只是现阶段还不够完善罢了。)

在编写本文时,clang的最新版本是16.0,但这个版本对于module的支持仍然不够完善,使用起来还是非常复杂。如果读者有兴趣可以参考clang的官方使用教程-如何使用C++module

思考

那么这里最严重的问题就是,工具并不支持工程内自动扫描,我们必须手动配置预编译的顺序(如果model有嵌套使用,那么将会更复杂。

另一个问题就是,即便我们使用了Module,目前也仍然无法摆脱头文件,两种语法混在一起还会造成各种奇怪的问题(比如说在引入头文件之前进行模块导出,将会导致头文件中的声明也被归到了模块中)。

因此,现阶段将module投入使用确实还为时过早,不过module的出现着实颠覆了C++的工程排布方式和编译模式,我们期待后续不断完善标准和工具,让其能够像其他语言那样,彻底摆脱显式声明,让程序员把更多的精力聚焦在业务逻辑上。

本文简单介绍了引入Module的原因,和使用的简单实例,旨在让读者理解该概念引入的原因和需要解决的问题。待后续标准、工具支持完善后,笔者会再添加该系列的详细说明。