IE盒子

搜索
查看: 106|回复: 1

[C++20笔记] 模块 Modules

[复制链接]

1

主题

5

帖子

6

积分

新手上路

Rank: 1

积分
6
发表于 2023-1-9 13:49:02 | 显示全部楼层 |阅读模式
C++20 模块简介

一般 C++ 项目需要引用另一个代码库时,一般通过 #include 将外部符号引入当前代码环境,并进行使用。#include 头文件是代码级依赖,可能存在的问题包括:

  • 重复编译相同文件,导致编译速度慢;
  • 当使用宏时,可能带来奇奇怪怪的问题,可能包括:

    • 不同库偶然使用了相同的宏名,导致无法预料的编译结果;
    • 不恰当地在头文件中声明宏,宏定义扩散,污染其他编译单元,导致无法预料的编译结果;
    • ......

  • 头文件暴露了实现;
  • 偶然符号重名,或者复杂依赖关系导致同个符号被多次链接,导致编译失败;
  • ......
模块特性期望于能解决上述的问题。
笔者对模块的感性认知

笔者学习过程中,梳理有如下非严谨的、感性的认知,写在本文较前的位置,辅助读者理解。
首先,我们将全局变量名、静态变量名、函数名、类名、结构名、方法名等需要对外可见的名字,统称为符号(Symbol)。
模块系统将一组同属于一个模块名称下的声明和实现,编译到中间二进制文件。该二进制文件对外暴露导出的符号(通过 export 修饰的符号),但不会暴露非导出的符号(没有通过 export 修饰的符号),也不会导出内部使用的宏定义,以及可能其他一些应该被隔离的内容。
在上述的流程中可以看出,符号属于模块,是多对一的关系。
留意这里提到的“中间二进制文件”,并没有任何标准约束,因而不同编译器产出的文件格式可能是完全不同、无法通用的,因而很可能并不适合作为编译成品进行发布(传统的 .a .so .dll,在一些场合下是可以作为二进制进行发布的)。但因为模块系统本身和传统编译产物(目标文件 .o,库文件 .a .so .dll)是正交的,即模块源代码理论上可以编译为库文件后进行发布,但使用模块时,编译为二进制库文件发布的做法,是否可能又“还原”了模块系统本身要解决的问题?如果读者的产品是通过二进制库发布的,则这个问题需要提前想清楚。
快速上手

本章节提供一个最简单的模块示例代码,并使用声明与实现分离的方法组织文件。
扩展名问题

笔者至今见到一下几种扩展名习惯:

  • 涉及模块的文件使用 .cppm,一般来自于 clang 建议;
  • 若声明实现分离,则接口声明文件使用 .cppm,实现文件使用 .cpp;
  • 模块接口文件使用 .ixx,一般来自于微软系 Visual Studio 建议;
  • 继续使用 .cpp 或者 .cc 等传统扩展名,一般是为了使用 g++ 编译(g++ 遇到这些扩展名的时候会当作是 C++ 文件来编译,相当于自动 -x c++)
扩展名没有标准或统一规范,本文暂使用 .cppm 作为 C++ 模块代码文件扩展名。
笔者吐槽:自古以来 C++ 下的扩展名就是“百花齐放”,在引入模块后大抵会更加混乱。
示例代码

// foo.cppm
module;  // 先声明全局(global)模块单元
         // 一方面,模块声明必须是翻译单元的首个声明(在有模块时),即代码文件必须由 "module" 开始
         // 另一方面,为了能够正常 include 非模块化的代码头文件(例如,这里的 <iostream> ),在 include 时必须处于全局模块

#include <iostream>

// 若编译器支持模块化的 stl 库,则可以删除以上所有代码,使用 import 语法引入 stl 库。
// import 具体语句可能取决于未来编译器和 stl 库的实现,可能的写法是:
// ```
// import <iostream>;
// ```

export module foo;  // 为模块 "foo" 声明主模块接口单元

