137

在类的赋值运算符中,您通常需要检查被分配的对象是否是调用对象,这样您就不会搞砸了:

Class& Class::operator=(const Class& rhs) {
    if (this != &rhs) {
        // do the assignment
    }

    return *this;
}

移动赋值运算符是否需要相同的东西?有没有一种情况this == &rhs会是真的?

? Class::operator=(Class&& rhs) {
    ?
}
4

6 回答 6

155

哇,这里有这么多要清理的东西......

首先,复制和交换并不总是实现复制分配的正确方法。几乎可以肯定,在 的情况下dumb_array,这是一个次优解决方案。

Copy and Swap的使用dumb_array是一个典型的例子,将最昂贵的操作与最完整的功能放在底层。它非常适合想要最完整功能并愿意支付性能损失的客户。他们得到了他们想要的。

但对于不需要最完整功能而是寻求最高性能的客户来说,这是灾难性的。对他们dumb_array来说,这只是另一个他们必须重写的软件,因为它太慢了。如果dumb_array设计不同,它可以满足两个客户,而不会对任何一个客户做出妥协。

满足这两个客户的关键是在最低级别构建最快的操作,然后在其之上添加 API 以获取更完整的功能,但成本更高。即,您需要强大的例外保证,很好,您需要为此付费。你不需要吗?这是一个更快的解决方案。

让我们具体一点:这是快速、基本的异常保证复制赋值运算符dumb_array

dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)
    {
        if (mSize != other.mSize)
        {
            delete [] mArray;
            mArray = nullptr;
            mArray = other.mSize ? new int[other.mSize] : nullptr;
            mSize = other.mSize;
        }
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }
    return *this;
}

解释:

您可以在现代硬件上做的更昂贵的事情之一就是去堆。你可以做的任何事情来避免去堆是花费时间和精力。的客户dumb_array可能希望经常分配相同大小的数组。当他们这样做时,您需要做的就是一个memcpy(隐藏在 下std::copy)。您不想分配一个相同大小的新数组,然后释放相同大小的旧数组!

现在,对于真正需要强大异常安全性的客户:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    swap(lhs, rhs);
    return lhs;
}

或者,如果您想利用 C++11 中的移动赋值,则应该是:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    lhs = std::move(rhs);
    return lhs;
}

如果dumb_array的客户重视速度,他们应该致电operator=. 如果他们需要强大的异常安全性,他们可以调用通用算法,这些算法将适用于各种各样的对象,并且只需要实现一次。

现在回到最初的问题(此时有一个类型-o):

Class&
Class::operator=(Class&& rhs)
{
    if (this == &rhs)  // is this check needed?
    {
       // ...
    }
    return *this;
}

这实际上是一个有争议的问题。有些人会说是的,绝对的,有些人会说不。

我个人的意见是不,你不需要这个检查。

理由:

当一个对象绑定到一个右值引用时,它是以下两种情况之一:

  1. 一个临时的。
  2. 调用者希望您相信的对象是暂时的。

如果您对一个实际临时对象的引用,那么根据定义,您对该对象具有唯一的引用。它不可能被整个程序中的其他任何地方引用。即this == &temporary 不可能

现在,如果您的客户对您撒谎并承诺您会得到一个临时的,而实际上您并没有,那么客户有责任确保您不必在意。如果你想非常小心,我相信这将是一个更好的实现:

Class&
Class::operator=(Class&& other)
{
    assert(this != &other);
    // ...
    return *this;
}

即,如果您传递一个自我引用,这是客户端的一个应该修复的错误。

为了完整起见,这里是一个移动赋值运算符dumb_array

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

在移动赋值的典型用例中,*this它将是一个移动对象,因此delete [] mArray;应该是一个空操作。实现尽可能快地在 nullptr 上进行删除至关重要。

警告:

有些人会争辩说这swap(x, x)是一个好主意,或者只是一个必要的邪恶。如果交换进入默认交换,这可能会导致自移动分配。

我不同意这swap(x, x)一个好主意。如果在我自己的代码中发现它,我会认为它是一个性能错误并修复它。但是,如果您想允许它,请意识到swap(x, x)self-move-assignemnet 仅对已移动的值进行。在我们的dumb_array示例中,如果我们简单地省略断言,或者将其约束到移动的情况下,这将是完全无害的:

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other || mSize == 0);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

如果您自行分配两个 move-from (empty) dumb_array,除了在程序中插入无用的指令之外,您不会做任何不正确的事情。对于绝大多数物体都可以进行同样的观察。

