一、模块的深入分析
在模块的学习中,英文文档中反复出现translation unit,它其实就是模块单元(module unit)的组成者。可以直译为"翻译单元",也可以根据模块的特点理解为"迁移单元"。
C++中对模块的应用来说,是一个革命的性的变化。所以影响最大的有两种场景:
- 编译处理
为了理解编译的流程,需要先了解模块的整体划分,它主要由模块的接口单元(moudle interface unit)、模块实现单元(module implementation unit)、模块分区 (module partitions)和BMI (binary module interface)编译生成的二进制接口文件组成。
也就是说,模块分为模块的定义和实现两部分,有点类似头文件和cpp文件。其同样也存在着模块的声明和定义一体的情况(在一个文件中)。特别是在某些小规模的应用场景下,开发者不愿意组织成不同的文件形式进行处理(即主分区接口和实现单元),所以又提供了私有模块分区这一情况。这都对编译器提出出了较强的要求。
下面以一个主程序依赖模块B,而B又依赖A的情况进行说明:
首先,编译器将编译模块的接口单元(B.ixx,包括主模块接口单元,先进行分区接口的编译,然后再依次聚合成主模块接口),生成BMI文件(B.gcm)。即通过解析接口单元代码,处理export的声明有效性来生成对应的二进制接口文件(前面的例程中的std.gcm)。在这个文件中,包含着模块定义中对外声明的所有接口信息,可以理解为一种头文件件的声明(没有实现内容);
其次,编译器开始处理实现单元,首先验证gcm文件接口的声明与实现单元中是否保持一致(比如函数的签名等),然后编译实现单元(如B.cpp)生成目标文件(.o文件),但此过程不会有新的gcm文件生成
再次,编译开始处理模块的依赖模块的编译,比如B依赖A模块,则开始检查A的BMI是否成生,如未生成则开始编译A模块。然后解析B模块中import A中的接口信息与A.gcm中的是否保持一致。编译生成目标文件(A.o)且不生成新的BMI。即其方式与主编译单元方式一致(如果有更深层的编译则依次进行即可)。
最后,编译主程序并链接模块B,先从B.gcm导入接口信息并验证,如果通过,则编译主程序生成目标文件(main.o),然后在链接过程中,将三个目标文件即A.o,B.o,main.o链接成可执行文件。
编译器在处理模块间的依赖时,是通过显式的依赖方式来构建依赖图的,从而保证编译的依赖顺序(大家是不是想到了传统的动态库中的手动控制库的依赖顺序的问题以及头文件顺序的问题),防止出现类似库依赖时顺序颠倒就会导致链接错误的情况。另外,模块的接口变化仅处理模块A及其依赖相关模块的编译,而内部的变化则只影响模块本身的编译(想想头文件的污染)。
由于模块提供了增量编译的功能,所以其避免了重复编译和解析,提高了编译的效率。 - 与头文件混合处理
模块的引入与头文件的使用并不是一种二选一的结果,而一种可以混合存在的形式,毕竟庞大的C++代码库和框架不会允许出现一种推翻性的新技术点的应用。在前面的例程中已经提供了对头文件和模块共同使用的方式。那么如何才能更好的解决新老代码的整合呢?
首先,在新工程中引入模块封装为模块接口单元;其次,对强使用、相对简单的旧代码优先引入模块机制;利用命名模块来处理与传统宏定义的冲突;最后,对一些很少应用,特别复杂的不进行模块的引入。
二、模块的结构组织
在前面学习的基础上,很容易在实际的工程中进行模块的组织架构处理:
- 抽象对外导出接口及其相关功能描述说明
- 以模块分区和分片段为基础划分出最底层的实现单元
- 以层级(树结构)来处理功能实现中模块(子模块间)的依赖(防止循环依赖和重复导出)
- 划分命名和匿名模块来处理一些特殊场景的处理
通过上述的组织,就可以实现编译的解耦,保证了功能实现上的隔离。并可以为导出控制提供更好的实现方式。
三、混合编程
新技术的引进往往是渐进性的,模块机制亦是如此。混合编程既可以兼顾模块机制的各种优势(如解决依赖顺序、提高编译效率等)又可以兼容老的头文件库和框架,让开发者不至于陷入重写老代码和编写新代码的混合状态。
下面给出一个具体的例程:
c
// legacy.h
#pragma once
#include <string>
#include <iostream>
class LegacyDemo {
public:
void display(const std::string& msg);
};
int add(int a, int b);
// legacy.cpp
#include "legacy.h"
void LegacyDemo::display(const std::string& msg) {
std::cout << "old " << msg << std::endl;
}
int add(int a, int b) {
return a + b;
}
// new_module.cppm
export module new_module;
// 全局模块片段:包含传统头文件
module; // 开始全局模块片段
#include "legacy.h"
export module new_module;
#include <iostream>
#include <string>
export namespace ModuleDemo {
export int add(int a, int b);
export class DemoClass {
private:
int ret_ = 0;
public:
DemoClass();
int sub(int a, int b);
};
}
// new_module.cpp
module new_module;
//#include <cmath>
namespace ModuleDemo {
int add(int a, int b) {
return a + b;
}
DemoClass::DemoClass(){}
int DemoClass::sub(int a, int b) {
ret_ = a - b;
return ret_;
}
}
// main.cpp
#include <iostream>
#include "legacy.h"
import new_module;
int main() {
// 使用头文件机制
std::cout << "legacy add : " << add(10, 20) << std::endl;
LegacyDemo ld;
ld.display("Hello include!");
// 使用模块机制
std::cout << "moudle add : " << ModuleDemo::add(30, 40) << std::endl;
ModuleDemo::DemoClass calc;
std::cout << "计算器结果: " << calc.sub(5, 6) << std::endl;
return 0;
}
编译的方法如同前文一致,不再重复。大家可以在前面的环境中进行验证即可。
说明:在本次编译中遇到了新问题,如果在模块的实现文件中增加了未使用的头文件,在直接用命令编译时没有问题但使用CMake编译时则会报编译错误。
四、总结
在学习了模块的相关开发处理后,就出现了一个必须面对的问题即如何在实际的工程中组织相关的模块结构。正如反复说明的,正规化和系统化才是一切工程开发的最终的目的。特别是目前软件的规模已经达到了空前巨大,并且C++还有庞大的历史遗产的情况下,引入模块编程是一个解决传统问题的好的方式。
模块本身是一个相对较完整的组织体系(但为了更方便,可以在其内部继续分区,正如在进程时可以细分线程一样)。不同的粒度可以让模块的组织结构看上去更清晰。这样,在不同的层次和不同的角度来控制模块的开发和应用,会让其在设计和实现上更加灵活。通过整合模块机制与传统的头文件包含机制,可以更好的提高代码整体的编译效率和安全性。大家可以在实际的工程中借鉴这种思想,通过混合编程对相关工程在整体上进行思考和设计。