【问题标题】:std::optional: Not participating in overload resolution vs. being defined as deletedstd::optional: 不参与重载决议与被定义为删除
【发布时间】:2020-10-01 10:48:19
【问题描述】:

我试图了解类型特征传播背后的机制,如http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0602r4.html 中为 std::optional 所描述的那样。在处理复制操作(应有条件地定义为删除)与移动操作(不参与重载解决方案)之间存在细微差别。

造成这种差异的原因是什么,我将如何测试后者?示例:

#include <type_traits>
#include <optional>

struct NonMoveable {
  NonMoveable() = default;
  NonMoveable(NonMoveable const&) = default;
  NonMoveable(NonMoveable&&) = delete;
  NonMoveable& operator=(NonMoveable const&) = default;
  NonMoveable& operator=(NonMoveable&&) = delete;
};

// Inner traits as expected
static_assert(!std::is_move_constructible<NonMoveable>::value);
static_assert(!std::is_move_assignable<NonMoveable>::value);

// The wrapper is moveable, via copy operations participating in
// overload resolution. How to verify that the move operations don't?
static_assert(std::is_move_constructible<std::optional<NonMoveable>>::value);
static_assert(std::is_move_assignable<std::optional<NonMoveable>>::value);

int main(int argc, char* argv[])
{
  NonMoveable a1;
  NonMoveable a2{std::move(a1)}; // Bad, as expected
  std::optional<NonMoveable> b1;
  std::optional<NonMoveable> b2{std::move(b1)}; // Good, see above. But
                                                // useless as a test for
                                                // P0602R4.
  return 0;
}

奖金问题

GCC 做正确的事吗?我稍微修改了这个例子,让我们更接近一步:https://godbolt.org/z/br1vx1。在这里,我通过将复制操作声明为私有来使它们无法访问。带有 -std=c++20 的 GCC-10.2 现在无法通过静态断言并抱怨

error: use of deleted function 'std::optional<NonMoveable>::optional(std::optional<NonMoveable>&&)'

根据Why do C++11-deleted functions participate in overload resolution?,删除是在重载决议之后应用的,这可能表明移动构造函数参与了,尽管 P0602R4 说它不应该参与。

另一方面,https://en.cppreference.com/w/cpp/language/overload_resolution 一开始就声明

...如果这些步骤产生多个候选函数,则执行重载决策...

所以跳过了重载决议,因为移动构造函数是唯一的候选者?

【问题讨论】:

  • 在程序中,不参与重载决议的函数与从未定义的函数没有区别。一个候选的重载决议仍然是重载决议。每个调用都受到重载决议的影响。为什么修改后的例子令人惊讶?您是否期望 std::optional&lt;NonMoveable&gt; 应该是可移动构造的?
  • 我认为差异的原因是这个。如果用户请求复制并且复制构造函数不可用,则这是一个硬错误。我们不希望选择任何其他构造函数。所以我们删除了复制构造函数。如果用户请求移动并且移动构造函数不可用,我们希望回退到任何其他构造函数可以完成这项工作。不太清楚这个解释。
  • @n.'pronouns'm.:我对不可移动的结果并不感到惊讶,但我有点在黑暗中寻找一个指标,这是由不参与重载解决,而不是删除。 cppreference 引用似乎与始终完成重载解决方案相矛盾。是误会吗?
  • 对,libstdc++ 似乎没有实现“不参与重载解析”位。不确定在移动 ctor 的情况下是否重要。
  • 我不确定你能区分这两种情况。如果有可行的copy ctor,不管move是被删除还是不参与都会被使用。如果没有,则程序格式错误,同样不管。我找不到任何有区别的案例。

标签: c++ optional overload-resolution


【解决方案1】:

std::optional 是一个红鲱鱼;关键是了解导致为什么将这些要求放在库类型上的机制

在处理复制操作(应有条件地定义为已删除)与移动操作(不应参与重载决议)之间存在细微差别。

