一个类的拷贝控制操作包含:
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
其中:
- 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时的操作。
- 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时的操作。
- 析构函数定义了当此类型对象销毁时的操作。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺省的操作。对一些类来说,依赖于这些操作的默认定义会导致灾难。
拷贝构造函数
如果一个构造函数的第一个参数是自身类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数:
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
};
- 拷贝构造函数的第一个参数必须是引用类型。
- 虽然可以定义一个接受非
const
引用的拷贝构造函数,但是此参数几乎总是const
的。 - 拷贝构造函数在一些情况下会隐式使用,因此拷贝构造函数通常不定义成
explicit
。
合成拷贝构造函数
如果没有自定义拷贝构造函数,编译器会自动生成一个,与合成默认构造函数不同,即使定义了其他构造函数,编译器也会合成一个拷贝构造函数。
一般情况,合成的拷贝构造函数会从给定对象中依次将每个非 static
成员拷贝到正在创建的对象中,每个成员的类型决定了如何拷贝:
- 对类类型的成员,会使用其拷贝构造函数来拷贝;
- 内置成员则直接拷贝;
- 虽然不能直接拷贝一个数组,但合成构造函数会逐个元素拷贝一个数组中的成员,如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
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.orig),
units_sold(orig.units_sold),
revenue(orig.revenue),
{}
拷贝初始化
string dots(10,'.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string s3 = "9-999-9"; //拷贝初始化
string s4 = string(100,'9'); //拷贝初始化
当使用直接初始化时,实际上要求编译器使用普通的函数匹配来选择与提供参数最匹配的构造函数。
当使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要还要进行类型转换。
拷贝初始化将依靠拷贝构造函数和移动构造函数来完成。
不仅仅在使用= 定义变量时发生拷贝初始化,如下情况也会发生拷贝初始化:
- 将一个对象作为实参传递给一个非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回对象。
- 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员。
- 某些类类型还会对它们所分配的对象使用拷贝初始化,当初始化标准库容器时,调用其
insert
或push
成员,容器会对其元素进行拷贝初始化,与之相对,用emplace
创建的成员执行直接初始化。
参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化,类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型:如果参数不是引用类型,则调用永远不会成功------为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,又需要调用拷贝构造函数,如此无限循环。
拷贝初始化的限制
如果使用的初始化值要求通过一个 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-9"; //拷贝初始化
改写为:
string null_book("9-999-9"); //编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在并且可访问的,例如,不能是是 private
的。
拷贝赋值运算符
重载赋值运算符
重载运算符本质上是函数,其名字由关键字 operator
接表示要定义的运算符的符号组成。
重载运算符的参数表示运算符的运算对象,某些运算符,包括赋值运算符,必须定义为成员函数,如果一个运算符是一个成员函数,其左侧运算对象就绑定到了隐式的 this
参数。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
如果未自定义拷贝赋值运算符,编译器会合成一个。
合成拷贝赋值运算符会将右侧对象的每个非 static 成员赋予左侧运算对象的对应的成员,这一过程是通过成员类型的拷贝赋值运算符来完成的,对于数组类型的成员,将逐个赋值数组元素,合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
class Foo{
public:
Foo& operatoe=(const Foo&);
}
等价的合成拷贝赋值运算符:
Sales_data & Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo;
units_sold = rhs.units_sold;
revenue = rhs.revenue;
return *this;
}
析构函数
构造函数初始化对象的非 static
数据成员,还可以做一些其他的工作;析构函数释放对象使用的资源,并销毁对象的非 static
数据成员。
析构函数没有返回值,不接受参数:
class Foo{
public:
~Foo();
}
由于析构函数不接受参数,因此它不能被重载,对于一个给定的类,只会有一个析构函数。
析构函数执行的操作
析构函数首先执行函数体,然后按照成员初始化的逆序顺序来销毁成员。
在对象最后一次使用中,析构函数的函数体可以执行类设计者希望执行的任何收尾工作,通常,析构函数释放对象在生存期所分配的所有资源。
析构部分是隐式的,成员销毁时发生什么完全依赖于成员的类型,如果销毁类类型的成员则执行该成员自己的析构函数,如果销毁内置类型则无需其他操作。
注意:
隐式销毁一个内置指针类型的成员不会 delete
它指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数,因此智能指针成员在析构阶段也会被自动销毁。
析构函数的调用时机
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量离开其作用域时被销毁。
- 当一个对象被销毁,其成员也将被销毁。
- 容器(无论是标准容器还是数组)被销毁时,其元素也将被销毁。
- 对于动态分配的对象,当对指向它的指针引用
delete
时运算符时被销毁。 - 对于临时对象,当创建它的完整表达式结束时被销毁。
//新的局部作用域
{
Sales_data *p = new Sales_data();
auto p2 = make_shared<Sales_data>();
Sales_data item(*p);
vector<Sales_data> vec;
vec.push_back(*p2);
delete p; //对p指向的对象执行析构函数
} //退出局部作用域,对p2,item,vec,调用析构函数
//销毁p2会递减其引用计数,如果引用计数变为0,对象被释放
//销毁vec,也会将其内部的元素销毁
合成析构函数
当一个类未定义自己的析构函数,编译器会为它定义一个合成析构函数。合成析构函数的函数体是空的。
class Sales_data{
public:
~Sales_data(){}
}
在(空)析构函数体执行完毕之后,成员会被自动销毁。
析构函数体本身不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁,在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
三/五 法则
拷贝构造、拷贝赋值、析构、移动构造、移动赋值这些操作通常只定义一个或两个,而不必定义所有。但是通常这些操作被看作一个整体,通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见得。
需要析构函数的类也需要拷贝和赋值操作
当决定一个类是否要自定义拷贝控制成员时,一个基本原则就是首先确定这个类是否需要一个析构函数,如果一个类需要析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):ps,(new std::string(s),i(0)){}
~HasPtr(){delete ps;}
}
HasPtr
类的构造函数动态分配内存,因为合成析构函数不会delete一个指针数据成员,所以必须定义一个析构函数来释放构造函数分配的内存。
这里没有自定义拷贝构造函数和拷贝赋值运算符,所以编译器会合成默认的拷贝构造函数和拷贝赋值运算符,这些函数将简单的拷贝指针成员,这意味着多个 HasPtr
对象可能指向相同的内存:
HasPtr f(HasPtr hp) //HasPtr是一个传值参数,所以被拷贝
{
HasPtr ret = hp; //拷贝给指定的HasPtr
return ret; //ret 和 hp 被销毁
}
当函数返回,ret
和 hp
被销毁,这两个对象都会调用 HasPtr
的析构函数,此析构函数会 delete
ret
和 hp
中的指针成员,会造成该指针被 delete
两次,会发生未定义的错误。
此外:
HasPtr p("some value");
f(p); //f调用结束,p.ps指向的内存将被释放
HasPtr q(p); //p、q都将指向无效内存
需要拷贝操作的类也需要赋值操作,反之亦然
使用 =default
可以将拷贝控制成员函数定义为 =default
来显示地要求编译器生成合成版本。
class Sales_data{
public:
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data&) = default;
~Sales_data = defaut;
}
类内部使用 =default
修饰成员的声明时,合成的函数将隐式的声明为内联函数,如果不希望合成的成员是内联的,则应该在类外使用 =default :
Sales_data& Sales_data::operator=(const Sales_data&) = default;
注意:
只能对具有合成版本的成员函数使用 =default
,即默认构造函数或拷贝控制成员。
阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是显式的还是隐式的。但是对于一些类来说,这些操作时没有意义的,例如 iostream
类阻止了拷贝,以避免多个对象写入或读取相同的 IO
缓冲。
定义删除函数
新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除函数来阻止拷贝,删除函数是这样的函数:虽然声明了它们,但是不能以任何方式使用它们,在函数的参数列表后面加上 =delete
函数来指明。delete
的作用是通知编译器,不希望定义这样的成员。
struct Nocopy{
NoCopy() = default; //默认合成构造函数
NoCopy(const NoCopy&) = deleta; //阻止拷贝
NoCopy& operator=(const NoCopy&) = deleta; //阻止赋值
~NoCopy() = default; //默认合成析构函数
}
=delete 与 =default 的差别
=delete
必须出现在函数第一次声明的时候,编译器需要知道一个函数是删除的,以便禁止它。- 可以对任何函数指定
=delete
,但是只能对有默认合成的函数使用=default
。
析构函数不能是删除的成员
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
struct NoDtor{
NoDtor() = default;
~NoDtor() = delete;
}
NoDtor nd; //错误,NoDtor的析构函数是删除的,该成员无法被销毁
NoDtor* p = new NoDtor(); //正确
delete p; //错误,NoDtor的析构函数是删除的
合成的拷贝控制成员可能是删除的
对于某些类来说,编译器可能将合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或者是不可访问的(比如是private的),则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数定义成删除的或者不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或者不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的赋值运算符是删除的或者不可访问的,或者是类有一个
const
的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。 - 如果类的某个成员的析构函数是删除的或者不可访问的,或是类有一个引用成员,它没有类内初始化器,或者有一个
const
成员,它没有类内初始化器且未显示定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上来说,如果一个类有数据成员不能默认构造、拷贝、复制、销毁,则对应的成员函数将被定义为删除的。
- 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,原因是,如果没有这条规则,将会创建出无法销毁的对象。
- 对于具有引用成员或无法默认构造的
const
成员的类,编译器不会为其合成默认的构造函数。 - 如果一个类有
const
成员,则它不可能使用合成的拷贝赋值运算符,因为,此运算符试图赋值所有成员,而将一个新值赋值给一个const
对象显然是不可以的。 - 虽然可以将一个新值赋予一个引用成员,但这样做改变的是引用指向对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并非是所期望的,所以,对于有引用成员的类,合成赋值运算符被定义为删除的。
private 拷贝控制
新标准之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为 private
的类阻止拷贝。
class PrivateCopy{
PrivateCopy(const PrivateCopy &);
PrivateCopy& operator= (const PrivateCopy &);
public:
PrivateCopy();
~PrivateCopy();
}
拷贝构造函数和拷贝赋值运算符是 private
的,用户代码不能拷贝这个类型的对象,但是友元和成员函数仍旧可以拷贝对象,为了阻止友元和成员函数进行拷贝,可以将这些拷贝控制成员声明为 private
的,但不定义它们。
声明但不定义成员函数通常是合法的,将拷贝构造函数和拷贝赋值运算符是 private
的:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。