C++中类的默认成员函数

本文详细介绍了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值运算符重载。阐述了各函数的概念、特性、默认生成情况,以及对内置类型和自定义类型的处理方式,还分析了深浅拷贝问题和典型调用场景,帮助读者深入理解C++类和对象。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

🍁类的默认成员函数

在C++中如果创建一个类,类的里面什么都不编写那么这个类叫做空类。

但是这个类并不是真的就是空的,编译器会自动生成6个默认成员函数。当然不仅仅是空类会生成,创建的任何类都会生成这些成员函数。

这些默认生成的六个函数也可以被称为天选之子,它们分别是:

  1. 构造函数主要完成初始化工作
  2. 析构函数主要完成清理工作
  3. 拷贝构造函数使用同类对象初始化创建对象
  4. 赋值重载函数把一个对象赋值给另一个对象
  5. 取地址操作符重载函数
  6. const取地址操作符重载函数

其中前四个默认生成函数在C++类和对象中起到非常重要的作用,也是在该篇文章所要介绍的。

🍁构造函数

🍂概念

  • 构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次

单纯看概念意思的话还是有点懵,来举个例子:

创建一个简单的日期类

#include <iostream>
using namespace std;

class Date
{
public:
	void Init(int year, int month, int day) 
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1; //创建d1对象
	d1.Init(2023, 4, 21); //初始化
	d1.Print();
	
	Date d2; //创建d2对象
	d2.Init(2023, 4, 21); //初始化
	d2.Print();
	
return 0;
}

从在上面这段代码中,可以看到我们每次创建一个对象都要将对其进行初始化。一两个对象还好,要是数量多起来,难免会忘记初始化,而且频繁对其初始化也有点麻烦。

有没有一种方法就是每次创建对象时就将其初始化呢?

哎!这便要提到构造函数的作用了:构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次

🍂构造函数特性

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
  6. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个

根据构造函数的这些特性后可以将上面日期类改进成构造函数版本:

#include <iostream>
using namespace std;

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

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

int main()
{
	Date d1; // 调用无参构造函数
	Date d2(2023, 4, 21); // 调用带参的构造函数

	return 0;
}

在这里肯定有点小疑问,调用带参的构造函数和调用无参的构造函数为什么不一样,调用无参构造函数不应该是这样子的么:Date d3();

在这里插入图片描述

这样创建出来的对象是会报警告的,我们再仔细看看Date d3();这样子创建的d3对象方式是不是很像一个函数声明。

因此通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明

当然上面这样创建的日期类的构造函数还是有点冗余了,我们可以将其变成全缺省函数

#include <iostream>
using namespace std;

class Date
{
public:

	Date(int year = 1, int month = 1, int day = 1) //全缺省构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

🍂默认构造函数的分类

默认构造函数分成三种:无参的构造函数全缺省的构造函数编译器默认生成的构造函数

#include <iostream>
using namespace std;

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

	Date(int year = 1, int month = 1, int day = 1) //全缺省的构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

🍃默认构造函数的细节

  1. 一个类中只能存在一个默认构造函数,如果存在多个默认构造函数编译器会报错

在这里插入图片描述
但是重载的构造函数不会发生报错,可以和默认构造函数并存,这里得区分一下。

  1. 如果用户显式定义了构造函数,编译器将不再生成,届时如果我们定义一个对象却没有去初始化的话,那么编译器会报错

例如:

#include <iostream>
using namespace std;

class Date
{
public:

	Date(int year, int month, int day) //定义了构造函数,但是没有定义默认构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
	
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1; //没有进行初始化
	return 0;
}

在这里插入图片描述

总结:默认构造函数只能存在一种,多种默认构造函数同时存在会使编译器不明确调用哪一个默认构造函数而发生报错;
一般情况下,如果没有特别要求,我们就实现全缺省构造函数即可

🍃编译器默认生成的构造函数

当用户不去定义构造函数时,编译器会自动生成一个默认的构造函数。那么这样生成的构造函数和我们自己定义的构造函数有什么区别吗?

我们先看看编译器自己生成的构造函数是怎么样的:

#include <iostream>
using namespace std;

class Date
{
public:
	
	//没有定义默认构造函数,编译器自动生成

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
	
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	d1.Print();

	return 0;
}

在这里插入图片描述
可以发现 d1 对象其成员变量并没有没有进行初始化,结果都是一些随机值。
那么编译器生成的默认构造函数有什么用?

这个得从语言本身设计说起:

当初在设计C++语法的时候,C++把类型分成内置类型自定义类型

