C++ 类和对象(中)

(一)、类的默认成员函数

默认成员函数就是⽤⼾没有显式实现,编译器会⾃动⽣成的成员函数称为默认成员函数。

默认成员函数一共有6个分别是
1、构造函数
2、析构函数
3、拷贝构造函数
4、赋值运算符重载函数
5、取地址运算符重载函数
6、const取地址运算符重载函数

需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可。
其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们后⾯再讲解。默认成员函数很重要,也⽐较复杂,我们要从两个⽅⾯去学习:
• 第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
• 第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?

(二)、构造函数

1、什么是构造函数

构造函数:用于初始化对象的成员变量,确保对象在创建时就处于一个合理的初始状态。它的函数名与类名相同,没有返回值。
构造函数的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数⾃动调⽤的特点就完美的替代的了Init。

2、构造函数的特点

1、函数名与类名相同。
2、没有返回值。
3、对象实例化时编译器会自动调用相应的构造函数。
4、构造函数可以重载。
5、如果类中没有显式的定义一个构造函数,C++编译器会自动生成一个默认构造函数,一旦用户定义,编译器便不再生成。
6、无参构造函数、全缺省构造函数、我们不再写构造函数时编译器默认生成的构造函数,都叫做默认构造函数。
但是这3个函数,有且只能存在一个,不能够同时存在。
无参构造函数,全缺省构造函数对然构成重载,但是会造成歧义。
需要注意的是:不只是编译器生成的构造函数叫做默认构造函数,无参构造函数、全缺省构造函数也是默认构造函数,只要不传实参就可以调用的构造都叫做默认构造。
7、对于我们不写,编译器默认生成的构造函数:
A、对内置类型成员变量没有初始化的有奥球,也就是说是否初始化是不确定的,具体需要看编译器,但是C++没有一个固定的标注。
B、对于自定义成员变量,要求调用这个成员变量的的默认构造函数初始化
如果这个成员变量没有默认构造函数,就会报错
注:C++把类型分为内置类型:int、double、char,自定义类型class、struct等关键字自己定义的类型。

3、构造函数使用

1、首先看一个例子,当用户没有写构造函数时,编译器会自动生成一个构造函数

#include<iostream>
using namespace std;

// 有构造时,直接调用用户写好的构造
class Date
{
public:
	// 1.⽆参数构造函数
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	// 2.带参构造函数
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// 3.全缺省构造函数
	
	//Date(int year = 1, int month = 1, int day = 1)
	//{
	//_year = year;
	//_month = month;
	//_day = day;
	//}
	/*
	在Date类里,你既定义了带参构造函数Date(int year, int month, int day),
	又定义了全缺省构造函数Date(int year = 1, int month = 1, int day = 1)。
	这两个构造函数虽然构成了重载,但当调用时不传参数,就会产生歧义。
	因为编译器无法明确到底该调用带参构造函数(把所有参数都使用默认值)还是全缺省构造函数。
	所以 2和3 只能保留一个
	*/
	
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

 无构造时,编译器默认自动生成一个构造
class Date1
{
public:
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	// 这里实例化时,类 + 对象名 
	// 对象实例化一定会调用相对应的构造,保证了对象实例化出来时一定被初始化了

	Date d1; // 调⽤默认构造函数
	// 因为构造函数相当于初始化函数,对象实例化时编译器会自动匹配相应的构造函数
	// 所以选中了 无参数构造函数 Date(),将所有的成员变量全部初始化为1
	// 注意:如果通过⽆参构造函数创建对象时,对象后⾯不⽤跟括号Date d1();
	// 否则编译器⽆法正确解析,可能会将其误认为是函数声明,所以戴上()就需要传参了
	cout << "测试无实参构造函数:" << endl;
	d1.Print();
	// 所以打印对象d1时,输出1/1/1
	// 不只是编译器生成的构造函数叫做默认构造函数。
	// 无参构造函数、全缺省构造函数也是默认构造函数,只要不传实参就可以调用的构造都叫做默认构造。
	
