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

履歴 編集

モジュール(C++20)

概要

C++20では、インクルードに代わる新たな仕組みとしてモジュールが導入された。

C++20では、プリプロセッサを用いずにプログラムを分割することができる:

// P1103R3より引用

// a.cpp
export module A; // モジュールAのインターフェース

int foo() { return 1; } // エクスポートしていない関数foo
export int bar();       // エクスポートしている関数bar

// a-impl.cpp
module A; // モジュールAの実装

int bar() {
  return foo() + 1; // OK: fooはエクスポートしていないが、モジュールAの中では見える。
}

// unrelated.cpp
import A;

int main() {
  bar(); // OK: barはAからエクスポートされているので見える
  foo(); // エラー: fooはモジュールAの外では見えない
}

ただし、C++20では標準でモジュールとして提供されるライブラリはない。

仕様

モジュール宣言

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

export(opt) module モジュール名 属性(opt);

  • モジュール宣言は翻訳単位あたり1回だけ、原則として翻訳単位の先頭に記述する。
  • モジュール宣言を含む翻訳単位をモジュールユニットという。
    • exportがある場合をモジュールインターフェースユニット、ない場合をモジュール実装ユニットと呼ぶ。
    • あるモジュールについて、モジュールインターフェースユニットがただ1つ存在しなければならない。モジュールの実装は好きなだけ存在できる。
    • モジュール実装ユニットはモジュールインターフェースユニットを暗黙的にインポートする。
  • モジュール名は、識別子または識別子をドットで繋いだもの(例えば、foostd.core)である。
    • stdおよびstdから始まるあらゆるモジュール名は、今後の規格や処理系のために予約されているので、ユーザー定義のモジュール名として使うことはできない。
    • モジュールの名前は、モジュールに属する型、関数などの名前とは無関係である。
    • 処理系の中には、モジュールユニットのファイル名とモジュール名が揃っていることを期待するものがある(そうでない場合は追加のコマンドラインオプションが必要になる)。

export module foo;                // fooのモジュールインターフェースユニット
module foo;                       // fooのモジュール実装ユニット
module foo.bar;                   // foo.barのモジュール実装ユニット
export module bar [[deprecated]]; // 属性

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

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

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

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

グローバルモジュール

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

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

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

エクスポート

宣言の前に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 namespase std;                              // エラー: 新たな名前を宣言していない
}

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

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

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

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

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

モジュールリンケージ

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

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

インポート

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

import lib; // libのインポート

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

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

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

再エクスポート

インポート宣言もエクスポートできる。これを再エクスポートという。

export import lib; // libの再エクスポート

モジュールをインポートすると、そのモジュールが再エクスポートしているモジュールも同時にインポートする。

インターフェース依存

翻訳単位がモジュールユニット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)を利用できる。

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

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

モジュールパーティション

モジュールは分割することができる。分割したモジュールをモジュールパーティションという。

モジュールパーティションを宣言する構文は以下のようになる:

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

  • モジュールパーティション名の書式は、モジュール名と同じである。
  • export がある場合をモジュールインターフェースパーティション、ない場合をモジュール実装パーティションという。

export module lib:part; // libモジュールのモジュールインターフェースパーティションpart

module lib:internal; // libモジュールのモジュール実装パーティションinternal

モジュールパーティションは基本的に別のモジュールと考えてよいが、以下の点で異なる:

  • 主となるモジュールが異なる場合はインポートできない。
    • 外部へ公開するには、モジュールインターフェースから再エクスポートする。
    • モジュールの利用者にパーティションの存在を意識させてはいけない。
  • インポート宣言にはモジュールパーティション名だけを書く。
  • インポートするとエクスポートしていない宣言も見えるようになる。
    • ただし、再エクスポートはできない。

主となるモジュールのインターフェースとパーティションを区別する場合は、プライマリーモジュールインターフェースユニットという事がある。

// 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; }

このモジュールAは4つの翻訳単位からなる。上から順に、

  1. (プライマリー)モジュールインターフェースユニット
  2. モジュールインターフェースパーティション :Foo
  3. モジュール実装パーティション :Internals
  4. モジュール実装ユニット

モジュールにおけるODR

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

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

後方互換性のための機能

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

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

グローバルモジュールフラグメントにはプリプロセッサディレクティブのみ記述できる。 翻訳フェーズ4以前の段階でプリプロセッサディレクティブ以外の記述がある場合は、エラーとなる。

この機能は、モジュールのインターフェースへ影響を与えずにインクルードをするために用意された。

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

#include "lib.h"    // "lib.h"中の宣言はモジュールfooに含まれない。

export module foo;

#include "lib.h"    // "lib.h"中の宣言がモジュールfooに含まれてしまう(モジュールリンケージを持ってしまう)。

void f() {
  std::cout << "foo" << std::endl;
}

名前のあるモジュールに属する定義に対してはODRの例外が適用されないため、 グローバルモジュールフラグメント以外でインクルードすると、ODR違反になりやすいので注意が必要である。

グローバルモジュールフラグメント内の宣言は、後続のモジュールに属する宣言から参照されていない場合は、破棄(discard)される。

C++標準ライブラリのヘッダーをモジュールユニット内でインクルードする場合、グローバルモジュールフラグメント内でインクルードするべきである。

ヘッダーユニット

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

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

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

ヘッダーユニットをインポートしてもその内容が展開されることはないが、#includeとほぼ同じ効果が得られる(そのようなヘッダーファイルだけがインポータブルヘッダーに指定されるともいえる)。

プリプロセッサは、インポータブルヘッダーに対する#includeディレクティブをimport宣言に置換できる。ただし、実際に行われるかは処理系定義である。

モジュールとの違い

ヘッダーユニットはインポートしたときの効果を#includeと近くするために、普通のモジュールとは異なる性質をもつ。

  • ヘッダーユニットはモジュール宣言を持てない。
  • ヘッダーユニット内の宣言はすべてグローバルモジュールに属し、名前を導入する宣言ならば暗黙的にエクスポートされる。
  • ヘッダーユニットをインポートすると、ヘッダーユニット内のマクロが使えるようになる。

// 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 に置き換え、 コンパイルを速くすることができる。

例えば、C++20では標準ライブラリは従来通りヘッダーファイルで提供されるが、C++ライブラリヘッダーはインポータブルヘッダーでもある。処理系はこれらをコンパイル済みモジュールとして提供するかもしれない(ヘッダーファイルからヘッダーユニットを生成する手順を事前に行っておくことは禁止されていない)。

そのような処理系では、C++20としてコンパイルするだけで従来のコードでも恩恵を得ることができる。

ビルド

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

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

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

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

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

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

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

参照