[C++]C++20モジュールの変遷 - Module TSからC++20DISまで

C++20のモジュールは確かにある一つの提案がベースになっているのですが、その後C++20策定完了までの間に複数の提案やIssue報告によってそこそこ大きく変化しています。その結果、C++20のモジュールはその全体像を把握するためにどれか一つの提案を読めばわかるものではなく、関連する提案を追うのもC++20のDIS(N4861)を読み解くのも辛いものがあり、ただでさえ難解な仕様を余計に分かりづらくしています。

この記事は、C++20の最初のモジュールの一歩手前から時系列に沿って、モジュールがどのように変化していったのかを眺めるものです。

Working Draft, Extensions to C++ for Modules (Module TS)

Module TSと呼ばれているもので、最終的なC++20の仕様のベースとなっているものです。

意味論の細かい差異はあれど、現在書くことのできる基本的なモジュールの構文や仕様(export/import/module宣言など)はここで決定されました。

Another take on Modules (ATOM Proposal)

この提案は、モジュールシステムを別の観点から見つめ直し、モジュールTSにあったいくつかの問題を修正しようとするものです。

TSからの変更点としては

  • export/moduleの非キーワード化
  • module宣言は翻訳単位の先頭になければならない
  • モジュールパーティションの導入
  • private/publicimport
  • マクロのexport
  • ヘッダユニット(Legacy header unitsと呼ばれていた)
    • ヘッダのimport
    • #includeimportへの置換
  • インスタンス化経路(path of instantiation

これはあくまでモジュールTSをベースとしており、対案と言うよりはモジュールTSを補間し修正しようとする提案です。他の提案からは、ATOM Proposalと呼ばれます。

Merging Modules (最初期のC++20モジュール)

この提案はC++20に最初に導入されたモジュール仕様です。モジュールTS(N4720)にATOM提案(P0947R1)をマージする形でモジュールは導入されました。

ここで、新たに次のものが追加されました

  • グローバルモジュールフラグメントの導入
    • 正確には、グローバルモジュールフラグメントは明示的に導入するものとなった(TSでは#includeによるヘッダインクルードが実質的にグローバルモジュールフラグメントを導入していた)
  • プライベートモジュールフラグメントの導入
  • semantic boundaries ruleの導入
    • 「(定義を持つものは)以前の定義が到達可能なところで再定義されてはならない」と言うルール
  • ヘッダユニットからのマクロの定義位置
    • import宣言の直後と規定

また、次のものは導入されませんでした

  • モジュールTS
    • Proclaimed ownership declaration
  • ATOM提案

参考資料

Relaxing redefinition restrictions for re-exportation robustness

当初のモジュールでは、従来の#includeは可能ならすべてヘッダユニットのimportに置き換えられていました。そして、ヘッダユニットの宣言はグローバルモジュールに属する宣言として扱われます。

またODR要件が緩和されており、以前の定義が到達可能でなければ定義は複数あってもいい、とされていました。ある場所から定義が到達可能というのは、その場所で直接見えているimport宣言をたどっていった先で定義が見つかることで、この到達可能な定義の集合に同じ宣言に対する定義が複数含まれているとエラーとなります。

C++17以前と同様に、グローバルモジュール(非モジュール内)においては、その宣言が同一であれば異なる翻訳単位で定義が重複しても良い、というルールがあります。ただし、それらの定義がモジュールのインポートによって到達可能となってしまう場合は定義の重複は許されません。

/// M.cpp

module;
#include "a.h" // struct A {};
export module M;

// b.hはインポート可能なヘッダ
export import "b.h"; // struct B {};

// 宣言が破棄されないようにする
export A f();
/// src.cpp

import M;
// 構造体AとBはともにこの場所から到達可能

#include "a.h" // error, Aの再定義
#include "b.h" // OK, b.hのインクルードはimportに変換され、Bの定義は再定義とならない

この時、b.hが次のようになっていると多重定義エラーが発生する可能性があります。

/// b-impl.h (インポート可能なヘッダではない

#ifndef B_IMPL_H
#define B_IMPL_H
struct B {};
#endif
/// b.h (インポート可能なヘッダ

#include "b-impl.h"
/// src.cpp

import M;
#include "b-impl.h" // error, Bの再定義

インポート可能なヘッダというのは実装定義であり、このようなエラーはコンパイラによって発生したりしなかったりするかもしれません。また、従来の#includeであれば、このようなケースはODRの例外規定によって特別扱いされていたはずです。

このようなグローバルモジュールに属するエンティティの再エクスポート時の不可思議な振る舞いを避けるために、この提案によって次のように仕様が調整されました。

  • グローバルモジュールに属するエンティティの定義は、定義が到達可能かどうかに関係なく、各翻訳単位で最大1つの定義の存在を許可する。
  • 名前付きモジュールに属するエンティティに対するODRの例外規定の削除。
    • ODRの例外規定とは、定義が同一であれば複数の翻訳単位に現れてもいい、というルール。
    • テンプレートをヘッダに書いてコンパイルする際に実装を容易にするための特殊なルールだったが、モジュールのimportは宣言をコピペしないのでモジュールでは不要。
  • インポート可能なヘッダの#includeは、importに置き換えても よい という表現に変更
    • そもそも、インポート可能なヘッダを常にimportしていたのは、先程のb.hの最初の例のようなケースで再定義エラーが起こらないようにするため。この提案の変更によって前提となる問題が解決された。
// この提案適用後では
import M;
#include "b-impl.h" // OK, Bの定義は到達可能だが、この翻訳単位では最初の定義

この提案によってグローバルモジュールにおけるODR周りの事はC++17までとほとんど同様となり、名前付きモジュール内でだけODR要件が厳しくなります。

  • グローバルモジュール : 宣言に対する定義は各翻訳単位で唯一つであり、全ての定義は同一でなければならない
  • 名前付きモジュール : 宣言に対する定義はプログラム内で唯一つでなければならない

どちらの場合でも、同じ翻訳単位内での多重定義はコンパイルエラーとなりますが、翻訳単位が分かれている場合にこのルールに違反していると必ずしもコンパイルエラーとはならず、未定義動作となります。

参考資料

Mitigating minor modules maladies

この提案はモジュールによって問題となる、3つの特殊なケースのバグを修正するものです。

1. using/typedef

※この問題はコンパイラの実装に大きくかかわる物で、今一よくわかりませんでしたので、結論のみを書いておきます・・・

この提案では、リンケージを与えるためのtypedefの対象となる構造体は次のものを含むことができないようにします。

  • 非静的データメンバ・メンバ列挙型・メンバ型(入れ子クラス)を除くすべてのメンバ
  • 基底クラス
  • データメンバに対するデフォルト初期化子
  • ラムダ式

この提案によって、リンケージを与えるためのtypedefC言語互換のためだけの機能であることが明確となり、その対象となる構造体はC互換の構造体に限定されるようになります。また、typedef/usingによって名前のリンケージを変更できないことが明確となります。

これはモジュールの内外を問わず適用されるため、破壊的変更となります。

2. エクスポートブロック内でのstatic_assert

モジュールにおけるexport宣言では、名前を導入しないタイプの宣言をexportすることができません。

export static_assert(true); // error、エクスポートできない

export {
  struct Foo { /*...*/ };
  static_assert(std::is_trivially_copyable_v<Foo>); // error、エクスポートできない

  struct Bar { /*...*/ };

  template<typename T>
  struct X { T t; };

  template<typename T>
  X(T) -> X<T>;  // error、エクスポートできない

  // ...

#define STR(x) constexpr char x[] = #x;
  // セミコロン(;)が余計に一つ付くが、エクスポートできないのでエラー
  STR(foo);
  STR(bar);
#undef X
}

この提案では、このようなエクスポートブロックの内部でのみ、宣言が少なくとも1つの名前を導入しなければならない、というルールを削除します。

export static_assert(true); // error、エクスポートできない

export {
  struct Foo { /*...*/ };
  static_assert(std::is_trivially_copyable_v<Foo>); // OK

  struct Bar { /*...*/ };

  template<typename T>
  struct X { T t; };

  template<typename T>
  X(T) -> X<T>;  // OK

  // ...

#define STR(x) constexpr char x[] = #x;
  // 両方OK
  STR(foo);
  STR(bar);
#undef X
}

ただし、ブロックではない通常のexport宣言においては名前を導入しない宣言をエクスポートできないのは変わりません。

3. デフォルト引数の不一致

inlineではない関数では、デフォルト引数を翻訳単位ごとに異なるものとすることができます。また、テンプレートのデフォルトテンプレート引数も翻訳単位ごとに異なるものとすることができます。

/// a.h
int f(int a = 123);
/// b.h
int f(int a = 45);
/// main.cpp
import A;     // a.hを間接的にインクルードしているが、エクスポートはしていない
import "b.h";

// a.hのf()は到達可能
// b.hのf()は可視であり到達可能
int main() {
  int n = f();  // 結果は・・・?
}

同じ宣言に対して異なるデフォルト引数が与えられた複数の宣言が同じ翻訳単位内で出現する場合はコンパイルエラーとなりますが、モジュールにおいては一方のみが名前探索で可視であるが、両方の宣言に到達可能となる場合があります。当初の仕様ではこの場合にどう振舞うかは未規定でした。

この提案では、異なる翻訳単位の同じ名前空間スコープの2つの宣言が、同じ関数引数に異なるデフォルト引数を、あるいは同じテンプレート引数に異なるデフォルトテンプレート引数を指定することをそれぞれ禁止します。ただし、異なるデフォルト引数を持つ複数の宣言が同時に到達可能とならない限り、コンパイルエラーとならない可能性があります。

/// main.cpp
import A;     // a.hを間接的にインクルードしているが、エクスポートはしていない
import "b.h";

// a.hのf()は到達可能
// b.hのf()は可視であり到達可能
int main() {
  int n = f();  // NG、コンパイルエラー
}

この変更は、その宣言がモジュールにあるかどうかにかかわらず適用されます。つまり、これは破壊的変更となります。

Recognizing Header Unit Imports Requires Full Preprocessing

この提案は、依存関係スキャンを簡易化・高速化するために、ヘッダユニットのインポートを#includeとほとんど同等に扱えるようにするものです。

当初のモジュールでは、import宣言はほとんどC++のコードとして解釈され、プリプロセス時にはヘッダユニットのインポートに対してマクロのエクスポートを行う以外のことをしていませんでした。そのため、ヘッダユニットのインポートを識別するには翻訳フェーズ4(プリプロセスの実行)を完了する必要がありました。

すなわち、import宣言はほとんどどこにでも現れる可能性があり、マクロ展開を完了しなければimport宣言を抽出することができません。

これは従来#includeに対して行われていた依存関係スキャンに対して、実装が困難になるだけではなく、速度の面でも明らかに劣ることになります。例えば、#includeに対する依存関係スキャンでは、プリプロセッシングディレクティブ以外の行は何もせず無視することができ、#includeは1行で書く必要があるため行をまたぐような複雑なマクロ展開をしなくても良くなります。

この提案では、(export) importによって開始される行をプリプロセッシングディレクティブとして扱うようにします。それによって、(export) importをマクロ展開によって導入する事ができなくなり、(export) importは空白を除いて行の先頭に来ていなければならず、import宣言は1行で書かなければならなくなります。

プリプロセスの最初の段階ではモジュールのインポートもヘッダユニットのインポートもまとめて扱われ、その後ヘッダユニットのインポートに対してエクスポートされたマクロのインポートを行います。最後に、importトークンを実装定義のimport-keywordに置き換えて、importディレクティブのプリプロセスは終了します。

翻訳フェーズ5以降、つまりC++コードのコンパイル時には、このように導入されたimport-keywordによるものだけがimport宣言として扱われるようになります。

なお、(export) importトークンおよびimportディレクティブを終了する;と改行だけがマクロで導入できないだけで、import対象のヘッダ・モジュール名はマクロによって導入することができます。

この提案によって可能な記述は制限される事になります。

Before After
// 行中にあっても良かった
int x; import <map>; int y;
// importディレクティブは1行で独立
int x;
import <map>;
int y;
Before After
import <map>; import <set>;
// それぞれ1行づつ書く
import <map>;
import <set>;
Before After
// 複数行に渡っていても良かった
export
import
<map>;
// importディレクティブは1行で完結する
export import <map>;
Before After
// プリプロセッサによる切り替えが可能だった
#ifdef MAYBE_EXPORT
export
#endif
import <map>;
// importディレクティブの一部だけを#ifで変更できない
#ifdef MAYBE_EXPORT
export import <map>;
#else
import <map>;
#endif
Before After
#define MAYBE_EXPORT export
MAYBE_EXPORT import <map>;
// (export) importはマクロによって導入できない
#define MAYBE_EXPORT
#ifdef MAYBE_EXPORT
export import <map>;
#else
import <map>;
#endif

この提案の内容はのちにP1857R3によって大幅に(より制限する方向に)拡張されることになります。

参考資料

Standard library header units for C++20

この提案は、少なくとも標準ライブラリのヘッダはヘッダユニットとしてインポート可能であることを規定するものです。

C++20にモジュールが導入されるのは確定的で、そうなると標準ライブラリをモジュールとして提供する(できる)必要が生じます。この提案の時点ではその作業が間に合うかは不透明であり(実際間に合わなかった)、間に合わなかった場合は、それぞれのベンダーからそれぞれの(互換性のない)方法でモジュール化された標準ライブラリが提供され、C++エコシステムに分断をもたらす事になりかねません。

この提案では、既存の標準ライブラリをモジュールとして提供するための最低限のメカニズムを提供しつつ、将来的な標準ライブラリの完全なモジュール化を妨げる事が無いようにするものです。

そのために、C++ 標準ヘッダは全てヘッダユニットとしてインポート可能であると規定し、標準ライブラリへのアクセス手段としての標準ヘッダのインポートを規定します。そして、モジュール単位(名前付きモジュール)の中での標準ライブラリヘッダの#includeはグローバルモジュールフラグメントの中でのみ行える事が規定されました(診断は不要とあるので、これに従わなくてもコンパイルエラーとはならない可能性があります)。

なお、C互換の標準ヘッダ(<cmath>, <cassert>などの<cxxx>系のヘッダ)はインポート可能ではありません。これらのヘッダは事前のマクロ定義に大きく影響を受けますが、ヘッダユニットも含めたモジュールは外で定義されたマクロが内部に影響を及ぼさないため、インポータブルでは無いためです。

また同時に、stdから始まる全てのモジュール名を将来の標準ライブラリモジュールのために予約します。

NBコメントへの対応1

このIssueは、規格文書中で標準ライブラリのエンティティ名にアクセスする手段を記述している所にヘッダユニットのimportを加えるものです。P1502R1の内容を補強するもので、P1502R1ではおそらく見落とされていたものです。

このIssueは、モジュールのインターフェースという言葉を使用していたために、エクスポートしていない関数名がADLを介して表示されるかのように読めてしまっていた部分の表現を修正するものです。

意味するところはこの前後で変わらず、モジュールの内部にあるエクスポートされた宣言はテンプレートのインスタンス化経路上で可視となりますが、エクスポートされていない宣言はいかなる場合にも可視になりません。

このIssueは、予約するモジュール名について名前空間の予約と記述を一貫させるものです。

意味するところは変わらず、stdに数字が続く名前空間名、stdから始まるモジュール名は全て予約されます。

このIssueは、グローバルモジュールフラグメントに関する箇所の規格参照用のラベルが[module.global]だったり[cpp.glob.frag]だったりしていたのを、[xxx.global.frag]に一貫させるものです。

このIssueは、モジュールに関するサンプルコードで翻訳単位の境界が曖昧だった所を明確にするものです。

Core Language Changes for NB Comments at the November, 2019 (Belfast) meeting

この提案は2019年11月のベルファストの会議において採択されたコア言語のIssue解決(NBコメントについて)をまとめたものです。モジュールに関連するものは4件あります。

これは、それまで規格としての記述のみで利用法が不明瞭だったプライベートモジュールフラグメントについて、サンプルコードを追加するものです。

これは、ヘッダユニットのインポートが再起して巡回する事が無いことを明確に記述するものです。

それまで、モジュールのインポートはインターフェース依存関係という言葉を用いて巡回インポートが禁止されていましたが、ヘッダユニットについては特に規定がありませんでした。

ここでは、インターフェース依存関係の対象にヘッダユニットを含めることで、モジュールと同様に巡回インポートを禁止します。あらゆる巡回インポートはコンパイラによって検出され、コンパイルエラーとなります。

これは、コマンドラインオプション(-Dなど)によって定義されたマクロ名がヘッダユニットからエクスポートされないことを規定するものです。

これによって、そのようなマクロ名が重複したり、それがコンパイラによって異なったりする事が防止されます。ただ、これはどうやら文面として強制するものでは無いようです・・・

これは、グローバルnew/deleteを使うための<new><=>の戻り値型を使用するための<compare>など、言語機能の利用のために標準ライブラリヘッダのインクルードの必要が規定されているものについて、ヘッダユニットのimportも認めるようにするものです。

Resolution to US086

この提案によって解決されるIssueは、あるモジュール単位Iを同じモジュール内の他のモジュール単位Mがインポートする時に、Iのグローバルモジュールフラグメントにあるインポート宣言を暗黙的にインポートしないようにするものです。

同じモジュール内にあるモジュール単位をインポートするとき、インポート対象のモジュール単位内でインポートされているすべての翻訳単位をインポートします。2つのモジュール単位が別々のモジュールに属する場合のインポートは再エクスポート(export imprt)されている翻訳単位のみをインポートしますが、同じモジュール内ではインポート宣言がより多くの翻訳単位をインポートすることになります。

グローバルモジュールフラグメントは#includeをモジュール内で安全に行うための宣言領域であり、そこにあるインポート宣言は#includeimportへの置換によって導入されたものでしょう。それらはグローバルモジュールに属するものでありIの一部ではなく、IからエクスポートされMから到達可能となるのは不適切です。

export宣言はグローバルモジュールフラグメントに直接的にも間接的(#includeやマクロ展開など)にも書くことはできないので、グローバルモジュールフラグメントでimportされている翻訳単位をインポートしてしまう可能性があるのは同じモジュール内でのモジュール単位のインポート時だけです。

当初の仕様ではその考慮は抜けており(モジュールTSでは考慮されていましたが、ATOMとのマージ時にグローバルモジュールフラグメントが導入されたことで見落とされていた様子)、グローバルモジュールフラグメントのインポート宣言がモジュールのインターフェースの一部となってしまっていたため、この提案では明示的にそうならないことを規定しています。

/// uses_vector.h
import <vector>; // #includeからの置換である可能性がある
/// partition.cpp
module;
#include "uses_vector.h" // import <vector>; と展開される
module A:partition;

// この中でstd::vector<int>を使っているとする。
/// interface.cpp
module A;
import :partition;

// 必ずコンパイルエラーになるようになる
// ここでは<vector>はインポートもインクルードもされていない
std::vector<int> x; 

以前の仕様では、最後のstd::vectorの使用がwell-definedとなってしまっていました。

Dynamic Initialization Order of Non-Local Variables in Modules

当初の仕様では、モジュールとそれをインポートする翻訳単位の間で静的記憶域期間を持つオブジェクト(すなわちグローバル変数)の動的初期化順序が規定されていなかったために、std::coutの利用すら未定義動作を引き起こす可能性が潜んでいました。

import <iostream>;  // <iostream>ヘッダユニットのインポート

struct G {
  G() {
    std::cout << "Constructing\n";
  }
};

G g{};  // Undefined Behaior!?

このような場合でも安全に利用できるようにするために、モジュールを含めた翻訳単位間での静的オブジェクトの動的初期化に一定の順序付けを規定するようにします。

ある翻訳単位がヘッダユニットも含めてモジュールをインポートする時、そのモジュールに対してインターフェース依存関係が発生します。インポートが絡む場合の動的初期化順序はこのインターフェース依存関係を1つの順序として初期化順序を規定します。ただし、この初期化順序は半順序となります(すなわち、順序が規定されない場合があります)。

同じ翻訳単位内での動的初期化順序はその宣言順で変わりありません。これは、別の翻訳単位をインポートしたときに、インポート先にある静的変数とインポート元の静的変数との間の動的初期化順序を最低限規定するものです。

参考資料

Core Language Changes for NB Comments at the February, 2020 (Prague) meeting

これは、言語リンケージ指定を伴うブロック内でのimport宣言を許可するものです。

例えばextern "C"なブロック内でCのヘッダを#includeしている場合にも、そのファイルがC++としてコンパイルされていればそのヘッダをimportに置換することができるはずです。しかし以前の仕様ではimportのリンケージ指定もリンケージブロック内でのimportも許可されていなかった(import宣言はグローバル名前空間スコープにのみ現れることができた)ため、その場合は常に#includeするしかありませんでした。

このIssueの解決では、直接的に書くことができないのは従来通りですが、#include変換の結果としてヘッダユニットのimportが現れるのが許可されるようになります。ただし、C++言語リンケージ指定以外に現れるimport宣言は実装定義の意味論で条件付きのサポートとなります。

extern "C" import "importable_header.h" // NG、直接書けない

extern "C" {
  #include "importable_header.h"  // OK、ヘッダユニットのインポートに変換可能
                                  // ただし、実装依存のサポート

  import "importable_header.h"    // NG、直接書けない
}

extern "C++" {
  #include "importable_header.h"  // OK、ヘッダユニットのインポートに変換可能
  
  import "importable_header.h"    // NG、直接書けない
}

このことは、構文定義を変更してインポート宣言をおよそ宣言が書ける場所にどこでも書けるようにしたうえで、文書でインポート宣言を書ける場所をグローバル名前空間スコープに限定しておき、リンケージ指定ブロック内で(#include置換の結果として)インポート宣言が間接的に現れることを許可する形で表現されており、少しややこしいです。

Translation-unit-local entities

これは名前付きモジュールのインターフェースにある内部リンケージ名がそのモジュールの外部へ露出する事を禁止するものです。

これは特に、モジュール外部でインライン展開されうる関数にて問題になっていました。

/// mymodule.cpp
module;
#include <iostream>
export module mymodule;

// 内部リンケージ
static void internal_f(int n) {
  std::cout << n << std::endl;
}

namespace {
  // 内部リンケージ
  int internal_g() {
    return 10;
  }
}

// エクスポートされている、外部リンケージ
export inline int external_f(int n) {
  // この関数がインライン展開されると・・・
  internal_f(n);
  return n + internal_g();
}

名前付きモジュールにおけるinline関数がexportされる場合、その定義はそのモジュールのインターフェースに無ければなりません。そのため、exportされたinline関数はinline指定の本来の効果(関数のインライン展開の指示)の適用対象となります。

インライン展開される関数の本体から内部リンケージ名を参照していると、本来翻訳単位を超えて参照できないはずの内部リンケージ名がインライン展開によって翻訳単位の外側から参照されてしまう事になります。内部リンケージ名の翻訳単位外への暴露は望ましい動作では無いため、この提案によって禁止されました。

名前付きモジュールのインターフェースに存在する外部への露出が禁止されるもののことを、翻訳単位ローカルのエンティティ(TU-local Entities)と呼びます。TU-localエンティティの正確な定義は複雑ですが、ほぼ内部リンケージ名を持つ関数・変数・型のことを指します。

それらTU-localエンティティがinline関数などによって翻訳単位の外に曝露する可能性のある時、コンパイルエラーとなります。注意なのは、TU-localエンティティが曝露された時ではなく、その可能性がある段階でコンパイルエラーとなる事です。

export module M;

// 内部リンケージの関数
static constexpr int f() { return 0; }

static int f_internal() { return f(); } // 内部リンケージ、OK
       int f_module()   { return f(); } // モジュールリンケージ、OK
export int f_exported() { return f(); } // 外部リンケージ、OK

// 外部orモジュールリンケージを持つinline関数は内部リンケージ名を参照できない
static inline int f_internal_inline() { return f(); } // OK
       inline int f_module_inline()   { return f(); } // NG
export inline int f_exported_inline() { return f(); } // NG

もう一つ、inline関数では無いけれどほぼ同じ振る舞いをするものにテンプレートがあります。テンプレートの厄介なところは、インスタンス化されるまで何を参照しているかが確定しない事にあります。そのため、テンプレートでは、インスタンス化された時に内部リンケージ名を参照する可能性がある場合にコンパイルエラーとなります。

/// mymodule.cpp
export module mymodule;

export struct S1 {};

// 内部リンケージ
static void f(S1);  // (1)

export template<typename T>
void f(T t);  // (2)

// インスタンス化前はエラーにならない
export template<typename T>
void external_f(T t) {
  f(t);
}
/// main,cpp
import mymodule;

struct S2{};

void f(S2);  // (3)

int main() {
  S1 s1{};
  S2 s2{};

  external_f(10);  // OK、(2)を呼ぶ
  external_f(s2);  // OK、(3)を呼ぶ
  external_f(s1);  // NG、(1)を呼ぶ
}

内部リンケージ名を参照する可能性がある場合というのは、直接的に現れていなかったとしても、関数オーバーロードの候補集合に内部リンケージな関数が含まれている場合です。その場合使用する関数の決定を待たずにコンパイルエラーとなります。

テンプレートに関しては例外があり、内部リンケージ名を外部から参照しエラーとなる宣言であっても、モジュールのインターフェース内で予め特殊化されインスタンス化済みである時はエラーとなりません(inline指定が無ければ)。

/// mymodule.cpp
export module mymodule;

export struct S1 {};

static void f(S1 s);  // (1)

// 宣言はOK
export template<typename T>
void external_f(T t) {
  f(t);
}

// S1に対するexternal_f()の明示的インスタンス化
template void external_f<S1>(S1); // (2)
/// main,cpp
import mymodule;

int main() {
  S1 s1{};

  external_f(s1);  // OK、(2)でインスタンス化済
}

この場合、モジュールは予めコンパイルされているはずなので、インスタンス化済のテンプレートのインスタンス化を省略し、通常の関数と同様にシグネチャのみで参照することができます。テンプレートはinline指定がなければインライン展開されるとは限らず、その必要がありません。

このようにこの提案の後では、名前付きモジュールにおける関数に対するinline指定はインライン展開の対象であることをコンパイラに伝えるマーカーとしての本来の役割のみを担うようになります。

参考資料

ABI isolation for member functions

これは、名前付きモジュール内で定義されたクラスについて、その定義内で定義されているメンバ関数の暗黙inlineをしなくするものです。

先ほどのP1815R2の変更によって、モジュールのインターフェース内のinline関数内での内部リンケージ名の使用がコンパイルエラーとなるようになります。これによって大きな影響を受けるのは、クラスの定義内で定義されているメンバ関数です。

クラス定義内で定義されているメンバ関数は暗黙inlineであり、P1815R2の影響を強く受けることになります。

export module M;

// 内部リンケージの関数
static constexpr int f() { return 0; }

// エクスポートされ外部リンケージを持つクラス定義
export struct c_exported {
  int mf_exported();
  int mf_exported_inline() { return f(); } // NG、暗黙inline
};

int c_exported::mf_exported() { return f(); } // OK、暗黙inlineではない

これを避けようとすると、メンバ関数は全てクラス外で定義することになってしまい、冗長な記述が増え、非メンバ関数との一貫性がなくなります。この辺りの仕様は複雑なので、このことはユーザーにとって意味がわからないエラーとなるかもしれません。

クラス定義内で定義されたメンバ関数が暗黙inlineなのは、ヘッダに定義を書いて複数の翻訳単位でインクルードした時に多重定義エラーを起こさないためなので、モジュールの利用においてはほとんど必要ありません。

そのため、この提案では名前付きモジュール内に限って、クラス定義内で定義されたメンバ関数に対する暗黙inlineを行わないようにします。これによって、モジュールにおけるクラスの定義は今まで通りに行う事ができ、複雑なことを気にする必要は無くなります。

export module M;

...

export struct c_exported {
  int mf_exported();
  int mf_exported_inline() { return f(); } // OK、暗黙inlineではない
};

ただし、このことはモジュールの外側(グローバルモジュール)においては従来通りです。モジュールではないところで定義されたクラスのクラス定義内で定義されたメンバ関数は相変わらず暗黙inlineです。

Modules Dependency Discovery

これは、moduleimportを書くことのできる場所や形式を制限するものです。

P1703R1も同様の目的の変更でしたが、moduleはなんら制限されておらず、importを使用する既存のコードへの影響が小さくありませんでした。この提案はP1703R1のアプローチをさらに進めて、moduleを用いる構文についても書き方や書ける場所を制限し、かつimportmoduleを使用している既存のコードへの影響を減らそうとするものです。

この提案では、次の条件を満たすもので始まる行は、inportディレクティブとmoduleディレクティブとして扱われるようになります。

  • import : 以下のいずれかが同じ行で後に続くもの
    • <
    • 識別子(identifier
    • 文字列リテラル
    • :::とは区別される)
  • module : 以下のいずれかが同じ行で後に続くもの
    • 識別子
    • :::とは区別される)
    • ;
  • export : 上記2つの形式のどちらかの前に現れるもの

これらは新しいプリプロセッシングディレクティブとして扱われますが、プリプロセッシングディレクティブの扱いは従来通りであるため、ここでのimport, module, exportはマクロによって置換されたり導入されたりせず、ディレクティブは1行で書く必要があります。

// これらは各行がプリプロセッシングディレクティブとみなされる
#                     
module ;              
export module leftpad;
import <string>;      
export import "squee";
import rightpad;      
import :part;

// これらの行はプリプロセッシングディレクティブではない
module            
;                     
export                
import                
foo;                  
export                
import foo;           
import ::             
import ->             

これによってまず、インポート宣言、モジュール宣言、グローバルモジュールフラグメント、プライベートモジュールフラグメントの構文は、マクロによって導入されず、1行で書かなければなりません。ただし、インポート対象の名前やモジュール名はマクロによって導入することができます。

なお、通常のexport宣言はこれらの処理の対象ではありません。exportから始まるプリプロセッシングディレクティブはあくまで、すぐ後にimport/moduleが現れるものです。

これらのディレクティブに含まれるimport, module, exportプリプロセッサによってimport-keyword, module-keyword, export-keywordに置き換えられ、この*-keywordによるものがC++コードとしてのインポート宣言やモジュール宣言として扱われるようになります。

例えば次のようなコードは

module; // グローバルモジュールフラグメント
#include <iosream>
export module sample_module;  // モジュール宣言

// インポート宣言
import <vector>;
export import <type_traits>;

// エクスポート宣言
export int f();

// プライベートモジュールフラグメント
module : private;

int f() {
  return 20;
}

プリプロセス後(翻訳フェーズ4の後)に、おおよそ次のようになります。

__module_keyword;
#include <iosream>
__export_keyword __module_keyword sample_module;

__import_keyword <vector>;
__export_keyword __import_keyword <type_traits>;

// export宣言はプリプロセッシングディレクティブでは無い
export int f();

__module_keyword : private;

int f() {
  return 20;
}

__import_keywordなどは実装定義なので実際にどう書き換えられるかは分からず、それを直接書くための構文は用意されていません。

そしてもう一つの大きな変更は、プリプロセスの一番最初の段階でソースファイルがモジュールファイルなのか通常のファイルなのかを判定し、モジュール宣言、グローバルモジュールフラグメントとプライベートモジュールフラグメントはモジュールファイルだけに現れることが出来るように規定されている事です。

この判定は、ファイルの一番最初に現れる非空白文字がmoduleあるいはexport moduleで始まっているかどうかをチェックすることで行われ、それがある場合にのみモジュールディレクティブに対応するディレクティブ(の処理方法)が定義されます。

通常のファイルとして処理された場合でもインポートディレクティブを処理することはできますが、モジュールディレクティブは対応するディレクティブが定義されないため、コンパイルエラーとなります。

この事は、#ifdefなどによってあるファイルがモジュールであるかヘッダファイルであるかを切り替える、ような事ができないことを意味しています。

これらの事はCプリプロセッサのEBNFによる構文定義の中で表現されており、少し複雑です。

Before After
// OK、モジュール宣言
export
module x
;
// -Dm="export module x;"
m   // OK
module;
#define m x
export module m;  // OK
module;
#if FOO
export module foo;  // OK
#else
export module bar;  // OK
#endif
module;
#define EMPTY
EMPTY export module m;  // OK
#if MODULES
module;
export module m;  // NG
#endif
#if MODULES
export module m;  // OK
#endif
module y = {};         // NG

::import x = {};       // OK
::module y = {};       // OK

import::inner xi = {}; // NG、インポートディレクティブ
module::inner yi = {}; // OK

namespace N {
  module a;            // OK
  import b;            // NG、インポートディレクティブ
}

#define MAYBE_IMPORT(x) x
MAYBE_IMPORT(
  import <a>;          // UB
)
#define EAT(x)
EAT(
  import <a>;          // UB
)

void f(Import *import) {
  import->doImport();  // NG、インポートディレクティブ
}
// NG
export
module x  // モジュールディレクティブ
; 
// -Dm="export module x;"
m   // NG
module;
#define m x
export module m;  // OK
module;
#if FOO
export module foo;  // NG
#else
export module bar;  // NG
#endif
module;
#define EMPTY
EMPTY export module m;  // NG
#if MODULES
module;
export module m;  // NG
#endif
#if MODULES
export module m;  // NG
#endif
module y = {};         // NG

::import x = {};       // OK
::module y = {};       // OK

import::inner xi = {}; // OK
module::inner yi = {}; // OK

namespace N {
  module a;            // NG、モジュールディレクティブ
  import b;            // NG、インポートディレクティブ
}

#define MAYBE_IMPORT(x) x
MAYBE_IMPORT(
  import <a>;          // UB
)
#define EAT(x)
EAT(
  import <a>;          // UB
)

void f(Import *import) {
  import->doImport();  // NG、インポートディレクティブ
}

さらに、モジュールファイル内においては#includeによってimportディレクティブが現れる事が禁止されます。これは#includeによって展開されたファイル内にimportディレクティブがあってはならないという事ですが、インポート可能ヘッダの#includeをヘッダユニットのインポートに置換する事が行われないことも意味します。

/// header.hpp

import <iostream>;
/// mymodule.cpp
module:
#include "header.h"  // NG
export module mymodule;

#include "header.h"  // NG
import "header.h";   // OK

module : private;
#include "header.h"  // NG
/// main.cpp

#include "header.h"  // OK
import "header.h";   // OK

int main(){}

これは検出されコンパイルエラーとなります。

参考資料

Issueの解決3

これは、エクスポートされたインポート宣言がモジュールインターフェース以外の場所で現れることを禁止するものです。

/// M.cpp

export module M;

// OK、モジュールAをインポートしつつエクスポート
export import A;
/// M_impl.cpp

module M; // Mの実装単位(not インターフェース単位)

// NG、export importはここにかけない
export import B;
/// main.cpp

// NG、export importはここにかけない
export import M;

int main() {}

以前は特にケアされていなかったのでコンパイルエラーになっていませんでしたが、この変更によって明確にエラーにされます。

これは、異なるモジュールから到達可能となっている同じ無名のスコープ無し列挙型の定義をマージするためのルールを定めるものです。

無名のスコープ無し列挙型はヘッダファイルで一般的であり、同じものが複数のモジュールから到達可能となるとき、それが同一であることを判定しマージできなければODR違反となります。

これはヘッダユニットのインポートで特に問題となり得るため、そのような複数の定義が同じものであることを認識する方法を指定することでODR違反とならないようにします。

/// importable.h
namespace X { 
  enum { A }; // 無名のスコープ無し列挙型 (1)

  enum {};  // (2)
}
/// M.cpp

module;
#include "importable.h"
export module M;

// 宣言が破棄されないようにする
constexpr int N = X::A;
/// main.cpp

// (1)の定義が異なる経路で到達可能となる
// どちらも全く同じ定義を参照しているためマージされODR違反は起きない
// (2)は翻訳単位毎に別の型として扱われるためODR違反は起きない
import "importable.h"
import M;

int main() {}

これは従来からあるテンプレートのためのODRの例外規則を拡張する形で表現されています。つまり、定義が異なる翻訳単位から到達可能になっているときでも、その定義が意味的に完全に同一である場合にのみODRの例外を適用し定義が一つにマージされます。

列挙型としての名前がないので、これらの識別では最初の列挙子がその列挙型の名前として使用されます。そのため、列挙子を持たない無名の列挙型は常に異なる型として扱われます。

おわり

おおよそ時系列に沿っているはずです。細かいIssue解決は見逃しているかもしれません。あとGithubリポジトリに直接コミットする形のeditorialな修正は追い切れていません・・・

あと、P1787R6 Declarations and where to find themの変更は直接的にはモジュールに対するものではなく、C++23での採択であるので省いています(それでも影響はそこそこありますが)。

もし見落としや間違いなどを発見されたら教えてくださいませ・・・

この記事のMarkdownソース