[C++]モジュール理論 基礎編

※この記事はC++20を相談しながら調べる会 #2の成果です。

※この内容はC++20より有効なものです。C++20正式策定までの間に内容が変化する可能性があります。

より読みやすい解説がすでにあるのでそちらにも目を通されるといいと思われます。→ モジュール - cpprefjp

モジュールのインターフェースと実装

あるファイルをモジュールである!と宣言するには、モジュール宣言module モジュール名;) をファイル先頭で行います。モジュール宣言を行うことでそのファイルは1つの モジュール単位、かつ1つの翻訳単位となります。

通常のC++コードがヘッダファイル(宣言)とソースファイル(定義)に分割できる様に、モジュールにおいても宣言と定義を別のファイル(別々のモジュール単位)に分けて実装する事ができます。その時、それらのモジュール単位はそれぞれ モジュールインターフェース単位モジュール実装単位 と呼ばれます。

以下、コメントでファイル名が書かれている部分は、そこから別のファイルにあるものとして書いています。

///mymodule.cpp
//(プライマリ)モジュールインターフェース単位の宣言
export module MyModule;

//export宣言、宣言をモジュール外部で使用可能にする
export int f(int n);

///mymodule_impl.cpp
//モジュール実装単位の宣言
module MyModule;
//ここではプライマリモジュールインターフェース単位内のものが見えている

//exportされたf()の再宣言・定義
int f(int n) {
  return n;
}

///main.cpp(非モジュール)
//モジュールMyModuleのインポート宣言、exportされているものを取り込む
import MyModule;

int main() {
  int n = f(10);  //ok, f()は使用可能
}

このように、export moduleの形のモジュール宣言によってインターフェース単位を宣言します。このインターフェース単位は プライマリモジュールインターフェース単位 と呼ばれ、モジュールには必ず 唯一つだけ 含まれていなければなりません。
対して、モジュール実装単位はいくつあっても構いません。

その名の通り、(モジュール)インターフェース単位にはモジュールのインターフェース、すなわち外部に公開する宣言を、実装単位にはそれら宣言の実装をそれぞれ書く、という事を想定しています。

どちらの宣言においてもmoduleの後に来るのがモジュール名で、一部の予約語stdから始まる名前を除いて好きな名前を付けることができます。

モジュールのインターフェースでは、モジュールの外側に提供したい宣言をエクスポート宣言(export 宣言;)によって外部へ公開します。
そして、モジュールを利用する側(翻訳単位)ではインポート宣言(import モジュール名;)によってモジュールからエクスポートされている宣言を取り込みます。

モジュール実装単位とインターフェース単位は同じ名前を持ちます。すると、実装単位においてインターフェース単位をインポートしようとすると自分自身をインポートすることになってしまいます。
いかなるモジュールも自分自身をインポートすることはできません。そのため、モジュール実装単位は対応するプライマリモジュールインターフェース単位を暗黙的にインポートします。
そして、これらのことからモジュール実装単位そのものはエクスポートもインポートもできない事がわかります。

なお、ヘッダーやソースファイルがそうである様に、モジュールファイルの拡張子は規定されていません。コンパイラがそれと認識すれば、拡張子は自由です。
現在の所、MSVCでは.ixx、clangでは.cppmが使われているようです(両コンパイラ共、それ以外のファイルも利用可能)。

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

そしてさらに、モジュール内部のファイルを複数のファイルに分けて実装する事ができます。
これは例えば、自作のライブラリを1つのモジュールとして提供したいが、その内部の実装においては整理のためにも複数のファイルに分けたい、という際に役立ちます。

その様にモジュール内部で分割したファイル(これもまたモジュール単位)は モジュールパーテイション と呼ばれます。
そして、モジュールパーティションもまたインターフェースと実装に分割する事ができ、分割後のモジュール単位はそれぞれ モジュールインターフェースパーティションモジュール実装パーティション と呼ばれ、それぞれモジュールインターフェース単位とモジュール実装単位でもあります。

///mymodule_part.cpp
//モジュールインターフェースパーティションの宣言
export module MyModule:InterfacePart;

export double g(double v);

///mymodule_part_impl.cpp
//モジュール実装パーティションの宣言
module MyModule:ImplPart;
import :InterfacePart;  //import宣言、モジュールパーティションMyModule:InterfacePartを取り込む

//実装パーティションでは、export宣言を行えない

double g_impl(double v) {
  return v + v;
}

//パーティション:InterfacePart内のgの再宣言・定義
double g(double v) {
  return g_impl(v);
}

///mymodule.cpp
//(プライマリー)モジュールインターフェース単位の宣言
export module MyModule;
export import :InterfacePart;  //インターフェースパーティション:InterfacePartの再エクスポート、必須

export int f(int n);

///mymodule_impl.cpp
//モジュール実装単位の宣言
module MyModule;

int f(int n) {
  return n;
}

///main.cpp
import MyModule; 

int main() {
  //共に使用可能
  int n = f(10);
  double v = g(1.0);

  //ng!呼び出し不可能
  int m = g_impl(10);
}

モジュールパーティションの宣言はモジュール宣言とほぼ同様ですが、モジュール名の後に:パーティション名を指定します。:がモジュールパーティションである証です。
通常のインターフェース単位は実質プライマリモジュールインターフェース単位の一つしか作ることができませんが、インターフェースパーティションならばいくつでも作ることができます。

ただし、同じモジュール内で複数のモジュールパーティションが同じ名前を持つことは出来ません。従って、インターフェースパーティションと実装パーティションでは異なる名前を付ける必要があります(このため、あるパーティションは使用する別のパーティションを明示的にインポートする必要があります)。

このようなモジュールのパーティションへの分割はモジュールの外からは観測することができません。したがって、モジュールパーティションはモジュール外でインポート出来ません。
そのため、インターフェースパーティション内でエクスポートした宣言は全て、プライマリーモジュールインターフェース単位から再エクスポートされなければなりません(これはしなければならないが、していなくてもエラーにはならない)。

また、モジュールパーティションをインポートするときは、(:も含めた)そのパーティション名だけを指定する必要があります。モジュール名は要らず、むしろコンパイルエラーになります。

上記の例では以下のように書いてはいけません。

///mymodule_part.cpp
//モジュールインターフェースパーティションの宣言
export module MyModule:InterfacePart;
import MyModule:ImplPart;  //compile error!

///mymodule.cpp
//(プライマリ)モジュールインターフェース単位の宣言
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つが必要になってしまうのでそれを達成できません。

その様な場合は、プライマリモジュールインターフェース単位のみを用いると似た様な事ができます。

///mymodule.cpp
//Mymoduleのプライマリモジュールインターフェース単位の宣言、かつMymoduleの唯一のモジュール単位
export module Mymodule;

int f_impl(int n) {
  return n * n;
}

export int f(int n) {
  return f_impl(n);
}

///main.cpp
import MyModule;

int main() {
  int n = f(10);
}

ただし、プライマリモジュールインターフェース単位に書かれた宣言と定義は、エクスポートしているかに関わらずインポートした側の翻訳単位に公開されます。 エクスポートしていないものは名前探索において見つからないだけで(そのため利用は出来ないが)インポートした翻訳単位から見えています。これは思わぬバグの原因になるかもしれません。

///main.cpp
import MyModule;

//ODR違反!モジュールの中身を知らずに定義してしまった
int f_impl(int n) {
  return n + n;
}

int main() {
  int n = f(10);
  int m = f_impl(10);  //おそらくコンパイルエラー
}

モジュール実装単位を用いる場合、実装単位内にあるものはそのモジュール外部に一切公開されず、インポートによってはそのプライマリモジュールインターフェース単位のみを取り込むことになります。そのためこの様な問題は起こらないのです。

これらのことをあとで説明する言葉を用いて表現すると、モジュール単位内のエクスポートされていないものはそれをインポートした翻訳単位において、「到達可能だが可視ではない」状態に置かれる、と言えます。

それでは、それらの問題を解決した上でモジュールを1ファイルで定義する方法は無いのでしょうか??
もちろんそれは用意されています。詳細は下の方で説明しますが、 プライベートモジュールフラグメント を用いる事で、不要なものを隠蔽しながらモジュールを1ファイルで定義する事ができます。

///mymodule.cpp
//Mymoduleのプライマリモジュールインターフェース単位の宣言、かつMymoduleの唯一のモジュール単位
export module Mymodule;

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

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

//f_impl()の宣言・定義はモジュール外から見えない(到達可能でない)
int f_impl(int n) {
  return n * n;
}

//エクスポートされている宣言の再宣言は外部リンケージを持っている
int f(int n) {
  return f_impl(n);
}

///main.cpp
import MyModule;

//ok、以前の定義は見えてない(到達可能でない)
int f_impl(int n) {
  return n + n;
}

int main() {
  int n = f(10);      //ok
  int m = f_impl(10); //ok, m == 20
}

module : private;というマーカーによってプライベートモジュールフラグメントが開始され、それ以降の宣言・定義はモジュール外からは一切観測できません(以前にエクスポートされていない限り)。そのため、プライベートモジュールフラグメント内ではエクスポート宣言を行えません。

この様に、プライベートモジュールフラグメントを利用することで宣言と定義を適切に分離・隠蔽しつつ1ファイルでモジュールを構成することができます。

可視と到達可能(Visible and Reachable)

可視到達可能 はモジュール内の宣言や定義の参照についての2つの重要な概念です。この言葉をぬいてモジュールを説明していくのは少し難しいのでここでそれらの説明しておきます。

  • 可視(Visible)
    • ある宣言は(いずれかの)名前探索において見つかる(候補に上がる)時、そのコンテキストにおいて可視となる
  • 到達可能(Reachable)
    • ある宣言は、(名前探索とは無関係に)その宣言の持つ意味論的な性質が利用可能である時、そのコンテキストにおいて到達可能となる

可視の方は新しく導入されたのではなく同じ様な意味合いで前からあった様です。こちらは問題無いでしょう。

到達可能の方は何をいっているのかわかりづらいですが、ほぼその名前の通りの意味です。
宣言の持つ意味論的な性質とはその宣言の持つC++コードとして規定された効果のことです。
たとえば、クラスの定義の持つ効果はクラスを完全型にしてそのメンバを利用可能にします。逆にいうと、クラスの定義が到達可能であるときそのクラスは完全型となりそのメンバが利用可能になります。

定義は必ず宣言を含むので、宣言が定義を兼ねている場合はその宣言に到達可能=定義に到達可能、ということになります。

可視と到達可能という2つの概念の間には次の様な関係性があります。

  • 宣言が可視 → 宣言は到達可能:常に成り立つ
  • 宣言が到達可能 → 宣言は可視:成り立たない事がある

例えば、モジュールをインポートするとそのインターフェース(プライマリーインターフェース単位)内でエクスポートされている宣言はインポートした側で可視となり、同時に到達可能になります。
しかし、そのインターフェース内でエクスポートされていない宣言は到達可能ではありますが、可視ではありません。
このために、上のインターフェースオンリーなモジュールの項で上げたような意図しないODR違反が起こってしまう可能性があります。

///mymodule2.cpp
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;
  }
}


///mymodule_part.cpp(インターフェースパーティション)
export module MyModule:InterfacePart;
//他モジュールの再エクスポート
export import Mymodule2;

export double h(double v);

///mymodule_part_impl.cpp(実装パーティション)
module MyModule:ImplPart;
import :InterfacePart;

double h(double v) {
  MyModule2::print(v); //ok、可視であり到達可能

  return v + v;
}


///mymodule.cpp(プライマリモジュールインターフェース単位)
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);
};


///mymodule_impl.cpp(実装単位)
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';
}


///main.cpp
import Mymodule;

int main() {
  int n = f(2);         //ok、可視であり到達可能
  double d = h(0.1)     //ok、可視であり到達可能
  MyModule2::print(n);  //ok、可視であり到達可能
  S* ps = nullptr;      //ok、型名`S`は可視であり到達可能
  C c{};                //ok、クラス`C`の定義は到達可能
  int m = c;            //ok、`C`の定義は到達可能なのでその全メンバの宣言も可視であり到達可能
  m = c.set(20);        //ok、`C`の宣言はエクスポートされており外部リンケージを持ち、そのメンバも外部リンケージを持つ
                        //そして`C`の定義は到達可能なので全メンバの宣言は可視
                        //実装単位内部の定義(再宣言)は外部リンケージを持つため、可視な宣言から呼び出しが可能

  int l = g();          //ng、到達可能だが可視ではない
  print("Hello World.");//ng、到達可能だが可視ではない

  S s = {10, 3.14};     //ng、`S`の型名は可視だが定義は到達可能ではない

  char a = get_a();     //ng、可視でも無く到達可能でもない
}

