《吃透 C++ 类和对象(中):拷贝构造函数与赋值运算符重载深度解析》

发布于:2025-08-15 ⋅ 阅读:(17) ⋅ 点赞:(0)

🔥个人主页:@草莓熊Lotso

🎬作者简介:C++研发方向学习者

📖个人专栏: 《C语言》 《数据结构与算法》《C语言刷题集》《Leetcode刷题指南》

⭐️人生格言:生活是默默的坚持,毅力是永久的享受。

前言: 在上篇博客中我们学习了构造函数和析构函数这两个类中的默认成员函数,今天这篇博客我想继续为大家分享拷贝构造函数和赋值运算符重载。主要是先介绍特点再通过举例说明,所以举例中的代码注释是很重要的。


目录

一.拷贝构造函数

拷贝构造的特点:

举例说明:(注意看注释) 

二.赋值运算符重载

运算符重载:

举例说明:(注意注释)

赋值运算符重载:

赋值运算符重载的特点:

举例说明:(注意注释) 


一.拷贝构造函数

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

拷贝构造的特点:

  1. 拷贝构造函数是构造函数的⼀个重载。
  2. 拷贝构造函数的第⼀个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归用。 拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象引用,后面的参数必须有缺省值。
  3. C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成
  4. 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝)(这里和构造与析构不一样,它是会对内置类型有特定处理的),对自定义类型成员变量会调用他的拷贝构造。
  5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。这里还有一个小技巧,如果⼀个类显示实现了析构并释放资源(也可以说必须显示实现析构),那么他就需要显示写拷贝构造,否则就不需要。
  6. 传值返回会产生⼀个临时对象调用拷贝构造,传值引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于⼀个野引用,类似⼀个野指针⼀样。传引用返回可以减少拷贝,但是⼀定要确保返回对象,在当前函数结束后还在,才能用引用返回。

--上述特点在举例说明中都会体现出来 

关于无穷递归图示:

举例说明:(注意看注释) 

#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(Date d),这种写法不行,我们可以拿个func的例子看看,会发现传值要先调用拷贝函数,下面有提到
	// 但是拷贝函数本身再一直调用拷贝函数本身的话,会出现无限递归的问题,所以要使用传引用传参
	//加个const可以让下面传实参的选择更多,避免出现权限扩大等问题
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	//其实这里用默认生成的也会处理内置类型,进行浅拷贝,在这里是没问题的。这也是和构造和析构的一个区别

	//用指针实现拷贝,但是这里并不算拷贝函数
	/*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)
{

}

//传值返回也会调用拷贝函数,但是传引用就不会(后续会用Stack继续来讲),这里就不过多介绍了
//Date& func2()
Date func2()//这里最好用传值返回
{
	Date d(2025,7,31);
	return d;
}


int main()
{
	Date d1(2025,8,1); 
	Date d2(d1);

	//func(d1);//我们调试发现,他会先去调用Date里面的拷贝函数再去func

	//传值返回可以,传引用返回不行,这里不细讲
	Date ret = func2();
	//Date ret = (func2());,这样写也是可以的

	//由这个我们还可以看出,那其实之前的拷贝也可以写出这样
	//Date d2 = d1;


	// 这里可以完成拷⻉,但是不是拷⻉构造,只是⼀个普通的构造
	//Date d2(&d1);

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

	return 0;
}

关于对内置类型处理和深浅拷贝的相关示例: (注意注释,用的栈)

#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 = s._a;
		_capacity = s._capacity;
		_top = s._top;
	}*/

	//所以我们需要这样写
	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;
	}
	//补充一点,这里也不能不写,用编译器自动生成的默认的拷贝构造函数,因为这个函数虽然会处理内置类型
	//但是只会是浅拷贝/值拷贝,像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()"<<'\n';
		if (_a)
		{
			free(_a);
			_a = nullptr;
		}
		_top = 0;
		_capacity = 0;
	}
private:
	//内置类型
	STDataType* _a;
	int _top;
	int _capacity;
};

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

	Stack s2(s1);

	return 0;
}

图示如下:

理解技巧:可以这样说,如果一个类必须显示实现析构函数(需要释放资源),那么他就一定也要显示写拷贝构造函数

借助上面的栈的类(这里就不再写出来了),给大家对比看看传引用返回在这里的弊端,同时也是在说第6个特点 :(注意注释)

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;
}

 --会报realloc fail,但是上面的结构需要改一下(int改size_t),不然不会报这个错误

  再来看看如果是默认生成的拷贝构造函数对自定义类型的处理:(注意注释)

#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;
	}
	//补充一点,这里也不能不写,用编译器自动生成的默认的拷贝构造函数,因为这个函数虽然会处理内置类型
	//但是只会是浅拷贝/值拷贝,像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()" << '\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 size = 0;
};
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;
}


二.赋值运算符重载

--在正式学习赋值运算符重载之前我们需要先了解一下运算符重载

运算符重载:

  • 当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错。
  • 运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其他函数⼀样,它也具有其返回类型和参数列表以及函数体。
  • 重载运算符函数的参数个数和该运算符作用的运算对象数量⼀样多。一元运算符有⼀个参数,二元运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第二个参数。
  • 如果一个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。
  • 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。
  • 不能通过连接语法中没有的符号来创建新的操作符:比如operator@。
  • .* (点*),:: ,sizeof ,?:  ,. (点)注意以上5个运算符不能重载。(选择题里面常考,大家要记一下)
  • 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int operator+(int x, int y)
  • 一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意义,但是重载operator+(当日期加日期时)就没有意义(加天数还是可以的)。
  • 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分。
  • 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调⽤时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。

--上述特点在举例说明中大多都会提到,其中最后三个特点会在后续的博客中讲解 

举例说明:(注意注释)

#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,传d2就行,d1有this指针,但是在实参和形参不能直接写出来,函数体内可以
	bool operator==(const Date& d2)
	{
		return this->_year == d2._year
			&& this->_month == d2._month
			&& this->_day == d2._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(2025,8,1);
	Date d2(2025,10,1);
	Date d3(2025, 8, 1);

	//我们在这里就需要实现运算符重载函数
	d1 == d2;

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

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

	return 0;
}

--0表示不相等,1表示相等 

给大家大概看一下 .* 这个符号:(注意注释)

// .*符号普及,了解即可,刚好提到了这个运算符不能重载
#include<iostream>
using namespace std;

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

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

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

	// A类型成员函数的指针
	void(A::*pf2)() = &A::func2;
	A aa;
	(aa.*pf2)();//这里就是使用的.*

	return 0;
}

赋值运算符重载:

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

赋值运算符重载的特点:

  1. 赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const 当前类类型引用,否则会传值传参会有拷贝(const还可以有效防止权限扩大,让能传的参选择更多)
  2. 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值的场景。
  3. 没有显式实现时,编译器会自动生成⼀个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。
  4. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显⽰实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现MyQueue的赋值运算符重载。这里还有⼀个小技巧,如果⼀个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要(跟拷贝构造函数类似)。

--上述特点大部分会在后续的举例说明中解释

举例说明:(注意注释) 

//赋值运算符重载
#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),用void不好处理
	Date& operator=(const Date& d)//const和传引用传参的作用就不再多说了
	{
		//自己等于自己就可以不用赋值了
		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(2025, 8, 1);
	d1 = d2;

	Date d3;
	d3 = d1 = d2;//从右往左

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

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

	return 0;
}

完整源代码: 

cpp-exclusive-warehouse: 【CPP知识学习仓库】 - Gitee.com


往期回顾:

《吃透 C++ 类和对象(上):封装、实例化与 this 指针详解》

《吃透 C++ 类和对象(中):构造函数与析构函数的核心逻辑》

结语:本篇博客就到此结束了,在学完类和对象的这些知识后,虽然还没学完,但博主后续会先更新实现一个完整的日期类的博客,这个还是有点难度的。大家可以看完之后自己去试一下,检验一下自己的学习成果,如果文章对你有帮助的话,欢迎评论,点赞,收藏加关注,感谢大家的支持。


网站公告

今日签到

点亮在社区的每一天
去签到