Part III: Tools for Class Authors
Chapter 13. Copy Control
当定义一个类时,显式或隐式地指定当该类类型的对象被复制、赋值、移动、销毁时发生什么。类通过定义 5 种特殊的成员函数控制这些操作:
- 复制构造函数 (copy constructor)
- 复制赋值运算符 (copy-assignment operator)
- 移动构造函数 (move constructor)
- 移动赋值运算符 (move-assignment operator)
- 析构函数 (destructor)
这些操作被称为复制控制 (copy control)。
如果一个类没有定义所有的复制控制成员,则编译器会自动定义缺少的操作。
13.1 复制、赋值和销毁
复制构造函数
如果构造函数的第一个形参是对该类类型的引用,并且任何其他参数都具有默认值,则该构造函数是复制构造函数。
class Foo {
public:
Foo(); // default constructor
Foo(const Foo&); // copy constructor
// ...
};
第一个形参几乎总是 const 引用,虽然可以定义接受非const引用的复制构造函数。
在某些情况下,复制构造函数会被隐式地使用。因此,复制构造函数通常不应该是 explicit。
合成复制构造函数
当没有为一个类定义一个复制构造函数时,编译器会合成一个。与合成默认构造函数不同,即使定义了其他构造函数,复制构造函数仍然会合成。
对某些类来说,合成复制构造函数 (synthesized copy constructor) 阻止我们复制该类类型的对象。除此以外,合成复制构造函数以成员为单元逐一复制 (memberwise copy) 其参数的成员到正在被创建的对象中。编译器将每个非static成员依次从给定对象复制到正在创建的对象中。
每个成员的类型决定了该成员如何被复制:类类型的成员通过该类的复制构造函数来复制;内置类型的成员直接复制。尽管不能直接复制数组,合成复制构造函数通过复制每个元素复制数组类型的成员。
class Sales_data {
public:
// other members and constructors as before
// declaration equivalent to the synthesized copy constructor
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// equivalent to the copy constructor that would be synthesized for Sales_data
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // uses the string copy constructor
units_sold(orig.units_sold), // copies orig.units_sold
revenue(orig.revenue) // copies orig.revenue
{ } // empty body
复制初始化
string dots(10, '.'); // direct initialization
string s(dots); // direct initialization
string s2 = dots; // copy initialization
string null_book = "9-999-99999-9"; // copy initialization
string nines = string(100, '9'); // copy initialization
当使用直接初始化时,要求编译器使用普通函数匹配,来选择最佳匹配实参的构造函数。
当使用复制初始化时 (copy initialization),要求编译器将右侧运算对象复制到正在创建的对象中,如果需要,将运算对象进行类型转换。
复制初始化通常使用复制构造函数来完成。有时会使用移动构造函数来完成复制初始化。
复制初始化发生在以下情况:
- 使用
=
定义变量 - 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回非引用类型的函数中返回一个对象
- 使用花括号列表初始化数组中的元素或聚合类的成员
有些类类型会对它们分配的对象使用复制初始化。例如,当初始化容器,或调用 insert 或 push 成员时,库容器复制初始化它们的元素。相反,通过 emplace 成员创建元素是直接初始化。
形参和返回值
复制构造函数用来初始化类类型的非引用形参,这一事实解释了为什么复制构造函数本身的形参必须是引用。
如果形参不是引用,调用永远不会成功 —— 为了调用复制构造函数,需要使用复制构造函数复制实参,为了复制实参,需要调用复制构造函数,如此无限循环。
复制初始化的限制
如果使用的初始值需要通过 explicit 构造函数进行转换,那么使用复制初始化还是直接初始化很重要。
vector<int> v1(10); // ok: direct initialization
vector<int> v2 = 10; // error: constructor that takes a size is explicit
void f(vector<int>); // f's parameter is copy initialized
f(10); // error: can't use an explicit constructor to copy an argument
f(vector<int>(10)); // ok: directly construct a temporary vector from an int
当传递一个实参或从函数中返回值时,不能隐式使用一个 explicit 构造函数。
编译器可以绕过复制构造函数
在复制初始化过程中,编译器允许(不是强制)跳过复制/移动构造函数,直接创建对象。
string null_book = "9-999-99999-9"; // copy initialization
string null_book("9-999-99999-9"); // compiler omits the copy constructor
复制赋值运算符
正如类控制该类的对象如何被初始化的,它还控制该类的对象如何赋值的。
Sales_data trans, accum;
trans = accum; // uses the Sales_data copy-assignment operator
如果类没有定义自己的复制赋值运算符,编译器会合成一个。
重载赋值
重载运算符 (overloaded operator) 是函数,它的名字由 operator
后接要定义的运算符符号组成。
因此,赋值运算符是名为 operator=
的函数。
重载运算符的形参表示运算符的运算对象。
一些运算符,包括赋值,必须定义成成员函数。当运算符是成员函数时,左侧运算对象绑定到隐式 this 形参。二元运算符的右侧运算对象作为显式形参传递。
复制赋值运算符接受一个与其所在类相同类型的实参。
class Foo {
public:
Foo& operator=(const Foo&); // assignment operator
// ...
};
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成复制赋值运算符
如果类未定义自己的复制赋值运算符,编译器会为类生成一个合成复制赋值运算符 (synthesized copy-assignment operator)。
对于一些类,合成复制赋值运算符不允许赋值。除此以外,它使用成员类型的复制赋值运算符,将右侧对象的每个非static成员赋值到左侧对象的对应成员。
// equivalent to the synthesized copy-assignment operator
Sales_data& Sales_data::operator=(const Sales_data &rhs) {
bookNo = rhs.bookNo; // calls the string::operator=
units_sold = rhs.units_sold; // uses the built-in int assignment
revenue = rhs.revenue; // uses the built-in double assignment
return *this; // return a reference to this object
}
析构函数
析构函数释放对象使用的资源,销毁对象的非static成员。
析构函数是一个成员函数,名字由波浪线 ~
和类名组成。没有返回值,不接受形参。因此,不能重载。对于一个给定的类,只有一个析构函数。
class Foo {
public:
~Foo(); // destructor
// ...
};
析构函数完成什么工作
析构函数有一个函数体和析构部分。先执行函数体,然后销毁成员。成员按照它初始化顺序的逆序销毁。
析构函数是隐式的。
当成员销毁时发生什么取决于成员的类型。类类型的成员通过运行成员自己的析构函数销毁。内置类型没有析构函数,所以销毁内置类型成员时,什么也不做。
内置指针类型的成员的隐式销毁不会 delete 该指针指向的对象。
智能指针是类类型,有析构函数。因此,智能指针成员在析构阶段会被自动销毁。
析构函数什么时候会被调用
无论何时一个对象被销毁,就会自动调用其类型的析构函数:
- 变量离开作用域时被销毁。
- 当对象被销毁时,该对象的成员被销毁。
- 当容器被销毁时,容器(库容器或数组)中的元素被销毁。
- 当 delete 运算符应用到指向动态分配对象的指针时,销毁动态分配对象。
- 当创建临时对象的完整表达式结束时,销毁临时对象。
{ // new scope
// p and p2 point to dynamically allocated objects
Sales_data *p = new Sales_data; // p is a built-in pointer
auto p2 = make_shared<Sales_data>(); // p2 is a shared_ptr
Sales_data item(*p); // copy constructor copies *p into item
vector<Sales_data> vec; // local object
vec.push_back(*p2); // copies the object to which p2 points
delete p; // destructor called on the object pointed to by p
} // exit local scope; destructor called on item, p2, and vec
// destroying p2 decrements its use count; if the count goes to 0, the object is freed
// destroying vec destroys the elements in vec
当指向一个对象的引用或指针离开作用域时,析构函数不会运行。
合成析构函数
如果类没有定义自己的析构函数,编译器会为它定义合成析构函数 (synthesized destructor)。
对于一些类,合成析构函数被定义成不允许该类型的对象被销毁。除此以外,合成析构函数具有空函数体。
// equivalent to the synthesized Sales_data destructor
class Sales_data {
public:
// no work to do other than destroying the members, which happens automatically
~Sales_data() { }
// other members as before
};
三/五法则
有三种基本操作来控制类对象的复制:复制构造函数、复制赋值运算符、析构函数。在C++11标准中,类还可以定义移动构造函数和移动赋值构造运算符。
需要析构函数的类需要复制和赋值
当决定一个类是否需要定义其自己的复制控制成员版本时,要使用的一条经验法则是,首先确定该类是否需要析构函数。
通常,对析构函数的需求比对复制构造函数或赋值运算符的需求更为明显。
如果该类需要一个析构函数,则几乎肯定需要一个复制构造函数和一个复制赋值运算符。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
~HasPtr() { delete ps; }
// WRONG: HasPtr needs a copy constructor and copy-assignment operator
private:
std::string *ps;
int i;
};
上面的类使用合成版本的复制和赋值。这些函数复制指针成员,意味着多个 HasPtr 对象可能指向同一个内存空间:
HasPtr f(HasPtr hp) // HasPtr passed by value, so it is copied
{
HasPtr ret = hp; // copies the given HasPtr
// process ret
return ret; // ret and hp are destroyed
}
析构函数会 delete 对象 ret 和 hp 中的指针成员。但这两个对象包含同样的指针值。代码会 delete 这个指针两次,这是错误的。
HasPtr p("some values");
f(p); // when f completes, the memory to which p.ps points is freed
HasPtr q(p); // now both p and q point to invalid memory!
需要复制的类需要赋值,反之亦然
第二个经验法则:如果一个类需要一个复制构造函数,它几乎肯定需要一个复制赋值运算符。反之亦然——如果该类需要赋值运算符,则几乎肯定也需要一个复制构造函数。
但是,需要复制构造函数或复制赋值运算符都不(必要)表明需要析构函数。
使用 =default
可以通过将复制控制成员定义成 =default
,来显式要求编译器生成合成的版本。
class Sales_data {
public:
// copy control; use defaults
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
// other members as before
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;
如果在类内的成员声明中指定 =default,合成函数是隐式内联的。
如果不想合成的成员是内联函数,可以在函数的定义指定 =default,像复制赋值运算符定义的那样。
只能在有合成版本的成员函数中使用 =default。
阻止复制
大多数类应隐式或显式定义默认构造函数、复制构造函数以及复制赋值运算符。
但对于某些类,复制和赋值操作没有合理意义。在这种情况下,定义类时必须阻止复制或赋值。例如,iostream 类阻止复制,以避免多个对象写入或读取同样的IO缓冲区。
定义删除的函数
在C++11标准下,可以通过将复制构造函数和复制赋值运算符定义成删除的函数 (deleted function) 来阻止复制。
删除的函数:声明,但不能以任何方式使用。在函数的形参列表后面添加 = delete
表面该函数是删除的函数。
struct NoCopy {
NoCopy() = default; // use the synthesized default constructor
NoCopy(const NoCopy&) = delete; // no copy
NoCopy &operator=(const NoCopy&) = delete; // no assignment
~NoCopy() = default; // use the synthesized destructor
// other members
};
= delete
必须出现在删除的函数的第一次声明中。
可以对任何函数指定 = delete
。
析构函数不应是删除的成员
如果析构函数被删除,就无法销毁此类型的对象。
如果某个类型具有删除的析构函数,那么编译器不允许定义该类型的变量或创建该类型的临时对象。
如果某个类的一个成员类型具有删除的析构函数,那么不能定义该类的变量或临时对象。
虽然不能定义这种类型的变量或成员,但可以动态分配具有删除的析构函数的对象。但是不能释放这些对象。
struct NoDtor {
NoDtor() = default; // use the synthesized default constructor
~NoDtor() = delete; // we can't destroy objects of type NoDtor
};
NoDtor nd; // error: NoDtor destructor is deleted
NoDtor *p = new NoDtor(); // ok: but we can't delete p
delete p; // error: NoDtor destructor is deleted
合成的复制控制成员可能是删除的
对于某些类,编译器将这些合成的成员定义成删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的(例,private),那么该类的合成析构函数被定义为删除的。
- 如果类的某个成员的复制构造函数是删除的或不可访问的,或者类的某个成员的析构函数是删除的或不可访问的,那么该类的合成复制构造函数被定义为删除的。
- 如果类的某个成员的复制赋值运算符是删除的或不可访问的,或者类有一个 const 或引用成员,那么该类的合成复制赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或者类有一个没有类内初始值的引用成员,或者类有一个 const 成员,该成员没有显式定义一个默认构造函数,且该成员没有类内初始值,那么该类的合成默认构造函数定义为删除的。
本质上,如果类的某个数据成员不能默认构造、复制、赋值或销毁,那么类对应的复制控制成员被合成为删除的。
private 复制控制
在C++11标准之前,类通过将其复制构造函数和复制赋值运算符声明为 private 来阻止复制。
class PrivateCopy {
// no access specifier; following members are private by default
// copy control is private and so is inaccessible to ordinary user code
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopy&);
// other members
public:
PrivateCopy() = default; // use the synthesized default constructor
~PrivateCopy(); // users can define objects of this type but not copy them
};
因为析构函数是 public,所以使用者可以定义 PrivateCopy 对象。
因为复制构造函数和复制赋值运算符是 private,所以用户的代码不能复制这个对象。但是,友元和该类的成员仍然可以进行复制操作。为了阻止友元和成员的复制,将这些复制成员声明为 private 但不定义它们。
声明但不定义一个成员函数是合法的,对此有一个例外(第15章)。
试图使用一个未定义成员会导致链接错误。
13.2 复制控制和资源管理
通常,管理不在类中的资源的类必须定义复制控制成员。这种类需要析构函数来释放对象分配的资源。一旦一个类需要析构函数,那么几乎可以确定还需要复制构造函数和复制赋值运算符。
为了定义这些成员,首先必须确定复制此类型的对象意味着什么。通常有两个选择:可以定义复制操作使类的行为像一个值或像一个指针。
- 行为像值的类有它们自己的状态。当复制一个像值的对象时,复制的和原来的对象相互独立。改变副本不会影响原来的对象,反之亦然。库容器和 string 的行为像值。
- 行为像指针的类分享状态。当复制这样的类的对象时,复制的和原来的对象使用相同的底层数据。改变副本会影响原来的对象,反之依然。shared_ptr 类提供类似指针的行为。
IO类型和 unique_ptr 不允许复制和赋值,所以它们的行为既不像值也不像指针。
以 HasPtr 类为例,说明上面的两种方式。通常,类直接复制内置类型(除了指针)成员;这样的成员是值,所以通常应该使它们的行为像值。如何复制指针成员决定了像 HasPtr 这样的类的行为像值还是像指针。
行为像值的类
为了提供像值的行为,对于类管理的资源,每个对象必须拥有自己的副本。这意味着对于 ps 指向的 string,每个 HasPtr 对象必须有自己的副本。为了实现类值行为,HasPtr 需要:
- 复制构造函数,复制 string,而不只是指针
- 析构函数,释放 string
- 复制赋值运算符,释放对象存在的 string,从右侧运算对象复制 string
class HasPtr {
public:
HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
// each HasPtr has its own copy of the string to which ps points
HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
关键概念:赋值运算符
当编写赋值运算符时,需要记住两点:
- 大多数赋值运算符组合了析构函数和复制构造函数的工作。类似析构函数,赋值操作销毁左侧对象的资源。类似复制构造函数,赋值操作从右侧对象复制数据。
- 如果对象赋值给自己,赋值运算符必须正确工作。此外,在可能的情况下,注意在发生异常时将左侧操作对象保持在合理的状态。
编写赋值运算符时,一个好的模式是首先将右侧操作对象复制到本地临时对象中。复制完成后,可以安全地销毁左侧操作对象的现有成员。销毁左侧操作对象后,将数据从临时对象中复制到左侧操作对象的成员中。
类值复制赋值运算符
在本例中,通过先复制右侧运算对象处理自赋值情况,并保证代码安全。复制完成后,释放左侧资源,更新指针指向新分配的 string。
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
auto newp = new string(*rhs.ps); // copy the underlying string
delete ps; // free the old memory
ps = newp; // copy data from rhs into this object
i = rhs.i;
return *this; // return this object
}
定义行为像指针的类
对于行为类似指针的 HasPtr 类,需要复制构造函数和复制赋值运算符,来复制指针,而不是指针指向的 string。
类仍然需要析构函数释放分配的内存。但在本例中,析构函数不能单方面地释放它关联的 string。只有当最后一个指向此 string 的 HasPtr 消失时,才可以释放它。
使类的行为像指针的最简单方式是,在类内使用 shared_ptr 管理资源。
如果想要直接管理资源,可以使用引用计数 (reference count)。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(复制构造函数除外)都创建一个计数器。该计数器将跟踪有多少对象与正在创建的对象共享状态。创建对象时,只有一个这样的对象,因此将计数器初始化为 1。
- 复制构造函数不分配新的计数器;而是复制给定对象的数据成员,包括计数器。复制构造函数递增这个共享的计数器,表示这个对象有一个新的使用者。
- 析构函数递减计数器,表示这个分享的状态少了一个使用者。如果计数器变为 0,析构函数释放这个状态。
- 复制赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,复制赋值运算符必须销毁左侧运算对象的状态。
唯一的难题是确定在哪里存放引用计数。计数器不能直接作为 HasPtr 的成员。
解决这个问题的一个方式是,在动态内存中存储计数器。当创建一个对象时,分配一个新的计数器。复制或赋值一个对象时,复制指向计数器的指针。
定义一个使用引用计数的类
class HasPtr {
public:
// constructor allocates a new string and a new counter, which it sets to 1
HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// copy constructor copies all three data members and increments the counter
HasPtr(const HasPtr &p): ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // member to keep track of how many objects share *ps
};
类似指针的复制成员“篡改”引用计数
析构函数:
HasPtr::~HasPtr() {
if (--*use == 0) { // if the reference count goes to 0
delete ps; // delete the string
delete use; // and the counter
}
}
赋值运算符必须考虑到自赋值:
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
++*rhs.use; // increment the use count of the right-hand operand
if (--*use == 0) { // then decrement this object's counter
delete ps; // if no other users
delete use; // free this object's allocated members
}
ps = rhs.ps; // copy data from rhs into this object
i = rhs.i;
use = rhs.use;
return *this; // return this object
}
13.3 交换
除了定义复制控制成员之外,管理资源的类通常还定义一个名为 swap
的函数。
如果一个类定义了自己的 swap,那么算法使用类特定的版本,否则使用库定义的 swap 函数。
理论上,交换两个对象需要一次复制操作和两次赋值操作。
HasPtr temp = v1; // make a temporary copy of the value of v1
v1 = v2; // assign the value of v2 to v1
v2 = temp; // assign the saved value of v1 to v2
理论上,内存分配是不必要的。
string *temp = v1.ps; // make a temporary copy of the pointer in v1.ps
v1.ps = v2.ps; // assign the pointer in v2.ps to v1.ps
v2.ps = temp; // assign the saved pointer in v1.ps to v2.ps
编写自己的 swap 函数
class HasPtr {
friend void swap(HasPtr&, HasPtr&); // other members as in § 13.2.1
};
inline
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // swap the pointers, not the string data
swap(lhs.i, rhs.i); // swap the int members
}
与复制控制成员不同,swap 不是必要的。但是,对于分配资源的类,定义 swap 是一个重要优化。
swap 函数应该调用 swap,而不是 std::swap
在 HasPtr 类中,数据成员是内置类型。内置类型没有特定版本的 swap。在本例中,这些调用会调用库 std::swap。
然而,如果类的成员有自己类型特定的 swap 函数,调用 std::swap 是错误的。
例如,假定有一个命名的类 Foo 有一个名为 h 的成员,成员类型是 HasPtr。
为 Foo 编写一个 swap 函数来避免不必要的复制:
void swap(Foo &lhs, Foo &rhs) {
// WRONG: this function uses the library version of swap, not the HasPtr version
std::swap(lhs.h, rhs.h);
// swap other members of type Foo
}
上面的代码会编译和执行。但是与简单使用默认版本的 swap 相比,这段代码没有任何性能区别。
编写这个 swap 函数的正确方式如下:
void swap(Foo &lhs, Foo &rhs) {
using std::swap;
swap(lhs.h, rhs.h); // uses the HasPtr version of swap
// swap other members of type Foo
}
如果存在类型特定版本的 swap,其匹配程度会优于 std 定义的版本,原因见第16章。
在赋值运算符中使用 swap
定义 swap 的类通常使用 swap 来定义其赋值运算符。这些运算符使用名为复制和交换 (copy and swap) 的技术。这种技术将左侧运算对象与右侧运算对象的副本进行交换。
// note rhs is passed by value, which means the HasPtr copy constructor
// copies the string in the right-hand operand into rhs
HasPtr& HasPtr::operator=(HasPtr rhs) {
// swap the contents of the left-hand operand with the local variable rhs
swap(*this, rhs); // rhs now points to the memory this object had used
return *this; // rhs is destroyed, which deletes the pointer in rhs
}
使用复制和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。