※この記事はC++20を相談しながら調べる会 #2の成果です。
※この内容はC++20より有効なものです。C++20正式策定までの間に内容が変化する可能性があります。
より読みやすい解説がすでにあるのでそちらにも目を通されるといいと思われます。→ モジュール - cpprefjp
モジュールのインターフェースと実装
あるファイルをモジュールである!と宣言するには、モジュール宣言(module モジュール名;
) をファイル先頭で行います。モジュール宣言を行うことでそのファイルは1つの モジュール単位、かつ1つの翻訳単位となります。
通常のC++コードがヘッダファイル(宣言)とソースファイル(定義)に分割できる様に、モジュールにおいても宣言と定義を別のファイル(別々のモジュール単位)に分けて実装する事ができます。その時、それらのモジュール単位はそれぞれ モジュールインターフェース単位 と モジュール実装単位 と呼ばれます。
以下、コメントでファイル名が書かれている部分は、そこから別のファイルにあるものとして書いています。
export module MyModule;
export int f(int n);
module MyModule;
int f(int n) {
return n;
}
import MyModule;
int main() {
int n = f(10);
}
このように、export module
の形のモジュール宣言によってインターフェース単位を宣言します。このインターフェース単位は プライマリモジュールインターフェース単位 と呼ばれ、モジュールには必ず 唯一つだけ 含まれていなければなりません。
対して、モジュール実装単位はいくつあっても構いません。
その名の通り、(モジュール)インターフェース単位にはモジュールのインターフェース、すなわち外部に公開する宣言を、実装単位にはそれら宣言の実装をそれぞれ書く、という事を想定しています。
どちらの宣言においてもmodule
の後に来るのがモジュール名で、一部の予約語とstd
から始まる名前を除いて好きな名前を付けることができます。
モジュールのインターフェースでは、モジュールの外側に提供したい宣言をエクスポート宣言(export 宣言;
)によって外部へ公開します。
そして、モジュールを利用する側(翻訳単位)ではインポート宣言(import モジュール名;
)によってモジュールからエクスポートされている宣言を取り込みます。
モジュール実装単位とインターフェース単位は同じ名前を持ちます。すると、実装単位においてインターフェース単位をインポートしようとすると自分自身をインポートすることになってしまいます。
いかなるモジュールも自分自身をインポートすることはできません。そのため、モジュール実装単位は対応するプライマリモジュールインターフェース単位を暗黙的にインポートします。
そして、これらのことからモジュール実装単位そのものはエクスポートもインポートもできない事がわかります。
なお、ヘッダーやソースファイルがそうである様に、モジュールファイルの拡張子は規定されていません。コンパイラがそれと認識すれば、拡張子は自由です。
現在の所、MSVCでは.ixx
、clangでは.cppm
が使われているようです(両コンパイラ共、それ以外のファイルも利用可能)。
そしてさらに、モジュール内部のファイルを複数のファイルに分けて実装する事ができます。
これは例えば、自作のライブラリを1つのモジュールとして提供したいが、その内部の実装においては整理のためにも複数のファイルに分けたい、という際に役立ちます。
その様にモジュール内部で分割したファイル(これもまたモジュール単位)は モジュールパーテイション と呼ばれます。
そして、モジュールパーティションもまたインターフェースと実装に分割する事ができ、分割後のモジュール単位はそれぞれ モジュールインターフェースパーティション、モジュール実装パーティション と呼ばれ、それぞれモジュールインターフェース単位とモジュール実装単位でもあります。
export module MyModule:InterfacePart;
export double g(double v);
module MyModule:ImplPart;
import :InterfacePart;
double g_impl(double v) {
return v + v;
}
double g(double v) {
return g_impl(v);
}
export module MyModule;
export import :InterfacePart;
export int f(int n);
module MyModule;
int f(int n) {
return n;
}
import MyModule;
int main() {
int n = f(10);
double v = g(1.0);
int m = g_impl(10);
}
モジュールパーティションの宣言はモジュール宣言とほぼ同様ですが、モジュール名の後に:パーティション名
を指定します。:
がモジュールパーティションである証です。
通常のインターフェース単位は実質プライマリモジュールインターフェース単位の一つしか作ることができませんが、インターフェースパーティションならばいくつでも作ることができます。
ただし、同じモジュール内で複数のモジュールパーティションが同じ名前を持つことは出来ません。従って、インターフェースパーティションと実装パーティションでは異なる名前を付ける必要があります(このため、あるパーティションは使用する別のパーティションを明示的にインポートする必要があります)。
このようなモジュールのパーティションへの分割はモジュールの外からは観測することができません。したがって、モジュールパーティションはモジュール外でインポート出来ません。
そのため、インターフェースパーティション内でエクスポートした宣言は全て、プライマリーモジュールインターフェース単位から再エクスポートされなければなりません(これはしなければならないが、していなくてもエラーにはならない)。
また、モジュールパーティションをインポートするときは、(:
も含めた)そのパーティション名だけを指定する必要があります。モジュール名は要らず、むしろコンパイルエラーになります。
上記の例では以下のように書いてはいけません。
export module MyModule:InterfacePart;
import MyModule:ImplPart;
export module MyModule;
export import MyModule:InterfacePart;
パーティションかどうかは関係なく、モジュール実装単位ではexport
宣言を行うことができず、実装単位をエクスポート(export import
)することもできません。実装単位にあるものはインターフェースでエクスポートしておかない限り、モジュール外部からは利用不可能です。
また、モジュールパーティションを(同モジュール内で)インポートした際は通常のインポートとは異なり、そのパーティションのすべての宣言が(エクスポートされていなくても)利用可能になります。
モジュール宣言、まとめ
ここまで4種類のモジュールが出てきましたが、モジュールはその種別にかかわらず1ファイル1モジュール単位であり、モジュール単位1つは1つの翻訳単位を成しています(すなわち、個別にコンパイルされます)。
そのような複数のモジュール単位は、そのモジュール名によって1つのモジュールとしてまとめて扱われます。
4つしかないとはいえ名前がこんがらがってとてもややこしいですが、インターフェースか実装か、パーティションか否か、という2つの軸から分割できます。そして、モジュール宣言にexport
が含まれていればインターフェース、:
が含まれていればパーティション、という風に見分けられます。
モジュール名をM
、パーティション名を:Part
として、それぞれのモジュール(宣言)は以下の様な関係にあります。
パーティション? \ インターフェース? |
インターフェース |
実装 |
モジュール |
export module M; |
module M; |
パーティション |
export module M:Part; |
module M:Part; |
インターフェースオンリーなモジュール
ヘッダオンリーライブラリの様に、ファイル分割とかしないから1ファイルにまとめたい!という事もあるでしょう。単にパーティションを使わないだけでは、実装単位と(プライマリな)インターフェース単位の2つが必要になってしまうのでそれを達成できません。
その様な場合は、プライマリモジュールインターフェース単位のみを用いると似た様な事ができます。
export module Mymodule;
int f_impl(int n) {
return n * n;
}
export int f(int n) {
return f_impl(n);
}
import MyModule;
int main() {
int n = f(10);
}
ただし、プライマリモジュールインターフェース単位に書かれた宣言と定義は、エクスポートしているかに関わらずインポートした側の翻訳単位に公開されます。
エクスポートしていないものは名前探索において見つからないだけで(そのため利用は出来ないが)インポートした翻訳単位から見えています。これは思わぬバグの原因になるかもしれません。
import MyModule;
int f_impl(int n) {
return n + n;
}
int main() {
int n = f(10);
int m = f_impl(10);
}
モジュール実装単位を用いる場合、実装単位内にあるものはそのモジュール外部に一切公開されず、インポートによってはそのプライマリモジュールインターフェース単位のみを取り込むことになります。そのためこの様な問題は起こらないのです。
これらのことをあとで説明する言葉を用いて表現すると、モジュール単位内のエクスポートされていないものはそれをインポートした翻訳単位において、「到達可能だが可視ではない」状態に置かれる、と言えます。
それでは、それらの問題を解決した上でモジュールを1ファイルで定義する方法は無いのでしょうか??
もちろんそれは用意されています。詳細は下の方で説明しますが、 プライベートモジュールフラグメント を用いる事で、不要なものを隠蔽しながらモジュールを1ファイルで定義する事ができます。
export module Mymodule;
export int f(int n);
module : private;
int f_impl(int n) {
return n * n;
}
int f(int n) {
return f_impl(n);
}
import MyModule;
int f_impl(int n) {
return n + n;
}
int main() {
int n = f(10);
int m = f_impl(10);
}
module : private;
というマーカーによってプライベートモジュールフラグメントが開始され、それ以降の宣言・定義はモジュール外からは一切観測できません(以前にエクスポートされていない限り)。そのため、プライベートモジュールフラグメント内ではエクスポート宣言を行えません。
この様に、プライベートモジュールフラグメントを利用することで宣言と定義を適切に分離・隠蔽しつつ1ファイルでモジュールを構成することができます。
可視と到達可能(Visible and Reachable)
可視 と 到達可能 はモジュール内の宣言や定義の参照についての2つの重要な概念です。この言葉をぬいてモジュールを説明していくのは少し難しいのでここでそれらの説明しておきます。
- 可視(Visible)
- ある宣言は(いずれかの)名前探索において見つかる(候補に上がる)時、そのコンテキストにおいて可視となる
- 到達可能(Reachable)
- ある宣言は、(名前探索とは無関係に)その宣言の持つ意味論的な性質が利用可能である時、そのコンテキストにおいて到達可能となる
可視の方は新しく導入されたのではなく同じ様な意味合いで前からあった様です。こちらは問題無いでしょう。
到達可能の方は何をいっているのかわかりづらいですが、ほぼその名前の通りの意味です。
宣言の持つ意味論的な性質とはその宣言の持つC++コードとして規定された効果のことです。
たとえば、クラスの定義の持つ効果はクラスを完全型にしてそのメンバを利用可能にします。逆にいうと、クラスの定義が到達可能であるときそのクラスは完全型となりそのメンバが利用可能になります。
定義は必ず宣言を含むので、宣言が定義を兼ねている場合はその宣言に到達可能=定義に到達可能、ということになります。
可視と到達可能という2つの概念の間には次の様な関係性があります。
- 宣言が可視 → 宣言は到達可能:常に成り立つ
- 宣言が到達可能 → 宣言は可視:成り立たない事がある
例えば、モジュールをインポートするとそのインターフェース(プライマリーインターフェース単位)内でエクスポートされている宣言はインポートした側で可視となり、同時に到達可能になります。
しかし、そのインターフェース内でエクスポートされていない宣言は到達可能ではありますが、可視ではありません。
このために、上のインターフェースオンリーなモジュールの項で上げたような意図しないODR違反が起こってしまう可能性があります。
export module Mymodule2;
#include <iostream>
namespace Mymodule2 {
export void print(int n);
export void print(double v);
void print(const char* str) {
std::cout << str << std::endl;
}
}
module : private;
namespace MyModule2 {
void print(int n) {
std::cout << n << std::endl;
}
void print(double v) {
std::cout << v << std::endl;
}
}
export module MyModule:InterfacePart;
export import Mymodule2;
export double h(double v);
module MyModule:ImplPart;
import :InterfacePart;
double h(double v) {
MyModule2::print(v);
return v + v;
}
export module Mymodule;
export import :InterfacePart;
export struct S;
export class C;
export int f(int n);
int g() {
return 1;
}
class C {
int m = 10;
public:
C() = default;
operator int() const noexcept {
return m;
}
int set(int n);
};
module Mymodule;
struct S {
int n;
double v;
};
int f(int n) {
return n * n;
}
int C::set(int n) const noexcept {
int b = m;
m = n;
return b;
}
char get_a() {
return 'a';
}
import Mymodule;
int main() {
int n = f(2);
double d = h(0.1)
MyModule2::print(n);
S* ps = nullptr;
C c{};
int m = c;
m = c.set(20);
int l = g();
print("Hello World.");
S s = {10, 3.14};
char a = get_a();
}
int g() {
return 1;
}
可視ではないということは名前探索で見つからないということなので、可視でなければその宣言は使用不可です。そして、その宣言の持つ期待する効果を利用するためには、その宣言が到達可能である必要があります。
すなわち、モジュール内部の宣言をモジュール外で利用するための必要十分条件は、(import
を前提として)その宣言が可視かつ到達可能であることです。
ただしその定義の利用に関しては関数とそれ以外とで少し異なります。関数の場合は宣言だけが予めエクスポートされてさえいれば、再宣言で定義をしてもその宣言は外部リンケージを持つため、通常の翻訳単位越えのルールにより定義を呼び出すことができます。
それ以外のもの(例えばクラス定義)はそうではなく、名前のエクスポート宣言の後、定義が到達可能なところでなされないとモジュール外で定義を利用できません。
ODR(One-definition rule)の強化
これまではヘッダに定義されてインクルードされるもの(テンプレート、inline
変数・関数やクラスの定義など)のように、その定義の文字列とプログラムとしての意味が全く同一であるとき、宣言は複数の定義を持つことが許されていました。
基本的にそれは変わりませんが、名前付きモジュールに対しては適用されません。名前付きモジュールに属するものが複数の定義を持つことはできず、前の宣言が到達可能であるときに同じ宣言を行うことは(たとえ同一であっても)許されません。
このことは次のように規定されています。
- 名前付きモジュールに属するエンティティは複数の定義を持ってはならない。その場合、後の定義が現れるときに前の定義が到達可能でなければ診断は不要
- ある宣言が別のモジュールに属した到達可能な宣言を再宣言する場合、プログラムはill-formd
これらの規則より、次の結論が導かれます。
- 1つのエンティティに対する宣言・定義は同じモジュールに属しており、名前付きモジュール内では定義は唯一つでなければならない
- グローバルモジュールにおいては依然としてODRの例外規定が有効
幸い、多くのケースではこれに違反するとコンパイラによってエラーとされるので気づく事ができます。診断不要と書いてある条件に引っかかるのは、異なるモジュールで同じ名前・シグネチャの関数などが定義されていて、それを1つのプログラム内の異なる翻訳単位から使用した、ような場合でしょう。
export
ここまでモジュール外部に宣言を公開するんだよーくらいの意味でexport
を説明していましたが、その詳細を見て行きます。
モジュール内でexport
によって宣言を外部公開する構文は、export
宣言(エクスポート宣言)と呼びます。
export
宣言は伴う宣言の宣言としての効果(名前の導入等)を持ちます。逆に言うと、宣言できるものは基本的にexport
できます。
モジュールによるexport
宣言によって導入・再宣言された名前はそのモジュールによって エクスポートされている と言われます。エクスポートされた名前はそのモジュールをインポートしている翻訳単位内で(の名前探索において)可視となります。
なお、クラスのメンバ名はそのクラスの定義が到達可能である場合に可視となります。
export module Mymodule;
export struct S {
int n = 10;
};
export int f() {
return 0;
}
export int g();
int g() {
return -1;
}
export inline constexpr double PI = 3.14159;
export
宣言はモジュールインターフェース単位の本文内の名前空間スコープ(グローバル名前空間を含む)で現れることができ、必ず何らかの名前を導入しなければなりません。そして、その名前は外部リンケージを持つ必要があります。
export module Mymodule;
export static int f() {
return 0;
}
namespace {
export void f(int n) {}
}
export constexpr double PI = 3.14159;
export static_assert(true);
export using namespace std;
export int g() {
export int n = 10;
return n;
}
class S {
int n = 0;
public:
export void set(int m) { n = m; }
};
module Mymodule;
export int h();
export
宣言を出来るのはモジュールインターフェース単位内だけです。パーティションであろうとなかろうと、実装単位ではできません。
export
宣言はブロックでまとめて行うことができます。この場合のブロックはスコープを導入せず、内部にexport
が現れてはいけません。
export module Mymodule;
export {
struct S {
int n = 10;
};
int f() {
return 0;
}
inline constexpr double PI = 3.14159;
export int g() {
return -1;
}
}
export inline constexpr PI_2 = 2.0 * PI;
export
宣言は当然名前空間に対しても行うことができます。その場合、エクスポートされた名前空間内の宣言は暗黙的にエクスポートされることになります。従って、内部の宣言は全て外部リンケージを持つ必要があります。
すなわち、無名名前空間はexport
宣言に関わってはいけません。
また、エクスポートしていない名前空間内部にexport
宣言が現れることもできます。この時それを囲む名前空間は暗黙的にエクスポートされますが、エクスポートしていない宣言はエクスポートされません。
export module Mymodule;
export namespace Mymodule {
struct S {
int n = 10;
};
int f() {
return 0;
}
inline constexpr double PI = 3.14159;
}
namespace Detail {
export int g() {
return -1;
}
int g(int n) {
return -n;
}
}
export namespace NG {
using namespace std;
static int N = 10;
export int g() {
return -1;
}
static_assert(true);
namespace {
int h() {
return 1;
}
}
}
export namespace {
int h() {
return 1;
}
}
また、export
宣言はusing
(typedef
)宣言に対しても行えます。ただし、上記の例でも示したように名前を導入するものでなくてはならず、参照先の名前は外部リンケージを持っていなければなりません。
ただし、using
(typedef
)による型エイリアスの宣言では、参照先の名前が外部リンケージを持つ必要はありません。
export module Other;
export int f() {
return 0;
}
export int g() {
return -1;
}
namespace Other{
export struct S {
int n = 10;
};
}
export module Mymodule;
import Other;
export using ::f, ::g;
export using Other::S;
export using namespace std;
static int h1() {
return 1;
}
double h2(double v) {
return v;
}
struct T {
int n = 0;
double v = 0.0;
}
export using ::h1;
export using ::h2;
export using ::T;
export using C1 = ::T;
export typedef ::T C2;
また、変わったところではリンケージ指定もエクスポートできます。とはいえ、出来ることできないことはこれまでと変わりありません。
export module Mymodule;
export extern "C++" int f() {
return 0;
}
export extern "C" {
int g() {
return -1;
}
double PI = 3.14159;
export int h() {
return 1;
}
static int h() {
return 1;
}
}
export PI_2 = 2.0 * PI;
再宣言におけるexport
あらゆる宣言は再宣言を行うことができます。その際、export
があったりなかったりすることがあるでしょう。その時、どうなるかは主に2つのパターンに分かれます、
- 以前に
export
宣言によって宣言されている場合
- 以前の宣言は
export
宣言ではない場合
どちらの場合もその再宣言された名前のリンケージは、1番最初の宣言時のリンケージに従います。すなわち、export
宣言は名前のリンケージを変更できません。
export module Mymodule;
export int f();
int f() {
return 0;
}
export struct S;
struct S {
int n = 10;
};
struct T {
int n = 10;
double v = 0.0;
};
export struct T;
namespace {
int g() {
return -1;
}
}
int h() {
return 1;
}
export int g();
export int h();
モジュールリンケージ
モジュールに属しかつ外部リンケージを持つ宣言(定義)がexport
されていないとき、これまでの規則で考えるとその宣言を用意してやりさえすればモジュール外部から参照できるはずです。
しかし、これをやられるとモジュールの意味が薄くなってしまうので出来ないようになっています。
モジュールに属している宣言によって導入される名前が外部リンケージを持ち、エクスポートされていない場合、その名前は外部リンケージではなく モジュールリンケージ を持ちます。
モジュールリンケージを持つ名前は次の場所から参照され、参照することができます。
- 同じモジュール単位内の他のスコープにある名前
- 同じモジュール内の他のモジュール単位のスコープにある名前
名前というのはそのままの意味で、変数名や関数名、クラス名などのことです。
つまり、モジュール内ではexport
宣言のみが外部リンケージを与え、任意の翻訳単位を超えて参照することができるのは外部リンケージを持つ名前だけです。
モジュールリンケージを持つ名前は同じモジュール内でしか参照できません。
なお、これらのことは宣言や定義が到達可能であるかどうかとは関係がありません。
import
export
ときたら次はimport
を見て行きます。
import
に続いてモジュール名を指定することでそのモジュールでエクスポートされている宣言を取り込む構文の事をimport
宣言(インポート宣言)と言います。
モジュール単位内ではimport
宣言は書ける場所が決まっていて、基本的にはそのモジュール単位の先頭(すなわちファイルの先頭)で、他のあらゆる宣言よりも前に来なければなりません。ただし、モジュール宣言よりは後ろになります。
非モジュールの翻訳単位ではそのような規定はなく、import
はどこにでも書くことができます。ただし、あらゆる宣言の内部に書くことはできません。
export module Mymodule;
import A;
export import B;
export int f();
import C;
struct S{};
import A;
int main() {
}
import B;
import A;
export module NG;
import B;
export int f();
import C;
namespace N {
import A;
}
struct S {
import B;
};
int main() {
import C;
}
import
宣言がインポートするものは、エクスポートされている宣言の塊とかの抽象的なものではなくて、いくつかの翻訳単位そのものをインポートします。
インポートされる翻訳単位は次のものの集まりです
- 指定されたモジュールのプライマリーモジュールインターフェース単位
- (下の条件より)必然的にモジュールの全てのインターフェース単位
- インポートされる翻訳単位内で再エクスポート(
export import
、後述)されている翻訳単位
- そのような翻訳単位は再エクスポートしている翻訳単位からエクスポートされる
- 指定されたのがモジュールパーティションなら、そのパーティション
- ヘッダーユニット(後述)
- 同じモジュールの他のモジュール単位をインポートしている場合、そこでインポートされているすべての翻訳単位(
export
の有無によらず)
- 同じモジュール内ではインポートした翻訳単位内のすべての宣言が可視となる
そして、そのようにインポートされた翻訳単位内でエクスポートされている宣言(名前)は、インポート先の翻訳単位で可視となり、エクスポートされていない宣言は到達可能となります。インポート宣言の持つ効果は実質これだけです。
import
宣言は#include
とは異なり宣言や定義をインポート先に導入しません。インポートされると言う事は、単に宣言が可視もしくは到達可能となるかどうかだけに影響します。
あるインターフェース単位内で単にインポートされただけの(再エクスポートされていない)モジュール内の宣言(必然的にそのインターフェース内宣言)は、そのインターフェース単位をインポートした先で可視にはなりませんが到達可能となります。
これを「推移的なインポート」と呼び言い直すと、ある翻訳単位で推移的にインポートされた翻訳単位内の宣言は可視ではないが到達可能となる、という事になります。
export module Mymodule:Part;
export import A;
import B;
module Mymodule:impl;
import :Part;
export module Mymodule;
export import :Part;
import C;
export import D;
module Mymodule;
import Mymodule;
int main() {
}
同モジュール内ではインポートするとそこでインポートされている翻訳単位をすべてインポートします。そのため、予想外にインポートする翻訳単位が膨れ上がることがあり得ます。上記だとモジュール実装単位(Mymodule_impl.cpp
)がそうであるように、実装単位は特に沢山の翻訳単位をインポートすることになりがちです。
なお、パーティションでないモジュール実装単位では自分自身のモジュールを指定するインポート宣言を行えません。
その代わり、そのようなモジュール実装単位は対応するプライマリモジュールインターフェース単位を暗黙的にインポートしています。
export module Mymodule;
export int f();
module Mymodule;
import Mymodule;
int f() {
return 0;
}
再エクスポート
ここまでにもちらちら出て来ていますが、インポート宣言はエクスポートすることができ、それを再エクスポートと呼びます。再エクスポート宣言は、import
宣言の前にexport
を付けます。
そのように再エクスポートされた翻訳単位は、import
宣言の効果を持ちつつ、再エクスポートしている翻訳単位から指定した翻訳単位をエクスポートします。
ただし、モジュール実装単位(パーティション含む)はエクスポートできません。
export module Mymodule:Part;
export import A;
module Mymodule:impl;
export module Mymodule;
export import :Part;
export import B;
export import :impl;
再インポート(複数回のインポート)
インポート宣言は同じモジュール名に対して複数回行うことができ、それは特に禁止されていません。
import A;
import B;
import A;
int main() {
}
同じモジュールを何回インポートしても意味はなく、特に問題も起きません。
なぜなら、先に述べたようにインポート宣言は指定したモジュール内の宣言が可視・到達可能となるかどうかのみを変更します。
再度のインポートをしたとしても、すでに可視な宣言が可視に、到達可能な宣言が到達可能になるだけです。ODR違反等を起こしません。
インターフェース依存関係
ある翻訳単位は次のいずれかの場合にあるモジュール単位U
にインターフェース依存関係を持ちます。
U
を指定するモジュールインポート宣言がある時
U
と同名のモジュール実装単位である時(パーティションではなく)
U
にインターフェース依存関係を持つモジュール単位にインターフェース依存関係を持つ時
基本的には1つ目の条件によりインターフェース依存関係が発生し、それは再帰的に推移します(3つ目の条件)。
そして、あるモジュール単位は自分自身にインターフェース依存関係を持ってはいけません。
export module M1;
import M2;
export module M2;
import M3;
export module M3;
import M1;
前述のように、同モジュール内でのモジュール単位のインポートは再エクスポートしていなくてもそのモジュールのインポートが発生するため、予想外のインターフェース依存関係が発生することがあります。その時、この様な循環的なインターフェース依存が発生しない様に注意しなければなりません。
幸いな事に、これはコンパイラによって検出されコンパイルエラーとなるはずです。
また、パーティションではない実装単位は多くのインターフェース依存関係を持ち得ますが、自身をエクスポート/インポートできないために循環的な依存関係は発生しないでしょう。
パーティションではないモジュール実装単位はインターフェース依存関係を断ち切るのに有効利用できます。
変り者のモジュール達
ここまではほぼごく普通のモジュールだけを紹介してきましたが、世の中には少し変なモジュールが存在しています・・・
プライベートモジュールフラグメント
これは上の方でも出てきましたが、プライベートモジュールフラグメントはモジュールを単一ファイルで構成するための仕組みです。
モジュールをインポートするとインポートされるのはそのモジュールのプライマリモジュールインターフェース単位です。すなわち、プライマリモジュールインターフェース単位さえあればモジュールは構成できるわけです。
しかし、そのようなインターフェース単位に書いたエクスポートされていない宣言・定義はインポート先の翻訳単位で到達可能となり、意図しないODR違反を起こす可能性があります。
サンプルコード再掲
export module Mymodule;
int f_impl(int n) {
return n * n;
}
export int f(int n) {
return f_impl(n);
}
import MyModule;
int f_impl(int n) {
return n + n;
}
int main() {
int n = f(10);
int m = f_impl(10);
}
そのため、実装や公開する必要のない宣言を適切に隠蔽する仕組みが必要となり、プライベートモジュールフラグメントはそのための仕組みです。
プライベートモジュールフラグメントはプライマリモジュールインターフェース単位でのみ使用可能で、module : privete;
というマーカーから開始されます。
プライベートモジュールフラグメントの内部の宣言は他の翻訳単位から到達可能ではなく、したがって可視でもありません。(プライベートモジュールフラグメントの宣言は同じプライベートモジュールフラグメント内からか、テンプレートのインスタンス化時に一定条件の下で到達可能になります。)
そして、プライベートモジュールフラグメントを持つ翻訳単位は、そのモジュールで唯一の翻訳単位となっていなければなりません。単一ファイルでモジュールを構成する仕組みですが、利用することによって単一ファイルであることを強いることにもなります。
ちなみに、この時に複数ファイルでモジュールを構成したとしても診断はされません(つまりエラーになりませんが、規格違反状態です)。
export module Mymodule;
export int f(int n);
module : private;
int f_impl(int n) {
return n * n;
}
int f(int n) {
return f_impl(n);
}
import MyModule;
int f_impl(int n) {
return n + n;
}
int main() {
int n = f(10);
int m = f_impl(10);
}
その性質上明らかですが、プライベートモジュールフラグメント内部でexport
宣言を行うことは出来ません。
しかし、import
宣言を行うことは出来ます。その場合は、プライベートモジュールフラグメントの開始宣言の直後で、他のあらゆる宣言よりも前に行います。
プライベートモジュールフラグメント内部でインポートした翻訳単位内のあらゆる宣言は、モジュール外部から可視でも到達可能でもありません。
export module Mymodule;
export import A;
import B;
module : private;
import C;
import MyModule;
int main() {
}
プライベートモジュールフラグメントを利用する事で、推移的なインポートによる到達可能な宣言の漏出を防止する事ができます。
プライベートモジュールフラグメントは、module : privete;
という行を境目としてプライマリインターフェース単位と実装単位を1ファイル内に書いている、と見ることもできます。
グローバルモジュール
グローバルモジュールは通常の名前付きモジュール(モジュール宣言によって導入されるモジュール)ではない全てのコードとグローバルモジュールフラグメント(後述)が属しているモジュールです。
モジュール宣言の無い翻訳単位に書かれている宣言は全てグローバルモジュールに属することになります。
丁度、名前空間に包まれていないものがグローバル名前空間内にあるように、モジュール内部に無いものはグローバルモジュールに属する形になります。
そのようなグローバルモジュールには名前はなく、インターフェース単位も持たず、導入するためのモジュール宣言もありません。
すなわち、明示的にグローバルモジュールを定義する構文はなく、インポートすることもできません。
グローバルモジュールでは従来のヘッダ利用のためにモジュール内部よりもODRが緩くなっていて(というか従来通り)、定義の文字列と意味が同一ならば複数の定義が存在する事が許可されています。
export module Mymodule;
import Mumodule;
int main() {
}
グローバルな確保・解放関数(new/delete
)はグローバルモジュールに属しており、main
関数は必ずグローバルモジュールに属していなければなりません。
また、ここまでに出てきたものの中でも次の2つは実はグローバルモジュールに属しています。
- 外部リンケージを持つ名前空間の定義
- リンケージ指定内部に現れる宣言
export module Mymodule;
namespace Mymodule {
export int f() {
return 1;
}
inline constexpr double PI = 3.14159;
}
export extern "C++" int g() {
return 0;
}
export extern "C" {
int h() {
return -1;
}
double PI = 3.14159;
}
グローバルモジュールは複数のファイルに渡ってその本文を持ち得る巨大な1つのモジュールです。そして、モジュールに対して適用される規則はグローバルモジュールにも適用されます。
この記事中では(規格書中でも)、グローバルでない通常のモジュールだけを指定する必要がある時は「名前付きモジュール」と言う言葉を使いグローバルモジュールと区別します。
グローバルモジュールフラグメント
グローバルモジュールフラグメントは、モジュール内部でのヘッダーファイルの#include
をモジュール内で完結させるための宣言領域です。
グローバルモジュールフラグメントの宣言は全てグローバルモジュールに属します。
モジュールの時代が到来したとはいえ、まだまだ世の中のライブラリはヘッダベースのものがほとんどです。それは標準ライブラリも例外ではありません。そのため、モジュール内部においてもそれらのヘッダをインクルードして利用する必要があります。
#include
はコピペのため、インクルード先にヘッダの内容が展開されます。モジュール内部においてそうしたインクルードを行うとモジュールおよびそのインターフェースの肥大化(使用しないものもコンパイルされ、残る)を招くとともに、インターフェースでインクルードするとそのモジュールをインポートした先でヘッダ内の宣言が到達可能となってしまい、意図しないODR違反を引きおこす可能性が著しく増加してしまいます。
以下のコードは後述のヘッダーユニットは無いものとした例です。
export module Mymodule;
#include <iostream>
import Mumodule;
#include <iostream>
int main() {
std::cout << "Hello Compile Error!" << std::endl;
}
この例のようにモジュールとその利用側で同じヘッダをインクルードすると、ODR違反が大量に起こるでしょう。幸いほとんどはコンパイルエラーとされるはずです。
この時、内部リンケージを持つものは翻訳単位毎に生成されているため、それらに依存する処理は思わぬ動作をする可能性があります。運良く何も起こらなくても未定義動作の世界です・・・
この様な状況を回避し旧来のヘッダを利用するための仕組みが、グローバルモジュールフラグメントになります。
その名の通りプライベートモジュールフラグメントと似たもので、モジュール単位内部にグローバルモジュールフラグメントという領域を作ります。ファイル先頭でモジュール宣言の前にmodule;
という宣言をすることでグローバルモジュールフラグメントが開始されます。
グローバルモジュールフラグメント内の宣言は全てグローバルモジュールに属し、そのモジュールには属しません。
module;
#include <iostream>
export module Mymodule;
import Mumodule;
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
}
グローバルモジュールフラグメントにはコンパイルの開始段階でプリプロセッサ以外のものが含まれてはいけません。また、グローバルモジュールフラグメントの終了を意味するモジュール宣言はプリプロセッサによって生成されたものであってはなりません。
module;
#include <iostream>
int f() {
return 0;
}
#define END_EXP export
#define END_MOD module
END_EXP END_MOD Mymodule;
そして、グローバルモジュールフラグメント内の宣言のうち、その後のモジュール内から参照されないものは 破棄 されます。モジュール内から参照され、さらにそこから参照されている宣言は破棄されませんが、単にグローバルモジュールフラグメント内から参照されているだけでは破棄されてしまいます。
破棄された宣言はモジュール外部から可視でも到達可能でもなく、おそらくコンパイルされません。
宣言の破棄によってヘッダファイルを利用しながらモジュールの肥大化を抑えることができます。
module;
#include <tuple>
export module Mymodule;
export using int_tuple = std::tuple<int, int, int>;
export auto f() -> std::tuple<char, short, double>;
import Mymodule;
#include <tuple>
int main() {
int_tuple t{1, 2, 3};
}
なお、テンプレートのテンプレート引数に関わる形でグローバルモジュールフラグメント内の宣言が使用されている場合、その宣言は参照されているとみなされず破棄されることがあります。
module;
#include <tuple>
export module Mymodule;
export template<typename T>
using triple = std::tuple<T, T, T>;
export template<typename T>
triple<T> f(T t) {
return {t, t, t};
}
triple<double> d_triple = f(3.14);
import Mymodule;
int main() {
triple<int> t1{};
triple<int> t2 = f(1);
triple<double> t3{};
triple<double> t4 = f(2.72);
}
テンプレートパラメータが確定するまでは、そのテンプレートは参照されているとみなされません。これは引っ掛かりやすい罠なので注意する必要があります。
ヘッダーユニット(ヘッダ単位)
ヘッダーユニットは従来のヘッダファイルをモジュールとしてインポートする仕組みです。ヘッダーユニットはそれが一つのモジュール単位であるかのようにふるまい、基本的には1つのモジュールとして扱われます。
ヘッダーユニット内の全ての宣言は抽出されインターフェースとしてエクスポートされ(いわば、プライマリなインターフェース単位を自動生成する)、それらの宣言はグローバルモジュールに属します。
その際、外部リンケージを持たないものがあっても大丈夫ですが、そう言うものはヘッダーユニット外から参照してはいけません。
つまりは結局、ヘッダユニットからエクスポートされるのは外部リンケージを持つものだけという事です。
ヘッダーユニットを利用するには、import
宣言にヘッダ名を指定してやります。また、インポート可能なヘッダであるとコンパイラに認識された場合、そのヘッダに対する#include
はヘッダーユニットのインポート宣言に置き換えられる可能性があります(これは実装に任されている)。
以下サンプルコードはSTLのヘッダがインポート可能な世界です
export module Mymodule;
import <tuple>;
#include <type_traits>
export import <iostream>;
import Mymodule;
#include <tuple>
#include <type_traits>
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
}
同じ名前を指定するヘッダーユニットのインポート宣言は、常に同じヘッダーユニットをインポートします。つまり、インポートする翻訳単位が異なっていたとしてもヘッダーユニットは1つ(1度)しか生成されません。
そして、通常のインポート宣言と同様、同じヘッダーユニットの複数回のインポートをしてもODR違反等の問題は起きません。
この様に、ヘッダのインクルードの問題を解決できるので、利用可能ならばグローバルモジュールフラグメントよりもヘッダーユニットを使った方が良いでしょう。
ちなみに、ヘッダーユニットはマクロをインポートする唯一の方法です。インポートされたマクロはヘッダーユニットのインポート宣言の直後で定義されています。
ただし、ヘッダーユニットの再エクスポート時はマクロはエクスポートされません。
export module Mymodule;
export import <cmath>;
import <tuple>;
double huge_value = HUGE_VAL;
double nan = FP_NAN;
import Mumodule;
int main() {
double r2 = std::sqrt(2.0);
double huge_value = HUGE_VAL;
double nan = FP_NAN;
}
import <cmath>;
void m() {
double huge_value = HUGE_VAL;
double nan = FP_NAN;
}
詳細には、ヘッダーユニットはヘッダーを一つのソースファイルとして翻訳フェーズ7(テンプレートの実体化直前)までコンパイルした1つの翻訳単位です。
マクロは、そのコンパイル時の翻訳フェーズ4(プリプロセッサの実行)終了時の段階でヘッダーユニット内に定義されているマクロがエクスポート(ヘッダーユニットインポート宣言の直後で再定義)されます。
外部から見るとヘッダーユニット内では、プリプロセスは終了していますがテンプレートは実体化されていません。そして、すべての宣言は抽出されエクスポートされているので、外部リンケージを持つもの(非テンプレート)は翻訳単位超えにより呼び出す事ができます。そして、テンプレートは最終的なコンパイル時に実体化され定義されます。
翻訳フェーズについて → 翻訳フェーズ - cppreference.com
マクロがエクスポートされインポート先で再定義される、1つのモジュールとしてコンパイルされる、という性質から明らかですが、ヘッダユニットのインポートは全てプリプロセス時(翻訳フェーズ4)に処理されます(なので厳密にはヘッダーユニットのインポートはインポート宣言ではないのです)。
また、インポート可能なヘッダは処理系定義とされているので何でもかんでもインポート出来るわけでは無いようですが、それらヘッダをどう識別するか、もしくは特定の名前のヘッダのみをインポート可能とするか、に関しても処理系定義であり、意図としてはインポート可能なヘッダの扱いについての研究が進められることを狙っているようです。
ただし、STLのヘッダのうちC言語由来でないヘッダは全てインポート可能ヘッダとなります。C言語由来なヘッダとは<cmath>
や<cstdlib>
などのcから始まるヘッダのことです。
グローバルモジュールフラグメント及びヘッダーユニットの推移的なインポート
あるモジュールインターフェース内でエクスポートせずにインポートしているものは、そのモジュールインターフェースをインポートする先に推移的にインポートされます。推移的にインポートされた宣言は可視にはなりません。
ただし、それが名前付きモジュールならそのインターフェース内宣言は到達可能になります。
そして、そのインターフェース内のグローバルモジュールフラグメントに破棄されず残った宣言、及び推移的にインポートされたものがヘッダーユニットである場合、それらの宣言が到達可能となるかは未規定であり実装は到達可能とする事が許されています。
ただ、それら宣言は可視である時には到達可能になります(これは規定されている)。
仮に到達可能とされた場合、ヘッダーユニットは必ず再インポートになりなんら問題は起きません。グローバルモジュールフラグメント内の宣言はODRの例外規定に沿っていればグローバルモジュールに属しており、定義の文字列と意味が同一ならば複数の定義を持つ事ができるためODR違反は起こりません。。
inline bool ok() {
return true;
}
bool ng() {
return false;
}
const double e = 2.72;
module;
#include "header.hpp"
export module Mymodule;
import <cmath>;
void use() {
bool b1 = ok();
bool b2 = ng();
auto e2 = 2.0 * e;
}
import Mymodule;
#include "header.hpp"
const double r2 = std::sqrt(2.0);
import <cmath>
int main(){
bool b1 = ok();
bool b2 = ng();
double r5 = std::sqrt(5.0);
}
テンプレートやクラスのメンバでなければ、関数や変数はinline
でないとODRの例外には当てはまりません。
上級編
onihusube.hatenablog.com
参考文献
この記事のMarkdownソース