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

146
在一个类的赋值运算符中,通常需要检查被赋值的对象是否为调用对象,以免出现问题:
Class& Class::operator=(const Class& rhs) {
    if (this != &rhs) {
        // do the assignment
    }

    return *this;
}

你需要对移动赋值运算符也做同样的事情吗?是否存在一种情况,this == &rhs 会是真的?

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

12
与问题无关,仅为了避免后来读到此问题的新用户(我知道Seth已经知道此事)产生错误想法,复制并交换是实现复制赋值运算符的正确方式,其中您不需要检查自我赋值等。 - Alok Save
5
@VaughnCato: A a; a = std::move(a);. - Xeo
12
使用std::move是正常的。然后考虑别名问题,当你深入调用堆栈并且你有一个对T的引用以及另一个对T的引用时...你要在这里检查身份吗?你想找到第一个调用(或调用),在那里记录无法两次传递相同参数的文档将静态证明这两个引用不会别名吗?还是你让自我赋值正常工作? - Luc Danton
2
@LucDanton 我更喜欢在赋值运算符中使用断言。如果 std::move 被使用的方式可能导致出现 rvalue 自我赋值,我会认为这是一个应该被修复的 bug。 - Vaughn Cato
4
在使用 std::sortstd::shuffle 时,自我交换是很常见的操作。当你在交换数组中第 i 个和第 j 个元素时,且没有先检查 i != j 条件时,这种操作就会发生。(std::swap 是通过移动赋值实现的。) - Quuxplusone
显示剩余3条评论
6个回答

167
首先,复制和交换并不总是实现复制分配的正确方法。在dumb_array的情况下,这几乎肯定是一个次优的解决方案。
对于dumb_array使用复制和交换是将最昂贵的操作与最完整的功能放在底层的经典示例。它非常适合那些想要最完整功能并愿意承担性能损失的客户。他们得到了他们想要的东西。
但是对于不需要最完整功能而寻求最高性能的客户来说,这是灾难性的。对于他们来说,dumb_array只是另一段他们必须重写的软件,因为它太慢了。如果dumb_array被设计得不同,它就可以在不妥协任何客户的情况下满足两个客户。
重点在于以最低级别构建最快的操作,然后在其上添加API以获得更多功能,但需要付出更多代价。即,如果您需要强大的异常保证,那么您需要为此付费。如果您不需要它?那么这里有一个更快的解决方案。
让我们具体一些:这是的快速基本异常保证复制赋值运算符。
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;
}

如果的客户端看重速度,他们应该调用。 如果他们需要强大的异常安全性,有一些通用算法可以调用,这些算法可以在各种对象上工作,并且只需要实现一次即可。

现在回到原始问题(此时存在拼写错误):

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& 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& 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的原始值是什么,它们最终都会成为大小为零的数组。这没关系。
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

6
我对这个答案感到矛盾。它让人觉得实现这样的类(非常明确地管理它们的内存)是一件很普遍的事情。当你确实编写这样的类时,必须非常小心地考虑异常安全保证和找到界面简洁而方便的平衡点,但问题似乎是在寻求一般性建议。 - Luc Danton
5
因为建议在自我移动赋值时断言失败或产生“未指定”结果而被踩。自我赋值是最容易正确处理的情况。如果您的类在std::swap(x,x)上崩溃,那么我为什么要相信它能够正确地处理更复杂的操作呢? - Quuxplusone
1
@Quuxplusone:我已经同意你在assert-fail上的观点,正如我答案更新所指出的一样。至于std::swap(x, x),即使当x = std::move(x)产生未指定的结果时,它仍然可以正常工作。试试吧!你不必相信我。 - Howard Hinnant
@HowardHinnant 说得好,只要 x = move(x)x 处于可移动状态,swap 就能正常工作。而且 std::copy/std::move 算法已经被定义为在无操作复制时产生未定义的行为(痛苦;20 年前的 memmove 能正确处理简单情况,但 std::move 却不能!)。所以我想我还没有想到一个关于自赋值的“绝杀”方案。但显然,无论标准是否支持,自赋值在实际代码中都经常发生。 - Quuxplusone
@HowardHinnant,“仅仅3年后?”是的。在过去几年中,我没有遇到任何仍在使用C ++ 2003的行业人士。但如果有什么东西阻碍了C ++标准的行业采用,那可能就是存在"某些std ::类型的move赋值可能会出现虚假失败"(这个问题)。或者“使某些类型的const变量可能会虚假失败”(DR 253)。我正在收集这样的失败列表,以便在今年晚些时候进行演示,即使我没有与可以实际修复这些问题的任何人交流。 - Quuxplusone
显示剩余5条评论

12

首先,您在移动赋值运算符的签名中出现错误。由于移动会从源对象中窃取资源,因此源对象必须是非const右值引用。

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

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

对于任一类型的直接赋值操作,标准不是为了检查自我赋值,而是确保自我赋值不会导致崩溃。通常情况下,没有人明确执行 x = x 或者 y = std::move(y) 操作,但是别名,特别是通过多个函数可能导致 a = b 或者 c = std::move(d) 成为自我赋值。显式检查自我赋值,即 this == &rhs,当为真时跳过函数主体的方法之一可以确保自我赋值的安全性。但这是最糟糕的方法之一,因为它优化了一个(希望)罕见的情况,而对于更常见的情况来说则是一种反优化(由于分支和可能的缓存未命中)。

现在当至少有一个操作数是直接临时对象时,您永远不会遇到自我赋值的情况。有些人主张假设这种情况并对代码进行优化,以至于当假设错误时,代码变得极其愚蠢。我认为,将相同对象检查的责任放在用户身上是不负责任的。我们没有为复制赋值作出这样的论点;为什么要反其道而行之,对移动赋值进行相反的处理呢?

