为什么在C++20中unique_ptr不能与nullptr_t相等比较?

58
使用C++20的概念(concept)时,我注意到std::unique_ptr似乎无法满足std::equality_comparable_with<std::nullptr_t,...>概念。根据std::unique_ptr的定义,在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;

这个要求 应该 实现与 nullptr 的对称比较 -- 据我理解,这足以满足 equality_comparable_with 的要求。
有趣的是,这个问题似乎在所有主要编译器上都存在。以下代码被 Clang、GCC 和 MSVC 拒绝:
// fails on all three compilers
static_assert(std::equality_comparable_with<std::unique_ptr<int>,std::nullptr_t>);

在线尝试

然而,使用std::shared_ptr的同样断言被接受:

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

在线尝试

除非我误解了什么,否则这似乎是一个错误。 我的问题是,这是三个编译器实现中的巧合错误,还是C++20标准的缺陷?

注意:如果这恰好是一个缺陷,我会打上标签。


3
根据我的理解,这已足够满足equality_comparable_with的要求。”实际上并不足够,但我没有看到其他未满足的要求。 - Nicol Bolas
1个回答

71
TL;DR: std::equality_comparable_with<T, U>要求TU都可以转换为TU的公共引用。对于std::unique_ptr<T>std::nullptr_t的情况,这要求std::unique_ptr<T>是可复制构造的,但实际上它不是可复制构造的。

请系好安全带,这将是一次惊险的旅程。把我看作一个nerd-sniped

为什么我们不能满足概念?

std::equality_comparable_with要求:

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

这是一个很长的话题。将其拆分为不同部分,std::equality_comparable_with<std::unique_ptr<int>, std::nullptr_t> 对于 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>
      |                              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

这是Compiler Explorer链接

std::common_reference_with要求:

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&>std::unique_ptr<int>(请参见compiler explorer link)。

综合考虑,有一个传递性要求,即std::convertible_to<const std::unique_ptr<int>&, std::unique_ptr<int>>,这等价于要求std::unique_ptr<int>是可复制的。

为什么std::common_reference_t不是引用?

为什么std::common_reference_t<const std::unique_ptr<T>&, const std::nullptr_t&> = std::unique_ptr<T>而不是const std::unique_ptr<T>&?对于两个类型(sizeof...(T)为两个)的std::common_reference_t的文档如下:

  • 如果T1T2都是引用类型,并且T1T2简单公共引用类型S存在(如下所定义),则成员类型类型名为S
  • 否则,如果存在std::basic_common_reference<std::remove_cvref_t<T1>, std::remove_cvref_t<T2>, T1Q, T2Q>::type,其中TiQ是一个一元别名模板,使得TiQ<U>是添加了Ti的cv和引用限定符的U,则成员类型类型名为该类型;
  • 否则,如果decltype(false? val<T1>() : val<T2>())是一个有效类型,则成员类型类型名为该类型,其中val是一个函数模板template<class T> T val();
  • 否则,如果std::common_type_t<T1, T2>是一个有效类型,则成员类型类型名为该类型;
  • 否则,没有成员类型。

const std::unique_ptr<T>&const std::nullptr_t&没有一个简单的公共引用类型,因为这些引用不能立即转换为一个共同的基础类型(即false ? crefUPtr : crefNullptrT是不合法的)。对于std::unique_ptr<T>,没有std::basic_common_reference专门化。第三个选项也失败了,但我们触发了std::common_type_t<const std::unique_ptr<T>&, const std::nullptr_t&>

对于std::common_typestd::common_type<const std::unique_ptr<T>&, const std::nullptr_t&> = std::common_type<std::unique_ptr<T>, std::nullptr_t>,因为:

如果将std::decay应用于T1T2中的至少一个,则产生不同的类型,则成员类型命名与std::common_type<std::decay<T1> ::type,std::decay<T2> ::type> ::type相同,如果存在;如果不存在,则没有成员类型。

