c++拷贝、赋值与销毁

一、拷贝构造函数
拷贝构造函数的第一个参数必须是该类的引用类型,其它的参数有默认值。
否则会一直陷入死循环。

1
2
3
4
5
6
class Foo
{
public:
Foo();
Foo(const Foo&); //copy constructor
};

通常不应该是explicit。

合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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(const Sales_data &orig):bookNo(orig.bookNo),//使用string的拷贝构造函数
units_sold(orig.units_sold),//拷贝orig.units_sold
revenue(orig.revenue)//拷贝orig.revenue
{}

拷贝初始化

1
2
3
4
5
string dots(10, '.');	//直接初始化
string s(dots); //直接初始化。书上是这么写,实际上也调用了拷贝构造函数
string s2 = dots; //拷贝初始化
string null_book = "9-9999-9999"; //拷贝初始化
string nines = string(100,'9'); //拷贝初始化

拷贝构造函数依靠拷贝构造函数或移动构造函数来完成的。

拷贝构造函数不仅在=定义变量时发生。在以下情况也会发生。

将一个对象作为实参传递给一个非引用类型的形参
从一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员

拷贝初始化的限制

使用explicit构造函数时,必须显式。

1
2
3
4
5
vector<int> v1(10);	//正确:直接初始化
vector<int> v2 = 10; //错误:接受大小参数的构造函数是explicit的
void f(vector<int>); //f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector

编译器可以绕过拷贝构造函数,直接创建对象

1
string null_book = "9-999-99999-9";	//拷贝初始化

编译器被允许改写为:

1
string null_book("9-999-99999-9");//略过了拷贝构造函数,调用string (const char* s);

二、拷贝赋值运算符

如果类为定义,编译器也会为它合成一个。

重载运算符

重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数,也有一个返回类型和一个参数列表。

重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

拷贝赋值运算符接受一个与其所在类相同类型的参数。

1
2
3
4
5
class Foo
{
public:
Foo& operator=(const Foo&); //赋值运算符
};

重要

拷贝赋值运算符,通常返回一个指向其左侧运算对象的引用,与内置类型一致。看下面代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class NewInt
{
public:
NewInt(int value = 0):value_(value){}
NewInt operator=(const NewInt& ni)
{
value_ = ni.value_;
return *this;
}
void print(){std::cout<<value_<<std::endl;}
private:
int value_;
};
int main()
{
NewInt ni_1(1);
NewInt ni_2(2);
NewInt ni_3(3);
(ni_3 = ni_2) = ni_1; //因为NewInt的copy-assignment operator返回的不是引用,所以将返回临时值,ni_1对临时值进行赋值,并不会改变ni_3。
ni_1.print();
ni_2.print();
ni_3.print();
int a= 1, b=2,c=3;
(c = b) = a; //因为内置类型int的copy-assignment operator返回的是引用,b和a将对其进行两次赋值操作。
std::cout<<a<<std::endl;
std::cout<<b<<std::endl;
std::cout<<c<<std::endl;

}

输出结果:

1
2
2    ni_3的值
1
2
1    c的值

合成拷贝赋值函数
将右侧运算对象的非static成员赋予左侧运算对象的对应成员。调用成员类型的拷贝赋值运算符。

1
2
3
4
5
6
7
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; //调用string::operator=
units_sold = rhs.units_sold; //内置int赋值
revenue = rhs.revenue; //内置double赋值
return *this;
}

三、析构函数

析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。

1
2
3
4
5
class Foo
{
public:
~Foo();
};

由于析构函数不接受参数,所以不能被重载。对于给定类,只会有唯一一个析构函数。
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

通常,析构函数释放对象在生存期分配的所有资源。

在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要。

1
2
3
4
5
6
7
8
9
10
{
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指向的对象执行析构函数
}//退出局部作用于;对item、p2和vec调用析构函数
//销毁p2会递减其引用计数;如果引用计数变为0,对象被释放
//销毁vec会销毁它的元素

当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。

四、三五法则

有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。这些操作通常应该被看作一个整体。新标准下,还可以定义移动构造函数和移动赋值运算符。

如果一个类需要析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class HasPtr
{
public:
HasPtr(const std::string &s = std::string()):ps(new std::string(s)),i(0){}
~HasPtr(){delete ps;} //错误:还需要拷贝构造函数和拷贝赋值运算符
private:
std::string *ps;
int i;
}

HasPtr f(HasPtr hp)
{
HasPtr ret = hp;
return ret; ret和hp被销毁,会导致指针被delete两次。
}

int main()
{
HasPtr p("some values");
f(p); //当f结束,p.ps指向的内存被释放
HasPtr q(p); //现在p和q都指向无效内存
}

需要拷贝操作的类也需要赋值操作,反之亦然。

五、使用=default

将拷贝控制成员定义为=default来显式地要求编译器声称合成的版本。

1
2
3
4
5
6
7
8
9
10
class Sales_data
{
public:
Sales_data() = default;
Sales_data(const Sales_data&) = default;//类内用=default声明,隐式地声明为内联的。
Sales_data& operator=(const Sales_data&);
~Sales_data() = default;
};

Sales_data& Sales_data::operator=(const Sales_data&) = default;//类外声明,不是内联的。

六、阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式的。
某些类不需要拷贝构造函数和赋值运算符,必须采用某种机制阻止。例如,iostream类阻止拷贝,以避免多个对象写入或读取相同的IO缓冲。如果不定义拷贝控制成员,编译器会为它合成,这种策略无效。

定义删除的函数

在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数:虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete。

1
2
3
4
5
6
struct NoCopy{
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
~NoCopy() = default;
};
=delete必须出现在函数第一次声明的时候。=default直到编译器生成代码时才需要;编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。
可以对任何函数指定=delete。

析构函数不能是删除的成员

如果析构函数被删除,就无法销毁此类型的对象了。对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。

1
2
3
4
5
6
7
struct NoDtor{
NoDtor() = default;
~NoDtor() = delete;
};
NoDtor nd; //错误:NoDtor的析构函数是删除的
NoDtor *p = new DoDtor(); //正确:但我们不能delete p
delete p; //错误:NoDtor的析构函数是删除的

如果一个类有数据成员不能有默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的。如果没有这条规则,我们可能会创建出无法销毁的对象。

对于具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。
    如果一个类有const成员,则它不能使用合成的拷贝赋值运算符,将一个新值赋予一个const对象是不可能的。
    虽然可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。这不是我们期望的。因此对于有引用成员的类,合成拷贝复制运算符被定义为删除的。

private拷贝控制(新标准下,不建议使用)

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。

1

class PrivateCopy {
	//拷贝控制成员是private,因此普通用户代码无法访问。
	PrivateCopy(const PrivateCopy&);
	PrivateCopy &operator=(const PrivateCopy&);
public:
	PrivateCopy() = default; // 使用合成的默认构造函数
	~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝它们 };

拷贝控制成员是private,此普通用户代码无法访问(否则在编译阶段被标记为错误);只声明未定义,阻止友元和成员函数进行拷贝(否则会导致链接时错误)。