最終更新日時(UTC):
が更新

履歴 編集

モジュール(C++20)

概要

C++20では、ヘッダーファイル・ソースファイルに代わる新たなファイル分割の仕組みとしてモジュールが導入された。

モジュールは翻訳単位の先頭でモジュール宣言を行うことにより宣言する。

export module mylib;

モジュールの中では、関数や型などの宣言に export キーワードを付けて宣言をエクスポートできる。

export module mylib;

namespace mylib {
  export struct myfunc_result_t {
    int x, y;
  };
  export myfunc_result_t myfunc() { /*...*/ };
};

モジュールを利用するには、インポート宣言を行う。これにより、インポートしたモジュールがエクスポートしている宣言が見えるようになる。

import mylib;
// iostreamなど、一部のヘッダーはインポート(≠インクルード)可能
import <iostream>

int main() {
  // これらの型や関数の宣言はこの翻訳単位には無いが、
  // mylibでエクスポートしているので、使用することができる。
  mylib::myfunc_result_t ret = mylib::myfunc();
  std::cout << ret.x << std::endl;
}

モジュールは単一の翻訳単位で構成することも、複数の翻訳単位で構成することもできる。

標準ライブラリは、C++23でモジュール化された。詳しくはモジュール(ライブラリ)を参照。

C++20でも、C++ライブラリのヘッダーファイルはモジュールのように扱うことができ、モジュール機能の恩恵を受けられる(ヘッダーユニット)。

仕様

翻訳単位の分類

C++20では翻訳単位がその役割によって細かく分類される。

まず、モジュールを構成する翻訳単位(モジュールユニット)とそれ以外(従来の翻訳単位すべて)の区別がある。