  1. 内置类型:C++语言本身提供的数据类型,如:int / char / float / double
  2. 自定义类型:用户自己定义的类型,如:class / struct / union
🔥构造函数对于 内置类型 和 自定义类型 的处理
  1. 编译器生成的默认构造函数对于内置类型是不作处理的

在Date类中的成员变量都是int类型是属于内置类型,因此默认构造函数不会对其进行初始化处理,这便有了乱码的现象。
创建类的成员变量中但凡有一个是内置类型都需要我们自己去定义一个默认的构造函数,才能将其作初始化处理
这也是为什么编译器自己会生成默认构造函数,我们自己还需要设计默认构造函数的原因。

  1. 编译器生成的默认构造函数对自定义类型成员,会调用他的默认构造函数

例如定义一个时间类和一个日期类, _t 为Date类中的自定义类型成员,运行结果会是如何呢?

#include <iostream>
using namespace std;

class Time
{
public:
	Time() //时间类的构造函数
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
	void Print()
	{
		cout << _hour << "-" << _minute << "-" << _second << endl;
	}
private:
	// 基本类型(内置类型)
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
		_t.Print();
	}

private:
	// 基本类型(内置类型)
	int _year;
	int _month;
	int _day;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d;
	d.Print();

	return 0;
}

在这里插入图片描述

可以看到,Date类中的自定义成员 _t 会调用time类中的默认构造函数,但是由于date类中没有默认构造函数,则编译器自动生成的默认构造函数没有将Date类中的基本类型进行初始化,所以结果为随机值。

🍂内置类型成员变量在类中声明时可以给默认值

编译器生成的默认构造函数对于内置类型来说是非常鸡肋的,所以C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。

注意:这个默认值在对象没有实例化的时候是不分配空间的,只有在对象进行实例化后才会分配空间

class Date
{
	//没有定义默认构造函数
	void Print()
	{
		cout << _hour << "-" << _minute << "-" << _second << endl;
	}
private:
	// 基本类型(内置类型)
	int _year = 1; //1为默认值
	int _month = 1; //1为默认值
	int _day = 1; //1为默认值
};
int main()
{
	Date d;
	d.Print();
	return 0;
}

在这里插入图片描述
这个补丁就解决了编译器默认生成的构造函数无法处理内置类型的问题了。

注意: 若是存在默认构造函数是全缺省参数时,会优先使用默认构造函数的全缺省参数值,内置类型的默认值不起作用

构造函数到这里就结束了吗?然而并没有,还有最后一个也是很重要的点:

🍂构造函数的初始化列表

构造函数的复杂,如此多的细节真的让人抓耳挠腮,这个初始化列表又是什么?

我们先来看看下面几种情况:

int main()
{
	const int a = 10;  //加了const修饰后a为常量,初始化后不可再改变

	int b = 20;
	int& c = b;	      //变量c是变量b的别名,b的改变,c也会发生变化,反之如此

	return 0;
}

上面一小段代码对于大家来说都不陌生,我们都知道const修饰的变量和引用创建时必须进行初始化,否则编译会报错。

如果类中的成员变量中包含以上两种情况的话,那么构造函数初始化还能否起效?

class A
{
public:

	A() //构造函数
	{
		b = 20;
		c = a;
	}

private:
	int a = 10
	const int b;
	int& c;
};

这样的结果只能是:
在这里插入图片描述

上面两种情况是因为我们对构造函数内部实现体与初始化之间概念产生一些误导:

构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化因为初始化只能初始化一次,而构造函数体内可以多次赋值

当然不仅仅是这两个问题,还有一种比较常见的:当自定义类型成员没有默认构造函数

举个例子:

class B
{
public:
	B(int a) //B类构造函数
	{
		_a = a;
	}
private:
	int _a;
};

class A
{
public:
	//编译器默认生成的构造函数

private:
	int _a;
	B b1;
};

int main()
{
	A a1;
	return 0;
}

在这里插入图片描述
像如上代码:B类中没有默认构造函数,A类成员变量中含有自定义类型b1,如果此时实例化对象a1的话编译器会报错。这是因为编译器生成的默认构造函数对于自定义类型成员会调用它的默认构造函数,但是此时b1自定义类型中没有默认构造函数,此时就很尴尬,编译器不知道如何是好,只能报错咯。

