移动赋值运算符和`if(this!=&rhs)`


125

在类的赋值运算符中,通常需要检查被赋值的对象是否是调用对象,因此您无需搞砸:

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

    return *this;
}

移动分配运算符是否需要相同的东西?有没有一种情况this == &rhs是对的?

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

12
与被问到的Q无关,只是为了使新用户在时间轴上读到该Q(我知道Seth已经知道这一点)不会有错误的主意,Copy and Swap是实现Copy Assignment Operator的正确方法,其中不需要检查自我分配等等。
Alok保存2012年

5
@VaughnCato :A a; a = std::move(a);
Xeo 2012年

11
@VaughnCato使用std::move是正常的。然后考虑别名,当您深入到调用栈中时,您有一个对的引用T,另一个对T...的引用。您要在此处检查身份吗?您是否要查找第一个调用(或多个调用),在该文档中无法两次传递相同参数的记录将静态证明这两个引用没有别名?还是会让自我分配发挥作用?
吕克·丹顿

2
@LucDanton我希望在赋值运算符中使用一个断言。如果使用std :: move的方式可能导致右值自赋值,那么我认为这应该是一个错误。
沃恩·卡托

4
@VaughnCato自交换是正常的一个位置是在- std::sortstd::shuffle- 每次交换数组的ith和jth元素时都无需i != j先检查的情况。(std::swap根据移动分配执行。)
Quuxplusone,2014年

Answers:


142

哇,这里要清理的东西太多了...

首先,“ 复制和交换”并非始终是实现“复制分配”的正确方法。几乎可以肯定的是dumb_array,这是次优的解决方案。

使用复制和交换dumb_array是把与最大特征的最昂贵的操作在底部层的一个典型的例子。对于想要最完整功能并愿意支付性能损失的客户而言,它是完美的选择。他们得到了他们想要的东西。

但这对于不需要最完整功能的客户来说却是灾难性的。对于他们dumb_array来说,这只是他们不得不重写的另一款软件,因为它太慢了。如果dumb_array设计不同,它可以使两个客户满意而不会折衷任何一个客户。

满足两个客户端的关键是在最低级别上构建最快的操作,然后在API之上添加API以获得更全面的功能,而费用更高。也就是说,您需要强有力的例外保证,罚款,您为此付费。你不需要吗 这是一个更快的解决方案。

让我们具体点:这是快速,基本的例外保证Copy Assignment运算符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)仅对移出的值进行自移动分配。在我们的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;
}

如果您自分配两个从(空)移来dumb_array的值,则除了在程序中插入无用的指令外,您不会做任何错误的事情。可以对绝大多数对象进行相同的观察。

<更新资料>

我对这个问题进行了更多的思考,并改变了我的立场。我现在认为分配应该容忍自我分配,但是副本分配和移动分配的职位条件是不同的:

对于副本分配:

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

上述实施容忍自我分配,但是*thisother最终成为自招分配后一个零大小的数组,不管是什么的原值*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可以保持互斥锁或文件的打开状态,则客户端可以合理地期望立即释放移动分配的lh上的那些资源,因此此实现可能会出现问题。

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

取决于硬件,这三种实现方式的成本都可能不同:分支的价格如何?登记册是否很多?

得出的结论是,与自我复制分配不同,自我移动分配不必保留当前值。

</更新>

受Luc Danton评论的启发,进行了最后(希望如此)的编辑:

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

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

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


6
我不知道这个答案的感觉。它看起来像实现这样的类(非常明确地管理它们的内存)是一件常见的事情。这是真的,当你写这样一类一要非常非常小心异常安全保证,并找到甜蜜点的界面简洁而方便,但问题好像是问的一般建议。
Luc Danton '02

是的,我绝对不使用复制和交换,因为对于管理资源和事物的类来说,这是浪费时间(为什么还要为您的所有数据制作另一个完整副本?)。谢谢,这回答了我的问题。
塞斯·卡内基

5
Downvoted的建议,布展任务,从自身应不断断言失败或产生“未指定”的结果。自我分配实际上是最容易解决的情况。如果您的课程在上崩溃std::swap(x,x),那么为什么我应该相信它可以正确处理更复杂的操作?
Quuxplusone

