[C++] constexpr関数がインスタンス化されるとき

P0859R0 評価されない文脈でconstexpr関数が定数式評価されることを規定」を理解するためのメモです。

以下の文章内でのconstexpr関数についてのインスタンス化という言葉はテンプレートにおけるインスタンス化と同じ意味、すなわちconstexpr関数の定義の評価が発生するという意味合いで使用しています。

必要な知識

unevaluated operand(未評価オペランド

unevaluated operandsとは、sizeofdecltypenoexcepttypeidオペランド(引数)として指定されるもの(式)の事で、その名の通りそのオペランドは評価されません。

評価されないとは、そのオペランドに含まれるいかなる計算や関数呼び出しも実行されないということで、そこで参照される関数やクラス等の宣言のみを考慮する(定義が必要ない)ことを意味します。

struct S;

template<typename T>
int f();

int main()
{
  //評価されないのでSとfには定義が必要ない
  decltype(f<S>()) n = sizeof(f<S>()); //int n = sizeof(int);と等価
  std::cout << n;
}

ただし、noexcept以外は型名を指定することができ、その場合の型名オペランドはunevaluated operandではありません。

potentially evaluated(おそらく評価される)

ある式がunevaluated operandでなくその部分式(subexpression)でもないとき、その式はpotentially evaluatedと言われます。評価される(evaluated)のでその式に関わる関数や型には定義が必要になる可能性があります。

template<typename T>
int f();

int main()
{
  //f<int>()はpotentially evaluated、fの定義が必要
  auto n = f(0);
  std::cout << n;
}

つまりは、sizeofdecltypenoexcepttypeid、いずれのオペランドでもない式の事だと思っていいでしょう。

potentiallyというのは、例えば以下のようなとき

if(true) {
  f(0);
} else {
  f(1);
}

この場合、f(0)f(1)も評価される可能性のある文脈に現れていますが、f(1)の方は絶対に評価されません。しかし、この場合でもコンパイラは両方のコードをコンパイルします。この様に、評価されるとは思うけど本当にされるかどうかはわからない、という意味合いでpotentially evaluatedなのだと思われます。

odr-used

大さっぱに言えば、potentially evaluatedな式に含まれている変数や関数はほぼodr-usedとなります。つまりは、定義が必要となる使われ方の事で、odr-usedであれば定義が必要になります。

potentially evaluatedであってodr-usedとならない例は、純粋仮想関数の呼び出しやメンバポインタの形で現れるとき、static constなメンバ変数を定数式でrvalueに変換する場合、最終的に結果が捨てられる形(discarded-value expression)になる場合などです。

struct S;

S* ps{};  //ポインタは不完全型でも宣言可能、odr-usedではない

struct S { 
  static const int x = 0; 
};

decltype(&S::x) p{};  //unevaluated operandなので、odr-usedではない

int f() {
  S::x;  //discarded-value expression、odr-usedではない
  return S::x;  //lvalue -> rvalue変換が定数式で可能、odr-usedではない
}
関数

後ほど重要になってくるので関数だけはもう少し詳しく掘り下げておきます。

関数名がpotentially evaluatedな式に現れるとき、以下の場合にodr-usedされます。

  • 関数は名前探索のただ一つの結果、であるか
  • オーバーロード候補の一つである

ただし、以下を満たしていること

  • その関数が純粋仮想関数ならば、明示的な修飾名で呼び出されている
  • 式の結果がメンバポインタとならない

クラスのコピー・ムーブコンストラクタは、それが最適化などの結果によって省略されたとしても、odr-usedされています。

また、純粋仮想関数ではない仮想関数は常にodr-usedされます。

明示的な修飾名で呼び出されている純粋仮想関数とは、以下のような呼び出しの事です。

struct base {
  virtual int f() = 0;
};

int base::f() {
  return 0;
}

struct derived : base {
  int f() override {
    return 10;
  }
};

base* b = new derived{};

auto n = b->f();        //通常の仮想関数呼び出し、odr-usedでない
auto m = b->base::f();  //明示的な修飾名での呼び出し、odr-used
//n == 10, m == 0

つまり、明示的な修飾名での呼び出しはもはや仮想関数呼び出しではなく、通常の純粋仮想関数の呼び出しはodr-usedではありません。

クラスの特殊メンバ関数が実装されるとき

クラスの特殊メンバ関数とは、デフォルト・コピー・ムーブコンストラクタ、コピー・ムーブ代入演算子、デストラクタ、の事です。

ユーザー定義されていないクラスの特殊メンバ関数コンパイラによって暗黙の宣言が行われ、odr-usedされたときに初めて暗黙に定義されます。
仮に最適化等によってそのodr-usedが最終的に消え去ったとしても、その時点で定義されます。

実は、常に定義されているわけではないのです。

odr-usedされたとき、なのでsizeof等の未評価オペランド内では宣言のみで定義がないことになります。

constexpr関数の実行と評価のタイミング

constexpr関数は定数式から呼び出されたときにインスタンス化され、実行されます。
定数式となるには定数式で現れてはいけない構文が現れなければ定数式となり、コンパイル時に実行可能になります。

これを説明しだすと止まらないので、詳しくは以下をご参照ください。
5.22 定数式(Constant expressions) - C++11の文法と機能(C++11: Syntax and Feature)

しかし、定数実行を行うコンテキストについては規定がありません。そのためコンパイラは可能な限り定数式を処理し、コンパイル時に値を確定させようとします。それが未評価オペランドであっても例外ではありません・・・

問題となるコード例

以下はP0859R0によって欠陥修正されていない世界のお話です。 この変更は欠陥の修正なので過去のバージョンにさかのぼって適用されています。なので、clangやgccでは一部の問題が早い段階から修正されているようです。
そのために問題が確認できるコンパイラが古いものであることがあります。Wandbox様々です。

その1

Core Issue 1581より

struct duration {
  constexpr duration() {}
  constexpr operator int() const { return 0; }
};

int main()
{
  //duration d = duration();
  int n = sizeof(short{duration(duration())});
  std::cout << n;
}

コンパイルエラー : [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

一見コンパイルの通りそうなこのコードは、C++17までの規格に則るとエラーとなります。なぜでしょう?

int n = sizeof(short{duration(duration())});

ここの行に注目すると、sizeofオペランド中のduration(duration())durationクラスのコピー/ムーブコンストラクタを要求しています。durationクラスはそれらを宣言すらしていないので、コンパイラによって暗黙に宣言されています。

しかしsizeofオペランドは未評価オペランドであり、odr-usedではないためdurationのコピー/ムーブコンストラクタは暗黙定義されません。従って、未定義の関数を呼び出す形になるため定数式では無くなります。ただし、宣言はあるのでコンパイルエラーにはなりません。
残るのは、duration -> shortの変換です。これにはduration::operator int()が使われてint -> shortの変換となり、これは縮小変換になるためリスト初期化(波かっこ初期化)において許可されないのでコンパイルエラーになります。

なるほど、一つづつ見ていくと納得の動作ですね。
では次に、その上の行にあるコメントを外してみましょう!

コメントを外すと : [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

コンパイル通りました。なぜでしょうか・・・

duration d = duration();

この行ではdurationクラスの変数宣言とデフォルト構築を行っています。最終的にはデフォルトコンストラクタのみになるとはいえ、形式的には左辺でデフォルト構築した一時オブジェクトを右辺dにムーブして構築する形になります。つまり、ここでdurationのムーブコンストラクタはodr-usedされるので、暗黙に定義されます。

そして次の行に行くわけですが、durationのムーブコンストラクタは既に定義されており、それは暗黙定義のものであり、メンバや基底も特に無いためconstexprになります。その結果durationの構築からintリテラル0の変換までが定数式で実行可能になります。
そこからshortへの変換ですが、定数式であれば縮小変換であっても、変換元の定数値が変換先の型で表現可能でありさえすれば定数実行可能になります。
この結果、sizeofオペランドはすべて定数実行可能であり、shortの定数値0になります。sizeofは結果としてshortのサイズを恙なく出力し、何のエラーも起こりません。

何とも奇妙な振舞です。
これらの奇妙な振舞の根本は特殊メンバ関数(この場合ムーブコンストラクタ)の暗黙定義のタイミングと定数実行が行われるコンテキストが曖昧であることにあります。

その2

P1065r0および、llvm bug 23135より

template<typename T>
int f(T x)
{
    return x.get();
}

template<typename T>
constexpr int g(T x)
{
    return x.get();
}

int main() {

  // O.K. The body of `f' is not required.
  decltype(f(0)) a;

  // Seems to instantiate the body of `g'
  // and results in an error.
  decltype(g(0)) b;

  return 0;
}

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

一つ目のdecltype(f(0)) a;int a;と同じ意味であり、f(0)はconstexprでもないので何の問題もなくコンパイル可能です。

decltype(g(0)) b;も同じであるはずです。しかし、g(0)は定数実行可能でありコンパイラが貪欲に定数化しようとした結果、未評価オペランドのはずなのにg<int>(int)インスタンス化(定義が評価)されてしまい、intメンバ関数getを持たないことからコンパイルエラーを引き起こします。

これは明らかにバグですが、根本原因は定数実行が行われるコンテキストが曖昧であることにあります。

その3

Core Issue 1581より

template<int N>
struct U {};

int g(int);

template<typename T>
constexpr int h(T) { return T::error; }

template<typename T>
auto f(T t) -> U<g(T()) + h(T())> {}

int f(...) { return 0;}

int k = f(0);

失敗例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
成功例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

これは少し複雑ですが、最終的にk = f(0);によって呼ばれるfは引数がellipsisである方が想定されます。

template<typename T> auto f(T t) -> U<g(T()) + h(T())> {}

問題となるのはここの関数定義です。特に変数後置部U<g(T()) + h(T())>operator+の評価順によって分岐します。

その左辺g(T())を先に評価する場合、g(int)はconstexprでなく定義もないので定数実行できません。そのため、定数式ではないことが分かります。そのため、Uの非型テンプレートパラメータは確定できず、その時点で以降の式を評価する必要がないことが分かるためh(T())は評価されず、SFINAEによってもう一つのf()を見に行きます。

右辺h(T())が先に評価される場合は必ずコンパイルエラーを引き起こします。
h<int>(int)単体はconstexpr指定されており、定数実行可能である可能性があります。そのためコンパイラは定義を見に行きます。結果、intはメンバ変数errorを持たないためエラーになりますが、このエラーが引き起こされるところはSFINAEによって継続される文脈ではないのでコンパイルエラーを引き起こしてしまいます。

この場合の問題は、プログラマから見てconstexpr関数のインスタンス化が必要かどうかが不明瞭であることです。つまりは、constexpr関数がインスタンス化されるコンテキストが不明瞭であることが原因です。
エラーになるのかならないのか、はっきりしてほしい所です。

その4

Core Issue 1581より

#include <type_traits>

template <class T>
constexpr T f(T t) { return +t; }

struct A { };

template <class T>
decltype(std::is_scalar<T>::value ? T::fail : f(T()))
  g() { }

template <class T>
void g(...);

int main()
{
  g<A>();
}

失敗例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
成功例: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

この例はより深淵に踏み込みます。

一つ目のg()の戻り値型decltype(std::is_scalar<T>::value ? T::fail : f(T()))の評価タイミングが問題です。

constexpr関数がいつインスタンス化するのか?すなわち、構文解析時にここが評価されたとき、f(T())インスタンス化されるかどうかで結果が変わります。

構文解析時にconstexpr関数がインスタンス化される場合、f(T())インスタンス化され、型Aには単項+演算子オーバーロードはないのでインスタンス化に失敗しエラーとなります。エラー発生個所はSFINAEによって置き換えられる文脈ではないのでコンパイルエラーとなります。

構文解析の後の)定数評価時にconstexpr関数がインスタンス化される、すなわち構文解析時にconstexpr関数インスタンス化が行われない場合、g()の戻り値を決めようとするとA入れ子T::failがないことからエラーとなり、SFINAEによってもう一つのg()が選ばれます。よってコンパイルは恙なく完了します。

この問題も、constexpr関数のインスタンス化がいつ行われるのか?が曖昧であることが原因で、つまりは定数実行されるコンテキストが明確ではないことが原因です。

解決のための変更

上記の問題に共通することは、定数式が実行されるコンテキストが曖昧であるために、constexpr関数がインスタンス化されるタイミングも不明解になってしまっている、という事です。

では、P0859はこれらをどのように解決するのでしょうか?

named by(指名される)

まず、関数のodr-usedについてが以下のように変更されます。

ある関数が、式もしくは何らかの変換の中でnamed byである(指名される)とは

  • 関数は名前探索のただ一つの結果、であるか
  • その式・変換に際し必要となる関数呼び出しについてのオーバーロード候補の一つである

ただし、以下を満たしていること

  • その関数が純粋仮想関数ならば、明示的な修飾名で呼び出されていること
  • 式の結果がメンバポインタとならない

純粋仮想関数ではない仮想関数は常にodr-usedされ、そうでない関数はpotentially-evaluatedな式・変換から指名された(named by)ときにodr-usedされる。

named byという言葉が間に入っただけで実質あまり変わっていません。named byという言葉は後で使います。

potentially constant evaluated(おそらく定数評価される)

ある式がpotentially constant evaluatedであるとは、以下の時です

  • manifestly constant-evaluatedな(間違いなく定数評価される)式
    • 定数式
    • constexpr if文の条件式
    • consteval関数の呼び出し
    • 制約式(コンセプト)
    • 定数初期化(constant initialization)できる変数の初期化式
    • 定数式で使用可能な変数の初期化式
      • constexpr変数
      • 参照型
      • const修飾された整数型
      • enum
  • potentially evaluatedな式
  • リスト初期化子の直接の部分式
  • テンプレートパラメータに依存する変数名に対するアドレス取得
  • 上記いずれかの部分式が、ネストした未評価オペランドの部分式ではない

このルールは定数式が実行されうるコンテキストを定めたものと言えます。これらのコンテキストでは定数式が実行されるかもしれません。

manifestly constant-evaluatedな式とは、std::is_constant_evaluated() == trueとなる式の事でもあります。

ネストした未評価オペランドの部分式とは、sizeofの中にsizeofがあるような場合です。

constexpr int a = 0, b = 1;

auto s = sizeof(sizeof(a + b));   //この場合、`sizeof(size_t)`であることが明らかなので
                                  //定数式`a+b`はpotentially constant evaluatedではない

needed for constant evaluation(定数評価に必要)

ある関数がpotentially constant evaluatedな式から指名(named by)される時、needed for constant evaluationであると言われます。

また、ある変数の名前がpotentially constant evaluatedな式に現れる時、needed for constant evaluationであると言われます。
ただし、そのような変数はconstexpr変数、参照型、const修飾された整数型のいずれかであるときに限ります。

クラスの特殊メンバ関数が実装されるタイミング

ユーザー定義されていないクラスの特殊メンバ関数コンパイラによって暗黙の宣言が行われ、odr-usedされたときに初めて暗黙に定義される、というのが今までの動作でした。

C++20ではそこに加えて、needed for constant evaluationであるときにも暗黙に定義されるようになります。
これにより、未評価オペランドの定数式内であっても暗黙に定義されるようになります。

(関数)テンプレートのインスタンス

合わせて、テンプレートが暗黙的にインスタンス化されるときも若干変更が入ります。
主に、existence of the definition affects the semantics of the program(定義の存在がプログラムのセマンティクスに影響を与えるとき)という条件が追加されます。

クラステンプレートおよびメンバーテンプレートのメンバー関数・変数が明示的特殊化も明示的インスタンス化もされていないとき、以下のどちらかの場合に暗黙的にインスタンス化されます。

  • そのメンバー定義が必要になるコンテキストで参照されたとき(odr-usedされたとき)
  • メンバー定義の存在がプログラムのセマンティクスに影響を与えるとき

明示的特殊化も明示的インスタンス化もされていない関数テンプレートの特殊化、またはfrinde関数テンプレートの定義から生成された宣言は、以下のどちらかの場合に暗黙的にインスタンス化されます。

  • その関数定義が必要となるコンテキストで参照されたとき(odr-usedされたとき)
  • 定義の存在がプログラムのセマンティクスに影響を与えるとき

frinde関数テンプレートの定義から生成された宣言とは、クラス内でfrinde関数の定義を行った場合にその外部名前空間になされる暗黙の関数宣言の事です(この宣言は明示的に行われない限りADLによってのみ参照可能です)。

変数テンプレートに関してはここでは重要でなく、上記二つとほぼ同じ文言なので省略します。

ではこの、「定義の存在がプログラムのセマンティクスに影響を与えるとき」、という何ともあいまいな条件は一体どんな時でしょうか?

テンプレート変数・関数がある式においてneeded for constant evaluationであるとき、「定義の存在がプログラムのセマンティクスに影響を与える」とみなされます。これには以下の場合も含みます。

  • 式を定数評価する必要がないとき
  • 定数式の評価の際に定義が使われないとき

横文字用語が再帰しまくってよく分からなくなってきました・・・

ある関数がneeded for constant evaluationとは、定数評価されうる式からその関数が参照される事です。
そしてそのような式が定数評価の必要がないか(未評価オペランド等)、定数式の評価の際に定義が使われない場合(条件演算子の絶対に評価されない方、等)でも、その関数の定義が評価されます。

すなわち、テンプレート関数はpotentially constant evaluatedな式に出現する場合に確実にインスタンス化される、ということです。

これらの追加された条件は主に定数式内の関数・変数テンプレートがインスタンス化されるタイミングを定めたものであることが分かります。

一緒に書いてあるサンプルコードを見てみましょう。

template<typename T>
constexpr int f() { return T::value; }

template<bool B, typename T>
void g(decltype(B ? f<T>() : 0));

template<bool B, typename T>
void g(...);

template<bool B, typename T>
void h(decltype(int{B ? f<T>() : 0}));

template<bool B, typename T>
void h(...);

void x() {
  g<false, int>(0);  //OK
  h<false, int>(0);  //compile error!
}

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

x()内1行目、g<false, int>(0);はまずg()の1つ目の宣言を見に行きます。そこの引数宣言を見るとdecltype(B ? f<T>() : 0)となっています(B = false)。decltype内の式は未評価オペランドなのでB ? f<T>() : 0はpotentially constant evaluatedではありません(ので、needed for constant evaluationでもありません)。
そのため、f<T>()インスタンス化されずdecltypeの結果は両方の式に共通するintとなり、コンパイルは恙なく完了します。

x()内2行目、h<false, int>(0);はまずh()の1つ目の宣言を見に行きます。その引数宣言はdecltype(int{B ? f<T>() : 0})となっています(B = false)。decltype内の式は未評価オペランドですが、int{B ? f<T>() : 0}にはリスト初期化があります。この内側の式はpotentially constant evaluatedになります(ので、needed for constant evaluationでもあります)。
すると、f<T>()の定義が「プログラムのセマンティクスに影響を与える」のでf<T>()インスタンス化されます。しかし、int入れ子T::valueを持たないのでインスタンス化は失敗し、コンパイルエラーとなります。

結局

この様なややこしい規則と用語の導入によって、定数式が評価されるコンテキストと、constexpr関数及び関数テンプレートがインスタンス化される場所・タイミングが明確に定められました。

それによって先の4つの問題も一定の解決が図られます。

1つ目の問題

  //duration d = duration();
  int n = sizeof(short{duration(duration())});

これはdurationクラスのムーブコンストラクタが定義されるタイミングが問題でした。

このコードのsizeof内は未評価オペランドではありますがリスト初期化があるので、その内側の式はpotentially constant evaluatedになります。 durationクラスのムーブコンストラクタはそこから指名(named by)されているので(すなわちneeded for constant evaluation)暗黙の定義がなされます。
結果全ての定数式の実行及びsizeofの評価は恙なく完了し、上の行のコメントを解除しなくてもこのコードはコンパイルが通るようになります。

2つ目の問題

template<typename T>
constexpr int g(T x)
{
    return x.get();
}

decltype(g(0)) b;

このコードでは貪欲な定数実行の結果g(0)インスタンス化が発生してしまうことが原因でした。

しかし、decltypeの内部は未評価オペランドであり、そこの式(g(0))はpotentially constant evaluatedな式の条件に当てはまっておらず、これは定数式ではありません。そのため、「定義の存在がプログラムのセマンティクスに影響を与える」とはみなされません。
よって、g(0)インスタンス化は発生せず、decltype(g(0))intとなりコンパイルが完了します。

3つ目の問題

template<int N>
struct U {};

int g(int);

template<typename T>
constexpr int h(T) { return T::error; }

template<typename T>
auto f(T t) -> U<g(T()) + h(T())> {}

int f(...) { return 0;}

int k = f(0);

このコードは、関数fの戻り値型計算の過程で、+オペランドをどちらから評価するかでエラーになるかならないか決まる物でした。

U<g(T()) + h(T())>の内部ですが、これは非型テンプレートパラメータの初期化式です。未評価オペランドはありませんので、potentially evaluatedな式であり、定数式である筈です。
したがって、g(T())はodr-usedされており、h(T())は「定義の存在がプログラムのセマンティクスに影響を与える」とみなされることから、両方の関数のインスタンス化が発生します。これはどちらのオペランドから評価をしたとしても、両方のインスタンス化が必要になります。
結果、h(T())インスタンス化はint::errorとなる型は存在せず失敗し、SFINAEによる継続が可能な文脈でもないため、このコードは必ずコンパイルエラーになります。

4つ目の問題

template <class T>
constexpr T f(T t) { return +t; }

struct A { };

template <class T>
decltype(std::is_scalar<T>::value ? T::fail : f(T()))
  g() { }

template <class T>
void g(...){}

g<A>();

このコードは、構文解析時と定数式実行時のどちらでconstexpr関数のインスタンス化が発生するかでエラーになるかが決まる物でした。

問題となるのはg()の戻り値型計算部分ですが、decltypeの内部は未評価オペランドとなるのでpotentially evaluatedではありません。したがってconstexpr関数のインスタンス化は一切発生しません。
結果、g()の戻り値を決めようとするとAT::failという静的メンバがないことからエラーとなり、SFINAEによってもう一つのg()が選ばれます。よってコンパイルは恙なく完了します。
構文解析時と定数実行のタイミングが異なるとかは関係なく、ここでは定数実行もインスタンス化も必要無くなったのです。

この様に、定数式実行のコンテキストとconstexpr(テンプレート)関数のインスタンス化タイミングを明確にすることで、C++標準仕様とは無関係なコンパイラの実装によってエラーとなるかが決まるような奇妙な振る舞いが取り除かれ、エラーとなるケースとそうでないケースが明快かつコンパイラ非依存に決定されるようになりました。

参考文献

この記事のMarkdownソース

[C++] constexprなメモリの確保と解放のために(C++20)

※この内容はC++20から利用可能予定の情報であり、内容が変更される可能性があります。また、constexprなアロケータを作る類の内容ではないです。

前回の記事の「コンパイル時メモリアロケーション」の所に入りきらなかったP0784の内容をまとめたものです。

constexprデストラク

C++17まではデストラクタにconstexprを付けることはおろか、リテラル型として振舞えるクラスにデストラクタを定義することができず、デストラクタのコンパイル時実行もできませんでした。
標準コンテナは例外なくデストラクタがtrivialでない(定義してある)ので、標準コンテナをconstexpr対応させるためにもこの制限は撤廃されます。

C++20より、デストラクタにconstexpr指定が可能になり、そのデストラクタはコンパイル時に実行可能になります。ただし、そのようなクラスは仮想基底を持っては(仮想継承しては)ならず、デストラクタの中身は定数式で実行可能である必要があります。

= default;なデストラクタやtrivialなデストラクタは、メンバや基底クラスのデストラクタが全てconstexprであれば、暗黙的にconstexprとなります。

リテラル型の要件変更

そして、この変更に伴ってリテラル型となるクラスの条件が変更となります。

C++17までは(メンバ変数および基底型は全てリテラル型であることを前提として)、 constexprコンストラクタとtrivialなデストラクタを要求していました。
C++20からは、constexprコンストラクタとconstexprデストラクタを持つこと、という要求に少し緩和されます。
つまり、リテラル型のオブジェクトはコンパイル時に構築・破棄可能である必要があります。

//C++17でのリテラル型の例
struct literal17 {
  //constexprなコンストラクタが少なくとも一つ必要
  //かつ、そこからメンバをすべて定数式で初期化できなければならない
  constexpr literal17()
    : m{}
    , d{}
  {}

  //デストラクタは書けてもdefaultまで
  ~literal17() = default;

  //メンバは全てリテラル型
  int m;
  double d;
};

//C++20でのリテラル型の例
struct literal20 {
  //constexprなコンストラクタが少なくとも一つ必要
  //かつ、そこからメンバをすべて定数式で初期化できなければならない
  constexpr literal20()
    : m{}
    , d{}
    , str{"constexpr string"}
  {}

  //constexprであればデストラクタを書ける
  constexpr ~literal20() {
      //しかしこの例では意味のある処理を書くのがムズカシイ・・・
      m = 0;
      d = 0;
      str.clear();
  }

  //もちろんこう書いてもok
  //~literal20() = default;

  //メンバは全てリテラル型
  int m;
  double d;
  std::string str;  //!?
};

std::stringはこれから説明する変更に伴って全メンバconstexpr指定されるようになるので、リテラル型として扱うことができるようになります。

virtual constexpr destructor

すでにconstexprな仮想関数呼び出しは可能になっていますが、それはあくまでリテラル型自動変数のアドレスをその基底クラスのポインタ/参照に移して呼び出すもので、デストラクタがvirtualである必要はありませんでした。
しかし、constexprデストラクタの導入とそれに伴うリテラル型の要件変更、そしてconstexprなメモリアロケーションによってその前提は崩れます。

つまり、コンパイル時にnewによって確保されたオブジェクトが基底クラスのポインタからdeleteされたとき、実行時と同じようにデストラクタ呼び出しの問題が発生します。
皆様ご存知のように、この解決策はデストラクタをvirtualにしておくことです。

virtualでconstexprなデストラクタは(この後の変更のためにも)必要不可欠なため、許可されます。

struct base {
  virtual int f() const = 0;

  //virtual constexprと書ける!
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr int f() const override {
    return 10;
  }
};


constexpr int new_sample() {
  //この様なことが可能だったとして
  base* d = new derived{};

  int n = d->f();

  delete_func(d);

  return n;
}

constexpr void delete_func(base* ptr) {
  //derived::~derived()がコンパイル時にも正しく呼ばれる!
  delete ptr;
}

int main() {
  constexpr n = new_sample(); //定数式で実行
}

constexprなnew式/delete式

標準コンテナをconstexpr対応させるとなると一番問題となるのが動的なメモリアロケーションです。これを定数式で認めなければ標準コンテナはコンパイル時に利用できません。そこで、一定の制限の下でコンパイル時の動的メモリ確保が認められるようになります。

constexpr関数等をコンパイル時に実行する際、未定義動作が検出された場合にはコンパイル時実行不可能 になります。そのため、コンパイラはそれを可能な限り検出しようとします。
ところが、動的なメモリ確保につきものなのがvoidポインタから別のポインタへのキャストです。

//operator new / operator delete のうちの一つ
void* operator new(std::size_t);
void  operator delete(void* ptr) noexcept;

//std::malloc / std::free
void* malloc(std::size_t size);
void  free(void* ptr);

通常メモリ確保に使われるこれらは、見てわかるようにvoid*への/からのキャストが必要です。

ポインタのキャストという行為が容易に未定義動作を踏み得る(strict aliasing rulesなど)上にそれを検出しづらいこともあって、現在定数式でそれは許可されていません。そして、C++20でも許可されません。
しかし、C++には見た目上ポインタのキャストを必要とせずにメモリ確保と解放を担う式があります。つまり、new/delete式です。

(new式(new expression)とnew演算子(operator new)の違いについて → 動的メモリ確保 - 江添亮の入門C++

new式は任意の型のメモリ領域の確保と構築、delete式は(new式で確保された)任意の型の破棄とそのメモリ領域の解放を行ってくれます。そして、これらの式の入力及び出力においてはなんらポインタの再解釈は行われません。

このnew/delete式であれば確実に不正なポインタの再解釈は行われない事が分かるため、これらの式に限ってconstexprでコンパイル時実行可能になります。

ただし、呼び出せるのグローバルなoperator newを利用するようなnew式のみで、そうでないnew式の呼び出しはコンパイル時には常に省略されます(クラススコープのoperator newオーバーロードがある場合など)。
この省略はC++14より許可されているnew式の最適化の一環として行われます。省略された場合、別の領域をあてがわれるか別のnew式の確保したメモリを拡張して補われます。
省略とはいっても何もなされなくなるわけではありません。

また、コンパイル時に割り当てたメモリはコンパイル時に確実にdeleteされる必要があり、そうなっていないnew式の呼び出しはコンパイル時実行不可となります。

delete式についても、コンパイル時にnew式で確保されたメモリを開放するもの以外はコンパイル時実行不可となります。

struct base {
  virtual bool f() const = 0;
  
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr bool f() const override {
    return false;
  }
};

constexpr bool allocate_test1() {
  base* d = new derived{};
  auto b = d->f();
  delete d;

  return b;
}

constexpr bool allocate_test2() {
  base* d = new derived{};
  auto b = d->f();
  //現実にもよくあるdelete忘れをする
  //delete d;

  return b;
}

constexpr bool b1 = allocate_test1();  //ok
constexpr bool b2 = allocate_test2();  //compile error!

delete忘れるとコンパイルエラー!誰もが望んだことが可能になります。

std::allocator<T>std::allocator_traits

ところで、C++にはもう一つポインタの危険な再解釈を必要とせずに任意の型のメモリ領域を確保/解放する手段があります。それが、std::allocator<T>std::allocator_traits<std::allocator<T>>です。

std::allocator<T>は殆どの標準コンテナで使われているデフォルトのアロケータで、そのメンバ関数によってメモリの確保、解放を行うことができます。それも、その式の入力と出力に際してユーザー側から見てポインタの再解釈は行われません。 そこで、このstd::allocator<T>及びstd::allocator_traitsによるメモリの確保と解放もconstexprに行うことができるようになります。

それに伴ってstd::allocator<T>及びstd::allocator_traitsのすべてのメンバがconstexpr指定されます(とはいえ、std::allocator<T>allocate()/deallocate()以外のメンバ関数はちょうど削除されたので、残ったのは代入演算子とコンストラクタ、デストラクタくらいですが)。

std::allocator<T>allocate()/deallocate()は実際には定数式で呼び出し可能ではないnew/delete演算子を呼び出してしまうのですが、言語機能として特別扱いすることでconstexprに呼び出しができるようになります。

new/delete式と同じように、コンパイル時にstd::allocator<T>::allocate()で確保したメモリはコンパイル時にstd::allocator<T>::deallocate()によって確実に解放される必要があり、std::allocator<T>::deallocate()コンパイル時にstd::allocator<T>::allocate()によって確保されたメモリの解放のみを行う必要があります。
そうでない場合はコンパイル時実行不可となります。

少し注意点ですが、new式で確保したメモリをstd::allocator<T>::deallocate()で解放する、std::allocator<T>::allocate()で確保したメモリをdelete式で解放する、等といったことは定数式ではできません。コンパイルエラーです。

std::construct_atstd::destroy_at

詳しい人はご存知かもしれませんが、std::allocator<T>new/delete式とは違ってメモリの確保と解放しか行いません。オブジェクトの構築・破棄を行ってくれないのです。

std::allocator<T>::allocate()で確保したメモリを利用するにはplaccement newが、std::allocator<T>::deallocate()でメモリの解放を行う前にはpseudo-destructor call(T型のオブジェクトaに対して a.~T()のような形のデストラクタ呼び出し)もしくはstd::destroy_at()の呼び出しが必要になります。

placement new式はvoidポインタの受け入れに伴って再解釈が発生します。また、両方とも定数式では現在許可されておらず、C++20でも許可されません。
std::destroy_at()もconstexpr関数ではなく定数式で実行できません。

これを解決するために、既存のstd::destroy_at()の対となる std::construct_at()を追加し、それらにconstexprを付加します。

//C++20からのそれぞれの宣言
namespace std {
  template<class T, class... Args>
  constexpr T* construct_at(T* location, Args&&... args);

  template<class T>
  constexpr void destroy_at(T* location);
}

std::construct_at()はその呼び出しが、return ::new (location) T(std::forward<Args>(args)...);という式(つまりplacement new)と同じ効果を持つように定義されます。
std::destroy_at()はその呼び出しがlocation->~T()(つまりpseudo-destructor call)と同じ効果を持つと定義されており、特に変更はありません。 そして、両方ともconstexprが付加されコンパイル時実行可能になります。

そして現在、placement new及びpseudo-destructor callを使用しているstd::allocator_traitsconstruct()/destroy()両関数の効果をこれらを使って定義しなおします(std::allocator<T>std::allocator_traitsを通して使われることを前提とするため、構築・破棄に関わるこれらの関数を持ちません)。

これで何が変わるんじゃいという感じですが、placement new及びpseudo-destructor callの呼び出しを避け、std::construct_atstd::destroy_atコンパイラに特別扱いしてもらって定数式で実行してもらうことで、それぞれの問題を解決しています。「同じ効果を持つ」という所がキモです。

このような機能実現方法のことをコンパイラーマジックと呼んだりして、C++11以降いくつかの機能の実現において利用されています。

この涙なしには語れない(コンパイラの)努力によって、std::allocator<T>を用いてコンパイル時にメモリの確保と解放をする事の障害が取り除かれました。

ちなみに、類似のstd::destroy()std::destroy_n()、及びRangeの追加に伴ってstd::range名前空間に追加される同名の関数も同様にされ、定数式で実行できます(std::construct_at()関連も同様)。

瑣末な注意点ですが、定数式でのstd::construct_at()/std::destroy_at()の呼び出し時の第一引数T*std::allocator<T>::allocate()によって確保された領域を指すポインタでなければなりません(当然、new式で確保されたものであってもダメ)。
また、それぞれの関数内で呼び出されるTのコンストラクタおよびデストラクタが定数式で実行可能でなければconstexpr実行不可となります、これはnew/delete式でも同様です。

struct base {
  virtual int f() const = 0;
  
  virtual constexpr ~base() = default;
};

struct derived : base {
  constexpr bool f() const override {
    return false;
  }
};

constexpr bool allocate_test1() {
  std::allocator<derived> alloc{};
  //メモリ確保と構築
  derived* d = alloc.allocate(1);
  base* b = std::construct_at(d);  // b = new(d) derived{};と等価

  auto r = b->f();

  //オブジェクト破棄とメモリ解放
  std::destroy_at(b);  // b->~base();と等価
  alloc.deallocate(d, 1);

  return r;
}

constexpr bool allocate_test2() {
  std::allocator<derived> alloc{};
  //メモリ確保と構築
  derived* d = alloc.allocate(1);
  base* b = std::construct_at(d);  // b = new(d) derived{};と等価

  auto r = d->f();

  //忘れる
  //std::destroy_at(b);
  //alloc.deallocate(d, 1);

  return r;
}

constexpr bool b1 = allocate_test1();  //ok
constexpr bool b2 = allocate_test2();  //compile error!

std::allocator<T>std::construct_at()/std::destroy_at()の組み合わせで、プログラマから見た扱いはnew/delete式とほぼ同じになるわけです。

また、std::allocator_traitsの確保と解放・構築と破棄に関わるメンバが全て同様にconstexpr関数として定数式で実行可能になっているので、メモリ確保周りに関して標準コンテナは追加の作業無しでconstexpr対応をすることができます(他の部分で考慮が必要ではあります)。
C++20では、std::vectorstd::stringがこれらの変更によってconstexpr対応を果たします。

これらの多大なる努力によってコンパイル時メモリ確保に関する障害はほぼ取り除かれ、全人類の夢であったコンパイル時動的メモリ確保が可能になります。

コンパイル時確保メモリの解放タイミング

Transient allocation(一時的な割り当て)

Transient allocationとは、コンパイル時に動的に確保されたメモリのうち、その開放もコンパイル時になされたもののことを言います。
そのようなメモリは実行時に参照されず、できません。

これはほとんど問題ないでしょう。

Non-transient allocation(非一時的な割り当て)

C++20における最終的な仕様では、Non-transient allocationは認められないことになりました(P0784R6で削除されました)。従って、コンパイル時に確保したメモリは確実にコンパイル時に解放されなければなりません。

Non-transient allocationに関する以前の仕様

※以下の記述は、以前の仕様を記したものです。参考に残しておきます・・・・

Non-transient allocationはその名の通り、コンパイル時に確保されたメモリ領域のうち、コンパイル時には解放されない物の事です。
コンパイル時に確保したメモリ領域を実行時に参照したいことがある事からこの様な場合分けがなされています。

そのようなメモリ確保を許可する場合、その領域を実行時にどう扱うのかが問題となります。つまり、実行時に改めてメモリを確保しなおすのかどうかということです。C++のゼロオーバーヘッド原則的にも実行時に確保しなおすのはちょっと・・・、という感じでしょう。

そこで、クラス型内部で確保されるメモリについてのみ特別な条件を課すことでこれを可能にします。その条件とは、あるリテラルTについて

  • Tは非トリビアルconstexprデストラクタを持つ
  • そのデストラクタはコンパイル時実行可能
  • そのデストラクタ内で、Tの初期化時に確保されたメモリ領域(Non-transient allocation)を解放する

そして、これらの条件を満たしていれば、そのNon-transient allocationなメモリ領域は実行時に静的ストレージへ昇格されます。

template<typename T>
struct sample {
  std::allocator<T> m_alloc;
  T* m_p;
  size_t m_size;

  template<size_t N>
  constexpr sample(T(&p)[N])
    : m_alloc{}
    , m_p{m_alloc.allocate(N)}
    , m_size{N}
  {
    for(size_t i = 0; i < N; ++i) {
      std::construct_at(m_p + i, p[i]);
    }
  }

  constexpr ~sample() {
    for(size_t i = 0; i < N; ++i) {
      std::destroy_at(m_p + i);
    }
    m_alloc.deallocate(m_p, m_size);
  }
}

constexpr sample<char> str{"Hello."};
//実行時には、strは"Hello"を保持する静的配列を参照するようになる

このようなsampleクラスが上の条件を満たしています。

コンパイル時にメモリを確保してそれを実行時まで残すことを話していたのに、なぜか開放している・・・

どういうことかというと、この場合のsmple::~sample()は必要なものですが呼ばれないものです。Non-transient allocationとなる場合に、このデストラクタは見かけ上コンパイル時に確保したメモリを全て解放しているように見せるためにあります。
そして、この様に一旦解放した様に扱ったメモリ領域を最終的には静的ストレージへ移行することで実行時にも参照可能になります(つまりこの場合、const char*な文字列と同じ扱いになる)。実行時にはそのクラスのオブジェクト共々定数となるので、コンパイル時に評価完了した場合は実行時にデストラクタが呼ばれることはありません。

もちろんこの要件を満たしていればTransient allocationとして扱う事にも何ら問題はありません(constexpr関数のローカル変数として利用されるなど)。そして実行時に扱う事にも問題がない事が分かるでしょう。

まどろっこしいですが、この様な規則を導入することで前項の確保・解放関数のコンパイル時実行条件の修正や、コンパイル時動的メモリ→実行時動的メモリの変換、などのさらに煩わしいことを考えなくて済むようになります。

std::mark_immutable_if_constexpr()

ここまでで、コンパイル時に解放され切らないメモリについての扱いは分かりました。しかしそこにはまだ問題があります。

先のsampleクラスがNon-transient allocationとなる場合にはそのデストラクタは見た目だけのもので、それは呼ばれることはありません。そしてそのコンパイル時動的メモリ領域は静的記憶域へ昇格されます。
では、そのメモリの内容はどの時点で決まるのでしょうか?

先のsampleクラスはポインタをパブリックに公開しているのでコンパイル時のどの時点でもそれを書き換えることができます。さてその場合、呼び出し時点の値を実行時に持ち越せばいいのでしょうか?それともコンパイル時の全ての評価を待たねばならないのでしょうか?

利用するプログラマ視点から見るとどうでもいい話かもしれませんが、コンパイラ様から見ると大変です。えっ?コンパイル時に動的確保した領域すべてを監視し続けるんですか!?という感じです。

そのようなコンパイラ様をお助けするために、std::mark_immutable_if_constexpr()という関数が<new>に追加されます。
その役割はある時点以降はそのメモリ領域は不変であることをコンパイラに通知することです。

//宣言
template<class T>
constexpr void mark_immutable_if_constexpr(T* p);

std::mark_immutable_if_constexpr()でマークされた領域は不変であるとして扱われます。おそらく実行時に残る値はstd::mark_immutable_if_constexpr()が呼び出された時点の値となるでしょう。その後で変更しても何も起きないかと思われます(そして、それについての指示が見当たらないことから未定義動作でしょう)。ちなみに実行時に(の文脈で)呼び出しても何の効果もありません。

先のsampleクラスは以下のように修正されます。

template<typename T>
struct sample {
  std::allocator<T> m_alloc;
  T* m_p;
  size_t m_size;

  template<size_t N>
  constexpr sample(T(&p)[N])
    : m_alloc{}
    , m_p{m_alloc.allocate(N)}
    , m_size{N}
  {
    for(size_t i = 0; i < N; ++i) {
      std::construct_at(m_p + i, p[i]);
    }
    //ここ以降は確保した領域は不変
    std::mark_immutable_if_constexpr(m_p);
  }

  constexpr ~sample() {
    for(size_t i = 0; i < N; ++i) {
      std::destroy_at(m_p + i);
    }
    m_alloc.deallocate(m_p, m_size);
  }
}

constexpr sample<char> str{"Hello."};
//strは"Hello"を保持する静的配列を参照するようになる

この様にしておけば、strはその後どう弄繰り回されても実行時から見たらHelloを保持するようになります。

なるはずでした・・・

参考文献

この記事のMarkdownソース

[C++]さらに出来るようになったconstexpr(C++20)

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

C++11でconstexprが導入されて以降、あらゆる処理をconstexprで行うことを目指すかのように(おそらく実際そう)constexprは着実に強化されてきました。 C++20ではC++14以来の大幅な強化が行われ、constexprの世界はさらに広がることになります。

constexprな仮想関数

ついに仮想関数をconstexprの文脈で呼び出せるようになります。初っ端から意味わからないですね・・・。

仮想関数呼び出しというとnewがちらつきますが、ポインタや参照を介してさえいればnewによって確保されたオブジェクトでなくても動的ポリモーフィズムを行うことができます。
そのような場合であれば不正なキャストなどのチェックを静的に行うことができ、その動的型(dynamic type)を静的に追跡すれば仮想関数呼び出しすら静的に解決することが可能です。

そのため、constexprの文脈で仮想関数呼び出しを禁止している制限は不要であるとして撤廃されました。

struct base {
  virtual int f() const = 0;
};

struct derived1 : public base {
  constexpr int f() const override {
    return 10;
  }
};

struct derived2 : public base {
  constexpr int f() const override {
    return 20;
  }
};

constexpr derived1 d1{};
constexpr derived2 d2{};
  
constexpr base const& b1 = d1;
constexpr base const* b2 = &d2;
  
constexpr int n1 = b1.f();   //n1 == 10
constexpr int n2 = b2->f();  //n2 == 20

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
この様なコードが動くようになります。

このコードのように、非constexprな仮想関数をconstexprな仮想関数でオーバーライドすることができますし、その逆(constexpr仮想関数を非constexpr仮想関数でオーバーライド)も可能です。
また、const修飾はしておかないと実行時に呼び出すことができなくなります(constexprに初期化された変数は実行時にはconstになっているため)。

ただし、constexprなポインタ・参照はstatic変数やグローバル変数のように、staticストレージと呼ばれるところにあるものしか参照できません。
なので、ローカルconstexpr変数をポインタ・参照に入れることは出来ません。
@mokamukurugaさん、ご教授ありがとうございました!

しかし、constexpr関数の内部で利用する分にはその制約は受けず、そのconstexpr関数が定数実行されれば目的を達せます。
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
この例でもは引数に渡してますが、完全にconstexpr関数内で生成から仮想関数呼び出しまでを完結させても問題ありません。

最基底で定義された仮想関数はそのconstexprの有無に関わらず派生クラスにおいてconstexprの有る無し両方でオーバーライドできます。その際、途中のオーバーライドが非constexprであっても、最終的に呼び出される最派生(most derived)のオーバーライドがconstexprであれば定数実行可能です。

struct base {
  virtual int f() const = 0;

  constexpr virtual int g() const {
    return 0;
  }
};

struct derived1 : public base {
  int f() const override {
    return 0;
  }
  
  constexpr int g() const override {
    return 10;
  }
};

struct derived2 : public derived1 {
  constexpr int f() const override {
    return 10;
  }
  
  int g() const override {
    return 20;
  }
};

constexpr derived2 d2{};
constexpr base const& b = d2;

//ok
constexpr int a = b.f();  //a == 10
//compile error! derived2::g() is not constexpr function.
constexpr int b = b.g();

ちなみに、純粋仮想関数の定義もconstexprにできます。

dynamic_castとtype_id

前項の内容の延長です。constexprな仮想関数が許可されたのと同様の理由によりdynamic_castや多態的な型のオブジェクトに対するtype_idも静的に解決することができます。なのでそれが可能になりました。
また、この変更に伴ってstd::type_infooperator==operator!=がconstexpr指定され定数式で使用可能になります。

struct base {
  virtual int f() const = 0;
};

struct derived1 : public base {
  constexpr int f() const override {
    return 10;
  }
};

struct derived2 : public base {
  constexpr int f() const override {
    return 20;
  }
};

//組み込み型に対するtypeid
{
  constexpr auto&& int_t  = typeid(int);
  constexpr auto&& char_t = typeid(char);
  //ここまではC++11以降なら可能

  constexpr bool is_same = int_t == char_t;  //constexprな同値比較はC++20より
  static_assert(is_same == false);
}

//polymorphicな型に対するtypeid
{
  constexpr derived1 d1{};
  constexpr derived2 d2{};
  
  constexpr base const* b1 = &d1;
  constexpr base const* b2 = &d2;
  
  constexpr auto&& b1_t = typeid(*b1);
  constexpr auto&& b2_t = typeid(*b2);

  constexpr bool is_same = b1_t == b2_t;
  static_assert(is_same == false);
}


struct base2 {
  virtual int g() const = 0;
};

struct derived3 : public base, public base2 {
  constexpr int f() const override {
    return 20;
  }
  
  constexpr int g() const override {
    return 30;
  }
};

//dynamic_cast
{
  constexpr derived3 d{};
  constexpr base const* b1 = &d;
  //side cast
  constexpr base2 const* b2 = dynamic_cast<base2 const*>(b1);
}

多分このように書けるようになります(普段あまり使わないのと確認できないのとで自信がないですが・・・)。

このような定数実行の中で例外を投げるような適用が行われた場合は定数実行されません。例外を投げるような適用とは、dynamic_castなら参照の変換での失敗時、typeidはnullptrを参照するポインタを受けたとき、です。

constexpr base* nullp = nullptr;
constexpr auto&& t = typeid(*nullp);  //compile error! 例外を投げるため定数実行不可


constexpr derived1 d1{};
//b1のmost derived typeはderived1
constexpr base const& b1 = d1;

//down cast
constexpr derived2 const& d2 = dynamic_cast<derived2 const&>(b1);  //compile error! 例外を投げるため定数実行不可

コンパイル時メモリアロケーション

※この項は複雑で長くなるのでページ分けしました
[C++] constexprなメモリの確保と解放のために(C++20)

ざっとまとめると以下が可能になります

  • constexpr デストラク
  • new式/delete式のコンパイル時実行(operator newではない)
  • std::allocator<T>及びstd::allocator_traits<std::allocator<T>>コンパイル時実行
  • コンパイル時に確保され解放されなかったメモリは静的記憶域に移行され実行時に参照可能

unionのアクティブメンバの切り替え

共用体(union)のアクティブメンバとは、ある時点の共用体のオブジェクトにおいて最後に初期化されたメンバの事です。共用体の初期化自体はconstexprに行うことが可能ですが、あるメンバの初期化後に別のメンバを初期化した場合にアクティブメンバの切り替えが発生します。アクティブメンバの切り替えはC++17までコンパイル時に行えません。

前項の変更によってコンパイル時にメモリ確保すら可能になるため、STLの多くのクラスをconstexpr対応させることができるようになります。しかし、std::stringstd::optionalstd::variantはその実装において共用体が使われています(std::stringはsmall-string optimization : ssoと呼ばれる最適化のために)。

それらのクラスでは共用体のアクティブメンバの切り替えが発生する可能性があり、その場合にconstexprの文脈で使用できなくなります。そのようなクラスをconstexprにさらに対応させるため、この制限は撤廃されることになりました。

union U {
  int n;
  float f;
};

//U::fを読み出しアクティブメンバをU::nに切り替える
constexpr float change() {
  //fをアクティブメンバとして初期化 (Designated Initialization!)
  U u = { .f = 3.1415f };

  float f = u.f;  //u.nがアクティブメンバの場合はここは定数実行不可
  u.n = 10;  //u.nへアクティブメンバを切り替え、C++17までは定数実行不可

  return f;
}

int main()
{
  constexpr auto f = change();
  static_assert(f == 3.1415f);
}

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

ただし、非アクティブなメンバへのアクセス(そこへの参照からの間接アクセスも含む)は未定義動作であり、定数式で現れてはいけません。つまりは定数式の文脈でそのようなアクセスを行った時点でコンパイル時実行不可能になります(std::stringのsso実装がこれに当てはまってしまっています)。

try-catch

constexpr関数内にはこれまでtry-catchブロックを書くことは出来ませんでした。書いてあった場合はコンパイル時実行不可能です。しかし、それを書くことができるようになります。

と言っても、書くことができるようになるだけです。相変わらずthrow式が現れてはいけませんし、コンパイル時実行中に例外が投げられればその時点で実行不可です。
つまりは、コンパイル時実行時のtry-catchブロックは無視されます。

この変更は、std::vectorをconstexpr対応させる際に問題となったために為されました。将来的にconstexprをさらに拡大させていく際にも地味な障害となるので早めに取り除いておく方が良いと考えられたのでしょう。

また、C++20では単に無視することにしただけで、将来的にコンパイル時に例外処理が行えるようになる可能性が閉ざされた訳ではありません。

std::is_constant_evaluated()

std::is_constant_evaluated()コンパイル時にはtrueを、実行時にはfalseを返す関数です。これにより、コンパイル時と実行時でそれぞれ効率的な処理を選択することが可能になります。

おそらくconstexprで数学関数を実装しようと思った方が通るであろう、コンパイル時にはコンパイル時実行可能なアルゴリズムで、実行時にはcmathの対応する関数で実行してほしい!ということがついに可能になります。

#include <type_traits>  //←必須
#include <cmath>
#include <iostream>

template<typename T>
constexpr auto my_sin(T theta) {
  if (std::is_constant_evaluated()) {
    //コンパイル時
    auto fabs = [](T v) -> T { return (v < T(0.0))?(-v):(v); };
    T x_sq = -(theta * theta);
    T series = theta;
    T tmp = theta;
    T fact = T(2.0);
    
    //マクローリン級数の計算
    do {
      tmp *= x_sq / (fact * (fact+T(1.0)));
      series += tmp;
      fact += T(2.0);
    } while(fabs(tmp) >= std::numeric_limits<T>::epsilon());
    
    return series;
  } else {
    //実行時
    return std::sin(theta);
  }
}

int main()
{
  constexpr double pi = 3.1415926535897932384626433832795;
  
  std::cout << std::setprecision(16);
  
  //sin(60°)を求める
  constexpr auto sin_static = my_sin(pi/3.0); //コンパイル時計算
  auto sin_dynamic = my_sin(pi/3.0);  //実行時計算
  
  std::cout << sin_static << std::endl;
  std::cout << sin_dynamic << std::endl;
}

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

if constexprstatic_assertでこの関数を利用すると必ずtrueとして処理されます。なので、コンパイル時と実行時で処理を分けるような目的で利用する場合は通常のifで分岐する必要があります。しかし、実行時までif文が残る事は無いでしょう。

また、通常のifを使うという事はtrue及びfalseとなる両方のステートメントコンパイル出来なければなりません。
しかし、falseとなる方のステートメントにconstexpr実行不可能なもの(throwmemcopy等)が現れることは問題ありません。

trueと評価されるところ

std::is_constant_evaluated()は、manifestly constant-evaluated(間違いなく定数評価される)という式の中でtrueとなります。

manifestly constant-evaluatedな式とは以下のようなものです。

  • 定数式
  • constexpr if文の条件式
  • consteval関数の呼び出し(consteval関数の中身)
  • 制約式(コンセプト)
  • 定数初期化(constant initialization)できる変数の初期化式
  • 定数式で使用可能な変数の初期化式
    • constexpr変数
    • 参照型
    • const修飾された整数型
    • enum

難しく書いてありますが、要するにコンパイル時計算中にtrueとなるという事です。
サンプルコードを見てみましょう(ドラフト規格文書より)

//(1)
template<bool>
struct X {};
X<std::is_constant_evaluated()> x; // type X<true>

//(2)
int y;
const int a = std::is_constant_evaluated() ? y : 1; // dynamic initialization to 1
double z[a];  // ill-formed: "a" is not "usable in constant expressions"

//(3)
const int b = std::is_constant_evaluated() ? 2 : y; // static initialization to 2 

//(4)
int c = y + (std::is_constant_evaluated() ? 2 : y); // dynamic initialization to y+y

//(5)
constexpr int f() {
  const int n = std::is_constant_evaluated() ? 13 : 17; // n == 13
  int m = std::is_constant_evaluated() ? 13 : 17; // m might be 13 or 17 (see below)
  char arr[n] = {}; // char[13]
  return m + sizeof(arr);
}

int p = f();     // m == 13; initialized to 26
int q = p + f(); // m == 17 for this call; initialized to 56

さて、これらの場合にstd::is_constant_evaluated()はどちらに評価されるのでしょうか?順に考えていきましょう。

(1)はクラスXのテンプレートパラメータ指定時に呼ばれています。
Xのテンプレート引数はboolの非型テンプレートパラメータなので、その初期化式は定数式です。結果はtrueになります。

(2)はconst intな変数aの初期化式で呼ばれています。
これは定数式ですので(上記「定数初期化(constant initialization)できる変数の初期化式」に当たります)まず結果はtrueになります。しかしその場合は非constexprで未初期化の変数yを用いて初期化することになり、これは定数実行不可なので定数式では初期化されません。
そのため、aの初期化はコンパイル時ではなく実行時に行われ、その場合の結果(と我々から見た結果)はfalseになります。
これにより変数aは実行時に1で初期化されることになり、整数定数を要求する配列のサイズ指定に用いることは出来ず、配列zの宣言も失敗します。
つまりこの場合の結果は、truefalse両方となります。

(3)はconst intな変数bの初期化式で呼ばれています。 この結果は先ほどと逆になる事が分かるでしょう。
つまり、結果がtrueとなる方のリテラル2での初期化が常に定数式で可能なため、この場合の結果は必ずtrueとなります。

(4)は普通のintの変数cの初期化で呼ばれます。
初期化式にはいきなり非定数の未初期化変数yがでてきます。この時点で定数式ではないので、この場合の結果は必ずfalseです。

(5)は少し複雑です。 まずはint p = f();で、f()の中で呼ばれます。
f()はconstexpr関数でありintは定数初期化が可能ですのでこの場合の初期化式は定数式になります(上記「定数初期化(constant initialization)できる変数の初期化式」に当たります)。
そのためf()コンパイル時に実行され、中のis_constant_evaluated()は全てtrueになります。 結果、コンパイル時のf()26を返し、int p26で定数初期化されます。

次に、int q = p + f();ですが、pは定数初期化されているだけで定数ではありません。なので、これは定数式ではありません。
そのためf()は実行時に実行されます。その時のf()内では、int mの初期化式のis_constant_evaluated()だけがfalseになります(const int nの初期化式は常に定数実行、つまりtrueになります)。
結果m17になるので、実行時のf()30を返します。p26で定数初期化されているので、qは実行時に56で初期化されます。

定数式内で、trivially default constructibleな型をデフォルト初期化する

これまで、以下のように基本型の変数宣言に初期化子が無い場合は未定義動作となり、constexpr関数では未定義動作が現れてはならないことからコンパイルエラーとなってしまっていました。

constexpr int ng() {
  int n;  //undefined behavior!初期化子が必要
  ++n;

  return n;
}

constexpr int ok() {
  int n{};  //ok、デフォルト初期化(0)される
  ++n;

  return n;
}

こんなとても簡単なコードならば初期化子を書けばよいのですが、問題となるのはテンプレートにしたときです。

template <typename T>
constexpr T copy(const T& other) {
  T t;  //デフォルト初期化(してほしい)
  t = other;

  return t;
}

struct trivial {
  int n;
};

struct non_trivial {
  int n = 100;
};

int main() {
  {
    //全てok
    auto cp1 = copy(10);
    auto cp2 = copy(trivial{});
    auto cp3 = copy(non_trivial{});
  }

  {
    constexpr auto cp1 = copy(10);            //ng
    constexpr auto cp2 = copy(trivial{});     //ng
    constexpr auto cp3 = copy(non_trivial{}); //ok
  }
}

intに代表される基本型は初期化子が無いとその変数の状態は未定義となりますが、それはテンプレートにおいても同様です。そして、それが定数式で現れてしまうとコンパイルエラーを引きおこします。

また、この例のtrivial型のように集成体でありデフォルトメンバ初期化によってメンバが初期化されていない型も基本型と同様に初期化に際して初期化子が必要になります。

これらの型のように、デフォルトコンストラクタ(に相当するもの)が全くユーザーによって定義されていないとき(メンバも何ら初期化していない時)、その型はtrivially default constructibleといいます。

trivially default constructibleな型はデフォルト初期化されると、その値は(ユーザー定義型の場合そのメンバが)0に相当する値によって初期化されます。
ただし、初期化子が無い場合の初期化状態は未定義になります。

C++20からはこのようなtrivially default constructibleな型は定数式に限って初期化子が無くてもデフォルト初期化されるようになります。
従って、先ほどのコードは全てのケースでコンパイルできるようになります。

template <typename T>
consteval T copy(const T& other) {
  T t;  //デフォルト初期化される
  t = other;

  return t;
}

struct trivial {
  int n;
};

struct non_trivial {
  int n = 100;
};

int main() {

  constexpr auto cp1 = copy(10);            //ok
  constexpr auto cp2 = copy(trivial{});     //ok
  constexpr auto cp3 = copy(non_trivial{}); //ok
}

ただし、その値を読み出すことは相変わらず未定義とされます。
あくまで、実行時とコンパイル時でコンパイル出来たりできなかったりする一貫しない挙動の修正が目的です(例えば、読み取りを出来るようにすると変数領域を確実に初期化するオーバーヘッドが入ってしまう)。

consteval int ng() {
  int n;  //デフォルト初期化されるけど・・・

  return n; //undefined behavior!コンパイルエラーとなる
}

constexpr関数内でasm宣言が書けるように

なるのですが、書けるだけです。

std::is_constant_evaluated()の導入によってconstexpr関数内でif文によって実行時とコンパイル時の処理を分けることができます。その場合、実行時の処理はコンパイル時に実行されることはなく逆も然りです。

そのため、実行時のブロックではコンパイル時に現われてはいけないものが現れていたとしても問題はないはずです。
例えば、asm宣言はC++17までは定数式に現われることができず書いてあるだけでコンパイルエラーになります。

しかし、C++20からはconstexpr関数内でasm宣言を書くことができるようになります。ただし、実行は出来ないため定数式でasm宣言に到達しないようにしなければなりません。つまりstd::is_constant_evaluated()とセットで用いる必要があります。

提案文書のサンプルコードを基にした例

#include <iostream>
#include <type_traits>

constexpr double fma(double a, double b, double c) {
  if (std::is_constant_evaluated()) {
    return a*b+c;
  } else {
    //GCC拡張のインラインアセンブラ構文なのでVC++では動かないかも・・・
    asm volatile ("vfmadd213sd %0,%1,%2" : "+x"(a) : "x"(b),"x"(c));
    return a;
  }
}

int main()
{
  constexpr double fma1 = fma(2.0, 8.0, 1.0);
  double fma2 = fma(2.0, 9.0, 2.0);
  
  std::cout << fma1 << "\n" << fma2 << std::endl;
}

vfmadd213sdFMA命令でIntelのCPUではHaswell以降のものしか対応していません(そのためWandboxで試せません・・・)

動くサンプル、二次元ベクトルの内積計算。

#include <iostream>
#include <iomanip>
#include <type_traits>

constexpr double inner_product_v2(const double (&v1)[2], const double (&v2)[2]) {
  double dp{};

  if (std::is_constant_evaluated()) {
    for (int i = 0; i < 2; ++i) dp += v1[i]*v2[i];
  } else {
    constexpr int imm8 = 0b110001;
    asm volatile (
      "movlpd %%xmm0, %1;"
      "movhpd %%xmm0, %2;"
      "movlpd %%xmm1, %3;"
      "movhpd %%xmm1, %4;"
      "dppd %%xmm0, %%xmm1, %5;"
      "movlpd %0, %%xmm0"
      : "=m"(dp)
      : "m"(v1[0]), "m"(v1[1]), "m"(v2[0]), "m"(v2[1]), "N"(imm8)
    );
  }

  return dp;
}


int main()
{
  {
    constexpr double v1[2] = { 2.0, 2.0 }; 
    constexpr double v2[2] = { 2.0, -2.0 };
  
    constexpr double dp1 = inner_product_v2(v1, v2);
    double  dp2 = inner_product_v2(v1, v2);

    std::cout << std::setprecision(16);
    std::cout << dp1 << "\n" << dp2 << std::endl;
  }
  {
    constexpr double v1[2] = { 0.0, 1.0 }; 
    constexpr double v2[2] = { 1.0, 1.4142135623730950488016887242097 };
  
    constexpr double dp1 = inner_product_v2(v1, v2);
    double  dp2 = inner_product_v2(v1, v2);

    std::cout << dp1 << "\n" << dp2 << std::endl;
  }
}

/*
出力
0
0
1.414213562373095
1.414213562373095
*/

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

consteval(immediate function : 即時関数)

constexprを指定した関数は定数実行可能であり、定数式の文脈でコンパイル時に実行可能であることを表明します。
しかし、文脈によっては定数実行されたかどうかを確かめることが困難であったり、定数実行中に実行不可となるようなエラーが発生した場合は暗黙的に実行時まで処理が先延ばしされたりします(単なるconstな変数の初期化試行時等)。

そこで、必ずコンパイル時に定数を生成しそれができなければコンパイルエラーとなる関数が欲しい場合があります。そのような需要に応えるためにconsteval指定子が導入されました。

constevalは関数の頭(constexprと同じ位置)に付け、その関数が必ずコンパイル時実行されることを示します。そして、そのような関数は即時関数(immediate function)と呼ばれます。
基本的には、consteval関数内でできる事/出来ない事等の性質はconstexpr関数と同じです。

consteval int square(int n) {
  return n*n;
}

constexpr int sqc = square(10);   //ok. executed at compile time.
int x = 10;
int sqr = square(x);   //compile error! can't executed at compile time.

変数sqcの初期化はconstexprが付加されていることもあり定数式で実行可能ですので、square(10)コンパイル時に実行され、sqc == 100になります。
一方sqrの初期化では、square即時関数の引数が非constexprな変数xになっているために即時実行不可なのでその時点でコンパイルエラーを発生させます。
また、consteval関数内に定数実行不可能な処理がある場合もコンパイルエラーです。

consteval関数はほかのどの関数よりも早く実行され、consteval関数が出現したらほぼその場で実行されます。つまり、constexpr関数実行時点ではその内部のconsteval関数実行は終了しています。
ただし、consteval関数の中でconsteval関数が呼び出されている場合はそうではなく、そのように囲んでいるconsteval関数が最終的に定数評価されればエラーにはなりません。

consteval int sqrsqr(int n) {
  return square(square(n)); //この時点では定数評価されていないが、エラーにはならない
}

constexpr int dblsqr(int n) {
  return 2*square(n); // compile error! 囲む関数はconstevalではない
}

つまりはconsteval関数呼び出しが実行時まで残っている可能性のある場合にエラーとなり、実行時には必ず結果となる定数値に置き換えられていなければなりません。

また、consteval関数内にconstexpr関数呼び出しがあっても良いようです。

そのように、コンパイル時には全て終わっているという性質のためconsteval関数のアドレスを取ることは出来ません。そのような行為を働いた時点でコンパイルエラーとなります。
ただし、consteval関数内で扱われている限りはconsteval関数のアドレスを扱うことができます。

consteval int f() { return 42; }
consteval auto g() { return f; } //ok
consteval int h(int (*p)() = g()) { return p(); } //ok

constexpr int r = h();  //ok
constexpr auto e = g(); //compile error! consteval関数のアドレスは定数式として許可されない

このような性質から、即時関数はコンパイラのフロントエンドで処理され、バックエンドはその存在を知りません。すなわち、関数形式マクロ(悪名高いWindows.hのmin,maxマクロのようなプリプロセッサ)の代替として利用することができます。

デメリットとしてはテンプレートメタプログラミングと同じでデバッグが困難であることです。constexpr関数であれば通常の関数としてデバッグ可能ですが、consteval関数は実行時には跡形も残りませんので通常の手段ではデバッグできません。

constevalコンストラク

consteval指定はコンストラクタに行うこともできます。そのようなコンストラクタもまた即時関数となり、そのコンストラクタを通じた初期化は他のconsteval関数と同じタイミング(constexprコンストラクタよりも早い)で行われます。
コンストラクタに付ける場合に(そのクラスに)必要な要件・制限はconstexpr指定したのとほぼ同じです。

constevalコンストラクタの挙動は通常のconsteval関数と同じです。
すなわち、その初期化は定数かリテラル型を通して行われなければならず、constexpr関数内で使用される場合はその関数が実行される時点で既に初期化が終了していなければなりません。
そして、実行時にはそのコンストラクタは残らないため、実行時にconstevalコンストラクタは使用不可能になります。アドレスも取得不可です。

つまりは、constevalコンストラクタのみを持つようなクラスは(consteval関数以外から見ると)定数として振舞い、実行時に生成することができなくなります。

struct immediate {
  consteval immediate(int m, double d)
    : n{m}
    , f{d}
  {}

  consteval operator int() const {
    return n;
  }

  consteval operator double() const {
    return f;
  }

private:
  int n;
  double f;
};

consteval auto make_immediate(int m, double d) -> immediate {
  return immediate{m, d};
}

constexpr auto make_immediate2(int m, double d) -> immediate {
  return immediate{m, d};  //ng、この関数はconstevalではない
}

int main() {
  constexpr immediate im1{10, 3.141};  //ok
  constexpr auto im2 = make_immediate(20, 2.718);  //ok

  int n{};
  double d{};
  immediate im3{n, d};  //ng
  auto im4 = make_immediate(n, d);  //ng

  immediate im5 = immediate{30, 1.618};  //ok?、即時生成した一時オブジェクトをムーブする?

  constexpr auto m = int(im1)    + int(im2);     //ok, m == 30
  constexpr auto e = double(im1) + double(im2);  //ok, e == 5.859
  
  std::cout << int(im1)    << ", " << int(im2)    << std::endl;  //ok
  std::cout << double(im1) << ", " << double(im2) << std::endl;  //ok
}

この様なimmediateクラスはもはや実行時にオブジェクトを生成することはできません。ただし、その特殊メンバ関数はconstexprに暗黙定義されており、コピーやムーブは実行時でも可能なはずです(現状では、暗黙定義される特殊メンバ関数はconsteval関数にはなりません)。

その他のメンバ関数にもconstevalをつけることができますが、デストラクタに付けることはできません。

constevalラムダ

consteval指定はラムダ式に対しても行えます。その場合、ラムダ式によって生成される暗黙の関数オブジェクトの関数呼び出し演算子がconsteval関数になります。
付ける位置はmuttableやconstexprと同じ位置です。

auto sq = [](auto n) consteval { return n*n; };

constexpr int sqc = sq(10);
//sqc == 100

そのほかの性質はconsteval関数に準じます。

consteval指定されるのはあくまで関数呼び出し演算子なので、このラムダ式を受けている変数自体は即時評価される必要はありません。あくまで関数呼び出しが即時評価されます。
ただし、キャプチャをする場合の変数は定数、もしくはconsteval関数によって初期化されるリテラル型である必要があり、その場合はラムダ式を受けている変数にconstexprが必須になるかもしれません(ラムダ式の生成する関数オブジェクトの初期化がconstevalコンストラクタを通して行われるため)。

consteval関数を持ち回るのに利用すると良いかもしれません。後は通常のローカル関数としての利用でしょうか。

consteval仮想関数

仮想関数がconstexpr指定できるようになったので、当然のように?consteval指定することもできます。ただし、constexprが非constexpr仮想関数をオーバーライドしたり出来るのに対して、constevalはconsteval同士の間でしかオーバーライドしたり/されたりしてはいけません。

struct polymorphic_base {
  virtual int f() const = 0;
  consteval virtual int g() const { return 0; };
  consteval virtual int h() const { return 1; };
};

struct delived : polymorphic_base {
  //compile error! polymorphic_base::f() is not consteval function.
  consteval int f() const override {
    return 10;
  }

  //ok
  consteval int g() const override {
    return 20;
  }

  //compile error! missing consteval.
  int h() const override {
    return 30;
  }
};

オーバーライド前とオーバーライド後でconsteval指定の有無が一致している必要があります。

consteval関数は実行時には跡形もなく消え去るため、consteval仮想関数のみが定義されているようなクラスは実行時には多態的な振舞を行えなくなります。しかし、それでも仮想関数テーブル等の動的ポリモーフィズムのための準備が省かれるわけではありません。

STLのconstexpr追加対応

vector

上記の様々な変更の結果、std::vector<T>およびその特殊化std::vector<bool>のすべてのメンバ関数にconstexprが付加され、完全にコンパイル時利用が可能になります。ただし当然ながら、要素型はリテラル型である必要があります。

constexpr int test_vector() {
  std::vector<int> v = {5, 3, 2, 9, 1, 0, 4};
  v.push_back(11);

  int s{};
  for(auto n : v) {
    s += n;
  }

  return s;
}

constexpr auto sum = test_vector(); //ok. sum == 35

string

そしてさらに、std::stringchar, char8_t char16_t, char32_tに対して)の全てのメンバもconstexpr対応を果たし、コンパイル時利用が可能になります。

constexpr std::string is_cpp_file(const std::string& filename) {
  return filename.end_with(".cpp") || filename.end_with(".hpp");
}

constexpr std::string is_cpp_file(const std::u8string& filename) {
  return filename.end_with(u8".cpp") || filename.end_with(u8".hpp");
}


constexpr std::string src_name{"main.cpp"};          //ok
constexpr std::u8string header_name{u8"header.cpp"}; //ok

static_assert(is_cpp_file(src_name));     //ok、エラーにならない
static_assert(is_cpp_file(header_name));  //ok、エラーにならない

cmathとcstdlib

一部の数学関数にconstexprが付加されるようになります。とはいえ、std::sin等の特殊関数がconstexpr実行可能になるわけではありません。
絶対値(abs)や丸め(ceil, floor, round, trunc)、剰余(fmod, remainder, remquo)等の一部の関数がconstexpr指定されるようになります(一覧)。

四則演算はすでにconstexprなので、これで基本的な操作はコンパイル時実行できるようになります。おそらく恩恵が強いのは丸め関数系でしょうか。

algorithmとutility

std::swap()やstd::sort()等、かなりの関数にconstexprが付加されるようになります(一覧)。

これによりstd::vectorも含めて、constexprなイテレータを用いたアルゴリズムコンパイル時実行できるようになります!

constexpr std::vector<char> cvec = {`h`, `e`, `l`, `l`, `o`};
constexpr auto& r = cvec.emplace_back(`.`);

constexpr auto it = std::find(std::begin(cvec), std::end(cvec), `e`);
//*it == `e`

constexpr auto no = std::find(std::begin(cvec), std::end(cvec), `w`);
//no == std::end(cvec)

std::invokeとそれを用いるもの

C++17まではconstexpr関数をどこで実行すべきかが明確に規定されていなかったために、その実行に関しては処理系に一任されていました。
そのため、処理系によっては貪欲な定数実行の結果、意図しない文脈でconstexpr関数が実行され、不明確なコンパイルエラーを引き起こしていました。

std::invokeSTL内での呼び出し可能コンセプトの表現や関数呼び出しのnoexcept指定、戻り値型推論等に広く用いられており、constexpr関数の実行コンテキストが明確でないままにstd::invokeconstexprにしてしまうとそれらの関数利用時に意図しないコンパイルエラーを引き起こす可能性がありました。
そのため、std::applyconstexpr関数の定義でも使用されているにも関わらずstd::invokeconstexpr関数ではありませんでした。

C++20より、constexpr関数をどこで評価・実行すべきかを明確にしたこと(P0859R0)によってそれらの問題は払拭され、std::invokeconstexpr指定されました。
そしてそしてそれに伴い、std::invokeを定義に利用するいくつかのSTL関数もconstexpr指定されます。

P0859R0 評価されない文脈でconstexpr関数が定数式評価されることを規定について↓ onihusube.hatenablog.com

C++20よりconstexpr指定される関連関数

  • std::invoke
  • std::reference_wrapper<T>
  • std::ref()
  • std::cref()
  • std::not_fn()
  • std::bind_front()
  • std::bind()
  • std::mem_fn()

全てのメンバ関数のconstexpr化を達成したクラス

  • std::vector
  • std::string
  • std::allocator<T>
  • std::array
  • std::pair
  • std::tuple
  • std::back_insert_iterator
  • std::front_insert_iterator
  • std::insert_iterator

追加のconstexpr対応

  • std::complex(それぞれ非メンバ関数版を含む)
    • 全ての四則演算の演算子(自己代入系含む)
    • 全代入演算子
    • real(), imag()
    • norm(), conj()
  • std::pointer_traits<T*>
    • pointer_to()
  • std::char_traits
    • move()
    • copy()
    • assign()

参考文献

この記事のMarkdownソース

[C++]集成体の要件とその変遷

集成体(aggregate)とは配列と幾つかの条件を満たしたクラス(union含む)の事で、集成体初期化(aggregate initialization)を行えるような型のことです。一様初期化構文の導入によってその他の初期化との見た目の差異が無くなりあまり意識されなくなったかもしれませんが、集成体初期化という初期化方式および集成体という分類がなくなったわけではありません。

自前のクラスを集成体にして集成体初期化を行えるようにすることのメリットは、データメンバの初期化と参照のための煩わしい各種関数の定義をしなくて済むことです。これにより取り扱いやすくなり、コードとしての見た目が見やすくなります。 デメリットは、カプセル化を完全に破壊していることです。なので、単にデータをまとめて可搬にするためだけの型に使用することが多いかと思います。

実はクラスが集成体になるための条件はC++11以降毎回少しづつ変化しているので、それをバージョン毎に見てみます。
以下、staticメンバは関わってこないのでstaticメンバに関しては触れません。また、配列は特に変化がないのでクラスの条件のみを対象にしています。

C++98/03

  • ユーザー宣言のコンストラクタを持たない
  • privateやprotectedなメンバ変数を持たない
  • 仮想関数をメンバに持たない
  • 継承していない

おそらくC言語由来の基本要件。これを満たしておけばC++14以降のどのバージョンでも集成体となれますので集成体となる要件が知りたい場合はこれでお話が終わります(C++11はここにもう一つ制約が加わります)。

C++11

  • ユーザー定義のコンストラクタを持たない
    • defaultやdelete指定された宣言はあってもok
  • privateやprotectedなメンバ変数を持たない
  • 仮想関数をメンバに持たない
  • 継承していない
  • メンバ変数が初期化されていない

これらを満たせばC++11での集成体となれます(同時にC++14での要件も満たします)。また、メンバ変数が集成体である必要はありませんし、仮想関数でなければ関数がいくらあっても構いません。

集成体初期化は一様初期化のようなコンストラクタ呼び出しとは異なり、値を直接初期化するような構文です。そのため、全メンバはpublicであり、ユーザー定義コンストラクタがあってはいけません。

メンバ変数が初期化されていないとは、C++11から可能になったデフォルトメンバ初期化子による初期化がされていてはいけないという事です。

//集成体の例
struct aggregate {
  aggregate() = default;
  aggregate(aggregate&&) = delete;

  int a;
  int b;
  std::string str;
};

//全メンバを明示的に初期化
aggregate a1 = {10, 20, "string"};
//全メンバをデフォルト初期化(各メンバmiについて mi = {}、のような空の初期化リストからのコピー初期化を行う)
aggregate a2 = {};

//集成体でない例
struct not_aggregate {
  not_aggregate()
    : a{} 
    , b{}
  {}
  
private:
  int a;
  int b;
};

struct default_initialized {
  int a = 10;
};

struct has_vfuuc {
  int n;
  
  virtual int f() {
    return n;
  }
};

//base自体は集成体
struct base {
  int a;
};

struct derived : base {};

//complie error 集成体初期化不可
not_aggregate a = {10, 20};
default_initialized d = {30};
has_vfuuc h = {10};
derived d = {10};

VisualStudio 2015同梱のcl.exeは部分的にC++17まで対応していますが、集成体の要件に関してはこのC++11止まりです。

C++14

  • ユーザー定義のコンストラクタを持たない
    • defaultやdelete指定された宣言はあってもok
  • privateやprotectedなメンバ変数を持たない
  • 仮想関数をメンバに持たない
  • 継承していない

メンバ変数の初期化が解禁されました。これは疑問の余地のない当然の変更といえるでしょう。

//集成体の例
struct aggregate {
  aggregate() = default;
  aggregate(aggregate&&) = delete;

  int a = 0;
  int b;
  std::string str = "string";
};

aggregate ag = {10, 20};

//集成体でない例
struct not_aggregate {
  not_aggregate()
    : a{} 
    , b{}
  {}
  
private:
  int a;
  int b;
};

struct has_vfuuc {
  int n;
  
  virtual int f() {
    return n;
  }
};

//base自体は集成体
struct base {
  int a;
};

struct derived : base {};

//complie error 集成体初期化不可
not_aggregate a = {10, 20};
has_vfuuc h = {10};
derived d = {10};

C++17

  • ユーザー定義のコンストラクタ、explictコンストラクタ宣言、継承されたコンストラクタを持たない
    • explictでなければ、defaultやdelete指定された宣言はあってもok
  • privateやprotectedなメンバ変数を持たない
  • 仮想関数をメンバに持たない
  • virtual, private, protectedな基底クラスを持たない

public継承に限って継承が許可されました。ただし、基底クラスのコンストラクタを継承してはいけません。しかし、基底クラスが集成体でなければならないわけではありません。
単にpublic継承しただけでは、基底クラスのすべてのコンストラクタは隠蔽されています(言うなれば、コンストラクタは継承していません)。基底クラスのコンストラクタを使用可能にするにはusing宣言が必要です。

struct Base {
    Base() : m{10}
    {}

    Base(int n) : m{n}
    {}

    int m;
}

struct Derived : Base {
    //このusingによって基底クラスの全てのコンストラクタは継承される
    using Base::Base;
}

//call Base::Base(int) not aggregate initialization, d.m == 30
Derived d{30};

継承されたコンストラクタを持たないとは、この様なusing宣言を行っていないことを意味します。

もう一つの変更点、explictなコンストラクタの宣言があってはならないというのはどういうことでしょうか?ユーザー定義コンストラクタはそもそも書けないので同じことではないか?
しかし、explicit default/deleteなコンストラクタの宣言は可能なのです。そしてその結果、集成体でありながら集成体初期化できないという意味のないことが起こります。そのため、explicitコンストラクタ宣言をもつ場合は集成体となれないとされたわけです。

struct explicit_ctor {
  explicit explicit_ctor() = delete;
  
  int a;
  int b;
};

//compile error! before C++14 and after C++17
explicit_ctor e = {10, 20};

以下コード例

struct base {
  base(int n) : a{n} {}

private:
  int a;
};

//集成体の例
struct aggregate : base {
  aggregate() = default;
  aggregate(aggregate&&) = delete;

  int a = 0;
  int b;
  std::string str = "string";
};

aggregate ag = {10, 20, 30, "string."};

//集成体でない例
struct not_aggregate {
  not_aggregate()
    : a{} 
    , b{}
  {}
  
private:
  int a;
  int b;
};

struct has_vfuuc {
  int n;
  
  virtual int f() {
    return n;
  }
};

//complie error 集成体初期化不可
not_aggregate a = {10, 20};
has_vfuuc h = {10};

C++20

C++20はまだ発効前なのでこれはあくまで予定です。

  • ユーザー宣言のコンストラクタ、継承されたコンストラクタを持たない
  • privateやprotectedなメンバ変数を持たない
  • 仮想関数をメンバに持たない
  • virtual, private, protectedな基底クラスを持たない

defaultやdelete指定も含めてあらゆるコンストラクタの宣言が禁止されました。C++03までの要件+継承可能、になった感じです。この変更はなぜなされたのでしょうか?

C++20での要件変更の理由

1. 意図しない初期化

以下のようなケースが可能になってしまうことです。

struct delete_defctor {
  delete_defctor() = delete;
};

//compile error!
delete_defctor x;
//ok. aggregate initialization
delete_defctor x{};

デフォルトコンストラクト不可能にしたいのに、集成体の要件を満たしているので集成体初期化が可能になっており、結果としてデフォルトコンストラクト可能であるかのように振舞っています。
これはprivateにしても変わりません。

struct delete_defctor {
private:
  delete_defctor() = delete;
};

//compile error!
delete_defctor x;
//ok. aggregate initialization
delete_defctor x{};

簡単な回避策としてはとりあえずexplicitを付ければ期待通りになりますが、それを理解できる人がどれほどいるのでしょうか・・・?

また、上のコードにメンバがある場合にまた面白いことになります。

struct delete_init_int {
  delete_defctor() = delete;
  delete_defctor(int) = delete;

  int n = 10;
};

//compile error!
delete_init_int x(3);
//ok. aggregate initialization
delete_init_int x{3};

intで初期化してほしくないが集成体初期化によりコンストラクタを完全にスルー出来てしまっています。
これも解決はexplicitつけるとか、privateにしろよ、とかですが、このような些末な仕様の詳細をほとんどのC++プログラマは知らず、知ることもなく、また知る必要がないようにすべき。というのが理由の一つです。

2. = defaultの位置による違い
struct aggregate {
  aggregate() = default;

  int n;
};

struct not_aggregate {
  not_aggregate();

  int n;
};

not_aggregate::not_aggregate() = default;

aggregate x{10};
//compile error! can't aggregate initialization
not_aggregate y{10};

コンストラクタの宣言と定義を分割すると、集成体ではなくなります。同じ意味のコードであるはずなのにdefaultの位置で型の意味が全く変わってしまっているのは思わぬバグの原因になりえます。

3. C++20より可能になる通常の()による集成体初期化のため

onihusube.hatenablog.com

C++20より集成体初期化を普通の丸かっこで行えるようなります。この環境の下では、明らかに上の1の問題がさらに深刻になります。

要件変更によるメリット

このように些末な問題ではありますが、ただでさえ複雑なC++における初期化についてこれらの問題はその複雑度を上げてしまっています(C++17で禁止されたexplicitコンストラクタの宣言という特殊ケースも含めて)。この複雑性はユーザー宣言コンストラクタを持つ型は集成体ではない、と決めることで取り除くことができるためそのようになりました。

この変更によって、ユーザー宣言コンストラクタがある場合にはコンパイラが暗黙に生成するデフォルトコンストラクタは無効になる、というルールに集成体初期化(が提供する仮想的なコンストラクタ)も含まれることになり初期化に関するセマンティクスの一貫性が増します。
クラスの初期化に関して複雑なルールを覚えることなく、クラスに宣言されたコンストラクタがあれば必ずそれらのうちの一つを通してクラスは初期化される、という単純なルールを覚えればよくなります。

そして、この変更は意図せず集成体となってしまっていたバグを取り除きます。以下のコードのように、C++03まではコンストラクタのdefaultなど無かったため、デフォルトコンストラクタを取り合えず書いておけば集成体にはなりませんでした。

struct X {
  X() {}
  // some data members...
};

しかしC++11に対応する過程でそのようなコードが=defaultによって書き直された場合、それは集成体になってしまい意図しない初期化が可能になります。これは実際にLLVM/clangのコードで確認されたことのようです。

こうしてみればバラ色の提案にも見えますが問題がないわけではありません。意図をもって各コンストラクタをdeleteしているようなC++11以降のコードは完全に壊れます(この変更はC++03以前とは互換性がある)。
また現在の所、集成体のムーブ/コピーコンストラクタを明示的に制御する方法は提供されていません。つまりは、集成体がムーブ/コピー可能であるかはそのデータメンバによって暗黙的に変化します。これはC++20正式採用までに変化する可能性はありますが・・・

以下C++20におけるコード例

struct base {
  base(int n) : a{n} {}

private:
  int a;
};

//集成体の例
struct aggregate : base {
  int a = 0;
  int b;
  std::string str = "string";
};

aggregate ag = {10, 20, 30, "string."};

//集成体でない例
struct not_aggregate {
private:
  int a;
  int b;
};

struct has_ctor {
  has_ctor() = default;

  int n;
};

struct has_vfuuc {
  int n;
  
  virtual int f() {
    return n;
  }
};

//complie error 集成体初期化不可
not_aggregate a = {10, 20};
has_ctor c = {10};
has_vfuuc h = {10};

参考文献

この記事のMarkdownソース

[C++]フェラーリの方法による4次方程式の求解

フェラーリの方法

ax^ 4+bx^ 3+cx^ 2+dx+e=0の形の実数係数4次方程式の解の公式を求める方法がフェラーリさんによって考案されたフェラーリの方法です。 まずは、これを導出してみます。

1. 4次の係数を1にする

まず、4次の項の係数を1にします。全体を4次の係数aで割ってやります。


\begin{aligned}
ax^ 4+bx^ 3+cx^ 2+dx+e&=\frac{a}{a}x^ 4+\frac{b}{a}x^ 3+\frac{c}{a}x^ 2+\frac{d}{a}x+\frac{e}{a}\\
&=x^ 4+\frac{b}{a}x^ 3+\frac{c}{a}x^ 2+\frac{d}{a}x+\frac{e}{a}\\
&=x^ 4+Ax^ 3+Bx^ 2+Cx+D
\end{aligned}

3次以下の係数をA,B,C,Dと置きなおして次に進みます。

このように最高次の係数を1にした1変数多項式をモニック多項式と呼んだりします。

2. 3次の項を削除する(チルンハウス変換)

次に、チルンハウス変換により3次の項の係数を0にします。3次項を消し去ります・・・。 x=y-\frac{A}{4}と置換してやります。


\begin{aligned}
x^ 4+Ax^ 3+Bx^ 2+Cx+D&=\left(y-\frac{A}{4}\right)^ 4+A\left(y-\frac{A}{4}\right)^ 3+B\left(y-\frac{A}{4}\right)^ 2+C\left(y-\frac{A}{4}\right)+D\\
&=y^ 4-4y^ 3\left(\frac{A}{4}\right)+6y^ 2\left(\frac{A}{4}\right)^ 2-4y\left(\frac{A}{4}\right)^ 3+\left(\frac{A}{4}\right)^4+Ay^ 3-A3y^ 2\frac{A}{4}+A3y\left(\frac{A}{4}\right)^ 2-A\left(\frac{A}{4}\right)^ 3+By^ 2-B2y\frac{A}{4}+B\left(\frac{A}{4}\right)^ 2+Cy-C\frac{A}{4}+D\\
&=y^ 4+(-A+A)y^ 3+\left(\frac{6A^ 2}{16}-\frac{3A^ 2}{4}+B\right)y^ 2+\left(-\frac{4A^ 3}{64}+\frac{3A^ 3}{16}-\frac{AB}{2}+C \right)y+\left(\frac{A}{4}\right)^ 4-A\left(\frac{A}{4}\right)^ 3+B\left(\frac{A}{4}\right)^ 2-C\left(\frac{A}{4}\right)+D\\
&=y^ 4+\left(-\frac{6}{16}A^ 2+B \right)y^2+\left(\frac{8}{64}A^ 3-\frac{AB}{2}+C \right)y+\left(\frac{A}{4}-A\right)\left(\frac{A}{4}\right)^ 3+B\left(\frac{A}{4}\right)^ 2-C\left(\frac{A}{4}\right)+D\\
&=y^ 4+\left(-6\left(\frac{A}{4}\right)^ 2+B\right)y^ 2+\left(8\left(\frac{A}{4}\right)^3-2B\left(\frac{A}{4}\right)+C\right)y+\left(-3\left(\frac{A}{4}\right)^4+B\left(\frac{A}{4}\right)^2-C\left(\frac{A}{4}\right)+D\right)\\
&=y^ 4+py^ 2+qy+r
\end{aligned}

係数をp,q,rと置いて次に行きます。

チルンハウス変換

一般のn次方程式a _ nx^ n + a _ {n-1}x^ {n-1}+ ... + a _ 0 = 0に対してx = y - \frac{a _ {n-1}}{na _ n}のような変数変換を行うと、n-1次の係数を0にすることができます。これをチルンハウス変換と呼びます。

ちなみに、5次方程式で出てくるチルンハウス変換はこれを推し進めた結果4~2次までの項を消し去ることに成功した、少しハイレベルなチルンハウス変換です。結果として手計算がほぼ不可能になっているようですが・・・。

3. 完全平方式に

4次式のままだと相変わらず解けないので、何とかして式の次数を落とします。

式を右辺と左辺に分け、両辺をyに関する完全平方式にすることを考えます。どういうことかというと、 (y^ 2+t)^ 2 = (my+n)^ 2の形にするのです。ただし、途中で出てくる係数を綺麗にするためにt=\frac{t}{2}と置き換えて置きます。 まず、 (y^ 2+\frac{t}{2})^ 2 = y^ 4+ty^ 2+\frac{t^ 2}{4}という公式を利用します。ここではまだ、tは任意の数として具体的に決めないでおきます。


\begin{aligned}
y^ 4+py^ 2+qy+r&=0\\
y^ 4 &=-py^ 2-qy-r\\
y^ 4 +ty^ 2+\frac{t^ 2}{4}&=-py^ 2-qy-r +ty^ 2+\frac{t^ 2}{4}\\
(y^ 2+\frac{t}{2})^2&=(t-p)y^ 2-qy+(\frac{t^ 2}{4}-r)
\end{aligned}

左辺をまず完全平方式にするために、4次の項だけを左辺に残して両辺にty^ 2+\frac{t^ 2}{4}を加えて変形していきます。結果、左辺は完全平方式になりましたが右辺はまだそうなってはいません。右辺の式はよく見てみるとyに関する2次式になっています。

ここで、2次式が完全平方式になるためにはその判別式(D=b^ 2-4ac)が0でなければならないという条件を利用します。その条件を満足するようにtの値を決めてやります。


\begin{aligned}
b^ 2-4ac&=(-q)^ 2-4(t-p)(\frac{t^ 2}{4}-r)\\
&=q^ 2 -4(\frac{t^ 3}{4}-rt-\frac{pt^ 2}{4}+pr)\\
&=q^ 2 -t^ 3+4rt+pt^ 2-4pr\\
&=-t^ 3+pt^ 2+4rt+q^ 2-4pr\\
&=t^ 3-pt^ 2-4rt+(4pr-q^ 2)\\
&=0
\end{aligned}

と、このようにtに関しての3次方程式が出てきました。この3次方程式の3つの解のうち1つを選んでtとしてやれば、(-q)^ 2-4(t-p)(\frac{t^ 2}{4}-r)=0の条件を満たしているため先ほどの式の右辺を完全平方式(my+n)^ 2の形に置き換えることができます(3次方程式の求解は以前の記事に投げます)。

そして、ここで得られた3次方程式のことを三次分解方程式と呼びます。

それでは右辺を完全平方式に変形します。途中で、x^ 2-2ax = (x-a)^ 2-a^ 2という関係を使って式を変形します。


\begin{aligned}
(t-p)y^ 2-qy+(\frac{t^ 2}{4}-r)&=(t-p)\left(y^ 2-\frac{qy}{t-p}+\frac{\frac{t^ 2}{4}-r}{t-p} \right)\\
&=(t-p)\left(y^ 2-2\frac{q}{2(t-p)}y+\frac{\frac{t^ 2}{4}-r}{t-p} \right)\\
&=(t-p)\left(\left(y-\frac{q}{2(t-p)}\right)^ 2-\left(\frac{q}{2(t-p)}\right)^ 2+\frac{\frac{t^ 2}{4}-r}{t-p} \right)\\
&=(t-p)\left(\left(y-\frac{q}{2(t-p)}\right)^ 2-\frac{q^ 2}{4(t-p)^ 2}+\frac{\frac{t^ 2}{4}-r}{t-p} \right)\\
&=(t-p)\left(y-\frac{q}{2(t-p)}\right)^ 2-\frac{q^ 2}{4(t-p)}+\frac{t^ 2}{4}-r\\
&=(t-p)\left(y-\frac{q}{2(t-p)}\right)^ 2-\frac{1}{4(t-p)}(q^ 2-4(t-p)(\frac{t^ 2}{4}-r))\\
&=(t-p)\left(y-\frac{q}{2(t-p)}\right)^ 2-\frac{1}{4(t-p)}\cdot 0\\
&=(t-p)\left(y-\frac{q}{2(t-p)}\right)^ 2
\end{aligned}

途中で判別式のq^ 2-4(t-p)(\frac{t^ 2}{4}-r)=0を利用して不要な項をまとめて消してしまっています(というか、これが出てくるため前述の条件がある)。結果、完全平方式一歩手前のすっきりした式になりました。
さらに変形してm, nを求めてやりましょう。外に出ている(t-p)を何とかしてかっこの中に埋め込みます。


\begin{aligned}
(y^ 2+\frac{t}{2})^2&=(t-p)y^ 2-qy+(\frac{t^ 2}{4}-r)\\
&=(t-p)\left(y-\frac{q}{2(t-p)}\right)^ 2\\
&=(\sqrt{t-p})^ 2\left(y-\frac{q}{2(t-p)}\right)^ 2\\
&=\left((\sqrt{t-p}) \left(y-\frac{q}{2(t-p)}\right)\right)^ 2\\
&=\left(\sqrt{t-p}y-\frac{q \sqrt{t-p}}{2(t-p)}\right)^ 2\\
&=\left(\sqrt{t-p}y-\frac{q}{2\sqrt{t-p}}\right)^ 2\\
&=(my+n)^ 2
\end{aligned}

半ば無理やりかっこの中へ押し込み、(my+n)^ 2の形に持っていきました。下から二行目では\sqrt{t-p}の有理化によって係数を少しすっきりとさせています。

4. 2次式へ

天下り的に4次方程式から両辺をyに関する完全平方式の形に変換していましたが、なぜそのようなことをするのでしょうか?得られた完全平方式を変形してみると


\begin{aligned}
(y^ 2+\frac{t}{2})^ 2&=(my+n)^ 2\\
(y^ 2+\frac{t}{2})^ 2-(my+n)^ 2&=0\\
(y^ 2+\frac{t}{2} + my+n)(y^ 2+\frac{t}{2} - my -n)&=0\\
(y^ 2+my+\frac{t}{2}+n)(y^ 2-my+\frac{t}{2}-n)&=0
\end{aligned}

2行目→3行目では、因数分解でよく出る二乗の公式(x^ 2-y^ 2=(x+y)(x-y))を使っています。
このように二つのyに関する2次方程式の積に変形することができます。この式が成り立つのはどちらか(もしくは両方)の2次式が0になるとき、つまりyがそれぞれの2次方程式の解となるときです。

遠回りをしてきたので印象薄いかもしれませんが、ここのyはチルンハウス変換後の式y^ 4+py^ 2+qy+rから来ています。つまり、この二つの2次式の解として求められるyこそが欲しかった4次方程式の解となります。

2次方程式の解の公式は分かっているのでそれを利用すれば4次方程式の解の公式は以下のようになります。


\begin{aligned}
y_{1,2}&=\frac{-m\pm \sqrt{m^ 2-4(\frac{t}{2}+n)}}{2}\\
y_{3,4}&=\frac{m\pm \sqrt{m^ 2-4(\frac{t}{2}-n)}}{2}
\end{aligned}

必要な係数m, t, nはここまでですでに求まっていますので、この式は解くことができそうです。

5. 得られた各値より解を求める

無事にyが4つ得られたので、チルンハウス変換を解いてxを求めてやりましょう。と言ってもそのままそれぞれのx,yについて


\begin{aligned}
x_i&=y_i - \frac{A}{4}
\end{aligned}

としてやれば、4次方程式の4つの解xを求めることができます。
先ほどの式をまとめて少し展開すると


\begin{aligned}
y&=\frac{-(\pm m) \pm \sqrt{(\pm m)^ 2-4(\frac{t}{2}\pm n)}}{2}\\
&=\frac{\mp m \pm \sqrt{m^ 2-4(\frac{t}{2}\pm n)}}{2}\\
&=\frac{\mp m \pm \sqrt{m^ 2-2t-(\pm 4n)}}{2}\\
&=\frac{\mp m \pm \sqrt{m^ 2-2t\mp 4n}}{2}\\
&=\frac{\mp \sqrt{t-p} \pm \sqrt{(\sqrt{t-p})^ 2-2t\mp 4 \left(-\frac{q}{2\sqrt{t-p}} \right)}}{2}\\
&=\frac{\mp \sqrt{t-p} \pm \sqrt{(t-p)-2t\mp \left(-2\frac{q}{\sqrt{t-p}} \right)}}{2}\\
&=\frac{\mp \sqrt{t-p} \pm \sqrt{-t-p \pm \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}\\
&=\frac{\mp_1 \sqrt{t-p} \pm_2 \sqrt{-t-p \pm_1 \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}
\end{aligned}

\mp_1,\pm_1は二つの2次式より、\pm_2は解の公式より来ているものなので独立に変化します。分かりやすく分けて、チルンハウス逆変換も含めて書けば各式は以下のようになります。


\begin{aligned}
x_1&=\frac{- \sqrt{t-p} + \sqrt{-t-p + \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}- \frac{A}{4}\\
x_2&=\frac{- \sqrt{t-p} - \sqrt{-t-p + \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}- \frac{A}{4}\\
x_3&=\frac{\sqrt{t-p} + \sqrt{-t-p - \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}- \frac{A}{4}\\
x_4&=\frac{\sqrt{t-p} - \sqrt{-t-p - \left(2\frac{q}{\sqrt{t-p}} \right)}}{2}- \frac{A}{4}
\end{aligned}

判別式

3次以下の多項式に判別式があったように、4次方程式にも判別式があります。一見すると、最終的に求められた公式の2,3次と同じようにルートの中身\left(-t-p \pm \left(2\frac{q}{\sqrt{t-p}} \right)\right)が使えそうに見えますが、この中のプラスマイナスは2組の2次方程式の解の公式毎に独立しています。つまり、2次方程式毎の判別式としてしか使えません。

Wikipediaによると4次方程式の判別式は以下のようになります(a=1,b=0としてここで求めた式に合わせて係数も置き換えています)。


\begin{aligned}
D&=256r^ 3-128p^ 2r^ 2+144pq^ 2r-27r^ 4+16p^ 4r-4q^3r^2
\end{aligned}

このDは3次以下の時と同じように

  • D=0 : 少なくとも二つの解が重複。それが複素数ならば、その共役も重複。
  • D \gt 0 : 2組の互いに共役な複素数
  • D \lt 0 : 2つの実数解と互いに共役な複素数

となります。

解の様子がどうなるかは分かりますが、この式はここで求めた解の公式(の各項)との関連が見られず、計算したとしても使いまわせないので今回の実装においては使用しないことにします。

一部の係数が0になる場合の変形

複二次式

ax^ 4+bx^ 3+cx^ 2+dx+eという形の4次方程式に対して、奇数次の項の係数b,dが0になっている形の4次方程式ax^ 4+cx^ 2+eを複二次式と呼びます。

複二次式はx^ 2についての二次方程式と見ることで簡単に解くことができます。
y = x^ 2とおいてやるとay^ 2+cy+eとなるので、二次方程式の解の公式を適用してやると


\begin{aligned}
y&=\frac{-c \pm \sqrt{c^ 2 -4ae} }{2a}\\
x&=\pm\sqrt{y}\\
&=\pm\sqrt{\frac{-c \pm \sqrt{c^ 2 -4ae} }{2a}}
\end{aligned}

この様に、簡単に解を求めることができます。

求めたy^ 4+py^ 2+qy+r=0の場合は、q=0のときにy^ 4+py^ 2+r=0となって、y = z^ 2と置いてやれば同じように、\sqrt{\frac{-p \pm \sqrt{p^ 2 -4r} }{2}}と求めることができます。

4次方程式には4つの解がありますが、複二次式の場合は二次方程式を解いた解が2つ出てきて、最後に平方根を求めるところでそれぞれ最大2つの解が得られます。

r=0の場合

y^ 4+py^ 2+qy+r=0の切片r=0の場合も式を簡単にすることができます。


\begin{aligned}
y^ 4+py^ 2+qy+0&=y^ 4+py^ 2+qy\\
&=y(y^ 3+py+q)
\end{aligned}

となり、解はy=0y^ 3+py+qの3つの解となります。

C++実装

さて、必要な公式などは一通りそろったのでC++コードにコピペしていきます。公式通り順番にやっていきます。

3次式の時と同じく、値型はテンプレートにしておきます。相変わらず構造化束縛を使用しているのでC++17未満のコンパイラではstd::tieを使う等してください。

まずstep1、4次の係数を1にするところです。

template<typename T>
auto SolveQuarticEquation(const T a, const T b, const T c,const T d, const T e) -> std::tuple<std::complex<T>, std::complex<T>, std::complex<T>, std::complex<T>> {
    if (a == T(0.0)) {
        constexpr std::tuple<std::complex<T>> zero{};

        auto x = SolveCubicEquation(b, c, d, e);
        return std::tuple_cat(std::move(x), zero);
    }
    else if (b == T(0.0) && d == T(0.0)) {
        //複二次式
        return BiquadraticEquation(a, c, e);
    }

    return SolveQuarticEquation(b / a, c / a, d / a, e / a);
}

普通に各係数を割り算をするだけ。もし4次の係数がゼロなら三次方程式として、奇数次係数がゼロなら複二次式として解いてやります。

template<typename T>
auto SolveQuarticEquation(const T A, const T B, const T C, const T D) -> std::tuple<std::complex<T>, std::complex<T>, std::complex<T>, std::complex<T>> {
    //(A/4)
    const auto A_d4 = A / T(4.0);
    //(A/4)^2
    const auto A_d4_sq = A_d4 * A_d4;
    //(A/4)^3
    const auto A_d4_cu = A_d4 * A_d4_sq;

    const auto p = T(-6.0) * A_d4_sq + B;
    const auto q = T(2.0) * (T(4.0) * A_d4_cu - B * A_d4) + C;
    const auto r = (T(-3.0) * A_d4_cu - C) * A_d4 + B * A_d4_sq + D;

    auto [y1, y2, y3, y4] = SolveQuarticEquation(p, q, r);

    return std::make_tuple(y1 - A_d4, y2 - A_d4, y3 - A_d4, y4 - A_d4);
}

step2、チルンハウス変換部分。p,q,rを求めて、次のステップに渡します。帰ってきた各yから\frac{A}{4}を引いてチルンハウス変換を戻し、最終的な解xとして返します。

template<typename T>
auto SolveQuarticEquation(const T p, const T q, const T r) -> std::tuple<std::complex<T>, std::complex<T>, std::complex<T>, std::complex<T>> {
    if (q == T(0.0)) {
        //複二次式
        return BiquadraticEquation(T(1.0), p, r);
    }
    else if (r == T(0.0)) {
        constexpr std::tuple<std::complex<T>> zero{};

        auto x = SolveCubicEquation(p, q);

        return std::tuple_cat(std::move(x), zero);
    }

    //tの式の各係数を求める
    //t^3 -pt^2-4rt+(4pr-q^2) = 0

    const auto r4 = T(4.0)*r;
    const auto c =r4*p - q * q;

    std::complex<T> t_c{};

    //tのうち実数のものを選択
    std::tie(t_c, std::ignore, std::ignore) = SolveCubicEquation(-p, -r4, c);
        
    const auto t = t_c.real();

    //t-p
    const auto t_p = t - p;

    if (T(0.0) <= t_p) {
        //m = √(t-p)
        const auto m = std::sqrt(t_p);

        //n = q/2√(t-p)
        const auto n = q / (T(2.0)*m);

        //t/2
        const auto half_t = T(0.5)*t;

        //2つの二次式を解く!
        auto&& y_12 = SolveQuadraticEquation(T(1.0),  m, half_t - n);
        auto&& y_34 = SolveQuadraticEquation(T(1.0), -m, half_t + n);

        return std::tuple_cat(std::move(y_12), std::move(y_34));
    }
    else {
        //負の数の平方根を求めなければならない場合

        //m = √(t-p)
        const std::complex<T> m = { 0.0, std::sqrt(-t_p) };

        //n = q/2√(t-p)
        const auto n = q / (T(2.0)*m);

        //t/2
        const auto half_t = T(0.5)*t;

        //複素係数二次方程式
        auto&& y_12 = SolveQuadraticEquation({ T(1.0) },  m, half_t - n);
        auto&& y_34 = SolveQuadraticEquation({ T(1.0) }, -m, half_t + n);

        return std::tuple_cat(std::move(y_12), std::move(y_34));
    }
}

最後のstep、二つの二次方程式の解を求めることで各yの値を求めます。ここでもq=0の場合に複二次式として、r=0の場合には3次方程式として、それぞれ飛ばします。

tを3次方程式から求めますが、3次方程式は必ず一つは実数解があるのでそれを使うようにします(前回実装の通りなら、3次方程式の一つ目の解が実数解になるようになっています)。
しかしそれでも、\sqrt{t-p}の中身が負になってしまうと結局複素数が出て来てしまうのでその場合は複素数係数の2次方程式を解くようにします(その選択はオーバーロードにやってもらっています)。

template<typename T>
auto BiquadraticEquation(const T a, const T c, const T e) -> std::tuple<std::complex<T>, std::complex<T>, std::complex<T>, std::complex<T>> {
    auto [x1, x2] = SolveQuadraticEquation(a, c, e);

    auto sqrt_x1 = std::sqrt(x1);
    auto sqrt_x2 = std::sqrt(x2);

    return std::make_tuple(sqrt_x1, -sqrt_x1, sqrt_x2, -sqrt_x2);
}

最後に、複二次式を解く部分。こちらも2次方程式求解部分に投げます。帰ってきた値の符号を反転させたものが残りの解です。

3次も4次も主に引算の桁落ちについて慎重な考慮が必要ですが、それはまたの機会に・・・

コードとテスト実行
[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

解の検証
四次方程式の解 - 高精度計算サイト

Githubにも上げておきます(ヘッダ分けしてあったり一部異なっています、後随時更新されると思います)
QuarticEquation.hpp

参考文献

この記事のmarkdawnソース

[C++]void_tとその周辺

std::void_t

void_tとは以下のようなエイリアステンプレートです。f:Ts... \to void な感じのメタ関数になります。

template<typename Ts...>
using void_t = void;

あらゆる型のベクトルを受け取りvoidへ写します。こんなもんが一体何の役に立つというのか?というと、detection idiomと呼ばれる手法に使われます(というかそれ以外の使い方を知らない)。

detection idiom

detection idiomとはvoid_tを利用して、ある型が持つ特性や適用可能な操作をコンパイル時に検出する手法です。ググると以下のような感じの典型的な実装が出てくるかと思います。

template<class, class = std::void_t>
struct is_equality_comparable : std::false_type {};

template<class T>
struct is_equality_comparable<T, std::void_t<decltype(std::declval<const T&>() == std::declval<const T&>())>
   : std::true_type {};

この例だと、同じ型を引数にとるoperator==を実装しているか(等値比較可能か)を検出しています。 このままだと、operator==のチェック部分とvoid_tによる検出部分がくっついていて見づらいため、それらを分離してみます。

template<class, template<class> class, class = std::void_t<>>
struct detect : std::false_type {};

template<class T, template<class> class Check>
struct detect<T, Check, std::void_t<Check<T>>> : std::true_type {};
template<class T>
using is_equality_comparable_checker = decltype(std::declval<const T&>() == std::declval<const T&>());

template<class T>
using is_equality_comparable = detect<T, is_equality_comparable_checker>;

detectクラスがvoid_tを利用して特性の検出を行う部分、is_equality_comparable_checkerエイリアステンプレートが型Tを受け取り、operator==の存在チェックを行う部分になります。

このdetectクラスにある型TとTを一つ引数にとるメタ関数(この場合はis_equality_comparable_checker)を渡すことで、そのメタ関数が実行可能かによって任意の性質をチェックします。

例えばある型について足し算が可能かをチェックするには次のようなメタ関数を作ってやります。

template<class T>
using is_addable_checker = decltype(std::declval<const T&>() + std::declval<const T&>());

template<class T>
using is_addable = detect<T, is_addable_checker>;

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

たったのこれだけ、検出のキモはdetectクラスとvoid_tにあり、detectクラスではテンプレートの部分特殊化とSFINAEを利用してfalse_typeとtrue_typeの分岐を行っています。

void_tを主に利用しているのはstd::true_typeを継承する方のdetect(部分特殊化)で、チェックする一変数メタ関数(Check<T>)を実行しその戻り値型をvoidへ写しています。

template<class T, template<class> class Check>
struct detect<T, Check, std::void_t<Check<T>>> : std::true_type {};

ここで、もしもCheck<T>が失敗する(=TがCheckで調べられている特性を持っていない)場合はvoid_tはエラーとなりSFINAEによってプライマリテンプレート(std::false_typeを継承する方)が選択されます。

Check<T>が恙なく評価されれば、その戻り値型が何であれvoidへ写して終了。結果、プライマリテンプレートと部分特殊化のシグネチャが同じになり、部分特殊化が優先的に選択され、std::true_typeを継承したdetectが実体化されます。

void_tはその型引数が全てエラー無く評価出来たらvoidに、エラーが出たなら自身もエラーとなりSFINAEを起動する、そのための核となっているわけです。

int_tあるいは任意のT_t

ここで疑問に持たれた方もいるかもしれません。なぜvoid_tなのか?int_tやその他の型ではダメなのか?と。

結論から言えば、int_tでもなんでも良いのです。必要なのは、あらゆる型をある一つの型に写すという性質と、与えられた型引数がエラーとなるときは自身もエラーとなる(SFINAEのトリガーとなる)、この2つの性質なのです。

int_tを試してみましょう。detectクラスを以下のように修正します。

template<class...>
using int_t = int;

template<class, template<class> class, class = int_t<>>
struct detect : std::false_type {};

template<class T, template<class> class Check>
struct detect<T, Check, int_t<Check<T>>> : std::true_type {};

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

void_tと同じ結果を得られています。void_tと同じようにint_tを実装し、void_tを置き換えてやっただけです。

T_tにしてもおkです。

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

この様に別に何_tでもいいのですが、voidが選ばれたのはvoidという型がこのような性質を表現するのに最適であるからだと思われます(引数は捨てるし、写した結果も使用しない)。

クラステンプレートの部分特殊化とオーバーロード解決

ところで、例えばint_tではプライマリテンプレートの最後のデフォルトパラメータ以下のようにしても動きます。

template<class, template<class> class, class = int>
struct detect : std::false_type {};

これはvoid_tの場合も同じ(= voidにする)です。しかし、int_tならintvoid_tならvoidT_tならTにしないといけません(もしくは、最終的にならないといけない)。それ以外の型を指定しておくと、必ずプライマリテンプレートが選択されるようになります。なぜでしょうね・・・

これを理解するにはプライマリテンプレートと部分特殊化があるとき、どの様にそのオーバーロードが解決されるのかを知らねばなりません。

クラステンプレートが実体化が必要な個所で使用された場合、プライマリテンプレートと部分特殊化のうちの一つから最適な物を選択します。その時、与えられた実際の型の実引数列が部分特殊化の特殊化された仮引数列にどれだけ一致しているかを見ることで選択されます。

  1. 一致する部分特殊化が一つだけ見つかった場合は、それを選択。
  2. 一致する部分特殊化が複数見つかった場合は、半順序規則により最も特殊化されている部分特殊化を選択する。最も特殊化された部分特殊化が複数ある場合はコンパイルエラー。
  3. 一致する部分特殊化が見つからなかった場合、プライマリテンプレートが選択される。

半順序規則はここでは関係ないので説明を省きます(というかできません・・・)。

この選択ルールからまずわかることは、与えられた型引数にプライマリテンプレートと部分特殊化が両方マッチするとき、部分特殊化が優先されるということです(プライマリテンプレートの優先度は最低)。これにより、detection idiomにおいてstd::true_typeを継承する方が常に部分特殊化になっていることと、それが選択される理由が分かります。

次にプライマリテンプレートの最後のデフォルト引数をT_tTと一致しておく必要があること、ですが、これはコンパイラのお気持ちになって実引数と仮引数のマッチングを考えれば分かります。void_tを使ったdetectクラスで考えてみます。

例えばis_addable<int>を呼ぶと、detectは以下のように呼ばれます。

detect<int, is_addable_checker>;

この時、引数は2つしか渡していないから部分特殊化を選択、とはなりません。呼び出しが適格であるためには、少なくともプライマリテンプレートのシグネチャに合っていなければなりません。プライマリテンプレートは3引数で宣言されているので3つ目の引数をまず充填します。どこからというと、プライマリテンプレートのデフォルト引数(すなわちvoid)を入れます(デフォルト引数が無いとコンパイルエラー)。

結果、呼び出されたdetectシグネチャdetect<int, is_addable_checker, void>となります。

次にこのシグネチャで部分特殊化を見に行きましょう。とはいえ一つしかないのでそれの仮引数列と上の実引数列をマッチングします。

template<class T, template<class> class Check>
struct detect<T, Check, std::void_t<Check<T>>> : std::true_type {};

2つ目の引数までは問題ありませんが3つ目の引数はメタ関数の結果をvoid_tで写した結果(つまりvoid)が利用されます。なのでそこを展開しましょう。is_addable_checker<int>intの足し算の結果の型(int)で定義されます。これは何のエラーも起こりませんので結果intが帰り、void_tによってvoidにされます。

結果、部分特殊化のシグネチャdetect<int, is_addable_checker, void>となり、呼び出されたシグネチャと見事に一致するので部分特殊化が選択されます。

なお、もしここでis_addable_checker<T>が失敗する(Tが足し算できない)場合、部分特殊化全体はエラーとなり、SFINAEによって候補から除外されます。結果、探すべき部分特殊化は無くなるので、選択ルールの3番目によりプライマリテンプレートが選択されます。

さて、ここでdetectの3番目の引数をintとでも置いてみましょう。

template<class, template<class> class, class = int>
struct detect : std::false_type {};

この状態で先ほどと同じように呼び出し、まずプライマリテンプレートから3つ目の引数を取得します。

結果、呼び出されたシグネチャdetect<int, is_addable_checker, int>となります。

次に部分特殊化のシグネチャを求めます、ここは先ほどとは変わらないため、部分特殊化のシグネチャdetect<int, is_addable_checker, void>となります。

この2つを比較してみますと3番目の引数が一致しません。よって部分特殊化はマッチングしているとはみなされなくなり、残りの部分特殊化も無いためプライマリテンプレートが選択されます。

このため、プライマリテンプレートのデフォルト引数はT_tTと一致している必要があり、無くてはならないものなのです。

void_tの誕生

(一旦void_tを忘れて)このような規則の下で、部分特殊化を利用してあるメタ関数が成功する場合と失敗する場合とで選択されるクラステンプレートを切り替えることを考えてみます。

プライマリテンプレートに対して部分特殊化を優先的に選択させるには、メタ関数成功時にプライマリテンプレートのシグネチャと一致している必要があります(失敗時はSFINAEに頼れば良い)。

メタ関数実行のためにテンプレートパラメータを余分に一つ受ける必要があるでしょう。SFINAEで選択される方(プライマリテンプレート)は実行の必要がないので、そこにデフォルトパラメータを設定しておけば良いでしょう。

しかし部分特殊化の方はその部分でメタ関数を実行するので、その結果をプライマリテンプレートのデフォルトパラメータに一致させる必要があります。

そのためには渡されるメタ関数の結果が何であれ最終的には予め予見できる何かしらの一つの型になってほしい。そのような何かが欲しい・・・

すなわちそれこそがvoid_tです。

この様に見れば、void_tとはクラステンプレートの部分特殊化オーバーロード解決をうまく利用するために必要不可欠なメタ関数であるという事と、detection idiomが上手く働く仕組みが分かるのではないでしょうか・・・?

参考文献

この記事のMarkdownソース

[C++]素敵な宇宙船演算子(<=>)

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

Spaceship Operator(宇宙船演算子)とは

C++20より追加される新しい二項演算子で、比較演算子の一つです。
ある値a,bでa<=>bとすると、a<b、a>b、a==bをそれぞれ0<、<0、0として一括判定します。
コードで書くと

#include <compare>//←必須!

int a{}, b{};

std::cin >> a;
std::cin >> b;

auto comp = a <=> b;

if (comp < 0) {
   std::cout << "a < b";
} else if (0 < comp) {
   std::cout << "a > b";
} else if (comp == 0) {
   std::cout << "a = b";
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ
この様に書けます。
以下この記事では宇宙船演算子と呼びますが、Consistent comparison(一貫比較)、Three-way comparison(三方比較)などとも呼ばれています。

C++で自前クラスに比較演算子を実装したことがあればその面倒さをご存じで、そこに新しくよく分からない一つが加わったとなれば、面倒なだけでいいことなどないと思われるでしょう。
しかし、そんなあなたにも朗報です。なんと、クラスに対して宇宙船演算子を定義しておくと、最大で6つの比較演算子が自動生成されます。

class C {
   int x;
   int y;
   double v;
   std::string str;
public:
   auto operator<=>(const C&) const = default;
   //もしくは
   //friend auto operator<=>(const C&, const C&) = default;
   //自前定義でもok
   //std::strong_ordering operator<=>(const C&, const C&) {/*実装略*/};
};

このクラスCは6つの比較演算子(a<b、a>b、a<=b、a>=b、a==b、a!=b)を用いて比較を行うことが出来ます。
もちろん、default任せにせずに自前で自由な比較を実装可能です。その場合でも最大6つの比較演算子の自動生成を期待できます。
この様に、宇宙船演算子は複雑な比較を一括で行うだけでなく今まで非常に面倒になっていた比較演算子の実装の手間を大幅に軽減してくれる凄い奴です。
そして、この宇宙船演算子の登場によってstd::rel_opsはその意味をほぼ失い、非推奨となりました・・・。

自動生成される演算子

宇宙船演算子を用いれば他の6つの比較は宇宙船演算子を用いて書くことが出来るので、そのように実装されるはずです。先ほどのクラスCに対してあえて書いてみると

class C {
//省略

   //bool operator==(const C& rhs) const { return *this <=> rhs == 0; }
   //bool operator!=(const C& rhs) const { return *this <=> rhs != 0; }
   bool operator< (const C& rhs) const { return *this <=> rhs <  0; }
   bool operator> (const C& rhs) const { return *this <=> rhs >  0; }
   bool operator<=(const C& rhs) const { return *this <=> rhs <= 0; }
   bool operator>=(const C& rhs) const { return *this <=> rhs >= 0; }

   bool operator==(const C& rhs) const = default;  //実装は後述
   bool operator!=(const C& rhs) const { return !(*this == rhs); }
};

この様な形になるでしょう。(ちなみに、宇宙船演算子の優先順位は他のすべての比較演算子よりも高くされています。)
ただし、実際にはこのように実装される訳ではなく、各比較演算子オーバーロード解決時にその比較演算子の実装に関わらず、宇宙船演算子を用いて(上記のような実装で)書かれた式をオーバーロード候補に入れて解決を行う、という形で各比較演算子を使用することが出来ます。なので、自動実装ではなく自動生成と呼んでいます。

少し詳しく書いておくと
任意の型T1,T2の値a,bに対して、任意の比較演算子@が呼び出された場合

  1. まず、a@b、a<=>b、b<=>aの3つをオーバーロードの候補として加えておく。
  2. オーバーロード解決の結果a<=>bが選ばれたら、a@b → a<=>b@0、のように書き換える。
  3. オーバーロード解決の結果b<=>aが選ばれたら、b@a → 0@b<=>a、のように書き換える。
  4. そして、そのように書き換えた式が比較演算子@であるかのように実行されます。この書き換えは上の定義と同じである事が分かるでしょう。

なお、書き換えた式内の<=>及び==演算子に対してこの演算子生成は行われません。

ただし、同値比較演算子(operator==,operator!=)は宇宙船演算子を用いて生成されません。operator==は独自に、operator!=はoperator==を利用してそれぞれ実装されます。
しかし、宇宙船演算子を=defaultで宣言しておけばoperator==が自動実装されるので、2つの同値比較演算子も利用可能になります。
上の手順において@が==か!=である場合、<=>の代わりに==を用いた書き換えが行われます。

残念ながら、自分で実装を行う場合はoperator==の実装も書かなければなりません・・・

対称性

あるクラスに対して宇宙船演算子を定義した場合、同じ型同士の同値比較ならほぼ気になりませんが、異なる型との比較や順序付けを行う場合はその対称性が当然に期待されます。
つまり

struct A {
   int a;

   auto operator<=>(const double) const;   //実装は省略
   auto operator== (const double) const;   //実装は省略
};


A a = {1};

auto comp1 = a   <=> 2.0;   //こっちが出来るなら、comp1 < 0
auto comp2 = 2.0 <=> a;     //こっちも出来てほしい、comp2 > 0

この様に、A<=>doubleが比較可能ならば、その逆のdouble<=>Aも比較可能であってほしいしそれが自然です。でも、一々両方定義するのって面倒ですよね・・・
ご安心ください、異種比較を行う宇宙船演算子は2対存在していますが、片方が定義されていればもう片方も自動生成されます。

かしこい人はお気づきかもしれませんが、前項のオーバーロード解決に関する説明で次のように書いてありました。
>任意の型T1,T2の値a,bに対して、任意の比較演算子@が呼び出された場合
>まず、a@b、a<=>b、b<=>aの3つをオーバーロードの候補として加えておく。
つまり、a@bと書いてもb<=>aが、b@aと書いてもa<=>bがオーバーロード解決の候補に入るため、片方だけ書いておけば良いのです。
ただし、@が<=>の場合、書き換えによって冗長な呼び出しとなる場合は書き換えは行われません。つまり、a<=>bと呼ばれた場合の候補はa<=>bと0<=>(b<=>a)の二つになり、a<=>b → (a<=>b)<=>0のような書き換えは行われません(逆も同様)。

等値比較演算子の場合も同様に、a==b(a!=b)と書いてもb==a(b!=a)がオーバーロードの候補に入れられるのでどちらか片方があればよくなります。

この結果、異種比較の演算子を定義する時でも宇宙船演算子(とoperator==)の利用によって最大12個の演算子をたった2つの演算子の定義から生成してもらうことが可能になります。

結局、それぞれの演算子を書いたときに考慮される候補は以下のようになります。

呼び出す演算子 a@b オーバーロード候補
a <=> b a <=> b
0 <=> (b <=> a)
a == b a == b
b == a
a != b a != b
!(a == b)
!(b == a)
a < b a < b
(a <=> b) < 0
0 < (b <=> a)
a <= b a <= b
(a <=> b) <= 0
0 <= (b <=> a)
a > b a > b
(a <=> b) > 0
0 > (b <=> a)
a >= b a >= b
(a <=> b) >= 0
0 >= (b <=> a)

生成される候補の式内で使用される<=>及び==演算子は、型T1,T2それぞれのメンバ・非メンバ・組み込み、のものが考慮されます。
この時、使用する<=> ==演算子が使用可能でない(定義されていない、削除されている、2つ以上の候補がマッチする、アクセスできない)場合は単に候補から外されますが、使用可能であっても戻り値型が比較カテゴリ型・boolを返さない場合はコンパイルエラーとなります。

default実装

宇宙船演算子が非テンプレートの非staticメンバ関数であり、そのクラスのconst参照を引数にとる宣言のみdefault指定することができ、コンパイラ様に実装して頂くことが可能です。
そのようなdefault実装は基底クラス及びメンバ変数の辞書式比較によって実装され、同時に宣言されるoperator==も同様の実装になります。
辞書式比較とは要するに、ある順番で並んでいる要素をその順番で比較していく方法です。std::tieを使って変数の比較をまとめて行う時も辞書式比較が用いられます。
defaultの<=>(==)は以下のように比較を行います。

  1. 基底クラスの<=>を呼び出し比較する。その順番は左から右(:の後に書いてある順)へ深さ優先で実行。
  2. 次に、宣言された順でメンバ変数の<=>を呼び出し比較を行う。
    • その際、配列はその要素に添え字順で<=>を適用する。
  3. これらの比較の際、比較の結果が0でない時点でその結果を返して終了。
class D : Base1, Base2 {
   int x;
   int y;
   double v;
   int array[3];
   std::string str;
public:
   //auto operator<=>(const D&) const = default;
   auto operator<=>(const D& that) const {
      if (auto comp = static_cast<const Base1&>(*this) <=> static_cast<const Base1&>(that); comp !=0) return comp; 
      if (auto comp = static_cast<const Base2&>(*this) <=> static_cast<const Base2&>(that); comp !=0) return comp;
      if (auto comp = x <=> that.x; comp !=0) return comp;
      if (auto comp = y <=> that.y; comp !=0) return comp;
      if (auto comp = v <=> that.v; comp !=0) return comp;
      if (auto comp = array[0] <=> that.array[0]; comp !=0) return comp;
      if (auto comp = array[1] <=> that.array[1]; comp !=0) return comp;
      if (auto comp = array[2] <=> that.array[2]; comp !=0) return comp;
      return str <=> that.str;
   }

   //auto operator==(const D&) const = default;
   auto operator==(const D& that) const {
      if (auto comp = static_cast<const Base1&>(*this) == static_cast<const Base1&>(that); comp != true) return false; 
      if (auto comp = static_cast<const Base2&>(*this) == static_cast<const Base2&>(that); comp != true) return false;
      if (auto comp = x == that.x; comp != true) return false;
      if (auto comp = y == that.y; comp != true) return false;
      if (auto comp = v == that.v; comp != true) return false;
      if (auto comp = array[0] == that.array[0]; comp != true) return false;
      if (auto comp = array[1] == that.array[1]; comp != true) return false;
      if (auto comp = array[2] == that.array[2]; comp != true) return false;
      return str == that.str;
   }
};

つまりはこの様な実装になることになります。もし辞書式順序かつ宣言とは違う順番に比較したいときはこのような形で自前実装する必要があります。
基底クラスの比較順が深さ優先とは、基底クラスのdefault実装<=>(==)を呼び出したときに、同じことが基底クラスの<=>(==)でも行われるためです。

なお、仮想基底クラス(virtual継承)がこの場合に複数回比較されるかは未規定です。コンパイラによって変わる可能性がありますが、それによって比較結果が変わる事は無いでしょう。

このようなデフォルト実装は、その実装がconstexpr関数の要件を満たしていれば自動的にconstexpr関数になりますし、呼び出す比較演算子が全てnoexceptなら自動的にnoexceptになります。
もちろん、それらを明示的に指定しておくこともできます。

暗黙deleteされるケース

宇宙船演算子のdefault実装は以下の場合にdeleteされます
(<=>を==に、比較カテゴリ型をboolに読み替えて、operator==も同様です)

  1. 基底クラス・メンバに使用可能な<=>を持たない型が存在する
    • 使用可能でないとは、存在しない、宣言されているが削除されているかアクセスできない場合のこと
  2. default実装内の<=>による比較が比較カテゴリ型を返さない
  3. メンバに参照型がある
  4. 基底クラス・メンバのいずれかがUnion-likeなクラスである

Union-likeなクラスとは、共用体そのもの、もしくは匿名共用体を含んでいるような型の事を言います。
なお、std::variantはこれには当てはまらず、別途<=>が用意されるのでdefault実装にも問題はありません。

戻り値型

default実装される宇宙船演算子の戻り値の型は後述するComparison category types(比較カテゴリ型)と呼ばれる3つの型のいずれかになります。同様に、組み込み/STLの宇宙船演算子もComparison category typesのいずれかの型を返します。
default実装は上記のように、基底クラスとメンバに対して連続的に<=>を適用するので、その戻り値型は全ての<=>の戻り値型の比較カテゴリ型を変換できる最も強い型となります。つまり、ユーザーは予測することは可能ですが面倒です(一応、そのような型を求めるためのstd::common_comparison_categoryというメタ関数が提供されます)。そのため、戻り値型はautoにしておくことが推奨されます。
なお、ユーザー定義の宇宙船演算子の戻り値型は自由です。一応0との比較が可能な型が望ましいですが制限はありません。つまりは誤用し放題です。

その他比較演算子のdefault実装

実は、<=>と==を除く残りの5つの比較演算子についても=default指定をすることができるようになります。
その場合、!=は==を、残りの4つは<=>を用いて実装されます(上で示したように)。

そのような<=>が見つからない(定義なし、deleteされている、比較カテゴリが合わない等の)場合、そのdefault比較演算子はdeleteされます。

これは、比較演算子のアドレスを取る必要がある場合に利用することを想定しているようです。

struct C {
   //比較カテゴリがstrong_equality
   friend std::strong_equality operator<=>(const C&);

   //宣言はOKだが、関数は暗黙deleteされる
   bool operator<(const C&) = default;
};

Comparison category types(比較カテゴリ型)

半順序、弱順序、全順序について予め理解しておくとこの節及び各型の意味の理解が深まるかもしれません。
[C++]狭義の弱順序(strict weak orderings)とは? - 地面を見下ろす少年の足蹴にされる私

前述のようにdefault実装/組み込み型/STLの一部の型、の宇宙船演算子は5つの比較カテゴリ型のいずれかを返します。これらの型はその比較(<=>)が満たしている順序や同値関係に関する性質を表しています。
新しく追加される<compare>ヘッダにおいて以下の5つが定義されます。

比較カテゴリ型 対応する2項関係 生成される演算子
std::weak_equality 同値関係(相当関係も含む全ての同値関係) == !=
std::strong_equality 相当関係(最も細かい(強い)同値関係) == !=
std::partial_ordering 半順序 == != < <= > >=
std::weak_ordering 弱順序 == != < <= > >=
std::strong_ordering 全順序 == != < <= > >=

※~_equalityな型は最終的に無くなりました・・・

partial→weak→strongの順で強く(制約がきつく)なり、逆方向への変換が可能です(暗黙変換が定義される)。

比較カテゴリ型の関係(矢印の方向に変換可能): p0515r3よりhttps://raw.githubusercontent.com/onihusube/blog/master/2019/20190113_spaceship_operator/comparison_categories.png

比較カテゴリ型は6つの比較演算子を用いて比較を行うことができます。その際、比較に使えるのは0リテラルのみで、0以外との比較は未定義動作とされています。例えば、nullptr_tを引数型として実装されます。

宇宙船演算子の返り値は+0-を表現できればいいので任意の符号付整数型でも十分ですが、わざわざこのような複雑な型を返すように定義されているのは、比較というものの分類についてC++型システムの恩恵を受けるためです。
単純な=という関係にさえ同値と等価の二つの種類があり、順序にも半・弱・全順序の3つの種類があります。中には=を=とみなせない、意味のある順序が付かないものがあり、実装する処理によってはこれを考慮しなければなりません(STL内ソートに関わる「狭義の弱順序」等)。
この時、その比較が満たしている要件を型で表現しておくことで、テンプレート等の機構によりコンパイル時の検出・切替を行うことができます。そのため、単純な符号付整数を返すのではなく比較のカテゴリを表明する型を返すようになっているのです。

weakとstrong

strongでない(partialやweakな)比較カテゴリ型による同値比較(operator==)では、trueとなった結果であっても区別できることがあります。

例えばアルファベットのみの文字列の比較を考えてみると

  1. 長さの比較
  2. 先頭からの辞書式比較

という手順が考えられますが、2番目の比較時に大文字小文字をどうするかどうかが問題です。

文字ごとの比較で大文字小文字を区別しない場合、そのoperator==がtrueを返したとしても目で見ればその文字列が異なっている場合があります(その二つの文字列は同値)。
この場合の比較カテゴリ型はweak_equalityになります(とすべきです)。

文字ごとの比較で大文字小文字を区別する場合、そのoperator==がtrueを返した文字列同士は目で見ても区別できないはずです(その二つの文字列は等価)。
この場合の比較カテゴリ型がstrong_equalityになります。

また、そこに順序(<)を加えることを考えてみると
アルファベットの順に大きくなっていくという風に決めたとしても

大文字と小文字を区別しない場合、a == Aという同値関係から同じ文字の大文字小文字同士の比較(例えばa < AとA < a)はともにfalseとなります。すなわち比較不能です。
この時、比較不能である=同値である、とすることによって上で決めた(大文字小文字を区別しない)同値関係を満たしつつ順序を導入することができます。
この様に、比較不能な要素を同値として扱い、そのような要素は他の要素との相対的な順序によって順序を付ける(並べる)とき、そのような比較(<)を弱順序であると言います。
そして、この場合の比較カテゴリ型はweak_orderingになります(とすべきです)。

大文字と小文字を区別する場合、例えば常に大文字 < 小文字かつZ < aと決めれば、全ての要素同士が比較可能になります。
この様に、比較不能な要素が無く全ての要素に順序を付けられるとき、そのような比較(<)を全順序であると言います。
そして、この場合の比較カテゴリ型がstrong_orderingになります。

substitutability(代入可能性、代入原理)

代入可能性とは、ある比較カテゴリにおいてa=b \to f(a)=f(b)となる性質の事です(この場合のfはpure function、数学的な関数と思ってください)。これを満たすのはstrong_orderingだけです。(数学的にはむしろ、この代入原理を満たしている=がtrueとなるときにのみ等価であるとします)
半順序(partial_ordering)はその順序付けにおいて比較不可能な値の存在を認めます。弱順序(weak_ordering)は比較不可能な値を同値(=)として扱う事で比較不可能な値を認めません。同値関係(weak_equality)は同値とみなせる値同士の関係がtrueとなりえます。つまり、これらの比較においてa == bは必ずしも等価であることを表しません。そのため、a == b がtrueだとしてもある関数を通した結果のf(a) == f(b)はtrueとは限りません。
宇宙船演算子をユーザー定義してstrong_orderingを返す場合はこの性質を満たしているべきです。特に、データメンバや基底クラスの一部が比較に関与しない実装になっているとこの性質を満たさない可能性があるので、すべてを比較に参加させる必要があります。

狭義の弱順序との関係

C++標準のソートに関わるところで要求されているのはoperator<()が狭義の弱順序を満たすことです。そして、これを満たすような比較カテゴリ型はweak_orderingとstrong_orderingのみです。
*_equalityとなる比較カテゴリ型は当然として、partial_orderingは順序付け比較を提供しますがそれを用いてソートをしても意味のある順序をつけることはできません。
ソートに使用するような比較演算子を提供したい場合、定義する宇宙船演算子の返すカテゴリ型がweak_orderingかstrong_orderingのどちらかに(もしくはそれを満たすように)なるように注意しなければなりません。

共通比較カテゴリ型(Common comparison category type)

宇宙船演算子の戻り値型をautoにする場合、その型は比較に参加するすべての型の宇宙船演算子による比較の結果となる比較カテゴリ型から共通して変換できる最も強い型、になります。
そのような型を共通比較カテゴリ型と言い、共通比較カテゴリ型は以下のように決定されます。

共通比較カテゴリ型をUとして、比較に参加するすべての型の宇宙船演算子による比較カテゴリ型をそれぞれ`Ti (0 <= i < N)`とすると

  1. Tiの中に一つでも比較カテゴリ型でない型がある場合、U = void
  2. Tiの中に1つでもpartial_orderingがある場合、U = partial_ordering
  3. Tiの中に1つでもweak_orderingがある場合、U = weak_ordering
  4. それ以外の場合、U = strong_ordering

これを一々考えるのは面倒なのでautoとしておくか、これを求めるために提供されるstd::common_comparison_categoryというメタ関数を使用するといいでしょう。

//<compare>ヘッダにて定義

namespace std {
   template <class ...Ts>
   struct common_comparison_category {
      using type = /* 略 */ ; 
   };

   template <class ...Ts>
   using common_comparison_category_t = typename common_comparison_category<Ts...>::type;
}

このメタ関数は以下のような型を返します。

  • Tsが空ならstrong_ordering
  • 各Tsが<=>をサポートする場合、各<=>の返す型を変換可能な最も強い比較カテゴリ型
  • それ以外の場合はvoid

比較カテゴリ型と==,<を利用した宇宙船演算子の合成

上で説明した宇宙船演算子のデフォルト実装は素晴らしいものですが、少し足りていないところがあります。それは、C++17以前に作成され、宇宙船演算子を持たない型に対してはデフォルト実装を提供できないことです。
そのような型をメンバに含んでいるクラスは宇宙船演算子のデフォルト実装の恩恵を受けることができません。

//C++17以前から使用されてきた秘伝の型、従来の比較演算子は実装されているが・・・
struct old_type {
   int n = 10;

   //共に実装は省略
   bool operator==(const old_type&) const;
   bool operator< (const old_type&) const;
};


//C++20環境で定義された新しい型
struct new_type {
   int m = 10;
   old_type l = {20};
   int n = 30;

   //old_typeは<=>を持たないため、実装不可、暗黙delete
   auto operator<=>(const new_type&) const = default;

   //==があるため実装可能(明示的な宣言は実は不要)
   bool operator== (const new_type&) const = default;
};

new_type n1{}, n2 = {20, {30}, 40};
auto comp = n1 <=> n2;  //ng!
bool eq   = n1 == n2;   //ok!

このような型に対して変更を加えることができればいいのですが、ライブラリの中にあったりして自分で手を出せない事もあるでしょう・・・
宇宙船演算子を手で実装すれば実装はできますが、やはりdefault実装に任せたいものです。

このような時、宇宙船演算子の戻り値型を明示的に書くことでdefault実装で済ます事ができます。

struct new_type {
   int m = 10;
   old_type l = {20};
   int n = 30;

   //指定した戻り値型とold_typeの持つ比較演算子を用いて実装してもらう
   std::strong_ordering operator<=>(const new_type&) const = default;
};

new_type n1{}, n2 = {20, {30}, 40};
auto comp = n1 <=> n2;  //ok!
bool eq   = n1 == n2;   //ok!

戻り値型を明示的に指定する事で比較の実装をどのように行えば良いのかが明確になり、< ==を使って<=>と同等の比較を構成できるようになります。
このようなdefault実装は以下のような実装になります。

struct new_type {
   int m = 10;
   old_type l = {20};
   int n = 30;

   //std::strong_ordering operator<=>(const new_type&) const = default;
   std::strong_ordering operator<=>(const new_type&) const = default {
      if (auto comp = static_cast<std::strong_ordering>(m <=> that.m); comp != 0) return comp;

      //<=>の合成
      std::strong_ordering comp = (l == that.l) ? std::strong_ordering::equal : 
                                  (l <  that.l) ? std::strong_ordering::less
                                                : std::strong_ordering::greater;
      if (comp != 0) return comp;

      return static_cast<std::strong_ordering>(n <=> that.n);
   }
};

このように、先ほど説明した通常のデフォルト比較の実装に加えて、必要ならこのように<=>を合成したうえでdefault実装を行います。
これによって、<=>を持たないレガシーな型をメンバに持つ際にも<=>のデフォルト実装を提供できるようになります。

合成のレシピ

ある型Tの値a,bと比較カテゴリ型Rを用いて、<=>は次のように合成されます。

まず、a <=> bのオーバーロード解決を行い使用可能な<=>が見つかった場合は、static_cast<R>(a <=> b)のように合成され、見つかったけれど使用できない(アクセスできない、削除されている等の)場合は合成されません。

次に、いかなる形の<=>も見つからない時はRによって以下のように合成されます。

指定された戻り値型R 合成結果
std::strong_ordering return a == b ? std::strong_ordering::equal :
a < b ? std::strong_ordering::less :
std::strong_ordering::greater;
std::weak_ordering return a == b ? std::weak_ordering::equivalent :
a < b ? std::weak_ordering::less :
std::weak_ordering::greater;
std::partial_ordering return a == b ? partial_ordering::equivalent :
a < b ? partial_ordering::less :
b < a ? partial_ordering::greater :
partial_ordering::unordered;
std::strong_equality return a == b ? strong_equality::equal : strong_equality::nonequal;
std::weak_equality return a == b ? weak_equality::equivalent : weak_equality::nonequivalent;

戻り値型が比較カテゴリ型でない場合等、これらの条件に当てはまらない場合は合成されません。

この様に合成された<=>を用いて、上で説明したようなdefault実装を行います。
なお、戻り値型がautoの時はこの合成は行われません。

合成された結果となる<=>が定義されない場合はdefault <=>は暗黙的にdeleteされます。
ill-formedとなる場合も同様にdeleteされていますが、コンパイルエラーを引きおこします。
ill-formedとなる場合とは、戻り値型をRに変換できない場合や==,<の戻り値型がboolに変換できない場合、そもそも==,<さえも利用できない場合などです。

戻り値型指定と==は必要?

一見すると戻り値型を指定せずとも==,<を用いて合成を行えばいいように思えます。しかしその場合、partial_orderingな型に対する比較に問題があります。

浮動小数点型等、カテゴリがpartial_orderingとなる比較では、比較不可能(unorderd)な値が存在します。
その際、上記strong_orderingと同じように比較を行ってしまうと、比較不可能な値に対して常にstd::strong_ordering::greaterを返すようになってしまいます。
そのために、partial_orderingの時は引数順を入れ替えて両方向から<による比較を行うことで比較不可能な値を検出しています。

==が必要とされるのも同様の理由によります。
!(a < b) && !(b < a) -> a == b、となるはずなのでstrong_orderingの合成には<だけで十分なのですが、これだと同値(a == b)がweak_ordering相当の比較になっている可能性があります。

weak_orderingではstrong_orderingの同値に加えて、比較不可能である値も同値であるとして扱います。
つまり、!(a < b) && !(b < a) -> a == bは、必ずしもstrong_orderingでのa == bを満たしていません。
従来の演算子は比較カテゴリを表明していないため、念のために==での比較によって同値をチェックするようにしているわけです。

これと同様に、partial_orderingでの実装時にも!(a < b) && !(b < a) -> a == bとしてしまうと同値なのか比較不可能なのかが区別がつかないため、やはり同様に==を用いて同値をチェックしています。

このような理由から、<=>の合成には明示的な戻り値型(比較カテゴリ)の指定と< ==両演算子が必要となるわけです。

組み込み型の宇宙船演算子

ここまで当たり前のように前提にしていましたが、C++20からは参照型、関数/メンバポインタ、std::nullptr_t、void以外の基本型(Fundamental types)にはもれなく宇宙船演算子が導入されます。そして、その比較カテゴリは以下のようになります。

  • bool型 → std::strong_ordering
    • boolはboolのみと比較可能
  • charやint等の整数型 → std::strong_ordering
    • 同じ列挙型同士、スコープ無し列挙型と整数型間の比較を含む
  • float、double等の浮動小数点型 → std::partial_ordering
  • 関数/メンバポインタ、nullptr → std::strong_equality
  • オブジェクトポインタ → std::strong_ordering

浮動小数点型がstd::partial_orderingなのは、あらゆる値との比較が不可能なNaNを持っているためです(弱順序の要件にすら満たない)。

以下、少し詳しめの解説。
ここでは、比較=宇宙船演算子による比較という意味で使います。また、型変換に関しては複雑であるので説明しません、なんとなく同じ型になるんだなあと思ってください(標準型変換(Standard conversions)等を参照してください)。

任意の基本型T1, T2の値a, bに対してa <=> bが呼ばれた場合

a, bの型(T1, T2)が共に算術型(ただし、片方がboolならもう片方もboolでなければならない)もしくはスコープ無しenumと整数型のペアである場合は、その値にusual arithmetic conversionsが適用された後
整数型→浮動小数点型、以外の縮小変換が適用される場合はコンパイルエラー(例えば、signed → unsigned)。
共に整数型となる場合は比較が可能で、カテゴリはstd::strong_ordering。
共に浮動小数点型となる場合も比較可能で、カテゴリはstd::partial_ordering。

a, bが共に同じ列挙型の値である場合、その基底となる整数型に変換したうえで<=>を適用する(スコープの有無によらず、異なる列挙型間の比較はコンパイルエラー)。

a, bの片方もしくは両方が何らかのポインタである場合、両方をなるべく同じポインタ型(composite pointer type)に変換し(配列→ポインタ、派生→基底、関数ポインタ変換、CV修飾変換等による)
結果のポインタ型が関数ポインタ、メンバポインタ、std::nullptr_t である場合は比較可能で、カテゴリはstd::strong_equality。
結果のポインタ型がオブジェクトポインタである場合は比較可能で、カテゴリはstd::strong_ordering。
ただし、どちらのケースも変換されたポインタ同士の比較が規定されていない場合はその比較結果は未規定(未規定の動作(unspecified behavior))。

a, bが共に配列である場合はコンパイルエラー。
以上に当てはまらないような比較についてもコンパイルエラー。

operator==について

当初の提案では、宇宙船演算子を用いて6つの比較演算子全てを生成するはずでした。しかし、同値比較についてショートサーキットの問題が発覚したために、そこから同値比較演算子が切り離されることになりました。

ショートサーキットの問題とは、std::vectorやstd::string等のクラスの同値比較をする場合に比較をする順番によってパフォーマンスが大きく変化してしまう可能性があることです。

std::vectorで宇宙船演算子を実装して比較を提供することを考えてみましょう(ここから下のコードはoperator==のデフォルト実装導入前の世界のコードです)

template<typename T>
strong_ordering operator<=>(const std::vector<T>& lhs, const std::vector<T>& rhs) {
   //本来はこう書けばいい
   //return std::lexicographical_compare_3way(lhs.begin(), lhs.end(), rhs.begin(), rhs.end());

   //問題を見るために直接実装
   size_t min_size = std::min(lhs.size(), rhs.size());
   for (size_t i = 0; i != min_size; ++i) {
      if (auto const cmp = std::compare_3way(lhs[i], rhs[i]); cmp != 0) {
         return cmp;
      }
   }
   return lhs.size() <=> rhs.size();
}

std::lexicographical_compare_3wayは二つの範囲の辞書式三方比較を行ってくれる関数で、std::compare_3wayはTに<=>があればそれを利用し、無ければ<と==を使って比較を行う関数です。
ともにC++20からalgorithmヘッダに追加されます。
この比較方法は同値比較でない4つの比較に対してはなにも問題ありません。

しかし、こと同値比較の場合はサイズを先に比較すればショートサーキットできる可能性があります。
つまり、サイズが一致していなければそもそも同値になりえないのです。
実装してみれば

template<typename T>
bool operator==(const std::vector<T>& lhs, const std::vector<T>& rhs)
{
   //サイズを先にチェックすることで比較をショートサーキット
   const size_t size = lhs.size();
   if (size != rhs.size()) {
      return false;
   }

   for (size_t i = 0; i != size; ++i) {
      //ネストする比較においても<=>ではなく==を使う(ようにしたい)
      if (lhs[i] != rhs[i]) {
         return false;
      }
   }

   return true;
}

この様にすれば、そもそもサイズが一致しない場合に時間のかかりうる要素同士の比較をスキップして比較を終了させることができます。
C++17までの実装もこうなっており、ゼロオーバーヘッド原則的にもこれが理想です。
非同値比較(大小比較)の場合もサイズを先に比較しないの?と疑問がわきますが、C++17までの実装でもそうしていないのでそこは問題ない様子です(辞書式順序的に大小比較の場合はそれでいい様子)。

これだけであれば、そのクラス内だけはこのように実装しておけば別に構わないでしょう。しかし問題なのは、他のクラスのメンバとして比較されるときです。

struct S {
   std::vector<string> names;
   auto operator<=>(const S&) const = default;
};

このクラスSは6つの比較演算子が宇宙船演算子から生成され、比較が可能です。
当初はoperator==も<=>を使って生成されていました。つまり、S::namesの比較においても<=>が使われます。
vectorには先ほどのように実装された効率的なoperator==があるにもかかわらず、このままでは使われません。

当初の提案の下で、このクラスSで効率的なoperator==を実装するには以下のようにする必要があります。

struct S {
   std::vector<string> names;

   auto operator<=>(const S&) const = default;

   bool operator==(const S& that) const {
      return names == that.names;
   }
   
   bool operator!=(const S& that) const {
      return names != that.names;
   }
};

そう、書くのです、手で。

この様な利用をする他のクラス、このSをメンバにするクラス、すべてでこの様に書くんです。
しかも、組み込み型でない型の場合はその比較演算子の実装を見に行ってこの様に書くかを判断する必要があります・・・・

この様な問題があり、Rust等の他の言語では同値比較とそれ以外を区別したうえで各種比較関数を生成するようになっている事から、宇宙船演算子は同値比較演算子を生成しないように変更されました。
そして、operator==のdefault実装とoperator!=を==から導出する、という仕様が追加されました。

operator==のdefault実装は最初の方で説明したように、基底・メンバのoperator==を呼び出します。
なので、上記のSにおいてもoperator==のdefault宣言を追加しておけば、効率的な==を利用したうえで6つの比較演算子による比較が可能になります。

しかし、簡便さのためにもdefaultの宇宙船演算子がある場合はdefaultのoperator==も暗黙に宣言・定義されるようにされました。
そのため、当初の宇宙船演算子の持っていた1つから全演算子の自動生成という特性が失われたわけではありません。

任意のクラス型の非型テンプレートパラメータとしての使用

この項目は、前項の変更の余波により宇宙船演算子との関連が薄くなったのでページを移しました。
onihusube.hatenablog.com