什么是复制和交换习语,什么时候应该使用它?它解决了哪些问题?对于 C++11,它会改变吗?
有关的:
原文由 GManNickG 发布,翻译遵循 CC BY-SA 4.0 许可协议
什么是复制和交换习语,什么时候应该使用它?它解决了哪些问题?对于 C++11,它会改变吗?
有关的:
原文由 GManNickG 发布,翻译遵循 CC BY-SA 4.0 许可协议
已经有一些很好的答案。我将 主要 关注我认为他们缺乏的东西——用复制和交换成语解释“缺点”….
什么是复制和交换成语?
一种根据交换函数实现赋值运算符的方法:
X& operator=(X rhs)
{
swap(rhs);
return *this;
}
基本思想是:
分配给对象最容易出错的部分是确保获取新状态所需的任何资源(例如内存、描述符)
如果制作了新值的副本,则可以在修改对象的当前状态(即 *this
) 之前 尝试获取,这就是为什么 rhs
被 值 接受(即复制) 而不是 通过引用
交换本地副本 rhs
和 *this
的状态 通常 相对容易做到,没有潜在的故障/异常,因为本地副本之后不需要任何特定状态(只需要状态适合析构函数运行,就像从 >= C++11 中 移动 的对象一样)
什么时候应该使用它? (它解决了哪些问题 [/create] ?)
当您希望被分配对象不受引发异常的分配影响时,假设您拥有或可以编写具有强大异常保证的 swap
并且理想情况下不会失败/ throw
..†
当您想要一种干净、易于理解、健壮的方式来根据(更简单的)复制构造函数、 swap
和析构函数定义赋值运算符时。
当在分配期间有一个额外的临时对象造成的任何性能损失或暂时更高的资源使用对您的应用程序不重要时。 ⁂
† swap
抛出:通常可以可靠地交换对象通过指针跟踪的数据成员,但非指针数据成员没有无抛出交换,或者必须实施交换因为 X tmp = lhs; lhs = rhs; rhs = tmp;
和复制构造或赋值可能会抛出,仍然有可能失败,让一些数据成员交换而其他数据成员不交换。这种潜力甚至适用于 C++03 std::string
詹姆斯对另一个答案的评论:
@wilhelmtell:在 C++03 中,没有提及 std::string::swap (由 std::swap 调用)可能引发的异常。在 C++0x 中,std::string::swap 是 noexcept 并且不能抛出异常。 – 詹姆斯麦克内利斯 2010 年 12 月 22 日 15:24
‡ 当从一个不同的对象进行赋值时看起来很正常的赋值运算符实现很容易因自赋值而失败。虽然客户端代码甚至会尝试自分配似乎是不可想象的,但在容器上的算法操作期间它可以相对容易地发生,使用 x = f(x);
代码在哪里 f
是(可能仅适用于某些 #ifdef
branches) a macro ala #define f(x) x
or a function returning a reference to x
, or even (likely inefficient but concise) code like 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”进行自分配!
⁂ 由于使用了额外的临时变量(当操作符的参数是复制构造时),复制和交换惯用语可能会导致效率低下或限制:
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
相同的服务器(如果有用,可能会发送“重置”代码)而复制和交换方法将调用复制构造函数,该构造函数可能会被编写为打开一个不同的套接字连接,然后关闭原始套接字。这不仅意味着远程网络交互而不是简单的进程内变量复制,它还可能违反客户端或服务器对套接字资源或连接的限制。 (当然这个类有一个非常可怕的界面,但那是另一回事;-P)。
原文由 Tony Delroy 发布,翻译遵循 CC BY-SA 3.0 许可协议
3 回答2k 阅读✓ 已解决
2 回答3.9k 阅读✓ 已解决
2 回答3.2k 阅读✓ 已解决
1 回答3.2k 阅读✓ 已解决
1 回答2.7k 阅读✓ 已解决
3 回答3.4k 阅读
1 回答1.6k 阅读✓ 已解决
概述
为什么我们需要复制和交换习语?
任何管理资源的类( _包装器_,如智能指针)都需要实现 三巨头。虽然复制构造函数和析构函数的目标和实现很简单,但复制赋值运算符可以说是最细微和最困难的。应该怎么做?需要避免哪些陷阱?
复制和交换习语 是解决方案,它优雅地协助赋值运算符实现两件事:避免 代码重复,并提供 强大的异常保证。
它是如何工作的?
从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用
swap
函数获取复制的数据,将旧数据与新数据交换。然后临时副本销毁,并带走旧数据。我们留下了新数据的副本。为了使用 copy-and-swap 习惯用法,我们需要三个东西:一个工作的复制构造函数,一个工作的析构函数(两者都是任何包装器的基础,所以无论如何都应该是完整的)和一个
swap
功能。交换函数是一个 非抛出 函数,它交换一个类的两个对象,成员对成员。我们可能会想使用
std::swap
而不是自己提供,但这是不可能的;std::swap
在其实现中使用复制构造函数和复制赋值运算符,我们最终将尝试根据自身定义赋值运算符!(不仅如此,对
swap
的非限定调用将使用我们的自定义交换运算符,跳过std::swap
将需要的类的不必要的构造和破坏。)深入的解释
目标
让我们考虑一个具体的案例。我们想在一个无用的类中管理一个动态数组。我们从一个有效的构造函数、复制构造函数和析构函数开始:
这个类几乎成功地管理了数组,但它需要
operator=
才能正常工作。失败的解决方案
下面是一个幼稚的实现的样子:
我们说我们已经完成了;这现在管理一个数组,没有泄漏。但是,它存在三个问题,在代码中依次标记为
(n)
。这个检查有两个目的:它是一种防止我们在自赋值时运行不必要的代码的简单方法,它可以保护我们免受细微的错误(例如删除数组只是为了尝试复制它)。但在所有其他情况下,它只会降低程序的速度,并在代码中充当噪音;自分配很少发生,所以大多数时候这种检查是浪费。
如果操作员可以在没有它的情况下正常工作会更好。
new int[mSize]
失败,*this
将被修改。 (即大小不对,数据没了!)对于强大的异常保证,它需要类似于:
我们的赋值运算符有效地复制了我们已经在其他地方编写的所有代码,这是一件可怕的事情。
在我们的例子中,它的核心只有两行(分配和复制),但是对于更复杂的资源,这个代码膨胀可能会很麻烦。我们应该努力永不重复自己。
(有人可能想知道:如果需要这么多代码来正确管理一个资源,如果我的班级管理多个资源怎么办?
虽然这似乎是一个有效的问题,实际上它需要非平凡的
try
/catch
子句,但这不是问题。那是因为一个类应该 只管理一个资源!)
一个成功的解决方案
如前所述,复制和交换习语将解决所有这些问题。但是现在,除了一个
swap
函数,我们有所有的要求。虽然三法则成功地包含了我们的复制构造函数、赋值运算符和析构函数,但它确实应该被称为“三巨头半”:任何时候你的班级管理一个资源,提供一个swap
功能。我们需要为我们的类添加交换功能,我们这样做如下†:
( 这里 解释了为什么
public friend swap
。)现在我们不仅可以交换我们的dumb_array
,而且通常交换可以更有效;它只是交换指针和大小,而不是分配和复制整个数组。除了功能和效率方面的好处之外,我们现在已经准备好实现复制和交换的习惯用法了。事不宜迟,我们的赋值运算符是:
就是这样!一口气,所有三个问题都被优雅地解决了。
为什么它有效?
我们首先注意到一个重要的选择:参数参数是 按值 获取的。虽然人们可以很容易地做到以下几点(事实上,许多成语的幼稚实现都是这样做的):
我们失去了一个 重要的优化机会。不仅如此,这种选择在 C++11 中也很关键,后面会讨论。 (一般来说,一个非常有用的指导方针如下:如果您要在函数中复制某些内容,请让编译器在参数列表中完成。‡)
无论哪种方式,这种获取资源的方法都是消除代码重复的关键:我们可以使用来自复制构造函数的代码进行复制,而无需重复任何部分。既然副本已经制作好了,我们就可以进行交换了。
请注意,在进入函数时,所有新数据都已分配、复制并准备好使用。这就是免费为我们提供强大的异常保证的原因:如果副本的构造失败,我们甚至不会进入该函数,因此无法更改
*this
的状态。 (我们之前手动为强异常保证所做的工作,现在编译器正在为我们做;怎么样。)在这一点上,我们是无家可归的,因为
swap
是非投掷的。我们将当前数据与复制的数据交换,安全地更改我们的状态,并将旧数据放入临时数据中。当函数返回时,旧数据被释放。 (参数的作用域在哪里结束并调用它的析构函数。)因为成语没有重复代码,所以我们不能在操作符中引入错误。请注意,这意味着我们不再需要自分配检查,从而允许
operator=
的单一统一实现。 (此外,我们不再对非自我分配进行性能惩罚。)这就是复制和交换的习语。
那么 C++11 呢?
C++ 的下一个版本,C++11,对我们管理资源的方式做出了一个非常重要的改变:三法则现在 是四法则(半)。为什么?因为我们不仅需要能够复制构造我们的资源, 还需要移动构造它。
幸运的是,这很容易:
这里发生了什么?回想一下移动构造的目标:从类的另一个实例中获取资源,使其处于保证可分配和可破坏的状态。
所以我们所做的很简单:通过默认构造函数(C++11 特性)进行初始化,然后与
other
进行交换;我们知道可以安全地分配和销毁我们类的默认构造实例,因此我们知道other
在交换之后将能够做同样的事情。(请注意,一些编译器不支持构造函数委托;在这种情况下,我们必须手动默认构造类。这是一个不幸但幸运的是微不足道的任务。)
为什么这行得通?
这是我们需要对我们的班级做出的唯一改变,那么为什么它会起作用呢?请记住我们做出的使参数成为值而不是引用的重要决定:
现在,如果
other
正在使用右值进行初始化, 它将是 move-constructed 。完美的。就像 C++03 让我们通过按值获取参数来重用我们的复制构造函数功能一样,C++11 也会在适当的时候 自动 选择移动构造函数。 (当然,正如之前链接的文章中提到的,值的复制/移动可能会被完全省略。)复制和交换的习语就这样结束了。
脚注
*为什么我们将
mArray
设置为空?因为如果运算符中的任何进一步代码抛出,可能会调用dumb_array
的析构函数;如果在没有将其设置为 null 的情况下发生这种情况,我们将尝试删除已被删除的内存!我们通过将其设置为 null 来避免这种情况,因为删除 null 是无操作的。†还有其他要求我们应该专门
std::swap
对于我们的类型,提供一个类中的swap
以及一个自由函数swap
等等。这都是不必要的:任何正确使用swap
都将通过不合格的调用,我们的函数将通过 ADL 找到。一个功能就可以了。‡原因很简单:一旦您拥有了自己的资源,您就可以在任何需要的地方交换和/或移动它(C++11)。通过在参数列表中进行复制,您可以最大限度地优化。
††移动构造函数通常应为
noexcept
,否则即使移动有意义,某些代码(例如std::vector
调整大小逻辑)也会使用复制构造函数。当然,只有在里面的代码没有抛出异常的情况下才标记为 noexcept 。