最終更新日時(UTC):
が更新

履歴 編集

一貫比較(C++20)

概要

新しく三方比較演算子<=>が導入されることにより、順序付けと同値比較の6つの関係演算子(<, <=, >, >=, ==, !=)を容易に実装することができるようになる。

#include <compare>  //<=>利用の場合必須
#include <iostream>

struct C {
  int x;
  int y;
  double v;
  char str[32];

  //<=>をpublicで定義しておくことで、その他の演算子が導出される
  auto operator<=>(const C&) const = default;
};


int main() {
  C c1 = {10, 20, 3.1415, "Three-way Comparison" };
  C c2 = {10, 20, 3.1415, "Spaceship Operator" };

  //三方比較演算子そのものによる比較
  std::cout << ((c1 <=> c2) == 0) << std::endl;
  std::cout << ((c1 <=> c2) <  0) << std::endl;
  std::cout << ((c1 <=> c2) >  0) << std::endl;

  //クラスCは6つの演算子による比較が可能
  std::cout << (c1 <  c2) << std::endl;
  std::cout << (c1 <= c2) << std::endl;
  std::cout << (c1 >  c2) << std::endl;
  std::cout << (c1 >= c2) << std::endl;
  std::cout << (c1 == c2) << std::endl;
  std::cout << (c1 != c2) << std::endl;
}

この様に、あるクラスに対して三方比較演算子<=>を定義しておくことで最大6つの関係演算子を導出し使用することができる。
そして、そのような<=>default実装で十分ならば実装を省略できる。

この様な三方比較の事を一貫比較(Consistent comparison)と言い、この演算子は三方比較演算子(Three-way comparison operator)と呼ぶ。また、演算子の見た目から宇宙船演算子と呼ばれることもある。

この様に、三方比較演算子を用いれば比較演算子の定義が非常に容易になるためstd::rel_opsはその役割をほとんど失い、非推奨となった。

仕様

三方比較

ある型の値a, bについてのa <=> bによる比較の結果は単純なboolではなく、未満・等しい・超える、という3つの関係を同時に表す値を返す。
そこからa, bの関係をboolの形で得るには、0リテラルとの比較を用いる。

int a = 10;
int b = 20;

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";
}

戻り値の値は左辺に対する右辺の関係を表すので、引数順を入れ替えると順序の方向も逆転する。(上記の例の場合、comp = b <=> aとするとcomp < 0 == false, 0 < comp == trueとなり、a > bが出力される)

なお、三方比較演算子の戻り値の0リテラル以外との比較は未定義動作とされる。1だったり0.0であってはならない。

int a = 10;
int b = 20;

auto comp = a <=> b;

//全て未定義動作
bool is_less = comp == 1;
bool is_greator = -1 < comp
bool is_equal = comp == 0.0;

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

三方比較演算子の戻り値型はintなどの整数型ではなく、比較カテゴリ型と呼ばれる専用の型である。
これは、比較対象となる型の満たしている同値や順序の関係についてを専用の型によって表明し、コンセプト等の機構によってその性質に応じた適切な処理へのディスパッチを行うことを出来るようにするためである(例えば、以下で述べる比較カテゴリ型によって導出する演算子を変化させるのに利用されている)。

以下の5つの比較カテゴリ型が提供される。

比較カテゴリ型 対応する数学的な関係 導出される演算子
weak_equality 同値関係 == !=
strong_equality 相等関係:最も細かい同値関係 == !=
partial_ordering 半順序 == != < <= > >=
weak_ordering 弱順序 == != < <= > >=
strong_ordering 全順序 == != < <= > >=

表にあるように5つの比較カテゴリ型はそれぞれ数学的な2項関係の一つと対応している。また、それによって(orderingでないカテゴリでは)、順序の4つの演算子が導出されない。

三方比較演算子による比較の結果となる値は、これら比較カテゴリ型のいずれかのprvalueオブジェクトとなる。
全てのカテゴリにおいてそのようなオブジェクトの0リテラル以外との比較は未定義動作となる。

これらの比較カテゴリ型は新しく追加される<compare>ヘッダにて定義されるが、<=>をコード中で使用したとしても自動でインクルードされないため、<=>の使用も含めて比較カテゴリ型を利用する際は<compare>を明示的にインクルードする必要がある。

比較カテゴリ間の順序関係

