[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