《C++ Primer》学习笔记(第十三章)——拷贝控制

拷贝控制

本章继续复习类的相关内容,主要包括拷贝构造函数、析构函数、移动构造函数、对象移动、右值引用等相关内容。

一、拷贝构造函数
1、 如果一个构造函数的第一个参数的自身类型的引用,且任何其他参数都有默认值,那么此构造函数就是拷贝构造函数。拷贝构造函数的第一个参数必须是一个引用,而且通常上都是const的引用,(因为我们拷贝一个对象,不希望改变该对象的状态)。由于在某些情况下拷贝构造函数都会被隐式调用,因此拷贝构造函数通常不应该为explicit。

class A{
public:
 A(){  // };  //默认构造函数
 A(const A&){  //  };//拷贝构造函数

2、和合成默认构造函数不同,编译器是在我们没有定义任意一个构造函数的情况下,才会合成默认构造函数。但是即使我们定义了其他的构造函数,你编译器还是会为我们合成默认拷贝构造函数。合成的默认拷贝构造函数会将给定对象的每一个非static成员(因为static为属于类,为所有对象共有),赋予拷贝到正在创建的对象中去。

3、当执行直接初始化时,通常都是调用匹配的构造函数完成初始化,而当执行拷贝初始化时,通常调用拷贝构造函数或者移动构造函数完成。通常拷贝初始化会在下列情况下出现:
①、使用=定义变量‘
②、将一个对象作为实参传递给一个非引用的形参
③、从一个返回类型为非引用的类型返回一个对象时
④、使用花括号列表初始化一个数组中的元素

二、拷贝赋值运算符
(关于运算符重载的具体内容,往后再记录吧·········这里大概记一下)
1、 重载运算符本质上是一个函数,名字有operator关键字后面紧跟需要重载的运算符组成。如果将重载运算符定义成一个成员函数,那么左侧运算对象就隐式地绑在this指针上,而右侧对象作为参数传入。赋值运算符通常返回一个指向左侧对象的引用

class A{
public:
  A& operator=(const A&){//};//赋值运算符重载,返回类型为一个指向左侧对象的引用

2、如果一个类未定义自己的拷贝赋值运算符,那么编译器会自动生成一个合成拷贝赋值运算符。合成的拷贝赋值运算符会将右侧对象的每一个非static成员,赋值到左侧对象相应的成员中。

三、析构函数
1、析构函数与构造函数执行相反的操作,先构造的后析构,后构造的先析构。析构函数释放对象使用的资源,并销毁对象的非static数据成员。析构函数是类的一个成员,名字由波浪号后跟类名组成,没有返回值也不接受参数

class A{
public:
 A(){  // };  //默认构造函数
 A(const A&){  //  };//拷贝构造函数
 ~A(){//};//析构函数,和构造函数一样没有返回值,

2、一个给定的类只有唯一一个析构函数,(因为析构函数没有参数,所以不能构成重载版本,当然就只能有唯一一个了);

3、构造函数有一个初始化部分和一个函数体,析构函数同样有一个函数体和一个析构部分。对于构造函数来讲:先进行初始化,然后执行函数体,并且按照成员在类中出现的顺序进行初始化。而对于析构函数来讲:向执行函数体然后再销毁成员,并且按照成员初始化的顺序进行逆序销毁。析构函数不存在初始化列表这个玩意儿。

4、指针的销毁:对于普通的指针来讲:如果隐式销毁一个指针,不会delete该指针所指向的内容,因此我们需要在析构函数的函数体中手动delete指针。对于智能指针(后面再总结智能指针的相关知识吧),由于智能指针是一个类类型,具有自己的析构函数,因此智能指针在析构阶段会被自动销毁,,如果引用计数为0则会自动释放内存,不需要我们手动进行delete。

5、在以下几种情况下,会调用析构函数:
简单来说,主要对象被销毁,就会调用析构函数
①、变量离开其作用域时;
②、当一个对象被销毁时,其成员也被销毁;
③、标准库容器或者数组被销毁时,其元素被销毁;
④、对于动态分配的对象(new运算符),当对指向它的指针使用delete时被销毁;
⑤、对于临时对象,当创建它的表达式结束时被销毁

值得注意的是:当指向一个对象的引用或指针离开作用域时,对象不会被销毁。另外需要强调的是,析构函数体本身不直接销毁成员,成员是在函数体之后隐含的析构阶段被销毁的,析构函数体只是作为成员销毁步骤之外的另一部分进行的。

6、类似于构造函数,当我们没有定义自己的析构函数时,编译器会为我们定义一个合成析构函数(默认析构函数),默认析构函数体为空。

针对构造函数、拷贝构造函数、赋值运算符、析构函数来举一个超简单的栗子:

class A {
private:
	string name;
public:
	A() = default;  //默认构造函数
	
	explicit A(string s) :name(s) {   //显示的构造函数
	    cout << "构造函数执行" << endl; 
	};
	
	A(const A &m ) {  //拷贝构造函数
		name = m.name;
		cout << "拷贝构造函数执行" << endl;
	};
	
	A& operator=(const A &m) {  //赋值运算符重载
		name = m.name;
		cout << "赋值运算符重载执行" << endl;
		return *this;
	 }
	 
	~A() {  //析构函数
		cout << "析构函数执行" << endl;
	}
};
 
int main()
{
	//A a1 = "IG";  //错误,由于构造函数被声明为explicit,因此无法进行隐式的类型转换
	A a1("IG");  //调用构造函数
	A a2(a1);  //调用拷贝构造函数;
	A a3;  //调用默认构造函数
	a3 = a2;  //调用赋值运算符重载函数
    return 0;  //程序结束时,调用三次析构函数,顺序为:a3、a2、a1
}

7、一般来讲:如果我们定义了析构函数,那么我们就得定义拷贝构造函数和拷贝赋值运算符,这三个函数一般都会同时出现。尤其是当自定义类中含有指向自动分配内存的指针(new运算符)时,就必须在析构函数中手动delete该指针,与此同时也得自己定义拷贝构造函数,以及拷贝赋值运算符。如果只是使用编译器合成的拷贝构造函数或者拷贝赋值运算符,那么对指针简单的拷贝会使两个指针指向同一个对象,可能发生不必要的错误,(后续讲到值行为类和指针行为类时在举例子讲解吧)。

8、我们同样可以使用=default来显示地要求编译器生产默认版本的构造函数、拷贝构造函数、拷贝赋值运算符以及析构函数。当在类内声明时使用=default,合成的函数将会被隐式为内联函数,如果不想把合成版本的函数声明为内联函数,可以在类外定义时使用=default。另外,我们只能对合成版本的函数使用=default。举个栗子:

class A{
public:
  A()=default;//显示生成默认构造函数,内联的
  A(const A&)=default;//显示生成合成版本的拷贝构造函数,内联的
  A& operator=(const A&);
  ~A()=default;//显示生成合成版本的析构函数,内联的
  }
A& A::operator=(const A&)=default;//显示生成合成版本的赋值运算符,但不是内联的

9、阻止拷贝
①、对于某些类,如果我们不希望该类的对象能够进拷贝,可以将拷贝构造函数和拷贝赋值运算符定义为删除的函数,即虽然我们声明了他们,但是我们不能以任何方式使用他们。通过在参数列表后面加上=delete来定义删除的函数;

class A{
public:
 A()=default;//显示生成合成默认构造函数;
 A(const A&)=delete;// 删除的函数,禁止拷贝
 A& operator=(const A&)=delete; //删除的函数,禁止赋值;
 ~A()=default;//显示生成合成析构函数。

②、与default不同,delete必须出现在第一次声明的地方。另外default只能用在编译器可以合成的函数,如默认构造函数,默认拷贝构造函数等,而delete可以用于任何函数

③、不能将析构函数定义为删除的函数,如果将析构函数定义为删除的函数,那么该类的对象将不会被析构也就无法销毁,此时编译器不允许定义该类的变量会在该类的临时对象(因为无法销毁对象嘛,所以不允许定义对象出来),但是我们可以动态分配这种类型的对象,然而无法释放这些对象,即不能delete指向这些对象的指针

④、如果一个类A含有类B的成员,但是类B删除了析构函数,那么A类中的B类成员将不能被销毁,导致A类对象整体都不被销毁(株连九族啊),所以也就不能定义A类的对象或者临时变量了,(其实编译器为自动为类A生成删除的析构函数)。

⑤、当一个类含有不可能拷贝、赋值或者销毁的成员时,那么该类的合成拷贝控制成员就会被自动定义为删除的。比如:当类有一个const或引用成员时,由于const成员不能被赋值,因此此时该类的合成拷贝赋值运算符会被定义为删除的(其他情况可以参考450页)(c++新标准之前是通过把拷贝构造函数和拷贝赋值运算符定义声明为private来阻止拷贝)。

四、行为像值的类
类的行为像值,也就是当我们拷贝一个行为像值的类对象时,副本和原对象是完全独立的状态,改变副本不会对原对象产生任何影响(拷贝指针所指向的内容)。默认的拷贝构造函数会拷贝指针的内容,即拷贝后两个对象中的指针仍然指向同一对象,显然不符合类的行为像值的特点,因此需要自定义拷贝构造函数以及拷贝赋值运算符。举个栗子:

class A {
public:
	A(string s, int i=0) :p(new string(s)), num(i) {  //构造函数
		cout << "构造函数被调用" << endl;
	};
	A(const A& a) {  //拷贝构造函数
		p = new string(*a.p);  //  拷贝指针所指向的内容,而不是拷贝指针本身,行为像值的类
		num = a.num;
		cout << "拷贝构造函数被调用" << endl;
	};
	A& operator=(const A& a);  //声明拷贝赋值运算符

	~A() {  //析构函数
		delete p;  // 手动释放内存
		cout << "析构函数" << endl;
	};

	void show() {
		cout << *p << "****" << num << endl;
	}
private:
	string * p;
	int num;
};
A& A::operator=(const A& a) {  //定义拷贝赋值运算符
	auto temp = new string(*a.p);  //先将右侧对象拷贝到一个临时变量,防止自赋值
	delete p;  //删除左侧对象指针
	p = temp;
	num = a.num;
	cout << "拷贝赋值运算符被调用" << endl;
	return *this;
}

int main()
{
	A a1("IG");  //构造函数被调用
	//A a2;  //错误,因为没有默认构造函数
	A a2 = a1;  //拷贝构造函数被调用
	A a3("RNG",10);  //构造函数被调用
	a1.show();
	a1 = a3;  //拷贝赋值运算符被调用
	a1.show();
    return 0;
}

1、如上述代码所示,我们定义拷贝构造函数和拷贝赋值运算符时,拷贝的是指针所指向的对象,而不是指针本身,如果只是拷贝指针本身的话,就会导致两个指针(a1.p与a3.p)指向同一个对象,当我们析构对象a3释放a3的指针所指向的内容时,此时a1中的指针所指向的内容同样被释放掉了,当我们在析构对象a1时,会试图delete一个已经释放过的指针,将会发生错误。(接下来会讲到引用计数来解决这个问题),

2、另外在拷贝赋值运算符定义中,我们首先是用一个临时值来保持右侧运算对象中的指针所指向的值,然后再delete左侧对象中的指针,在将之前的临时值赋值给该指针。这样做的目的是防止自赋值情况时发生错误,因为如果不用一个临时值来保存右侧对象中的值而直接delete指针时,如果出现自己给自己赋值情况,就会先delete自身的指针,让后将已经delete的指针赋给自己,显然不对。

3、拷贝赋值运算符通常需要执行拷贝和析构左侧对象的工作

五。行为像指针的类
1、与行为像值的类相反,定义一个行为像指针的类,就是在自定义拷贝构造函数中,拷贝指针本身而不是拷贝指针所指向的内容。另外比较麻烦的是需要定义析构函数,由于多个对象中的指针可能指向相同的内容,因此析构函数不能简单的对每一个对象进行析构,这个时候就得使用引用计数了。

2、引用计数:我们创建一个计数器用来记录有多少个对象处于共享状态。当创建一个新对象时,我们创建一个计数器,在拷贝或赋值对象时,我们同时拷贝指向计数器的指针,并且使计数器加一。在析构函数中我们使计数器减一,并且判断计数器是否为0,如果为0则执行析构释放内存。同样来举个栗子:

class A {
public:
	A(string s, int i=0) :p(new string(s)), num(i),count(new int(1)) {  //构造函数,创建新对象时初始化计数器为1
		cout << "构造函数被调用" << endl;
	};
	A(const A& a) {  //拷贝构造函数
		p = a.p;  //  拷贝指针本身
		num = a.num;
		count = a.count; //将右侧对象的计数器同样拷贝给左侧对象,使两个对象的计数器相同
		(*count)++;  //由于进行了拷贝,因此将计数器加一,表示由增加一个对象共享资源
		cout << "拷贝构造函数被调用" << endl;
	};
	A& operator=(const A& a);  //声明拷贝赋值运算符

	~A() {  //析构函数
		if (--*count == 0) {  //先使计数器减一,如果计数器为0,表示没有对象共享资源了,释放内存
			delete p;
			delete count;  //同样释放计数器的内存
			cout << "析构函数调用并释放内存" << endl;
		}
		else
			cout << "析构函数被调用但没有释放内存" << endl;
	};

	void show() {
		cout << *p << "****" << num <<"****"<<*count<< endl;
	}

private:
	string * p;
	int num;
	int *count;  //定义一个计数器,记录有多少个对象共享资源
};
A& A::operator=(const A& a) {  //定义拷贝赋值运算符
	++*a.count;  //先使右侧对象的计数器加一,避免自赋值时发生错误
	if (--*count == 0)  //对左侧对象计数器减一,并且判断计数器是否为0
	{
		delete p;
		delete count;
		cout << "左侧对象被释放" << endl;
	}
	p = a.p;
	num = a.num;
	count = a.count;  //将右侧计数器拷贝到左侧对象中
	cout << "拷贝赋值运算符被调用" << endl;
	return *this;
}

int main()
{
	A a1("IG"); //构造函数被调用,a1中初始化一个计数器count1=1;
	A a2 = a1;  //拷贝构造函数被调用,并且a1中的计数器被拷贝到a2中,计数器加一,count1=2;
	a1.show();
	A a3("RNG",10);  //拷贝构造函数调用,同样为新对象a3初始化一个计数器count3=1;
	a1.show();
	a1 = a3;  //拷贝赋值运算符被调用,a3的计时器加一(count3=2),左侧对象的计时器减一(conut1=1),并且将a3的计时器拷贝到a1中,此时a1中保存的计时器是count3.
	a1.show();
    return 0;
}

3、如上述代码所示:我们对对象进行拷贝或赋值时,拷贝的是指针本身,这表示两个对象中的指针实际上指向的是同一个值。当使用构造函数创建一个新对象时(注意是使用构造函数创建新对象),同时为该对象初始化一个计数器并初始化为1。在拷贝构造函数中,我们不为左侧对象创建一个新的计数器,而是将右侧对象中的计数器拷贝到左侧对象中,同时使计数器加一。在拷贝赋值运算符中,我们首先需要将右侧对象中的计数器加一,防止自赋值时发生错误。然后将左侧对象的计数器减一操作后同时判断计数器是否为0,若为0就需要对左侧对象执行析构工作,delete内存,最后再将右侧对象中计数器拷贝给左侧对象中。

4、和之前类似,在拷贝赋值运算符中,我们需要负责对左侧对象进行析构的工作

六、对象移动
对象移动和右值引用是为提高性能而生的,因为c++中的临时对象存在构造、析构以及内存申请等函数调用,影响性能。引入对象移动只是将对象的状态移动到另一个对象,并没有内存的拷贝

1、右值引用:必须绑定到右值的引用,左值引用用&表示,右值引用用&&来表示。右值引用只能绑定到一个将要销毁的对象上,右值引用不能绑定到左值上,左值引用不能绑定在右值上,但是对于const 类型的引用可以绑定到右值上

int i=10;
int &a1=i;//正确,左值引用绑定到左值
int &&b1=i;//错误,右值引用不能绑定到左值
int &a2=i*10;//错误,表达式i*10为右值,不能将左值绑定到右值上
int &&b2=i*10;//正确,右值引用绑定到右值上
const int &a3=i*10;//正确,const引用可以绑定到右值上。

返回左值引用的函数有:赋值、下标、解引用、前置递增/递减运算符都是返回左值
生成右值的函数有:算术、关系、位、后置递增/递减运算符都生成右值。

2、左值具有持久状态,而右值引用只能绑定到临时对象,因此右值所引用的对象将要被销毁,右值引用的代码可以自由接管所引用的对象的资源。

3、变量是左值,也就是说右值引用类型的变量也是左值,不能将右值引用绑定到该类型上,如:

int &&a=10;//10是右值,但是变量a是左值
int &&b=a;//错误,a是左值,哪怕它是右值引用类型的变量它也是变量,是变量就是左值

4、通过新标准库函数move函数可以显示地将左值转换为右值引用,如:

int i=10;  //变量i为左值
int &&a=i;  //错误,右值引用不能绑定到左值上
int &&b=std::move(i);  ..正确,move函数将左值转化为了右值引用

值得注意的是,调用move函数之后,我们不能使用变量i,但是可以对其进行赋值或者销毁。另外move函数定义在头文件utility中。
对以上内容举个栗子吧:

string s = "HELLO";  //定义一个string对象,s为左值
	vector<string> v;
	//v.push.back(s);//此时将会对s调用拷贝构造函数,
	v.push_back(std::move(s)); //此时不会调用拷贝构造函数,而是将对象s状态进行了移动,提高性能
	cout << s << endl; //对s调用move函数后,不要使用s(经测试,此时s输出为空)

七、移动构造函数和移动赋值运算符
与拷贝构造函数类似,移动构造函数的第一个参数为该类的右值引用。拷贝构造函数会对对象的成员进行拷贝,而移动构造函数只是从给定的对象“窃取”资源而不是拷贝资源。另外调用移动构造函数完成资源移动后,原对象必须不再指向被移动的资源,这些资源以及归属于新对象,对原对象的销毁是无害的。另外我们对移动构造函数声明为一个不会抛出异常的函数,声明方式为在参数列表后初始化列表开始的冒号之间使用关键字noexcept。举个拷贝构造函数和移动构造函数的栗子:

class A {
public:
	explicit A(string s):p(new string(s)) { //构造函数
		cout << "构造函数被调用" << endl;
	};

	A(const A &a) :p(new string(*a.p)) {  //拷贝构造函数,对资源进行拷贝,申请了内存空间
		cout << "拷贝构造函数被调用" << endl;
	};
	
	A(A &&a) noexcept:p(a.p) {  //移动构造函数,移动了资源,没有申请内存空间
		a.p = nullptr;  //原对象不再指向原来的资源
		cout << "移动构造函数被调用" << endl;
	}
	~A() {  //析构函数
		delete p;
	}
private:
	string *p;
};

int main()
{
	string s1 = "HELLO";
	A a(s1);  //构造函数
	A b(a);  //拷贝构造函数
	A c(std::move(a));  //移动构造函数
    return 0;
}

如上述代码所示:在对a调用移动构造函数后,需要让a与原来的资源断绝关系,并且往后最好不要在使用a的值。另外也可以看出用移动构造函数没有内存申请,因此性能比拷贝构造函数要好。移动构造函数类似于前面所讲到的行为像指针的类,不同在于行为像指针的类是多个指针指向同一资源,并使用了引用计数的方法来处理对象的析构,而移动构造仍然是一个指针指向一个资源。由此也可以看出如果我们对一个对象进行拷贝而且拷贝后又用不到原对象时,可以使用移动构造函数来进行资源移动,这样性能会比较好一点。

移动赋值运算符
1、移动赋值运算符完成移动构造函数和析构函数的工作,即要对赋值运算符左边的对象执行析构,同样,如果移动赋值运算符不会抛出异常那么应该标记为noexcept。对上面的代码加上应该赋值运算符,如:

A& operator=(A &&a) noexcept{
if(this!=a)  //防止自赋值
{
delete p;  //先释放左侧对象的内存
p=a.p; //移动资源
a.p=nullptr;  //将右侧对象与资源断绝关系
}
};

2、在移动操作之后,移后对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

3、只有当一个类没有定义自己的拷贝控制成员,且它所有的数据成员都能移动构造或者移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

3、如果一个类既有拷贝构造函数,又有移动构造函数,那么当传入一个左值引用时调用拷贝构造函数,当传入一个右值引用时调用移动构造函数。如果一个类只有拷贝构造函数,没有移动构造函数,那么编译器不会合成移动构造函数,这时哪怕是传入一个右值引用,也会调用拷贝构造函数对该引用进行拷贝。

4、成员函数的参数也可以是左值或右值,此时可以构成函数重载

void push.back(const string&);  //传入左值
void push.back(string &&);  //传入右值

string s="HELLO";
push.back(s);//传入左值
push.back("WORLD");//传入右值,字面值是右值

5、为什么传入左值时是const,而传入右值时不是const?这是因为当传入左值时,会进行拷贝构造,拷贝构造过程中不希望改变原始对象的值,因此是const,而传入右值时,需要改变原始对象的状态,因此显然不能是const。

6、一般情况下,成员函数可以被左值或者右值调用,如果想要限制左值或者右值调用,我们可以对成员函数使用引用限定符,来表示this的属性是左值还是右值,如果一个成员函数同时拥有const和引用限定符,那么引用限定符必须放在const之后,如:

void find() &;//可修改的左值能调用
void find() &&;//可修改的右值能调用
void find() const &;  //任何类型的左值可调用

注意:如果一个成员函数有引用限定符,那么具有相同函数名和参数列表的所有版本都必须加上引用限定符,或者都不加。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值