	Date d2(2025, 1, 1); // 调⽤带参的构造函数
	// 因为构造函数相当于初始化函数,对象实例化时,自动进行函数重载,匹配相应的构造函数
	// 因为实例化初始化时,传递了三个参数,自动匹配Date(int year, int month, int day),构造函数
	// 区分这⾥是函数声明还是实例化对象
	cout << "测试三个参数的构造函数:" << endl;
	d2.Print();

	// 如果留下三个构造中的第⼆个带参构造,第⼀个和第三个注释掉
	// 编译报错:error C2512: “Date”: 没有合适的默认构造函数可⽤
	// 因为Data类在实例化时,执行Date d1;这条语句时,是默认传递3个参数的

	cout << "测试无自定义构造:" << endl;
	// 没有自定义构造时,编译器自动生成一个无参数的构造
	// 对内置类型:int/char/double/指针等初始化的结果是不确定的
	// 本质原因是C++标准是不确定的
	Date1 d3;
	d3.Print();
	// 因为对内置类型编译器初始化的结果是不确定的
	// 所以打印结果:-858993460/-858993460/-858993460
	

	return 0;    
}

(三)、析构函数

析构函数与构造函数功能相反,析构函数是完成对对象本⾝的销毁,⽐如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会⾃动调⽤析构函数,完成对象中资源的清理释放⼯作。
析构函数的功能类⽐我们之前Stack实现的Destroy功能,⽽像构造函数中Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。

析构函数最本质的使用方法:当类中包含动态分配的资源(如 new 分配的内存、文件句柄等)时,需要在析构函数中手动释放这些资源。对于内置类型的变量,若其本身不管理动态资源(如普通指针指向的内存),则无需特殊处理。若类中没有动态资源或已使用智能指针管理资源,则可以使用编译器默认生成的析构函数。

析构函数特性:

  1. 析构函数名是在类名前加上字符 ~。 例如无参数构造函数为Data(),那么他的析构函数则为~Data()
  2. ⽆参数⽆返回值。 (这⾥跟构造类似,也不需要加void)
  3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会⾃动⽣成默认的析构函数。
  4. 对象⽣命周期结束时,系统会⾃动调⽤析构函数。
  5. 跟构造函数类似,我们不写编译器⾃动⽣成的析构函数对内置类型成员不做处理,⾃定类型成员会
    调⽤他的析构函数。
  6. 还需要注意的是我们显⽰写析构函数,对于⾃定义类型成员也会调⽤他的析构,也就是说⾃定义类
    型成员⽆论什么情况都会⾃动调⽤析构函数。
  7. 如果类中没有申请资源时,析构函数可以不写,直接使⽤编译器⽣成的默认析构函数,如Date;如果默认⽣成的析构就可以⽤,也就不需要显⽰写析构,如MyQueue;但是有资源申请时,⼀定要⾃⼰写析构,否则会造成资源泄漏,如Stack。
  8. ⼀个局部域的多个对象,C++规定后定义的先析构。
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
	{
		perror("malloc申请空间失败");
		return;
	}
	_capacity = n;
	_top = 0;
	}
	~Stack()//析构函数
	{
		cout << "~Stack()" << endl;
		free(_a); //析构函数的本质是将动态开辟的内存释放掉
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
	//编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源
	// 显⽰写析构,也会⾃动调⽤Stack的析构
	/*~MyQueue()
	{}*/
private:
	Stack pushst;
	Stack popst;
};
int main()
{
	Stack st;
	MyQueue mq;
	return 0;
}

(四)、拷贝构造函数

1、拷贝构造函数基础