// 对模块外部**不可见**的函数声明与实现
void FooFuncInternal() { std::cout << "Foo!" << std::endl; }

// 对模块外部可见的函数声明与实现,通过 export 修饰
export void FooFunc() { FooFuncInternal(); }

// main.cpp
import foo;  // 导入名为 "foo" 的单元模块

int main() {
    FooFunc();  // 调用 "foo" 内的符号
    return 0;
}
GCC11 编译

一步编译:
g++ -fmodules-ts -std=c++20 -x c++ foo.cppm main.cpp编译为目标文件后链接:
g++ -fmodules-ts -std=c++20 -x c++ -c foo.cppm -o foo.o
g++ -fmodules-ts -std=c++20 -c main.cpp -o main.o
g++ foo.o main.o编译完成后,可以发现编译目录下存在以下路径和文件:
|- gcm.cache
   |- foo.gcmgcm.cache 为 GCC 默认的缓存路径,foo.gcm 则是对模块 foo 的编译的二进制中间文件。
Clang14 编译

clang 需要手动编译模块为预编译 pcm 文件:
clang++ -std=c++20 foo.cppm --precompile -o foo.pcm
clang++ -std=c++20 main.cpp -fprebuilt-module-path=. foo.pcm
笔者的环境下,编译可以成功,但执行时会发生奇怪的 SegFault。。。
摊手 ┑( ̄Д  ̄)┍
MSVC 编译

TBD
声明与实现分离

首先要明确的是,使用模块机制后,技术上不再需要通过分离声明和实现来加快编译速度。
基于习惯或可读性需求(尽管笔者不认为这是真实需求,除了C/C++外,似乎也没有任何主流编程语言为了可读性将两者分离),也还是可能需要将声明和实现进行分离。本节也将进行示例说明。
示例代码

模块接口文件:
// foo.cppm
export module foo;  // 同样地,为模块 "foo" 声明主模块接口单元

export void FooFunc();  // 声明函数,但没有实现。
模块实现文件:
// foo_impl.cppm
module;  // 同样地,为了 include 非模块化的代码头文件,先声明全局(global)模块单元

#include <iostream>

module foo;  // 声明私有模块片段 foo
             // 在实现文件中,**无需**使用 export 修饰

// 定义函数实现
void FooFunc() { std::cout << "Foo!" << std::endl; }
入口文件:
// main.cpp
import foo;  // 导入 foo 模块

int main() {
  FooFunc();  // 调用 foo 模块内的符号
  return 0;
}
GCC11 编译

类似地,一步编译:
g++ -fmodules-ts -std=c++20 -x c++ foo.cppm foo_impl.cppm main.cpp以及同样类似地,也可以为目标文件后,再编译成可执行程序:
g++ -fmodules-ts -std=c++20 -x c++ -c foo.cppm -o foo.o
g++ -fmodules-ts -std=c++20 -x c++ -c foo_impl.cppm -o foo_impl.o
g++ -fmodules-ts -std=c++20 -x c++ -c main.cpp -o main.o
g++ foo.o foo_impl.o main.oClang14 编译

TBD
笔者的环境下,暂未成功编译
只能继续摊手 ┑( ̄Д  ̄)┍
仙人指路:Clang 16.0.0git documentation
MSVC 编译

TBD
export 语法与适用范围

笔者在学习过程中,发现 export 对以下(也可能不限于以下)符号起作用:
// 函数
export void FooFunction() { /* ... */ }

// 类或结构体
export class FooClass { /* ... */ };

// 命名空间
export namespace foo_ns {
  void FooFunction() { /* ... */ }
  class FooClass { /* ... */ };
}
同时,export 支持类似于代码块语法的声明序列语法:
// `Foo` 和 `Bar` 均被导出
export {
  void Foo() { /* ... */ }
  void Bar() { /* ... */ }
}

子模块(submodule)