std::optional 的底层要求(以及如何实现这些要求)很复杂。但是,移动操作不应参与重载决议(对于不可移动类型)与被删除的复制操作(对于不可复制类型)的要求可能与单独的主题有关;

  • NRVO(1)(复制省略的情况,如果可以的话)和
  • 更多隐式动作,例如从函数返回具有自动存储持续时间的命名对象时,选择移动构造函数而不是复制构造函数。

我们可以通过查看比std::optional 更简单的类型来理解这个主题。

(1)命名返回值优化


TLDR

在 C++ 中扩展的移动热切性(C++20 中的更热切移动)意味着存在一些特殊情况,即使移动构造函数已被删除,也会选择移动构造函数而不是复制构造函数。例如,对于不可移动类型,避免这些的唯一方法是确保类型 没有移动构造函数或移动赋值运算符,通过了解 5 规则以及控制这些是隐式定义的。

复制不存在相同的偏好,并且没有理由支持删除它们而不是删除它们,如果这甚至可能的话(2)。换句话说,在重载决议中偏爱移动存在的相同扭结,有时会意外地选择移动而不是复制,反之则不存在。复制移动。

(2) 没有复制 ctor 和复制赋值操作符的存在,就没有类这样的东西(尽管这些可能被定义为删除)。


隐式移动渴望

考虑以下类型:

struct A {
  A() { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  A(A const &) { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  A &operator=(A const &) {
    std::cout << __PRETTY_FUNCTION__ << "\n";
    return *this;
  }
};

struct B {
  B() { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  B(B const &) { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  B &operator=(B const &) {
    std::cout << __PRETTY_FUNCTION__ << "\n";
    return *this;
  }

  B(B &&) = delete;
  B &operator=(B &&) = delete;
};

在哪里,A

  • 具有用户定义的构造函数,因此不会隐式定义移动构造函数和移动赋值运算符,

还有B,此外:

  • 声明并删除一个移动构造函数和一个移动赋值运算符;由于这些被声明并定义为删除,它们将参与重载决议。

在我们继续浏览不同的标准版本之前,我们定义了以下我们将返回的函数:

A getA() {
    A a{};
    return a;
}

B getB() {
    B b{};
    return b;
}

C++14

现在,在 C++14 中,允许在某些情况下实现复制(/移动)省略;引用 N4140 中的 [class.copy]/31(C++14 + 编辑修复)[emphasis mine]:

当满足某些条件时,允许实现省略类对象的复制/移动构造,即使为复制/移动操作选择了构造函数和/或为对象有副作用。 [...]

这种复制/移动操作的省略,称为复制省略,在以下情况下是允许的:

  • 在具有类返回类型的函数的 return 语句中,当表达式是与函数具有相同 cv 非限定类型的非易失性自动对象(函数或 catch 子句参数除外)的名称时返回类型,直接在函数的返回值中构造自动对象即可省略复制/移动操作
  • [...]

并且,来自[class.copy]/32 [强调我的]:

当满足删除复制/移动操作的条件时,但不满足异常声明,并且要复制的对象由左值指定,或者当返回语句中的表达式是 a(可能是括号) id-expression 来命名一个对象,该对象具有在最里面的封闭函数或 lambda 表达式的主体或参数声明子句中声明的自动存储持续时间,重载决议以选择副本的构造函数首先执行,就好像对象由右值指定

但是[class.temporary]/1 仍然对对象的省略副本设置了相同的语义限制,就好像该副本实际上没有被省略一样 [emphasis mine]

[..] 即使临时对象的创建未被评估(子句 [expr])或以其他方式避免([class.copy]),所有语义限制都应得到尊重,就好像临时对象具有被创建然后被销毁

这样,即使对于复制省略是合格的(并被执行)的情况,来自一个命名对象的转换序列,例如对 NRVO 可行的,也需要通过重载决议来找到(可能被省略的)转换构造函数,并且会从通过重载决议开始就好像对象是由右值指定的。这意味着,在 C++14 中,以下是格式良好的

auto aa{getA()};  // OK, and copy most likely elided.

而以下是格式错误的

auto bb{getB()};  // error: use of deleted function 'B::B(B&&)'    

因为重载解析会在将return b; 中的b 中的getB() 中的b 视为右值的步骤中找到已声明但已删除的B 的移动构造函数。对于A,不存在移动构造函数存在,这意味着return a; 中的getA() 中的a 的重载决议将a 作为右值将失败,并且此后,没有此扭结的重载决议将成功找到 A 的复制构造函数(随后将被省略)。

C++17

现在,在 C++17 中,复制省略通过临时对象的延迟(结束完全省略)实现的概念变得更加强大,特别是添加了 [class.temporary]/3 [emphasis mine]:

当类类型 X 的对象被传递给或从函数返回时,如果 X 的每个复制构造函数、移动构造函数和析构函数都是平凡的或已删除的,并且 X 具有至少一个未删除的复制或移动构造函数,实现允许创建一个临时对象来保存函数参数或结果对象临时对象分别由函数参数或返回值构造,函数的参数或返回对象被初始化为通过使用未删除的普通构造函数来复制临时对象即使该构造函数不可访问或不会被重载决议选择来执行对象的复制或移动)。

这有很大的不同,因为现在可以为 getB() 执行复制省略,而无需通过返回值重载解析的特殊规则(之前选择了删除的移动构造函数),因此这两个都是格式正确的在 C++17 中:

auto aa(getA());  // OK, copy elided.
auto bb(getB());  // OK, copy elided.

C++20

C++20 实现了P1825R0,它甚至允许more implicit moves,扩展了移动构造或赋值可能发生的情况,即使乍一看,期望复制构造/赋值(可能省略)。


总结

关于移动急切(过度复制)的相当复杂的规则可能会产生一些意想不到的效果,如果设计人员想要确保类型不会遇到已删除的移动构造函数或移动赋值运算符优先的极端情况在对未删除的复制构造函数或复制赋值运算符进行重载解析时,与声明和定义它们相比,最好确保没有可用于重载解析的移动 ctor/赋值运算符(对于这些情况)明确删除。但是,此参数不适用于移动 ctor/复制赋值运算符,因为:

  • 该标准不包含类似的复制渴望(过度移动),并且
  • 没有复制构造函数或复制赋值操作符的类是不存在的,从重载决议中移除这些基本上(3)只有在 C++20 中使用 requires 子句才有可能。

作为一个例子(可能是一个 GCC 回归错误),对于非语言律师来说,很难正确地获取这些规则,GCC 主干目前拒绝以下 C++20 程序 (DEMO):

// B as above
B getB() {
    B b{};
    return b;
}

带有错误信息

 error: use of deleted function 'B::B(B&&)'

在这种情况下,如果B 删除了它的移动ctor,人们会期望在上面选择一个副本(可能被省略)。如有疑问,请确保移动 ctor 和赋值运算符不参与(即存在)重载决议。

(3) 可以声明一个已删除的赋值运算符重载了 const- 和 ref 限定符,例如 const A&amp; operator=(const A&amp;) const &amp;&amp; = delete;,这在重载解决方案期间很少成为可行的候选者(赋值 to const rvalue),这将保证不存在其他非常量和 &amp; 限定的重载,否则这些重载可能是有效的重载候选者。

【讨论】:

  • 很好地解释了为什么在某些版本(显然还有一些编译器)中未声明而不是删除移动构造函数是/很重要的。我还要补充一点:没有复制构造函数或复制赋值运算符的类是不存在的。在 C++20 添加约束之前,没有不参与重载决议的复制构造函数或复制赋值运算符之类的东西。
猜你喜欢
  • 2020-04-07
  • 1970-01-01
  • 2022-01-04
  • 2017-11-20
  • 2011-05-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多