什么是副本
什么是这个成语,什么时候应该使用它? 它解决了哪些问题? 当使用C ++ 11时,习语是否会发生变化?
尽管在很多地方都有提到,但我们没有任何单数的“问题”和答案,所以在这里。 这是以前提到的地方的部分列表:
概观
为什么我们需要复制和交换成语?
任何管理资源的类(包装器,像智能指针)都需要实现三巨头。 虽然复制构造函数和析构函数的目标和实现很简单,但是复制赋值运算符可以说是最细微和困难的。 应该怎么做? 需要避免哪些陷阱?
复制和交换成语是解决方案,并且优雅地协助赋值运算符实现两件事:避免代码重复,并提供强大的异常保证。
它是如何工作的?
从概念上讲,它通过使用复制构造函数来创建数据的本地副本,然后使用swap
功能获取复制的数据,并使用新数据交换旧数据。 然后临时副本破坏,将旧数据与它一起取出。 我们剩下一份新的数据。
为了使用copy-and-swap成语,我们需要三件事情:一个工作拷贝构造函数,一个工作析构函数(两者都是任何包装的基础,所以应该是完整的)和swap
函数。
交换函数是一个非抛出函数,用于交换类成员中的两个对象。 我们可能会试图使用std::swap
而不是提供自己的,但这是不可能的; std::swap
在其实现中使用了复制构造函数和复制赋值运算符,我们最终会试图根据自身定义赋值运算符!
(不仅如此,但不合格的呼叫swap
将使用我们的自定义交换子,跳过不必要的建设和我们的类,它的破坏std::swap
将需要。)
一个深入的解释
目标
让我们考虑一个具体的案例。 我们想要在一个无用的类中管理一个动态数组。 我们从一个工作构造函数,复制构造函数和析构函数开始:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
这个类几乎成功地管理了数组,但它需要operator=
才能正常工作。
失败的解决方案
下面是一个简单的实现可能的样子:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
我们说我们已经完成了; 这现在管理一个数组,没有泄漏。 然而,它有三个问题,在代码中依次标记为(n)
。
首先是自我分配测试。 这种检查有两个目的:这是一种简单的方法来防止我们在自我分配上运行不必要的代码,并且它保护我们免受微妙的错误(例如删除阵列以尝试并复制它)。 但在所有其他情况下,它仅仅是为了减慢程序速度,并在代码中充当噪声; 自我分配很少发生,所以大多数时候这种检查是浪费。 如果没有它,操作员可以正常工作会更好。
其次是它只提供了一个基本的例外保证。 如果new int[mSize]
失败, *this
将被修改。 (也就是说,大小是错误的,数据已经消失!)对于强大的异常保证,它需要类似于:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get the new data ready before we replace the old
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
// replace the old data (all are non-throwing)
delete [] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}
代码已经扩展! 这导致我们遇到第三个问题:代码重复。 我们的赋值运算符有效地复制了我们已经写在其他地方的所有代码,这是一件可怕的事情。
在我们的例子中,它的核心只有两行(分配和复制),但是对于更复杂的资源,这种代码膨胀可能会相当麻烦。 我们应该努力不要重复自己。
(人们可能会想:如果需要这么多的代码来正确管理一个资源,如果我的班级管理多个资源,该怎么办?虽然这似乎是一个有效的关注点,并且实际上它需要非平凡的try
/ catch
子句,这是这是一个非问题,因为一个类只能管理一个资源!)
成功的解决方案
如前所述,copy-and-swap成语将解决所有这些问题。 但现在,除了一个要求外,我们拥有所有的要求: swap
功能。 虽然三规则成功地要求我们的复制构造函数,赋值运算符和析构函数的存在,但它应该被称为“三大半”:任何时候当你的类管理资源时,提供swap
也是有意义的功能。
我们需要为我们的课程添加交换功能,并且我们按如下方式执行此操作†:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
(以下是public friend swap
的解释。)现在我们不仅可以交换我们的dumb_array
,而且总体来说交换可以更有效; 它只是交换指针和大小,而不是分配和复制整个数组。 除了这种功能和效率的优势之外,我们现在准备实施复制和交换习惯用法。
简而言之,我们的分配操作员是:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
就是这样! 一举突破,所有这三个问题都得到了优雅的解决。
它为什么有效?
我们首先注意到一个重要的选择:参数参数是按值进行的。 虽然人们可以很容易地做到以下几点(实际上,这个习语的很多天真的实现):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
我们失去了重要的优化机会。 不仅如此,这个选择在C ++ 11中很重要,后面将对此进行讨论。 (一般来说,一个非常有用的指导原则如下:如果要在函数中创建一个副本,让编译器在参数列表中进行操作。
无论哪种方式,这种获取我们的资源的方法是消除代码重复的关键:我们可以使用复制构造函数中的代码来创建副本,而不需要重复任何代码。 现在复制完成了,我们准备交换。
注意到在输入所有新数据已被分配,复制并准备使用的功能后。 这是为我们提供了一个强大的免费例外保证:如果复制构造失败,我们甚至不会输入函数,因此无法更改*this
的状态。 (我们之前手动做了一个强大的异常保证,现在编译器正在为我们做些什么,怎么样。)
在这一点上,我们是免费的,因为swap
是不投掷的。 我们将当前的数据与复制的数据进行交换,安全地改变我们的状态,并将旧数据放入临时数据中。 旧的数据然后在函数返回时被释放。 (在参数范围结束并且析构函数被调用的地方)
由于该习语不重复任何代码,因此我们不能在操作员中引入错误。 请注意,这意味着我们摆脱了对自我分配检查的需求,允许统一实现operator=
。 (此外,我们不再对非自我分配有表现惩罚。)
这就是复制交换的习惯用法。
那么C ++ 11呢?
下一版本的C ++,C ++ 11,对我们如何管理资源做出了一个非常重要的改变:现在三条法则是四条法则 (一半)。 为什么? 因为我们不仅需要能够复制构建资源,还需要移动构建它。
幸运的是,这很简单:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other)
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
这里发生了什么? 回想一下移动建设的目标:从班级的另一个实例中获取资源,使其保持可保证可分配和可破坏的状态。
所以我们做的很简单:通过默认构造函数(C ++ 11特性)初始化,然后与other
交换; 我们知道我们类的默认构造实例可以安全地分配和销毁,所以我们知道other
人可以在交换后执行相同的操作。
(请注意,有些编译器不支持构造函数委托;在这种情况下,我们必须手动默认构造类,这是一个不幸但幸运的小任务。)
为什么这个工作?
这是我们需要对班级做出的唯一改变,为什么它会起作用? 请记住我们为使参数成为一个值而不是参考而做出的至关重要的决定:
dumb_array& operator=(dumb_array other); // (1)
现在,如果other
正在用右值初始化,它将被移动构建。 完善。 以同样的方式,C ++ 03让我们通过获取参数的值来重新使用我们的拷贝构造函数,C ++ 11在适当的时候也会自动选择移动构造函数。 (当然,正如前面链接文章中提到的那样,价值的复制/移动可能完全消失。)
这样就完成了复制和交换的习惯用法。
脚注
*为什么我们将mArray
设置为null? 因为如果运算符中的任何其他代码都抛出,可能会调用dumb_array
的析构函数; 如果发生这种情况而没有将其设置为空,我们尝试删除已被删除的内存! 我们通过将其设置为空来避免这种情况,因为删除null是无效操作。
†还有其他一些声明,我们应该为我们的类型专门开发std::swap
,提供一个在自由函数swap
等方面的类内swap
。但是这是不必要的:任何正确使用swap
将通过一个不合格的调用,我们的功能将通过ADL找到。 一个功能就可以做到。
‡原因很简单:一旦你拥有了自己的资源,你可以在任何需要的地方交换和/或移动它(C ++ 11)。 通过在参数列表中复制副本,可以最大限度地优化。
分配的核心是两个步骤: 拆除对象的旧状态并将其新状态构建为其他对象状态的副本 。
基本上,这就是析构函数和复制构造函数的作用,所以第一个想法是将工作委派给他们。 然而,既然破坏不会失败,而建设可能会实现,我们实际上想要做到这一点: 首先执行建设性的部分 ,如果成功的话,做破坏性部分 。 copy-and-swap成语就是这样做的:它首先调用一个类的拷贝构造函数来创建一个临时对象,然后用临时对象替换它的数据,然后让临时对象的析构函数销毁旧状态。
由于swap()
应该永远不会失败,唯一可能失败的部分是复制构造。 这是首先执行的,如果失败,则目标对象中不会有任何变化。
在其改进形式中,通过初始化赋值运算符的(非引用)参数来执行复制和交换:
T& operator=(T tmp)
{
this->swap(tmp);
return *this;
}
已经有一些很好的答案。 我将主要关注我认为他们缺乏的东西 - 用复制和交换成语来解释“缺点”....
什么是复制和交换成语?
根据交换函数实现赋值运算符的一种方法:
X& operator=(X rhs)
{
swap(rhs);
return *this;
}
其基本思想是:
分配给对象的最容易出错的部分是确保获取新状态需要的任何资源(例如内存,描述符)
在修改对象的当前状态(即*this
)之前可以尝试获取新值的副本,这就是rhs
被值(即复制)接受而不是通过引用接受的原因
交换本地副本rhs
和*this
的状态, *this
因为本地副本之后不需要任何特定状态(通常需要状态适合析构函数运行,所以通常在没有潜在故障/异常的情况下相对容易)对象从> = C ++中移动11)
什么时候应该使用它? (它解决了哪些问题[/创建] ?)
当你想让赋值对象不受引发异常的赋值的影响时,假设你有或者可以编写一个具有强壮异常保证的swap
,并且理想情况下不会失败/ throw
。†
当你想要一个干净,容易理解,强大的方式来定义赋值运算符的(简单)复制构造函数, swap
和析构函数。
† swap
投掷:通常可以通过指针可靠地交换对象追踪的数据成员,但是非指针数据成员不具有无抛交换,或者必须将交换实现为X tmp = lhs; lhs = rhs; rhs = tmp;
X tmp = lhs; lhs = rhs; rhs = tmp;
并且复制构建或分配可能会丢失,但仍有可能失败,导致部分数据成员交换而其他数据不成员。 这个潜力甚至适用于C ++ 03 std::string
,正如James对另一个答案所作的评论:
@wilhelmtell:在C ++ 03中,没有提到可能由std :: string :: swap(由std :: swap调用)引发的异常。 在C ++ 0x中,std :: string :: swap是noexcept,不能抛出异常。 - 詹姆斯McNellis 12年10月22日在15:24
•赋值运算符的实现看起来很明智,当从一个不同的对象进行赋值很容易失败以进行自赋值。 虽然客户端代码甚至会尝试自我分配似乎是不可想象的,但在对容器进行算法运算期间,它可能会相对容易发生,其中x = f(x);
代码其中f
是(也许只对某些#ifdef
分支)宏ala #define f(x) x
或返回对x
的引用的x
,或甚至(可能是低效但简明)的代码,如x = c1 ? x * 2 : c2 ? x / 2 : x;
x = c1 ? x * 2 : c2 ? x / 2 : x;
)。 例如:
struct X
{
T* p_;
size_t size_;
X& operator=(const X& rhs)
{
delete[] p_; // OUCH!
p_ = new T[size_ = rhs.size_];
std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
}
...
};
在自分配上,上面的代码删除的x.p_;
,在新分配的堆区域指向p_
,然后尝试读取其中的未初始化数据(未定义行为),如果这没有做太奇怪的事情,则copy
尝试自行分配给每个刚被破坏的'T'!
copy由于使用额外的临时(当操作员的参数是复制构造的时候),复制和交换成语会引入效率低下或限制:
struct Client
{
IP_Address ip_address_;
int socket_;
X(const X& rhs)
: ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
{ }
};
在这里,一个手写的Client::operator=
可能会检查*this
是否已经连接到与rhs
相同的服务器(如果有用,可能会发送一个“重置”代码),而复制和交换方法会调用copy-构造函数可能会被写入以打开一个独特的套接字连接,然后关闭原始的连接。 这不仅意味着远程网络交互而代表简单的进程内变量副本,它可能与客户端或服务器对套接字资源或连接的限制相违背。 (当然这个类有一个非常可怕的界面,但这是另一回事; -P)。
上一篇: What is the copy