为什么有些人在移动赋值时使用交换(swap)?

77
例如,stdlibc++拥有以下内容:
unique_lock& operator=(unique_lock&& __u)
{
    if(_M_owns)
        unlock();
    unique_lock(std::move(__u)).swap(*this);
    __u._M_device = 0;
    __u._M_owns = false;
    return *this;
}

为什么不直接将这两个__u成员直接赋值给*this呢?交换难道不意味着__u被赋值为*this的成员,只有稍后再将其赋值为0和false...这种情况下,交换是在做无用功。我错过了什么吗? (unique_lock::swap只是对每个成员执行std::swap操作)

4个回答

136

这是我的错(半开玩笑,半认真)。

当我第一次展示移动赋值运算符的实现示例时,我只使用了swap。然后有个聪明的家伙(我记不清是谁了)指出,在赋值之前销毁lhs的副作用可能很重要(例如您示例中的unlock())。所以我停止使用swap进行移动赋值。但是使用swap的历史仍然存在并且仍在继续。

在这个例子中没有理由使用swap。它比您建议的方法效率低。实际上,在libc ++中,我正是按照您的建议做的:

unique_lock& operator=(unique_lock&& __u)
    {
        if (__owns_)
            __m_->unlock();
        __m_ = __u.__m_;
        __owns_ = __u.__owns_;
        __u.__m_ = nullptr;
        __u.__owns_ = false;
        return *this;
    }

一般而言,移动赋值运算符应该:

  1. 销毁可见资源(但可以保存实现细节资源)。
  2. 移动赋值所有基类和成员。
  3. 如果基类和成员的移动赋值没有使rhs变为无资源状态,则使其变为无资源状态。

如下所示:

unique_lock& operator=(unique_lock&& __u)
    {
        // 1. Destroy visible resources
        if (__owns_)
            __m_->unlock();
        // 2. Move assign all bases and members.
        __m_ = __u.__m_;
        __owns_ = __u.__owns_;
        // 3. If the move assignment of bases and members didn't,
        //           make the rhs resource-less, then make it so.
        __u.__m_ = nullptr;
        __u.__owns_ = false;
        return *this;
    }

更新

在评论中有一个关于如何处理移动构造函数的跟进问题。我开始在那里回答(在评论中),但格式和长度限制使得很难创建清晰的响应。因此,我将我的回应放在这里。

问题是:创建移动构造函数的最佳模式是什么?委托给默认构造函数然后交换?这样做的好处是减少代码重复。

我的回答是:我认为最重要的经验教训是程序员应该谨慎地遵循模式而不加思考。也许有些类实现移动构造函数作为默认+交换恰恰是正确的答案。这个类可能又大又复杂。 A(A&&) = default; 可能会出错。我认为考虑每个类的所有选择非常重要。

让我们详细看一下 OP 的例子:std::unique_lock(unique_lock&&)

观察:

A. 这个类相当简单。它有两个数据成员:

mutex_type* __m_;
bool __owns_;

B. 这个类在通用库中,将被未知数量的客户使用。在这种情况下,性能问题是一个高优先级。我们不知道我们的客户是否会在性能关键代码中使用这个类。因此,我们必须假设他们会。

C. 无论如何,这个类的移动构造函数都将由少量的加载和存储组成。因此,评估性能的好方法是计算加载和存储次数。例如,如果您使用4个存储器执行某些操作,而其他人只使用2个存储器执行相同的操作,则两个实现都非常快。但是他们的速度是你的两倍!这种差异可能在某些客户的紧密循环中非常重要。

首先,让我们计算默认构造函数和成员交换函数中的加载和存储次数:

// 2 stores
unique_lock()
    : __m_(nullptr),
      __owns_(false)
{
}

// 4 stores, 4 loads
void swap(unique_lock& __u)
{
    std::swap(__m_, __u.__m_);
    std::swap(__owns_, __u.__owns_);
}

