C++ 类和对象(三):拷贝构造函数与赋值运算符重载之核心实现

前言:

前面的博客给大家介绍了C++类的实例化、this指针、构造函数、析构函数以及代码应用,本篇文章会讲述C++的拷贝构造函数与赋值运算符重载,这里的内容有些难,大家可以多去实现一下。本篇文章主要是先介绍特点再通过代码说明,代码注释是很重要的。下面我们一起进入到文章中学习吧~


目录

一、拷贝构造函数

1.拷贝构造函数的概念:

2.拷贝构造函数的特点:

二、赋值运算符重载

1.运算符重载

2.赋值运算符重载

1)赋值运算符重载的概念:

2)赋值运算符重载的特点:


一、拷贝构造函数

1.拷贝构造函数的概念:

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数。

2.拷贝构造函数的特点:

1. 拷贝构造函数是构造函数的一个重载

2. 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。 拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。

3. C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成

4. 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造函数。

5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但 是 _a 指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像 MyQueue 这样的类型内部主要是自定义类型 Stack 成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现 MyQueue 的拷贝构造。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造函数,否则就不需要。

6. 传值返回会产生一个临时对象调用拷贝构造函数,传引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象在当前函数结束后还在,才能用引用返回

这是关于无限递归的图示,也是为什么不用传值传参而用传引用的原因。

下面我们来用代码详细说明一下:

关于前三点的代码说明:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//拷贝构造函数
	Date(const Date& d)//加个const可以让下面传实参的选择更多,避免出现权限扩大等问题
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	//Date(Date d)
	//这样是错误的,就是我们第二点里所说的传值传参,会导致无限递归
	//我们可以用下面的func函数来演示一下:
	//会发现传值要先调用拷贝函数
	
	//这里如果不显式写出拷贝构造,用默认生成的也会处理内置类型
	//默认生成的会进行浅拷贝,这里也是可行的

	////用指针也可以实现拷贝,但是这里并不算真正的拷贝函数
	//Date(Date* d)
	//{
	//	_year = d->_year;
	//	_month = d->_month;
	//	_day = d->_day;
	//}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << '\n';
	}

private:
	int _year;
	int _month;
	int _day;
};

//自定义类型传值传参和传值返回都会调用拷贝构造完成
//传值传参会调用拷贝函数
void func(Date d)
{

}
void func1(Date const& d)
{

}
//传值返回也会调用拷贝函数,但是传引用就不会
Date func2()//这里最好用传值返回
{
	Date d(2025, 7, 31);
	return d;
}
Date& func3()
{
	Date d(2025, 8, 31);
	return d;
}

int main()
{
	Date d1(2026,1,22);//调用构造函数
	Date d2(d1);//拷贝构造,调用拷贝构造函数
	d1.Print();
	d2.Print();

	Date d2(&d1);// 这里可以完成拷贝,但是不是拷贝构造,只是一个普通的构造
    // 这样写才是拷贝构造,通过同类型的对象初始化构造,而不是指针
	//Date d2(d1);
 
	func(d1);//我们调试发现,他会先去调用Date里面的拷贝函数再去func
	func1(d1);//这里就直接去了func1

	Date ret1 = func2();
	Date ret2 = func3();
    // Func2返回了一个局部对象d的引用作为返回值
    // Func2函数结束,d对象就销毁了,相当于了一个野引⽤
 
	//Date ret2 = (func3());,这样写也是可以的
	//由这个我们还可以看出,那其实之前的拷贝也可以写出这样
	//Date d2 = d1;

	return 0;
}

关于对内置类型处理和深浅拷贝的代码说明:

cpp 复制代码
#include<iostream>
using namespace std;
typedef int STDataType;

