cpp20-module学习记录

Module

What/Why:
module 是一种新的语言特性, 提供了c++中翻译单元一种组织方式, 即将源文件声明为模块, 并定义模块中符号的可见性, 文件通过引用模块来访问模块中的符号.
它的目的是解决传统 .h 头文件来分离实现与定义的一些问题

  • 暴力将内容展开

  • 无法很好的控制宏定义和类型定义的可见性

  • 编译缓慢

    引入的新关键字包括

  • module # 声明模块, 全局/私有模块段声明 (Global/Private module fragment)

  • export # 导出符号, 模块

  • import # 导入模块/头文件

首先来看一个最简单的例子

// speech.cc
// 声明当前文件为模块, 模块名为 speech
export module speech;

// 导出一个函数
export const char* get_phrase() {
  return "Hello, module!";
}

// main.cc
// 引用模块
import speech;
// 引用头文件
import <iostream>;

int main() {
  // 使用模块中的符号
  std::cout << get_phrase() << '\n';
  return 0;
}

在例子中, 我们声明了一个叫做speech的module, 并且在其中声明实现并导出了一个函数get_phrase. 然后在main函数中使用import导入模块, 之后就可以使用get_phrase函数了, 注意我们同样可以对头文件使用import.

模块声明

我们继续来看export module speech;这句定义, 它包含3部分export, module, speech.

  • module name 声明该文件属于名为name的模块, name可以是一个合法标识符, 或由.连接的合法标识符.
    • name:
      identifier . name |
      identifier
      name 也可以是一个模块分片(module partition), 这点我们后续会讲到.
  • export 表示该模块是一个模块接口单元(module interface unit), 如果没有 export 则为模块实现单元(module implement unit), 模块实现单元可以有多个, 但模块接口单元只能有一个, 且一个翻译单元也只能有一个.
// (每行表示一个单独的翻译单元)
 
export module A;   // 为具名模块 'A' 声明主模块接口单元
module A;          // 为具名模块 'A' 声明一个模块实现单元
module A;          // 为具名模块 'A' 声明另一个模块实现单元
export module A.B; // 为具名模块 'A.B' 声明主模块接口单元
module A.B;        // 为具名模块 'A.B' 声明一个模块实现单元

注意这里的A.B模块与A模块在语义上可以解释为A.B是A的子模块,但标准并没有声明这一点, 也就是说A.B和A在编译器眼中是两个不同的模块, 且没有任何关系(这点和其他语言不同, .* import 也是非法的).

// speech.cpp
export module speech;

export import speech.english;
export import speech.spanish;
// speech_english.cpp
export module speech.english;

export const char* get_phrase_en() {
    return "Hello, world!";
}
// speech_spanish.cpp
export module speech.spanish;

export const char* get_phrase_es() {
    return "¡Hola Mundo!";
}
// main.cpp
import speech;

import <iostream>;
import <cstdlib>;

int main() {
    if (std::rand() % 2) {
        std::cout << get_phrase_en() << '\n';
    } else {
        std::cout << get_phrase_es() << '\n';
    }
}

这是一个语义化子模块的例子, 当你import speech, 它自动帮你import speech.english和speech.spanish, 因此可以使用到两个“子模块”的定义.

导出/导入

export 在定义完模块接口单元后, 还需要指定在想要导出的符号上, 将其变为可见, 从而被其他文件使用.

export module A;   // 为具名模块 'A' 声明主模块接口单元
 
// hello() 会在所有导入 'A' 的翻译单元中可见
export char const* hello() { return "hello"; } 
 
// world() 不可见
char const* world() { return "world"; }
 
// one() 和 zero() 均可见
export {
    int one()  { return 1; }
    int zero() { return 0; }
}
 
// 也可以导出命名空间:hi::english() 和 hi::french() 均可见
export namespace hi {
    char const* english() { return "Hi!"; }
    char const* french()  { return "Salut!"; }
}

与之相对的是导入, 只可以导入模块或头文件, 但可以将导入再标记为导出, 可以使导入当前模块的单元也使用到该导入符号(个人感觉重导出只适用于当前模块依赖的, 因为它只是将其变为可性, 而声明还在原来的模块).

/////// A.cpp    ('A' 的主模块接口单元)
export module A;
 
export char const* hello() { return "hello"; }
 
/////// B.cpp    ('B' 的主模块接口单元)
export module B;
 
export import A;
 
export char const* world() { return "world"; }
 
/////// main.cpp (非模块单元)
#include <iostream>
import B;
 
int main() {
    std::cout << hello() << ' ' << world() << '\n';
}