//到達可能な定義と同名のものに対して定義をしてしまうとODR違反!
int g() {
  return 1;
}

可視ではないということは名前探索で見つからないということなので、可視でなければその宣言は使用不可です。そして、その宣言の持つ期待する効果を利用するためには、その宣言が到達可能である必要があります。
すなわち、モジュール内部の宣言をモジュール外で利用するための必要十分条件は、(importを前提として)その宣言が可視かつ到達可能であることです。

ただしその定義の利用に関しては関数とそれ以外とで少し異なります。関数の場合は宣言だけが予めエクスポートされてさえいれば、再宣言で定義をしてもその宣言は外部リンケージを持つため、通常の翻訳単位越えのルールにより定義を呼び出すことができます。
それ以外のもの(例えばクラス定義)はそうではなく、名前のエクスポート宣言の後、定義が到達可能なところでなされないとモジュール外で定義を利用できません。

ODR(One-definition rule)の強化

これまではヘッダに定義されてインクルードされるもの(テンプレート、inline変数・関数やクラスの定義など)のように、その定義の文字列とプログラムとしての意味が全く同一であるとき、宣言は複数の定義を持つことが許されていました。

基本的にそれは変わりませんが、名前付きモジュールに対しては適用されません。名前付きモジュールに属するものが複数の定義を持つことはできず、前の宣言が到達可能であるときに同じ宣言を行うことは(たとえ同一であっても)許されません。

このことは次のように規定されています。

  • 名前付きモジュールに属するエンティティは複数の定義を持ってはならない。その場合、後の定義が現れるときに前の定義が到達可能でなければ診断は不要
  • ある宣言が別のモジュールに属した到達可能な宣言を再宣言する場合、プログラムはill-formd

これらの規則より、次の結論が導かれます。

  • 1つのエンティティに対する宣言・定義は同じモジュールに属しており、名前付きモジュール内では定義は唯一つでなければならない
    • グローバルモジュールにおいては依然としてODRの例外規定が有効

幸い、多くのケースではこれに違反するとコンパイラによってエラーとされるので気づく事ができます。診断不要と書いてある条件に引っかかるのは、異なるモジュールで同じ名前・シグネチャの関数などが定義されていて、それを1つのプログラム内の異なる翻訳単位から使用した、ような場合でしょう。

export

ここまでモジュール外部に宣言を公開するんだよーくらいの意味でexportを説明していましたが、その詳細を見て行きます。

モジュール内でexportによって宣言を外部公開する構文は、export宣言(エクスポート宣言)と呼びます。 export宣言は伴う宣言の宣言としての効果(名前の導入等)を持ちます。逆に言うと、宣言できるものは基本的にexportできます。

モジュールによるexport宣言によって導入・再宣言された名前はそのモジュールによって エクスポートされている と言われます。エクスポートされた名前はそのモジュールをインポートしている翻訳単位内で(の名前探索において)可視となります。
なお、クラスのメンバ名はそのクラスの定義が到達可能である場合に可視となります。

///Mymodule.cpp
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宣言はモジュールインターフェース単位の本文内の名前空間スコープ(グローバル名前空間を含む)で現れることができ、必ず何らかの名前を導入しなければなりません。そして、その名前は外部リンケージを持つ必要があります。

///Mymodule.cpp
export module Mymodule;

//以下全てNG集

//内部リンケージ
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; }
};


///Mymodule_impl.cpp
module Mymodule;

//モジュール実装単位にあらわれている
export int h();

export宣言を出来るのはモジュールインターフェース単位内だけです。パーティションであろうとなかろうと、実装単位ではできません。

export宣言はブロックでまとめて行うことができます。この場合のブロックはスコープを導入せず、内部にexportが現れてはいけません。

///Mymodule.cpp
export module Mymodule;

//ブロックのエクスポート宣言、内部のものは暗黙的にエクスポートされる
export {
  struct S {
    int n = 10;
  };

  int f() {
    return 0;
  }

  inline constexpr double PI = 3.14159;

  //ng! exportキーワードは現れてはならない
  export int g() {
    return -1;
  }
}

//ok、ブロックのエクスポート宣言はスコープを導入しない
export inline constexpr PI_2 = 2.0 * PI;

export宣言は当然名前空間に対しても行うことができます。その場合、エクスポートされた名前空間内の宣言は暗黙的にエクスポートされることになります。従って、内部の宣言は全て外部リンケージを持つ必要があります。
すなわち、無名名前空間export宣言に関わってはいけません。

また、エクスポートしていない名前空間内部にexport宣言が現れることもできます。この時それを囲む名前空間は暗黙的にエクスポートされますが、エクスポートしていない宣言はエクスポートされません。

///Mymodule.cpp
export module Mymodule;

//名前空間のエクスポート宣言、内部のものは暗黙的にエクスポートされる
export namespace Mymodule {
  struct S {
    int n = 10;
  };

  int f() {
    return 0;
  }

  inline constexpr double PI = 3.14159;
}

namespace Detail {
  //名前空間内でのエクスポート、Detail::g(void)がエクスポートされる
  export int g() {
    return -1;
  }

  //エクスポートしていない宣言はエクスポートされない
  int g(int n) {
    return -n;
  }
}

//ダメな例
export namespace NG {
  //名前を宣言していない
  using namespace std;

  //内部リンケージ
  static int N = 10;

  //exportキーワードは現れてはならない
  export int g() {
    return -1;
  }

  //名前を宣言していない
  static_assert(true);

  //無名名前空間は名前空間のエクスポート宣言に現れてはならない
  namespace {
    int h() {
      return 1;
    }
  }
}

//無名名前空間はエクスポートできない
export namespace {
    int h() {
      return 1;
    }
}

また、export宣言はusingtypedef)宣言に対しても行えます。ただし、上記の例でも示したように名前を導入するものでなくてはならず、参照先の名前は外部リンケージを持っていなければなりません。
ただし、usingtypedef)による型エイリアスの宣言では、参照先の名前が外部リンケージを持つ必要はありません。

///OtherModule.cpp
export module Other;

export int f() {
  return 0;
}

export int g() {
  return -1;
}

namespace Other{
  export struct S {
    int n = 10;
  };
}

///Mymodule.cpp
export module Mymodule;
import Other;

export using ::f, ::g;  //ok、まとめてエクスポート!
export using Other::S;  //ok、import先では名前空間指定なしで利用できる

//ng!名前を宣言していない
export using namespace std;

static int h1() {
  return 1;
}

double h2(double v) {
  return v;
}

struct T {
  int n = 0;
  double v = 0.0;
}

//ng!内部リンケージを持つ名前のエクスポート
export using ::h1;

//ng!モジュールリンケージ(後述)を持つ名前のエクスポート
export using ::h2;
export using ::T;

//ただし型エイリアスならok
export using C1 = ::T;  //::Tのエイリアスとなる名前C1をエクスポート
export typedef ::T C2;  //同様

また、変わったところではリンケージ指定もエクスポートできます。とはいえ、出来ることできないことはこれまでと変わりありません。

///Mymodule.cpp
export module Mymodule;

//リンケージ指定のエクスポート宣言
export extern "C++" int f() {
  return 0;
}


//リンケージ指定ブロックのエクスポート宣言、内部のものは暗黙的にエクスポートされる
export extern "C" {
  int g() {
    return -1;
  }

  double PI = 3.14159;

  //ng!exportキーワードは現れてはならない
  export int h() {
    return 1;
  }

  //ng!内部リンケージ
  static int h() {
    return 1;
  }
}

//ok、ブロックのエクスポート宣言はスコープを導入しない
export PI_2 = 2.0 * PI;

再宣言におけるexport

あらゆる宣言は再宣言を行うことができます。その際、exportがあったりなかったりすることがあるでしょう。その時、どうなるかは主に2つのパターンに分かれます、

  • 以前にexport宣言によって宣言されている場合
    • その宣言は暗黙的にエクスポートされる
  • 以前の宣言はexport宣言ではない場合
    • 再宣言はexport宣言であってはならない

どちらの場合もその再宣言された名前のリンケージは、1番最初の宣言時のリンケージに従います。すなわち、export宣言は名前のリンケージを変更できません。

///Mymodule.cpp
export module Mymodule;

//エクスポート宣言によるfの導入
export int f();

//fの定義、暗黙的にエクスポート
int f() {
  return 0;
}

export struct S;

//暗黙的にエクスポート
struct S {
  int n = 10;
};

//エクスポートしないTの宣言
struct T {
  int n = 10;
  double v = 0.0;
};

//Tの再宣言、以前にエクスポートされていないのでng!
export struct T;

namespace {
  int g() {
    return -1;
  }
}

int h() {
  return 1;
}

//内部リンケージを持つgはエクスポートできない(再宣言はリンケージを変更できない)
export int g();

//モジュールリンケージを持つhはエクスポートできない(再宣言はリンケージを変更できない)
export int h();

モジュールリンケージ

モジュールに属しかつ外部リンケージを持つ宣言(定義)がexportされていないとき、これまでの規則で考えるとその宣言を用意してやりさえすればモジュール外部から参照できるはずです。
しかし、これをやられるとモジュールの意味が薄くなってしまうので出来ないようになっています。

モジュールに属している宣言によって導入される名前が外部リンケージを持ち、エクスポートされていない場合、その名前は外部リンケージではなく モジュールリンケージ を持ちます。

モジュールリンケージを持つ名前は次の場所から参照され、参照することができます。

  • 同じモジュール単位内の他のスコープにある名前
  • 同じモジュール内の他のモジュール単位のスコープにある名前

名前というのはそのままの意味で、変数名や関数名、クラス名などのことです。

つまり、モジュール内ではexport宣言のみが外部リンケージを与え、任意の翻訳単位を超えて参照することができるのは外部リンケージを持つ名前だけです。
モジュールリンケージを持つ名前は同じモジュール内でしか参照できません。

なお、これらのことは宣言や定義が到達可能であるかどうかとは関係がありません。

import

exportときたら次はimportを見て行きます。

importに続いてモジュール名を指定することでそのモジュールでエクスポートされている宣言を取り込む構文の事をimport宣言(インポート宣言)と言います。

モジュール単位内ではimport宣言は書ける場所が決まっていて、基本的にはそのモジュール単位の先頭(すなわちファイルの先頭)で、他のあらゆる宣言よりも前に来なければなりません。ただし、モジュール宣言よりは後ろになります。
非モジュールの翻訳単位ではそのような規定はなく、importはどこにでも書くことができます。ただし、あらゆる宣言の内部に書くことはできません。

//予め、モジュールA,B,Cがあるとして

///Mymodule.cpp
export module Mymodule;
import A;         //ok
export import B;  //ok

export int f();


///main.cpp
import C; //ok

struct S{};

import A; //ok

int main() {
}

import B; //ok


///ng1.cpp(ダメな例1、モジュール単位
import A; //ng、モジュール宣言よりも後にくる必要がある
export module NG;
import B; //ok

export int f();

import C; //ng、そのほかの全ての宣言よりも前に無ければならない


///ng2.cpp(ダメな例2、非モジュール
namespace N {
  import A; //ng、他の宣言内はだめ
}

struct S {
  import B; //ng、他の宣言内はだめ
};

int main() {
  import C; //ng、他の宣言内はだめ
}

import宣言がインポートするものは、エクスポートされている宣言の塊とかの抽象的なものではなくて、いくつかの翻訳単位そのものをインポートします。
インポートされる翻訳単位は次のものの集まりです

  • 指定されたモジュールのプライマリーモジュールインターフェース単位
    • (下の条件より)必然的にモジュールの全てのインターフェース単位
  • インポートされる翻訳単位内で再エクスポート(export import、後述)されている翻訳単位
    • そのような翻訳単位は再エクスポートしている翻訳単位からエクスポートされる
  • 指定されたのがモジュールパーティションなら、そのパーティション
  • ヘッダーユニット(後述)
  • 同じモジュールの他のモジュール単位をインポートしている場合、そこでインポートされているすべての翻訳単位(exportの有無によらず)
    • 同じモジュール内ではインポートした翻訳単位内のすべての宣言が可視となる