什么是拷贝构造函数:
如果⼀个构造函数的第⼀个参数是⾃⾝类类型的引⽤,且任何额外的参数都有默认值,则此构造函数也叫做拷⻉构造函数,也就是说拷⻉构造是⼀个特殊的构造函数。
拷贝构造函数的特点:

  1. 拷⻉构造函数是构造函数的⼀个重载。
  2. 拷⻉构造函数的第⼀个参数必须是类类型对象的引⽤使⽤传值⽅式编译器直接报错,因为语法逻辑上会引发⽆穷递归调⽤。 拷⻉构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引⽤,后⾯的参数必须有缺省值。
  3. C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥⾃定义类型传值传参和传值返回都会调⽤拷⻉构造完成。
  4. 若未显式定义拷⻉构造,编译器会⽣成⾃动⽣成拷⻉构造函数。⾃动⽣成的拷⻉构造对内置类型成员变量会完成值拷⻉ / 浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的拷⻉构造。
  5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器⾃动⽣成的拷⻉构造就可以完成需要的拷⻉,所以不需要我们显⽰实现拷⻉构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器⾃动⽣成的拷⻉构造完成的值拷⻉ / 浅拷⻉不符合我们的需求,所以需要我们⾃⼰实现深拷⻉(对指向的资源也进⾏拷⻉)。像MyQueue这样的类型内部主要是⾃定义类型Stack成员,编译器⾃动⽣成的拷⻉构造会调⽤Stack的拷⻉构造,也不需要我们显⽰实现MyQueue的拷⻉构造。这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就需要显⽰写拷⻉构造,否则就不需要。
  6. 传值返回会产⽣⼀个临时对象调⽤拷⻉构造,传值引⽤返回,返回的是返回对象的别名(引⽤),没有产⽣拷⻉。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使⽤引⽤返回是有问题的,这时的引⽤相当于⼀个野引⽤,类似⼀个野指针⼀样。传引⽤返回可以减少拷⻉,但是⼀定要确保返回对象,在当前函数结束后还在,才能⽤引⽤返回。
#define _CRT_SECURE_NO_WARNINGS 1
/*
如果⼀个构造函数的第⼀个参数是⾃⾝类类型的引⽤,且任何额外的参数都有默认值,则此构造函数
也叫做拷⻉构造函数,也就是说拷⻉构造是⼀个特殊的构造函数。
拷⻉构造的特点:
1. 拷⻉构造函数是构造函数的⼀个重载。
2. 拷⻉构造函数的第⼀个参数必须是类类型对象的引⽤,使⽤传值⽅式编译器直接报错,因为语法逻
辑上会引发⽆穷递归调⽤。 拷⻉构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引
⽤,后⾯的参数必须有缺省值。
3. C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥⾃定义类型传值传参和传值返
回都会调⽤拷⻉构造完成。
4. 若未显式定义拷⻉构造,编译器会⽣成⾃动⽣成拷⻉构造函数。⾃动⽣成的拷⻉构造对内置类型成
员变量会完成值拷⻉ / 浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的拷⻉构
造。
5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器⾃动⽣成的拷⻉构造就可以完
成需要的拷⻉,所以不需要我们显⽰实现拷⻉构造。像Stack这样的类,虽然也都是内置类型,但
是_a指向了资源,编译器⾃动⽣成的拷⻉构造完成的值拷⻉ / 浅拷⻉不符合我们的需求,所以需要
我们⾃⼰实现深拷⻉(对指向的资源也进⾏拷⻉)。像MyQueue这样的类型内部主要是⾃定义类型
Stack成员,编译器⾃动⽣成的拷⻉构造会调⽤Stack的拷⻉构造,也不需要我们显⽰实现
MyQueue的拷⻉构造。这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就
需要显⽰写拷⻉构造,否则就不需要。
6. 传值返回会产⽣⼀个临时对象调⽤拷⻉构造,传值引⽤返回,返回的是返回对象的别名(引⽤),没
有产⽣拷⻉。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使⽤
引⽤返回是有问题的,这时的引⽤相当于⼀个野引⽤,类似⼀个野指针⼀样。传引⽤返回可以减少
拷⻉,但是⼀定要确保返回对象,在当前函数结束后还在,才能⽤引⽤返回。
*/
#include <iostream>

