C++类的拷贝控制成员(拷贝构造、拷贝赋值、移动构造、移动赋值和析构函数)

类中定义了五种特殊的成员函数来控制对象的拷贝、移动、赋值和销毁操作,包括拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数,统称为拷贝控制操作

拷贝构造函数和移动构造函数定义了 当用同类型的另一个对象初始化正在创建的对象时需要做什么
拷贝赋值运算符和移动赋值运算符定义了 将一个对象赋给另一个同类型的对象时需要做什么
析构函数定义了 当类对象销毁时需要做什么

拷贝构造函数

拷贝构造函数的第一个形参必须是自身类类型的引用,且任何其他参数都有默认值。

拷贝构造函数的第一个形参必须是引用类型的原因:
拷贝构造函数用于初始化非引用类类型参数。如果第一个形参不是引用类型,则将实参赋给该形参时会调用拷贝构造函数,这将导致递归调用拷贝构造函数。也就是说,为了调用拷贝构造函数,就必须拷贝它的实参,这又需要调用拷贝构造函数,不断的循环下去。

合成拷贝构造函数:
当用户没有为类定义拷贝构造函数时,编译器会生成一个合成拷贝构造函数。与默认构造函数不同,即便用户为类定义了其他的构造函数,编译器也会生成一个合成拷贝构造函数。
合成拷贝构造函数会将其参数的每个非static数据成员依次拷贝到正在创建的对象的对应成员。每个非static数据成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数完成拷贝;对内置类型的成员则直接拷贝;对数组类型的成员,会逐个拷贝其元素。

拷贝构造函数调用时机:
当使用拷贝初始化,即用一个类对象初始化一个正在创建的对象时,会调用拷贝构造函数。如:将一个对象作为实参传递给一个非引用类型的形参;一个返回类型为非引用类型的函数返回一个对象;用花括号列表初始化一个数组中元素或一个聚合类中的成员。

Test t1, t2;
Test t3[4] = {t1, t2, 10, 11};//第一个和第二个元素会调用拷贝构造函数

拷贝赋值运算符

拷贝赋值运算符的第一个形参类型与所在类类型相同,通常返回指向其左侧运算对象的引用。

class Test{
public:
	Test& operator=(const Test& t){
		...
		return *this;//返回左侧运算对象的引用
	}
};

合成拷贝赋值运算符:
如果用户没有为类定义拷贝赋值运算符,编译器会生成一个合成拷贝赋值运算符,它会将右侧运算对象的每个非static数据成员赋予左侧运算对象的对应成员。

析构函数

构造函数用于初始化对象的非static数据成员,成员的初始化在函数体执行之前就已经完成,且按照它们在类中出现的顺序进行初始化。
析构函数用于释放对象使用的资源,并销毁对象的非static数据成员。在析构函数中,首先执行函数体,函数体执行完毕后才会自动销毁非static数据成员。需要认识到析构函数的函数体并不会销毁数据成员,而是做一些用户定义的操作,函数体之后的析构阶段才真正的销毁数据成员。对于内置指针类型的成员,析构函数只会销毁该指针成员,但不会delete指针所指向的对象,这步操作需要用户自己在函数体中实现。

析构函数调用时机:
当对象被销毁时,就会自动调用其析构函数,如:作用域结束;对于动态分配的对象,指向它的指针被delete时;对于临时对象,当创建它的完整表达式结束时。

合成析构函数:
当用户没有为类定义析构函数时,编译器会生成一个合成析构函数。

三五法则

三五法则分为三法则和五法则,三法则是针对C++98提出的,那时还没有移动构造函数和移动赋值运算符,三法则规定了什么时候需要定义拷贝构造函数、拷贝赋值运算符和析构函数。
;而五法则是针对C++11提出的,此时多了两个拷贝控制成员,即移动构造函数和移动赋值运算符,五法则规定了何时需要这五个特殊的成员函数。

三法则规定了什么时候需要定义拷贝构造函数、拷贝赋值运算符和析构函数。

