C++初阶——类和对象(四) 拷贝构造函数、赋值运算符重载函数
C++初阶——类和对象(四)
上期内容中,我们详细讲解了类的六个默认成员函数中的构造函数
和析构函数
,有了构造函数,我们在创建类对象的时候就可以将其初始化
成需要的信息;有了析构函数,我们可以在对象生命周期结束时自动销毁开辟的内存空间,有效的避免了内存泄漏。从这几期的内容中,我们逐渐感受到C++在C语言的基础上,做出了很多的优化。本期内容,将为大家介绍拷贝构造函数
以及赋值运算符重载函数
!
一、拷贝构造函数
1.背景引入
我们还是先以日期类为例:
这样的一个日期类中,我们重载了两个构造函数,一个无参,一个带有参数,在创建类对象时,用法也有所不同:对于带有参数的构造函数,创建类对象时将需要的信息填在后面的括号里;对于无参的构造,创建的对象后面不能加括号,因为会被误以为是函数的声明。
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
在这里我们已经知道了d2
的具体信息,因此可以通过Date d3(2025, 3, 15);
创建一个和d2
一样的对象d3
,但是这种显然是不方便的,那么,我们就需要了解一下拷贝构造函数
了!
2.什么是拷贝构造函数?
我们来看这样一段代码:
在这里,我们先用Date类
实例化出一个对象d1
,在对d2
实例化时,就有所不同了,从字面意思上也很容易理解,就是按照d1
的内容再初始化一个对象d2
。不过,这里的构造和前面的有一定的区别,因为前面的毕竟是直接传了具体的数值,可以直接调用构造函数初始化;而这里的参数是一个类对象
,肯定不能传参给我们之前写的那些构造函数,那么到底应该调用哪个函数呢?——拷贝构造函数
应运而生。通过拷贝构造,我们把类对象里的值拷贝一份,再赋给这个新对象d2
,完成它的初始化。拷贝构造函数的调用,发生在传递的参数是一个类对象时,因为它是一个类对象,就一定要先把成员的值拷贝出来,这种调用是一定会发生的
。至于拷贝构造函数是什么样的,这里没有显式地实现,因为这里都是内置类型,是简单的值拷贝编译器已经帮我们生成了默认的拷贝构造函数,但是,我们可以自己写一个拷贝构造,来进一步了解一下。
3.拷贝构造函数的特性
- 拷贝构造函数:只有
单个形参
,该形参是对本类类型对象的引用
(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用
。 - 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发
无穷递归
调用。
首先,和构造函数类似,拷贝构造函数的函数名与类名相同,在传参为类对象时会自动调用,形参为类对象的引用
。函数体的定义中,也是简单明了的,this指针指向的是需要被初始化的对象,d是已经存在的对象的别名,需要注意的是,一定要清楚是把谁赋值给谁,这里是很容易出错的,我们看一下调试:
按F11,转入调用的函数中:
我们再来探讨一下为什么需要传引用,无穷递归是怎么产生的呢?
如图所示:
只要是传递类对象,就会调用它的拷贝构造函数,这是语法规定。由于形参是实参的一份临时拷贝,那么对于Date d2(d1);
传给函数Date(Date d)
,首先需要拷贝实参d1
,既然要拷贝,就要调用拷贝构造函数,而拷贝构造函数的传参又需要将实参拷贝给形参,既然要拷贝,又要调用拷贝构造……也就是说拷贝构造函数自己在调用自己,引发无穷递归。因此,形参不能是传值类型,而应该是传引用,Date(const Date& d)
意思就是d
是实参d1
的一个别名,这样就可以避免传值需要拷贝的问题。
4.默认拷贝构造函数
若未显式定义,编译器会生成默认的拷贝构造函数
。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。比如:
这里就没有写拷贝构造函数,编译器默认生成了一个,由于这里的成员变量都是内置类型
,默认的拷贝构造函数是足够使用的,但是对于 自定义类型
,默认生成的就不一定够用了,需要自己实现,我们来看下面一段代码:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
运行时,程序崩溃了。因为这里我们没有自己写拷贝构造函数,默认生成的只能完成简单的值拷贝(浅拷贝),而Stack类中有一个成员变量是指针类型,指向的是动态开辟的内存空间,浅拷贝使得对象s2的成员——指针变量也指向同一块空间,然而程序退出时,由于析构函数的存在
,s1
、s2
动态开辟的内存空间要销毁,因此同一块空间被销毁了两次,程序崩溃,如图所示:
所以,类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
5.拷贝构造函数典型调用场景
- 使用已存在对象创建
新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型
,返回时根据实际场景,能用引用尽量使用引用。
二、赋值运算符重载
接下来,就到了另一个非常重要的板块——运算符重载
!本期内容,我们先为大家介绍赋值运算符重载。
1.背景引入
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
- 函数名:关键字
operator
后面接需要重载的运算符符号。 - 函数原型:
返回值类型
operator
操作符
(参数列表)
2.赋值运算符重载
赋值运算符重载格式:
- 参数类型:
const T&
,传递引用可以提高传参效率 - 返回值类型:
T&
,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值 - 检测是否自己给自己赋值(易错)
- 返回
*this
:要复合连续赋值的含义
我们以日期类为例,先来简单的实现一下:
- 先创建一个日期类对象
d1
,并且初始化想要的数据,然后再创建一个对象d2
,我们没有给出具体的数值,那么它将调用默认构造函数,也就是不需要传参的构造函数,在这里,我们自己实现了一个无参的构造函数,因此会出现Date()
打印到屏幕上,然后,我们将d1
的值赋给d2
。那么我们不难发现,赋值运算符重载和构造函数是有区别的,构造函数是在创建的时候初始化,对象是新创建的,而赋值是在两个已经存在的对象之间,这就是区别所在。
当然,上面的赋值运算符重载函数实现得比较粗糙,我们稍加改进,如图所示:
- 引用返回是为了减少拷贝次数,这里还加了一个检查是否是自己给自己赋值,如果是自己给自己赋值,直接返回this解引用即可。
和前面的几个默认成员函数一样,用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
if
语句块里面的this指针
是可以省略的,这里是为了方便大家理解是把谁赋给谁。
3.注意事项
- 赋值运算符只能重载成类的成员函数不能重载成全局函数,赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
- 如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
本期总结+下期预告
本期内容,我们详细介绍了拷贝构造函数已经赋值运算符重载函数,然而赋值运算符重载只是运算符重载之一,下期内容将为大家带来一系列的运算符重载的实现!
感谢大家的关注,我们下期再见!