class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(n * sizeof(STDataType));
		if (_a == nullptr)
		{
			perror("malloc fail!");
			exit(1);
		}
		_top = 0;
		_capacity = n;
	}
	////浅拷贝/值拷贝
	//Stack(Stack const& s)
	//{
	//	_a = s._a;
	//	_top = s._top;
	//	_capacity = s._capacity;
	//}
	//在栈这里这样写是错的
	//因为这里的数组如果像这样仅仅是浅拷贝,后续析构函数释放空间会释放同一块空间两次
	//调试也会报错,后面有画图理解
	
	//深拷贝
	Stack(Stack const& s)
	{
		// 需要对_a指向资源创建同样大的资源再拷贝值
		_a = (STDataType*)malloc(s._top * sizeof(STDataType));
		if (_a == nullptr)
		{
			perror("realloc fail!");
			exit(1);
		}
		memcpy(_a, s._a, s._capacity * sizeof(STDataType));
		_top = s._top;
		_capacity = s._capacity;
	}

	//补充一点,这里也不能不写,用编译器自动生成的默认的拷贝构造函数,因为这个函数虽然会处理内置类型
	//但是只会是浅拷贝/值拷贝,像Stack这样需要有深拷贝的就不行了
	//可以这样说,如果一个类必须显示实现析构函数(需要释放资源),那么他就一定也要显示写拷贝构造函数

	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				exit(1);
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}

	~Stack()
	{
		cout << "~Stack()" << endl;
		if (_a)
		{
			free(_a);
			_a = nullptr;
		}
		_top = 0;
		_capacity = 0;
	}

private:
	STDataType* _a;
	size_t _top;
	size_t _capacity;
};

int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);

	Stack s2(s1);

	return 0;
}

图示如下:

在日期类这里进行浅拷贝就没有问题,但是在栈中就出现了问题

借助上面的栈的类,给大家对比看看传引用返回在这里的弊端,同时也是在说第6个特点:

cpp 复制代码
int& func1()
{
	int x = 1;
	return x;
}
 
//自定义类型传值返回是会调用拷贝函数的,但是传引用返回不会,画图分析。
//它没调拷贝函数的话,在后面函数栈帧销毁,st析构掉了之后。你再通过别名来找,就出问题了,画图
//Stack func2()
Stack& func2()
{
	Stack st;
	return st;
}
 
int main()
{
	int ret1 = func1();
	cout << ret1 << '\n';//可能是1也可能是随机值,我们之前判断过
 
	//但是这个栈就很明显了,我们调试看看
	Stack ret2 = func2();//这里其实也是拷贝
 
	return 0;
}

这里会报错:Not enough space,原因是在func2的返回值赋值给ret 2时会调用拷贝构造,但是func2的返回值是局部变量,出作用域被销毁了,实际上里面的值是随机值,导致_capacity太大了无法成功malloc。

关于如果是默认生成的拷贝构造函数对自定义类型的处理的代码说明:

cpp 复制代码
#include<iostream>
using namespace std;
typedef int STDataType;

class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (int*)malloc(n * sizeof(int));
		if (_a == nullptr)
		{
			perror("malloc fail!");
			exit(1);
		}
		_top = 0;
		_capacity = n;
	}
	Stack(const Stack& s)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * s._capacity);
		if (_a == nullptr)
		{
			perror("malloc fail!");
			exit(1);
		}
		memcpy(_a, s._a, s._top * sizeof(STDataType));
		_capacity = s._capacity;
		_top = s._top;
	}

	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				exit(1);
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}

	~Stack()
	{
		cout << "~Stack()" << '\n';
		if (_a)
		{
			free(_a);
			_a = nullptr;
		}
		_top = 0;
		_capacity = 0;
	}
private:
	//内置类型
	STDataType* _a;
	int _top;
	int _capacity;
};

class MyQueue
{
public:
	//编译器默认生成MyQueue的构造函数调用了Stack的构造函数,完成了两个成员的初始化
	//编译器默认生成MyQueue的拷贝构造函数调用了Stack的拷贝构造函数,完成了拷贝
	//编译器默认生成MyQueue的析构函数调用了Stack的析构函数,释放的Stack内部的资源

private:
	//自定义类型
	Stack _pushst;
	Stack _popst;
};

