为什么std::vector有两个赋值运算符?

17

自 2011 年以来,我们都同时拥有复制赋值和移动赋值。然而,这个答案相当有说服力地认为,对于资源管理类,只需要一个赋值运算符。例如,对于 std::vector 来说,它看起来会像这样:

vector& vector::operator=(vector other)
{
  swap(other);
  return*this;
}

重要的是,在这里使用了按值传递的参数。这意味着在函数正文被输入之前,other已经完成了大部分工作(如果可能,通过移动构造函数,否则通过复制构造函数)。因此,这自动正确实现了复制和移动赋值运算符。

如果这是正确的,为什么(至少根据这份文档std::vector没有采用这种方式实现呢?


编辑以解释这是如何工作的。请考虑上面代码中other在以下示例中所发生的情况:

void foo(std::vector<bar> &&x)
{
  auto y=x;             // other is copy constructed
  auto z=std::move(x);  // other is move constructed, no copy is ever made.
  // ...
}

1
这需要进行内存分配。如果被赋值对象具有足够的容量,则从左值复制内容可能不会发生。 - juanchopanza
1
@MicroVirus 是的,它是这样的。参数是一个副本。如果它是一个左值,那么你确实会导致一个可能不必要的分配。另一方面,如果 std::vector 赋值提供了强异常保证,我认为仍然需要进行分配。 - juanchopanza
@MicroVirus 我认为juanchopanza在谈论复制赋值。 - Walter
3
请参考Howard Hinnant在这个问题中给出的答案。他讲解了赋值运算符和移动语义的相关知识。 - juanchopanza
@Walter 我认为(但不确定)标准对于std::vector的移动赋值放弃了强异常保证。如果是这种情况,那么可以避免在拷贝和交换中涉及额外的赋值操作。但我必须去核实一下是否真的是这样。 - juanchopanza
显示剩余5条评论
2个回答

7
如果元素类型是nothrow可复制的,或容器不支持强异常保证,则复制赋值运算符可以在目标对象具有足够容量的情况下避免分配:
vector& operator=(vector const& src)
{
    clear();
    reserve(src.size());  // no allocation if capacity() >= src.size()
    uninitialized_copy_n(src.data(), src.size(), dst.data());
    m_size = src.size();
}

好的,这是一个公正的论点(并且已经在评论中讨论过了)。看起来确实C++标准对于复制赋值没有任何异常保证,但从C++17开始可能会有移动赋值的例外情况(如果分配器允许)。 - Walter

-2

实际上,有三个赋值运算符被定义:

vector& operator=( const vector& other );
vector& operator=( vector&& other );
vector& operator=( std::initializer_list<T> ilist );

你的建议 vector& vector::operator=(vector other) 使用了复制并交换惯用法。这意味着,当调用该运算符时,原始向量将被复制到参数中,复制向量中的每个单独项。然后,此副本将与 this 交换。编译器可能能够省略该复制,但该复制省略是可选的,移动语义是标准的。

您可以使用该惯用法来替换复制赋值运算符:

vector& operator=( const vector& other ) {
    swap(vector{other}); // create temporary copy and swap
    return *this;
}

无论何时复制任何元素都会抛出异常,此函数也会抛出异常。
要实现移动赋值操作符,只需省略复制即可。
vector& operator=( vector&& other ) {
    swap(other);
    return *this;
}

由于swap()从不抛出异常,因此移动赋值运算符也不会抛出异常。

使用移动赋值运算符和匿名临时变量也可以轻松实现initializer_list赋值:

vector& operator=( std::initializer_list<T> ilist ) {
    return *this = vector{ilist};
}

我们使用了移动赋值运算符。因此,当元素实例化中的一个抛出异常时,initializer_list赋值运算符将只会抛出异常。
正如我所说,编译器可能能够省略复制操作以进行复制赋值。但是,编译器没有义务实现该优化。它有义务实现移动语义。

你真的读了我帖子中引用的答案吗?重点是你的两个移动和复制赋值实现在我的问题中被一个实现完美地实现了。 - Walter
代码说明编译器应在此处使用复制构造函数。真的吗?你能引用标准中的语句证明吗?在类似于“bar x=std::move(y)”的情况下,不需要进行复制。 - Walter
@Walter 好的,让我们深入探讨一下。由于长度原因,我必须将答案分为三个部分。你的例子 bar x = std::move(y) 是一个初始化。使用这种语法,编译器必须检查是否存在可能匹配的赋值运算符,以便语法合法,但实际上只会使用移动构造函数。 - cdonat
这与 x = std::move(y) 不同。在这种情况下,编译器必须使用赋值运算符,因为它不是初始化。std::move() 将返回给定对象的右值引用。然后编译器将选择适当的赋值运算符。在您的情况下,那就是 operator = (vector other)。这意味着它有一个右值引用到一个对象,它将不得不将其复制到参数对象中。 - cdonat
这里使用复制省略技术,编译器可以跳过复制过程,直接将栈上的空间作为参数的目标进行移动。因此,如果编译器支持复制省略,它将会创建你所描述的代码,但如果不支持,则会创建复制向量的代码。 - cdonat
显示剩余3条评论

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