<更新>

我对这个问题有了更多的思考,并稍微改变了我的立场。我现在认为assignment应该可以容忍self assignment,但是copy assignment和move assignment的post条件不同:

对于复制分配:

x = y;

应该有一个后置条件,即y不应更改 的值。当&x == &y这个后置条件转换为:自我复制分配应该对 的值没有影响x

对于移动分配:

x = std::move(y);

应该有一个y具有有效但未指定状态的后置条件。&x == &y然后,此后置条件转换为:x具有有效但未指定的状态。即自移动分配不必是空操作。但它不应该崩溃。这个后置条件与允许正常工作是一致的swap(x, x)

template <class T>
void
swap(T& x, T& y)
{
    // assume &x == &y
    T tmp(std::move(x));
    // x and y now have a valid but unspecified state
    x = std::move(y);
    // x and y still have a valid but unspecified state
    y = std::move(tmp);
    // x and y have the value of tmp, which is the value they had on entry
}

只要x = std::move(x)不崩溃,上述工作即可。它可以x处于任何有效但未指定的状态。

我看到了三种方法来编程移动赋值运算符dumb_array来实现这一点:

dumb_array& operator=(dumb_array&& other)
{
    delete [] mArray;
    // set *this to a valid state before continuing
    mSize = 0;
    mArray = nullptr;
    // *this is now in a valid state, continue with move assignment
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

上面的实现允许自赋值,但是在自移动赋值*this之后other最终是一个零大小的数组,不管原始值*this是什么。这可以。

dumb_array& operator=(dumb_array&& other)
{
    if (this != &other)
    {
        delete [] mArray;
        mSize = other.mSize;
        mArray = other.mArray;
        other.mSize = 0;
        other.mArray = nullptr;
    }
    return *this;
}

上述实现与复制赋值运算符一样,通过使其成为无操作来容忍自赋值。这也很好。

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

仅当dumb_array不包含应“立即”销毁的资源时,上述内容才可以。例如,如果唯一的资源是内存,上面的就可以了。如果dumb_array可能持有互斥锁或文件的打开状态,则客户端可以合理地期望移动分配的 lhs 上的那些资源会立即释放,因此这种实现可能会出现问题。

第一个的成本是两个额外的商店。第二个成本是测试和分支。两者都有效。两者都满足 C++11 标准中表 22 MoveAssignable 要求的所有要求。第三个也适用于非内存资源问题。

根据硬件的不同,这三种实现可能有不同的成本:一个分支有多贵?有很多寄存器还是很少?

要点是,与自复制分配不同,自移动分配不必保留当前值。

</更新>

受 Luc Danton 评论启发的最后一次(希望如此)编辑:

如果您正在编写一个不直接管理内存的高级类(但可能有这样做的基类或成员),那么移动分配的最佳实现通常是:

Class& operator=(Class&&) = default;

这将依次分配每个基地和每个成员,并且不包括this != &other检查。假设您的基础和成员之间不需要维护不变量,这将为您提供最高的性能和基本的异常安全性。对于要求强大的异常安全性的客户,请将他们指向strong_assign.

于 2012-02-17T03:40:23.237 回答
12

首先,您将移动赋值运算符的签名弄错了。由于移动会从源对象中窃取资源,因此源必须是非constr 值引用。

Class &Class::operator=( Class &&rhs ) {
    //...
    return *this;
}

请注意,您仍然通过(非constl值引用返回。

对于任何一种类型的直接分配,标准不是检查自我分配,而是确保自我分配不会导致崩溃和烧毁。通常,没有人明确地做x = xy = std::move(y)调用,但是别名,特别是通过多个函数,可能会导致a = bc = std::move(d)成为自赋值。对自赋值的显式检查,即this == &rhs当为 true 时跳过函数的核心是确保自赋值安全的一种方法。但这是最糟糕的方式之一,因为它优化了(希望如此)罕见的情况,而它是更常见情况的反优化(由于分支和可能的缓存未命中)。

现在,当(至少)其中一个操作数是直接临时对象时,您将永远不会有自赋值场景。有些人主张假设这种情况并为此优化代码,以至于当假设错误时代码变得愚蠢。我说将同一对象检查转储给用户是不负责任的。我们不会为复制分配提出这个论点。为什么要反转移动分配的位置?

让我们举一个例子,从另一个受访者那里改变:

dumb_array& dumb_array::operator=(const dumb_array& other)
{
    if (mSize != other.mSize)
    {
        delete [] mArray;
        mArray = nullptr;  // clear this...
        mSize = 0u;        // ...and this in case the next line throws
        mArray = other.mSize ? new int[other.mSize] : nullptr;
        mSize = other.mSize;
    }
    std::copy(other.mArray, other.mArray + mSize, mArray);
    return *this;
}

此复制分配可以优雅地处理自分配,而无需显式检查。如果源和目标大小不同,则在复制之前释放和重新分配。否则,仅完成复制。自分配没有得到优化的路径,它被转储到与源和目标大小开始相等时相同的路径。当两个对象相等时(包括当它们是同一个对象时),复制在技术上是不必要的,但这是不进行相等检查(按值或按地址)时的代价,因为所述检查本身最浪费的时间。注意这里的对象自赋值会引起一系列元素级的自赋值;元素类型必须是安全的。

与其源示例一样,此复制分配提供了基本的异常安全保证。如果您想要强保证,请使用原始复制和交换查询中的统一分配运算符,它处理复制和移动分配。但是这个例子的重点是降低安全等级以提高速度。(顺便说一句,我们假设各个元素的值是独立的;与其他值相比,不存在限制某些值的不变约束。)

让我们看一下同一类型的移动赋值:

class dumb_array
{
    //...
    void swap(dumb_array& other) noexcept
    {
        // Just in case we add UDT members later
        using std::swap;

        // both members are built-in types -> never throw
        swap( this->mArray, other.mArray );
        swap( this->mSize, other.mSize );
    }

    dumb_array& operator=(dumb_array&& other) noexcept
    {
        this->swap( other );
        return *this;
    }
    //...
};

void  swap( dumb_array &l, dumb_array &r ) noexcept  { l.swap( r ); }

需要自定义的可交换类型应该具有swap在与该类型相同的命名空间中调用的无两个参数的函数。(命名空间限制允许对 swap 的非限定调用起作用。)容器类型还应该添加一个公共swap成员函数以匹配标准容器。如果swap未提供成员,则swap可能需要将自由函数标记为可交换类型的朋友。如果您自定义要使用的招式swap,则必须提供自己的交换代码;标准代码调用类型的移动代码,这将导致移动自定义类型的无限相互递归。

与析构函数一样,如果可能的话,交换函数和移动操作应该永远不会被抛出,并且可能会被标记为这样(在 C++11 中)。标准库类型和例程对不可抛出的移动类型进行了优化。

第一个版本的移动分配履行了基本合同。源的资源标记被传输到目标对象。旧资源不会泄露,因为源对象现在管理它们。并且源对象处于可用状态,可以对其应用进一步的操作,包括赋值和销毁。

请注意,此移动分配对于自分配是自动安全的,因为swap调用是。它也是异常安全的。问题是不必要的资源保留。目标的旧资源在概念上不再需要,但在这里它们仍然存在,只是源对象可以保持有效。如果源对象的预定销毁还有很长的路要走,我们就是在浪费资源空间,或者更糟糕的是,如果总资源空间有限并且其他资源请求将在(新)源对象正式死亡之前发生。

这个问题引起了当前有争议的关于移动分配期间自我定位的大师建议。在不占用资源的情况下编写移动分配的方法如下:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        delete [] this->mArray;  // kill old resources
        this->mArray = other.mArray;
        this->mSize = other.mSize;
        other.mArray = nullptr;  // reset source
        other.mSize = 0u;
        return *this;
    }
    //...
};