using namespace std;

class Data
{
public:
	Data()// 无参构造函数
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Data(int year, int month, int day) //  带参构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//	Data(int year = 2024,int month = 4,int day = 28)
	//	{
	//		_year = year;
	//		_month = month;
	//		_day = day;
	//	}

	Data(Data& d) // 拷贝构造函数的形参必须是引用
	{
		_year = d._year; // 将对象d的成员变量赋值给_year
		_month = d._month;
		_day = d._day;
	}
	// 为什么这里不能用传值,必须用引用?
	// 因为如果传递是值的话:拷贝构造函数应该这么写 Data(Data d){},但是会造成无限递归拷贝
	/*
		当我们在main函数调用func(d1); 传递类类型的值时,会先将d1的值传递给拷贝构造函数(将d1传递给形参d)
		但是复制操作会调用拷贝构造函数 Data(Data d),
		此拷贝构造函数在接收参数时又需要对传入的对象进行复制,进而再次调用自身,形成无限递归。
		所以在类类型使用拷贝构造函数初始化的时候,形参必须使用传引用接受实参
	*/

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

void func(Data d)
{
	//...
}

int main()
{ 
	Data d1(2024, 12, 23); // 构造函数
	d1.Print(); 

	Data d2(d1);// 拷贝构造函数
	// 拷贝d1用来初始化d2
	// 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归
	// 拷贝构造函数也可以有多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值

	func(d1);
	// 通过调试可以看出来,进入func语句后,程序先跳转到了data类中的拷贝构造函数  Data(Data& d)
	// 拷贝结束后,再跳转到func函数那里,为什么会先跳转到到拷贝构造函数,而不是直接到func函数呢?
	// 原因:
	// 对于自定义类类型的对象,创建副本时会调用拷贝构造函数。
	// 因为编译器不知道如何复制自定义类的对象,不像int、float、double、char等基本类型直接进行浅拷贝(复制字节即可),必须借助拷贝构造函数来完成对象的复制操作
	// 所以在执行func(d1);时,为了创建d1的副本,就会先调用这个拷贝函数,将d1的成员变量赋值到新创建的对象d中。

	return 0;
}

2、浅拷贝

什么是浅拷贝?
什么是浅拷贝
浅拷贝指的是在创建新对象时,新对象的成员变量直接复制原对象成员变量的值。
若成员变量为基本数据类型(像 int、double 等),就直接复制数值;
若成员变量为指针类型,仅复制指针的值(也就是地址),而不会复制指针所指向的内存内容。所以换句话说,浅拷贝就是简单内存单元中值拷贝。
在默认状况下,若没有为类定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数,这个默认拷贝构造函数执行的就是浅拷贝操作吗,但是浅拷贝构造函数会带来一系列问题。
下面是一个简单示例:

#include <iostream>

using namespace std;

class Data
{
public:
	Data() // 构造函数
	{
		_p = (int*)malloc(4*sizeof(int));
		for(int i = 0;i<4;i++)
		{
			*(_p+i) = i;
		}
		_num = 4;
	}
	
	~Data() // 解析构造函数
	{
		cout << "free(_p)" << endl;
		free(_p);
		_p = nullptr;
		_num = 0;
	}
	