现在让我们以两种方式实现移动构造函数:
// 4 stores, 2 loads
unique_lock(unique_lock&& __u)
    : __m_(__u.__m_),
      __owns_(__u.__owns_)
{
    __u.__m_ = nullptr;
    __u.__owns_ = false;
}

// 6 stores, 4 loads
unique_lock(unique_lock&& __u)
    : unique_lock()
{
    swap(__u);
}

第一种方法看起来比第二种复杂得多。源代码更大,有些代码可能已经在其他地方编写过了(比如移动赋值运算符)。这意味着出现错误的机会更多。
第二种方法更简单,并且重用了我们已经编写的代码。因此出错的可能性较小。
第一种方法更快。如果加载和存储的成本大致相同,那么速度可能快66%!
这是一个经典的工程权衡。没有免费的午餐。工程师们永远不会摆脱必须做出权衡决策的负担。一旦做出决定,飞机就开始从空中掉落,核电站就开始熔毁。
对于libc ++,我选择了更快的解决方案。我的理由是,对于这个类,无论如何都要做到正确;这个类足够简单,我做对的机会很高;而我的客户将重视性能。在不同上下文环境中的不同类别可能会得出不同的结论。

感谢您提供这些有益的回复。在某些情况下,我已经看到了使用lhs = move(rhs)形式的单个成员分配操作,并且虽然对于这些内置类型来说这不是一个问题,但是在任何情况下,move()函数是否有用?或者可以认为编译器总是会使用成员类型的移动赋值操作符吗?此外,您能否解决Kerrek SB在下面提到的异常安全性问题?再次感谢。 - Display Name
3
我想补充一点:交换操作和移动赋值都是类型原语(type primitives)。它们的功能略有不同,应该尽可能高效,以便客户端可以在不付出性能代价的情况下构建它们。如果您可以在不影响性能的情况下使用其中一个构建另一个,那就太好了。如果您不能这样做,但仍然坚持用其中一个构建另一个,那只是懒惰,您的客户会为此付出代价。请尽可能提高对象的质量,并让客户自行判断什么是高质量的,什么是低质量的。 - Howard Hinnant
好的,最好实现Move-Assign T::operator=(T&&) 而不是使用swap。感谢您的好解释。但是Move T::T(T&&)怎么样? - towi
2
@towi:对于移动构造,我通常不使用swap。需要将this设置为某个有效状态,然后将其与rhs交换。同样可以将this设置为rhs的状态,然后将rhs设置为某个有效状态。这通常通过简单地移动构造每个基类和成员,然后修复rhs来完成。 - Howard Hinnant
3
目前为止,此处和查询中的移动赋值是自我赋值自杀式的。除非你喜欢客户在经历了长达5个小时的调试会话后因为循环偶尔崩溃而对你发誓,否则你可能需要对此进行证明。 (别名可能会使移动分配的双方引用同一对象。但是,如果至少一侧是真正的临时对象,则无需担心。) - CTMacUser
显示剩余6条评论

10
这是关于异常安全的问题。由于在调用运算符时__u已经被构建,我们知道没有异常,并且swap不会抛出异常。
如果手动执行成员分配,你可能会冒着每个成员都可能引发异常的风险,然后你必须处理部分移动分配但必须退出的情况。
也许在这个简单的例子中这并不明显,但这是一般的设计原则:
  • 通过复制构造和交换来进行复制分配。
  • 通过移动构造和交换进行移动分配。
  • 编写基于构造和+=等项的+
基本上,你尝试将“真正”的代码量最小化,并尝试尽可能多地用核心功能表达其他特性。
unique_ptr采用显示的右值引用进行赋值,因为它不允许复制构造/赋值,所以它不是这种设计原则的最佳示例。)

2
我将回答标题为“为什么有些人使用swap进行移动赋值?”的问题。
使用swap的主要原因是提供noexcept移动赋值
根据Howard Hinnant的评论:

通常,移动赋值运算符应该:

  1. 销毁可见资源(虽然可以保存实现细节资源)。
但是,一般情况下销毁/释放函数可能会失败并抛出异常
以下是一个示例:
class unix_fd
{
    int fd;

public:
    explicit unix_fd(int f = -1) : fd(f) {}

    ~unix_fd()
    {
        if(fd == -1) return;
        if(::close(fd)) /* !!! call is failed! But we can't throw from destructor so just silently ignore....*/;
    }

    void close() // Our release-function
    {
        if(::close(fd)) throw system_error_with_errno_code;
    }
};

现在让我们比较两种move-assignment的实现:
// #1
unix_fd &unix_fd::operator=(unix_fd &&o) // Can't be noexcept
{
    if(&o != this)
    {
        close(); // !!! Can throw here
        fd = o.fd;
        o.fd = -1;
    }
    return *this;
}

并且

// #2
unix_fd &unix_fd::operator=(unix_fd &&o) noexcept
{
    std::swap(fd, o.fd);
    return *this;
}

#2是完美无异常!

是的,在#2的情况下,close()调用可以被“延迟”。但是!如果我们想要严格的错误检查,我们必须使用显式的close()调用,而不是析构函数。析构函数只在“紧急”情况下释放资源,在这种情况下,任何异常都无法抛出。

P.S. 另请参阅评论中的讨论这里


你的例子展示了使用swap可能会导致混淆的问题。在移动赋值之后,人们期望旧值已经消失并且文件已经在移动后关闭,但是实际上它会一直保持打开状态直到原始块结束。 - Zan Lynx
1
你不应该期望那样!标准明确规定,在移动后,值处于某个有效但未指定的状态。相反,在移动之前明确调用close()方法。 - Victor Dyachenko
1
这就像期望operator+将东西加在一起。这可能不符合标准,但毁掉你分配的东西是被期望和礼貌的行为。 - Zan Lynx
1
“标准明确规定,在移动后,该值处于某个有效但未指定的状态。但这仅适用于标准库类型。不应期望用户定义的类型遵循相同的规则,因为标准库设计是否最适合任何给定情况是非常有争议的。” - Jean-Michaël Celerier

2

在权衡方面需要考虑的另一件事:

默认构造+交换实现可能看起来较慢,但有时编译器中的数据流分析可以消除一些无意义的赋值并最终变得非常类似于手写代码。这仅适用于没有"聪明"值语义的类型。例如:

 struct Dummy
 {
     Dummy(): x(0), y(0) {} // suppose we require default 0 on these
     Dummy(Dummy&& other): x(0), y(0)
     {
         swap(other);             
     }

     void swap(Dummy& other)
     {
         std::swap(x, other.x);
         std::swap(y, other.y);
         text.swap(other.text);
     }

     int x, y;
     std::string text;
 }

没有优化的移动构造函数中的生成代码:

 <inline std::string() default ctor>
 x = 0;
 y = 0;
 temp = x;
 x = other.x;
 other.x = temp;
 temp = y;
 y = other.y;
 other.y = temp;
 <inline impl of text.swap(other.text)>

这看起来很糟糕,但数据流分析可以确定它与以下代码等效:
 x = other.x;
 other.x = 0;
 y = other.y;
 other.y = 0;
 <overwrite this->text with other.text, set other.text to default>

也许在实践中编译器并不总是会生成最优版本。可能需要尝试一下并查看汇编代码。
此外,有些情况下交换比赋值更好,因为存在“巧妙”的值语义,例如如果类的成员之一是std::shared_ptr。移动构造函数没有理由干扰原子引用计数器。

1
是的,编译器在某些情况下可能足够聪明以进行优化。但是,如果您既要追求性能又要追求可移植性,我建议您不要依赖它。此外,您提供的shared_ptr示例并不是一个好例子,因为它已经实现了移动构造和移动赋值:只需使用这些操作即可。 - Giel

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