そして、そのようにインポートされた翻訳単位内でエクスポートされている宣言(名前)は、インポート先の翻訳単位で可視となり、エクスポートされていない宣言は到達可能となります。インポート宣言の持つ効果は実質これだけです。
import宣言は#includeとは異なり宣言や定義をインポート先に導入しません。インポートされると言う事は、単に宣言が可視もしくは到達可能となるかどうかだけに影響します。

あるインターフェース単位内で単にインポートされただけの(再エクスポートされていない)モジュール内の宣言(必然的にそのインターフェース内宣言)は、そのインターフェース単位をインポートした先で可視にはなりませんが到達可能となります。 これを「推移的なインポート」と呼び言い直すと、ある翻訳単位で推移的にインポートされた翻訳単位内の宣言は可視ではないが到達可能となる、という事になります。

//予め、モジュールA,B,C,Dがあるとして

///Mymodule_part.cpp(インターフェースパーティション
export module Mymodule:Part;
export import A;  //再エクスポート
import B;

///Mymodule_implpart.cpp(実装パーティション
module Mymodule:impl;
import :Part;/*
ここでインポートされている翻訳単位は3つ
1. (インターフェース)パーティション:Part(Mymodule_part.cpp)
2. モジュールA(パーティション:Partからの再エクスポート)
3. モジュールB(パーティション:Partでインポートされている、推移的なインポートではない)
*/

///Mymodule.cpp(プライマリなインターフェース単位
export module Mymodule;
export import :Part;
import C; 
export import D;/*
ここでインポートされている翻訳単位は5つ
1. (インターフェース)パーティション:Part(Mymodule_part.cpp)
2. モジュールA(パーティション:Partからの再エクスポート)
3. モジュールB(パーティション:Partでインポートされている)
4. モジュールC
5. モジュールD
*/

///Mymodule_impl.cpp(実装単位
module Mymodule;/*
ここでインポートされている翻訳単位は6つ
1. プライマリモジュールインターフェース単位(Mymodule.cpp)
2. (インターフェース)パーティション:Part(Mymodule_part.cpp)
3. モジュールA(パーティション:Partからの再エクスポート)
4. モジュールB(パーティション:Partでインポートされている)
5. モジュールC(これは推移的なインポートではない)
6. モジュールD
*/
//実装略

///main.cpp
import Mymodule;/*
ここでインポートされている翻訳単位は4つ
1. Mymoduleのプライマリーモジュールインターフェース単位(Mymodule.cpp)
2. Mymoduleのインターフェースパーティション:Part(Mymodule_part.cpp)
3. モジュールA(Mymodule:Partからの再エクスポートのMymoduleからの再エクスポート)
4. モジュールD(Mymoduleからの再エクスポート)
エクスポートされている宣言が可視となっているのはこの4つのモジュールのみ
加えて、推移的にインポートされたモジュールB,C内部の宣言が到達可能となる(可視ではない)
*/

int main() {
}

同モジュール内ではインポートするとそこでインポートされている翻訳単位をすべてインポートします。そのため、予想外にインポートする翻訳単位が膨れ上がることがあり得ます。上記だとモジュール実装単位(Mymodule_impl.cpp)がそうであるように、実装単位は特に沢山の翻訳単位をインポートすることになりがちです。

なお、パーティションでないモジュール実装単位では自分自身のモジュールを指定するインポート宣言を行えません。
その代わり、そのようなモジュール実装単位は対応するプライマリモジュールインターフェース単位を暗黙的にインポートしています。

///Mymodule.cpp
//Mymoduleのプライマリインターフェース単位
export module Mymodule;

export int f();


///Mymodule_impl.cpp
//Mymoduleの実装単位
module Mymodule;
import Mymodule;  //compile error!
                  //代わりにこの様に書いたように暗黙的にプライマリモジュールインターフェース単位をインポートしている

//エクスポートされているfの再宣言であり定義
//エクスポートされいるわけではないが外部リンケージを持つので呼び出し可能
int f() {
  return 0;
}

再エクスポート

ここまでにもちらちら出て来ていますが、インポート宣言はエクスポートすることができ、それを再エクスポートと呼びます。再エクスポート宣言は、import宣言の前にexportを付けます。
そのように再エクスポートされた翻訳単位は、import宣言の効果を持ちつつ、再エクスポートしている翻訳単位から指定した翻訳単位をエクスポートします。
ただし、モジュール実装単位(パーティション含む)はエクスポートできません。

//予め、モジュールA,Bがあるとして

///Mymodule_part.cpp
export module Mymodule:Part;
export import A;  //ok、モジュールの再エクスポート

///Mymodule_implpart.cpp
module Mymodule:impl;

///Mymodule.cpp
export module Mymodule;
export import :Part;  //ok、インターフェースパーティションの再エクスポート
export import B;      //ok
export import :impl;  //ng!実装単位(パーティション)はエクスポートできない

再インポート(複数回のインポート)

インポート宣言は同じモジュール名に対して複数回行うことができ、それは特に禁止されていません。

//予め、モジュールA,Bがあるとして
///main.cpp
import A;
import B;

import A; //ok、#includeとは違い問題は起きない

int main() {
}

同じモジュールを何回インポートしても意味はなく、特に問題も起きません。
なぜなら、先に述べたようにインポート宣言は指定したモジュール内の宣言が可視・到達可能となるかどうかのみを変更します。
再度のインポートをしたとしても、すでに可視な宣言が可視に、到達可能な宣言が到達可能になるだけです。ODR違反等を起こしません。

インターフェース依存関係

ある翻訳単位は次のいずれかの場合にあるモジュール単位Uにインターフェース依存関係を持ちます。

  • Uを指定するモジュールインポート宣言がある時
  • Uと同名のモジュール実装単位である時(パーティションではなく)
    • 必然的にUはプライマリインターフェース単位
  • Uにインターフェース依存関係を持つモジュール単位にインターフェース依存関係を持つ時
    • 推移的な依存関係のこと

基本的には1つ目の条件によりインターフェース依存関係が発生し、それは再帰的に推移します(3つ目の条件)。

そして、あるモジュール単位は自分自身にインターフェース依存関係を持ってはいけません。

//M1のインターフェース単位
export module M1;
import M2;

//M2のインターフェース単位
export module M2;
import M3;

//M3のインターフェース単位
export module M3;
import M1;  //compile error! 循環的なインターフェース依存関係の発生 M3→M1→M2→M3

前述のように、同モジュール内でのモジュール単位のインポートは再エクスポートしていなくてもそのモジュールのインポートが発生するため、予想外のインターフェース依存関係が発生することがあります。その時、この様な循環的なインターフェース依存が発生しない様に注意しなければなりません。
幸いな事に、これはコンパイラによって検出されコンパイルエラーとなるはずです。

また、パーティションではない実装単位は多くのインターフェース依存関係を持ち得ますが、自身をエクスポート/インポートできないために循環的な依存関係は発生しないでしょう。
パーティションではないモジュール実装単位はインターフェース依存関係を断ち切るのに有効利用できます。

変り者のモジュール達

ここまではほぼごく普通のモジュールだけを紹介してきましたが、世の中には少し変なモジュールが存在しています・・・

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

これは上の方でも出てきましたが、プライベートモジュールフラグメントはモジュールを単一ファイルで構成するための仕組みです。

モジュールをインポートするとインポートされるのはそのモジュールのプライマリモジュールインターフェース単位です。すなわち、プライマリモジュールインターフェース単位さえあればモジュールは構成できるわけです。

しかし、そのようなインターフェース単位に書いたエクスポートされていない宣言・定義はインポート先の翻訳単位で到達可能となり、意図しないODR違反を起こす可能性があります。

サンプルコード再掲

///mymodule .cpp
//Mymoduleのプライマリインターフェース単位の宣言、かつMymoduleの唯一のモジュール単位
export module Mymodule;

//f_implの宣言はインポート先で到達可能だが可視ではない
int f_impl(int n) {
  return n * n;
}

//fの宣言はインポート先で可視
export int f(int n) {
  return f_impl(n);
}

///main.cpp
import MyModule;

//ODR違反!モジュールの中身を知らずに定義してしまった
int f_impl(int n) {
  return n + n;
}

int main() {
  int n = f(10);
  int m = f_impl(10);  //おそらくコンパイルエラー
}

そのため、実装や公開する必要のない宣言を適切に隠蔽する仕組みが必要となり、プライベートモジュールフラグメントはそのための仕組みです。

プライベートモジュールフラグメントはプライマリモジュールインターフェース単位でのみ使用可能で、module : privete;というマーカーから開始されます。
プライベートモジュールフラグメントの内部の宣言は他の翻訳単位から到達可能ではなく、したがって可視でもありません。(プライベートモジュールフラグメントの宣言は同じプライベートモジュールフラグメント内からか、テンプレートのインスタンス化時に一定条件の下で到達可能になります。)

そして、プライベートモジュールフラグメントを持つ翻訳単位は、そのモジュールで唯一の翻訳単位となっていなければなりません。単一ファイルでモジュールを構成する仕組みですが、利用することによって単一ファイルであることを強いることにもなります。
ちなみに、この時に複数ファイルでモジュールを構成したとしても診断はされません(つまりエラーになりませんが、規格違反状態です)。

///mymodule.cpp
//Mymoduleのプライマリインターフェース単位の宣言、かつMymoduleの唯一のモジュール単位
export module Mymodule;

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

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

//f_impl()の宣言・定義はモジュール外から到達可能でない
//モジュールリンケージを持つため参照もできない
int f_impl(int n) {
  return n * n;
}

//エクスポートされている宣言の再宣言は外部リンケージを持つため呼び出し可能
int f(int n) {
  return f_impl(n);
}

///main.cpp
import MyModule;

//ok、以前の定義は見えてない(到達可能でない)
int f_impl(int n) {
  return n + n;
}

int main() {
  int n = f(10);      //ok
  int m = f_impl(10); //ok, m == 20
}

その性質上明らかですが、プライベートモジュールフラグメント内部でexport宣言を行うことは出来ません。

しかし、import宣言を行うことは出来ます。その場合は、プライベートモジュールフラグメントの開始宣言の直後で、他のあらゆる宣言よりも前に行います。
プライベートモジュールフラグメント内部でインポートした翻訳単位内のあらゆる宣言は、モジュール外部から可視でも到達可能でもありません。

//予め、モジュールA,B,Cがあるとして

///mymodule.cpp
export module Mymodule;
export import A;
import B;/*
ここではモジュールA,Bの宣言が可視
*/

module : private;
import C;/*
ここではモジュールA,B,Cの宣言が可視
*/

///main.cpp
import MyModule;/*
ここでインポートされている翻訳単位は2つ
1. Mymoduleのプライマリーモジュールインターフェース単位(Mymodule.cpp)
2. モジュールA(Mymoduleからの再エクスポート)
エクスポートされている宣言が可視となっているのはこの2つのモジュールのみ
推移的インポートによって宣言が到達可能になるのはモジュールBのみ
モジュールCは完全に観測不可能
*/

int main() {
}

プライベートモジュールフラグメントを利用する事で、推移的なインポートによる到達可能な宣言の漏出を防止する事ができます。

プライベートモジュールフラグメントは、module : privete;という行を境目としてプライマリインターフェース単位と実装単位を1ファイル内に書いている、と見ることもできます。

グローバルモジュール

グローバルモジュールは通常の名前付きモジュール(モジュール宣言によって導入されるモジュール)ではない全てのコードとグローバルモジュールフラグメント(後述)が属しているモジュールです。
モジュール宣言の無い翻訳単位に書かれている宣言は全てグローバルモジュールに属することになります。

丁度、名前空間に包まれていないものがグローバル名前空間内にあるように、モジュール内部に無いものはグローバルモジュールに属する形になります。

そのようなグローバルモジュールには名前はなく、インターフェース単位も持たず、導入するためのモジュール宣言もありません。
すなわち、明示的にグローバルモジュールを定義する構文はなく、インポートすることもできません。

グローバルモジュールでは従来のヘッダ利用のためにモジュール内部よりもODRが緩くなっていて(というか従来通り)、定義の文字列と意味が同一ならば複数の定義が存在する事が許可されています。

///Mymodule.cpp
export module Mymodule;
//以下の宣言はすべてMymoduleに属する

///main.cpp
import Mumodule;
//以下の宣言はグローバルモジュールに属する

int main() {
}