各比較カテゴリ型はその条件の強いものから弱いものへの暗黙変換が定義される。この方向は各カテゴリに対応する数学的な関係の包含関係によって定義されている。
ordering -> equalityに変換できてもequality -> orderingに変換できないのは、同値関係を満たしていても順序関係を満たさないような関係を考えることができるため。


図1 比較カテゴリ間の変換関係(P0515R3より引用)

これはつまり、各比較カテゴリ間の順序関係を示している。この順序は半順序となる。

クラス型に対するdefaultな三方比較演算子の戻り値型は比較に参加するすべての型の<=>による比較の結果となるカテゴリ型から共通して変換できる最も強い型となる。そのような型を共通比較カテゴリ型(common comparison category type)と呼ぶ。

比較に参加するすべての型の<=>による比較カテゴリ型をそれぞれTi (0 <= i < N)として、共通比較カテゴリ型Uは以下のように決定される。

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

この共通比較カテゴリ型を求めるのは場合によっては困難なので、それを求めるために<compare>ヘッダにてcommon_comparison_category<Ts...>というメタ関数が提供される。

operator==

<=>を利用する事で最大6つの関係演算子が導出されると説明したが、実際には同値比較演算子(== !=)は<=>から導出されず、==は定義されている必要があり!===から導出される。
ただし、<=>をdefault宣言した場合は同じアクセス指定で==が暗黙的にdefault宣言され利用可能になる。また、明示的にdefaultで宣言することもできる。
そのようなdefaultの==の戻り値型はboolであり、!=の導出に使用される==も戻り値型はboolでなければならない。

このため、異種型間比較や特殊な比較を実装するために<=>を独自に定義する場合、6×2個の関係演算子全てを導出するためには==も独自に定義しなければならない。

このような仕様になっているのは、<=>を用いた同値比較において発生しうるオーバーヘッドを回避するためである(詳細は後述の「検討された他の選択肢」を参照)。

なお、<=>がdelete宣言されている場合でも==は暗黙的にdefault宣言されている。

struct C {
  auto operator<=>(const C&) = delete;
};

int main() {
  C c1{}, c2{};

  //共にok
  bool eq = c1 == c2;
  bool ne = c1 != c2;
}

また、<=>を宣言せずに==だけをdefault指定で宣言することもでき、その場合でも== !=の2つの同値比較が可能である。

演算子の導出とオーバーロード候補

<=>及び==から導出される演算子は暗黙的に宣言され実装されているわけではなく、それらの演算子を呼び出した際のオーバーロード候補に、<=> ==を利用して生成した候補を入れることによって導出される。
そのため、そのアドレスを取ることは出来ない。

詳細な手順は以下のようになる。

任意の演算子@を任意の型T1, T2のオブジェクトa, bに対してa @ bのように呼び出したとき

  1. オーバーロード候補に、a @ bを加える
  2. @が三方比較演算子ならば、そのオーバーロード候補に<=>を使って生成した逆順の式b <=> aを加える
  3. @が関係演算子(< <= > >=)ならば、そのオーバーロード候補に<=>を使って生成した2種類の式a <=> b, b <=> aを加える
  4. @!=ならば、そのオーバーロード候補に==を使って生成した2種類の式a == b, b == aを加える
  5. @==ならば、そのオーバーロード候補に==を使って生成した逆順の式b == aを加える
  6. オーバーロード解決では使用可能なメンバ・非メンバ・組み込みの@ <=> ==が考慮され、@→正順の式→逆順の式 の順で優先される。また、選択された演算子に対して式の生成は行われない。
  7. それらの候補からのオーバーロード解決の結果生成された候補が選択された場合、その候補に応じて以下のように最終的な式を生成する
    • @が三方比較演算子ならば、0 <=> (b <=> a)
    • @が関係演算子(< <= > >=)ならば、(a <=> b) @ 0もしくは0 @ (b <=> a)
    • @!=ならば、!(a == b)もしくは!(b == a)
  8. 元の呼び出しa @ bを生成された式で書き換える(生成された式を元の呼び出しa @ bとして実行する)

結果、各演算子を呼び出したときに考慮されるオーバーロード候補は以下のようになる。

呼び出す演算子 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)

この様に、異種型間比較においても片方の<=> ==を定義しておけばその引数順を逆にした演算子も生成され、演算子の対称性が自動で補完される。