std::common_type<std::unique_ptr<T>, std::nullptr_t>实际上是存在的;它是std::unique_ptr<T>。这就是为什么引用被剥离的原因。


我们能否修复标准以支持这样的情况?

这已经变成P2404,提出了对std::equality_comparable_withstd::totally_ordered_withstd::three_way_comparable_with进行更改以支持仅移动类型。

我们为什么需要这些常见引用要求?

Does `equality_­comparable_with` need to require `common_reference`?中,T.C.给出的证明(最初源自n3351第15-16页)对于equality_comparable_with上的常见引用要求是:

[两个不同类型的值相等]甚至是什么意思?设计表示,交叉类型的相等由将它们映射到公共(引用)类型来定义(此转换需要保留值)。

仅要求可能被概念天真地期望的==操作是行不通的,因为:

[它]允许有t == ut2 == ut != t2

因此,常见引用要求是为了数学上的严谨性,同时允许可能的实现:

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;

使用C++0X concepts n3351支持的内容,如果没有异构operator==(T, U),则会使用此实现作为回退。在C++20 concepts中,我们要求存在异构operator==(T, U),因此永远不会使用此实现。
请注意,n3351表达了这种异构相等已经是相等的扩展,而严格数学上只在单个类型内定义。事实上,当我们编写异构相等操作时,我们假设两种类型共享一个公共超类型,并在该公共类型内进行操作。
可以通过更改equality_comparable_with中的std::common_reference_with来实现此目标。对于std::equality_comparable的通用引用要求可能过于严格。重要的是,数学要求仅需要存在一个公共超类型,在其中此提升的operator==是相等的,但是通用引用要求需要更严格,还需要以下要求:
  1. 公共超类型必须是通过std::common_reference_t获得的。
  2. 我们必须能够形成两种类型的公共超类型引用
放宽第一点基本上只是提供了std::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>&>>);

特别地,这个变化是将common_reference_with更改为假设的__CommonSupertypeWith,其中__CommonSupertypeWith的不同之处在于允许std::common_reference_t<T, U>产生一个去除引用的TU版本,并尝试使用C(T&&)C(const T&)来创建公共引用。有关更多详细信息,请参见P2404


在这个概念合并到标准之前,我该如何解决std::equality_comparable_with

更改使用的重载函数

对于标准库中所有使用std::equality_comparable_with(或任何其他*_with概念)的情况,都有一个谓词重载函数可以传递一个函数。这意味着您只需将std::equal_to()传递给谓词重载函数即可获得所需的行为(不是受约束的std::ranges::equal_to,而是未受约束的std::equal_to)。

但这并不意味着不修复std::equality_comparable_with是一个好主意。

我可以扩展自己的类型以符合std::equality_comparable_with吗?

公共引用要求使用std::common_reference_t,它具有std::basic_common_reference的自定义点,用于:

类模板basic_common_reference是一个自定义点,允许用户影响common_reference的结果,以用于用户定义的类型(通常是代理引用)。

这是一个可怕的黑客技术,但如果我们编写一个支持我们要比较的两种类型的代理引用,我们可以为我们的类型专门化std::basic_common_reference,使我们的类型符合std::equality_comparable_with。另请参见我如何告诉编译器MyCustomType与SomeOtherType是equality_comparable_with?。如果您选择这样做,请注意;std::common_reference_t不仅被std::equality_comparable_with或其他comparison_relation_with概念使用,您可能会在未来引起级联问题。最好确保公共引用实际上是一个公共引用,例如:

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

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

custom_vector_ref<T> 可以作为 custom_vector<T>custom_vector_ref<T> 之间的通用引用,甚至可以在 custom_vector<T>std::array<T, N> 之间使用。但需要小心谨慎。

如何扩展我无法控制的类型 std::equality_comparable_with