グローバルな確保・解放関数(new/delete)はグローバルモジュールに属しており、main関数は必ずグローバルモジュールに属していなければなりません。
また、ここまでに出てきたものの中でも次の2つは実はグローバルモジュールに属しています。

  • 外部リンケージを持つ名前空間の定義
  • リンケージ指定内部に現れる宣言
///Mymodule.cpp
export module Mymodule;

//名前空間Mymoduleはグローバルモジュールに属する
namespace Mymodule {
  //Mymodule::f()はモジュール"Mymodule"に属する
  export int f() {
    return 1;
  }

  //Mymodule::PIはモジュール"Mymodule"に属する
  inline constexpr double PI = 3.14159;
}

//g()はグローバルモジュールに属する
export extern "C++" int g() {
  return 0;
}

//内部のものはグローバルモジュールに属する
export extern "C" {
  int h() {
    return -1;
  }

  double PI = 3.14159;
}

グローバルモジュールは複数のファイルに渡ってその本文を持ち得る巨大な1つのモジュールです。そして、モジュールに対して適用される規則はグローバルモジュールにも適用されます。
この記事中では(規格書中でも)、グローバルでない通常のモジュールだけを指定する必要がある時は「名前付きモジュール」と言う言葉を使いグローバルモジュールと区別します。

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

グローバルモジュールフラグメントは、モジュール内部でのヘッダーファイルの#includeをモジュール内で完結させるための宣言領域です。 グローバルモジュールフラグメントの宣言は全てグローバルモジュールに属します。

モジュールの時代が到来したとはいえ、まだまだ世の中のライブラリはヘッダベースのものがほとんどです。それは標準ライブラリも例外ではありません。そのため、モジュール内部においてもそれらのヘッダをインクルードして利用する必要があります。

#includeはコピペのため、インクルード先にヘッダの内容が展開されます。モジュール内部においてそうしたインクルードを行うとモジュールおよびそのインターフェースの肥大化(使用しないものもコンパイルされ、残る)を招くとともに、インターフェースでインクルードするとそのモジュールをインポートした先でヘッダ内の宣言が到達可能となってしまい、意図しないODR違反を引きおこす可能性が著しく増加してしまいます。

以下のコードは後述のヘッダーユニットは無いものとした例です。

///Mymodule.cpp
export module Mymodule;

#include <iostream> //ここにiostreamヘッダが展開され、それらはモジュールリンケージ(もしくは内部、なし)をもち、Mymoduleに属する

///main.cpp
import Mumodule;
//ここではiostreamヘッダの内容は可視ではないが到達可能

#include <iostream> //この時点で数多くのODR違反が発生する

int main() {
  std::cout << "Hello Compile Error!" << std::endl;  //おそらくコンパイルエラー
}

この例のようにモジュールとその利用側で同じヘッダをインクルードすると、ODR違反が大量に起こるでしょう。幸いほとんどはコンパイルエラーとされるはずです。
この時、内部リンケージを持つものは翻訳単位毎に生成されているため、それらに依存する処理は思わぬ動作をする可能性があります。運良く何も起こらなくても未定義動作の世界です・・・
この様な状況を回避し旧来のヘッダを利用するための仕組みが、グローバルモジュールフラグメントになります。

その名の通りプライベートモジュールフラグメントと似たもので、モジュール単位内部にグローバルモジュールフラグメントという領域を作ります。ファイル先頭でモジュール宣言の前にmodule;という宣言をすることでグローバルモジュールフラグメントが開始されます。

グローバルモジュールフラグメント内の宣言は全てグローバルモジュールに属し、そのモジュールには属しません。

///Mymodule.cpp
module; //グローバルモジュールフラグメント(開始)宣言

#include <iostream> //ここにiostreamヘッダが展開される

export module Mymodule; //通常のモジュール宣言によってグローバルモジュールフラグメントは終了
//ここでは、iostreamヘッダの内容が使用可能(可視になる)


///main.cpp
import Mumodule;
//ここではiostreamヘッダの内容は可視ではない

#include <iostream> //無問題

int main() {
  std::cout << "Hello World!" << std::endl;  //何の問題もない
}

グローバルモジュールフラグメントにはコンパイルの開始段階でプリプロセッサ以外のものが含まれてはいけません。また、グローバルモジュールフラグメントの終了を意味するモジュール宣言はプリプロセッサによって生成されたものであってはなりません。

///Mymodule.cpp
module; //グローバルモジュールフラグメント(開始)宣言

#include <iostream> //ok

//通常の宣言はng(ヘッダファイルのインクルードを経由するかマクロで生成すればok)
int f() {
  return 0;
}

#define END_EXP export  //ok
#define END_MOD module  //ok

END_EXP END_MOD Mymodule; //ng、モジュール宣言を生成してはならない

そして、グローバルモジュールフラグメント内の宣言のうち、その後のモジュール内から参照されないものは 破棄 されます。モジュール内から参照され、さらにそこから参照されている宣言は破棄されませんが、単にグローバルモジュールフラグメント内から参照されているだけでは破棄されてしまいます。
破棄された宣言はモジュール外部から可視でも到達可能でもなく、おそらくコンパイルされません。

宣言の破棄によってヘッダファイルを利用しながらモジュールの肥大化を抑えることができます。

///Mymodule.cpp
module;

#include <tuple>

export module Mymodule;

export using int_tuple = std::tuple<int, int, int>;

export auto f() -> std::tuple<char, short, double>;

//これ以外のstd::tuple特殊化および、すべての関数の宣言は使用されていないので破棄される
//破棄された宣言はモジュールに含まれず、コンパイルもされない
//そして、このモジュールをimportした先で到達可能ではない

///main.cpp
import Mymodule;

#include <tuple>  //無論何ら問題なし

int main() {
  int_tuple t{1, 2, 3}; //ok
}

なお、テンプレートのテンプレート引数に関わる形でグローバルモジュールフラグメント内の宣言が使用されている場合、その宣言は参照されているとみなされず破棄されることがあります。

///Mymodule.cpp
module;

#include <tuple>

export module Mymodule;

export template<typename T>
using triple = std::tuple<T, T, T>; //テンプレートパラメータが確定していないのでstd::tupleは参照されているとみなされない

export template<typename T>
triple<T> f(T t) {
  return {t, t, t}; //テンプレートパラメータが確定していないのでstd::tupleは参照されているとみなされない
}

triple<double> d_triple = f(3.14);  //ok、std::tuple<double, double, double>が参照された

///main.cpp
import Mymodule;

int main() {
  triple<int> t1{};       //compile error! std::tuple<int, int, int>は破棄されている
  triple<int> t2 = f(1);  //compile error! std::tuple<int, int, int>は破棄されている

  triple<double> t3{};          //ok、std::tuple<double, double, double>は破棄されていない
  triple<double> t4 = f(2.72);  //ok、std::tuple<double, double, double>は破棄されていない
}

テンプレートパラメータが確定するまでは、そのテンプレートは参照されているとみなされません。これは引っ掛かりやすい罠なので注意する必要があります。

ヘッダーユニット(ヘッダ単位)

ヘッダーユニットは従来のヘッダファイルをモジュールとしてインポートする仕組みです。ヘッダーユニットはそれが一つのモジュール単位であるかのようにふるまい、基本的には1つのモジュールとして扱われます。

ヘッダーユニット内の全ての宣言は抽出されインターフェースとしてエクスポートされ(いわば、プライマリなインターフェース単位を自動生成する)、それらの宣言はグローバルモジュールに属します。
その際、外部リンケージを持たないものがあっても大丈夫ですが、そう言うものはヘッダーユニット外から参照してはいけません。
つまりは結局、ヘッダユニットからエクスポートされるのは外部リンケージを持つものだけという事です。

ヘッダーユニットを利用するには、import宣言にヘッダ名を指定してやります。また、インポート可能なヘッダであるとコンパイラに認識された場合、そのヘッダに対する#includeはヘッダーユニットのインポート宣言に置き換えられる可能性があります(これは実装に任されている)。

以下サンプルコードはSTLのヘッダがインポート可能な世界です

///Mymodule.cpp
export module Mymodule;

import <tuple>;           //tupleヘッダーユニットのインポート
#include <type_traits>    //type_traitsヘッダーユニットのインポートに置き換えられる
export import <iostream>; //iostreamヘッダをインポートしつつ再エクスポート

//ここでは、tuple、type_traits、iostreamヘッダの内容が使用可能(可視になる)


///main.cpp
import Mymodule;
//ここではiostreamヘッダの内容が可視
//tuple、type_traitsヘッダの内容は到達可能でもない

//以前の定義は到達可能ではないのでok
#include <tuple>
#include <type_traits>

#include <iostream> //同じ名前を指定するヘッダーユニットのインポートは、同じヘッダーユニットをインポートする
                    //翻訳単位のインポートは宣言を導入(コピペ)せず、可視・到達可能にするだけ
                    //既に可視になってる宣言を再び可視にする効果しかなく、再度のインポートに問題はない

int main() {
  std::cout << "Hello World!" << std::endl;  //ok、Mymoduleの再エクスポートを通して可視
}

同じ名前を指定するヘッダーユニットのインポート宣言は、常に同じヘッダーユニットをインポートします。つまり、インポートする翻訳単位が異なっていたとしてもヘッダーユニットは1つ(1度)しか生成されません。
そして、通常のインポート宣言と同様、同じヘッダーユニットの複数回のインポートをしてもODR違反等の問題は起きません。

この様に、ヘッダのインクルードの問題を解決できるので、利用可能ならばグローバルモジュールフラグメントよりもヘッダーユニットを使った方が良いでしょう。

ちなみに、ヘッダーユニットはマクロをインポートする唯一の方法です。インポートされたマクロはヘッダーユニットのインポート宣言の直後で定義されています。
ただし、ヘッダーユニットの再エクスポート時はマクロはエクスポートされません。

///Mymodule.cpp
export module Mymodule;

//cmathヘッダをインポートしつつ再エクスポート、この直後cmathヘッダ内のマクロが定義される
export import <cmath>;/*
ここで、<cmath>内のマクロが定義(エクスポート)される
マクロの内容によっては以降のimport宣言に指定した名前に影響するかもしれない
*/import <tuple>;

//共にok、cmathヘッダ定義のマクロが利用できる
double huge_value = HUGE_VAL;
double nan = FP_NAN;

///main.cpp
import Mumodule;
//ここではcmathヘッダの内容が可視

int main() {
  double r2 = std::sqrt(2.0); //ok
  
  //共にng、マクロは再エクスポートされない
  double huge_value = HUGE_VAL;
  double nan = FP_NAN;
}

import <cmath>;  //再インポート、マクロが定義される

void m() {
  //共にok、<cmath>のマクロが使用可能
  double huge_value = HUGE_VAL;
  double nan = FP_NAN;
}

詳細には、ヘッダーユニットはヘッダーを一つのソースファイルとして翻訳フェーズ7(テンプレートの実体化直前)までコンパイルした1つの翻訳単位です。
マクロは、そのコンパイル時の翻訳フェーズ4(プリプロセッサの実行)終了時の段階でヘッダーユニット内に定義されているマクロがエクスポート(ヘッダーユニットインポート宣言の直後で再定義)されます。
外部から見るとヘッダーユニット内では、プリプロセスは終了していますがテンプレートは実体化されていません。そして、すべての宣言は抽出されエクスポートされているので、外部リンケージを持つもの(非テンプレート)は翻訳単位超えにより呼び出す事ができます。そして、テンプレートは最終的なコンパイル時に実体化され定義されます。

翻訳フェーズについて → 翻訳フェーズ - cppreference.com

マクロがエクスポートされインポート先で再定義される、1つのモジュールとしてコンパイルされる、という性質から明らかですが、ヘッダユニットのインポートは全てプリプロセス時(翻訳フェーズ4)に処理されます(なので厳密にはヘッダーユニットのインポートはインポート宣言ではないのです)。

また、インポート可能なヘッダは処理系定義とされているので何でもかんでもインポート出来るわけでは無いようですが、それらヘッダをどう識別するか、もしくは特定の名前のヘッダのみをインポート可能とするか、に関しても処理系定義であり、意図としてはインポート可能なヘッダの扱いについての研究が進められることを狙っているようです。
ただし、STLのヘッダのうちC言語由来でないヘッダは全てインポート可能ヘッダとなります。C言語由来なヘッダとは<cmath><cstdlib>などのcから始まるヘッダのことです。

グローバルモジュールフラグメント及びヘッダーユニットの推移的なインポート