ただし、この時使用される==は戻り値型がbool(CV修飾は可)でなければならず、<=>は結果として@ 0もしくは0 @を適用可能な型を返さなければならない。そうでない場合はコンパイルエラーとなる(上記手順7以降に発生するエラーはコンパイルエラーとなる)。
== <=>が使用可能ではない(定義されてない、曖昧、アクセスできない、削除されている)場合は、オーバーロード解決の過程で候補から外されコンパイルエラーとはならない(上記手順6以前のエラーはコンパイルエラーとならない)。

default比較

ここまでにも説明せずに登場していたが、あるクラス型に対する<=>および==演算子はdefault指定することができる。
そうした場合、コンパイラによってそのクラスの基底及び全メンバの宣言順の辞書式比較を行う実装が暗黙に定義される。

あるクラスCに対する<=> ==default指定できる宣言は、Cの関数テンプレートでないメンバとして宣言されていて、かつconst C&型の1つの引数をもつ非静的constメンバ関数であるか、const C&型の2つの引数を持つCfriend関数、である必要がある。
つまり以下の様な宣言が有効である。

struct C {
  //有効な<=>のdefault宣言
  auto operator<=>(const C&) const = default;
  friend auto operator<=>(const C&, const C&) = default;

  //有効な==のdefault宣言
  bool operator==(const C&) const = default;
  friend bool operator==(const C&, const C&) = default;
};

<=>をdefault宣言した場合、対応する==が暗黙的にdefault宣言される。そのアクセス指定は同一であり、friendであるかも<=>に従う。
そして、このようなdefault宣言はその定義がconstexpr関数の要件を満たしていれば暗黙的にconstexpr指定され、呼び出す演算子が全てnoexceptであるならば暗黙的にnoexceptである(これらの指定は明示的に指定しておくこともできる)。

default指定された三方比較演算子の戻り値型は基底クラス及び全メンバの<=>の結果型の共通比較カテゴリ型となるが、その型がvoidである場合は暗黙的にdeleteされる。
その際、暗黙宣言される==演算子は定義可能(比較に参加するすべての型について==の呼び出しが適格)ならばdefaultで宣言される。

default実装

default宣言された<=> ==演算子はその基底クラスと非静的メンバを宣言順に比較していくことで実装される。

その手順は以下のようになる(演算子@<=> ==のどちらかとする)。

  1. 基底クラスの@を呼び出して比較を行う。その順番は継承順(:の後ろに書いてある型を左から右)、深さ優先で比較される。
    • この時、仮想基底クラスが複数回比較されるかは未規定。
  2. 宣言された順番(上から下)で非静的メンバを@によって比較する。
    • この時、配列は要素ごとに比較する。
  3. これらの比較の際、結果が0==ならtrue)とならない時点でその結果を返して終了する。
  4. 基底クラスもメンバも無い場合、strong_ordering::equal==ならtrue)を返して終了する。

この手順を明示的に書くと以下の様な実装になる。

class D : Base1, Base2 {
  int x;
  int y;
  char str[3];
  double v;

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 = str[0] <=> that.str[0]; comp !=0) return comp;
    if (auto comp = str[1] <=> that.str[1]; comp !=0) return comp;
    if (auto comp = str[2] <=> that.str[2]; comp !=0) return comp;
    return v <=> that.v;
   }

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

この時、使用可能な<=> ==演算子が見つからない場合、およびメンバに参照型を持つか匿名共用体を含む、もしくはその型が共用体である場合は宣言された全ての比較演算子のdefault宣言は暗黙的にdeleteされる(下記のその他演算子のdefault宣言も含む)。

default実装における<=>の合成

<=>のdefault比較実装はメンバおよび基底クラスに<=>を持たない型があると暗黙deleteされる。しかし、これでは現在使われている多くの<=>を持たない型をメンバに持つ際にdefaultの<=>を提供できない。
しかしその場合にも、<=>のdefault宣言に戻り値型を指定した上で、<=>を持たないメンバ(基底)型が< ==の2つの演算子を実装していれば、それらを元にして<=>を合成した上でdefault実装を行うことができる。

//C++17以前に作成された<=>を持たない型
struct legacy {
  int n = 10;

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

//C++20環境で作成された型、<=>を実装したい
struct newer {
  int m = 10;
  legacy l = {20}; //<=>を持っていない
  int n = 30;

