57

Working with C++20's concepts I noticed that std::unique_ptr appears to fail to satisfy the std::equality_comparable_with<std::nullptr_t,...> concept. From std::unique_ptr's definition, it is supposed to implement the following when in C++20:

template<class T1, class D1, class T2, class D2>
bool operator==(const unique_ptr<T1, D1>& x, const unique_ptr<T2, D2>& y);

template <class T, class D>
bool operator==(const unique_ptr<T, D>& x, std::nullptr_t) noexcept;

This requirement should implement symmetric comparison with nullptr -- which from my understanding is sufficient for satisfying equality_comparable_with.

Curiously, this issue appears to be consistent on all the major compilers. The following code is rejected from Clang, GCC, and MSVC:

// fails on all three compilers
static_assert(std::equality_comparable_with<std::unique_ptr<int>,std::nullptr_t>);

Try Online

However the same assertion with std::shared_ptr is accepted:

// succeeds on all three compilers
static_assert(std::equality_comparable_with<std::shared_ptr<int>,std::nullptr_t>);

Try Online

Unless I'm misunderstanding something, this appears to be a bug. My question is whether this is a coincidental bug in the three compiler implementations, or is this a defect in the C++20 standard?

Note: I'm tagging this in case this happens to be a defect.

Barry
  • 286,269
  • 29
  • 621
  • 977
Human-Compiler
  • 11,022
  • 1
  • 32
  • 59
  • 3
    "*which from my understanding is sufficient for satisfying `equality_comparable_with`.*" It is not, but I don't see any other requirements that aren't satisfied. – Nicol Bolas Apr 04 '21 at 04:29

1 Answers1

70

TL;DR: std::equality_comparable_with<T, U> requires that both T and U are convertible to the common reference of T and U. For the case of std::unique_ptr<T> and std::nullptr_t, this requires that std::unique_ptr<T> is copy-constructible, which it is not.


Buckle in. This is quite the ride. Consider me nerd-sniped.

Why don't we satisfy the concept?

std::equality_comparable_with requires:

template <class T, class U>
concept equality_comparable_with =
  std::equality_comparable<T> &&
  std::equality_comparable<U> &&
  std::common_reference_with<
    const std::remove_reference_t<T>&,
    const std::remove_reference_t<U>&> &&
  std::equality_comparable<
    std::common_reference_t<
      const std::remove_reference_t<T>&,
      const std::remove_reference_t<U>&>> &&
  __WeaklyEqualityComparableWith<T, U>;

That's a mouthful. Breaking apart the concept into its parts, std::equality_comparable_with<std::unique_ptr<int>, std::nullptr_t> fails for std::common_reference_with<const std::unique_ptr<int>&, const std::nullptr_t&>:

<source>:6:20: note: constraints not satisfied
In file included from <source>:1: 
/…/concepts:72:13:   required for the satisfaction of
    'convertible_to<_Tp, typename std::common_reference<_Tp1, _Tp2>::type>'
    [with _Tp = const std::unique_ptr<int, std::default_delete<int> >&; _Tp2 = const std::nullptr_t&; _Tp1 = const std::unique_ptr<int, std::default_delete<int> >&]
/…/concepts:72:30: note: the expression 'is_convertible_v<_From, _To>
    [with _From = const std::unique_ptr<int, std::default_delete<int> >&; _To = std::unique_ptr<int, std::default_delete<int> >]' evaluated to 'false'
   72 |     concept convertible_to = is_convertible_v<_From, _To>
      |                              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

(edited for legibility) Compiler Explorer link.

std::common_reference_with requires:

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

std::common_reference_t<const std::unique_ptr<int>&, const std::nullptr_t&> is std::unique_ptr<int> (see compiler explorer link).

Putting this together, there is a transitive requirement that std::convertible_to<const std::unique_ptr<int>&, std::unique_ptr<int>>, which is equivalent to requiring that std::unique_ptr<int> is copy-constructible.

Why is the std::common_reference_t not a reference?

