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

例如,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... 在这种情况下,交换正在做不必要的工作。我错过了什么? (惟一的 _ lock: : swap 只在每个成员上执行一个 std: : switch)

31267 次浏览

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

如果您手动完成成员分配,那么您将面临每个成员都可能抛出异常的风险,然后您将不得不处理部分移动分配的事情,但必须跳出来。

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

  • 通过复制构造和交换进行复制分配。
  • 通过 move 构造和交换来移动-分配。
  • 根据构造和 +=等编写 +

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

(unique_ptr在赋值中使用显式的 rvalue 引用,因为它不允许复制构造/赋值,所以它不是这个设计原则的最佳示例。)

是我的错。

当我第一次展示移动赋值运算符的实现示例时,我只使用了交换。然后一些聪明的家伙(我不记得是谁)向我指出,在赋值之前销毁 lhs 的副作用可能很重要(例如您的示例中的解锁())。因此,我停止使用移动分配交换。但是使用掉期交易的历史仍然存在,并且还在延续。

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

更新

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

问题是: 创建 move 构造函数的最佳模式是什么?授权给缺省构造函数然后交换?这具有减少代码重复的优点。

我的回答是: 我认为最重要的一点是,程序员应该对不假思索地遵循模式保持警惕。在某些类中,将 move 构造函数实现为 default + swap 正是正确答案。这门课可能很大而且很复杂。A(A&&) = default;可能会做错事。我认为考虑每门课的所有选择是很重要的。

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

观察结果:

这个类相当简单,它有两个数据成员:

mutex_type* __m_;
bool __owns_;

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

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

第一种看起来比第二种复杂得多。而且源代码更大,并且在某种程度上重复了我们可能已经在其他地方编写的代码(比如在 move 赋值操作符中)。这意味着窃听器出现的可能性更大。

第二种方法更简单,可以重用我们已经写好的代码,从而减少出错的几率。

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

这是典型的工程学折衷。天下没有免费的午餐。而且工程师们永远不会从做出权衡决定的负担中解脱出来。一旦出现这种情况,飞机就开始从空中坠落,核电站也开始融化。

对于 Libc + + ,我选择了更快的解决方案。我的理由是,对于这个类,无论如何我最好都能正确地使用它; 这个类非常简单,我正确使用它的几率很高; 而且我的客户会重视性能。在不同的背景下,我可能会对不同的课程得出另一个结论。

另一件需要考虑的事情是:

默认构造 + 交换实现可能看起来比较慢,但是有时候编译器中的数据流分析可以消除一些无意义的赋值,结果非常类似于手写代码。这只适用于没有“聪明”值语义的类型。举个例子,

 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: : share _ ptr。Move 构造函数没有理由干扰原子的 reffcounter。

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

使用 swap的主要原因是 只提供移动作业

来自霍华德 · 海南特的评论:

一般来说,移动分配操作符应该:

  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
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完全没有,除了!

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

另见评论中的讨论 给你