  //こう宣言すると暗黙delete
  //auto operator<=>(const new_type&) const = default;

  //legacyの比較に関しては指定した戻り値型とlegacyの持つ比較演算子< ==を用いて実装、使用可能
  std::strong_ordering operator<=>(const new_type&) const = default;
};

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

指定された戻り値型をR、比較しようとしているTの値をa, bとして、それらの満たす条件によって以下のように<=>は合成される。

条件 合成された<=>の式
a <=> bのオーバーロード解決で使用可能な<=>が見つかる static_cast<R>(a <=> b);
Rstd::strong_ordering a == b ? std::strong_ordering::equal :
a < b ? std::strong_ordering::less :
std::strong_ordering::greater;
Rstd::weak_ordering a == b ? std::weak_ordering::equivalent :
a < b ? std::weak_ordering::less :
std::weak_ordering::greater;
Rstd::partial_ordering a == b ? std::partial_ordering::equivalent :
a < b ? std::partial_ordering::less :
b < a ? std::partial_ordering::greater;
std::partial_ordering::unordered
Rstd::strong_equality a == b ? std::strong_equality::equal : strong_equality::nonequal;
Rstd::weak_equality a == b ? std::weak_equality::equivalent : std::weak_equality::nonequivalent;
どれにも当てはまらない 定義されない

戻り値型にautoを指定した際は、共通比較カテゴリ型をRとして1つ目(1番上)のように<=>が合成されている。
また、1つ目の条件により合成される際は、static_castしていることからも分かるようにa <=> bの戻り値型がRに変換できない場合はコンパイルエラーとなる。

先ほどのnewerに対して明示的に書くと以下のようになる。

struct newer {
  int m = 10;
  legacy l = {20}; //<=>を持っていない
  int n = 30;

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

    //legacy型に対する<=>の合成
    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);
  }
};

この合成において使用される< ==演算子の戻り値型の妥当性はチェックされない。仮にboolではなかったとしても、合成された式においてコンパイルエラーが発生しなければ<=>の合成はつつがなく行われる。
また、合成された<=>が定義されない(上記条件のいずれも当てはまらない)場合はdefault指定の<=>は暗黙にdeleteされる。

その他の比較演算子のdefault宣言

<=> ==だけでなく、残りの比較演算子もdefault指定で宣言することができる。その有効な宣言は<=> ==に従う。

そのようなdefault実装は、オーバーロード解決時に生成される式と同様の式を使って実装される(すなわち、<=> ==から実装される)。

<=> ==演算子が使用可能ではない場合や、<=>の戻り値型が対象の演算子を生成できないか==の戻り値型がboolではない場合は、そのdefault宣言は暗黙的にdeleteされる(オーバーロード候補生成時はコンパイルエラーとなる場合でも単にdeleteされる)。

struct C {
  std::nullptr_t np = nullptr;


  std::strong_equality operator<=>(const C&) const = default;

  bool operator<(const C&) const = default;  //ok、暗黙的にdeleteされる

  bool operator!=(const C&) const = default;  //ok、使用可能
};

これは、比較演算子のアドレスを取りたいときに使用する。

組み込み型の三方比較

三方比較演算子はvoidと参照型を除く組み込みの型に対して、組み込みの物が提供される。
その比較カテゴリ型は以下のようになる(以下、比較とは<=>によるものを指す)。

カテゴリ 備考
bool std::strong_ordering bool同士でしか比較不可
整数型 std::strong_ordering 縮小変換が行われる場合は比較不可
浮動小数点型 std::partial_ordering 縮小変換が行われる場合は比較不可
NaN±0.0の存在のため半順序
オブジェクトポインタ std::strong_ordering あらゆるポインタ変換が施された後、同じポインタ型にならなければ比較不可
配列と配列は比較不可
関数/メンバポインタ std::strong_equality あらゆるポインタ変換が施された後、同じポインタ型にならなければ比較不可
std::nullptr_t std::strong_equality
列挙型 std::strong_ordering スコープ有無に関わらず同じ列挙型同士でしか比較不可

なお、参照型に対する<=>による比較は参照先の型による比較になる。

従来の比較演算子との差異及び修正

三方比較演算子による比較は従来の比較演算子の挙動とは異なるところがある(より安全な比較となっている)。
それに伴って、いくつかの比較演算子の挙動が修正された。

