为什么有些人使用交换进行移动分配?

Dis*_*ame 56 c++ rvalue-reference move-semantics copy-and-swap c++11

例如,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;
}
Run Code Online (Sandbox Code Playgroud)

为什么不直接将两个__成员分配给*?交换是否意味着__u被分配了*this成员,后来才分配0和false ...在这种情况下交换正在做不必要的工作.我错过了什么?(unique_lock :: swap只对每个成员执行std :: swap)

How*_*ant 91

我的错.(半开玩笑,半开玩笑).

当我第一次展示移动赋值运算符的示例实现时,我只使用了swap.然后一些聪明的人(我不记得是谁)向我指出,在分配之前破坏lhs的副作用可能很重要(例如你的例子中的unlock()).所以我停止使用交换进行移动分配.但是使用交换的历史仍然存在并且持续存在.

在这个例子中没有理由使用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;
    }
Run Code Online (Sandbox Code Playgroud)

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

  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;
    }
Run Code Online (Sandbox Code Playgroud)

更新

在评论中有关于如何处理移动构造函数的后续问题.我开始回答(在评论中),但格式和长度限制使得难以创建明确的响应.因此,我在这里提出我的回应.

问题是:创建移动构造函数的最佳模式是什么?委托默认构造函数然后交换?这具有减少代码重复的优点.

我的回答是:我认为最重要的一点就是程序员应该对未经思考的模式持谨慎态度.可能有一些类实现移动构造函数,因为默认+交换是正确的答案.这堂课可能很大而且很复杂.该A(A&&) = default;会做错事.我认为考虑每个班级的所有选择很重要.

让我们详细了解OP的例子: std::unique_lock(unique_lock&&).

观察:

答:这门课很简单.它有两个数据成员:

mutex_type* __m_;
bool __owns_;
Run Code Online (Sandbox Code Playgroud)

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_);
}
Run Code Online (Sandbox Code Playgroud)

现在让我们用两种方式实现move构造函数:

// 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);
}
Run Code Online (Sandbox Code Playgroud)

第一种方式看起来比第二种方式复杂得多.并且源代码更大,有些重复的代码我们可能已经在其他地方写过(比如在移动赋值运算符中).这意味着错误的机会更多.

第二种方式更简单,并重用我们已编写的代码.因此错误的机会减少.

第一种方式更快.如果装载和存储的成本大致相同,可能要快66%!

这是一个经典的工程权衡.天下没有免费的午餐.工程师永远不会放松必须做出权衡决策的负担.一分钟,飞机开始从空中坠落,核电站开始融化.

对于libc ++,我选择了更快的解决方案.我的理由是,对于这门课程,无论如何我都能做得更好; 这堂课很简单,我获得正确的机会很高; 我的客户将重视绩效.对于不同背景下的不同课程,我可能会得出另一个结论.

  • 我想补充一点:交换和移动赋值都是类型原语.它们做的事情略有不同,应尽可能高效,以便客户可以在不牺牲性能的情况下构建它们.如果你可以在没有性能损失的情况下构建一个,那就太好了.如果你不能,但仍然坚持建立一个,那只是懒惰,你的客户付出代价.尽可能提高您的物品质量.让您的客户判断什么是高质量,什么不是. (3认同)
  • 在撰写本文时,此处和查询中的移动分配是自我分配 - 自杀.您可能想要证明这一点,除非您希望客户在5小时的调试会话后发誓为什么循环偶尔会崩溃.(别名可能会使移动分配的两侧都指向同一个对象.不过担心如果至少有一方是真正的临时对象.) (3认同)
  • @towi:对于移动构造我通常不使用swap.一个人必须将*this设置为某个有效状态,然后将其交换为rhs.可以很容易地将*this设置为rhs的状态,然后将rhs设置为某个有效状态.这通常通过简单地移动构造每个基座和构件,然后固定rhs来实现. (2认同)

Ker*_* SB 8

这是关于异常安全的.因为__u在调用运算符时已经构造了,所以我们知道没有异常,并且swap不会抛出.

如果你手动完成了成员分配,那么你可能会冒这个问题,因为每个人都可能会抛出异常,然后你必须处理部分移动分配的东西,但不得不挽救.

也许在这个琐碎的例子中,这并没有显示,但它是一个通用的设计原则:

  • 通过复制构造和交换进行复制分配.
  • 通过move-construct和swap进行移动分配.
  • 写作+构造+=等等.

基本上,您尝试最小化"真实"代码的数量,并尝试尽可能多地表达核心功能方面的其他功能.

(unique_ptr在赋值中采用显式的右值引用,因为它不允许复制构造/赋值,因此它不是此设计原则的最佳示例.)