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

新手上路,请多包涵

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

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

    return *this;
}

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

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

原文由 Seth Carnegie 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 637
2 个回答

首先, 复制和交换 并不总是实现复制分配的正确方法。几乎可以肯定,在 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;
}

如果您自行分配两个移出(空) 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;
}

上面的实现允许自赋值,但是 *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 可能持有互斥锁或文件的打开状态,则客户端可以合理地期望移动分配的 lhs 上的那些资源会立即释放,因此这种实现可能会出现问题。

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

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

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

< /更新 >

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

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

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

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

原文由 Howard Hinnant 发布,翻译遵循 CC BY-SA 4.0 许可协议

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

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

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

对于任何一种类型的直接分配,标准都不是检查自我分配,而是确保自我分配不会导致崩溃和烧毁。 Generally, no one explicitly does x = x or y = std::move(y) calls, but aliasing, especially through multiple functions, may lead a = b or c = std::move(d) into being自我分配。对自赋值的显式检查,即 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 的非限定调用起作用。)容器类型还应该添加一个 public 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;
    }
    //...
};

When other and this are distinct, other is emptied by the move to temp and stays that way.然后 this 将其旧资源丢失给 temp 同时获得 other 最初持有的资源。然后 --- 的旧资源在 temp this 被杀死时被杀死。

当发生自赋值时,清空 othertemp 清空 this 。然后目标对象在 tempthis 交换时取回其资源。 temp 的死亡声明了一个空对象,这实际上应该是一个空操作。 this / other 对象保留其资源。

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

原文由 CTMacUser 发布,翻译遵循 CC BY-SA 3.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题