比較するペア 従来演算子での比較の可否 <=>での比較の可否 従来演算子の修正(C++20より、非推奨扱い)
符号なし整数型と符号付整数型 ×
ただし定数式で符号付きオペランドが正の値に評価されれば可能
━(従来通り)
列挙型と算術型
例えば、列挙型と浮動小数点型の比較が可能

スコープ無し列挙型と整数型のみ可能

列挙型と浮動小数点型間比較は不可
それ以外は従来通り
異なる列挙型間 × ×
配列同士
標準は未規定だが多くの実装ではポインタに変換して比較する
× ×
nullptrとポインタ
同値比較のみ可能

ただし、ポインタがnullptrでない場合の結果は未規定
関数ポインタ間
異なるポインタ間の順序付けの結果は未規定

C++17までの比較演算子実装の一例

#include <iostream>
#include <tuple>

struct S  {
  int x;
  double d;
  char str[4];

  constexpr bool operator<(const S& rhs) const {
    return std::tie(x, d, str[0], str[1], str[2], str[3])
         < std::tie(rhs.x, rhs.d, rhs.str[0], rhs.str[1], rhs.str[2], rhs.str[3]);
  }

  constexpr bool operator==(const S& rhs) const {
    return std::tie(x, d, str[0], str[1], str[2], str[3])
        == std::tie(rhs.x, rhs.d, rhs.str[0], rhs.str[1], rhs.str[2], rhs.str[3]);
  }

  constexpr bool operator!=(const S& rhs) const {
    return !(*this == rhs);
  }

  constexpr bool operator>(const S& rhs) const {
    return rhs < *this;
  }

  constexpr bool operator<=(const S& rhs) const {
    return !(*this > rhs);
  }

  constexpr bool operator>=(const S& rhs) const {
    return !(*this < rhs);
  }
};

int main()
{
  S s1 = {10, 0.1, "abc"};
  S s2 = {10, 0.1, "ABC"};

  std::cout << std::boolalpha;

  std::cout << (s1 <  s2) << std::endl;
  std::cout << (s1 <= s2) << std::endl;
  std::cout << (s1 >  s2) << std::endl;
  std::cout << (s1 >= s2) << std::endl;
  std::cout << (s1 == s2) << std::endl;
  std::cout << (s1 != s2) << std::endl;
}

出力

false
false
true
true
false
true

C++20での比較演算子実装例

#include <compare>
#include <iostream>

struct S {
  int x;
  double d;
  char str[4];

  auto operator<=>(const S&) const = default;
};

int main()
{
  S s1 = {10, 0.1, "abc"};
  S s2 = {10, 0.1, "ABC"};

  std::cout << std::boolalpha;

  std::cout << (s1 <  s2) << std::endl;
  std::cout << (s1 <= s2) << std::endl;
  std::cout << (s1 >  s2) << std::endl;
  std::cout << (s1 >= s2) << std::endl;
  std::cout << (s1 == s2) << std::endl;
  std::cout << (s1 != s2) << std::endl;
}

出力

false
false
true
true
false
true

この機能が必要になった背景・経緯

C++17以前の例に示したように、従来のC++における比較演算子の実装は煩雑でボイラープレートの様なコードを大量にコピーアンドペーストすることになる。さらに、異種型間比較を加えるとその対称性のために同じ比較について引数型を逆にしたものを用意しなければならないため、そのようなボイラープレートは更に倍になることになる。
この問題は以前から認識されており、比較演算子が実際には< ==の2つから残りのすべてを導出できることを利用して実装を簡易にする、Boost Operators Librarystd::rel_ops等が提供されていた。しかし、これを用いても異種型間比較におけるボイラープレートを完全に取り除くことは出来ない。

また、比較演算子の実装に伴う別の問題として、クラスの全メンバが参加するような構造的な比較を提供する際にも、メンバの列挙方法が無いために全てのメンバを<==で繋いで回るコードを書くことを強いられていた。
比較演算子実装の多くの場合はこのような構造的な比較を提供すれば十分であり、その場合はクラスによってメンバ名等が違えど全メンバの辞書式比較を行うという点に変わりはない。

これらの問題の解決を言語機能によって提供するために、三方比較演算子が導入された。

