[C++]宇宙船演算子のフォールバック処理

宇宙船演算子の導入によって比較演算子の定義が著しく楽になります。ただ、テンプレートな所ではそう単純にはいかない事もあります。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap&) const = default;
  //Tが<=>を持っていなかったら?
  //Tが参照型だったら???
};

例えばこの様に任意の型を保持するようなクラステンプレートです。この様な型は標準ライブラリにもpairtupleoptionalなどがあります。
このような型ではTによっては<=>がそのまま利用できないかもしれません・・・

この様な時でも<=>だけを書くのがC++20からのC++でしょう。しかし、そんな時でもなるべく楽をしたい・・・

なるべくdefaultにお任せしたい

与えられたT<=>を定義しておらず利用可能でなかったとしても、比較カテゴリ型の指定と< ==演算子からdefault<=>を実装する仕組みがあります。

template<typename T>
struct wrap {
  T v;

  std::weak_ordering operator<=>(const wrap&) const = default;
};

この様に明示的に戻り値型を指定することで、Tが持つ< ==演算子が利用可能であればそれらを用いて<=>を実装してもらうことができます。

しかし、この様に書いてしまうと逆に<=>を使用可能な型に対して戻り値型を明示してしまうことになり、T<=>が返す比較カテゴリ型が指定したものに変換可能でなければコンパイルエラーを引きおこします。

上記の場合、例えばTdoubleの時にエラーになります。しかし、doubleの時は普通にdefault実装が利用可能であるはずです・・・

template<typename T>
struct wrap {
  T v;

  //この2つの宣言を両立できない・・・
  auto operator<=>(const wrap&) const = default;
  std::weak_ordering operator<=>(const wrap&) const = default;
};

この様な場合、指定する戻り値型がTに合わせて変化すればいいはずです。つまり、T<=>を使用可能ならその戻り値型を指定し、使用可能でないなら特定の比較カテゴリ型を指定する、という風になればいいわけです。

template<typename T, typename Cat>
using fallback_comp_cat = std::conditional_t<std::three_way_comparable<T>, std::compare_three_way_result_t<T>, Cat>;

そこでこの様なマジックアイテムを用意します。これを使って先ほどの<=>宣言を修正します。

template<typename T>
struct wrap {
  T v;

  //Tが<=>を使用可能ならそれを使って、そうでないなら< ==を利用して、<=>をデフォルト実装
  fallback_comp_cat<T, std::weak_ordering> operator<=>(const wrap&) const = default;
};

この様にすることでT<=>を使用可能であるかに関わらず、1つの宣言から<=>のデフォルト実装を利用することができます。

//<=>を実装していない型
struct no_spaceship {
  int n;

  bool operator<(const no_spaceship& that) const noexcept {
    return n < that.n;
  }

  bool operator==(const no_spaceship& that) const noexcept {
    return n == that.n;
  }
};

int main()
{
  wrap<no_spaceship> t1 = {{20}}, t2 = {{30}};

  std::cout << std::boolalpha;

  //全て利用可能!
  std::cout << (t1 <=> t2 < 0) << std::endl;
  std::cout << (t1 <  t2) << std::endl;
  std::cout << (t1 <= t2) << std::endl;
  std::cout << (t1 >  t2) << std::endl;
  std::cout << (t1 >= t2) << std::endl;
  std::cout << (t1 == t2) << std::endl;
  std::cout << (t1 != t2) << std::endl;
}

特に、default実装にしておくことで==を暗黙宣言してもらえるのはとても嬉しいです。

ところでfallback_comp_catとは一体・・・

template<typename T, typename Cat>
using fallback_comp_cat = std::conditional_t<std::three_way_comparable<T>, std::compare_three_way_result_t<T>, Cat>;

three_way_comparable<T>C++20から利用可能になる、型が三方比較可能であることを表明するコンセプトです。コンセプトの制約式そのものはbool値を生成する定数式として利用可能です。
それをconditional_tの条件とすることで、T<=>を利用可能であるかに応じて型を変化させます。

利用可能なら、compare_three_way_result_t<T>(これもC++20から利用可能)によってその戻り値型を、利用不可能ならば指定したカテゴリ型(Cat)を返します。

これによって、fallback_comp_cat<T, Cat>T<=>を利用可能なときはその戻り値型になり、利用できなければ指定したカテゴリ型Catになるわけです。