Why is std::common_reference_t<const std::unique_ptr<T>&, const std::nullptr_t&> = std::unique_ptr<T> instead of const std::unique_ptr<T>&? The documentation for std::common_reference_t for two types (sizeof...(T) is two) says:

  • If T1 and T2 are both reference types, and the simple common reference type S of T1 and T2 (as defined below) exists, then the member type type names S;
  • Otherwise, if std::basic_common_reference<std::remove_cvref_t<T1>, std::remove_cvref_t<T2>, T1Q, T2Q>::type exists, where TiQ is a unary alias template such that TiQ<U> is U with the addition of Ti's cv- and reference qualifiers, then the member type type names that type;
  • Otherwise, if decltype(false? val<T1>() : val<T2>()), where val is a function template template<class T> T val();, is a valid type, then the member type type names that type;
  • Otherwise, if std::common_type_t<T1, T2> is a valid type, then the member type type names that type;
  • Otherwise, there is no member type.

const std::unique_ptr<T>& and const std::nullptr_t& don't have a simple common reference type, since the references are not immediately convertible to a common base type (i.e. false ? crefUPtr : crefNullptrT is ill-formed). There is no std::basic_common_reference specialization for std::unique_ptr<T>. The third option also fails, but we trigger std::common_type_t<const std::unique_ptr<T>&, const std::nullptr_t&>.

For std::common_type, std::common_type<const std::unique_ptr<T>&, const std::nullptr_t&> = std::common_type<std::unique_ptr<T>, std::nullptr_t>, because:

If applying std::decay to at least one of T1 and T2 produces a different type, the member type names the same type as std::common_type<std::decay<T1>::type, std::decay<T2>::type>::type, if it exists; if not, there is no member type.

std::common_type<std::unique_ptr<T>, std::nullptr_t> does in fact exist; it is std::unique_ptr<T>. This is why the reference gets stripped.


Can we fix the standard to support cases like this?

This has turned into P2404, which proposes changes to std::equality_comparable_with, std::totally_ordered_with, and std::three_way_comparable_with to support move-only types.

Why do we even have these common-reference requirements?

In Does `equality_­comparable_with` need to require `common_reference`?, the justification given by T.C. (originally sourced from n3351 pages 15-16) for the common-reference requirements on equality_comparable_with is:

[W]hat does it even mean for two values of different types to be equal? The design says that cross-type equality is defined by mapping them to the common (reference) type (this conversion is required to preserve the value).

Just requiring the == operations that might naively be expected of the concept doesn't work, because:

[I]t allows having t == u and t2 == u but t != t2

So the common-reference requirements are there for mathematical soundness, simultaneously allowing for a possible implementation of:

using common_ref_t = std::common_reference_t<const Lhs&, const Rhs&>;
common_ref_t lhs = lhs_;
common_ref_t rhs = rhs_;
return lhs == rhs;

With the C++0X concepts that n3351 supported, this implementation would actually be used as a fallback if there was no heterogeneous operator==(T, U). With C++20 concepts, we require a heterogeneous operator==(T, U) to exist, so this implementation will never be used.

Note that n3351 expresses that this kind of heterogeneous equality is already an extension of equality, which is only rigorously mathematically defined within a single type. Indeed, when we write heterogeneous equality operations, we are pretending that the two types share a common super-type, with the operation happening inside that common type.

Can the common-reference requirements support this case?

Perhaps the common-reference requirements for std::equality_comparable are too strict. Importantly, the mathematical requirement is only that there exists a common supertype in which this lifted operator== is an equality, but what the common reference requirements require is something stricter, additionally requiring:

  1. The common supertype must be the one acquired through std::common_reference_t.
  2. We must be able to form a common supertype reference to both types.

Relaxing the first point is basically just providing an explicit customization point for std::equality_comparable_with in which you could explicitly opt-in a pair of types to meet the concept. For the second point, mathematically, a "reference" is meaningless. As such, this second point can also be relaxed to allow the common supertype to be implicitly convertible from both types.

Can we relax the common-reference requirements to more closely follow the intended common-supertype requirements?

This is tricky to get right. Importantly, we actually only care that the common supertype exists, but we never actually need to use it in the code. As such, we do not need to worry about efficiency or even whether the implementation would be impossible when codifying a common supertype conversion.

This can be accomplished by changing the std::common_reference_with part of equality_comparable_with:

template <class T, class U>
concept equality_comparable_with =
  __WeaklyEqualityComparableWith<T, U> &&
  std::equality_comparable<T> &&
  std::equality_comparable<U> &&
  std::equality_comparable<
    std::common_reference_t<
      const std::remove_reference_t<T>&,
      const std::remove_reference_t<U>&>> &&
  __CommonSupertypeWith<T, U>;

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

In particular, the change is changing common_reference_with to this hypothetical __CommonSupertypeWith where __CommonSupertypeWith differs by allowing for std::common_reference_t<T, U> to produce a reference-stripped version of T or U and also by trying both C(T&&) and C(const T&) to create the common reference. For more details, see P2404.


How do I work around std::equality_comparable_with before this gets merged into the standard?

Change which overload you use

For all of the uses of std::equality_comparable_with (or any of the other *_with concepts) in the standard library, there is helpfully a predicate overload which you can pass a function to. That means that you can just pass std::equal_to() to the predicate overload and get the desired behavior (not std::ranges::equal_to, which is constrained, but the unconstrained std::equal_to).

This doesn't mean that it would be a good idea to not fix std::equality_comparable_with, however.

Can I extend my own types to meet std::equality_comparable_with?

The common-reference requirements use std::common_reference_t, which has a customization point of std::basic_common_reference, for the purpose of:

The class template basic_common_reference is a customization point that allows users to influence the result of common_reference for user-defined types (typically proxy references).

It is a horrible hack, but if we write a proxy reference that supports both types we want to compare, we can specialize std::basic_common_reference for our types, enabling our types to meet std::equality_comparable_with. See also How can I tell the compiler that MyCustomType is equality_comparable_with SomeOtherType? . If you choose to do this, beware; std::common_reference_t is not only used by std::equality_comparable_with or the other comparison_relation_with concepts, you risk causing cascading problems down the road. It is best if you ensure that the common reference is actually a common reference, e.g.:

template <typename T>
class custom_vector { ... };

template <typename T>
class custom_vector_ref { ... };

custom_vector_ref<T> could be a good option for a common reference between custom_vector<T> and custom_vector_ref<T>, or possibly even between custom_vector<T> and std::array<T, N>. Tread carefully.

How can I extend types I don't control std::equality_comparable_with?

You can't. Specializing std::basic_common_reference for types you don't own (either std:: types or some third-party library) is at best bad practice and at worst undefined behavior. The safest choice would be to use a proxy type you own that you can compare through or else write your own extension of std::equality_comparable_with that has an explicit customization point for your custom spelling of equality.


Okay, I get that the idea of these requirements is mathematical soundness, but how do these requirements acheive mathematical soundness, and why is it so important?

Mathematically, equality is an equivalence relation. However, equivalence relations are defined over a single set. So how can we define an equivalence relation between two sets A and B? Simply put, we instead define the equivalence relation over C = A∪B. That is to say, we take a common supertype of A and B and define the equivalence relation over this supertype.

This means that our relation c1 == c2 must be defined no matter where c1 and c2 come from, so we must have a1 == a2, a == b, and b1 == b2 (where ai is from A and bi is from B). Translating to C++, this means that all of operator==(A, A), operator==(A, B), operator==(B, B), and operator==(C, C) must be part of the same equality.

This is why iterator/sentinels do not meet std::equality_comparable_with: while operator==(iterator, sentinel) may actually be part of some equivalence relation, it is not part of the same equivalence relation as operator==(iterator, iterator) (otherwise iterator equality would only answer the question of "Are either both iterators at the end or both iterators not at the end?").

It is actually quite easy to write an operator== that is not actually equality, because you must remember that the heterogeneous equality is not the single operator==(A, B) you are writing, but is instead four different operator==s that must all be coheisve.

Wait a minute, why do we need all four operator==s; why can't we just have operator==(C, C) and operator==(A, B) for optimization purposes?

This is a valid model, and we could do this. However, C++ is not a platonic reality. Although concepts try their hardest to only accept types that truly meet the semantic requirements, it cannot actually acheive this goal. As such, if we were to only check operator==(A, B) and operator==(C, C), we run the risk that operator==(A, A) and operator==(B, B) do something different. Besides, if we can have operator==(C, C), then this means that it is trivial to write operator==(A, A) and operator==(B, B) based on what we have in operator==(C, C). That is to say, the harm of requiring operator==(A, A) and operator==(B, B) is quite low, and in return we get a higher confidence that we actually have an equality.

