Working with C++20’s concept
s 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>);
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>);
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 language-lawyer 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
andT2
are both reference types, and the simple common reference typeS
ofT1
andT2
(as defined below) exists, then the member type type namesS
;- Otherwise, if
std::basic_common_reference<std::remove_cvref_t<T1>, std::remove_cvref_t<T2>, T1Q, T2Q>::type
exists, whereTiQ
is a unary alias template such thatTiQ<U>
isU
with the addition ofTi
‘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 templatetemplate<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 ofT1
andT2
produces a different type, the member type names the same type asstd::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
andt2 == u
butt != 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>>>; }; }
Note that std::common_reference
:
Determines the common reference type of the types
T...
, that is, the type to which all the types inT...
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 ofcommon_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)