【C++ primer】第13章 复制控制 (1)


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
}

使用复制和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。


【C++ primer】目录

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值