【C++】类和对象(中)

构造函数

前言

假设有这么个场景,你需要往栈里面入栈数据。

Stack st;
st.Push(1);
st.push(2);
  • 但是一运行起来可以发现,程序却奔溃了,这是为什么呢?

原来我们没有写Init函数,那么,栈都还没有开辟空间初始化这些

  • 仔细一想就发现好像是忘记Init()初始化了,加上之后就没有问题了
    在这里插入图片描述
  • 那么问题就来了,我们使用栈这个数据结构的时候,有时总是会忘记初始化出问题。这在练习中可能容易发现,但是如果在一个大项目中就难以调试。

这就要涉及到我们的构造函数(C++默认的6个函数之一)

首先我们来介绍这六个函数

【空类的概念】:如果一个类中什么成员都没有,简称为空类。

class Date{}
  • 那么空类就是里面什么都没有吗?
  • 并不是,C++类默认生成六个默认成员函数
  • 【默认成员函数概念】:用户没有显式实现,编译器会生成的成员函数称为默认成员函数

概念

  • 构造函数是一个特殊的成员函数,名字与类名相同创建类类型对象时由编译器自动调用,,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次
  • 例如下面这个Date类,如果创建对象后,调用了Init成员函数就会初始化。如果没有进行对对象初始化,那么会调用构造函数进行初始化。
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	d1.Init(2024, 8, 10);
	d1.Print();
	Date d2;
	d2.Print();
}

在这里插入图片描述

  • 上图可以看到,d1和d2都默认去调用了构造函数,只是d1后面又调用了Init函数初始化

特性

需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象 从上面的例子可以知道,构造函数实际上就是用来代替Init函数的。

下面介绍它的特性

1.函数名与类名相同
2. 无返回值(这点没有返回值不是函数类型写void,而是什么都不写)
3. 对象实例化时编译器自动调用对应的构造函数
4. 构造函数可以重载【⭐】
这里我们细讲一下,函数重载的条件就是参数不同或者参数类型不同那,对于构造函数来说,其实也是可以重载的,不仅仅限于上面的Date(),这只是最普通的一种无参默认构造

那么一个类如果可以有多个构造函数,那么他的初始化方式就有很多。

通过调试观察就可以发现,不同的初始化方式会调用各自的构造函数,就和函数重载后的函数名修饰一样,编译器去进行了一个自动的匹配。此时我们就不再需要这个Init()函数了

在这里插入图片描述
但是呢,你不可以像下面这样去定义对象,同学看构造函数很像函数,所以也用调用函数的形式去调用构造函数。不过上面说到过了,对于构造函数而言是会自动调用的,不需要我们手动去调用,只需要考虑重载的形式传入不同的参数即可、

Date d1;
Date d2(2023, 3, 22);
Date d3();

可以看到 d3()这种手动去调用,会被编译器误认为是函数声明。返回类型是Date类
在这里插入图片描述

看了上面的写法,你对构造函数怎么写多少也有点底了。但是我们利用缺省参数推荐这样写

//Date()
//{
//	_year = 1;
//	_month = 1;
//	_day = 1;
//}

//Date(int y, int m, int d)
//{
//	_year = y;
//	_month = m;
//	_day = d;
//}
Date(int y = 1, int m = 1, int d = 1)
{
	_year = y;
	_month = m;
	_day = d;
}

这样我们给构造函数传参就很灵活了。
在这里插入图片描述
注意:
【默认无参构造】和【全缺省有参构造】是不可以同时存在的,会产生歧义
因为在没有给到初始值的时候编译器不知道调用那个,缺省参数可以不传参,无参更不会传参,所以【存在多个默认构造函数】
在这里插入图片描述

  1. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。默认构造不光是编译器自动生成的,全缺省构造和无参构造都是默认构造,这里一个小技巧就是,不传实参的构造就是默认构造。

通过如下图就可以看出,若是我将自己写的构造函数全部够注释掉之后,那【显式定义】的构造函数就不存在了,此时我们编译一下可以发现没有报错,因为在这里编译器自动去调用了类内默认生成的构造函数

在这里插入图片描述
但是通过打印这些结果来看,默认调用的构造函数真的初始化这些内置的成员变量了吗?
在这里插入图片描述
可以看到打印的都是随机值。这就要讲讲它的第6个特性

6.对于类中的内置类型成员(int、char、double)来说,注意指针也是内置类型,不会做初始化处理;对自定义类型成员(class、struct、union),会调用它的默认构造函数【⭐】
在这里插入图片描述