为了解决类中成员变量有如:const修饰的成员变量引用成员变量自定义类型成员(且该类没有默认构造函数时) 等情况,C++引出了构造函数的初始化列表。

🍃概念

  • 初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式

初始化列表的语法只能运用在构造函数,拷贝构造函数是构造函数重载,因此拷贝构造函数也可以使用初始化列表

举个日期类的例子:

Date(int year, int month, int day)
	: _year(year)
	, _month(month)
	, _day(day)
	{}

🍃初始化列表对成员变量进行初始化的先后顺序

来看看如下一段代码:

class A
{
public:
	A(int a)
	:_a1(a)
	,_a2(_a1)
	{}
	
	void Print() {
		cout<<_a1<<" "<<_a2<<endl;
	}
private:
	int _a2;
	int _a1;
};
int main() 
{
	A aa(1);
	aa.Print();
}

在这里来猜想一下_a1_a2成员变量初始化的值各是多少,我猜有很多人会想到_a1的值为1,_a2的值也为1.

但是运行结果却是这样:
在这里插入图片描述

初始化列表再对成员变量进行初始化时有这样的规则:

  • 成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关

在类中成员变量中_a2变量次序在_a1变量之前,初始化列表会先对_a2进行初始化,然后才对_a1进行初始化,由于开始初始化时,_a1变量内部为随机值,赋予_a2得自然而然便是随机值了,然后再是对_a1进行1的赋值。

所以,在使用初始化列表时,我们要注意到成员变量在类中声明次序,再进行初始化,防止自己给自己挖坑

🔥注意点
  • 如果当在类中声明时内置类型成员变量有默认值,此时如果初始化列表给予其他值时,谁的优先级级高?

来看看下面代码:

class A
{
public:
	A()
		:_a1(20) //初始化为20
	{}

	void Print() {
		cout << _a1 << endl;
	}
private:
	int _a1 = 10; //赋值为10
};

int main()
{
	A a;
	a.Print();
	return 0;
}

在这里插入图片描述

从上面例子中的结论可以得到:当在类中声明时成员变量有默认值,同时初始化列表给予其他值时,优先使用初始化列表的

因此,可以总结出来构造函数对于成员变量的各种初始化方法的优先级的结论:

  • 初始化列表 > 类中声明时成员变量赋值 > 直接在构造函数内部进行赋值

看到这里的小伙伴们可能会有一些不解,构造函数如此多的初始化的方法,在平时运用时都要运用到吗?有没有就是可以掌握其中一个就可以一劳永逸了呢?

答案是没有的,由于早期设计的问题导致构造函数不够完美只能是在后期不断完善,对于上面讲到的构造函数的诸多知识点,只能相互结合着使用,在这里建议的是,能够用初始化列表就使用初始化列表,因为不管你是否使用初始化列表,对于成员变量,一定会先使用初始化列表初始化

🍁析构函数

🍂概念

  • 析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

由于析构函数和构造函数很相似,所以我们直接来看性质:

🍂析构函数的特性

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
    注意:析构函数不能重载
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数
  5. 编译器生成的默认析构函数,对自定类型成员调用它的析构函数,对于内置类型也是不会处理的

在这里以简单栈类为例子:

class Stack
{
public:
	Stack(size_t capacity = 3) //栈的构造函数
	{
		_array = (int*)malloc(sizeof(int) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(int data)
	{
		//...检查扩容等函数略写

		_array[_size] = data;
		_size++;
	}
	void Destroy() //销毁函数
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
}
private:
	int* _array;
	int _capacity;
	int _size;
};
int main()
{
	Stack s;
	s.Push(1);
	s.Push(2);

	s.Destroy();  // 销毁栈
	return 0;
}

还是类似的情况,要是使用多个栈的同时,待使用结束后也需要就要调用多个销毁函数,若是忘记调用销毁函数那么就会造成内存泄露,损失是很大的。

改成析构函数版本:

class Stack
{
public:
	Stack(size_t capacity = 3) //栈的构造函数
	{
		_array = (int*)malloc(sizeof(int) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(int data)
	{
		//...检查扩容等函数略写

		_array[_size] = data;
		_size++;
	}
	~Stack()  //栈的析构函数
	}
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
}
private:
	int* _array;
	int _capacity;
	int _size;
};

对比上面销毁函数,这里的析构函数怎么实现都是一模一样的?

析构函数的实现与销毁函数的实现大差不差,但是析构函数的优势却是比销毁函数好得多,使用者可以不用自行去调用析构函数,都可以交给编译器自己去调用,这样也可以减少内存泄漏的问题发生。

🍂两种析构函数区别

析构函数分成两种:一种是用户自己定义的;另一种是编译器自己生成的。

跟构造函数一样,若是用户自己不去实现析构函数的话,编译器会自动生成一个。

🍃用户自己实现的析构函数

首先我们来看看两个类,日期类和栈类:

  1. 日期类
class Date
{
	Date(int year = 1, int month = 1, int day = 1)//日期的构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//...具体函数定义的内容

	~Date()  //日期的析构函数
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}
private:
	int _year;
	int _month;
	int _day;
};
  1. 栈类
class Stack
{
public:
	Stack(size_t capacity = 3) //栈的默认构造函数
	{
		_array = (int*)malloc(sizeof(int) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	
	//...具体函数定义的内容

	~Stack()  //栈的析构函数
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
}
private:
	int* _array;
	int _capacity;
	int _size;
};

首先来看日期类中的析构函数,日期类对象被销毁时其内置类型需要对其进行处理吗?
答案是不需要的,因为栈帧销毁后内存便归还给操作系统了,再给日期类成员变量赋予0的意义不大,对于日期类乃至后面相似实现的类我们可以不定义析构函数的。

再来看看栈类,其中的一个成员是需要在堆区开辟空间的,首先要将堆区资源归还才不会导致内存泄漏,此时的析构函数是一定要实现的。

总结:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如日期类;
有资源申请时,一定要写,否则会造成资源泄漏,比如栈类

🍃编译器默认生成的析构函数

编译器生成的默认析构函数,对自定类型成员调用它的析构函数,对内置类型不作资源处理。

#include <iostream>
using namespace std;

class Time
{
public:
	~Time() //时间类的析构函数
	{
		cout << "~Time()" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
private:
	// 基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d;
	
	return 0;
}

在这里插入图片描述
d对象中,有四个成员变量其中_year、_month、_day 都是内置类型,_t 是自定义类型。

当程序结束时,对象d被销毁前对于3个内置类型不做处理,系统会直接回收;

对于自定义类型 _t 会先调用 _t 对应的Time类中的析构函数,但是,main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date类中没有提供析构函数,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁main函数中并没有直接调用Time类析构函数,而是调用编译器为Date类生成的默认析构函数。

🍁拷贝构造函数

🍂概念

  • 拷贝构造函数是构造函数的一个重载形式,只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用

看概念总是带懵状态的,还是来举例子来的实在,日期类:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1) //默认构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;	
	
	return 0;
}

从上面看到已经创建好了一个 d1 对象,如果想创建一个d2对象和 d1 对象属性一模一样如何去实现呢?

这便是要用到拷贝构造函数功能了。

🍂实现拷贝构造函数时两个注意点

看特性前我们按照概念试着去实现一个拷贝构造:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1) //默认构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	Date(Date date) //传日期类对象进行构造
	{
		_year = date._year;
		_month = date._month;
		_day = date._day;
	}
	
	//内置类型没有对资源申请,析构函数可以不写
	
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	
	//下面是调用拷贝构造函数两种方式,记住即可
	
	//Date d2(d1);  //第一种常规调用方式
	Date d2 = d1;  //第二种赋值方式
	
	return 0;
}

运行走起看看
在这里插入图片描述
看到报错是有点束手无策的,问题出在哪里了呢?第一个参数不应该是Date那应该是什么?

定义拷贝构造函数稍微不注意都会崩溃,上面报错原因是因为引发无穷递归调用,为什么这么说呢?

在这是因为调用拷贝构造函数的时候我们 d1 对象实参传给形参 date ,对于值拷贝编译器对于内置类型编会直接拷贝到形参;但是对于自定义类型编译器会调用对应的拷贝构造函数。于是便发生以下情况:
在这里插入图片描述
当然上面这个情况编译器会直接报错,毕竟是无穷递归。

如何解决此类问题呢?值传递会导致无穷递归,除了值传递还可以进行地址传递,便是指针的运用,C++是不怎么喜欢用指针的,指针的用法总是有点麻烦。在这里解决的方法便是传引用,引用与指针底层实现是一样的,传引用的方法比传地址的方法好用多了。

传引用时我们得注意一点,拷贝构造函数功能是一个对象的基础值赋予新对象,我们并不想被传递的对象基础信息被修改,于是往往在传引用前我们都要用 const 修饰