これをdefault<=>の戻り値型に指定してやることで、Tの型に応じて自動で最適なdefault実装を選択してもらうようにするわけです。

defaultを諦めて更にフォールバックする

しかしT==を持っていなかったらどうしましょうか・・・
さすがにそうなるともうコンパイラに頼ることは出来ません。
そのような場合でも、なんとか<演算子だけから<=>を構成したいものです。

弱順序(weak_orderin)における順序付けにおいてなら、!(a < b) && !(b < a)ならばa == bと判断することができます。
そこで、先ほどと同じ感じで<=>を使用可能であるか否かで分けることにしましょう。

template<typename T>
concept less_than_compareble = requires(T a, T b) {
  //<演算子が利用可能であり、戻り値型がbooleanコンセプトを満たすこと
  {a < b} -> boolean;
}

auto fallback_cmp_3way = []<typename T>(const T& a, const T& b)
requires less_than_compareble<T>
{
  if constexpr (std::three_way_comparable<T>) {
    //<=>が使えるならそれを使う
    return a <=> b;
  } else if constexpr (less_than_compareble<T> && std::equality_comparable<T>) {
    //==と<を使って三方比較
    if (a == b) return std::weak_ordering::equivalent;
    if (b < a) return std::weak_ordering::less;
    return std::weak_ordering::greater;
  } else {
    //<だけから三方比較
    if (a < b) return std::weak_ordering::less;
    if (b < a) return std::weak_ordering::greater;
    return std::weak_ordering::equivalent;
  }
};

またこんなマジックアイテムを用意して、これを利用して<=>を定義します。

ちなみに、std::equality_comparableというのはC++20から標準で用意されてるコンセプトで、その名の通り==による比較が可能であることを表明します。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }
};

こうすることで、T<=>を持っていなかったとしても、最悪<だけから<=>を構成することができるようになります。

なお、<演算子すら持っていなかったらもはや諦めるしかないでしょう・・・

一般化

先ほどのfallback_cmp_3wayは一つの型Tの間でのみ<=>を構成しますが、二つの型の間で同じことをするように一般化しておきたくなってしまうでしょう。

ほとんどそのまま、T, Uの2つの型を受け取るように関連するものを書き換えます。

template<typename T, typename U>
concept less_than_compareble_with = requires(T a, U b) {
  //<演算子が利用可能であり、戻り値型がbooleanコンセプトを満たすこと
  {a < b} -> boolean;
  {b < a} -> boolean;
}

auto fallback_cmp_3way = []<typename T, typename U>(const T& a, const U& b)
requires less_than_compareble_with<T, U>
{
  if constexpr (std::three_way_comparable_with<T, U>) {
    //<=>が使えるならそれを使う
    return a <=> b;
  } else if constexpr (less_than_compareble_with<T, U> && std::equality_comparable_with<T, U>) {
    //==と<を使って三方比較
    if (a == b) return std::weak_ordering::equivalent;
    if (b < a) return std::weak_ordering::less;
    return std::weak_ordering::greater;
  } else {
    //<だけから三方比較
    if (a < b) return std::weak_ordering::less;
    if (b < a) return std::weak_ordering::greater;
    return std::weak_ordering::equivalent;
  }
};

< ==演算子が利用可能かを調べる両コンセプトはT, Uの順番に関わらず比較可能である事をチェックするために、引数順を入れ替えて両方向から比較可能かをチェックしておきます。
ただし、==演算子だけは片方しか使わないので、この用途だけを目的とするならばチェックするのは片方だけでよいかもしれません。

std::three_way_comparable_withC++20より標準で用意されるコンセプトで、2つの型の間で<=>による比較が使用可能であることを表明します。std::equality_comparable_with==について同様のものです。

同値比較演算子の導出

<=>を自前定義してしまうと==はもはや暗黙に宣言されなくなります。そもそも、T==を利用可能でないの場合はdefault==も利用可能ではありません。
そのため、<=>と同様に==も自分で定義してあげる必要があります。

