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

时间:2011-07-14 01:03:51

标签: c++ c++11 rvalue-reference move-semantics copy-and-swap

例如,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成员,后来才分配0和false ...在这种情况下交换正在做不必要的工作。我错过了什么? (unique_lock :: swap只对每个成员执行std :: swap)

4 个答案:

答案 0 :(得分:86)

这是我的错。 (半开玩笑,一半不开心)。

当我第一次展示移动赋值运算符的示例实现时,我只使用了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;
    }

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

  1. 销毁可见资源(尽管可能会节省实施细节资源)。
  2. 移动分配所有基地和成员。
  3. 如果基地和成员的移动分配没有使rhs资源减少,那么就这样做。
  4. 像这样:

    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&&)

    观察:

    一个。这个课很简单。它有两个数据成员:

    mutex_type* __m_;
    bool __owns_;
    

    B中。此类位于通用库中,供未知数量的客户端使用。在这种情况下,性能问题是一个高度优先事项。我们不知道我们的客户是否会在性能关键代码中使用此类。所以我们必须假设它们是。

    ℃。无论如何,这个类的移动构造函数将包含少量的加载和存储。因此,查看性能的一个好方法是计算负载和存储。例如,如果您使用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_);
    }
    

    现在让我们用两种方式实现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);
    }
    

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

    第二种方式更简单,并重用我们已编写的代码。因此,错误的可能性更小。

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

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

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

答案 1 :(得分:7)

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

如果你手动完成了成员分配,那么你可能会冒这个问题,但是你必须处理部分移动分配的东西,但不得不挽救。

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

  • 通过copy-construct和swap复制 - 分配。
  • 通过move-construct和swap移动分配。
  • 以构造和+等方式编写+=

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

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

答案 2 :(得分: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;
 }

在没有优化的情况下在move ctor中生成代码:

 <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。没有理由移动构造函数应该与原子refcounter混淆。

答案 3 :(得分:2)

我将从标题回答问题:&#34;为什么有些人使用交换进行移动分配?&#34;。

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

现在让我们比较一下移动分配的两个实现:

// #1
void 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
void unix_fd::operator=(unix_fd &&o) noexcept
{
    std::swap(fd, o.fd);
    return *this;
}

#2完全没有例外!

是的,close()来电可以延迟&#34;以防#2。但!如果我们想要严格的错误检查,我们必须使用显式close()调用,而不是析构函数。析构函数仅在&#34; emergency&#34;中释放资源。无论如何,不​​能抛出异常的情况。

P.S。另见评论

中的讨论here