	Data(const Data& d) // 浅拷贝构造函数
	{
		_p = d._p;
		_num = d._num;
	}

	
	void Print()
	{
	for(int i = 0; i < 4; i++)
		cout << *(_p+i) << " "; 
	cout << endl;
	}	
	

private:
	int* _p;
	int _num;
};

// 先看第一个main函数的打印结果
// 因为d2是d1的拷贝,所以他们输出的结果都是一样的
int main()
{
	Data d1;
	d1.Print();
	
	Data d2(d1); // 拷贝对象d1的属性去实例化对象d2
	d2.Print();
	
/*	打印结果
	0 1 2 3 对应d1.Print();
	0 1 2 3 对应d2.Print();
	free(_p) 对应d1.~Data()
	free(_p) 对应d2.~Data()
*/
	
	return 0;
}

// 第二个main函数就详细解释了浅拷贝带来的问题
// 编译器直接将_p指针的值(也就是地址)进行复制,而不会复制指针所指向的内存内容,时引发一系列问题的根源。
int main()
{
	Data d1;
	d1.Print();	 // 打印d1的属性 
	// 0 1 2 3
	
	Data d2(d1); // 拷贝对象d1的属性去实例化对象d2
	/*
		注意此处调用的浅拷贝构造函数,只是对d1的属性逐字节进行赋值
		类中2个成员变量:
			d2中 int* _p 指向的内存空间 与 d1中 int* _p指向的内存空间是一样的。
			即假如d2中成员变量 _p中的地址是0x0012ff40,那么d1中成员变量 _p中的地址也是0x0012ff40
			它只是将d1的成员变量的值逐字节复制给d2。
			对于指针成员变量_p,这意味着d2._p和d1._p将指向同一块内存空间;
			对于普通成员变量_num,d2._num将获得与d1._num相同的值。
	
	*/
	d2.Print();  // 打印d2的属性
	// 0 1 2 3
	
	d1.~Data(); 
	// 在这里调用d1的析构函数,将d1动态开辟的空间进行释放,此时d1._p指向的内存空间被释放,d1._p被置为nullptr。
	// 但由于浅拷贝的原因,d2._p仍然指向这块已经被释放的内存空间,形成了野指针。
	
	d2.Print();	
	// 此时d2.Print()通过野指针_p进行访问,这是非常危险的行为,可能会导致程序崩溃或者产生不可预测的结果。
	// 在这个例子中,输出了一些看似奇怪的值,这是因为访问了已经被释放的内存空间,
	// 该空间中的数据已经不再有效,其内容可能已经被其他程序或系统操作所修改。
	// 13970424 13959360 2 3
/*	打印结果
	0 1 2 3
	0 1 2 3
	free(_p)
	15084536 15073472 2 3
	free(_p)
	free(_p)
*/
	return 0;
}

当执行 Data d2(d1); 时,由于没有显式定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。
// 这个默认的拷贝构造函数会进行浅拷贝,即它会将 d1 的成员变量逐个复制到d2 中。
// 对于指针成员 _p,浅拷贝只是简单地将 d1._p 所存储的地址复制到 d2._p 中。
// 这意味着 d1._p 和 d2._p 指向同一块内存。
// 当程序结束时,n1 和 n2 会依次调用析构函数。
// d1 的析构函数会首先执行 free(_p),这会释放 d1._p 所指向的内存。
// 然后,当 d2 的析构函数执行时,它也会尝试执行 free(_p),但此时这块内存已经被 n1 的析构函数释放了。
// 这就是为什么会导致重复内存释放的原因。
// 重复释放内存会导致程序出现未定义行为,可能会引发崩溃、数据损坏或其他难以调试的问题。
// 为了避免这种情况,你需要定义一个深拷贝构造函数,该函数会为 d2 的 _p 指针分配新的内存,并将 d1._p 所指向的内存内容复制到新的内存中。这样,d1._p 和 d2._p 就会指向不同的内存块,从而避免了重复释放内存的问题。

3、深拷贝

还是刚才那个程序,我们只需要对他的拷贝构造函数进行修改,既可以完成深拷贝操作。

#include <iostream>
#include <stdio.h>
#include <string.h>

using namespace std;

class Data
{
public:
	
	Data() // 构造函数
	{
		_p = (int*)malloc(4*sizeof(int));
		for(int i = 0;i<4;i++)
		{
			*(_p+i) = i;
		}
		_num = 4;
	}
	
