Why is unique_ptr not equality_comparable_with nullptr_t in C++20?

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.

Answer

TL;DR: std::equality_comparable_with<T, U> requires that both 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?

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

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; the above code would actually work if const common_ref_t& was used (allowing reference collapsing), since std::convertible_to<decltype(rhs_), const common_ref_t&> = std::convertible_to<const std::nullptr_t&, const std::unique_ptr<T>&> is true, because we can use the implicit constructor of std::unique_ptr<T> and perform temporary lifetime extension.

If we massage std::common_reference_with to support this case, then we’d be golden. As std::common_reference_with is built on std::common_reference_t, the question becomes, “Can we fix std::common_reference_t?”

Can we fix std::common_reference_t?

If specializations of basic_common_reference for std::nullptr_t and std::unique_ptr are added, then std::equality_comparable_with<std::unique_ptr<int>, std::nullptr_t> is true:

namespace std {
template <typename T, typename D, template <typename> class UPtrQual, template <typename> class NullptrQual>
struct basic_common_reference<std::unique_ptr<T, D>, std::nullptr_t, UPtrQual, NullptrQual> {
    using type = UPtrQual<NullptrQual<std::unique_ptr<T, D>>>;
};
template <typename T, typename D, template <typename> class UPtrQual, template <typename> class NullptrQual>
struct basic_common_reference<std::nullptr_t, std::unique_ptr<T, D>, NullptrQual, UPtrQual> {
    using type = UPtrQual<NullptrQual<std::unique_ptr<T, D>>>;
};
}

(Compiler Explorer link)

Note that std::common_reference:

Determines the common reference type of the types T..., that is, the type to which all the types in T... can be converted or bound.

It could be argued that this would justify a specialization of std::common_reference_t for std::unique_ptr<T> and std::nullptr_t for const&, &&, and const&& cases, where a std::nullptr_t will create a temporary std::unique_ptr<T> which is bound to the reference. However, this specialization is unsound if it was std::unique_ptr<T>&, since it then cannot bind to an rvalue.

It may also be worth a specialization for std::shared_ptr<T> as well, because—at this time—std::common_reference_t<const std::shared_ptr<T>&, const std::nullptr_t&> is const std::shared_ptr<T>, meaning that generic code like the above conversion-to-common-reference-equality can end up unnecessarily copying the std::shared_ptr<T>, adding an unnecessary atomic increment/decrement.

Can we fix std::common_reference_t in general?

std::common_reference<T, U>—for reference T and U—could be extended to support cases where either std::convertible_to<U, T> or std::convertible_to<T, U> are true, i.e. where temporary lifetime extension via a temporary is possible. This would prevent the need for the specialization for unique_ptr or any custom user types with the same problem.

Is it a good idea to extend std::common_reference_t to support temporary lifetime extension?

As mentioned by T.C. in the comments, this is quite dangerous. If we were to support temporary lifetime extension, it would be incredibly easy to write innocent-looking code which dangles a reference. In generic code, this would mean adding another case that would have to be known about and considered, otherwise risking subtle undefined behavior that would only manifest for a small subset of all types.

Should we worry about extending std::equality_comparable_with?

Note that std::equality_comparable_with is unimportant in the grand scheme of things. AFAIK, every standard library function that has a constraint of std::equality_comparable_with also has a predicate version without that constraint, meaning that you could just use std::equal_to() or a custom lambda instead.

Even with possible relaxations, assuming maintaining mathematical rigor is impossible without the common reference requirements, std::equality_comparable_with will always fail to support some types that behave perfectly well when pretended to be equality_comparable_with.


Can I fix the types without waiting for the standard to be changed?

How can I extend my own types to support 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).

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? .

This could be implemented for the standard types as well for any example where we have heterogenous equality and we don’t get a good common-reference by default, such as this std::unique_ptr<T> + std::nullptr_t case.

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. However, we can use a wrapper type and a custom concept instead of std::equality_comparable_with:

#include <utility>

// From https://en.cppreference.com/w/cpp/concepts/boolean-testable
template<class B>
concept boolean_testable_impl = std::convertible_to<B, bool>;

template<class B>
concept boolean_testable =
    boolean_testable_impl<B> &&
    requires (B&& b) {
        { !std::forward<B>(b) } -> boolean_testable_impl;
    };

// From https://en.cppreference.com/w/cpp/concepts/equality_comparable
template<class T, class U>
concept weakly_equality_comparable_with =
    requires(const std::remove_reference_t<T>& t,
             const std::remove_reference_t<U>& u) {
      { t == u } -> boolean_testable;
      { t != u } -> boolean_testable;
      { u == t } -> boolean_testable;
      { u != t } -> boolean_testable;
    };

// The actual additional type and concept
template <typename T>
class eq_wrapper {
private:
    const T& ref_;

public:
    template <typename U>
        requires std::convertible_to<U, T>
    constexpr eq_wrapper(U ref)
        : ref_(ref)
    {}

    template <typename U>
        requires weakly_equality_comparable_with<T, U>
    constexpr bool operator==(const eq_wrapper<U>& rhs) const {
        return ref_ == rhs.ref_;
    };
};

template <typename T, typename U>
concept wrapped_equality_comparable_with =
    // To give us subsumption with std::equality_comparable_with:
    std::equality_comparable_with<T, U> ||
    // To support when we're not actually equality_comparable_with
    std::equality_comparable_with<eq_wrapper<T>, eq_wrapper<U>> &&
    weakly_equality_comparable_with<T, U>;

To support wrapped_equality_comparable_with<T, U>, you’d implement the above extension for eq_wrapper<T> and eq_wrapper<U> (note: this should not be implemented for any weakly-comparable T and U, but only for types which have a true equality)