There are some circumstances where this runs into rough edges, however; see P2405.

How exhausting. Can't we just require that operator==(A, B) is an actual equality? I'm never going to actually use the operator==(A, A) or operator==(B, B) anyway; I only cared about being able to do the cross-type comparison.

Actually, a model where we require operator==(A, B) is an actual equality would probably work. Under this model, we would have std::equality_comparable_with<iterator, sentinel>, but what precisely that means in all known contexts could be hammered out. However, there was a reason why this is not the direction the standard went with, and before one can understand if or how to change it, they must first understand why the standard's model was chosen.

Justin
  • 24,288
  • 12
  • 92
  • 142
  • Ahhh, thank you for tracing that down! I didn't realize there was an implicit requirement of copy-constructibility due to its use of `convertible_to`. Would this be considered a defect in the standard? The current definition will break on any move-only types that are equality comparable with a type they are convertible to -- so this seems like a bad definition – Human-Compiler Apr 04 '21 at 05:08
  • 7
    @Human-Compiler I won't pretend to understand the standard or the reason why `std::equality_comparable_with` has the `common_reference` requirements, but I do think this is a defect in the standard. – Justin Apr 04 '21 at 05:15
  • 7
    @Human-Compiler: Personally, I think the whole [`common_reference` requirement of `equality_comparable_with`](https://stackoverflow.com/questions/61177302/does-equality-comparable-with-need-to-require-common-reference) is defective, but I highly doubt it's going to be changed. – Nicol Bolas Apr 04 '21 at 05:17
  • 1
    The problem with allowing lifetime extension with the result of `common_reference` is that it is _fragile_; I can't even put it into a `tuple` because the temporary produced from `nullptr_t` would just dangle. – T.C. Apr 04 '21 at 15:03
  • Due to `equality_comparable_with` oddity, I am left having to define effectively an orthogonal implementation of `equality_comparable_with` that behaves like `equality_comparable` only with `nullptr` -- which also means that this won't provide constraint ordering with `equality_comparable`/`equality_comparable_with` and may even be ambiguous if they become part of a competing overload set. This is disappointing. – Human-Compiler Apr 04 '21 at 15:17
  • @Justin: "*every standard library function that has a constraint of std::equality_comparable_with also has a predicate version without that constraint*" So what happens if you want to have asymmetric comparison for things like `std::unordered_map`? As it currently stands, the asymmetric `operator[]` is unconstrained, but wouldn't it make sense to constrain it with `equality_comparable_with`? And if not, then what good is that constraint anyway; if you can't use it for something as brain-dead obvious as that, why have it at all? – Nicol Bolas Apr 04 '21 at 15:39
  • @NicolBolas Hmm. I do at least kind of agree with you. I've got another Q/A in the works, though, which would demonstrate how to allow your own custom types implement this concept if they meet the weak version. – Justin Apr 04 '21 at 16:09
  • @NicolBolas It doesn't make a lot of sense to constrain with `equality_comparable_with` when the equality predicate is an arbitrary function object. In any event, heterogeneous lookup allows more than the "same platonic value" relationship codified by `common_reference` and concepts using it. We permit lookup using something that matches multiple items, even in a unique-key container. – T.C. Apr 05 '21 at 00:40
  • 14
    Is it just me or is the language slowly drifting towards a playground for language lawyers while becoming practically unusable in a safe manner (because it is generally impossible to understand what a given piece of code is doing)? – Peter - Reinstate Monica Apr 05 '21 at 11:47
  • 5
    @Peter-ReinstateMonica It only looks that way if you take tiny details like these and make too big a deal out of them. Sure, it would have been nice if this corner case would have worked more as expected. But overall, I think C++ is drifting towards being an easier and safer language to use. – G. Sliepen Apr 05 '21 at 15:11
  • @G.Sliepen - the key part of @ Peter-ReinstateMonica's point (IMO) is "because it is generally impossible to understand what a given piece of code is doing". The "safeness" now is because it if doesn't pass the compiler you just fiddle with it until it does - you don't understand it any better though. – davidbak Apr 05 '21 at 16:19
  • @Peter-ReinstateMonica The code does what it is defined to do. But I would say that this is *meta-code*. Used to constrain and define what and how can use this code. – Robert Andrzejuk Apr 05 '21 at 18:57
  • 1
    @davidbak But that's the point I'm making, it's not like every line of code has suddenly become impossible to understand. `int i =1` still works exactly how it did before. Also note that `std::equality_comparable_with` was introduced in C++20, so it's not surprising that not everyone immediately understands exactly how it works in all possible scenarios. It is possible to understand it though, as Justin's answer shows, so there is no need to fiddle. Please don't "fiddle until it compiles" with production code. – G. Sliepen Apr 05 '21 at 19:47
  • 1
    @G.Sliepen "Not in production code", in turn, was *my* point: Everybody knows what `int i=1;` does. Should production code not stick with clearly understandable constructs? Wouldn't that relegate all the things discussed here to the nerd playground? – Peter - Reinstate Monica Apr 05 '21 at 20:26
  • 1
    @G.Sliepen I don't quite agree it's a language lawyer playground, but I don't entirely think you're right either. These details only look tiny, until you consider that is isn't just one, or two, or three details, it's thousands: type pruning unions, undefined bitfield ordering, parent move constructors, std::char as the only way to write binary data, finger crossing for code to compile into assembly bit rotation, unavailible template types that have to be passed in through template parameters instead of on template object, there's just too much to type. – Krupip Apr 05 '21 at 20:27
  • 3
    @G.Sliepen It _is_ surprising that not everyone immediately understands how it works in all possible scenarios. Professionals, who have been writing C++ code for years, will have to devote hundreds of hours to learning every time a new standard comes out if they want this level of understanding. That is totally unreasonable. – Passer By Apr 05 '21 at 22:58
  • Defining an "is equivalent to" operator that can compare things of arbitrary type is easy: if neither operand type is aware of any way that an instance could be equivalent to an instance of the other type, then the objects being compared are not equal to each other. While there may be situations where it may be more useful to have a comparison operator that squawks at attempts to perform vacuous comparisons than one that would silently report incomparable objects as unequal, that should not preclude the possibility of a universal comparison operator which acts on things of arbitrary type. – supercat Jun 10 '21 at 20:49
  • @Peter-ReinstateMonica: Practical improvements are blocked by people who are opposed to allowing constructs they view as encouraging inelegant code, but are oblivious to the fact that not all real world problems will have "elegant" solutions, and refusing to allow a something to be coded in somewhat-inelegant fashion will often make it necessary to write it in more severely inelegant fashion instead. – supercat Jun 10 '21 at 20:52
  • @supercat "Defining an 'is equivalent to' operator that can compare things of arbitrary type is easy". I understand this, but I don't understand what this has to do with the answer. – Justin Jun 10 '21 at 21:52
  • @Justin: Your answer suggests that there must exist a common supertype to which one can convert the objects being converted. My point was that equivalence testing need not require the existence of such a supertype if one regards the lack of such a supertype as implying that two objects aren't equivalent. – supercat Jun 10 '21 at 22:00
  • @supercat Okay, I see what you mean. This supertype is to be understood in the context of mathematics, not in the context of C++. If you have two sets `N = {1, 2, 3}` and `F = {apple, orange, banana}`, if we have `n` from `N` and `f` from `F`, then `n == f` would always be false, yes. However, it is important to understand that `==` is mathematically understood as an equivalence relation, which is only well defined on a single set. So what we're actually doing is we're forming `N union F` and defining `==` over this superset. Naturally, anything from `N` will be unequal to anything from `F`. – Justin Jun 10 '21 at 22:09
  • @Justin: I suppose that perhaps from a language perspective, one could design a comparison operator that would work with arbitrary types by having it expand to either a normal comparison or an unconditional false, so there's no need to have a concept to indicate whether two objects can be compared via such means, and it makes more sense to have one that tests whether they might potentially be equal. – supercat Jun 10 '21 at 22:32