凄く楽をするのであれば、定義済みの<=>を用いて実装することができます。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  bool operator==(const wrap& that) const {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

しかし、T==が使えるのならそれを使いたいのが世の常というものでしょう・・・

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないときにこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T>)  //==演算子が使用可能でない
  {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

T==を使える時はデフォルト実装を使います。この場合、もう片方の==は制約(!std::equality_comparable<T>)を満たさないので曖昧にはなりません。
そして、T==が使えない場合はwrapのデフォルトな==は暗黙deleteされ、もう片方は制約を満たすことから使用可能になり、やはり曖昧にはなりません。

参照型メンバがあるとき

これで完璧!と思われますが、比較演算子はデフォルト実装に頼ってしまうとTが参照型の時に実装されないという問題に直面するかもしれません。

となれば、参照型のためのケアも必要になります。
そんなに難しいことは無く、非デフォルトの方に制約を一つ追加するだけです。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

非デフォルトの方の制約にstd::is_reference_v<T>||で追加します。こうすることで、T==が使用可能でないかTが参照型の時にのみ非デフォルトの===が仕様可能になります。
そして、その場合はいずれもデフォルトの方は暗黙deleteされているので曖昧にはなりません。

とはいえ先ほど同様、Tが参照型であっても==演算子は本来参照先の型による比較になります。となれば、それを使えると良いですよね・・・

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    if constexpr (std::equality_comparable<T>) {
      //Tが==を使えるのならばそれを使う
      return v == that.v;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }
};

if constexprstd::equality_comparableでさらに分岐させます。
オーバーロードにしてしまうのも良いかもしれませんが、コンセプトのオーバーロードの半順序は複雑なのでお勧めしません・・・

実は、union-likeな型(共用体そのものか匿名共用体をメンバに持つ型)でも同じ問題が起きますが、それを解決することは難しいので触れないでおきます・・・

pairのような型に対しての拡張

今まで見てきたwrapはしょせん1つのテンプレートパラメータしか受け取らない型です。std::pairのように、2つ以上の型を受けるものに対しても同じことがしたいことがあるかもしれません。

とはいえ難しくは無く、型が増えた分制約が増えるだけです。

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

  auto operator<=>(const my_pair& that) const
    -> std::common_comparison_category_t<decltype(fallback_cmp_3way(first, that.first)), decltype(fallback_cmp_3way(second, that.second))>
  {
    if (auto comp = fallback_cmp_3way(first, that.first); comp != 0) return comp;
    return fallback_cmp_3way(second, that.second);
  }

  //普段はdefault任せ
  bool operator==(const my_pair& that) const = default;

  //TかUの==が使えないかTかUが参照型のときはこちらを使用
  bool operator==(const my_pair& that) const
    requires (
      (!std::equality_comparable<T> || !std::equality_comparable<U>) ||
      (std::is_reference_v<T> || std::is_reference_v<U>)
    )
  {
    if constexpr (std::equality_comparable<T> && std::equality_comparable<U>) {
      //==を使えるのならばそれを使う
      return first == that.first && second == that.second;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }
};

std::common_comparison_category<=>の正しい戻り値型である比較カテゴリ型を複数受け取り、変換可能な最も強い型(共通比較カテゴリ型)を返すメタ関数です。

まあすでに非デフォルト==の宣言がおかしなことになっています、もうちょっと数が増えたら考えたくないですね・・・。

番外編、変換可能な型との比較

wrpa<T>Tに変換可能な型Uとの比較演算子を実装したくなることもあるんじゃないでしょうか。

fallback_cmp_3wayはさっき2つの型の間で比較できるようになったのでそのまま使えそうです。
==は異種型間比較の場合全くdefaultに頼れないので、それによる困ったことを考慮する必要は無さそうです。

template<typename T>
struct wrap {
  T v;

//---自分自身との比較用---

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    if constexpr (std::equality_comparable<T>) {
      //Tが==を使えるのならばそれを使う
      return v == that.v;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }

//---変換可能な型との比較用---

  template<typename U>
  auto operator<=>(const U& other) const -> decltype(fallback_cmp_3way(v, other))
  requires std::convertible_to<U, T>
  {
    return fallback_cmp_3way(v, other);
  }

  template<typename U>
  bool operator==(const U& other) const
  requires std::convertible_to<U, T>
  {
    if constexpr (std::equality_comparable_with<T, U>) {
      //T, U間で==を使えるのならばそれを使う
      return v == other;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> other) == 0;
    }
  }
};

変換可能な型との比較用のものには、それをチェックするためにstd::convertible_toコンセプトによって制約をかけておきます。
あとは、<=>はほぼそのまま、==TUの間で==を使えるかをチェックするだけです。

なお、requires節をどこに置くかは好みで好きにしてください。

参考文献

この記事のMarkdownソース