这样看起来还挺简单的, 但仔细想想还是有一些的问题, 比如导入符号, 但符号依赖的定义没有导入, 该如何判定? 可以导出的范围是什么, 原来c语言里文件内static声明的变量和函数如果能够导出是否破坏了原有的规则? 比如下面这个例子

export module A;

class B{};
// 合法, 但对外部来说 B 依然不可见
export auto hello() {
  return B{};
}

这里可以看下vector-of-bool c++ module的第二篇.

我简单总结一下几点:

  1. export 符号必须在符号的第一次声明就 export (命名空间除外).
  2. 内部连接(internal linkage)的符号不能导出 (static修饰的变量和函数, 匿名命名空间).
  3. 除命名空间外只能在最外层声明export, 如class内部变量不能单独export.
  4. 同理,最外层声明export后, 内部的符号自动被export. (这点好像是强制的, 也就是一个class不能即包含导出符号也包含未导出符号, 但对命名空间来说可以声明两次, 一次导出一次不导出, 来达到这个效果.)
  5. import 不能出现在出模块声明外的其他定义后.

模块分区

相较于“子模块”, 模块分区()才是正确的模块拆分支持, 它将模块组织为模块和多个分区, 编译后同属于一个模块, 语法为

export module name:subname;  // 定义一个模块分区并导出
<export> import name:subname; // 导入一个模块分区

继续上文将speech拆分的例子

// speech.cpp
export module speech;

export import :english;
export import :spanish;

// speech_english.cpp
export module speech:english;

export const char* get_phrase_en() {
    return "Hello, world!";
}
// speech_spanish.cpp
export module speech:spanish;

export const char* get_phrase_es() {
    return "¡Hola Mundo!";
}
// main.cpp
import speech;

import <iostream>;
import <cstdlib>;

int main() {
    if (std::rand() % 2) {
        std::cout << get_phrase_en() << '\n';
    } else {
        std::cout << get_phrase_es() << '\n';
    }
}

与头文件的兼容问题

前文中我们提到了import <header>;,是合法的, 它被叫做头文件单元导入(header-unit import), 首先我们来考虑在模块中使用#include <header>会发生什么

首先它是一个预处理器指令, 暴力的将会把头文件中的定义和声明都导入到当前模块中, 因此它与我们新的模块系统并不十分兼容, 没有办法去控制它的可见性.

但直接对模块未知(module-not-aware)的头文件使用import也不能保证正确, 有两点问题

  1. 首先根据模块的定义, 当前模块的预处理指令不会影响到其他翻译单元, 即依赖宏定义的头文件无法使用宏定义修改.
  2. import 不能神奇的将头文件转为模块, 非importable的头文件还是有可能污染预处理器.

因此引入了全局模块段(Global Module Fragment), 它将module依赖的include部分和module的实现部分分离,达到与头文件的兼容, 它的语法如下.

module;
// stuff ... [1]
module foo;
// module purview... [2]

我们在文件开头声明module, 再到模块定义module foo, 中间的这段区域就是全局模块段, 可以安全的使用头文件, 和它们依赖的宏定义, 且在这个区域只能使用预处理器指令.

最后是私有模块段(Private Module Fragment), 语法如下.

export module foo;
export int f();
 
module :private; // ends the portion of the module interface unit that
                 // can affect the behavior of other translation units
                 // starts a private module fragment
 
int f() {        // definition not reachable from importers of foo
    return 42;
}

在声明module :private;后的内容不再被导出, 可以通该方法来实现实现和声明分离.

总结

本文总结了c++20 module的一些功能和使用示例, 它的目的主要是替代传统头文件模式的一些问题, 从而更好的组织文件结构, 控制符号可见性, 进而改进跨翻译单元的共享. 这里再贴一张使用基于module的库的示例图, 通过模块接口单元暴露接口确定BMI(binary module interface), 内部也是通过BMI依赖实现.

module

ref:

cppreference.com

vector-of-bool 3篇module的文章讲到了很多细节.

A (Short) Tour of C++ Modules - Daniela Engert - CppCon 2021 这篇讲到了一些实现原理,可惜没有英文字幕.

C++20 Modules purecpp的一篇文章

Modules in Clang 11

C++ 20 Modules 尝鲜

如何在现阶段使用module的文章. 现阶段对module支持比较好的是msvc, 我的mac机器上支持非常有限, gcc也需要更新到10以后才能使用, 因此很多特性都不能写代码实验

posted @ 2022-04-21 14:02  新新人類  阅读(192)  评论(0编辑  收藏  举报