1、需要析构函数的类也需要拷贝构造函数和拷贝赋值运算符;
当需要用户定义析构函数时,说明由编译器生成的合成析构函数并不能完全释放对象拥有的资源,比如对象有一个指针成员,合成析构函数并不会delete指针所指向的对象。而使用合成拷贝构造函数和合成拷贝赋值运算符仅仅拷贝指针的值,使得多个对象的指针成员指向同一个内存,从而导致对同一块内存多次delete。

2、需要拷贝构造函数的类也需要拷贝赋值运算符,反之亦然,但析构函数不是必须的;

=default是C++11新增的关键字,可以显式要求编译器生成合成的版本,只能修饰具有合成版本的成员函数,如默认构造函数和拷贝控制成员。=default放在成员函数的参数列表后面,可以在函数声明或函数定义时使用。
=delete是C++11新增的关键字,放在函数的参数列表后面,必须出现在函数第一次声明的时候,可以修饰任何函数,表明该函数被定义为删除的,不可用。
3、析构函数不能是删除的;
如果析构函数被删除,就无法销毁此类型的对象,编译器不允许定义该类型的变量或创建该类的临时对象;如果类的某个成员删除了析构函数,也不能定义该类的变量或临时对象,因为该成员无法被销毁,导致对象整体也无法被销毁。
对于删除析构函数的类,可以动态分配该类的对象,但不能对其使用delete。

4、若类的某个成员的析构函数是删除的或不可访问(priavte)的,那么该类由编译器合成的默认构造函数和拷贝构造函数被定义为删除的;
因为该成员的析构函数被删除或不可访问,编译器不允许创建该成员,也就不能使用类的合成默认构造函数和合成拷贝构造函数。

5、若类有const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
因为类的const成员不能被修改,该类的合成拷贝赋值运算符必然会对其进行修改,而这是不允许的。而对于引用成员,对其赋值时,只会改变所引用的对象的值,而不会让左侧对象和右侧对象指向相同的对象,这种行为不是我们期望的。

第4、5具体规则:

  • 若类的某个成员的析构函数是删除的或不可访问的(private),则类的合成析构函数和合成拷贝构造函数被定义为删除的。
  • 若类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
  • 若类的某个成员的拷贝赋值运算符是删除的或不可访问的,或类有一个const的或引用的成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 若类的某个成员的析构函数是删除的或不可访问的,或类有一个引用成员但没有类内初始值,或类有一个const成员,但没有类内初始值且其类型没有显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。为定义拷贝控制成员,必须先确定类的行为类似值还是类似指针。

行为像值的类:
行为像值的类拥有自己独立的状态,拷贝像值的对象时,副本和原对象之间是完全独立的,对于类管理的资源,每个对象都应该拥有一份自己的拷贝,类似深度复制。
类值拷贝赋值运算符通常组合了析构函数和构造函数的操作,它需要释放左侧运算对象的资源(析构函数),也需要从右侧运算对象拷贝数据(拷贝构造函数)。此外,类值拷贝赋值运算符必须正确自赋值的情况,处理方法:先将右侧对象的数据拷贝到临时对象中,再释放左侧运算对象的资源,最后将临时对象的数据拷贝到左侧对象中(修改左侧对象的指针即可)。

行为像指针的类:
行为像指针的类共享状态,副本和原对象使用相同的底层数据,对于类管理的资源,每个对象都指向同一份资源,类似浅复制。
类似指针的类的析构函数不能单方面的释放指针所指向的资源,而应该让最后一个使用该资源的类对象释放资源。
为了记录有多少个对象共享同一个资源,引入了引用计数。引用计数是一个指针,指向由构造函数动态分配的内存,该内存中存放共享同一个资源的类对象的个数。
引用计数工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)需要创建一个引用计数,初始值为1;
  • 当使用拷贝构造函数时,拷贝给定对象的数据成员,包括计数器,并递增计数器。
  • 当调用析构函数时,会递减计数器,并判断计数器是否为0,若为0则释放共享的资源;
  • 当调用拷贝赋值运算符时,需要正确处理自赋值的情况,处理方法:先递增右侧对象的计数器,再递减左侧对象的计数器并判断是否为0(为0就需要释放资源),最后将右侧对象的数据拷贝到左侧对象中。拷贝赋值运算符也包括了析构函数和拷贝构造函数的操作。