モジュールユニットはさらにインターフェースと実装に分けられる。

  • モジュールインターフェースユニット
    従来のヘッダーファイルに相当する翻訳単位(#includeのようにソースファイルに展開されることはなく、単独で翻訳単位になる)。外部(別のモジュール)に公開したい宣言や定義を書く。
  • モジュール実装ユニット
    従来のソースファイルに相当する翻訳単位。公開しない宣言や定義を書く。

また、それぞれにモジュール本体とパーティションという区別がある。

パーティションは、モジュールを構成するファイルをさらに分割するために使うもので、内部的には別モジュールのように見えるが、外部からは見えないファイルである。

まとめると、以下のようになる。

  • 翻訳単位
    • モジュールユニット
      • モジュールインターフェースユニット
        • プライマリーモジュールインターフェース
        • モジュールインターフェースパーティション
      • モジュール実装ユニット
        • モジュール本体の実装ユニット(特別な名称無し)
        • モジュール実装パーティション
    • モジュールユニット以外(特別な名称無し)

モジュール宣言

モジュール宣言の構文は以下のようになる:

export(opt) module モジュール名 :パーティション名(opt) 属性(opt);

  • モジュール宣言は翻訳単位あたり1回だけ、原則として翻訳単位の先頭に記述する。モジュール宣言を含む翻訳単位をモジュールユニットという。
  • exportがある場合はモジュールインターフェースユニット、ない場合はモジュール実装ユニットになる。
  • パーティション名がある場合はそれぞれモジュールインターフェースパーティション、モジュール実装パーティションになる。

どのモジュールも、必ずただ1つのプライマリーモジュールインターフェースユニットを持たなければならない。 それ以外のモジュールユニットの個数は任意である。ただし、パーティション名はモジュール内で重複してはならない。

export module foo;                // fooのモジュールインターフェースユニット
module foo;                       // fooのモジュール実装ユニット
module foo.bar;                   // foo.barのモジュール実装ユニット
export module bar [[deprecated]]; // 属性
export module lib:part; // libモジュールのモジュールインターフェースパーティションpart
module lib:internal; // libモジュールのモジュール実装パーティションinternal

次のモジュールAは4つの翻訳単位からなる。

// P1103R3より引用
// 翻訳単位1 プライマリーモジュールインターフェースユニット
export module A;
export import :Foo;
export int baz();

// 翻訳単位2 モジュールインターフェースパーティション
export module A:Foo;
import :Internals;
export int foo() { return 2 * (bar() + 1); }

// 翻訳単位3 モジュール実装パーティション
module A:Internals;
int bar();

// 翻訳単位4 モジュール実装ユニット
module A;
import :Internals;
int bar() { return baz() - 10; }
int baz() { return 30; }

モジュール宣言は1行で書く必要があり、プリプロセッサで生成してはならない。これは、#ifなどによる切り替え、#include#defineによる置換などによるものを含む。後述のグローバルモジュールフラグメントを記述する場合を除き、モジュール宣言の前にトークンがあってはならない。

モジュール名の詳細

モジュール名は、識別子または識別子をドットで繋いだもの(例えば、foostd.core)である。

  • stdstdから始まるあらゆるモジュール名及び予約語を含むモジュール名は、今後の規格や処理系のために予約されているので、ユーザー定義のモジュール名として使うことはできない。
  • モジュールの名前は、モジュールに属する型、関数などの名前とは無関係である。
  • 処理系の中には、モジュールユニットのファイル名とモジュール名が揃っていることを期待するものがある(そうでない場合は追加のコマンドラインオプションが必要になる)。

モジュール名にはドットを使いたいが、一方で識別子にはドットが使えない。このような矛盾があるため、モジュール名にドットを使うとトークンが分割される。 そのため、識別子の規則は分割されたそれぞれのトークンに適用される。

module foo . bar;              // OK. 'foo.bar'と等しい
module foo . /*comment*/ bar;  // OK. 'foo.bar'と等しい
module foo . . bar;            // NG. ドットの間に識別子がない
module _Foo.bar                // NG. 識別子'_Foo'は予約されている
module foo__bar                // NG. 識別子'foo__bar'は予約されている
module foo.__bar.baz           // NG. 識別子'__bar'は予約されている
module foo.version.0           // NG. 識別子の1文字目は数字にできない

プライベートモジュールフラグメント

プライベートモジュールフラグメントは、1ファイルでモジュールを定義しつつインターフェースと実装を分離するための機能である。

export module foo;
// モジュールのインターフェース
module :private;
// プライベートモジュールフラグメント

プライベートモジュールフラグメントを記述する場合、そのモジュールは翻訳単位(必然的にモジュールインターフェースユニット)を1つしか持つことができない。

グローバルモジュール

C++20では、名前のあるモジュールに属していない宣言はグローバルモジュールに属している。

グローバルモジュールの性質は以下の通り。

  • 名前を持たず、インポートすることはできない。
  • 宣言をエクスポートすることはできない。
  • モジュールインターフェースユニットを持つことはできない。

グローバルモジュールとは、要するに従来通りの、モジュールではないコードのことである。C++20では、main関数はグローバルモジュールに属していなければならない。

グローバルモジュールフラグメント

モジュール宣言の前にグローバルモジュールの実装を書ける。これをグローバルモジュールフラグメントという。

module;             // グローバルモジュールフラグメントの宣言

#include "lib.h"

export module foo;  // モジュールの宣言(この上の行までがグローバルモジュールフラグメント)

  • グローバルモジュールフラグメント内の宣言や定義は、後続のモジュールではなくグローバルモジュールに属する。
  • グローバルモジュールフラグメントにはプリプロセッサディレクティブ以外を書くことはできない。
  • グローバルモジュールフラグメント内の宣言は、後続のモジュールに属する宣言から参照されていない場合は、破棄(discard)される。
  • グローバルモジュールフラグメントの宣言は1行で書く必要があり、プリプロセッサで生成してはならない。これは、#ifなどによる切り替え、#include#defineによる置換などによるものを含む。グローバルモジュールフラグメントの前にトークンがあってはならない。

エクスポート

宣言の前にexportキーワードを付加することでその宣言をエクスポートできる。

エクスポート宣言はモジュールインターフェースユニット内の名前空間スコープで行える。ただし、以下の場所では不可。

  • グローバルモジュールフラグメントの中
  • プライベートモジュールフラグメントの中
  • 無名名前空間の中

export int x;                      // 変数のエクスポート
export class x{ /*...*/ };         // クラスのエクスポート
export namespace x { /*...*/ }     // 名前空間のエクスポート
export template<class T> foobar(); // 関数テンプレートのエクスポート

エクスポートできるのは新たな名前を導入する宣言のみである。ただし、その名前は内部リンケージを持ってはいけない。

// P1103R3より引用
export module M;
export namespace {}       // エラー: 新たな名前を宣言していない
export namespace {
  int a1;                 // エラー: 内部リンケージを持つ名前はエクスポートできない
}
namespace {
  export int a2;          // エラー: 内部リンケージを持つ名前はエクスポートできない
}
export namespace N {      // OK
  int x;                  // OK: エクスポートされる
  static_assert(1 == 1);  // エラー: 新たな名前を宣言していない
}
export static int b;      // エラー: 明示的にstaticで宣言されている名前はエクスポートできない
export int f();           // OK
export namespace N { }    // OK
export using namespace N; // エラー: 新たな名前を宣言していない

また、波カッコにexportをつけることで、その中の宣言をまとめてエクスポートできる。

  • この波カッコはスコープを作らない

export {
  struct Foo { /*...*/ };                           // エクスポートされる
  static_assert(std::is_trivially_copyable_v<Foo>); // エラー: 新たな名前を宣言していない
  using namespace std;                              // エラー: 新たな名前を宣言していない
}

まとめると、次のような宣言はエクスポートされる。

  • 明示的にexport宣言されている宣言
  • 明示的にexport宣言されている名前空間の定義の中にある宣言
  • エクスポートされる宣言を含む名前空間の定義
  • exportブロックの中にある宣言

宣言は再宣言できるが、再宣言によってエクスポートの有無が変わることはない。すなわち、

  • エクスポートされている宣言の再宣言は、暗黙的にエクスポートされる。
  • エクスポートされていない宣言の再宣言をエクスポートすることはできない。

エクスポートされる宣言が導入する名前は、そのモジュールからエクスポートされる。

モジュールリンケージ

C++20では、新たにモジュールリンケージが追加された。

  • エクスポートしている名前は外部リンケージを持つ。
  • 名前のあるモジュールに属していてエクスポートしていない名前は、モジュールリンケージを持つ。
  • モジュールリンケージを持つ名前は、同一モジュール内で参照できる。

インポート

インポート宣言は次のようになる:

export(opt) import lib; // libのインポート

インポート宣言は、モジュールのインターフェースユニットをインポートする。

  • モジュール本体の実装ユニットはプライマリーモジュールインターフェースユニットを暗黙的にインポートする。ソースファイルと同名のヘッダーファイルをインクルードすることは多いが、これを自動化したものである。

インポートされた翻訳単位でエクスポートされている名前は、インポート宣言を記述した翻訳単位において可視(visible)となる。 名前が可視であるとき、かつそのときに限り、名前は名前探索の候補となる。

マクロやusing namespaceはエクスポートできないので、インポートによって取り込まれることはない。 ヘッダーファイル中での using namespace はしばしば避けられるが、モジュールでは問題なく使うことができる。

モジュールユニットの中では、インポート宣言はモジュールユニットの本体(グローバルモジュールフラグメントではない部分)の先頭で行わなければならない。

インポート宣言は1行で書く必要があり、importキーワードやexportキーワードをプリプロセッサで生成してはならない。また、モジュールユニット内では#includeの結果としてインポート宣言を生成してはならない。

再エクスポート

インポート宣言にexportキーワードを付けることで、モジュールを再エクスポートできる。

モジュールをインポートすると、そのモジュールが再エクスポートしているモジュールも同時にインポートする。再エクスポートは、モジュールインターフェースでしかできない。

パーティションのインポート

パーティションは内部的には別のモジュールのように振る舞うので、パーティション内の宣言などを利用するにはインポートが必要である。

パーティションは主となるモジュールが異なる場合はインポートできないので、間違いのないように、インポート宣言にはモジュールパーティション名だけを書く。

export module datetime;
export import :date; // インターフェースパーティション date をインポート
export import :time; // インターフェースパーティション time をインポート

モジュールインターフェースパーティションはモジュールインターフェースを分割するものなので、内部の宣言を外へ公開しなければ意味がない。そのため、モジュールインターフェースパーティションのインポート宣言は必ず再エクスポートしなければならない。

  • パーティション内の宣言はエクスポートしていなくても見える
  • ただし、再エクスポートされるのはパーティションがエクスポートしている宣言のみ

インターフェース依存

翻訳単位がモジュールユニットUにインターフェース依存(interface dependency)を持つとは、次のことをいう:

  • Uをインポートするモジュールインポート宣言か、Uを暗黙的にインポートするモジュール宣言を含む
  • または、Uにインターフェース依存を持つモジュールユニットに対してインターフェース依存を持つ(推移律)

翻訳単位は、自分自身に対してインターフェース依存を持ってはならない(インターフェース依存関係は循環しない)。

到達可能性

C++20では、翻訳単位と宣言に対して到達可能という用語を使うようになった。

翻訳単位Uがプログラムの点Pから必然的に到達可能(necessarily reachable) とは、次のことをいう:

  • Uがモジュールインターフェースユニットであり、点Pを含む翻訳単位が点Pに先立ってUにインターフェース依存を持っている
  • または、点Pを含む翻訳単位が点Pに先立ってUをインポートしている

点Pから到達可能(reachable)な翻訳単位とは、次のものをいう:

  • 点Pから必然的に到達可能な翻訳単位
  • その他、点Pを含む翻訳単位がインターフェース依存を持つ翻訳単位であって、処理系が規定するもの

宣言Dが点Pから到達可能とは、次のことをいう

  • DがPと同じ翻訳単位にあり、Pに先立って宣言されている
  • または、DがPから到達可能な翻訳単位にあって、破棄(discard)されておらず、プライベートモジュールフラグメント内にもない

C++20までは到達可能という用語はなかったが、前者の条件を満たす宣言だけが参照できていた。 宣言が到達可能であるとき、かつそのときに限り、宣言の意味論的な性質(semantic property)を利用できる。

例えば、クラス定義はクラスの完全性という性質を持っている。クラス定義が到達可能であるときそのクラスは完全である。

エクスポートの有無とは関係なく、モジュールをインポートしただけでインターフェース依存が発生し、そのモジュールインターフェースユニットおよびその中の宣言へ到達可能となる。

モジュールにおけるODR

同じトークン列であれば再定義しても良いというODRの例外は、その定義が名前のあるモジュールに属する場合は適用されない。

この例外はヘッダーファイルにクラス定義などを書いてインクルードした際にODR違反にならないための規定である。 モジュールを定義する場合はヘッダーファイルは使わないから、実質的な影響はない。

複数のモジュールが#includeで同じ宣言を取り込んだ場合はODR違反となってしまうので、基本的に名前のあるモジュールの本体で#includeを使用してはならない。

モジュールユニットの中で#includeを使用したい場合、グローバルモジュールフラグメント内で行えば従来通りにODRの例外となる。

module;             // グローバルモジュールフラグメントの宣言

#include "lib.h"    // "lib.h"中の宣言はグローバルモジュールに属する(ODRの例外が有効)。

export module foo;  // モジュールの宣言(この上の行までがグローバルモジュールフラグメント)

#include "lib.h"    // "lib.h"中の宣言がモジュールfooに含まれてしまう(ODRの例外なし = ODR違反の可能性大)。

ヘッダーユニット

一部のヘッダーファイルは、モジュールとしてインポートすることができる。この機能およびヘッダーファイルから生成される翻訳単位をヘッダーユニットという。

import <foo.h>; // foo.hをヘッダーユニットとしてインポート

ただし、インポートできるヘッダーファイル(インポータブルヘッダー)は以下のものに限られる。

ヘッダーユニットをインポートしてもその内容が展開されることはないが、ヘッダーユニット内のマクロが使えるようになる。これにより#includeとほぼ同じ効果が得られる。

プリプロセッサは、非モジュールユニットに現れるインポータブルヘッダーに対する#includeディレクティブをimport宣言に置換してもよいことになっている。モジュールユニットにおいては、明示的にimport宣言をするほうがよい。

ヘッダーユニットとなるヘッダーファイル自体はモジュール宣言を持てないし、export宣言もできない。

ヘッダーユニット内の宣言はすべてグローバルモジュールに属し、名前を導入する宣言ならば暗黙的にエクスポートされる。

// P1103R3より引用
// a.h
#define X 123 // #1
#define Y 45  // #2
#define Z a   // #3
#undef  X      // a.hではここで#1が無効になる

// b.h
import "a.h"; // b.hではここで#1, #2, #3が定義され、#1が無効になる
#define X 456 // OK: #1で定義したXはすでに無効
#define Y 6   // エラー: #2で定義したYが有効

// c.h
#define Y 45  // #4
#define Z c   // #5

// d.h
import "a.h"; // d.hではここで#1, #2, #3が定義され、#1が無効になる
import "c.h"; // d.hではここで#4, #5が定義される
int a = Y;    // OK: #4は#2と同じ
int c = Z;    // エラー: #5は#3を異なる値で再定義している

ヘッダーユニットは再エクスポートできるが、ヘッダーユニットを間接的にインポートした場合はマクロはインポートされない。

// lib.h
inline int f(){ return NUM; }
#define NUM 1000

// lib_mod.cpp
// lib.h中の宣言をすべてエクスポートするモジュールlib
export module lib;
export import "lib.h";

// main.cpp
import lib;

int main() {
  1 + f(); // OK
  1 + NUM; // エラー: マクロは再エクスポートしても引き継がれない
}

includeとの違い

ヘッダーユニットをインポートすると以下のことが起こる。

  • ヘッダーファイルを翻訳フェーズ7までコンパイルし、その翻訳単位(ヘッダーユニット)をインポートする。
  • さらに、ヘッダーファイルの翻訳フェーズ4終了時点で定義されていたマクロがインポート宣言の直後で宣言される。この処理はプリプロセッサで行われる。

ヘッダーファイルが新たな翻訳単位としてコンパイルされる点が従来の #include とは異なる。 ヘッダーユニット内のマクロはインポートできるが、逆は起こらない。すなわち、importを書いた翻訳単位におけるプリプロセッサの状態がヘッダーユニット内に影響を与えることはない。

ヘッダーユニットが必要になった背景・経緯

単にヘッダーファイルを使いたいだけであれば、グローバルモジュールフラグメント内でインクルードできる。

しかし、処理系はヘッダーファイルをインポータブルヘッダーに指定することで、それらに対する #includeimport に置き換えることができる。

プログラム全体であるヘッダーファイルに対する #includeimport に置き換わった場合、そのヘッダーファイルは1回しかコンパイルされなくなり、コンパイル時間の短縮につながる可能性がある。このように、C++20としてコンパイルするだけで従来のコードでも恩恵を得ることができる。

言語機能としてのモジュールが導入されても、すでにあるコードがリファクタリングされモジュールにまとめられるには長い時間がかかるため、過渡期においてヘッダーユニットは役に立つ。

ビルド

プログラムのビルドは規格の範囲外なので、ここでは一般論を述べる。

モジュールをコンパイルすると、何らかの中間表現(コンパイル済みモジュール)が保存される。

  • テンプレートはテンプレートのまま(実体化することなく)エクスポートできるので、中間表現は機械語ではなく、いわゆるプリコンパイルドヘッダーと似たようなものにならざるを得ない。
  • モジュールAをインポートするプログラムをコンパイルするには、モジュールAのコンパイル済みモジュールが存在しなければならない。
  • モジュールAをインポートするプログラムをリンクするには、モジュールAに関するモジュールユニットから生成されるオブジェクトファイル、ライブラリなどを別途リンクしなければならない。

この機能が必要になった背景・経緯

プリプロセッサによるインクルードは、ヘッダーファイルの内容をその場に展開する。 これには次のような問題が指摘されてきた。

  1. コンパイル時間が長くなる
    • ヘッダーファイルの内容が再帰的に展開され、プログラムが長くなる(Hello worldだけでも数万行に達する)
    • さらに、展開が翻訳単位ごとに行われるので、全体で見ると同じヘッダーファイルが何度も解析される
  2. プリプロセッサの状態により、インクルードの結果が変わってしまう
    • インクルードの順番によってエラーが起きることがあった。
  3. ヘッダーファイル内の記述の影響を受けすぎる
    • 影響が大きいため、ヘッダーファイル内に書くことがためらわれる記述があった。
    • using namespaceやマクロ(例えばWindowsにおけるmax)など。

モジュールは、以上のような問題のないプログラム分割の仕組みとして導入された。

関連項目

参照