Hey Justin,

I find this pretty compelling. So in short, equality_comparable_with<T, U> (among other things) both requires that a common reference exists (I'll call this type CR) but also that T and U are both convertible to CR, and what you're proposing here is to keep all the requirements the same, still require that CR exists as a type, but instead only require that T and U are both convertible to CR const&?

To me, that seems like it still completely satisfies the design intent of the model (we still have semantic meaning for the quality between T and U) while simply allowing non-copyable types to work. And unique_ptr<T> == nullptr_t does very much seem semantically sound to me. But I am not somebody that has ever really understood common reference, so I'm CCing the three people that do.


On Fri, Jun 11, 2021 at 12:58 AM Justin Bassett via Std-Proposals <std-proposals@lists.isocpp.org> wrote:

Summary: std::equality_comparable_with should be extended to support more types that have a heterogeneous equality, specifically types which are move-only or are otherwise burned by one particular aspect of the common reference requirements.

Background: std::equality_comparable_with<T, U> checks that const T& and const U& are both convertible to their std::equality_comparable common reference. This requirement is because we want std::equality_comparable_with to specify equality rather than any number of things that are not equality (e.g. iterator/sentinel "equality", DSLs). However, this requirement is too strict, and I believe we can do better.

https://stackoverflow.com/q/66937947/1896169 is relevant and why I've been thinking about this; it shows that std::equality_comparable_with<std::unique_ptr<T>, std::nullptr_t> is false, even though the heterogenous operator== is in fact an actual equality.

Why we have the common-reference requirement: cross-type equality isn't mathematically rigorously defined (see https://wg21.link/n3351 pages 15-16). When we write operator==(T, U) as an equality operator, what we are actually saying is that there is some common super-type of T and U under which this operator== is an equality. This is what std::equality_comparable_with attempts to encode. We don't actually need to convert to this common supertype to do t == u; we just need the heterogeneous operator== to be equivalent to the supertype's operator==.

Proposal: I believe the common reference requirement is too strict. Mathematically, a "reference" is meaningless. All we actually need is a common "supertype." This is important, because sometimes std::common_reference<const T&, const U&> is a non-reference V, such as with the unique_ptr<T>-nullptr_t case, where it is unique_ptr<T>. By specifying equality_comparable_with to require a common reference, we end up requiring that unique_ptr<T> is copyable (const unique_ptr<T>& must be convertible to unique_ptr<T>). In this case and many other similar cases with a heterogeneous operator== that means equality, we can capture this equality with equality_comparable_with if change the convertible_to<const T&, common_reference_t<const T&, const U&>> to allow const T& to be the same type as common_reference_t<const T&, const U&> after stripping cvref.

This does mean that it will become very difficult to express an equals() function in terms of the supertype operator==, but the point of the supertype operator== requirement is that it exists, not that it will be used.

In the code form that I believe captures this (quoting my answer to that stackoverflow question):
template <class T, class U>
concept equality_comparable_with =
  __WeaklyEqualityComparableWith<T, U> &&
  std::equality_comparable<T> &&
  std::equality_comparable<U> &&
      const std::remove_reference_t<T>&,
      const std::remove_reference_t<U>&>> &&
  __CommonSupertypeWith< // not necessarily a reference anymore
    const std::remove_reference_t<T>&,
    const std::remove_reference_t<U>&>;

template <class T, class U>
concept __CommonSupertypeWith = 
  std::same_as<std::common_reference_t<T, U>, std::common_reference_t<U, T>> &&
  std::convertible_to<T, const std::common_reference_t<T, U>&> &&
  std::convertible_to<U, const std::common_reference_t<T, U>&>;

Changes this: std::common_reference_with<const remove_reference_t<T>&, const remove_reference_t<U>&>
To this: __CommonSupertypeWith<const remove_reference_t<T>&, const remove_reference_t<U>&>

Which __CommonSupertypeWith is the same as common_reference_with, except we specify the convertibility to the common_reference_t as convertibility to const (common-reference)& instead of (common-reference).

Other thoughts:
I have other thoughts regarding equality_comparable_with, namely that I think it should be possible to opt-in to equality_comparable_with without hijacking common_reference_t for your type, which is problematic because common_reference_t is not exclusive to equality_comparable_with. That is, I think there should be an explicit customization point to declare "MyType's heterogeneous operator== is actually equality and isn't just using the '==' syntax." However, I don't feel as strongly about this.

Justin Bassett
Std-Proposals mailing list