int main()
{
	Stack st1;
	st1.Push(1);
	st1.Push(2);
	// Stack如果不显示实现拷贝构造,用自动生成的拷贝构造完成浅拷贝
	// 会导致st1和st2里面的_a指针指向同⼀块资源,析构时会析构两次,程序崩溃
	Stack st2 = st1;

	MyQueue mq1;
	// MyQueue自动生成的拷贝构造,会自动调用Stack拷贝构造完成pushst/popst的拷贝
	// 只要Stack拷贝构造自己实现了深拷贝,这里就没问题
	MyQueue mq2 = mq1;
	//这里我们可以调试来看

	return 0;
}

二、赋值运算符重载

1.运算符重载

• 当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错。

• 运算符重载是具有特殊名字的函数,他的名字是由operator后面要定义的运算符共同构成。和其他函数一样,它也具有其返回类型和参数列表以及函数体。

• 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

• 如果一个重载运算符函数是成员函数 ,则它的第一个运算对象默认传给隐式的 this 指针,因此运算符重载作为成员函数时,参数比运算对象少一个

• 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。

• 不能通过连接语法中没有的符号来创建新的操作符:比如operator@。

.* :: sizeof ?: . 注意以上5个运算符不能重载。(选择题里面常里面考,大家要记一下)。

• 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int operator+(int x, int y)

• ⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意义,但是重载operator+就没有意义。

• 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分。

• 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout ,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。

下面我们用代码来说明一下这些特性,最后两个特性我们在下一篇博客结合案例进行讲解。

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	//其实这里用默认生成的也会处理内置类型,进行浅拷贝,在这里是没问题的。
	//这也是和构造和析构的一个区别,构造和析构不会处理内置类型

	int Getyear()
	{
		return _year;
	}

	//d1==d2
	bool operator==(const Date &d)
	{
		return _year == d._year && _month == d._month && _day == d._day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << '\n';
	}

private:
	int _year;
	int _month;
	int _day;
};

// 重载为全局的面临对象访问私有成员变量的问题
// 有几种方法可以解决:
// 1、成员放公有
// 2、Date提供getxxx函数
// 3、友元函数
// 4、重载为成员函数
//bool operator==(const Date& d1, const Date& d2)
//{
//	return d1._year == d2._year//如果用了Get**就是这样写的:d1.Getyear() == d2.Getyear()
//		&& d1._month == d2._month
//		&& d1._day == d2._day;
//}


int main()
{
	Date d1(2026,1,22);
	Date d2(d1);//拷贝构造

	Date d3(2026, 2, 22);
	d3 == d1;//运算符重载
    // 编译器会转换成 d3.operator==(d1);

    // 编译报错:"operator +"必须⾄少有⼀个类类型的形参
    int operator+(int x, int y)
    {
        return x - y;
    }

	//运算符重载函数可以显示调用
	//operator==(d1, d3);

	//如果成成员函数了,显示调用是这样的
	d1.operator==(d3);
	d1 == d3;
	//只要传一个参d3就行,d1通过this指针,但是不能在实参和形参显示写出来的
	//再加上运算符重载要求参数个数和运算符作用对象一样多,所以只能传一个
	//可以具体去看看上面类里面怎么实现的

	cout << (d1 == d2) << '\n';//这里需要打括号,优先级的问题,0
	cout << (d1 == d3) << '\n';//1

	return 0;
}

这里给大家讲一下 .* 这个运算符:

cpp 复制代码
// .* 符号普及,了解即可
#include<iostream>
using namespace std;

void func1()
{
	cout << "void func1()" << endl;
}

class A
{
public:
	void func2()
	{
		cout << "A::func2()" << endl;
	}
};

int main()
{
	// 普通函数指针
	void (*pf1)() = func1;
	(*pf1)();

	// A类型成员函数的指针
	// C++规定成员函数要加&才能取到函数指针
	void (A:: * pf2)() = &A::func2;
	A aa;
	(aa.*pf2)();//这里就是使用的.*

	return 0;
}

2.赋值运算符重载

1)赋值运算符重载的概念:

赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这⾥要注意跟拷贝构造区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。