所以对于上面的这三个成员函数,都是属于【内置类型】的,均不会做处理。
值得注意的是:只有编译器
自动生成的默认构造是不会对内置类型成员变量处理,但是自己写的构造函数就可以决定是否对内置类型处理

那么编译器自动生成的构造函数一点都没有用了吗?还记得用栈实现队列吗?

这两个栈是不是都要初始化。初始化就要手动调用Init函数

但是C++中呢:

class MyQueue
{
public:
	void Push(int x)
	{
		//...
	}
private:
	Stack PushST;
	Stack PopST;
};

在这里插入图片描述

  • 这上面就可以知道,Myqueue类中编译器自动生成的默认构造对成员变量的自定义类型两个Stack类进行调用他们的默认构造函数,注意Stack的默认构造函数不是编译器自动生成的默认构造函数,而是我们自己写的全缺省默认构造函数,因为Stack类中编译器自动生成的默认构造不会对内置类型arr,top,capacity调用他们的默认构造函数,但是我们写的默认构造函数可以决定是否对他们初始化。所以Stack类通常需要自己写默认构造函数。
  • 上面我们说过,默认构造函数不光是编译器自动生成的默认构造函数,还有全缺省默认构造,无参默认构造,他们都被统称为默认构造函数,但是他们三个其中只能出现一个。不然会发生歧义,小技巧就是,不传实参的构造就是默认构造。

总结一下

  • 构造函数很多时候都是需要我们自己写的,因为编译器自动生成他不会对内置类型变量进行初始化。只对自定义类型进行初始化。而我们自己写的默认构造可以自己决定是否初始化内置类型成员变量
  • 但是编译器自动生成的默认构造并非一无是处,比如用栈实现队列这种成员变量只有自定义类型的就可以使用。
  • 默认构造函数不光是编译器自动生成的默认构造函数,全缺省默认构造和无参默认构造都被统称为默认构造。但是他们三个只能出现一个,不然会在都不传参的时候不知道调用谁,而发生歧义。小技巧就是,不传实参的构造就是默认构造。

析构函数

概念

对于前面我们对构造函数的学习,了解到构造函数主要就是对成员变量和成员函数这些进行初始化工作。

  • 那变量会初始化,也会释放资源。初始化是代替Init函数工作,那么销毁就是代替Destroy函数工作。
  • 这里要注意上面上的释放资源,不光是释放成员变量这些,或许有成员函数这些
  • 还有一点注意,释放不是销毁,释放通常是释放动态申请的内存这些,而销毁对象通常是一个对象生命周期结束后,函数栈帧的销毁。

下面就是Date类的析构函数,和构造函数很像,只需要在前面加上一个~即可

~Date()
{
	cout << "date析构函数" << endl;
	_year = 0;
	_month = 0;
	_day = 0;
}

特性

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

C与C++oj题对比(手动挡VS自动挡)
在这里插入图片描述
通过对比可以发现,若是用C++去实现的话,因为有默认构造函数的存在,所以我们不需要去调用Init()函数进行手动初始化,接着很不同的一点就是这个DestroyStack(),因为有默认析构函数的存在,所以不需要去显式地写一个析构函数,就方便了许多
在这里插入图片描述
5. 跟构造函数类似,我们不写编译器⾃动⽣成的析构函数对内置类型成员不做处理,⾃定类型成员会调⽤他的析构函数。
6. 注意的是,不管类中是否显示写析构函数,自定义成员变量都会调用他的析构函数。也就是说⾃定义类型成员⽆论什么情况都会⾃动调⽤析构函数。


class Stack
{
public:
	void Init(int n = 4);
	void Push(STDataType x);
	Stack(int n = 4)
	{
		STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * n);
		if (tmp == NULL)	
		{
			perror("malloc failed");
			exit(1);
		}
		_arr = tmp;
		_capacity = n;
		_top = 0;
		cout << "stack 默认构造" << endl;
	}
	~Stack()
	{
		if (_arr)
		{
			free(_arr);
			_capacity = _top = 0;
			cout << "stack默认析构" << endl;
		}
		

	}
};
class MyQueue
{
public:
	void Push(int x)
	{
		//...
	}
	MyQueue()
	{
		cout << "hehe" << endl;
	}
private:
	Stack PushST;
	Stack PopST;
};

在这里插入图片描述

总结一下

如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。申请资源一般是动态开辟空间这些,不析构他会造成内存泄露。

  • 如果类中没有申请资源时,析构函数可以不写,直接使⽤编译器⽣成的默认析构函数,如Date;如果默认⽣成的析构就可以⽤,也就不需要显⽰写析构,如MyQueue(默认生成的析构会去调用Stack类的析构(自定义类型));但是有资源申请时,⼀定要⾃⼰写析构,否则会造成资源泄漏,如Stack。
  • ⼀个局部域的多个对象,C++规定后定义的先析构。上面myqueue类动图已展示出来Pop和Push