交换操作

除了定义拷贝控制成员,管理资源的类通常也会定义一个swap函数,该函数用于交换两个对象的数据。交换两个类值对象时,可以先进行一次拷贝,再进行两次赋值,更高效的方法是直接交换类值对象的指针,而不需要拷贝类值对象的数据。

HasPtr temp = v1;//拷贝v1的数据
v1 = v2;//赋值
v2 = temp;//赋值
//直接交换指针
string* temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

标准库提供了一个swap函数,当为类自定义一个swap函数时,该函数中不该直接调用std::swap(),而是先声明std命名空间,在调用swap函数,如:

void swap(HasPtr& lhs, HasPtr& rhs){
	using std::swap;
	swap(lhs.ps, rhs.ps);//若类中没有定义该类型的swap函数,则会调用标准库的swap函数
	swap(lhs.i, rhs.i);
	//std::swap(lhs.ps, rhs.ps);//这种方式,即便类中定义了该类型的swap函数,也只调用标准库的swap函数
}

这种做法的好处时,当为类定义该类型的swap函数时,会调用自定义的swap函数,而不是标准库的swap函数。

定义swap的类通常用swap来定义赋值运算符,这些运算符使用了拷贝并交换的技术,会将左侧对象与右侧对象的一个副本进行交换。这种技术可以正确处理自赋值的情况。

对象移动

C++11新增了移动特性,① 对象拷贝后就立即被销毁;②如IO类或unique_ptr类,这些类不能被拷贝或赋值,这两种情况下需要使用移动。
为支持移动操作,C++11引入的新的引用类型,即右值引用。右值引用就是必须绑定到右值的引用,使用&&表示。右值引用只能绑定到将要销毁的对象(个人认为:这里即将销毁的对象只是用户认为它即将被销毁),可以自由接管该对象的资源。

左值和右值都是表达式的属性。一般来说,左值表达式表示的是一个对象的身份,右值表达式表示的是对象的值。左值有持久的状态;而右值要么是字面常量,要么是表达式求值过程中创建的临时对象,有暂时的状态,只在当前语句中有效。

右值引用也是给某个对象起个别名,可以绑定到要求转换的表达式(i*42)、字面值常量或返回右值的表达式上,但不能直接绑定到一个左值上(可以用static_cast将左值强制类型转换为右值或使用std::move函数)。变量是左值,不能将右值引用绑定到变量上,即便这个变量是右值引用类型也不可以。

标准库move函数:
该函数定义在头文件utility中,返回给定对象的右值引用。调用move意味着除了对给定对象赋值或销毁它外,将不再使用它,不能对移后源对象的值做任何假设,也就不能使用移后源对象的值。对于move函数,不使用using声明,而是直接调用std::move,可以避免潜在的名字冲突。
move函数中使用了static_cast强制类型转换,如:

template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
//直接使用强制类型转换
int x = 10;
int&& y = static_cast<int&&>(x);
int&& z = (int&&)(x);

在类代码中调用move函数可以大幅度提升性能,但随意的在普通用户代码中使用移动操作可能导致莫名其妙、难以查找的错误。因此,应该只在移动构造函数和移动赋值运算符中使用std::move,而在其他地方使用时必须确保有必要进行移动操作且移动操作是安全的。

移动构造函数和移动赋值运算符

移动构造函数的第一个参数是该类类型的右值引用,任何额外的形参必须有默认值。除了完成资源移动,移动构造函数还必须确保移后源对象处于有效的、可析构的状态,此时销毁该移后源对象是无害的。一旦完成资源的移动,源对象必须不再指向被移动的资源,这些资源的所有权归新创建的对象。(个人认为:这些都是对程序员使用移动构造函数的规定,由程序员来完成这些操作)。与拷贝构造函数不同,移动构造函数不分配任何新内存。