	~Data() // 解析构造函数
	{
		cout << "free(_p)" << endl;
		free(_p);
		_p = nullptr;
	}
	
//	Data(const Data& d) // 浅拷贝构造函数
//	{
//		_p = d._p;
//		_num = d._num;
//	}

	Data(const Data& d) // 浅拷贝构造函数
	{
		_num = d._num;
		_p = (int*)malloc(_num * sizeof(int));
		memcpy(_p,d._p,sizeof(int)*d._num);
	}
	
	void Print()
	{
	for(int i = 0; i < 4; i++)
		cout << *(_p+i) << " "; 
	cout << endl;
	}	
	

private:
	int* _p;
	int _num;
};
 // 深拷贝
int  main()
{
	Data d1;
	
	Data d2 = d1; // Data d2(d1);
	// 因为你实现的深拷贝构造函数中,为d2._p重新申请了_num个int类型大小的内存空间(这里_num为 4)
	// 并使用了memcpy函数将d1._p指向内存单元中的数据拷贝到了d2._p指向内存单元
	// 所以就算是d1进行了释放,也不影响d2的数据
	d2.Print();
	
	d1.~Data(); 
	// 调用d1的析构函数,释放d1中动态分配的内存(_p指向的内存)
	// 由于d2和d1有各自独立的_p指向的内存,在d1的析构函数释放了d1._p指向的内存后,d2仍然可以正确访问自己_p指向的内存,输出正确的数据
	d2.Print();
		
	return 0;
}

再看一个难一点的拷贝构造函数例子:


#include <iostream>
#include <assert.h>

using namespace std;
typedef int STDataType;

class Stack 
{
public:
	Stack(int n = 4) // 构造函数
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}

	// 这里是默认的浅拷贝行为(虽然没显式写拷贝构造函数,但编译器会生成类似的浅拷贝逻辑)
	// 实际上相当于下面这样的浅拷贝实现
//	 Stack(const Stack& s) {
//	     _a = s._a;
//	     _capacity = s._capacity;
//	     _top = s._top;
//	 }


	// st2(st1)
	Stack(const Stack& s) // 深拷贝构造函数
	{
		cout << "Stack(Stack& s)" << endl; // 如果调用拷贝构造函数,那么打印这个语句
		_a = (STDataType*)malloc(sizeof(STDataType) * s._capacity);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}
		memcpy(_a, s._a, sizeof(STDataType) * s._top);
		_top = s._top;
		_capacity = s._capacity;
	}

	void Push(const STDataType& x)
	{
		// 扩容
		_a[_top] = x;
		_top++;
	}

	int Top()
	{
		return _a[_top-1];
	}

	~Stack() // 析构函数
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;

};


int& f1()
{
	int ret = 0;
	return ret;
}
// 返回的是ret的别名,但是ret已经在函数栈帧结束后别销毁了
// 所以这里返回的类似于一个有野引用

Stack& f2() // 返回的是st1的别名
{
	Stack st1;
	st1.Push(1);
	st1.Push(2);
	st1.Push(3);
	st1.Push(4);

	return st1;
}
// 同样在调用完f2()函数以后,st1这个对象已经在函数栈帧中被销毁了
// 已经执行了~Stack()析构函数,malloc申请的空间已经被释放了
// 但是我们仍然通过引用取得了st1的别名,可通过st1这个栈对象可以取得.top()
// 程序可以通过编译器的检查,但是当执行.top()这个成员函数时,会报空指针解引用错误

Stack f3()
{
	Stack st1;
	st1.Push(1);
	st1.Push(2);
	st1.Push(3);
	st1.Push(4);

	return st1; // 注意此处返回的是值,是深拷贝
}
// 为什么是深拷贝呢?我们一步一步来捋一捋
// 当函数返回 st1 时,会调用 Stack 类的拷贝构造函数(这里的拷贝构造函数是你自定义的,实现了深拷贝逻辑)。
// 在拷贝构造函数中,会为新的对象重新分配内存并复制数据,这样就保证了返回的对象有自己独立的内存空间,不会和 st1 共享内存。
// 所以,f3 函数的返回值是有效的,它返回了一个新的 Stack 对象,该对象的数据和 st1 相同,但拥有独立的内存,避免了浅拷贝带来的内存管理问题,并且在函数返回后,st1 被销毁也不会影响到返回的对象。