你无法这样做。为你不拥有的类型(无论是 std:: 类型还是第三方库)专门化 std::basic_common_reference 最多是不良实践,最坏的情况下是未定义行为。最安全的选择是使用你自己拥有的代理类型来进行比较,或者编写自己的 std::equality_comparable_with 扩展,其中包含一个显式的定制点,用于你自定义的等号拼写。


好的,我明白这些要求的理念是数学上的正确性,但这些要求如何实现数学上的正确性,为什么它如此重要呢?
从数学上讲,等式是一种等价关系。然而,等价关系是在单个集合上定义的。那么我们如何在两个集合A和B之间定义一个等价关系?简单地说,我们改为在C=A∪B上定义等价关系。也就是说,我们取A和B的公共超类型,并在这个超类型上定义等价关系。
这意味着我们的关系c1 == c2必须被定义,无论c1和c2来自哪里,因此我们必须有a1 == a2、a == b和b1 == b2(其中ai来自A,bi来自B)。换成C++,这意味着operator==(A, A)、operator==(A, B)、operator==(B, B)和operator==(C, C)都必须属于同一个等式。
这就是为什么iterator/sentinel不符合std::equality_comparable_with的原因:虽然operator==(iterator, sentinel)实际上可能是某个等价关系的一部分,但它不是与operator==(iterator, iterator)相同的等价关系的一部分(否则迭代器相等只回答“两个迭代器是否都在末尾或两个迭代器都不在末尾?”的问题)。
实际上很容易编写一个operator==,它实际上并不是相等的,因为你必须记住异构相等不是你正在编写的单个operator==(A, B),而是四个不同的operator==,它们必须都是连贯的。
等一下,为什么我们需要所有四个operator==?我们为什么不能只有operator==(C, C)和operator==(A, B)来进行优化?
这是一个有效的模型,我们可以这样做。然而,C++并不是一个纯粹的现实。尽管概念尽力只接受真正满足语义要求的类型,但它实际上无法实现这个目标。因此,如果我们只检查operator==(A, B)operator==(C, C),我们就会冒着operator==(A, A)operator==(B, B)执行不同操作的风险。此外,如果我们可以有operator==(C, C),那么这意味着基于operator==(C, C)编写operator==(A, A)operator==(B, B)是微不足道的。也就是说,要求operator==(A, A)operator==(B, B)的危害非常低,换取的是我们确实具有相等性的更高信心。

然而,在某些情况下,这可能会遇到困难; 请参见P2405

太麻烦了。我们不能只要求operator==(A, B)是一个真正的相等吗?我永远不会实际使用operator==(A, A)operator==(B, B); 我只关心能够进行跨类型比较。

实际上,如果我们要求operator==(A, B)是一个真正的相等,这个模型可能会起作用。在这种模型下,我们将拥有std::equality_comparable_with<iterator, sentinel>,但在所有已知情况下,它确切意味着仍需澄清。然而,标准没有选择这个方向,这是有原因的。在理解是否以及如何更改之前,必须先理解标准选择该模型的原因。


7
我不会假装理解std::equality_comparable_with的标准或要求common_reference,但我认为这是标准中的缺陷。 - Justin
7
个人认为,equality_comparable_with 的整个“common_reference”要求是有瑕疵的,但我非常怀疑它会被改变。 - Nicol Bolas
14
是我一个人感觉还是语言正逐渐漂向成为语言律师游戏场,同时在实际使用中变得难以安全运用(因为通常很难理解给定代码正在做什么)? - Peter - Reinstate Monica
5
如果你过于关注这些微小的细节并且把它们看得太重要,那么它就会显得像这样。当然,如果这种特殊情况可以更符合预期,那么会更好一些。但总体而言,我认为C++正在朝着更易于使用和更安全的语言方向发展。 - G. Sliepen
3
@G.Sliepen,确实令人惊讶的是,并非所有人都能立即理解它在所有可能情况下的工作原理。那些写了多年C++代码的专业人士,如果想要这种程度的理解,每次新标准发布都必须花费数百小时来学习。这是完全不合理的。 - Passer By
显示剩余17条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接