あるモジュールインターフェース内でエクスポートせずにインポートしているものは、そのモジュールインターフェースをインポートする先に推移的にインポートされます。推移的にインポートされた宣言は可視にはなりません。
ただし、それが名前付きモジュールならそのインターフェース内宣言は到達可能になります。

そして、そのインターフェース内のグローバルモジュールフラグメントに破棄されず残った宣言、及び推移的にインポートされたものがヘッダーユニットである場合、それらの宣言が到達可能となるかは未規定であり実装は到達可能とする事が許されています。
ただ、それら宣言は可視である時には到達可能になります(これは規定されている)。

仮に到達可能とされた場合、ヘッダーユニットは必ず再インポートになりなんら問題は起きません。グローバルモジュールフラグメント内の宣言はODRの例外規定に沿っていればグローバルモジュールに属しており、定義の文字列と意味が同一ならば複数の定義を持つ事ができるためODR違反は起こりません。。

///header.hpp(ヘッダファイル
inline bool ok() {
  return true;
}

//非テンプレートの非inline関数
bool ng() {
  return false;
}

const double e = 2.72;

///Mymodule.cpp
module;

#include "header.hpp"

export module Mymodule;
import <cmath>;

//header.hpp内宣言を参照する
void use() {
  bool b1 = ok();
  bool b2 = ng();
  auto e2 = 2.0 * e;
}

///main.cpp
import Mymodule;
//<cmath>及び"header.hpp"内宣言が到達可能となるかは未規定、可視ではない
//以下、到達可能とすると

#include "header.hpp" //関数ng()がODR違反を起こす

const double r2 = std::sqrt(2.0); //ng、可視でない

import <cmath>  //ok、ヘッダーユニットの再インポート

int main(){
  bool b1 = ok(); //ok、可視であり到達可能
  bool b2 = ng(); //ng、おそらくリンクエラー
  double r5 = std::sqrt(5.0); //ok、可視であり到達可能
}

テンプレートやクラスのメンバでなければ、関数や変数はinlineでないとODRの例外には当てはまりません。

上級編

onihusube.hatenablog.com

参考文献

この記事のMarkdownソース

[C++]static constexprな配列メンバの定義

クラスの静的メンバ変数は通常クラス外にその定義が必要になります。

struct sample {
  //宣言
  static int n;
};

//定義
int sample::n = 10;

ただし、静的メンバ変数がstatic constexprな変数であるときは、多くの場合その定義は省略することができます(static constでもほぼ同様)。

struct sample {
  //宣言
  static constexpr int n = 10;
};

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

しかし、static constexprな配列メンバは同様の場合においても定義が必要とされます(ただし、C++14まで)。

struct sample {
  //宣言?
  static constexpr int m[] = {10, 20};
};

//定義、これが無いとリンカエラー(C++17以降は逆に非推奨)
constexpr int sample::m[];

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

これには変数がいつodr-usedされるのか、という難解な決まりごとが関わっています。

変数のodr-used

変数や関数などはodr-usedされたときにその定義が必要となります。逆に言えば、odr-usedされなければ定義は必要ではありません。

変数のodr-usedは以下のように規定されています(N4659 6.2.3 One-definition rule [basic.def.odr]より、C++17とC++14ではこの部分の内容に変更は無いのでC++17規格を参照します)。

A variable x whose name appears as a potentially-evaluated expression ex is odr-used by ex unless applying the lvalue-to-rvalue conversion to x yields a constant expression that does not invoke any non-trivial functions and, if x is an object, ex is an element of the set of potential results of an expression e, where either the lvalue-to-rvalue conversion is applied to e, or e is a discarded-value expression.

細かく説明するのは困難なので簡単に要約すると、ある変数xは以下の場合を除いてodr-usedされます。

xが評価されうる式exに現われていて

  • xにlvalue-rvalue変換を適用すると、非トリビアルな関数を呼び出さない定数式、で行うことができる
  • xは参照である
  • xはオブジェクトであり、かつ
    • exはlvalue-rvalue変換が適用されない結果が廃棄される式(discarded-value expression)の(想定される)結果の一つであるか
    • exはlvalue-rvalue変換が適用可能な、より大きな式の(想定される)結果の一つ

まあ良く分からないですね・・・。
今回重要なのはなんとなくわかるかもしれない1つ目の条文

xにlvalue-rvalue変換を適用すると、非トリビアルな関数を呼び出さない定数式、で行うことができる

という所です。

lvalue-rvalue変換とはその名の通り、変数を左辺値から右辺値へ変換するものです。そして、それが非トリビアルな関数(ユーザ定義された関数)を呼び出さず、定数式で行える時、変数はodr-usedされません。

実は、非配列のstatic constexprな変数はほとんどの場合この規則に当てはまっており、使っている(つもり)のところではその定数値が直接埋め込まれる形に変換されているわけです。

struct sample {
  //宣言
  static constexpr int n = 10;
};

int main() {
  //このsample::nの使用は、lvalue-rvalue変換によって
  int n = sample::n;
  //以下のように書いたかのように扱われている
  int n = 10;
}

常にこのようにすることができる場所ではその定義は必要とならないので、odr-usedである必要がない事が分かるでしょう。

ところで、lvalue-rvalue変換とはなんぞやと詳しく見に行ってみると、1番最初に以下のように書かれています(N4659 7.1.1 Lvalue-to-rvalue conversion [conv.lval])。

A glvalue of a non-function, non-array type T can be converted to a prvalue.

要約すると

関数型でも配列型でもない型Tのglvalueは、同じ型のprvalueに変換できる。

おわかりいただけたでしょうか、static constexprな配列メンバを使用すると定義を必要とされるのはこの条文によります。
つまり、配列型の変数にはlvalue-rvalue変換を適用することができない(そして、odr-usedとならない他の条件にも当てはまらない)ため、どのように使ったとしてもodr-usedになってしまうのです。

結果、クラス外に定義が必要になってしまいます。

そして、例えばコンパイルをとりあえず通すためにヘッダにそのような定義を書き、長い時間が過ぎた後でそのヘッダを複数のソースファイル(翻訳単位)からインクルードしたとき、今度は定義が重複している、ODR違反だ!というエラーに悩まされることでしょう・・・

C++17以降の世界

C++17以降は、冒頭のstatic constexprな配列メンバにおいても定義をすることなく使用することができるようになります。

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

これは、odr-usedの条件が変更されたわけではなく、C++17より導入されたinline変数の効果によるものです。

詳しくは → インライン変数 - cpprefjp C++日本語リファレンス

クラスの静的メンバ変数にconstexprがついているとき、暗黙的にinline指定したのと同じになり、定義がその場で生成されます(static constexprなメンバ変数については、むしろクラス外に定義を書くのは非推奨となります)。
しかも、inline変数なのでヘッダに書いて複数ファイルからインクルードしてもその定義は一つに保たれるためにODR違反に悩まされることもありません。

つまりはC++17以降を使いましょう、という事ですね・・・。

参考文献

謝辞

この記事は以下の方によるご指摘によって成り立っています。

この記事のMarkdownソース

[C++]explicit(bool)の使いどころ

※この内容はC++20から利用可能になる予定の情報であり、一部の内容が変更される可能性があります。

explicit(bool)指定子

従来コンストラクタと変換関数に付けることのできていたexplicit指定子は 、C++20よりexplicit(expr)という形のものに置き換えられます。
かっこの中のexprに指定出来るのはboolに変換可能な定数式です。

そして、そのexprtrueとなる場合はそのコンストラクタ(変換関数)はexplictコンストラクタになり、falseとなる場合は通常の非explicitコンストラクタとして扱われます。

また、従来の式を指定しないexplicit指定はexplicit(true)として扱われるようになります。

データメンバ型のexplicit性継承

ではこのexplicit(bool)指定子、一体何が嬉しいのでしょうか?

それを知るために、以下のようにテンプレートによって任意の型を保持するようなクラスを考えてみます。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

とりあえず最小限のコンストラクタをそろえておきます。
最小限であるとはいえこれで目的を達することができ、特に問題は無いように思えます。

そこで、このunit型にexplicitコンストラクタを持つ型を入れてみましょう。

struct has_explicit_ctor {
  explicit has_explicit_ctor(int) {}
};