int main() 
{

	cout << f1() << endl;
	cout << f2().Top() << endl;
	cout << f3().Top() << endl; 
    return 0;
}

注意:在例子f3中,当函数返回以类的传值调用进行返回时,会自动调用类的拷贝构造函数,这个程序中我写的是一个深拷贝构造函数,所以就算函数调用结束,函数栈帧销毁对象了st1,函数返回时,仍然能够返回一个深拷贝构造对象,满足程序使用。

4、比较浅拷贝与深拷贝

拷贝方式
浅拷贝:只是对对象中的成员变量进行简单的赋值操作,对于基本数据类型的成员变量,会直接复制其值;**而对于指针类型的成员变量,只复制指针的值,即只复制了指针所指向的地址,而不复制指针所指向的内存空间中的数据。**这意味着多个对象的指针成员会指向同一块内存空间。
深拷贝:不仅会复制对象中的基本数据类型成员变量的值,还会为指针类型的成员变量重新分配一块新的内存空间,并将原对象中指针所指向的内存空间中的数据复制到新分配的内存空间中。这样,每个对象都有自己独立的内存空间,相互之间不会影响。
内存管理
浅拷贝:由于多个对象的指针成员指向同一块内存空间,当其中一个对象对该内存空间进行释放或修改时,会影响到其他对象,容易导致内存泄漏或数据不一致的问题。比如在前面的代码示例中,当d1释放了其动态分配的内存后,d2的指针就变成了野指针,再通过d2访问该内存就会出现错误。
深拷贝:每个对象都有自己独立的内存空间,对一个对象的修改不会影响到其他对象,在对象销毁时,也可以独立地释放自己所占用的内存空间,避免了内存管理方面的问题。
适用场景
浅拷贝:当对象中的成员变量没有指向动态分配的内存,或者多个对象需要共享同一块数据时,可以使用浅拷贝。浅拷贝的效率相对较高,因为它不需要额外的内存分配和数据复制操作。
深拷贝:**当对象中的成员变量指向了动态分配的内存,并且在对象复制后,需要保证每个对象都有自己独立的一份数据,互不影响时,就需要使用深拷贝。**例如,在实现一些需要独立复制对象的功能,如对象的克隆、复制粘贴等操作时,深拷贝是必不可少的。

(五)、赋值运算符重载

1、运算符重载

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

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

重载运算符函数的参数个数和该运算符作⽤的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元
运算符有两个参数,⼆元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第⼆个参数。
如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算
符重载作为成员函数时,参数⽐运算对象少⼀个。

不能通过连接语法中没有的符号来创建新的操作符:⽐如operator@
.* :: sizeof ?: . 注意以上5个运算符不能重载

那么为什么需要运算符重载?
例如:
在比较2个int类型变量的时候

int a = 1,b = 2int  ret = a > b;
// 显然 ret 的值为0
// 因为 a > b 为假

比较2个double类型、char类型也是一样的,对于这些基本数据类型,编译器可以直接进行比较。
但是两个日期类对象怎么比较?编译器是无法知道的,例如:

class Data
{
public:
	Data(int year = 1,int month = 1,int day = 1) // 构造函数
	{
		cout << "Data(int year = 1,int month = 1,int day = 1)" << endl;
		_year = year;
		_month = month;
		_day = day;
	}
	
	bool operator==(const Data& x2); //  == 运算符重载的函数声明
	
private: 
	int _year;
	int _month;
	int _day;
};