此时上面的拷贝构造便可以修改成传引用版本:

Date(const Date& date) //拷贝构造
{
	_year = date._year;
	_month = date._month;
	_day = date._day;
}

在这里插入图片描述

为了防止以上情况的发生,拷贝构造函数的实现时总结为一下两点:

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。

🍂编译器默认生成的拷贝构造函数

拷贝构造函数也是默认成员函数中的一员,当用户不去实现时,编译器也会自动生成一个默认的拷贝构造函数

以日期类为例子,下面是没有实现拷贝构造函数样子,来看看编译器自动生成的默认拷贝构造的作用:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1) //默认构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	//没有定义拷贝构造
	
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
		
	//内置类型没有对资源申请,析构函数可以不写
	
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2(d1);  
	
	d1.Print();
	d2.Print();
	
	return 0;
}

在这里插入图片描述

看到结果后可以相信很多小伙伴都会认为编译器自己生成的拷贝构造挺好,能够实现对应功能。那以后是不是都可以交给编译器去实现,没必要在自己去手搓一个拷贝构造了?

我们换一个栈类来看看:

class Stack
{
public:
	Stack(size_t capacity = 10) //构造
	{
		_array = (int*)malloc(capacity * sizeof(int));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}

		_size = 0;
		_capacity = capacity;
	}
	
	//没有定义拷贝构造
	
	void Push(const int& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	
	~Stack() //析构
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	int*_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;
}

在这里插入图片描述
运行会直接让你中断,这里先说原因:造成这样的情况是因为调用的析构函数对同一块堆空间同时进行两次 free函数操作,这个行为是非法的。

所以编译器自己生成的好吗?

有时候好有时候会很很烧脑筋,针对日期类成员变量都是内置类型时,编译器默认生成的拷贝构造函数是够用的,但是默认成员变量一旦涉及资源申请时,编译器默认生成的拷贝构造函数是有bug的

上面为什么会造成这样的问题呢?这个就涉及到了深浅拷贝的问题了:

🔥拷贝构造函数的深浅拷贝问题
  • 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
    简单点也就是一字不落的给你拷贝。

在这里插入图片描述

当程序结束运行时,由于栈的特性:后定义的对象会先析构,所以编译器首先会先调用 s2 对象的析构函数,s2对象的析构函数会将 0x11111111 这块空间的地址释放了,并且归还给操作系统。到这里结束了么?并没有,因为对象 s1 还没有被销毁,s1自己也会调用它的析构函数, s1 对象的析构函数又会将 0x11111111 这块地址空间再次将其释放,这便导致运行崩溃。

当然不仅仅析构函数问题,当两个对象的成员都指向同一块空间时,其中一个对象发生改变另一个对象内容也会发生改变,插入和删除数据都会影响对方,这个情况也是非常不合理的。

上面栈类的拷贝构造函数可以实现为深拷贝版本:

Stack(const Stack& stack)//栈的拷贝构造函数
{
	_array = (int*)malloc(stack._capacity * sizeof(int)); //开辟同stack对象的大小相同的空间
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}

	memcpy(_array, stack._array, sizeof(int)*stack._size); //将stack对象成员数组信息全部拷贝
	_size = stack._size;
	_capacity = stack._capacity;
}
🔥拷贝构造函数对于内置类型与自定义类型的处理

上面日期类中的成员变量都是内置类型,如果其中包含自定义类型,编译器默认生成的拷贝构造函数又是如何处理的呢?

来看看例子:

class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	Time(const Time& t)
	{
		_hour = t._hour;
		_minute = t._minute;
		_second = t._second;
		cout << "Time::Time(const Time&)" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
private:
	// 基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d1;
	Date d2(d1);
	return 0;
}

这里定义了Time类和Date类,Date类中的成员变量包含了Time类对象_t
Time类中实现了拷贝构造函数;而在上面Date类中没有实现拷贝构造函数,我们让编译器自己默认生成拷贝构造函数。

用已经存在的d1拷贝构造d2,运行起来看看:

在这里插入图片描述
上面打印的结果看到,编译器默认生成的Date类的拷贝构造函数,在进行拷贝工作时,针对自定义类型会调用其自定义类型的拷贝构造函数

从上面的例子中可以得到这样的结论:

  • 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的

