在 C++11 中实现复制和交换习语的更好方法

A better way to implement copy-and-swap idiom in C++11

本文关键字:习语 更好 方法 交换 C++11 实现 复制      更新时间:2023-10-16

我看到许多代码在复制和交换方面实现了五条规则,但我认为我们可以使用移动函数来替换交换函数,如以下代码所示:

#include <algorithm>
#include <cstddef>
class DumbArray {
public:
DumbArray(std::size_t size = 0)
: size_(size), array_(size_ ? new int[size_]() : nullptr) {
}
DumbArray(const DumbArray& that)
: size_(that.size_), array_(size_ ? new int[size_] : nullptr) {
std::copy(that.array_, that.array_ + size_, array_);
}
DumbArray(DumbArray&& that) : DumbArray() {
move_to_this(that);
}
~DumbArray() {
delete [] array_;
}
DumbArray& operator=(DumbArray that) {
move_to_this(that);
return *this;
}
private:
void move_to_this(DumbArray &that) {
delete [] array_;
array_ = that.array_;
size_ = that.size_;
that.array_ = nullptr;
that.size_ = 0;
}
private:
std::size_t size_;
int* array_;
};

这段代码,我认为

  1. 异常安全
  2. 需要较少的键入,因为许多函数只需调用 move_to_this(),并且复制赋值和移动赋值统一在一个函数中
  3. 比复制和交换更有效,因为交换涉及 3 个赋值,而这里只有 2 个,并且此代码不会遇到此链接中提到的问题

我说的对吗?

谢谢

编辑:

  1. 正如@Leon所指出的,也许需要一个专门的函数来释放资源,以避免move_to_this()和析构函数中的代码重复
  2. 正如@thorsan指出的那样,对于极端的性能问题,最好将DumbArray& operator=(DumbArray that) { move_to_this(that); return *this; }分成DumbArray& operator=(const DumbArray &that) { DumbArray temp(that); move_to_this(temp); return *this; }(感谢@MikeMB)和DumbArray& operator=(DumbArray &&that) { move_to_this(that); return *this; }以避免额外的移动操作。

    添加一些调试打印后,我发现当您将其称为移动分配时,DumbArray& operator=(DumbArray that) {}中不涉及额外的移动

  3. 正如@Erik Alapää所指出的那样,在delete之前需要进行自我分配检查move_to_this()

内联注释,但简短:

  • 如果可能,您希望noexcept所有移动赋值和移动构造函数。如果启用此功能,标准库会快得多,因为它可以避免对对象序列进行重新排序的算法中的任何异常处理。

  • 如果要定义自定义析构函数,请使其 noexcept。为什么要打开潘多拉魔盒?我错了。除非默认,否则没有。

  • 在这种情况下,提供强大的异常保证是无痛的,而且几乎不需要任何成本,所以让我们这样做。

法典:

#include <algorithm>
#include <cstddef>
class DumbArray {
public:
DumbArray(std::size_t size = 0)
: size_(size), array_(size_ ? new int[size_]() : nullptr) {
}
DumbArray(const DumbArray& that)
: size_(that.size_), array_(size_ ? new int[size_] : nullptr) {
std::copy(that.array_, that.array_ + size_, array_);
}
// the move constructor becomes the heart of all move operations.
// note that it is noexcept - this means our object will behave well
// when contained by a std:: container
DumbArray(DumbArray&& that) noexcept
: size_(that.size_)
, array_(that.array_)
{
that.size_ = 0;
that.array_ = nullptr;
}
// noexcept, otherwise all kinds of nasty things can happen
~DumbArray() // noexcept - this is implied.
{
delete [] array_;
}
// I see that you were doing by re-using the assignment operator
// for copy-assignment and move-assignment but unfortunately
// that was preventing us from making the move-assignment operator
// noexcept (see later)
DumbArray& operator=(const DumbArray& that)
{
// copy-swap idiom provides strong exception guarantee for no cost
DumbArray(that).swap(*this);
return *this;
}
// move-assignment is now noexcept (because move-constructor is noexcept
// and swap is noexcept) This makes vector manipulations of DumbArray
// many orders of magnitude faster than they would otherwise be
// (e.g. insert, partition, sort, etc)
DumbArray& operator=(DumbArray&& that) noexcept {
DumbArray(std::move(that)).swap(*this);
return *this;
}

// provide a noexcept swap. It's the heart of all move and copy ops
// and again, providing it helps std containers and algorithms 
// to be efficient. Standard idioms exist because they work.
void swap(DumbArray& that) noexcept {
std::swap(size_, that.size_);
std::swap(array_, that.array_);
}
private:
std::size_t size_;
int* array_;
};

在移动分配运算符中还可以进行进一步的性能改进。

我提供的解决方案保证了移出数组为空(资源已解除分配)。这可能不是您想要的。例如,如果您分别跟踪 DumbArray 的容量和大小(例如,像 std::vector),那么您很可能希望在移动后将this中的任何已分配内存保留在that中。然后,这将允许将that分配给,同时可能在没有其他内存分配的情况下逃脱。

为了实现这种优化,我们只需根据(noexcept)swap 来实现移动分配运算符:

所以从这里:

/// @pre that must be in a valid state
/// @post that is guaranteed to be empty() and not allocated()
///
DumbArray& operator=(DumbArray&& that) noexcept {
DumbArray(std::move(that)).swap(*this);
return *this;
}

对此:

/// @pre that must be in a valid state
/// @post that will be in an undefined but valid state
DumbArray& operator=(DumbArray&& that) noexcept {
swap(that);
return *this;
}

在DumbArray的情况下,在实践中使用更宽松的形式可能是值得的,但要注意微妙的错误。

例如

DumbArray x = { .... };
do_something(std::move(x));
// here: we will get a segfault if we implement the fully destructive
// variant. The optimised variant *may* not crash, it may just do
// something_else with some previously-used data.
// depending on your application, this may be a security risk 
something_else(x);   

代码的唯一(小)问题是move_to_this()和析构函数之间的功能重复,如果需要更改类,这是一个维护问题。当然,可以通过将该部分提取到公共函数destroy()来解决。

我对斯科特·迈耶斯(Scott Meyers)在他的博客文章中讨论的"问题"的批评:

他尝试手动优化编译器如果足够智能可以做得同样好的地方。五法则可以简化为四法则

  • 仅提供按值获取其参数的复制赋值运算符,并且
  • 懒得写移动赋值运算符(正是你所做的)。

这自动解决了左侧对象的资源被交换到右侧对象中并且如果右侧对象不是临时对象时不会立即释放的问题。

然后,在根据复制和交换习惯用法的复制赋值运算符的实现中,swap()将把过期对象作为其参数之一。如果编译器可以内联后者的析构函数,那么它肯定会消除额外的指针赋值 - 事实上,为什么要保存下一步要delete的指针?

我的结论是,遵循成熟的习语更简单,而不是为了成熟编译器完全可以实现的微优化而使实现稍微复杂化。