让我们来看一个例子,它改编自另一个回答者:

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

这个复制赋值运算符可以优雅地处理自我赋值,而无需显式检查。如果源对象和目标对象的大小不同,则在复制之前进行释放和重新分配。否则,只执行复制操作。自我赋值没有获得优化路径,它被转储到与源对象和目标对象大小相等时相同的路径中。当两个对象等价(包括它们是相同对象时),复制实际上是不必要的,但这就是在不执行相等性检查(按值或地址)时的代价,因为大多数情况下该检查本身都是浪费的。请注意,此处对象的自我赋值将导致一系列元素级别的自我赋值;元素类型必须安全才能执行此操作。

与其来源示例一样,这个复制赋值运算符提供了基本的异常安全保证。如果您想要强烈的保证,请使用原始的Copy and Swap查询中的统一赋值运算符,它处理复制和移动赋值。但这个例子的重点是通过减少一个安全级别来换取速度。(顺便说一句,我们假设各个元素的值是独立的;即不存在某些值与其他值相比的不变约束。)

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

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失去了原来的资源,获取了other最初持有的资源。然后,this的旧资源在temp结束时被销毁。

当出现自我赋值情况时,other被清空到temp时,this也会被清空。然后,当tempthis交换时,目标对象将重新获取其资源。temp的结束会清除一个空对象,这应该是几乎没有操作的。 this/other对象保留其资源。

只要移动构造和交换也是never-throw,移动赋值就应该也是never-throw的。在自我赋值期间也保持安全的成本比低级类型多了一些指令,但应该被释放调用所覆盖。


在你的第二个代码块中调用 delete 之前,您需要检查是否分配了任何内存吗? - FreelanceConsultant
3
你的第二个代码示例,没有进行自我赋值检查的复制赋值运算符是错误的。如果源范围和目标范围重叠(包括它们相互重合的情况),std::copy 会导致未定义行为。请参阅 C++14 [alg.copy]/3。 - M.M

6

我属于那些希望具备自我分配安全运算符但不想在operator=的实现中编写自我分配检查的阵营。事实上,我甚至不想完全实现operator=,我希望默认行为能够“开箱即用”。最好的特殊成员是免费的。

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

表达式  返回类型  返回值  后置条件
t = rv T& t t等价于赋值之前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 一起工作吗?能举个例子吗? - Paul J. Lucas
@PaulJ.Lucas 调用 std::vector<T>::erase 如果 T 不是 MoveAssignable 是不被允许的。(顺便提一句,在 C++14 中,某些 MoveAssignable 的要求已被放宽为 MoveInsertable。) - Luc Danton
好的,所以 T 必须是可移动赋值的,但为什么 erase() 会依赖将元素移动到“它自己”呢? - Paul J. Lucas
@PaulJ.Lucas 对于那个问题没有令人满意的答案。一切归结为“不要违反合约”。 - Luc Danton

2
根据你当前的 `operator=` 函数的写法,由于你将右值引用参数设为了 `const`,所以没有办法“窃取”指针并更改传入的右值引用的值……你只能读取它,不能更改它。如果在你的 `this` 对象中开始像在普通左值引用的 `operator=` 方法中一样调用 `delete` 等指针,那么可能会出现问题,但这似乎有悖于右值版本的设计目的,因为这样做基本上与通常留给 `const` 左值引用的 `operator=` 方法执行相同的操作,看起来有点多余。
现在,如果你定义了一个非 `const` 的右值引用 `operator=`,那么我唯一能想到需要进行检查的方法是,如果你将 `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指针相同的对象。


请注意,“a=std::move(a)”是解决这种情况的一种简单方法。尽管您的答案也是有效的。 - Vaughn Cato
1
完全同意这是最简单的方法,尽管我认为大多数人不会有意这样做 :-) ... 但请记住,如果rvalue-reference是const,那么您只能从中读取,因此唯一需要进行检查的情况是,如果您在operator =(const T &&)中决定执行与典型的operator =(const T&)方法相同的重新初始化操作,而不是交换式操作(即,窃取指针等而不是进行深层复制)。 - Jason

2

我的回答仍然是移动赋值不必对自我赋值进行保存,但是它有一个不同的解释。考虑std::unique_ptr。如果我要实现一个,我会像这样做:

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

如果你看一下斯科特·迈耶斯的解释,他做了类似的事情。(如果你想知道为什么不使用swap-它有一个额外的写操作)。并且这对于自我赋值是不安全的。
有时这是不幸的。考虑移出向量中所有偶数的情况:
src.erase(
  std::partition_copy(src.begin(), src.end(),
                      src.begin(),
                      std::back_inserter(even),
                      [](int num) { return num % 2; }
                      ).first,
  src.end());

对于整数来说这是可以的,但我不认为你可以使用移动语义使其正常工作。

总之:将对象本身进行移动赋值不行,你必须要注意这一点。

小更新。

  1. 我不同意Howard的观点,这可能是一个坏主意,但我认为“已移出”的对象的自我移动赋值应该是有效的,因为 swap(x, x) 应该是有效的。算法喜欢这些东西!当一个边角案例能够正常工作时,这总是很好的。(虽然我还没有看到过不能免费使用它的情况,但并不意味着不存在这种情况)。
  2. 这就是在libc++中实现unique_ptrs分配的方式:unique_ptr& operator=(unique_ptr&& u) noexcept { reset(u.release()); ...} 对于自我移动赋值是安全的。
  3. 核心指南 认为自我移动赋值应该是可以的。

1

我能想到一种情况,即 (this == rhs)。 对于这个语句: Myclass obj; std::move(obj) = std::move(obj)


Myclass obj; std::move(obj) = std::move(obj); 我的类 对象; std::move(对象) = std::move(对象); - little_monster

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