C++ 标准中并没有子模块的概念,但是模块名字允许使用符号点(.),因而可以以此在逻辑上划分模块的归属关系。
例如,有子模块 a.b:
export module a.b;
// -- snip --
以及父模块 a:
export module a;
export import a.b;
那么,用户若只对 a.b 下的内容感兴趣,则可以单独导入 a.b,若需要 a 下的全部内容(包括 a.b),则只导入 a ,就可以同时导入 a.b。
当使用子模块规划模块时,上述的“重导出”做法也是建议的做法。
模块分区(module partition)

模块分区是另一种规划模块的方法,其被编译器识别并处理。
模块分区只能在自己所在地具名模块内部可见,在模块外部则不能直接导入模块分区,只能导入模块。
模块分区通过冒号(:)标识,以下声明了主模块 a 下的两个子模块 a:b1 和 a:b2:
export module a:b1;
// --snip--

export module a:b2;
// --snip--

每个分区最终必须由主模块导出,因而在主模块文件中通常有:
export module a;
export import :b1;
export import :b2;
分区只能在所属模块内部导入,因此分区前不需要(实际上也不能)指定主模块名称。

当用户使用模块及其分区下地内容时,仅导入主模块名称:
import a;

int main() { /* --snip-- */}
模块命名风格指南

Naming guidelines for modules 为模块提供了一些命名风格指南,整体包含以下三条:

  • 使用实体或(和)项目名称作为模块名称前缀,以防止不同公司、实体、项目中使用了相同的模块名称。

    • 例如,对于 boost Asio项目,可以使用 boost.asio,不需要使用 org.boost.asio,顶级域名通常是很尬尴的存在。
    • 例如,对于 Abseil 项目,其被 Google 公司开发维护,则可以使用 google.abseil 作为模块名,而不是 abseil。
    • 例如,对于 Qt、Catch2 等组织名称就是项目名称的,直接使用单层的 qt、catch2 即可, 不需要使用 qtproject.qt 或 catchorg.catch2。

  • 使用小写的 ASCII 模块名称。
  • 尽可能使用分层的组织模块。

    • 即“子模块”一节所述,例如 boost.asio 应该重导出boost.asio.executor 下的所有内容。

前 C++20 时代对模块的思考

在笔者梳理本文、测试代码的过程中,发现当前的编译器对模块的支持是比较堪忧的,因而本文发表时的 2022 年 12 月,笔者觉得在生产环境中使用模块是不太靠谱的。
更长远来看,即使编译器支持到位,推广使用模块也有两个阻碍:

  • 相比于其他语言的依赖管理,C++20 的模块更难于理解学习。
  • 长时间需要 import 和 #include 共存,导致大量繁琐的细节;公共库类的代码很长时间都需要支持无模块支持的编译环境,因而也不能迁移到模块中(使用编译选项宏隔离?光是想象一下就想吐。。),因而这个问题也将存在好久。
尤其对于后者,导致笔者对模块呈偏悲观的态度,很大可能大家还会用 #include 再写至少 5 年、7 年的样子,甚至可能一直 #include 下去。
因而从实际角度,笔者反而建议谨慎使用模块,一个可能是今天的代码使用模块规划,但 N 年后大家还在用 #include ,为了兼顾和其他项目互相引用,搞不好代码又需要从模块迁移回来。。。
安利时间

以下书籍为本文撰写提供了支持,特此安利。
当然留意的是,包括模块在内的很多 C++20 在各个编译器的支持程度可能非常有限,阅读书籍时候仍然要留意编译器不支持、编译器实现方式与书籍不一致的地方。
以上两本的打包版本:
回复

使用道具 举报

1

主题

9

帖子

16

积分

新手上路

Rank: 1

积分
16
发表于 2023-1-9 13:49:57 | 显示全部楼层
用xmake构建,加-v参数就会log出所有的编译命令,我看这个才知道这些编译器的模块怎么用[捂脸]
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表