一个类通过定义五种特殊的成员函数来控制对象拷贝、移动、赋值和销毁的操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符 和 析构函数,这些操作称为 拷贝控制操作。如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。有时编译器定义的默认操作可能并不满足我们的要求,而需要显式的重新定义某些操作。本章将详细介绍每个操作的细节。
1. 拷贝、赋值与销毁
以最基本的操作——拷贝构造函数、拷贝赋值运算符和析构函数作为开始。
1.1 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且其他额外的参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo& ); // 拷贝构造函数
// ...
};
拷贝函数的注意:
- 拷贝函数的第一个参数必须是引用类型(后面解释为什么)
- 第一个参数一般情况下都是定义为
const
- 拷贝函数会被隐式使用,所以拷贝函数通常不应该是
explicit
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,编译器会生成一个默认的拷贝构造函数,叫做合成拷贝构造函数。 默认构造函数在我们定义了其他任意的构造函数编译器就不会在自动生成,但是合成拷贝构造函数不同,即使我们定义了构造函数(没有定义拷贝构造函数),编译器也会生成默认的拷贝构造函数。
以 Sales_data
为例,合成拷贝构造函数的操作就是进行简单的 值拷贝,等价代码:
class Sales_data {
public:
// ...
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// 与 Sales_data 的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo),
units_sold(orig.units_sold),
revenue(orig.revenue)
{ }
合成拷贝构造函数在大多数的情况下是可以满足需求的,比如 Sales_data
这样的类。但是在类的成员变量中有引用类型的变量(指针),那么合成拷贝构造函数会出现浅拷贝(后面会详细讲解)。
拷贝初始化
直接初始化与拷贝初始化之间的差异
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-9999-9"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
直接初始化是调用参数匹配的普通有参构造函数,而当我们用 =
运算符初始化(注意,这里的 =
运算符是初始化而不是赋值操作)调用的是 拷贝构造函数 (拷贝初始化),将右侧的对象拷贝到正在创建的对象,如果需要的话还要进行类型转换。拷贝初始化并不总是调用拷贝构造函数,如果一个类定义了移动构造函数,那么有可能会调用移动构造函数,关于移动构造函数的工作机制后面会详细讲解。
拷贝初始化不仅在我们用 =
定义变量时会发生,在下列情况也会发生
-
将一个对象作为实参传递给一个非应用类型的形参
-
从一个返回类型为非引用的函数返回一个对象
// 调用 func 会发生两次拷贝初始化操作 // 1. 将一个 Sales_data 对象传递给形参 // 2. 返回一个 Sales_data 对象 Sales_data func(Sales_data item) { ret = item; // 对 ret 处理 return ret; }
-
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
// 聚合类 struct Data { int ival; string s; }; Data val1 = {0, "Anna"};
某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准库容器或是调用其 insert
或 push
成员(参见9.3.1节)时,容器对其元素进行拷贝初始化,用 emplace
成员创建的元素都进行直接初始化(参见9.3.1节)。
参数和返回值
在函数调用过程中,具有非引用类型的参数和具有非引用的返回类型都有拷贝初始化。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们有需要调用拷贝构造函数,如此无限循环。
拷贝初始化的限制
如果一个构造函数声明为 explicit
,那么我们就必须显式的调用
vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10; // 错误: 接受大小参数的构造函数是 explicit 的
void f(vector<int>); // f 的参数进行拷贝初始化
f(10); //错误: 不能用一个 explicit 的构造函数拷贝一个实参
f(vector<int>(10)); // 正确: 从一个 int 直接构造一个临时 vector
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象,即
string null_book = "9-999-9999-9"; // 拷贝初始化
// 编译器会改写为
string null_book("9-999-9999-9"); // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须存在且可访问(例如,不能是 private
的)。
1.2 拷贝赋值运算符
拷贝构造函数控制对象的初始化行为,赋值运算符重载控制对象之间的赋值操作
Sales_data trans, accum;
trans = accum; // 使用 Sales_data 的拷贝赋值运算符
赋值操作是调用拷贝赋值运算符,与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。
初始化化和赋值操作都是使用
=
,注意区分什么时候是初始化,什么时候是赋值操作(参考第2章)。
重载赋值运算符
拷贝赋值操作是通过重载 =
运算符来实现的,重载运算符本质上是函数,其名字由 operator
关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator=
的函数。类似于任何其他函数,运算符也有一个返回类型和一个参数列表。关于重载的详细介绍在第14章。
对于重载运算符的使用,如果一个重载运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this
参数,所以,成员函数的运算符重载的参数都是比实际需要的参数少一个。比如 =
是一个二元运算符,定义为成员函数,只需要一个参数。
class Foo {
public:
Foo& operator=(const Foo& rhs); // 赋值运算符
};
需要注意,标准库通常要求保存在容器容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。合成的赋值运算是将右侧的对象的属性逐个赋值到左侧对象。
// 等价于合成拷贝赋值运算符
Sales_data&
Sales_data::opertor=(const Sales_data &rhs)
{
bookNo = rhs.bookNo;
units_sold = rhs.untis_sold;
revenue = rhs.revenue;
return *this;
}
拷贝赋值运算符的调用
拷贝赋值运算符的调用和我们使用 =
运算的操作逻辑是一样的,这一点对于其他的重载操作符也是适用的。
Sales_data trans, accum;
trans = accum; // 使用 Sales_data 的拷贝赋值运算符
// 等价的形式,
trans.opertor=(accum);
operator=(trans, accum);
上面两种等价形式,第一种是正确的代码,第二种是把 this
显式的传递,语法上是错误的。通常我们不会像调用函数一样使用重载运算符,而是直接使用对应的操作符,编译器会将其自动转换为重载操作符对应的成员方法。
1.3 析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static
数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static
数据成员。
析构函数是类的一个成员函数,名字由波浪号加类名构成。它没有返回值,也不接受参数:
class Foo {
public:
~Foo(); // 析构函数
// ...
};
由于析构函数不接受参数,因此它不能被重载。对于一个给定类,只有唯一一个析构函数。
析构函数完成什么工作
如同构造函数有一个初始化部分(初始化列表)和一个函数体,析构函数也有一个函数体和一个析构部分。构造函数是先执行初始化后执行函数体,析构函数是先执行函数体后执行析构部分。析构函数的函数体是由类的设计者给出,不存在类似构造函数中初始化列表的东西来控制成员的如何销毁,析构部分是隐式的。
销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论时标准容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当指向它的指针应用
delete
运算符时被销毁 - 对于临时对象,当创建它的完整表达式结束时被销毁
由于析构函数自动运行,我们的程序可以按需分配资源,而(通常)无须担心何时释放这些资源(前提是我们自己或是别人设计的类的析构函数能够正常释放资源)。
{ // 新作用域
// p 和 p2 指向动态分配的对象
Sales_data *p = new Sales_data; // p 一个内置指针
auto p2 = make_shared<Sales_data>(); // p2 是一个 shared_ptr
Sales_data item(*p); // 拷贝构造函数将 *p 拷贝到 item 中
vector<Sales_data> vec; // 局部对象
vec.push_back(*p2); // 拷贝 p2 指向的对象
delete p; // 对p指向的对象执行析构函数
} // 退出局部作用域; 对 item、p2 和 vec 调用析构函数
// 销毁p2 会递减其引用计数; 如果引用计数变为0,对象被释放
// 销毁 vec 会销毁它的元素
1.4 三/五法则
三/五法则,“三”指的是三个基本的控制类拷贝的操作:拷贝构造函数、拷贝赋值函数 和 析构函数,“五”指的是新标准中类还可以定义一个移动构造函数和一个移动赋值函数。C++语言并没有要求我们定义所有的函数,可以只定义部分,但是有些操作通常是看作一个整体的,具体来说有以下准则
- 需要析构的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
1.5 使用 =default
C++11 我们可以通过将拷贝控制成员定义为 =default
来显示地要求编译器生成合成的版本
class Sales_data {
public:
Sales_data() = default;
Sales_data(const Sales_data& );
~Sales_data() = default;
//...
};
Sales_data::Sales_data(const Sales_data& ) = default;
我们只能对具有合成版本的成员函数使用
=default
1.6 阻止拷贝
有些类是不允许拷贝操作的,比如 iostream
,这样避免多个对象写入或读取相同的IO缓存。
C++11 在新标准中,我们可以通过将拷贝构造函数和拷贝赋值运算定义为 删除的函数 来阻止用户的拷贝行为
struct NoCopy {
NoCopy() = default; // 使用默认合成构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default;
};
=delete
通知编译器我们不希望定义这些成员。与 =default
不同,=delete
必须出现在函数第一次声明。
析构函数不能是删除的成员
如果析构函数被删除,就无法销毁此类型的对象了。
struct NoDtor {
NoDtor() = default; // 使用默认合成构造函数
~NoDtor() = delete;
};
NoDtor nd; // 错误: NoDtord 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确: 但我们不能 delete p
delete p; // 错误
以上代码中 nd
对象的释放是自动调用析构函数,而析构被定义为删除,所以错误;而 p
是由用户申请的内存空间用来存放对象,对象的释放是由用户管理的,所以这样定义可以,但是我们不能 delete 这个对象。
一些合成拷贝控制成员可能是删除的情况
- 类的成员的析构是删除的或不可访问(private)
- 类的某个成员的拷贝函数是删除的或不可访问
- 类的某个成员的拷贝赋值运算符是删除的或不了访问的
- 类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员
在新标准之前,通常为了阻止拷贝会把拷贝构造函数和拷贝赋值运算符声明为 private
,但是即使是私有的,友元或成员函数也是可以访问到的,所以我们只声明不定义。
2. 拷贝控制和资源管理
下面通过一个实例实现类的行为看起来像一个和像一个指针,两种类的区别主要在拷贝控制函数的定义
2.1 行为像值的类
为了是类的行为像值,对于类管理的资源,每个对象都应该拥有一份自己的拷贝,对象之间拥有的资源是独立的。这需要我们在进行对象拷贝时使用深拷贝。
class HasPtr {
public:
HasPtr(const std::string &s = std::string())
: ps(new std::string(s), i(0)) { }
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;
};
HasPtr& HasPtr::operator=(const HasPtr& rhs) {
auto newp = new std::string(*rhs.ps)
delete ps;
ps = newp;
i = rhs.i;
return *this;
}
2.2 定义行为像指针的类
行为像指针,我们在拷贝的时候拷贝的是指针而不是指向的对象,需要实现这种操作使用 shared_ptr
智能指针是最好的方法。我们这里不是用智能指针,通过引用计数的方式自己实现类资源的管理。
class HasPtr {
public:
// 构造函数分配新的string和新的计数器,计数器置为1
HasPtr(const std::string &s = std::string())
: ps(new std::string(s), i(0), use(new std::size_t(1))) { }
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; // 引用计数
};
HasPtr& HasPtr::operator=(const HasPtr& rhs) {
++*rhs.use; // 拷贝递增计数
if (--*use == 0) {
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
HasPtr::~HasPtr() {
if (--*use == 0) {
delete ps;
delete use;
}
}
3. 交互操作
对于交互两个类值的 HasPtr
我们的代码可能像这样
HasPtr temp = v1;
v1 = v2;
v2 = temp;
以上代码交换 v1, v2但是这个过程且进行了多次的拷贝操作。理论上。我们更希望swap
交换指针,而希望为类的成员 string 产生新的副本:
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
编写我们自己的 swap 函数
可以在我们的类上定义一个自己版本的swap来重载swap的默认行为
class HasPtr {
friend void swap(HasPtr &lhs, HasPtr &rhs);
};
inline void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 使用标准库的swap交换指针
swap(lhs.i, rhs.i); // 交换int 成员
}
4. 对象移动
C++11 新标准提供了移动操作,很多情况下我们拷贝一个对象后立即就被销毁,而用移动操作更加高效。
4.1 右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用 。所谓右值引用就是必须绑定到右值的引用,右值引用用 &&
获取。它有一个重要的性质,只能绑定到一个将要销毁的对象。
右值引用和我们之前的引用(为了区分,叫做左值引用)分别指向右值和左值,左值和右值表达式的区别:一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
int i = 42;
int &r = i; // 正确, r 左值引用绑定到左值表达式i
int &&rr = i; // 错误,rr右值引用不能绑定到一个左值
int &r2 = i * 42; // 错误, i * 42 是一个右值
const int &r3 = i * 42; // 正确 将一个const 引用绑定到一个右值上
int &&rr2 = i *42; // 正确,将rr2绑定到乘法结果上
左值持久;右值短暂
由于右值引用只能绑定到临时对象,我们得知
- 所引用的对象将要被销毁
- 该对象没有其他用户
变量是左值
变量是左值,因此我们不能将一个右值引用绑定到一个变量上,即使这个变量是右值引用类型也不行
int &&rr1 = 42; // 正确
int &&rr2 = rr1; // 错误 表达式rr1是左值
标准库 move 函数
我们可以使用标准库提供的 move
将一个左值转换为对应的右值引用类型,move
函数返回给定对象的右值引用
#include <utility> // 包含对应的头文件
int &&rr3 = std::move(rr1); // ok
调用 move
就意味着承诺:除了对 rr1
赋值或销毁它外,我们将不在使用它。
4.2 移动构造函数和移动赋值运算符
移动操作避免了拷贝是对资源的拷贝,移动构造函数和移动赋值运算符分别对应拷贝构造函数和拷贝赋值运算符
移动构造函数
第一个参数是给类类型的一个引用,这个引用是右值引用,其他额外的参数必须有默认实参
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。C++11 新标准提供noexcept
关键字声明构造函数是不会抛出异常的。
class StrVec {
StrVec(SteVec&&) noexcept; // 移动构造函数
};
StrVec::StrVec(SteVec&& s)noexcept : /* 列表初始化 */
{
//...
}
我们必须在头文件的声明中和定义中都指定 noexcept
移动赋值运算符
StrVec& StrVec::operator=(StrVec &&rhs) noexcept {
// 自我赋值
if ( this != &rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移动源对象必须可析构,因此我们将移动后的源对象中的指针置空。
移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝
4.3 右值引用和成员函数
除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同参数模式——个版本接受一个指向const的左值引用,第二个版本接受一个指向非const的右值引用。
void push_back(const X&); // 拷贝
void push_back(X&&); // 移动
我们可以将能转换为类型x的任何对象传递给第一个版本的push_back
。此版本从其参数拷贝数据。对于第二个版本,我们只可以传递给它非const
的右值。此版本对于非const
的右值是精确匹配(也是更好的匹配)的,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本。此版本会从其参数窃取数据。
**一般来说,我们不需要为函数操作定义接受一个const X&&
或是一个(普通的)X&
参数的版本。**当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const
的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&参数的版本。
区分移动和拷贝的重载函数通常有一个版本接受一个
const T&
,而另一个版本接受一个T&&
。
右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。
string s1 = "a value", s2 = "another";
auto n = (s1+s2).find('a');
我们甚至可以对一个右值赋值
s1 + s2 = "wow!"; // s1+s2 是一个右值
C++11 新标准可以使用 引用限定符 阻止这种行为,定义方式和 const
成员函数相同,和 const 成员函数一样,引用限定符也是修饰 this
.
class Foo {
public:
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
// ...
}
Foo &Foo::operator=(const Foo &rhs) & {
// ...
return *this;
}
引用限定符可以是&
或&&
,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中 。
对于 &
限定的函数,我们只能将它用于左值;对于 &&
限定的函数,只能用于右值
Foo& retFoo(); // 返回一个引用; retFoo 调用是一个左值
Foo retVal(); // 返回一个值;retVal 调用是一个右值
Foo i, j; // i 和 j 是左值
i = j; // 正确:i 是左值
retFoo() = j; // 正确:retFoo() 返回一个左值
retVal() = j; // 错误:retVal() 返回一个右值
i = revVal(); // 正确:我们可以将一个右值作为赋值操作的右侧运算对象
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:
class Foo {
public:
Foo someMem() & const; // 错误 const 限定符必须在前
Foo anotherMem() const &; // 正确:const 限定符在前
};
重载和引用函数
就像一个成员函数可以根据是否有const
来区分其重载版本一样,引用限定符也可以区分重载版本。而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。
class Foo {
public:
Foo sorted() &&;
Foo sorted() const & ;
private:
std::vector<int> data;
};
Foo Foo::sorted() &&{
std::sort(data.begin(),data.end());
return *this;
}
Foo Foo::sorted() const &{
Foo ret(*this);
std::sort(ret.data.begin(), ret.data.end());
return ret;
}
编译器会根据调用 sorted
的对象的左值/右值属性来确定使用哪个版本
retVal().sorted(); //retVal()是一个左值, 调用Foo::sorted() &&
retFoo().sorted(); //retFoo()是一个右值, 调用Foo::sorted() const &
当我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加:
class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // 错误: 必须加上引用限定符
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); // 正确:不同的参数列表
Foo sorted(Comp*) const; // 正确,两个版本都没有引用限定符
};
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。