看到这里,不禁有人会这样问:什么情况下要自己去实现拷贝构造函数,什么时候又不用自己去实现呢?

  • 类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝

🍂拷贝构造函数典型调用场景

  1. 使用已存在对象创建新对象
  2. 函数参数类型为类类型对象
  3. 函数返回值类型为类类型对象

这里举个简单的例子:

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)//构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	Date(const Date& d) //拷贝构造
	{
		cout << "Date(const Date& d):" << this << endl;
	}
	
	//析构...
	
private:
	int _year;
	int _month;
	int _day;
};

Date Test(Date d)
{
	Date temp(d);
	return temp;
}

int main()
{
	Date d1;
	Test(d1);
	return 0;
}

调用Test函数会触发几次拷贝构造?
在这里插入图片描述
答案是三次。

  • 第一次:d1 对象值传递给Test函数时 ,传参时候调用拷贝构造创建形参 d
  • 第二次:用形参d来调用拷贝构造创建temp对象。
  • 第三次:传值返回temp对象时,并不是返回temp本身。因为出了Test函数后temp对象会被销毁,此时会创建临时变量来充当返回值又会调用一次拷贝构造。

基于上面调用拷贝构造频繁,造成的效率低下是不可避免的。为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,如果出了作用域返回值生命周期还在那么就用引用返回;如果不存在那就只能使用值返回了

🍁赋值运算符重载

再了解赋值运算符重载之前,首先来介绍一下什么是运算符重载。

🍂运算符重载

对于内置类型的运算我们是很熟悉的,例如:

int main()
{
	int a = 10;
	int b = 20;
	int c = a + b; //结果为30
	
	return 0;
}

对于上面单一的内置类型运算是没有问题的,但是有试想过对自定义类型的运算吗?

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

//在这里为例子先将类成员设置为公有
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 4, 25);
	return 0;
}

上面日期类中我们创建两个对象分别是d1d2

若是来判断这两个对象谁大谁小,可以直接像内置类型那般直接比较吗?编译器不知道要如何比较,要实现对应函数功能,才能对比这两个对象大小。

例如:

bool Func1(const Date& d1, const Date& d2) //判断d1是否等于d2
{
	return (d1._year == d2._year)
	 && (d1._momth == d2._month)
	 && (d1._day == d2._day);
}

bool Func2(const Date& d1, const Date& d2) //判断d1是否小于d2
{
	return d1._year < d2._year
		|| (d1._year == d2._year && d1._month < d2._month)
		|| (d1._year == d2._year && d1._month == d2._month && d1._day < d2._day);
}

对于上面两个函数功能来说,实现者是知道的,但是对于其他人来说这样的取名是有点蛋疼的。

这里突出关于函数取名的问题,每个用户取名风格都会不一样,有的会取对应函数作用的英文名字,但是不乏就会有一些用户为了方便就会写成上面情况,更有的会用拼音来代替…

于是为了增强代码的可读性,C++引入了运算符重载,运算符重载是:具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

  • 函数名字为:关键字operator后面接需要重载的运算符符号

例如上面判断两个日期是否相等的函数可以表示为:

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

当然这个是在类外部实现的,在类内部实现如下:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	bool operator==(const Date& d)//比较相等
	{
		return (_year == d._year)
		 && (_momth == d._month)
		 && (_day == d._day);
	}

privateint _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 4, 25);
	return 0;
}

类中实现与类外实现的细节是不同的。

🍃使用运算符重载注意点

  1. 不能通过连接其他符号来创建新的操作符:比如operator@
  2. 重载操作符必须有一个类类型参数
  3. 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
  4. 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
  5. .* :: sizeof ?: . 以上5个运算符不能重载

对比其他运算符来说,最容易出错的运算符其实是赋值这个运算符,这里涉及深浅拷贝问题。

🍂赋值运算符的实现

开始实现前,先来看看内置类型赋值的情况:

int main()
{
	int a = 0;
	int b = 10;	
	a = b; //将b值赋予a
	
	return 0;
}

对于内置类型的赋值是很简单的,但对于自定义类型呢?

以日期类为例子:

int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 4, 25);
	
	d1 = d2; //若要将d1 = d2如何实现?  
	
	return 0;
}

注意:这里的赋值不是拷贝构造

将对象d2的值赋值给d1,我们得实现一个赋值运算符重载函数才能实现对应功能;

试着来实现一下:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)//构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	//赋值运算符重载(=)
	void operator=(const Date& d) //加const是为了防止被赋值者被改变,传引是为了用防止拷贝的消耗
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

