[C++]モジュールインポート時の動的初期化順序

ほぼほぼ、2019年12月のBelfast会議で採択されたP1874R1 Dynamic Initialization Order of Non-Local Variables in Modulesの和訳しただけです。

モジュールについては以前の記事でもご参照ください。 onihusube.hatenablog.com

P1874R1前夜の大問題

P1874R1が採択される以前のモジュールはほとんど完成していたはずですが、以下のような何の変哲も無い?コードが未定義動作に陥るという罠がありました。

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

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

G g{};  // Undefined Behaior!?

C++20においては通常のC++標準ライブラリヘッダをヘッダユニットとしてインポートできます。ヘッダユニットはそれが1つのモジュールかつ1つの翻訳単位として個別にコンパイルされ、インポートによってその外部リンケージを持つ宣言がインポート先の翻訳単位の名前探索で可視となります。

ここで問題なのは、翻訳単位が別れた場合にはグローバル変数(正確には静的記憶域期間を持つ変数)の初期化順序が不定になってしまうことです。std::coutは通常グローバルなオブジェクトなので、main()関数の開始前に使用した場合に初期化されているかどうか分かりません。この場合、変数gstd::coutはそれぞれ異なる翻訳単位にあるため、どちらが先に初期化されるのか、あるいは同時に初期化されるのか、コンパイラ様のみぞ知る世界です・・・

同じ翻訳単位にあればほぼその宣言順に初期化されるためこの問題は起きません。つまり、#includeの場合は問題ないわけです。

こんなことしねーよと思われるかもしれませんが、これは通常のモジュールをインポートして使用するときにも、そのインターフェース単位にあるグローバルな変数について同じことが起こります。また、C++20より多用されるカスタマイゼーションポイントオブジェクトを使用するときも気を付けなければならないでしょう・・・

Clangの実験的実装

Clangのモジュールの実装(experimental)では、この問題を見た目通りに直列化することで解決していました。

// H1.h
inline int a = init();

// H2.h
inline int b = init();

// TU.cpp
int c = init();
import "H1.h";  // ヘッダユニットとしてインポート
import "H2.h";  // ヘッダユニットとしてインポート

この場合に、c -> a -> bの順番で初期化されます。これはClang Module(not C++20 Module)から引き継がれたモデルであり、import#includeがなるべく同じ意味を持つことを意図したもののようです。

しかし、C++20のモジュールではimportはその順序が意味を持たないように規定されているため、このように規定するのは嫌がられたようで、これを基にした別のモデルを採用しました。

C++20における順序付け規定

あるモジュールMが他の翻訳単位Uにインポートされる場合、UMにインターフェース依存関係を持ちます。そして、U内の宣言Eはその宣言が現れているところでMへのインターフェース依存関係がある場合、Mのインターフェースの全ての宣言よりも後に順序付けられます(appearance-ordered関係)。

同じ翻訳単位にあるものはその宣言順に初期化されるのは従来通り変わらず、これもまたappearance-ordered関係が成り立ちます。

ある翻訳単位内のグローバル変数(静的記憶域期間を持つ変数)の動的初期化順序は、このappearance-ordered関係の順番通りに行われます。この関係が規定されない場合の動的初期化順序は不定実装依存?)です。

例えば、グローバルな変数V, WV -> Wという順番でappearance-ordered関係がある場合、V -> Wの順に動的初期化が行われます。

規格書該当部分の和訳

サンプル

// H1.h
int a = init();

// H2.h
int b = init();

// H3.h
int c = init();

// TU.cpp
int d = init();
import "H1.h";
import "H2.h";
int e = init();
import "H3.h";

この例では、まずd -> eappearance-ordered関係があり(同じ翻訳単位内にあるためその宣言順)、ヘッダユニットのインポートによるa -> e, b -> eの合計3つのappearance-ordered関係があります。従って、これらの変数の間では、このままの順序で動的初期化が行われます。そして、appearance-ordered関係にない変数間の初期化順序は不定です。

結果、次のような順番で初期化が行われます。

サンプルコードの変数初期化順

変数d, a, bの間、及びe, cの間にはappearance-ordered関係が無いので、その初期化順は不定です。ただ、変数d, a, beよりも前に初期化されることだけは確定しています。また、e, c間およびd, a, bcの間でも順序が規定されないので、cの初期化が一番先に行われる可能性もあったりします。

ヘッダーユニットもモジュールのインターフェースも1つの翻訳単位であり、同じモジュール名を指定するインポート宣言は同じモジュールをインポートします。従って、異なる翻訳単位で同じモジュールをインポートしたときでも、モジュールがコンパイルされるのは一回だけであり、そのグローバル変数の動的初期化が行われるのも一度だけです。
そのため、ある翻訳単位のグローバル変数の初期化よりも前に初期化されていることを保証できても、後に初期化されることを保証することができないのです。

ともあれ、これらの事により冒頭のimport <iostream>なコードには未定義動作はもはやありません。

モジュール実装単位の初期化順序(特に規定されない)

同じ事はモジュールの実装単位においても言えます。しかし、C++20時点ではこちらについては特にケアされていません。

  • 規定したとしても異なるモジュール間で規定するのがせいぜいで影響が少ないと思われる
    • モジュール内で実装単位間の初期化順序を規定すると、1つのモジュールをビルドする際のビルド順序を規定してしまうことになるため
  • モジュール実装単位は、異なるモジュール間の循環する依存関係を構成しうる
    • 自分自身のインターフェースに依存する別のモジュールをインポートできる
  • そもそも実装が無いので(規定するにせよしないにせよ)影響範囲がわからない
  • 実装単位については、この問題(import <iostream>にまつわる冒頭のコード)を解決するために触れる必要はない

これらのような理由によって先送りされたようです。

なお、モジュール実装パーティションを同モジュール内でインポートすることができますが、この場合はインターフェース依存関係が発生するので、先ほどの規定に沿った順序が定義されます。

参考文献

この記事のMarkdownソース