int main() {
  has_explicit_ctor s1{1};     //ok、直接初期化(明示的コンストラクタ呼び出し)
  has_explicit_ctor s2 = {2};  //ng、コピーリスト初期化(暗黙変換)
  has_explicit_ctor s3 = 3;    //ng、コピー初期化(暗黙変換)

  unit<has_explicit_ctor> s4 = {10};  //ok
  unit<has_explicit_ctor> s5 = 10;    //ok
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ご覧のように、包むunit型のコンストラクタを通すことによって、要素型has_explicit_ctorのexplicitコンストラクタのexplicit性が失われてしまっています。
その結果、初期化時に暗黙変換が行われるようになってしまっています・・・

暗黙変換されたくないのでコンストラクタにexplicitを付けているはずで、この様に別の型に包まれたとしても同じようになってくれなければ困ってしまいます。

では、unitのコンストラクタにexplicitを付加してやりましょう。そうすれば解決ですね。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  explicit constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

int main() {
  unit<has_explicit_ctor> u1 = {10};  //ng
  unit<has_explicit_ctor> u2 = 10;    //ng
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

しかしこれにも問題があります。

auto f() -> unit<int> {
  return {128};  //compile error!
}

int main() {
  unit<int> u1 = {10};  //ng
  unit<int> u2 = 10;    //ng
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

はい、今度は非explicitコンストラクタを持つ型を入れた時に意図しないコンパイルエラーが多発します。すべてunit型のコンストラクタがexplicitであるためです。
(これがまさに、C++17においてN4387で解決されたstd::tuplestd::pairのコンストラクタの問題です。)

これを解決する一つの策が、型引数Tのコンストラクタのexplicit性を継承するPerfect Initializationと呼ばれるイディオムです。

template<typename Cond>
using enabler = std::enable_if_t<Cond::value, std::nullptr_t>;

template<typename Cond>
using disabler = std::enable_if_t<!Cond::value, std::nullptr_t>;

template<typename T>
struct unit {

  //Tに暗黙変換可能なUから構築
  template<typename U=T, enabler<std::is_convertible<U, T>> = nullptr>
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}
  
  //Tにexplicitに変換可能なUから構築
  template<typename U=T, disabler<std::is_convertible<U, T>> = nullptr>
  explicit constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}

  T value;
};

auto f() -> unit<int> {
  return {128};  //ok
}

int main() {
  unit<has_explicit_ctor> s1 = {10};  //ng
  unit<has_explicit_ctor> s2 = 10;    //ng
  
  unit<int> u1 = {10};  //ok
  unit<int> u2 = 10;    //ok
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

std::is_convertible<U, T>UからTに暗黙変換可能かどうかを調べるものです。
それを用いて、暗黙変換可能かどうかでSFINAEして、同じコンストラクタを型Tに応じてexplicitかどうかを切り替えます。割と泥臭い・・・
このようにすることで、このunit<T>のような型は内包する型のexplicit性を継承することができます。

しかし、要するにexplicitの有無の違いだけでなぜ二つも同じコンストラクタを書かなければならないのでしょうか。また、さらにunit<U>const U&などから変換するようなコンストラクタを追加すると同じことをしなければなりません。
これは面倒です、どうにかしたい・・・

そこでようやくexplicit(bool)の出番です。これを使うと、unit型のコンストラクタは簡単になります。

template<typename T>
struct unit {

  //Tに変換可能なUから構築
  template<typename U=T>
  explicit(std::is_convertible_v<U, T> == false)
  constexpr unit(U&& other)
    : value(std::forward<U>(other))
  {}
  
  T value;
};

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

少し見辛い所はありますがexplicit(bool)に暗黙変換可能かどうかを判定する式を渡してやることで、一つのコンストラクタの定義だけで先ほどと同じ効果を得られます。
もはや訳の分からないSFINAEによる分岐は必要ありません。

explicit(bool)はおそらくこの問題のためだけに導入されました。ライブラリで複雑な型を書く場合にはありがたみを感じることができるでしょう・・・

ところで、STLにはこのように内部に任意の型のオブジェクトを保持するようなクラスがいくつかあります。std::tuplestd::pairstd::optionalがその代表です。
std::pairstd::optionalのある1つのコンストラクタを見てみます(std::tupleは説明するには複雑になるのでスキップ・・・)。

std::pairの場合

同じ問題が起きるstd::pairのコンストラクタの(5)を見てみます。

このコンストラクタには要件があります。

(5) : is_constructible<first_type, U&&>::value && is_constructible<second_type, V&&>::valueであること

これを考慮したうえでPerfect Initializationすると、おおよそ以下の様な宣言になります。

//与えられた条件全てがtrueの場合に有効化
template<typename... Cond>
using enabler = std::enable_if_t<std::conjunction<Cond...>::value, std::nullptr_t>;

template<typename T1, typename T2>
struct pair {

  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>,
        std::is_convertible<U, T1>,
        std::is_convertible<V, T2>
    > = nullptr
  >
  constexpr pair(U&& x, V&& y);
  
  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>,
        std::negation<
          std::conjunction<
            std::is_convertible<U, T1>,
            std::is_convertible<V, T2>
          >
        >
    > = nullptr
  >
  explicit constexpr pair(U&& x, V&& y);

};

先ほどのunitとやってることは同じです。型が二つに増えたので複雑になってしまいました。
要はT1,T2どちらかの型が暗黙変換不可であるとき、コンストラクタにexplicitを付けます。

explicit(bool)を使うと・・・

template<typename T1, typename T2>
struct pair {

  template<class U=T1, class V=T2,
    enabler<
        std::is_constructible<T1, U&&>,
        std::is_constructible<T2, V&&>
    > = nullptr
  >
  explicit(!(std::is_convertible_v<U, T1> && std::is_convertible_v<V, T2>))
  constexpr pair(U&& x, V&& y);

};

要件を判定しSFINAEする部分と、Perfect Initializationする部分とが分離していくらか見やすくなりました。そして1つのコンストラクタ定義で済むようになります。

std::optionalの場合

同じ問題が起きるstd::optionalのコンストラクタの(7)を見てみます。

このコンストラクタには要件があります。

型Tの選択されたコンストラクタがconstexprであれば、このコンストラクタもconstexprとなる
型Uから型Tがムーブ構築可能でなければ、このオーバーロードオーバーロード解決の候補から除外される
型Uから型Tに暗黙的に型変換ができる場合、このオーバーロードは非explicitとなる。
型Uから型Tに明示的な型変換ならできる場合、このオーバーロードはexplicitとなる

§23.6.3.1 Constructors [optional.ctor] - N4659も見るとより詳細が分かります。

これを考慮したうえでPerfect Initializationすると、おおよそ以下の様な宣言になります。

//与えられた条件全てがtrueの場合に有効化
template<typename... Cond>
using enabler = std::enable_if_t<std::conjunction<Cond...>::value, std::nullptr_t>;

template<typename T>
class optional {

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
      std::is_convertible<U&&, T>
    > = nullptr
  >
  constexpr optional(U&& rhs);

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
      std::negation<
        std::is_convertible<U&&, T>
      >
    > = nullptr
  >
  explicit constexpr optional(U&& rhs);

};

もう見るのも嫌ですね。2つを書かされる差は説明してきたようにexplicitを付けるためのstd::is_convertible<U&&, T>の結果がtrueなのかfalseなのかの所だけです。

ではexplicit(bool)を使ってみれば・・・

template<typename T>
optional {

  template<typename U=T,
    enabler<
      std::is_constructible<T, U&&>,
      std::negation<
        std::is_same<std::decay_t<U>, std::in_place_t>
      >,
      std::negation<
        std::is_same<std::optional<T>, std::decay_t<U>>
      >,
    > = nullptr
  >
  explicit(!std::is_convertible_v<U&&, T>)
  constexpr optional(U&& rhs);

};

要件を記述するSFINAE部はどうしようもありませんが、記述が一つにまとまり、explicitとなる条件が分離されて見やすくなっています。

これらのように、explicit(bool)があると自分で別の型をラップするような型を作る際にコンストラクタをいくらか簡単に書くことができるようになります。
特に、optionalの例のように複雑な要件が絡む場合に強い恩恵を感じることができるかと思います。

参考文献

この記事のMarkdownソース

[C++]std::from_charsにおける先頭スペースの扱いについて

C++17で追加されたstd::from_charsはその効果の説明においてCライブラリのstrtol、strtod関数を参照し、そこにいくつか制限を加えた形で説明されています。

ところで、strtol、strtod関数は共に入力文字列の先頭にあるスペースを読み飛ばすようになっています。std::from_charsの説明を単純に読むと先頭スペースの扱いについて何も書かれていないので同じようになるように思えます。
しかし実際にはstd::from_charsは先頭スペースの読み飛ばしを行いません。

それについては少し厄介な書き方をされているため、この記事はそこを読み解くためのメモです。

C++17 std::from_chars

C++17(N4659) §23.2.9 [utility.from.chars]のstd::from_chars関数の効果は以下のように記述されています。

整数型のオーバーロードの効果

The pattern is the expected form of the subject sequence in the "C" locale for the given nonzero base, as described for strtol, (以下略

浮動小数点型のオーバーロードの効果

The pattern is the expected form of the subject sequence in the "C" locale, as described for strtod, (以下略

両方をまとめてざっと訳すと

用いるパターンは、strtol(strtod)で説明されているCロケールによるsubject sequenceのexpected formである

そして、See also: ISO C 7.22.1.3, ISO C 7.22.1.4.と終わりに添えられています。

(subject sequenceは説明のため、expected formはピッタリな訳が思いつかないのでそのままにしておきます・・・)

C11 strtol, strtod

C11(N1570) §7.22.1.3, §7.22.1.4 のstrtol, strtod関数の所には以下のように記述されています(長いので一部抜粋)。

strtod

First, they decompose the input string into three parts: an initial, possibly empty, sequence of white-space characters (as specified by the isspace function), a subject sequence resembling a floating-point constant or representing an infinity or NaN; and a final string of one or more unrecognized characters, including the terminating null character of the input string.
~中略~
The subject sequence is defined as the longest initial subsequence of the input string, starting with the first non-white-space character, that is of the expected form. The subject sequence contains no characters if the input string is not of the expected form.

strtol

First, they decompose the input string into three parts: an initial, possibly empty, sequence of white-space characters (as specified by the isspace function), a subject sequence resembling an integer represented in some radix determined by the value of base, and a final string of one or more unrecognized characters, including the terminating null character of the input string.
~中略~
The subject sequence is defined as the longest initial subsequence of the input string, starting with the first non-white-space character, that is of the expected form. The subject sequence contains no characters if the input string is empty or consists entirely of white space, or if the first non-white-space character is other than a sign or a permissible letter or digit.

浮動小数点と整数とで多少の違いはあれど同じようなことが書かれているのでまとめて訳すと

まず入力文字列を3つの部分に分解する。
初めに、isspace関数で識別されるホワイトスペースのシーケンス(空でも可)
次に(浮動小数点数もしくは整数型の文字列を含む)subject sequence
最後に、残った1文字以上の識別されない文字列(終端の\0を含む)

subject sequenceは最初のホワイトスペース以外の文字で始まる入力文字列の部分文字列として定義される。それはexpected formである。
入力文字列がexpected formでない場合、subject sequenceは空になる。

subject sequence

この両方を参照すると、C++std::from_charsの効果の説明におけるsubject sequenceという言葉はCのstrtol, strtodにおけるsubject sequenceを指している事が分かります。
そして、subject sequenceは先頭のホワイトスペースのシーケンスを除いて最初に現われる、想定するパターンにマッチする文字列から構成されます。すなわち、subject sequenceには先頭ホワイトスペースの文字列は含まれていません。

また、std::from_charsが入力文字列中からまず探すのはsubject sequence(のexpected form)であり、先頭ホワイトスペースについては何ら記述がありません。

これらの事からようやく、std::from_charsは入力文字列先頭に1つ以上のホワイトスペースがある場合にそれを読み飛ばすことはしない、という仕様を読み取ることができます。

参考文献

この記事のMarkdownソース

[C++]ラムダ式と単項+演算子

状態を持たない(つまり、キャプチャをしていない)ラムダ式は暗黙的に同じシグネチャの関数ポインタに変換することができます。
しかし、テンプレートパラメータの推論等のタイミングでは暗黙変換以前にラムダ式の生成する関数オブジェクトとしての型が推論されてしまい、関数ポインタとしてラムダ式を受け取ろうとしてもそのオーバーロードにはマッチしません。

template<typename F>
void invoke(F&& f) {
  std::cout << "call functor." << std::endl;
  std::cout << f() << std::endl;
}

template<typename F>
void invoke(F* f) {
  std::cout << "call function pointer." << std::endl;
  std::cout << f() << std::endl;
}

int main()
{
  invoke([]{ return 10; });
}

/* 出力
call functor.
10
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ごくたまに、こういう時に何とかして関数ポインタの方に行ってほしいことがあります。そんな時、わざわざ関数ポインタの型をusingしてキャストして...としなくても次のように頭に+を付けることで解決できます。

template<typename F>
void invoke(F&& f) {
  std::cout << "call functor." << std::endl;
  std::cout << f() << std::endl;
}

template<typename F>
void invoke(F* f) {
  std::cout << "call function pointer." << std::endl;
  std::cout << f() << std::endl;
}

int main()
{
  invoke( []{ return 10; });
  invoke(+[]{ return 20; });
}

/* 出力
call functor.
10
call function pointer.
20
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

見た目からすると、ラムダの頭に+を付けることで明示的に関数ポインタへ変換しているような気分になれます。

組み込みの単項+演算子の挙動

C++17規格の組み込み単項+演算子を見てみると次のようにあります。

The operand of the unary + operator shall have arithmetic, unscoped enumeration, or pointer type and the result is the value of the argument. Integral promotion is performed on integral or enumeration operands. The type of the result is the type of the promoted operand.

重要なところだけをなんとなく訳すと(powered by google翻訳

単項+演算子の引数型は算術型、スコープ無し列挙型、ポインタ型のいずれかでなければならず、結果は引数をそのまま返す。

重要なのは任意のポインタ型に対して単項+演算子が用意されている、という所です。

つまり、単項+ラムダ式に対して特別に用意されているわけではなく、ラムダ式+を適用すると、暗黙の型変換により関数ポインタに変換され、その関数ポインタが結果として返されているわけです。

上記のように他のものに対しての単項+演算子は恒等写像みたいなもので、単項-演算子の対としての役割しかないので、単項+演算子が有効に使える唯一のケースかと思われます(整数昇格される、という所も何か役立つかもしれません)。

使いどころ?

あまり意味がないと言えばそんな気もするのですが、std::functionと関数ポインタを取る関数をオーバーロードとして別々に分けているとき、そこにラムダ式を渡す場合に役立つかもしれません。

template<typename... Args>
void invoke(const std::function<void(Args...)>& f, Args&&... args) {
  std::cout << "call std::function." << std::endl;
  f(std::forward<Args>(args)...);
}

template<typename... Args>
void invoke(void(*f)(Args...), Args&&... args) {
  std::cout << "call function pointer." << std::endl;
  f(std::forward<Args>(args)...);
}

int main()
{
  //オーバーロード候補二つがマッチするため、コンパイルエラー
  invoke( [](int n, double d){ std::cout << n << ", " << d << std::endl; }, 10, 3.14);
  
  //常に関数ポインタを受け取る方が選ばれる
  invoke(+[](int n, double d){ std::cout << n << ", " << d << std::endl; }, 20, 2.72);
}

上 - [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
下 - [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

こういうことをしたいときのほかにも、Cなインターフェースを持ったライブラリにラムダを渡そうとするときにも役に立つことがあるかもしれません。

参考文献

謝辞

この記事の9割は以下の方々によるご指摘で成り立っています。

この記事のMarkdownソース

[C++]丸かっこによる集成体初期化

※この記事はC++20を相談しながら調べる会 #1の成果です。

※内容はC++20正式策定までに変化する可能性があります。

集成体初期化(Aggregate Initilization)とは、配列か集成体(Aggregate)となる条件を満たしたクラスに対して行える特別な初期化方法の事です。

C++17まではこれは波かっこ"{}"の時にのみ使用することができ、丸かっこ"()"による初期化は常にその型のコンストラクタを呼び出していました。
しかし、C++20からはその制限がなくなり丸かっこによる初期化時にも集成体初期化が考慮され、必要なら行われるようになります。

struct aggregate {
  int a;
  double b = -1.0;
};

aggregate a{10, 3.14};  //ok
aggregate b(10, 3.14);  //ok

aggregate c = {20, 2.72};  //ok
aggregate d = (20, 2.72);  //ng

aggregate e(30);  //ok e.a == 30, e.b == -1.0

aggregate f();  //ng これは関数宣言となる

int arr1[]{0, 1, 2, 3};  //ok
int arr2[](0, 1, 2, 3);  //ok

int arr3[] = {0, 1, 2, 3};  //ok
int arr4[] = (0, 1, 2, 3);  //ng

int arr5[4](0, 1) //ok 残りの要素は0で初期化

int arr6[4]();  //ng 必ず1つ以上の初期化子が必要

波かっこの時と同じように、初期化子の数が足りないときはデフォルトメンバ初期化で初期化され、それもない場合は値初期化(デフォルトコンストラクタを呼び出すような初期化)されます。
逆に、初期化子の数が多すぎる場合はコンパイルエラーになります。これも波かっこと同じです。

ただし、丸かっこによる初期化を行う場合はその内部の要素(初期化子)の数は1つ以上なければなりません。そうしないと関数宣言と区別がつかないためです。
@yohhoyさんご指摘ありがとうございました!

丸かっこによる集成体初期化はなるべく波かっこによるものと同じように実行されます。一方で、今までの丸かっこによる初期化の持つ意味が変わらないようにもなっています。
そのため、波かっこによる集成体初期化と少し異なる挙動をするところがあります。

コンストラクタとの競合

集成体初期化と従来のコンストラクタ呼び出しが競合する場合はコンストラクタ呼び出しが優先されます。これは波かっこでも同様ですが、集成体はコンストラクタ宣言を行えないので問題となるのはコピー・ムーブコンストラクタとの競合時です。
そして、この時の挙動が少し異なっています。

struct A;

struct C { 
  operator A();  //実装略
};

struct A {
  C c;
};

C c{};  //cを値初期化

{
  A a(c);  //C::operator A()を呼び、その戻り値からaをムーブ(コピー)構築
  A b(a);  //bをaからコピー初期化
}

{
  A a{c};  //A::cをcからコピー初期化
  A b{a};  //bをaからコピー初期化
}

丸かっこによる初期化においては、あらゆる変換が考慮された(通常のオーバーロード解決を行った)うえで、マッチングするコンストラクタが見つからないときに集成体初期化が行われます。

波かっこによる初期化においては、渡された初期化子リストの要素が一つであり、その要素が初期化しようとしている型Tもしくはその派生型である場合にのみ、その要素からTをコピー・ムーブ初期化します。
それ以外の場合はすべて集成体初期化が行われます。

このように微妙ではありますが初期化方法が選択されるまでの手順が異なります。とはいえ、ただ1つの要素で初期化しようとしたときにのみ起こる事なのであまり出会わないでしょう。

これは、丸かっこによる初期化の持つ意味を変更しないようにしているために生じています。
配列は元々丸かっこ初期化を持っておらず挙動が曖昧にはならないため、配列の初期化時はこの問題は起きません。

縮小変換の許可

縮小変換とは変換後の型が変換前の型の表現を受け止めきれないような型の変換です(double -> float, signed -> unsigned 等)。
波かっこによる初期化時は集成体初期化でなくても、縮小変換が禁止されていました。それは思わぬところで変換エラーを引き起こし、特にテンプレート関数の中では波かっこ初期化は非常に使いづらくなってしまっていました。

template<typename T>
float to_float(T v) {
  return float{v};
  //こうするとok
  //return float(v);
}

auto d = to_float(3.14);  //compile error!
auto e = to_float(3.14f); //ok.


constexpr char str[50]{};
constexpr auto begin = std::begin(str);

if (auto [end, err] = std::to_chars(begin, std::end(str), 3.141592653589793); err == std::errc{}) {
  std::cout << std::string_view{begin, end - begin};  //compile error!
  //こう書くとok
  //std::cout << std::string_view(begin, end - begin);
}

これらのエラーは波かっこ初期化時には縮小変換が禁止されていることから発生しています。
しかし、丸かっこによる集成体初期化においてはそのような制限はなく、あらゆる変換が考慮され実行されます。

これは同じ丸かっこによる初期化において、コンストラクタ呼び出しと集成体初期化とで挙動が異なることがないようにするためにこうなっています。

ネストするかっこの省略(できない!)

ネストする波かっこ省略について → 宣言時のメンバ初期化を持つ型の集成体初期化を許可 - cpprefjp

波かっこ初期化時はネストしている内部の型に対する波かっこ初期化時に、一番外側以外の波かっこを省略できます。しかし、丸かっこではできません・・・。
また、ネストする初期化のために丸かっこを使うと意図しない結果になります。何故かというと、ネストする丸かっこにはすでに意味があるからです。

//この二つは同じ意味
int arr1[2][2]{{1, 2}, {3, 4}}; //ok
int arr2[2][2]{1, 2, 3, 4};     //ok

//丸かっこはこうするしかない
int arr3[2][2]({1, 2}, {3, 4}); //ok

//できない・・・
int arr4[2][2](1, 2, 3, 4);    //ng
int arr5[2][2]((1, 2), (3, 4)); //ng (2, 4)と書いたのと同じになるがどのみちできない

おそらく3次元以上の配列の場合は丸かっこ内の波かっこのさらに内側では波かっこを省略できます。そんな配列初期化は普通しないと思うのであまり意味は無いですが・・・

そして、丸かっこ初期化の内側でさらに丸かっこを使う場合は、通常のかっこに囲まれた式として処理されてしまい、内側のカンマはカンマ演算子として解釈されます。

クラス型の集成体の場合

//この二つは同じ意味
std::array<int, 3> arr1{{ 1, 2, 3 }}; //ok
std::array<int, 3> arr2{ 1, 2, 3 };   //ok

//丸かっこはこうするしかない
std::array<int, 3> arr3({ 1, 2, 3 }); //ok

//できない・・・
std::array<int, 3> arr4( 1, 2, 3 );   //ng
std::array<int, 3> arr4(( 1, 2, 3 )); //ng arr4(3)と同じ、どのみちできない

丸かっこ初期化の内側に丸かっこを使えないのはおそらくどうしようもないですが、波かっこ省略はそのうち可能になるような気はします。

一時オブジェクトの寿命延長(されない!)

丸かっこによる集成体初期化時は、渡された初期化子リスト内の一時オブジェクトの寿命が延長されません。ドラフト規格文書より、以下のコードをご覧ください。

struct A {
  int a;
  int&& r;
};

int f() { 
  return -1;
}

int n = 10;

A a1{1, f()};                   // OK, lifetime is extended
A a2(1, f());                   // well-formed, but dangling reference
A a3{1.0, 1};                   // error: narrowing conversion
A a4(1.0, 1);                   // well-formed, but dangling reference
A a5(1.0, std::move(n));        // OK

縮小変換(narrowing conversion)によるエラーはここでは関係なく、a2, a4の初期化後の右辺値参照メンバの状態が問題です。

波かっこによる集成体初期化においては渡された一時オブジェクト(f()の戻り値やリテラル1)が右辺値参照メンバA::rを初期化すると、その一時オブジェクトの寿命は参照A::rの寿命と同じになります(延長される)。

しかし丸かっこによる集成体初期化時はそうはなりません。その初期化式が終了すると、そのような一時オブジェクトはそこで死にます(寿命が尽きる)。
すなわち、そのように初期化された右辺値参照メンバは不正な参照となってしまい、これへのアクセスは未定義動作となります。

a5の初期化にあるように、すでに初期化済みの変数でこういう事をしたい場合にはきちんとmoveすることでこの罠を回避することができます(ただし、リテラルや関数の戻り値はmoveしても回避できない)。

右辺値参照メンバなんてものはそうそう使うことはないでしょうが、これがconst 左辺値参照メンバならばたまに使う事があるでしょう。その際も同じ罠が待ち構えていることになるので注意せねばなりません・・・

少し詳細な考察

サンプルコードのコメントにもある通り、右辺値参照メンバA::rf()の戻り値やリテラル1というprvalueで初期化するときに問題が起きています。

まず、prvalueを右辺値参照(もしくはconst左辺値参照)に束縛する(結びつける)とxvalueな一時オブジェクトに変換されたうえで、結びつけられます。

波かっこ初期化ではそのような一時オブジェクトは集成体要素の右辺値参照(A::r)に直接結び付ける(形になる)ため、その一時オブジェクトの寿命は結びつけた参照の寿命まで延長されます。

しかし、丸かっこによる集成体初期化ではそのような一時オブジェクトの寿命の延長がなく、その一時オブジェクトが生成された後の最初のセミコロンまでしか延長されません(明確に規定されています)。
そのため、初期化の完了後に一時オブジェクトの寿命は尽きることになり、メンバの参照は不正な参照となります。

一方lvaluen)をmoveした後の値(xvalue)は一時オブジェクトではないので、先ほどの規則には当てはまらず、右辺値参照(const左辺値参照)に束縛すればその参照の寿命まで寿命延長されます。

なぜこのような謎な仕様になっているかははっきりとしませんが、従来の丸かっこによるコンストラクタ呼び出しとの一貫性を確保するためだと思われます。
コンストラクタでメンバの右辺値参照を初期化するときは、一時オブジェクト等はコンストラクタ引数の右辺値参照でいったん受けてから、メンバ初期化子リストでmoveすることになります。

struct A {
  A(int&& arg)
    : r(std::move(arg))
  {}

  int&& r;
};


int n = 10;

//共にA::rが不正な参照になることはない
A a(1);
A b(std::move(n));

これであれば、2回寿命延長が入ることで結果的にその参照の寿命まで一時オブジェクトの寿命は延長されることになります。

丸かっこによる集成体初期化時もこの様な挙動を前提にしており、一旦コンストラクタ引数で受けてから各メンバの初期化を行うような挙動をとります(実際にそのように行われる訳ではありません)。その際、moveするかしないかを引数型から推測することには問題があるため、メンバ初期化リストでのmoveは行われず、2度目の寿命延長が発生しません。
その結果初期化終了後に一時オブジェクトの寿命が尽きることになるのだと思われます。

Designated Initialization(できない!!)

Designated Initializationについて → Designated Initialization @ C++ - yohhoyの日記

Designated Initialization(指示付初期化)は同じくC++20より可能になる集成体初期化の新しい形です。
その名の通り、集成体要素を直接指定する形で初期化を行います。

struct aggregate {
  int a = -10;
  double b;
};

aggregate a = {.a = 10, .b = 3.14};

union U {
  char c;
  float f;
};

U u = {.f = 2.72};

この様に、どの変数をどの初期化子で初期化しているのかが見やすくなり、特に共用体においては最初に初期化するアクティブメンバを選択できるようになります。

しかし、残念なことに、このDesignated Initializationは波かっこによる集成体初期化時にしか使えません。丸かっこでは、できません・・・・

//共にコンパイルエラー!!
aggregate a(.a = 10, .b = 3.14);
U u(.f = 2.72);

引数の評価順序

丸かっこによる集成体初期化時、渡された初期化子リスト内の要素の評価順序は左から右と規定されます。
これは波かっこと異なる動作ではなく同じ動作で、むしろ今までの丸かっこによるコンストラクタ呼び出しと異なる動作です。

詳細には、集成体(クラス、配列)のn個の要素を先頭(基底クラス→メンバの順)から1 <= i < j <= nとなるように添え字付けしたとして、i番目の要素の初期化に関連するすべての値の計算(value computation)及び副作用(side effect)は、j番目の要素の初期化の開始前に位置づけられる(sequenced before)、ように規定されます。

つまり、かっこの種類にかかわらず集成体初期化を行う場合は、初期化子に与えた式の順序に依存するようなコードを書いても未規定状態にならず意図したとおりの結果を得ることができます。

{
  int i{};

  int array[]{++i, ++i, ++i, ++i};
  //array = {0, 1, 2, 3}
}

{
  int i{};

  int array[](++i, ++i, ++i, ++i);
  //array = {0, 1, 2, 3}
}

//集成体でない
struct int_4 {
  int_4(int a, int b, int c, int d)
    : a1{a}, a2{b}, a3{c}, a4{d}
    {}

  int a1, a2, a3, a4;
};

{
  int i{};

  int_4 m{++i, ++i, ++i, ++i};
  //m = {0, 1, 2, 3}
}

{
  int i{};

  //unspecified behavior, a1~a4にどの値が入るか(++iがどの順で実行されるか)は未規定
  int_4 m(++i, ++i, ++i, ++i);
}

//集成体
struct agg_int_4 {
  int a1, a2, a3, a4;
};

{
  int i{};

  agg_int_4 m(++i, ++i, ++i, ++i);
  //m = {0, 1, 2, 3}
}

@yohhoyさんご指摘ありがとうございました!

この様に、丸かっこによる初期化時にコンストラクタを呼び出したときは相変わらず未規定の動作となってしまう点は注意です。

この変更の目的

この様になんだか複雑さを増した上に影響範囲がでかそうな変更をなぜ行ったのかというと、STLにおけるmake_~系やemplace系の関数に代表される、内部で要素を構築するような関数において、集成体初期化が行われないことをどうにかするためです。

例えばstd::make_from_tuple関数の実装例を見てみると

template<class T, class Tuple, std::size_t... Index>
constexpr T make_from_tuple_impl(Tuple&& t, std::index_sequence<Index...>){
  //ここで、Tのコンストラクタを呼びだしている
  return T(std::get<Index>(std::forward<Tuple>(t))...);
}

template <class T, class Tuple>
constexpr T make_from_tuple(Tuple&& t) {
  return make_from_tuple_impl(std::forward<Tuple>(t), std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{});
}

make_from_tuple_impl内でTを"()"で初期化することでコンストラクタを呼び出しています。"{}"ではないので集成体初期化が行われることはありません。
これは、与えられた引数(この場合はtに含まれる要素)が空か、Tかその派生型のただ一つだけ、で無ければ集成体は構築できないことを意味しています。
じゃあここを"{}"にすればいいじゃん?と思うかもしれませんが、上で述べた縮小変換が禁止されていることによって多くのケースで謎のエラーが発生することになるのでそれは解決にならないのです。

また、もう一つのケースとして集成体を要素とするコンテナを扱う時にも同じ問題が起こります。

//集成体
struct aggregate {
  int n;
  double d;
  char c[5];
};

int main() {
  std::vactor<aggregate> vec{};

  vec.emplace_back(10, 1.0, "abc");   //compile error!
  vec.emplace_back(aggregate{10, 1.0, "abc"});  //ok
}

emplace_backは要素型のコンストラクタ引数を受け取って、内部で直接構築する関数です。その際、呼び出すのは丸かっこによるコンストラクタであり、集成体初期化を行いません。

結果、1つ目のemplace_backコンパイルエラーとなります。しかもこのエラーはSTL内部で発生することになるので、一見すると意味の分からないものになってしまいます。

例:clang 8.0.0のエラー例 [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

/opt/wandbox/clang-8.0.0/include/c++/v1/memory:1826:31: error: no matching constructor for initialization of 'aggregate'
            ::new((void*)__p) _Up(_VSTD::forward<_Args>(__args)...);
                              ^   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/opt/wandbox/clang-8.0.0/include/c++/v1/memory:1718:18: note: in instantiation of function template specialization 'std::__1::allocator<aggregate>::construct<aggregate, int, double, char const (&)[4]>' requested here
            {__a.construct(__p, _VSTD::forward<_Args>(__args)...);}
                 ^

このエラーをよく見ると、placement newによる要素構築時に丸かっこを用いている事が分かるでしょう。

これらのケースだけでなく、他のSTLコンテナやstd::optional等Vocabulary typesにもemplace系関数があり、std::pairのpiecewise constructなど他の直接構築系の操作においても同様の問題が発生しており、この解決のために丸かっこによる集成体初期化が許可されました。

この変更によってこれらの関数は集成体を問題なく内部で構築できるようになり、丸かっこと波かっこの間の初期化に関するセマンティクスの一貫性が少し改善されることになります(むしろ悪化・・・?)。

参考文献

この記事のMarkdownソース

[C++]非型テンプレートパラメータたりうるには

※この内容はC++20より有効なものです。現行(C++17)ではまだ1ミリも利用可能な情報ではありません。また、随時記述が変更される可能性があります。

こちらの記事との関連があるので、予め目を通しておくと良いかもしれません。 onihusube.hatenablog.com

非型テンプレートパラメータとなれるもの

C++17では以下のものが非型テンプレートパラメータとなることができます。

  • 整数型
  • 列挙型
  • オブジェクト・関数ポインタ
  • オブジェクト・関数参照(左辺値参照のみ)
  • メンバポインタ
  • std::nullptr_t

C++20では、主に以下の2つのものが非型テンプレートパラメータとなることができるようになります。

  • strong structural equalityなリテラル
  • 左辺値参照型

減ってるように見えますが、参照型以外が一つ目にまとめられただけです。
そして、任意のクラス型(共用体を含まない)がstrong structural equalityであり、constexprに構築可能であるとき、そのクラスのオブジェクトを非型テンプレートパラメータとして利用することができるようになります。

そのような非型テンプレートパラメータとして渡されたオブジェクトはconstなglvalueオブジェクトとなり、異なる非型テンプレートパラメータ毎に唯一つのインスタンスがプログラム内に存在します。当然、メンバ関数呼び出しやアドレス取得が可能です。

こんなことができる・・・かもしれません。

//何か値のペアを受け取り出力する
template<auto Pair>
void f() {
   std::cout << std::get<0>(Pair) << ", " << std::get<1>(Pair) << std::endl;
}

f<std::pair<int, int>{10, 20}>();
f<std::tuple<int, int>{10, 20}>();

//固定文字列型
template<typename CharT, std::size_t N>
struct fixed_string {
   constexpr fixed_string(const CharT (&array)[N + 1]) {
      std::copy_n(array, N + 1, str);
   }

   auto operator<=>(const fixed_string&) = default;
   //auto operator==(const fixed_string&) = default;
   
   CharT str[N + 1];
};

template<typename CharT, std::size_t N>
fixed_string(const CharT (&array)[N]) -> fixed_string<CharT, N-1>;

//fixed_stringを出力
template<fixed_string Str>
void g() {
   std::cout << Str.str<< std::endl;
}

g<"Hello World!">();
g<"<=> Awesome!">();

strong structural equality(強い構造的等価性)?

ところで、ぽっと出のstrong structural equalityなる横文字は一体何なのでしょうか・・・?

ある型Tがstrong structural equalityであるとは以下のどちらかを満たしているときです。

  • Tがクラス型でない場合
    • cosnt Tの値aについてa <=> aが呼び出し可能
    • その結果となる比較カテゴリ型がstd::strong_­orderingstd::strong_­equalityのどちらか
  • Tがクラス型の場合
    • Tのすべての基底型及び非staticメンバ変数がstrong structural equalityである
    • mutable及びvolatileな非staticメンバ変数を持たない
    • Tの定義の終了点で、cosnt Tの値aについてa == aオーバーロード解決が成功し、publicなものかfrineddefault実装の==が見つかる

Tがクラス型でない場合というのは組み込み型の場合の事で、組み込みの宇宙船演算子の比較カテゴリ型がstd::strong_­equalityに変換可能であればいいわけです。
浮動小数点型およびvoid以外のすべての型がstrong structural equalityになります。

任意のクラス型の場合はpublicfrinedなdefault実装のoperator==を持っていて、すべての非staticメンバ変数がstrong structural equalityで(mutable、volatileはng)、基底クラスもそのようになっていればstrong structural equalityになります。
ただし、この==はあくまでstrong structural equalityであることの表明のために必要なだけで実際に比較をするわけではなく、また実際の比較結果によってstrong structural equalityであるかどうか決定されるわけではありません。

これらの諸条件は、浮動小数点型をメンバに持たずにoperator==をdefault実装していれば、おおよそのケースで満たすことができるはずです。
ただし、参照型がメンバにある時とUnion-likeな型ではdefaultoperator==は暗黙deleteされているので注意です。

その上で、非型テンプレートパラメータとして利用するためにはTリテラル型である必要があります。
C++17基準ならば、コンパイル時に構築可能(初期化が定数式で可能)でなくてはなりません。

クラス型のoperator==のチェック

オーバーロード解決が成功し使用可能な==が見つかる、という遠回りな言い回しになっているのは、defaultで定義されていても削除されていたりアクセスできないケースのためです。 例えば任意の型を保持するようなクラステンプレートでは、その型次第でoperator==は削除されている可能性があります。

そのような型には例えばstd::pairがあります。そして、std::pairは参照型を保持することができます。

//std::pairの参考用簡易実装
template <typename T, typename U>
struct pair {
    T first;
    U second;

    //デフォルト実装のみを提供すると・・・
    friend constexpr bool operator==(const pair&, const pair&) = default;
};

int i = 42, j = 42;
pair<int&, int> p(i, 17);
pair<int&, int> q(j, 17);
assert(p == q);   //C++17までは有効なコード、しかし上記実装だと==はdeleteされているため比較不可能

このような場合にはoperator==のデフォルト実装はもはや出来ず、参照型用の実装を追加で提供する必要があります。

template <typename T, typename U>
struct pair {
    T first;
    U second;

    friend constexpr bool operator==(const pair&, const pair&) = default;
    
    //参照型用の実装、T,Uのどちらかが参照型ならばより特殊化されているこちらが優先される
    friend constexpr bool operator==(const pair& lhs, const pair& rhs)
       requires (is_reference_v<T> || is_reference_v<U>)
    {
       return lhs.first == rhs.first && lhs.second == rhs.second;
    }
};

このようにしておけば参照型に対しても==による比較を提供できますが、同時にdefaultoperator==も存在はしています。 このような場合に、オーバーロード解決を用いて使用可能なoperator==をチェックすることで非型テンプレートパラメータとして使用されてしまうことを防止しています。

なぜstrong structural equalityなリテラル型なのか?

それは、二つのインスタンス化されたテンプレートがあるとき、その等価性を判断するためです。

関数・クラステンプレートはODR遵守のために、複数の翻訳単位にわたってその定義がただ一つとなるようにコンパイルされます。それは多くの場合、コンパイラによる名前マングルとリンカによる重複削除によって実現されます。

非型テンプレートパラメータを与えられた関数やクラス名をどうマングルするのかというと至極簡単で、その型名と定数値をマングル名に埋め込みます。そのうえでリンカがそのマングル名のマッチングにより重複定義を削除し、全ての翻訳単位にわたって定義がただ一つになるようにします。

そのため、非型テンプレートパラメータとなれる値は定数でなければならず、operator==による比較は同値関係ではなく等価関係でなければなりません。つまり、その定数表現(マングル名に埋め込む値の表現)が異なる値に対して==trueとなったり、同じ値となる筈なのにその定数表現が異なっていたりする型は非型テンプレートパラメータとして認めることは出来ません。

そのような型の典型例は浮動小数点型です。浮動小数点の定数表現はおそらくバイナリ列(2進浮動小数点表現)になると思われますが、丸めの影響で10進固定小数点表現とバイナリ列は必ずしも1対1対応せず、計算を行うとその影響はさらに複雑になります。
また、+0.0-0.0のようにバイナリ列も見た目も異なるがoperator==による比較がtrueとなる値があり、NaNという一見同じ値なのに内部表現がいくつもある値も持っており、これらの問題から今後も浮動小数点数を非型テンプレートパラメータとして渡すことは出来ないでしょう・・・。

この様に、何も考えずにクラス型を非型テンプレートパラメータとして許可してしまうと、名前マングルだけではテンプレートの同一性を判定できなくなります。 全基底及びメンバ単位で正確に等価であるかを確認できればその問題は解決されますが、ユーザー定義されたoperator==(or !=)においてそれを判定するのは困難です。また、比較演算子のdefault実装もC++17以前にはありません。

しかしC++20では宇宙船演算子が導入され、組み込み型の宇宙船演算子の戻り値型は比較の種類を表す比較カテゴリ型となります。それがstd::strong_equalityであれば等価性を保証できます(weak_equalityでは同値であることしか保証できません)。

また、クラス型についてはdefault実装のoperator==が導入され、その実装は全基底及びメンバの辞書式比較で実行されます。
したがって、全基底クラスを含めた全メンバが<=>の比較においてstd::strong_equalityを返すような組み込み型に行きつくかぎり、クラス型についてもその等価性を保証できます。

そしてリテラル型であれば、名前マングルを行う段階ではその定数値はすべて確定しており、そのクラスの全メンバの定数表現の列をマングル名とすることで、非型テンプレートパラメータを含めたマングル名の同一性をリンカが判定できるようになります

そしてそのような保証は、ユーザー定義の比較演算子で行うにはあまりにもコンパイラの多大な努力が必要である事が分かるでしょう。

これらの必要な条件をまとめたものが「strong structural equalityなリテラル型」となるわけです。

参考文献

この記事のMarkdownソース