privateint _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 4, 25);

	d1 = d2;

	d1.Print();

	return 0;
}

运行结果试试看:
在这里插入图片描述
好像还行,没有出问题。

这里实现的赋值运算符是不够完美的,为什么这样说呢?我们来联想一下内置类型赋值时具有的功能,例如:连续赋值

这里便是返回值的问题了,实现起来也比较简单,直接将*this作为返回值即可:

	Date& operator=(const Date& d) //加const是为了防止被赋值者被改变,传引是为了用防止拷贝的消耗
	{
		if(this != &d) //防止自己传自己
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		
		return *this;
	}

出了这个赋值运算符重载函数this的生命周期并没有结束,因此我们可以作引用返回,可以提高一些效率。

由上我们实现赋值重载时可以得到这样的模板:

  • 参数类型:const T&,传递引用可以提高传参效率
  • 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
  • 检测是否自己给自己赋值
  • 返回*this :要复合连续赋值的含义

🍂编译器默认生成的赋值重载

  • 赋值重载函数如同上面介绍的默认成员函数一样,用户不去实现的话,编译器会自动生成一个默认的赋值重载函数
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)//构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	//不实现,让编译器自己生成赋值重载

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

privateint _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 4, 25);

	d1 = d2;

	d1.Print();

	return 0;
}

在这里插入图片描述

对于日期类来说,其成员变量都是内置类型,编译器自己生成的默认赋值重载是够用的,但是所有的类中的赋值重载都由编译器生成好不好呢?有了上面的介绍后大家也知道了,如果好的话为什么还要自己实现对吧。

🔥赋值重载深浅拷贝问题

赋值运算符重载也涉及到了深浅拷贝问题,这里的深浅拷贝跟拷贝构造类似,已栈类为例子:

class Stack
{
public:
	Stack(size_t capacity = 10) //构造
	{
		_array = (int*)malloc(capacity * sizeof(int));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}

		_size = 0;
		_capacity = capacity;
	}
	
	Stack(const Stack& stack)//栈的拷贝构造函数
	{
		_array = (int*)malloc(stack._capacity * sizeof(int)); 
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}

		memcpy(_array, stack._array, sizeof(int) * stack._size); 
		_size = stack._size;
		_capacity = stack._capacity;
	}
	
	//没有实现赋值重载
	
	void Push(const int& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	
	~Stack() //析构
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	int*_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;
	
	s2 = s1; //栈类对象赋值
	
	return 0;
}

在这里插入图片描述
在这里插入图片描述

  • 报错原理如同拷贝构造类似,都是浅拷贝问题,当用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝
    出现错误的地方发生在析构函数上,由于是逐字节拷贝,当两个对象的成员_array都指向同一块空间,析构时对同一块空间进行两次释放

涉及资源申请时,用户必须实现赋值重载,编译器生成的是不靠谱的

下面用另一种比较巧妙的方法来实现栈类赋值重载实现版本:

	//栈类中实现属于自己的swap函数
	void swap(Stack& st)
	{
		std::swap(_array, st._array);
		std::swap(_size, st._size);
		std::swap(_capacity, st._capacity);
	}

	//赋值重载

	Stack& operator=(Stack st)
	{
		//Stack tmp(st);
		swap(st);

		return *this;
	}

  1. 在栈类中实现属于自己的swap函数,由于标准库中的swap函数用到了拷贝构造和赋值重载消耗太大,这里实现一个直接交换栈类对象中的指针即可
  2. 此处的赋值重载没有进行引用传参,利用拷贝构造为我们创建一个st对象,该对象生命周期只在赋值重载这个函数,用我们自己实现的swap函数将*thisst对象交换即可达到赋值重载的预期
🔥赋值重载对于内置类型与自定义类型的处理

老例子:

class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	
	Time& operator=(const Time& t)
	{
		if (this != &t)
		{
			_hour = t._hour;
			_minute = t._minute;
			_second = t._second;
		}
			
		cout << "Time::Time& operator=(const Time& t)" << endl;
		return *this;
	}
private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
	//没有实现赋值重载

private:
	// 基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d1;
	Date d2;
	//赋值重载
	d1 = d2;
	
	return 0;
}

在这里插入图片描述

  • 编译器默认生成的赋值重载对于内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。

以上就是C++类和对象默认成员函数的主要内容,感谢大家支持!!!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值