OpenSiv3Dに三方比較演算子 <=>
を導入しました。この記事では三方比較演算子とOpenSiv3Dにおける実装を説明します。
一貫比較 (C++20)
C++20 では一貫比較 (consistent comparison) と呼ばれる機能が導入され、比較演算子の実装が大幅に簡略化されました。
C++17 における比較演算子の実装
C++17 で大小比較可能な型を作ろうとすると 6 種類の演算子をオーバーロードする必要があり、非常に面倒でした。
#include <iostream>
#include <tuple>
using namespace std;
struct Date {
int year, month, day;
bool operator <(const Date& rhs) const {
return tie(year, month, day)
< tie(rhs.year, rhs.month, rhs.day);
}
bool operator ==(const Date& rhs) const {
return tie(year, month, day)
== tie(rhs.year, rhs.month, rhs.day);
}
bool operator > (const Date& rhs) const {
return rhs < *this;
}
bool operator <=(const Date& rhs) const {
return !(rhs < *this);
}
bool operator >=(const Date& rhs) const {
return !(*this < rhs);
}
bool operator !=(const Date& rhs) const {
return !(*this == rhs);
}
};
int main() {
cout << boolalpha;
cout << (Date{ 2021, 12, 3 } == Date{ 2021, 12, 3 }) << '\n';
cout << (Date{ 2021, 12, 3 } < Date{ 2021, 12, 3 }) << '\n';
cout << (Date{ 2021, 12, 3 } >= Date{ 2021, 12, 3 }) << '\n';
}
なお、比較演算子は <
, ==
の 2 つがあれば残りの全てを実装できます。また、std::tuple
でラップすることでメンバの辞書式比較をある程度楽に行えます。
それでも上記のようなほとんど自明の実装をする必要があります。そこで、あらかじめ比較演算子を定義しておいたクラスを基底にすることで記述量を削減したりもしていました。
三方比較演算子
C++20では三方比較演算子 (three-way comparison operator) 1 <=>
が導入され、三方比較演算子を定義するだけで比較演算子が導出されます2。
さらに、三方比較演算子はデフォルト定義することができ、その場合はメンバの辞書式比較になります。
#include <compare>
#include <iostream>
using namespace std;
struct Date {
int year, month, day;
//<=>をpublicで定義しておくことで、その他の演算子が導出される
auto operator <=>(const Date&) const = default;
};
int main() {
cout << boolalpha;
cout << (Date{ 2021, 12, 3 } == Date{ 2021, 12, 3 }) << '\n';
cout << (Date{ 2021, 12, 3 } < Date{ 2021, 12, 3 }) << '\n';
cout << (Date{ 2021, 12, 3 } >= Date{ 2021, 12, 3 }) << '\n';
}
三方比較演算子による比較を一貫比較といいます。
!=
の導出
<=>
と比べると影が薄いのですが、 !=
が ==
から導出されるようになります。==
のデフォルト定義も可能になっています(すべてのメンバを比較する)。等値比較だけ実装したい場合には大変便利です。
三方比較演算子を実装するには
メンバの辞書式比較以外の実装をしたい場合は三方比較演算子を手動実装する必要があります。
式 a <=> b
の値が次のようになるように実装します3。
-
a == b
のとき(a <=> b) == 0
-
a < b
のとき(a <=> b) < 0
-
a > b
のとき(a <=> b) > 0
これは、数値型における引き算の自然な拡張になっています。
三方比較演算子を手動実装する場合は ==
と !=
は導出されません4。等値比較をするには ==
を明示的に定義する必要があります(定義自体は =default
でも可)。
OpenSiv3D における三方比較演算子の実装
先日、Array
, Optional
, BigInt
, BigFloat
, HalfFloat
, String
, StringView
, UUIDValue
に三方比較演算子を実装しました。v0.6.3でリリースされています。
これらの実装について簡単に解説します。
s3d::String
, s3d::StringView
これらは std::u32string
, std::u32string_view
のラッパーなので、<=>
をデフォルト定義することで元のクラスの比較に任せることができます。
[[nodiscard]]
constexpr std::strong_ordering operator <=> (const String& rhs) const noexcept = default;
ただし、異なる型との間の比較はデフォルト定義できないので、自分で実装する必要があります。char32_t*
との比較を std::u32string
の一貫比較を使って定義すると次のようになります。
std::strong_ordering operator <=> (const String& lhs, const String::value_type* rhs)
{
return lhs.str() <=> rhs; // .str() は std::u32string の参照を返す
}
この場合でも、従来のように逆順の演算子(引数の型を入れ替えたもの)を定義する必要はありません。<=>
は1つ定義すれば逆順のものが自動的に導出されます。
s3d::UUIDValue
このクラスのメンバ変数はstd::array
で、<=>
を持っているので、<=>
のデフォルト定義だけで済みます。異なる型との比較は実装しませんでした。
s3d::Array
このクラスは std::vector
のラッパーなのでデフォルト定義で済みます。
なお、std::vector
の比較カテゴリは要素の型に依存し、結果として s3d::Array
の比較カテゴリも要素の型に依存します。このようなときは戻り値の型を推論させると楽です。
[[nodiscard]]
auto operator <=>(const Array& rhs) const = default;
s3d::Optional
これが一番大変でした。
このクラスは std::optional
のラッパーですが、<=>
をデフォルト定義しても異なるテンプレート引数の s3d::Optional
とは比較できません。
std::optional
は、その値同士が比較可能であれば異なるテンプレート引数でも比較できるようになっており、同じ仕様を実現する必要がありました。
実装は次のようになっています。
template <class Type1, std::three_way_comparable_with<Type1> Type2>
inline constexpr std::compare_three_way_result_t<Type1, Type2>
operator <=> (const Optional<Type1>& lhs, const Optional<Type2>& rhs) {
if (lhs && rhs) {
return *lhs <=> *rhs;
}
return lhs.has_value() <=> rhs.has_value();
}
両方の s3d::Optional
が値を持つ場合は値を一貫比較し、そうでないときは値を持つ方が大きくなるようにします。これは std::optional
と同じ仕様です。
std::three_way_comparable_with<T>
は、T
と一貫比較可能であることを示すコンセプトです。これによりこの<=>
の定義は要素の型同士を一貫比較可能な場合にのみオーバーロード解決に参加するようになります。
std::compare_three_way_result_t
は2つの型を一貫比較したときの比較カテゴリ型です。残念ながらこの定義では戻り値の型が曖昧になるので型推論はできません。そのようなときはこの型エイリアスを使うことで比較カテゴリを決めることができます。
BigFloat
, BigInt
BigFloat
は NaN を持つので半順序(比較できない値が存在する)になります。
[[nodiscard]]
friend inline std::partial_ordering operator <=>(const BigFloat& a, const BigFloat& b)
{
if (a.isNaN() || b.isNaN()) {
return std::partial_ordering::unordered;
}
return (a.compare(b) <=> 0);
}
このクラスは元から .compare
という一貫比較のような関数を持っていました。このようなときはその結果を 0
リテラルと一貫比較することで簡単に実装できます。
BigInt
も全順序になること以外は同様です。
[[nodiscard]]
friend inline std::strong_ordering operator <=>(const BigInt& a, const BigInt& b)
{
return (a.compare(b) <=> 0);
}
-
その見た目から、宇宙船演算子 (spaceship operator) とも呼ばれますが、規格上の名前ではありません。 ↩
-
ここでいう"導出"の意味は関数が定義されることではありません。導出される演算子たちは実際には関数定義を持たず、関数のアドレスも取得できません。そうではなく、あたかもそのような比較演算子が存在するかのように、
<=>
を使った式が各種比較演算子のオーバーロード解決に参加します。 ↩ -
三方比較演算子の結果は整数ではなく比較カテゴリ型の値です。比較カテゴリ型と整数の比較は、0 リテラルとの間でのみ可能で、それ以外は未定義動作です。 ↩
-
<=>
がデフォルト定義でないとき、==
は<=>
から導出するよりも個別に実装したほうがパフォーマンスのよい実装をできる可能性があるためです。 ↩