bool Data::operator==(const Data& x2) 
//  == 运算符重载的函数实现,注意此处形参传的是引用,避免传值,可以节省空间
// 而且我们是进行比较的,不对x2进行修改,所以前面加上const进行修饰的必要的,避免权限放大
{
	return _year == x2._year
			&& _month == x2._month
			&& _day == x2._day;
	// 在执行if(d1 == d2)中的“d1 == d2”时,会自动跳转到 == 的运算符重载程序段
	// bool Data::operator==(const Data& x2)比较的是2个类对象。是不是就应该给2个形参呢?
	// no!如果写成 bool operator>(const Data& x1,const Data& x2) 会报此运算符函数参数太多了
	// 因为在调用运算符重载成员函数的时候,成员函数默认有this指针作为第一个参数,所以只需要显式声明一个参数即可。
	// 如果是d1 == d2,本质上是调用了d1的运算符重载成员函数,d1作为this指针存在
	// 我们只需要比较this->_year 是不是等于 x2._year就可以了
	// 而this指针可以省略不写,就是上面我写出的程序了
}

int main()
{
	Data d1(2025,5,12);
	Data d2(2025,5,11);

	if (d1 == d2) 
	{
		cout << "d1 == d2" << endl;
	}
	else
	{
		cout << "d1 != d2" << endl;
	}
	// 这里对日期类d1和d2进行了比较
	// 在未进行运算符重载的时候,我们是无法知道编译器在进行两个对象== 比较时会如何处理。 
	// 两个int类型的变量直接比较大小即可,但是类类型如何比较呢?
	// 显然两个日期类中各有3个成员变量,他们必须逐一进行比较都是一样都,==才算成立。

	// 如果我们没有写 > 的运算符重载成员函数
	bool ret2 = d1 > d2;
	// error C2676 : 二进制“ > ”:“Data”不定义该运算符或到预定义运算符可接收的类型的转换
	// 显然编译器不知道如何处理类之间的比较了
	return 0;
}

2、赋值运算符重载

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

赋值运算符重载的特点:

  1. 赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成
    const 当前类类型引⽤,否则会传值传参会有拷⻉
  2. 有返回值,且建议写成当前类类型引⽤,引⽤返回可以提⾼效率,有返回值⽬的是为了⽀持连续赋
    值场景。
  3. 没有显式实现时,编译器会⾃动⽣成⼀个默认赋值运算符重载,默认赋值运算符重载⾏为跟默认拷
    ⻉构造函数类似,对内置类型成员变量会完成值拷⻉/浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义
    类型成员变量会调⽤他的赋值重载函数。
  4. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器⾃动⽣成的赋值运算符重载就
    可以完成需要的拷⻉,所以不需要我们显⽰实现赋值运算符重载。但是像之前的例子,成员变量中有指针变量,编译器自动生成的浅拷贝不符合我们的要求,会存在重复释放的问题,所以我们需要自己去实现深拷贝的赋值运算符重载的程序。
    注意:这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就需要显⽰写赋值运算符重载,否则就不需要。
#include <iostream>
using namespace std;

class Data
{
public:
	Data(int year, int month, int day) // 构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	Data(const Data& d) // 拷贝构造函数
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

	Data& operator=(const Data& x) // 赋值运算符重载函数
	{
		if (this != &x) // 检查不要自己给自己赋值
		{
			_year = x._year;
			_month = x._month;
			_day = x._day;
		}
		// d1 = d2表达式返回对象应该是d1,也就是*this
		return *this;
	}

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

int main()
{
	Data d1(2025, 5, 12);
	Data d2(d1); // 注意这里使用的是拷贝构造函数,是用一个对象拷贝初始化给另一个要创建的对象

	Data d3(2024, 5, 31); 

	d3 = d1; // 这里是赋值运算符重载,用于两个已经存在(完成初始化以后)的对象
	d3.Print();
	// 打印的结果是 2025/5/12
	// 所以我们完成了一次赋值运算符重载
	// 注意:必须是2个已经存在的对象才能实现赋值运算符重载,不能作为初始化的过程!

	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值