定义一个类我们需要显式或者隐式的指定这个类在拷贝,赋值,移动,销毁时需要做什么,
我们需要定义拷贝构造函数,移动构造函数,拷贝赋值运算符,移动赋值运算符和析构函数来控制这些操作。
拷贝和移动构造函数定义了一个对象初始化本对象时,需要做什么。
拷贝和移动赋值运算符则决定了将 一个对象赋值给同类型的另一个对象时需要做什么。
析构函数定义了销毁对象时需要做什么。
这些5个成员函数被称为拷贝控制操作。
13.1 拷贝、赋值和销毁
13.1.1 拷贝构造函数
什么是拷贝构造函数?
如果一个构造函数的第一个参数是该类型的引用类型,且额外参数都有默认值,那么这个函数是拷贝构造函数。
默认的拷贝构造函数在执行时,将一个对象的所有非static数据成员拷贝给本对象。如果数据成员为类类型,则执行该类型的拷贝构造函数,如果数据成员为内置类型,则直接拷贝。
如果数据成员是数组类型,则将数组中的成员逐一拷贝到本对象中。在其他时候,数组是无法直接拷贝的。
什么时候执行拷贝初始化
1.使用=定义变量时
2.使用一个对象初始化另一个对象时
3.一个函数的形参类型为非引用类型,而传入的实参是对象时
4.返回值类型是非引用类型,返回的是一个对象时
5.使用初始化列表,初始化数组或者聚合类的成员时。
注意拷贝初始化有时调用的是拷贝构造函数,但是有时调用的是移动构造函数
更重点的是,有时编译器会跳过拷贝构造函数,直接执行普通的构造函数。
class A {
public:
A(int i=1) :a(i) {
cout<<"构造函数"<<endl;
};
A(const A& _a):a(_a.a) {
cout << "拷贝构造函数" << endl;
}
private:
int a;
};
A a1 = 1;
在书上A a1 = 1;这种定义变量的方式是拷贝初始化,但是在VS2017下,执行的是构造函数,且拷贝构造被设置为private同样可以执行。
但是下面这种情况执行的是构造函数
A a(1);
A a2 = a;
因此我们可以假定,默写编译器中使用=定义变量时,如果包含了隐式转换,则直接执行构造函数,而不是拷贝构造。
因此我们也可以更好的理解,为什么当一个构造函数时explicit的时候。
A a1 = 1;
是错误的,因为执行的是构造函数,而此时构造函数不允许隐式转换。
这是我在VS2017上得到的结论,具体还是以书上为主
即上面5种情况执行的都是拷贝初始化,当构造函数为explicit时,使用=进行隐式转换时无法调用拷贝初始化。
另外一点,拷贝构造函数的形参,一般为const类型,因为我们不会在拷贝一个对象时修改它。且最好不要将拷贝构造函数声明为explict,不然使用
A a(1);
A a2 = a;//报错
这种形式定义变量会报错。
对于标准库的容器,insert和push执行的是拷贝初始化,而emplace()是直接初始化。
练习
13.1
当一个类的构造函数的第一个参数是该类型的引用,且其他的参数都有默认实参时,这个构造函数就是拷贝构造函数。
class A {
public:
A(int i) :a(i) {
cout<<"构造函数"<<endl;
};
//private:
A(const A& _a):a(_a.a) {
cout << "拷贝构造函数" << endl;
}
private:
int a;
};
0.使用=定义变量时,我在VS2017的情况下,使用=定义变量调用的是构造函数而不是拷贝构造,即使把拷贝构造变为private,代码同样可以正常的运行
A a(1);
A a1 = 1;//VS2017下,显示的执行的是构造函数
A a2 = a1;//拷贝构造函数
1.使用另一个对象初始化本对象时
A a3(a2);
2.使用一个对象实参传递给一个非引用类型的形参
func(a2);
3.返回值为非引用类型的函数返回一个对象
A fun_1() {
A a(1);
return a;
}
fun_1();
4.用花括号列表来初始化数组中的元素或者聚合类中的成员。
A a(1);
A a2[] = {a,a,a,a,a};
13.2
根据上面的定义我们知道,我们传入的实参是一个对象,而一个函数的形参类型是非引用类型时,这个形参将执行拷贝构造函数。
如果拷贝构造函数的形参又是非引用类型的,那么这意味着我们要调用拷贝构造函数来初始化这个对象,这样一来就变成了死循环。
13.3
StrBlob只有一个数据成员
std::shared_ptr<vector<string>> data;
所以拷贝一个StrBlob时,将一个对象的data初始化本对象的data,因为shared_ptr<vector<string>>是类类型,所以执行的是它的拷贝构造函数。此时他们共同维护的对象引用计数+1
StrBlobPtr由三个数据成员
std::weak_ptr<vector<string>> wptr;
size_t curr;
拷贝StrBlobPtr对象时,将一个对象的wptr和cur的值用于初始化本对象的wptr和curr。size_t是内置类型,所以直接初始化,而wptr的类型是类类型,所以执行该类类型的拷贝构造函数。
13.4
1.形参arg的初始化将执行拷贝构造
2.local的初始化
3.head的初始化
4.pa数组的初始化
5.返回*heap类型
class A {
public:
A(int i=1) :a(i) {
cout<<"构造函数"<<endl;
};
A(const A& _a):a(_a.a) {
cout << "拷贝构造函数" << endl;
}
private:
int a;
};
A global(1);
A foo_bar(A arg) {
A local = arg, *heap = new A(global);
*heap = local;
A pa[4] = {local,*heap};
return *heap;
}
13.5
class HasPtr {
public:
HasPtr(const std::string& s = std::string()) :ps(new std::string(s)),i(0){};
HasPtr(const HasPtr& hasptr):ps(new string(*hasptr.ps)),i(hasptr.i) {
};
private:
std::string *ps;
int i;
};
13.1.2 拷贝赋值运算符
我们可以通过定义拷贝赋值运算符,自己定义一个类类型如何赋值。
拷贝赋值运算符实际是重载=运算符。
使用operator+运算符来重载运算符,operator+运算符构成函数名同定义函数一样,我们需要定义返回值和形参列表,有些运算符必须在类中重载,比如赋值运算符。
如果一个运算符是成员函数,那么在调用时左侧运算对象将绑定到隐式的this上。
对于赋值运算符,右侧运算对象将作为参数显式的传入,而函数返回的是左侧对象的引用。
对于拷贝赋值运算符,在使用一个对象为同类型的对象赋值时,如果数据成员是类类型,则执行该数据成员的拷贝赋值运算符,如果是内置类型,也是执行内置类型的拷贝赋值运算符。对于数组则和在拷贝构造函数中一样,逐个元素赋值。
练习
13.6
拷贝赋值运算符,实际就是在类中重载赋值运算符。
在为对象赋值时将调用拷贝赋值运算符。
当使用一个对象为另一个同类型对象赋值时调用。
如果我们没有定义赋值运算符,那么会生成一个合成的拷贝赋值运算符
13.7
对于StrBlob,在赋值时,将调用data的拷贝赋值运算符将一个对象的值赋值给另外一个,此时两个对象所指向的对象引用计数+1。
对于StrBlobPtr,在赋值时,将分别调用
std::weak_ptr<vector<string>> wptr;
size_t curr;
ptr和curr对应类型中定义的拷贝赋值运算符进行赋值。对于wptr,其所指向的对象引用计数不变。
13.8
class HasPtr {
public:
HasPtr(const std::string& s = std::string()) :ps(new std::string(s)),i(0){};
HasPtr(const HasPtr& hasptr):ps(new string(*hasptr.ps)),i(hasptr.i) {
};
//列表初始化只能在构造函数中使用
HasPtr& operator=(const HasPtr& p) {
ps = new string(*p.ps);
i = p.i;
};
private:
std::string *ps;
int i;
};
13.1.3 析构函数
析构函数的作用和是回收对象的资源,销毁对象。
使用波浪号~+类名定义析构函数,在析构函数的函数中执行我们想要在对象被释放和销毁之前需要做的操作。
一般来说这里会将动态分配的内置指针delete掉。
如果我们只定义析构函数,但是函数体内什么都不做,在析构函数的函数体执行完毕之后,会按照构造函数初始化数据成员的逆序回收对象非static数据成员的资源并销毁对象。
这个过程是隐式的,在析构函数函数体执行之后执行。如果数据成员是类类型,销毁该数据成员需要执行它的析构函数,如果是内置类型,则不需要执行析构,直接销毁。
注意指针也是内置类型,所以如果数据成员中由指针数据成员,而我们在析构函数中不对这个指针做delete操作,则这个指针只会销毁自身而不会销毁所指向的对象。
什么时候执行析构函数
一句话来说,由对象被销毁(生命周期结束),则需要执行该对象的析构函数。
具体来说
1.非静态局部变量离开作用域
2.变量被销毁时,其成员也会被销毁
3.容器被销毁时
4.动态分配的内存,使用内置指针,delete该指针时
4.临时变量表达式结束时
一定要记住回收对象的资源和销毁对象不是在析构函数的函数体内完成的,而是在执行完析构函数之后,隐式执行的。
现在感觉拷贝构造函数,赋值拷贝函数和析构函数很像是C++提供的接口,我们可以不自己定义,他会生成默认的版本,但是我们也可以自定义他们的操作。
练习
13.9
析构函数是类中用于回收为对象分配的资源,销毁对象。它的工作和构构造函数相反。
合成的析构函数用于回收对象的资源并销毁类的非static数据成员
没有显式定义析构函数时,编译器生成合成析构函数/
13.10
StrBlob对象销毁时会调用类类型数据成员的析构函数,其中数据成员data是类类型,所以调用shared_ptr<vector<string>>的析构函数。如果该对象data的指向的对象引用计数为0。则销毁data指向的对象
size_t类型是内置类型不用析构,而wptr是类类型,所以调用weak_ptr<vector<string>>的析构函数,回收资源销毁对象。
std::weak_ptr<vector<string>> wptr;
size_t curr;
13.11
~HasPtr() {
delete ps;
}
13.12
因为指针是内置类型不需要调用析构函数,所以调用哦个该函数,accum,item1,item2都会执行析构,一共三次。
13.13
需要注意的是,对于vec,当我们push_back参数时,可能因为当前分配的空间不够,所以需要重新分配空间,再将原来的元素拷贝进去,所以这里会执行拷贝构造函数和析构函数,但是这并不是每次push_back都会发生这样的情况。
struct X {
X() { cout << "X()" << endl; }
X(const X&) {
cout << "X(const X&)" << endl;
};
X& operator=(const X&) {
cout<<"operator="<<endl;
}
~X() {
cout << "~X()" << endl;
}
};
void f(X x) {
}
void f1(X& x) {
}
测试代码
X x;//构造
f(x);//拷贝构造
f1(x);//没有
auto p = new X();//构造
vector<X> vec(20);//构造
cout << vec.capacity() << endl;
vec.push_back(x);//拷贝构造
cout << vec.capacity() << endl;
13.1.4 三/五法则
有了拷贝构造函数,拷贝赋值函数和析构函数,我们就可以得到拷贝控制的目的。
移动构造函数和移动赋值函数是新标准加的。
通常我们定义了析构函数就一定需要定义拷贝构造和拷贝赋值函数。
因为我们定义了析构,意味着由动态分配的内存。所以在拷贝构造和拷贝引用时也需要动态分配。
定义了拷贝构造通常需要定义拷贝赋值,但是不一定需要析构,同样,定义了拷贝赋值往往需要定义拷贝构造,但是不一定需要析构
例子:一个类中的每一个对象都需要一个唯一id标注该对象。这个时候我们不需要析构函数
练习
13.14
因为使用的是合成的拷贝构造,所以该函数只会对对象的数据成员直接拷贝而没有任何特殊的操作,所以得到的唯一需要都是一样的
13.15
会改变,因为在自定义的拷贝构造函数中执行了自定义的操作,使得每个对象由唯一的序号,而形参是非引用类型,传入时会执行拷贝构造函数所以f(a),f(b),f( c)三者得到的输出都不是三个对象的需要。
13.16
输出的序号就是a,b,c三个的序号。因为f的形参类型时引用类型,调用函数时不会创建对象。
13.17
struct numbered {
numbered() {
num = cur_num;
++cur_num;
};
numbered(const numbered&) {
num = cur_num;
++cur_num;
};
~numbered() {
--cur_num;
}
int num;
static int cur_num;
};
int numbered::cur_num = 0;
void f(const numbered& s) {
cout << s.num << endl;
}
测试用例:
numbered a, b = a, c = b;
f(a);
f(b);
f(c);
13.1.5 使用=default
如果我们没有定义拷贝构造,拷贝赋值运算符和析构函数,C++编译器会为我们合成一个。
我们也可以使用=default,显式的告诉机器,使用合成的版本。
13.1.阻止拷贝
有时我们不希望对象可以执行拷贝或者赋值操作,比如流对象。
过去可以通过将拷贝构造函数和拷贝赋值运算符修饰为private,使用这种方法在类外无法进行拷贝和赋值,但是在成员函数和类的友元函数中可以。
因此C++新标准推出了=delete,将函数定义为函数的函数。
struct A{
A(const A&)=delete;
}
除了对拷贝构造函数,拷贝赋值函数,析构函数添加=delete,我们可以对任何的函数使用=delete,不过那样做的意义不太大。
我们一般不将析构函数设置为删除的函数,因为这样做之后,对象就无法释放对象的资源和销毁对象了。
由编译器合成的拷贝控制成员有可能是删除的,这体现在,如果某个对象的数据成员不能默认构造、拷贝、复制、或者销毁则默认的拷贝控制函数将会是删除的。
比如,类中包含了const或者引用类型的但是没有类内初始化的数据成员。
struct A {
const int a;
};
测试代码
A a;
编译器报错
练习
13.18
struct Employee{
Employee():_id(id) {
++id;
};
Employee(const string& str) : _id(id), _name(str) { ++id; };
Employee(const Employee& ee) = delete;
Employee& operator=(const Employee& ee) = delete;
int _id;
string _name;
static int id;
};
int Employee::id = 0;
13.19
需要,但是在这里我将拷贝构造函数和拷贝赋值函数都定义为delete,因为我觉得一个雇员不可能拥有两个id。
13.20
拷贝,赋值和销毁
TextQuery的数据成员为
shared_ptr<std::vector<std::string>> file;
map<string,shared_ptr<set<line_no>>> wm.
QueryResult的数据成员为
可以看到都是类类型,所以在拷贝时,执行各自类型的拷贝函数。在赋值时执行各自类别的赋值函数,在销毁时,执行各自的析构。对于指针类型,在执行完析构之后,会查看所指向的对象引用计数是否为0,如果为0则释放并删除指针所指向的对象。
13.21
不需要,因为所有的数据成员都是标准库的类类型,由各自的拷贝控制函数,而且两个类都没有内置指针,智能指针不需要手动的维护指向对象的释放和销毁。