69

例如,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

4 回答 4

119

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

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

在这个例子中没有理由使用交换。它比你建议的效率低。确实,在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;
    }

更新

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

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

我的回答是:我认为最重要的一点是程序员应该警惕不假思索地遵循模式。在某些类中,将移动构造函数实现为 default+swap 正是正确的答案。这个类可能很大而且很复杂。可能会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++,我选择了更快的解决方案。我的理由是,对于这门课,无论如何我都最好把它做好;这门课很简单,我做对的机会很高;我的客户会重视绩效。对于不同背景下的不同班级,我很可能会得出另一个结论。

于 2011-07-14T01:29:07.163 回答
9

这是关于异常安全的。由于__u在调用操作符时已经构造好了,所以我们知道没有异常,swap也不会抛出异常。

如果您手动进行成员分配,您将面临每个可能引发异常的风险,然后您将不得不处理部分移动分配的内容,但不得不退出。

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

  • 通过复制构造和交换进行复制分配。
  • 通过移动构造和交换移动分配。
  • +用构造 和+=等来写。

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

unique_ptr因为它不允许复制构造/赋值,所以在赋值中需要一个明确的右值引用,所以它不是这个设计原则的最佳例子。)

于 2011-07-14T01:06:57.213 回答
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;
 }

在未优化的移动 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。移动构造函数没有理由与原子引用计数器混淆。

于 2013-03-03T22:15:42.603 回答
2

我将回答标题中的问题:“为什么有些人使用交换来分配移动任务?”。

使用的主要原因swap提供 noexcept move assignment

来自 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()呼叫可以“延迟”以防万一#2。但!如果我们想要严格的错误检查,我们必须使用显式close()调用,而不是析构函数。析构函数仅在“紧急”情况下释放资源,无论如何都不能抛出异常。

PS 另请参阅评论中的讨论

于 2015-11-24T12:39:26.943 回答