源被重置为默认条件,而旧的目标资源被销毁。在自我分配的情况下,您当前的对象最终会自杀。绕过它的主要方法是用一个if(this != &other)块包围动作代码,或者把它搞砸,让客户吃一个assert(this != &other)初始行(如果你感觉不错的话)。

另一种方法是研究如何在没有统一分配的情况下使复制分配具有强异常安全性,并将其应用于移动分配:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        dumb_array  temp{ std::move(other) };

        this->swap( temp );
        return *this;
    }
    //...
};

何时otherthis是不同的,other被移到temp并保持这种方式清空。然后在得到原本持有的this资源的同时又失去了它的旧资源。然后是什么时候被杀死的旧资源。tempotherthistemp

当自我分配发生时,清空otherto 也temp清空this。然后目标对象在什么时候取回它的资源tempthis交换。死亡temp声明一个空对象,这实际上应该是一个空操作。this/other对象保留其资源。

只要移动构造和交换也是,移动分配就应该永远不会被抛出。在自赋值过程中保持安全的代价是多于低级类型的指令,这些指令应该被释放调用淹没。

于 2012-03-13T17:06:38.690 回答
6

我属于那些想要自赋值安全运算符的阵营,但不想在operator=. 事实上,我什至根本不想实现operator=,我希望默认行为“开箱即用”。最好的特殊会员是免费的。