2)赋值运算符重载的特点:

1. 赋值运算符重载是一个运算符重载,规定必须重载为成员函数 。赋值运算重载的参数建议写成 const 当前类类型引用,否则传值传参会有拷贝(const还可以有效防止权限扩大,让能传的参选择更多)。

2. 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景

3. 没有显式实现时,编译器会自动生成 一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。

4. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。但像Stack这样的类,虽然也都是内置类型,但是 _a 指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像 MyQueue 这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现 MyQueue 的赋值运算符重载。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显⽰写赋值运算符重载,否则就不需要。

下面依然用代码给大家说明一下这些特点:

cpp 复制代码
//赋值运算符重载
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	//传引用返回可以减少拷贝(之前提到过在这里传值返回是自动调用拷贝函数的)
	//这里能使用是传引用返回是因为第一个参数用this来的,函数栈帧销毁后能找到
	//函数要返回类型是为了更好处理连续赋值的情况(d3=d1=d2)
	Date& operator=(const Date& d)
	{
		//自己等于自己就可以不用赋值了
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		//比如:d1=d2表达式的返回对象应该为d1,也就是*this
		return *this;
	}

	//赋值运算符重载,但其实在Date类型里面不写也没影响,跟拷贝构造函数处理内置类型原理一样

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << '\n';
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2(2026, 1, 22);
	d1 = d2;

	d1.Print();
	d2.Print();

	Date d3(2026, 2, 22);
	d1 = d2 = d3;//从右往左赋值

	d1.Print();
	d2.Print();
	d3.Print();

	// 需要注意这里是拷贝构造,不是赋值重载
	// 要牢牢记住赋值重载是完成两个已经存在的对象直接的拷贝赋值
	// 而拷贝构造用于一个对象拷贝初始化给另⼀个要创建的对象
	Date d4 = d1;
	//因为拷贝构造如果写出这样就有点容易混
	//Date d4(d1);//写成这样的时候不太容易混淆
	//与Date d4 = d1;一样
	return 0;
}

本篇博客的完整原代码:

小张同学的CPP仓库------gitee.com


往期回顾:

C++ 类和对象(二):实例化、this指针、构造函数、析构函数详解-CSDN博客

C++类和对象(一):inline函数、nullptr、类的定义深度解析-CSDN博客

C++ 入门不迷路:缺省参数、函数重载与引用轻松拿捏-CSDN博客

结语:

本篇文章就到此结束了,主要介绍了类的拷贝构造函数、运算符重载以及赋值运算符重载,整体来看还是比较有难度的,但其实只要理解了前面的知识,这些部分还是可以理解的。接下来我们会继续学习类和对象的知识,学习任重而道远,欢迎大家继续关注。如果文章对你有帮助的话,欢迎评论,点赞,收藏加关注,感谢大家的支持。

相关推荐
_OP_CHEN1 小时前
【算法基础篇】(五十)扩展中国剩余定理(EXCRT)深度精讲:突破模数互质限制
c++·算法·蓝桥杯·数论·同余方程·扩展欧几里得算法·acm/icpc
福楠1 小时前
C++ STL | set、multiset
c语言·开发语言·数据结构·c++·算法
一晌小贪欢1 小时前
Python 健壮性进阶:精通 TCP/IP 网络编程与 requirements.txt 的最佳实践
开发语言·网络·python·网络协议·tcp/ip·python基础·python小白
enfpZZ小狗1 小时前
基于C++的反射机制探索
开发语言·c++·算法
曹牧1 小时前
C#:WebReference
开发语言·c#
黎雁·泠崖2 小时前
Java static入门:概述+静态变量特点与基础实战
java·开发语言
玉梅小洋2 小时前
C盘爆满 修改VS Code缓存与插件目录指定方法
开发语言·windows·visualstudio
C#程序员一枚2 小时前
C#AsNoTracking()详解
开发语言·c#
王老师青少年编程2 小时前
2023年12月GESP真题及题解(C++七级): 纸牌游戏
c++·题解·真题·gesp·csp·七级·纸牌游戏