拷贝构造函数

概念

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

特性

  1. 拷⻉构造函数是构造函数的⼀个重载。(重载就是参数类型不同或者参数个数不同)
  2. 拷⻉构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引
    ⽤,后⾯的参数必须有缺省值。
  3. 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的【重点】

接下去我就依照上面这三点,先来见见拷贝构造函数

//全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{
	_year = y;
	_month = m;
	_day = d;
}
//拷贝构造函数
Date(Date d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

  • 上面这个Date(Date d)指的就是拷贝构造函数,Date d2(d1)指的;便是它的调用形式,用已经定义出来的对象d1来初始化d2
  • 但是我们编译一下却看到报出了错误,说【Date类的复制构造函数不能带有Date类】,这是为什么呢?
  • 在这里插入图片描述
  • 但此时若是我将形参的部分加上一个引用&就可以编过了,这是为什么呢?
    在这里插入图片描述
  • 还记得引用的作用吗?引用就是一个变量的别名,他是不会开辟函数栈帧空间的。所以通常C语言中说形参是实参的一个拷贝,但是在C++中,有了引用就减少了拷贝
void Func1(Date d)
{
	cout << "Func1函数的调用" << endl;
}

void Func2(Date& d2)
{
	cout << "Func2函数的调用" << endl;
}

int main(void)
{
	Date d;

	Func1(d);
	Func2(d);
	return 0;
}

  • 通过下面的调试观察可以发现,Func1传值调用,会去调用Date类的拷贝构造函数,然后再调用本函数;但是Func2传引用调用,却直接调用了本函数
  • 这就源于我们之前讲过的,对于【传值调用】会产生一个临时拷贝,所以此时d是d1的拷贝;对于【传引用调用】不会产生拷贝,此时d是d1的别名;
    在这里插入图片描述
  • 那内置类型,int和char传值调用这些适用于这个规则吗?
  • 对于内置类型的数据我们知道只有4/8个字节,这个其实编译器直接去做一个拷贝就可以了;但是呢对于自定义类型的数据,编译器可没有那么大本事,需要我们自己去写一个拷贝构造函数,然后编译器会来调用
  • 通过上面的观察我们来总结一下:对于内置类型(包括指针)/ 引用传值均是按照字节方式直接拷贝(值拷贝);对于自定义类型,需要调用调用其拷贝构造函数完成拷贝

深入拷贝构造

  • 这个时候我们再来分析一下第二点特性,为什么说使用传值方式编译器直接报错,因为会引发无穷递归调用?
  • 刚才讲到过,对于自定义类型来说都会去调用拷贝构造,那此时我们转换回Date类的拷贝构造函数这里。通过下面的这张图其实你可以看出自定义类型的传值调用引发的递归问题是多么严重!

在这里插入图片描述

  • t通过Date d2(d1)对象实例化,并且拷贝构造,但是调用拷贝构造就要传参,这时候形参是传值调用,刚刚说了,【自定义类型传参调用】就会引发拷贝构造,那调用拷贝构造就又需要传参数进来,传参数又会引发拷贝构造。。。于是就引发了这么一个无限递归的问题
  • 所以编译器就规定了对于拷贝构造这一块的参数不可以是【传值传参】,而要写成下面这种【传引用传参】的形式。此时d就是d1的别名,我们说了引用作为参数,就不会发生拷贝。此时d是d1的别名
Date(Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

Date d2(d1);

不过对于上面这种拷贝构造的形式并不是很规范,一般的拷贝构造函数都写成下面这种形式

Date(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

  • 那此时就会同学很疑惑,为什么要在前面加上一个const呢?这一点其实我们在模拟实现strcpy中其实也有说到过,有的时候你可能会不小心把代码写成下面这样👇
Date(Date& d)
{
	d._year = _year;
	d._month = _month;
	d._day = _day;
}

这时候加上const就:
在这里插入图片描述

  • 因此可以看到,加上这个const之后,程序的安全性就得到了提升,这就是它的第一个作用①

它还有第二点作用,我们再来看看

  • 我在实例化这个d1对象的时候在前面加上了一个const,此时这个对象就具有常属性,不可以被修改,然后此时再去使用d1对象初始化d2对象会发生什么呢?
int main(void)
{
	const Date d1;
	Date d2(d1);

	return 0;
}

  • 可以看到,编译器报出了错误,说【没有匹配的构造函数】,其实这里真正的问题还是在于权限放大
  • 本来d1这个对象被const修饰就具有常属性,但是形参这里没有被const修饰,老大都不能改变,小弟能改变吗?所以我们建议在形参引用这里加上const
    在这里插入图片描述
    小结一下,加上const的好处:
  • 更具有安全性,防止误操作将原对象内容修改
  • 权限只能缩小不能放大,当原对象被const修饰时候,这里就会权限持平,不会造成权限放大。

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

对于构造、析构来说我们在上面讲到了若是自己不写的话都会去自动调用编译器默认生成的,那对于拷贝构造也同样适用吗?

  • 此时我将上面所写的拷贝构造去除之后,再去进行一个拷贝的操作,通过下面的运行结果可以看出,d1和d2均完成了初始化操作,而且和构造函数不一样,对于内置类型也会去进行处理。其实在这里就是调用了编译器默认为我们生成的拷贝构造
//以下为有参构造
Date(int year = 2000, int month = 1, int day = 1)
{
	_year = year;
	_month = month;
	_day = day;
}

在这里插入图片描述

  • 前面说过,构造和析构都有编译器自己生成的默认构造和默认析构,那么拷贝构造也是默认有生成的拷贝构造。
  • 既然构造、析构都可以自动生成,那么拷贝构造作为类的默认成员函数编译器也是会自动为我们生成。那么此时就会调用默认生成的拷贝构造去拷贝其内部自定义类型_t的时候就会去调用Time类的显式拷贝构造完成初始化工作
    在这里插入图片描述
    在这里插入图片描述

因此对于像Date这种日期类来说,我们可以不用去自己去实现拷贝构造,编译器自动生成的就够用了,那其他类呢,像Stack这样的,我们继续来看看

浅拷贝和深拷贝

首先,我们就使用Stack类默认生成的拷贝构造,不去显式写拷贝构造。

Stack st1;
st1.Push(1);
st1.Push(2);
st1.Push(3);

Stack st2(st1);

  • 不过运行一下就可以发现直接报出了异常,这是为什么呢?
    在这里插入图片描述
  • 其实,根本的原因就是在于我们要使用到数组栈,便要去内存中开辟一块空间,那么s1开辟了一块空间后_array就指向堆中的这块内存地址,接着s2去拷贝了s1,里面的数据是都拷贝过来了,但是s2的_array也指向了堆中的这块空间
    在这里插入图片描述
  • 那此时我去往s1里面push数据的之后,s2再去push,就会造成【数据覆盖的情况】。假设现在s1push了【1】、【2】、【3】,那么它的size就是3,但是s1与s2二者的size是独立的,不会影响,所以此时s2的size还是0,再去push【4】、【5】、【6】的话还是会从0的位置开始插入,也这就造成了覆盖的情况
    在这里插入图片描述
  • 不仅如此,我们在析构这两个对象的时候,也会因为拷贝_array的时候是把他的值拷贝过来,指针存储的值就是那块空间的地址,所以s1和s2的两个成员变量_array指向同一块地址,这时候后创建的对象先析构,析构s2对象时,_arrary指向的空间被free,这时候再次析构s1对象的时候,因为他们的_array指向同一块空间,这块被free的空间已经成为野指针,野指针又被free就会报错。

所以来总结一下指向同一块空间的问题

  • 插入删除数据会互相影响
  • 析构两次会造成程序的奔溃

那么如何去解决这个问题?

Stack(const Stack& st)
{
	//根据st的容量大小在堆区开辟出一块相同大小的空间
	_array = (DataType *)malloc(sizeof(DataType) * st._capacity);
	if (nullptr == _array)
	{
		perror("fail malloc");
		exit(-1);
	}

	memcpy(_array, st._array, sizeof(DataType) * st._size);		//将栈中的内容按字节一一拷贝过去
	_size = st._size;
	_capacity = st._capacity;
}

  • 可以看到,对于深拷贝而言,就要去自己再去手动申请一块空间,然后将原先栈中的内容使用memcpy()一一拷贝过来,此时两个对象中的_array就指向了堆中两块不同空间,那么各自去进行入栈出栈的话就不会造成上述的问题了
    在这里插入图片描述
  • 而且两块空间是独立的,所以在对象进行析构的时候也不会造成二次析构的问题。
  • 但是这样去深拷贝有些麻烦,你可以观察是否显式写了析构函数,如果是,就说明当前这个类设计到资源释放。这时候需要去进行深拷贝
  • 像日期类这些没有动态申请资源之类的,就使用系统默认的拷贝构造浅拷贝
  • 27
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值