这个习语是什么,什么时候应该使用?它可以解决哪些问题?使用C ++ 11时,习惯用法会改变吗?
尽管在很多地方都提到过它,但是我们没有任何奇异的“这是什么”的问题和答案,所以就在这里。这是先前提到的地方的部分列表:
这个习语是什么,什么时候应该使用?它可以解决哪些问题?使用C ++ 11时,习惯用法会改变吗?
尽管在很多地方都提到过它,但是我们没有任何奇异的“这是什么”的问题和答案,所以就在这里。这是先前提到的地方的部分列表:
Answers:
任何管理资源的类(包装器,如智能指针)都需要实现“三巨头”。尽管复制构造函数和析构函数的目标和实现很简单,但是复制分配运算符无疑是最细微和最困难的。应该怎么做?需要避免什么陷阱?
在复制和交换成语是解决方案,并协助典雅赋值运算符在实现两件事情:避免重复代码,并提供了一个强大的异常保证。
从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后将复制的数据与swap
函数一起使用,将旧数据与新数据交换,从而起作用。然后,临时副本将销毁,并随身携带旧数据。我们剩下的是新数据的副本。
为了使用复制和交换习惯,我们需要三件事:一个有效的复制构造函数,一个有效的析构函数(两者都是任何包装程序的基础,因此无论如何都应完整)和一个swap
功能。
交换函数是一种非抛出函数,它交换一个类的两个对象,一个成员一个成员。我们可能很想使用std::swap
而不是提供我们自己的,但这是不可能的。std::swap
在实现中使用copy-constructor和copy-assignment运算符,我们最终将尝试根据自身定义赋值运算符!
(不仅如此,对的不合格调用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
子句,但这是非必需的。-issue。这是因为类只能管理一个资源!)
如前所述,复制和交换惯用语将解决所有这些问题。但是现在,除了一个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;
}
就是这样!一口气,所有三个问题都可以轻松解决。
我们首先注意到一个重要的选择:参数自变量采用by-value。尽管可以很容易地完成以下操作(实际上,许多成语的幼稚实现都可以做到):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
我们失去了重要的优化机会。不仅如此,这种选择在C ++ 11中也至关重要,稍后将进行讨论。(一般来说,非常有用的准则如下:如果要在函数中复制某些内容,请让编译器在参数列表中进行。‡)
无论哪种方式,这种获取资源的方法都是消除代码重复的关键:我们可以使用复制构造函数中的代码进行复制,而无需重复任何代码。复制完成后,我们就可以交换了。
观察到进入该功能后,所有新数据都已被分配,复制并准备使用。这就是给我们免费的强大异常保证的原因:如果副本的构造失败,我们甚至都不会输入该函数,因此无法更改的状态*this
。(我们以前为确保强烈的异常保证而手动进行的工作,现在编译器正在为我们做;这是多么友好。)
在这一点上,我们是无家可归的,因为swap
没有投掷。我们将当前数据与复制的数据交换,安全地更改我们的状态,并且旧数据被放入临时数据中。函数返回时,将释放旧数据。(在该参数的作用域终止并调用其析构函数的位置。)
由于该惯用语不重复任何代码,因此我们无法在运算符内引入错误。请注意,这意味着我们无需进行自我分配检查,从而可以统一实现operator=
。(此外,我们不再对非自我分配给予绩效惩罚。)
这就是复制和交换的习惯用法。
C ++的下一个版本C ++ 11对我们管理资源的方式进行了非常重要的更改:“三个规则”现在是“四个规则(一个半)”。为什么?因为我们不仅需要能够复制构建资源,还需要移动构建资源。
对我们来说幸运的是,这很容易:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: 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将自动在适当时选择move-constructor。(并且,当然,如先前链接的文章中所述,可以简单地完全省略复制/移动值。)
这样就得出了“复制和交换”的成语。
*为什么我们设置mArray
为空?因为如果在运算符中抛出任何其他代码,则dumb_array
可能会调用的析构函数;如果在没有将其设置为null的情况下发生这种情况,我们将尝试删除已经删除的内存!我们通过将其设置为null来避免这种情况,因为删除null是无操作的。
†还有其他主张,我们应该专门std::swap
针对我们的类型,在类中提供swap
自由函数swap
,等等。但这都是不必要的:对的任何正确使用swap
将通过不合格的调用进行,而我们的函数将通过ADL找到。一种功能将起作用。
‡原因很简单:一旦拥有了自己的资源,就可以在需要的任何地方交换和/或移动它(C ++ 11)。通过在参数列表中创建副本,可以最大程度地优化。
††move构造函数通常应为noexcept
,否则std::vector
即使在有意义的情况下,某些代码(例如,调整大小的逻辑)也将使用副本构造函数。当然,如果内部代码未引发异常,则仅将其标记为noexcept。
分配从本质上讲分两个步骤:拆除对象的旧状态和将其新状态构建为其他对象状态的副本。
基本上,这就是析构函数和复制构造函数的工作,因此第一个想法是将工作委托给他们。但是,由于销毁一定不会失败,而在构建可能会失败的情况下,我们实际上希望以另一种方式进行:首先执行建设性部分,如果成功,则进行破坏性部分。复制和交换的习惯是一种做到这一点的方法:它首先调用类的复制构造函数以创建一个临时对象,然后将其数据与该临时对象交换,然后让该临时对象的析构函数销毁旧状态。
以来swap()
应该永远不会失败,唯一可能失败的部分就是复制构造。首先执行该操作,如果失败,则目标对象将保持不变。
在其改进形式中,复制和交换是通过初始化赋值运算符的(非引用)参数执行复制来实现的:
T& operator=(T tmp)
{
this->swap(tmp);
return *this;
}
std::swap(this_string, that)
不提供无掷保证。它提供了强大的异常安全性,但没有不丢球的保证。
std::string::swap
(由调用std::swap
)。在C ++ 0x中,std::string::swap
is noexcept
并且一定不能抛出异常。
std::array
...)
已经有一些好的答案。我会专注主要集中在我认为它们缺少的内容上-对“复制和交换”习语的“缺点”进行解释。
什么是复制和交换习语?
根据交换函数实现赋值运算符的一种方式:
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;
复制构建或赋值的非指针数据成员可能会抛出,仍然有可能失败,导致某些数据成员被交换而另一些数据成员没有交换。std::string
当詹姆斯评论另一个答案时,这种潜力甚至适用于C ++ 03 。
@wilhelmtell:在C ++ 03中,没有提到std :: string :: swap(由std :: swap调用)可能引发的异常。在C ++ 0x中,std :: string :: swap为noexcept且不得引发异常。– James McNellis 10年12月22日在15:24
•当从一个不同的对象进行分配时,分配运算符的实现似乎很理智,很容易因自赋值而失败。尽管客户端代码甚至可能尝试自我分配似乎是不可想象的,但是在容器上进行算法操作期间,它可能相对容易发生,其中x = f(x);
代码f
(可能仅用于某些#ifdef
分支)是宏ala #define f(x) x
或函数返回对的引用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_);
}
...
};
在自赋值时,上述代码delete的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
(如果有用,也许会发送“重置”代码),而复制和交换方法将调用复制构造函数,该复制构造函数可能被编写为打开一个独特的套接字连接,然后关闭原始的套接字连接。这不仅意味着远程网络交互,而不是简单的进程中变量副本,还可能违反客户端或服务器对套接字资源或连接的限制。(当然,此类有一个非常恐怖的界面,但这是另一回事;-P)。
Client
是禁止禁止分配。
该答案更像是对以上答案的添加和稍作修改。
在某些版本的Visual Studio(可能还有其他编译器)中,存在一个确实令人讨厌且没有道理的错误。因此,如果您这样声明/定义swap
函数:
friend void swap(A& first, A& second) {
std::swap(first.size, second.size);
std::swap(first.arr, second.arr);
}
...当您调用swap
函数时,编译器会大喊大叫:
这与friend
调用函数和将this
对象作为参数传递有关。
一种解决方法是不使用friend
关键字并重新定义swap
函数:
void swap(A& other) {
std::swap(size, other.size);
std::swap(arr, other.arr);
}
这次,您可以调用swap
并传入other
,从而使编译器满意:
毕竟,您不需要使用friend
函数来交换2个对象。使swap
一个具有一个other
对象作为参数的成员函数同样有意义。
您已经可以访问this
对象,因此将其作为参数传递在技术上是多余的。
friend
调用函数时,似乎都会发生错误。*this
参数
在处理C ++ 11样式的可识别分配器的容器时,我想加个警告。交换和赋值具有微妙的语义。
具体来说,让我们考虑一个container std::vector<T, A>
,其中A
是一些有状态的分配器类型,我们将比较以下函数:
void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{
a.swap(b);
b.clear(); // not important what you do with b
}
void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
a = std::move(b);
}
这两种功能的目的fs
,并fm
为给a
该国b
已经开始。但是,存在一个隐藏的问题:如果发生什么情况a.get_allocator() != b.get_allocator()
?答案是:这取决于。让我们来写AT = std::allocator_traits<A>
。
如果AT::propagate_on_container_move_assignment
为std::true_type
,则fm
重新分配的分配器a
使用的值b.get_allocator()
,否则不a
分配,并继续使用其原始分配器。在这种情况下,数据元素需要被单独地交换,因为的存储a
和b
不兼容。
如果 AT::propagate_on_container_swap
为std::true_type
,fs
则以预期的方式交换数据和分配器。
如果AT::propagate_on_container_swap
为std::false_type
,则需要动态检查。
a.get_allocator() == b.get_allocator()
,则两个容器使用兼容的存储,并且交换将以通常的方式进行。a.get_allocator() != b.get_allocator()
,则程序具有未定义的行为(请参见[container.requirements.general / 8]。结果是,一旦您的容器开始支持有状态分配器,交换就已经成为C ++ 11中不平凡的操作。这在某种程度上是“高级用例”,但并非完全不可能,因为一旦类管理资源,移动优化通常才变得有趣,而内存是最受欢迎的资源之一。