话虽如此,标准中存在的 MoveAssignable 要求描述如下(来自 17.6.3.1 模板参数要求 [utility.arg.requirements],n3290):

表达式 返回类型 返回值 后置条件
t = rv T& tt相当于赋值前rv的值

其中占位符被描述为:“ t[是] T 类型的可修改左值;” 并且 "rv是 T 类型的右值;"。请注意,这些是对用作标准库模板参数的类型的要求,但在标准中的其他地方我注意到移动分配的每个要求都与此类似。

这意味着a = std::move(a)必须是“安全的”。如果您需要的是身份测试(例如this != &other),那就去做吧,否则您甚至无法将您的对象放入std::vector!(除非您不使用那些确实需要 MoveAssignable 的成员/操作;但没关系。)请注意,对于前面的示例a = std::move(a),thenthis == &other确实成立。

于 2012-02-17T04:12:34.657 回答
2

在编写当前operator=函数时,由于您已经创建了 rvalue-reference 参数const,因此您无法“窃取”指针并更改传入的 rvalue 引用的值……您根本无法更改它,您只能从中读取。我只会看到一个问题,如果你开始delete在你的对象中调用指针等,this就像你在一个普通的 lvaue 引用operator=方法中一样,但这有点违背了右值版本的观点......也就是说,它会使用右值版本基本上执行通常留给const-lvalueoperator=方法的相同操作似乎是多余的。

现在,如果您将您定义operator=为采用非const右值引用,那么我可以看到需要检查的唯一方法是将this对象传递给有意返回右值引用而不是临时值的函数。

例如,假设有人试图编写一个operator+函数,并混合使用右值引用和左值引用,以“防止”在对象类型的某些堆叠加法操作期间创建额外的临时对象:

struct A; //defines operator=(A&& rhs) where it will "steal" the pointers
          //of rhs and set the original pointers of rhs to NULL

A&& operator+(A& rhs, A&& lhs)
{
    //...code

    return std::move(rhs);
}

A&& operator+(A&& rhs, A&&lhs)
{
    //...code

    return std::move(rhs);
}

int main()
{
    A a;

    a = (a + A()) + A(); //calls operator=(A&&) with reference bound to a

    //...rest of code
}

现在,根据我对右值引用的理解,不鼓励执行上述操作(即,您应该只返回一个临时的,而不是右值引用),但是,如果有人仍然这样做,那么您需要检查一下确保传入的右值引用没有引用与this指针相同的对象。

于 2012-02-17T03:19:58.903 回答
2

我的回答仍然是移动分配不一定要避免自我分配,但它有不同的解释。考虑 std::unique_ptr。如果我要实现一个,我会做这样的事情:

unique_ptr& operator=(unique_ptr&& x) {
  delete ptr_;
  ptr_ = x.ptr_;
  x.ptr_ = nullptr;
  return *this;
}

如果你看一下Scott Meyers 的解释,他会做类似的事情。(如果你徘徊为什么不做交换 - 它有一个额外的写入)。这对于自我分配是不安全的。

有时这是不幸的。考虑将所有偶数移出向量:

src.erase(
  std::partition_copy(src.begin(), src.end(),
                      src.begin(),
                      std::back_inserter(even),
                      [](int num) { return num % 2; }
                      ).first,
  src.end());

这对整数来说没问题,但我不相信你可以用移动语义来做这样的事情。

总结:将分配移动到对象本身是不行的,你必须注意它。

小更新。

  1. 我不同意霍华德,这是一个坏主意,但仍然 - 我认为“移出”对象的自我移动分配应该有效,因为swap(x, x)应该有效。算法喜欢这些东西!当角落案例正常工作时总是很好。(而且我还没有看到它不是免费的情况。但这并不意味着它不存在)。
  2. 这就是在 libc++ 中分配 unique_ptrs 的方式: unique_ptr& operator=(unique_ptr&& u) noexcept { reset(u.release()); ...} 自移动分配是安全的。
  3. 核心指南认为应该可以自行分配。
于 2017-01-19T20:39:39.670 回答
0

有一种情况是(this == rhs)我能想到的。对于此语句: Myclass obj; std::move(obj) = std::move(obj)

于 2017-08-17T10:45:45.473 回答