1
@Quuxplusone:正如我对答案所做的更新中所述,我已经就断言失败与您达成了一致。就目前std::swap(x,x)而言,即使产生未指定的结果,它也可以正常工作x = std::move(x)。试试吧!你不必相信我。
Howard Hinnant 2014年

@HowardHinnant好点,swap工作只要x = move(x)叶子x在任何举动-进入-能状态。并定义了std::copy/ std::move算法,以便在无操作副本上已经产生未定义的行为(哎呀; 20岁memmove的小伙子没事了,但是std::move没有!)。因此,我想我还没有想到要进行自我分配的“灌篮”。但是,显然,自赋值在实际代码中会发生很多事情,无论标准是否得到了祝福。
Quuxplusone 2014年

11

首先,您错误地获得了移动分配运算符的签名。由于移动从源对象窃取资源,因此源必须是非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,那么您必须提供自己的交换代码;标准代码调用该类型的移动代码,这将导致针对移动定制的类型进行无限的相互递归。

像析构函数一样,交换函数和移动操作应尽可能不被抛出,并且可能被标记为此类(在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

当自赋值发生,排空other,以temp清空this为好。然后,目标对象在进行tempthis交换时取回其资源。死亡temp宣告为空物体,实际上应该是空运。该this/ other对象保持其资源。

只要移动构造和交换也都不会抛出移动分配。在自我分配过程中保持安全的代价是,对于低类型的指令,还有更多的指令,应该通过释放调用将其淹没。


在调用delete第二个代码块之前,是否需要检查是否已分配任何内存 ?
user3728501 2015年

3
您的第二个代码示例,即没有进行自我分配检查的复制分配运算符,是错误的。std::copy如果源和目标范围重叠(包括它们重合的情况),将导致不确定的行为。参见C ++ 14 [alg.copy] / 3。
MM

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)this == &other确实会成立。


您能解释一下a = std::move(a)不工作会使班级无法工作std::vector吗?例?
保罗·卢卡斯

@ PaulJ.Lucas std::vector<T>::erase除非TMoveAssignable,否则不允许调用。(除了IIRC之外,在C ++ 14中将某些MoveAssignable要求放宽到MoveInsertable。)
Luc Danton

好的,所以T必须是MoveAssignable,但是为什么要erase()依赖于将元素移动到自身呢?
保罗·卢卡斯

@ PaulJ.Lucas这个问题没有令人满意的答案。一切归结为“不违约”。
吕克·丹顿

2

operator=编写当前函数时,由于已经创建了rvalue-reference参数const,所以无法“窃取”指针并更改传入的rvalue引用的值...您根本无法更改它,只能从中读取。如果您像普通的lvaue-reference 方法中那样开始deletethis对象中调用指针等,我只会看到一个问题operator=,但是那样会破坏rvalue-version的意义……也就是说,使用rvalue版本基本上执行通常留给const-lvalue operator=方法执行的相同操作似乎是多余的。

现在,如果您定义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
}

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


请注意,“ a = std :: move(a)”是发生这种情况的简单方法。您的答案虽然有效。
沃恩·卡托

1
完全同意这是最简单的方法,尽管我认为大多数人不会故意这样做:-) ...请记住,尽管如果rvalue-reference是const,那么您只能从中读取内容,因此唯一需要请检查您是否决定operator=(const T&&)执行与this典型operator=(const T&)方法相同的重新初始化,而不是执行交换式操作(即,窃取指针等,而不是制作深层副本)。
杰森

1

我的回答仍然是,移动分配不必针对自我设定而省下来,但它有不同的解释。考虑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());

这对于整数是可以的,但是我不相信您可以使用move语义来完成类似的工作。

结论:将分配移动到对象本身是不正确的,您必须当心。

小更新。

  1. 我不同意霍华德的观点,这是一个坏主意,但仍然-我认为“移出”对象的自我移动分配应该可行,因为 swap(x, x)应该可行。算法喜欢这些东西!当拐角处的盒子开始工作时,总是很好。(而且我还没有看到它不是免费的情况。但这并不意味着它不存在)。
  2. 这是在libc ++中实现unique_ptrs分配的方式: unique_ptr& operator=(unique_ptr&& u) noexcept { reset(u.release()); ...} 对于自移动分配是安全的。
  3. 核心准则认为自行分配应该可以。

0

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


Myclass obj; std :: move(obj)= std :: move(obj);
little_monster
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.