• C++20之Module(浅析)


    「前置说明」

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

    为什么要引入Module?

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

    声明

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

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

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

    extern void f1(void); // 函数声明
    
    void Demo() {
      f1(); // 调用了一个外部函数
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

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

    class A1; // 声明类型A1
    
    class T1 {
      A1 a; // ERR
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

    class A1;
    
    class T1 {
      A1 *a; // OK
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

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

    class A1 {
     publicA1();
      ~A1();
      void f1();
     private:
      int m1_, m2_;
    };
    
    class T1 {
      A1 a; // OK
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    头文件

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

    f1.cpp:

    void f1() {} // 实现
    
    • 1

    t1.cpp:

    extern void f1(); // 声明
    void t1() {
      f1();
    }
    
    • 1
    • 2
    • 3
    • 4

    t2.cpp:

    extern void f1(); // 声明
    void t2() {
      f1();
    }
    
    • 1
    • 2
    • 3
    • 4

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

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

    f1.h:

    extern void f1(); // 声明
    
    • 1

    t1.cpp:

    #include "f1.h" // 包含头文件
    void t1() {
      f1();
    }
    
    • 1
    • 2
    • 3
    • 4

    t2.cpp:

    #include "f1.h" // 包含头文件
    void t2() {
      f1();
    }
    
    • 1
    • 2
    • 3
    • 4

    #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 {};
    
    • 1

    t2.h

    class T2 {
      T1 t1_;
    };
    
    • 1
    • 2
    • 3

    main.cpp

    #include "t2.h"
    
    int main() {
      T2 t2; // 这里只用到了T2
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    头文件自包含问题

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

    t1.h:

    struct T1 {};
    
    • 1

    t2.h:

    struct T2 {
      T1 t1;
    };
    
    • 1
    • 2
    • 3

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

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

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

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

    模板

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

    t1.h:

    template <typename T>
    void f(T); // 函数声明
    
    template <typename T>
    void f(T t) {} // 模板函数实现也要写在头文件里
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

    什么是Module?

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

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

    Module的现状和简单示例

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

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

    t1.cppm:

    #include 
    export module test1; // 这是一个可以对外使用的模块
    // 要注意,头文件需要再module语句之前,否则会把头文件内容也包含到module中
    
    export void f1() { // 这是一个外部可以使用的函数
      std::cout << 123 << std::endl;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    main.cpp:

    import test1; // 导入模块
    
    int main(int argc, const char * argv[]) {
      f1(); // 使用模块中的函数
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这样做确实回避掉的头文件,并且可以通过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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

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

    思考

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

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

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

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

  • 相关阅读:
    linux配置文件共享
    基于亚马逊云科技Amazon EC2云服务器的G4实例可提供极具成本效益的GPU并支持实时光追技术
    web前端期末大作业:HTML+CSS+JavaScript绿色的盆栽花店网站响应式模板 大学生鲜花网页设计
    设计模式 -- 适配器模式(Adapter Pattern)
    java批量消费队列(BlockingQueue)中的数据
    MySQL字段类型与Java实体类类型对应转换关系
    keepalived+nginx高可用 脑裂监控
    【web-代码审计】(14.2)常见漏洞签名
    玩机搞机---卸载内置软件 无root权限卸载不需要的软件 安全卸载
    Unity中Shader纹理的多级渐远Mipmap
  • 原文地址:https://blog.csdn.net/fl2011sx/article/details/127161813