当编写不会抛出异常的移动操作时,应将此事通知标准库。否则,标准库会认为该移动操作可能抛出异常,从而为处理这种情况而做些额外的工作。

一种通知标准库的方式是在构造函数的参数列表和初始化列表开始的冒号之间使用C++11新引入的关键字noexcept。该关键字表示程序员保证该函数不会抛出异常,并且在函数声明和定义中都要指定noexcept

不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept

移动赋值运算符的形参也是一个右值引用类型,与拷贝赋值运算符类似,需要执行与析构函数和移动构造函数相同的工作,且必须正确处理自赋值的情况。如果移动赋值运算符不抛出异常,需要明确标记为noexcept

移动赋值运算符处理自赋值的方法:对形参取地址,并与this指针比较,若相等,则是自赋值的情况,什么也不做,直接返回当前对象(*this)。检查自赋值的原因是:此右值可能是move调用的返回结果。与其他赋值运算符一样,关键点是不能在使用右侧运算对象之前就释放左侧运算对象的资源。

移后源对象:
完成移动操作后,必须保证移后源对象是可析构的状态,还必须保证移后源对象仍然是有效的。对象有效是指可以安全地为其赋新值或可以安全使用而不依赖于其当前值。

由编译器合成的移动操作遵循的法则(五法则):

  • 若类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符,这两个合成的移动操作被定义为删除。
  • 若类定义了移动构造函数或移动赋值运算符,则该类的合成拷贝构造函数和合成拷贝赋值运算符被定为删除的。因此,定义了两个移动操作的类也必须定义自己的拷贝操作。
  • 合成移动构造函数被定义为删除的条件:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
  • 有类成员的移动构造函数或移动赋值运算符被定义为删除或不可访问的,则类的合成移动构造函数或合成移动赋值运算符被定义为删除的。
  • 如果类的析构函数被定义为删除的或不可访问的,则类的合成移动构造函数被定义为删除的。
  • 如果有类成员是const的或是引用,则类的合成移动赋值运算符被定义为删除的。

移动右值,拷贝左值
当类既有移动构造函数,也有拷贝构造函数时,编译器使用普通的函数匹配规则确定使用哪个构造函数(赋值操作也一样)。

如果没有移动构造函数,右值会被拷贝
当类定义了拷贝构造函数但未定义移动构造函数时,编译器不会合成移动构造函数,而是使用拷贝构造函数来拷贝右值,即便调用move函数也是如此,因为可以将右值引用type&&转换为const type&。用拷贝构造函数代替移动构造函数几乎肯定是安全的。拷贝赋值运算符和移动赋值运算符情况类似。

左值和右值引用成员函数
引用限定符可以是&&&,分别指出this指针可以指向一个左值或右值。类似const限定符,引用限定符只能用于非static成员函数,且必须同时出现在函数的声明和定义中。

对于&限定的函数,只能使用左值调用它;对于&&限定的函数,只能使用右值调用它。一个函数可以同时用const限定符和引用限定符修饰,引用限定符必须跟随在const限定符之后。

成员函数可以根据是否有const限定符来区分重载版本一样,引用限定符也可以区分重载版本。

当定义const成员函数,可以定义两个版本,唯一的区别是一个有const,一个没有const。而引用限定符则要求定义两个或两个以上具有相同名字和参数列表的成员函数,要么都加上引用限定符,要么都不加。

#include <iostream>
class A {
public:
	void print() &{
		std::cout << "左值调用" << std::endl;
	}
	void print()&& {
		std::cout << "右值调用" << std::endl;
	}
	void print()const & {
		std::cout << "const左值调用" << std::endl;
	}
	void print()const&& {
		std::cout << "const右值调用" << std::endl;
	}
	/*void print() {//编译器报错,因为该函数没有引用限定符
		std::cout << "普通的print函数" << std::endl;
	}*/
};
int main() {
	A a;
	const A b;
	a.print();//a是左值,输出 左值调用
	std::move(a).print();//std::move(a)是右值, 输出 右值调用
	b.print();//b是const左值, 输出 const左值调用
	std::move(b).print();//b是const右值,输出 const右值调用
	return 0;
}
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值