上で示したように、任意のクラス型に対する比較演算子の実装は<=>を1つ定義するだけで完結する。その比較が構造的なものであるならば、default指定することで定義を書く必要すらない。
そして、default指定された<=>==は基底クラス及び全メンバの宣言順の辞書式比較を行う。
異種型間比較においても、1つの引数順の<=>==の2つを定義することで残りの11個の比較演算子を導出することができる。

検討されたほかの選択肢

当初の三方比較演算子から導出される演算子は同値比較(== !=)のものも含めた最大6つであった。しかし、同値比較なら比較についての処理を短絡評価できる場合に、<=>を用いて== !=を導出すると短絡評価が行われず非効率になるケースがあったため、<=>から==を切り離し、!===から導出するように変更された。

例えば、std::vector<=>を実装することを考えてみる。

template<typename T>
strong_ordering operator<=>(const std::vector<T>& lhs, const std::vector<T>& rhs) {
  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_three_way(lhs[i], rhs[i]); cmp != 0) {
      return cmp;
    }
  }
  return lhs.size() <=> rhs.size();
}

これは、保持する要素に対する辞書式比較を行う実装で既存の比較演算子と等価の処理である。
実際の比較はcompare_three_wayに移譲しているが、これはT<=>があればそれを利用し無ければ<==を使って比較を行う関数である(C++20より利用可能)。

これは順序付けにおいては問題ないが、同値比較を行おうとすると非効率な点がある。それは、長さ(サイズ)を一番最後に比較していることで、同値比較の場合は一番最初にvectorの長さをチェックし異なっていれば、その時点で結果がfalseになると分かり処理を終えることができる。
従って、vectorにおける==の効率的な実装は以下のようになる。

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;
}

template<typename T>
bool operator!=(const std::vector<T>& lhs, const std::vector<T>& rhs)
{
  return !(lhs == rhs);
}

この様にしておけば、vectorのオブジェクト同士の同値比較においては常に効率的な実装が選択される。

ところで、当初の仕様では== !=も三方比較演算子から導出されており、その際に生成する式は以下のように規定されていた。

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

この前提の下で上記の効率的な==実装をしたvectorを保持する別の型を考えてみると困ったことが起こる。

struct has_vector {
  std::vector<string> vec;

  auto operator<=>(const has_vector&) const = default;
};

has_vector hv1{}, hv2{};

//以下の二つの比較は当初提案の下では
bool eq = hv1 == hv2;
bool ne = hv1 != hv2;
//このように展開される
bool eq = (hv1 <=> hv2) == 0;
bool ne = (hv1 <=> hv2) != 0;

このhas_vectorクラスは以前の仕様の下でも<=>を利用して6つの比較演算子による比較が可能である。
しかし、このhas_vectorクラスのオブジェクトに対して== !=による比較を行った時、呼び出されるのはvectorに定義された<=>であって効率的に実装された==ではない。
このhas_vectorの同値比較において、内部vectorに実装された効率的な==を呼び出すには以下のようにしなければならない。

struct has_vector {
  std::vector<string> vec;

  auto operator<=>(const has_vector&) const = default;

  bool operator==(const has_vector& that) const {
    return vec == that.vec;
  }

  bool operator!=(const has_vector& that) const {
    return vec != that.vec;
  }
};

内部vector== !=を呼び出すようにhas_vectorに対する== !=を独自に定義する。しかも、このhas_vectorをメンバに持つ別のクラスがある時も同様にしなければならない。
また、この様な新たなボイラープレートのコピーアンドペーストが必要になるかどうかは、クラスのメンバ型の全ての== !=演算子の実装を再帰的に辿り判断する必要がある。

このような新たな煩雑さの導入は当初の一貫比較が目指した方向性とは真逆であり、RustやHaskell等の他言語においても同値比較と順序付けの演算子は区別されたうえで自動実装が行われていることから、C++においても同様に<=>から== !=が切り離されることとなった。

しかし、当初の一貫比較仕様の簡便さを損なわないために、default実装の<=>があれば暗黙的に==を宣言するという仕様を追加し、効率的な==の実装が必要ない型では当初の仕様にほぼ沿った形で恩恵を受けることができる。

関連項目

  • <compare>
    • 比較カテゴリ型
      • weak_equality
      • strong_equality
      • partial_ordering
      • weak_ordering
      • strong_ordering
    • common_comparison_category
    • 比較関数
      • strong_order
      • weak_order
      • partial_order
      • strong_equal
      • weak_equal
  • compare_three_way
  • lexicographical_compare_three_way

参照