(C++) 类和对象 (二) 默认成员函数 构造 析构 拷贝构造 赋值重载 初始化列表 运算符重载


承接上文:
https://blog.csdn.net/m0_71914032/article/details/136213721

# 0 默认成员函数

类拥有六个默认成员函数。
默认函数意味着必须存在,如果代码的编写者不写,编译器会自动生成。
即使你写一个空的类,编译器也会在类内部自动生成至少这么六个函数。
他们分别是:
1.构造函数
2.析构函数
3.拷贝构造
4.赋值运算符重载
5.取地址运算符重载
6.const取地址运算符重载
下面我们来一一了解。

# 1 一:构造函数

六个默认构造之一:构造函数。
构造函数的作用是自动初始化对象。

构造函数的函数名与类名相同。
没有返回值。
会被自动调用。
允许缺省参数。
可以重载,这意味着可以写很多个构造函数。

class Date
{
public:
	// 这种是无参的
	Date()
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}
	// 这种是全缺省的
	Date(int year = 2024, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// 当然也可以是半缺省
	Date(int year, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

# 1.1 默认构造

对于一个类而言,必须存在一个默认构造。
如果你自己不写构造函数,编译器会生成一个默认构造。

这里注意,构造函数和默认构造并不能划等号。

编译器生成的构造函数,
全缺省的构造函数,
和无参数的构造函数,
都属于默认构造。
即上述代码的三种重载中,只有1,3可以算作是默认构造,2不算。
总之,可以不传参数的构造函数才是默认构造。

以及,这三种默认构造只能同时存在一种,不然代码不知道该调用哪个默认构造。
所以上述的代码其实在编译时会报错,因为1和3不能同时存在。

还有的情况,你自己写了构造函数,那么编译器就不会生成默认构造。
此时如果你写的构造函数不是默认构造,即你写的是需要传参的构造函数。
此时这个类中就不存在默认构造了。
那么在调用默认构造时,会报错。

// 这个类没有写构造函数
// 那么编译器会自己生成默认构造
// 编译时不会报错
class Date
{
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d;
	return 0;
}
// 这个写了构造函数,却不是默认构造
// 于是主函数内的无参调用,就没有可用的默认构造了
// 会报错
class Date
{
public:
	Date(int year, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

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

输出如下:
在这里插入图片描述

# 1.2 编译器生成的默认构造

既然编译器会自己生成默认构造,是不是就不需要代码的编写者自己来写构造函数了呢。
想解答这个问题,首先需要知道编译器生成的默认构造究竟会做什么。

构造函数的作用是初始化。
编译器生成的默认构造也是这个功能。
具体的实现分为内置类型和自定义类型两种情况。

对于内置类型,会给随机值,也就是不初始化;
对于自定义类型,会调用其构造函数。

一句话说,自动生成的这个默认构造的作用:
帮你调用这个类内部的自定义类型成员变量自己的构造函数。

假设有一个类,所有的成员变量都是自定义类型,那么这个类就不需要写构造函数。
当然,那些自定义类型成员变量也是类,他们内部得有自己的构造函数。
除非他们也不需要写。

有一些编译器会很主动的多做一些事情,,
自动生成的默认构造会顺手帮你初始化内置类型。
为了让代码在大部分环境下都能跑,还是应该将其视为不初始化,由你自己来初始化。

# 1.3 初始化列表

类中只是对成员变量进行声明。
初始化列表才是每个成员定义并进行初始化的地方。
写法如下:

class Date
{
public:
	Date()
		:_year(2024)
		,_month(1)
		,_day(1)
	{
		;
	}
private:
	int _year;
	int _month;
	int _day;
};

每个成员初始化的顺序是按声明的顺序来的,而不是按照初始化列表中的顺序。
看如下代码:

class Date
{
public:
	Date()
		:_day(1)
		, _month(_day)
		, _year(_month)
	{
		;
	}

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

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

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

声明的顺序是年月日,所以初始化列表中,最先执行的语句实际上是第三行,然后是第二行,最后才是第一行。
所以年和月并没有被初始化,只有日被初始化了,结果如下:
在这里插入图片描述
引用的变量,或者有const修饰的变量,他们必须在定义时就初始化。
所以他们必须通过写进初始化列表来初始化。
其余的成员不是一定要写进初始化列表,不过规范来说还是写了比较好。

无论初始化列表内有没有写全成员变量,所有的成员变量都会在这里定义和初始化。
所以即使你没有写某个变量,并不意味着这个变量不在初始化列表。

如果初始化工作中涉及一些具体的执行语句,例如开辟空间失败后的报错及退出,
就需要写进构造函数的大括号内。
所以也不能在任何情况下都只使用初始化列表。

前文提过,在类没有默认构造的情况下,无参调用会在编译时报错。
而如果这个类中类被写进初始化列表给了值,那么就不算无参调用,参数合适的情况下就可以通过编译。

// 这里的A类就没有默认构造
// 这段代码在编译时会报错
class A
{
public:
	A(int a)
	{
		_a = a;
	}

	int _a;
};

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

只要写进初始化列表,在后面给一个参数,相当于就不是无参调用了,有匹配的构造可用,就不会报错。

class A
{
public:
	A(int a)
	{
		_a = a;
	}

	int _a;
};

class Date
{
public:
	Date()
		:_aa(1)
	{
		_year = 2024;
		_month = 1;
		_day = 1;
	}

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


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

初始化列表就是用于解决引用的变量,const变量,以及没有默认构造的类这三种情况的初始化问题。

# 1.3.1 声明时的缺省值

在成员变量声明时可以给缺省值。
这里的缺省值是给初始化列表的。

什么意思呢?类似函数的缺省值:
如果初始化列表里没有给初始化的值,就会用声明这里的缺省值;
如果给了,那么声明这里的缺省值就会被无视。

class Date
{
public:
	Date()
	// 这里初始化列表给了值,所以声明处的缺省值就会被无视,初始化用的是这里的值
		:_year(2022)
		, _month(3)
		, _day(3)
	{
		;
	}
	
	void Print()
	{
		cout << _year << endl;
		cout << _month << endl;
		cout << _day << endl;
	}
	
private:
	int _year = 2023;
	int _month = 2;
	int _day = 2;
};

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

结果:
在这里插入图片描述
如果将初始化列表注释掉,那么就会用到缺省值:

class Date
{
public:
	Date()
		//:_year(2022)
		//, _month(3)
		//, _day(3)
	{
		;
	}
	
	void Print()
	{
		cout << _year << endl;
		cout << _month << endl;
		cout << _day << endl;
	}
	
private:
	int _year = 2023;
	int _month = 2;
	int _day = 2;
};

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

结果:在这里插入图片描述

# 2 二:析构函数

六个默认成员函数之二:析构函数。

在对象销毁时自动调用,完成对象中的资源清理。
注意,析构函数的作用不是销毁对象,是清理资源。
对象的销毁都是发生在离开对象作用域时。

对于开辟了空间,需要释放的类,往往才需要写析构函数。
在析构函数内部写释放空间,指针置空等等语句。

对于不需要清理资源的类,是不需要写析构函数的。

析构函数名是类名前加上波浪号(取反);
同样没有返回值;
自动调用;
每个类只有一个,不能重载。

// 这里就属于不需要写析构函数的情况
// 因为没有什么资源需要清理的
	~Date()
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}

不写析构函数,同样会自动生成默认析构。
行为与构造类似,不赘述了。

# 3 三:拷贝构造

六个默认成员函数之三:拷贝构造。

有时自动调用的析构函数可能会出现对同一块空间的二次释放。
例如:

class P
{
public:
	P()
	{
		_p = (int*)malloc(sizeof(int));
	}

	~P()
	{
		free(_p);
		_p = nullptr;
	}

private:
	int* _p;
};

int main()
{
	// p1和p2指向的是同一块空间
	// 他俩各自销毁时都会调用析构函数
	// 就导致了对同一块空间的二次释放
	P p1;
	P p2(p1);
	return 0;
}

为了防止这类情况出现,创造出拷贝构造来解决此类问题。

拷贝构造的作用是,在自定义类型发生拷贝时,自动调用拷贝构造。
拷贝构造是构造函数的一种重载;
是一种特殊的构造函数;
必须用引用接收参数,
如果不用引用,那么传参本身也是一次拷贝,也会自动调用拷贝构造,引发无穷递归;
函数名与类名相同,参数是一个同类型的对象:

class P
{
public:
	P()
	{
		_p = (int*)malloc(sizeof(int));
		_val = 2024;
	}
	
	~P()
	{
		free(_p);
		_p = nullptr;
	}
	
	// 这个是拷贝构造
	// 其实就是一个构造函数的重载
	// 根据参数类型匹配会直接走这里
	// 拷贝时就不会走上面那个构造函数
	P(P &p)
	{
	// 内部如何实现当然是看具体需求
	// 例如这里为了防止对同一片空间的二次析构
	// 选择开辟一块大小一样的空间
	// 如果空间里有具体的值,那么也要写语句拷贝过去
		_p = (int*)malloc(sizeof(p._p));
		_val = p._val;
	}
	
private:
	int* _p;
	int _val;
};


int main()
{
	P p1;
	P p2(p1);
	return 0;
}

为了防止拷贝过程中出现的可能的问题,比如不小心修改了用来拷贝的对象,
可以在接收参数时加上const。

# 3.1 编译器生成的默认拷贝构造

默认生成的拷贝构造会对内置类型完成浅拷贝,也叫值拷贝。
什么意思,就是单纯的将对于的值拷贝过去。

所以会出现前面的两个指针指向同一块空间。
这种情况需要深拷贝。
也就是创建出一块大小一致,内容相同的空间,来给另一个指针指向。

对自定义类型则调用该自定义类型的拷贝构造。

所以总结来说:
对于需要深拷贝的类,需要自己写拷贝构造。
例如上文提到的,成员对象有指针的类。
不需要深拷贝的类,就不需要写拷贝构造了,默认生成的拷贝构造就足够了。

# 4 运算符重载

剩下的三个默认成员函数,都是重载的运算符。
本身简单的很,后续提一下就过了。
先来了解什么是运算符重载。

因为自定义类型无法使用编译器自带的运算符,
所以需要运算符重载。

以日期类举例,如何判断两个日期是否相等?
肯定不能直接用”==“比较,因为编译器根本不知道符号两侧的自定义类型到底要怎么比较。
于是就需要自己写重载:

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

运算符本质是一个函数,所以有返回值有参数,operator== 就是函数名。
不同的是他的调用得到了简化:

int main()
{
	Date d1(2024, 1, 1);
	Date d2(2024, 1, 1);
	Date d3(2023, 1, 1);

	// 用函数名来调用函数,和以往的函数一模一样
	// 毕竟运算符重载就是函数
	bool ret = d1.operator==(d2);
	cout << ret << endl;

	// 或者这样简易的调用
	// 这就是区别
	ret = d1==d3;
	cout << ret << endl;

	return 0;
}

结果:在这里插入图片描述

运算符重载不能重载语言本身不存在的运算符。
且操作数之一必须是自定义类型。

运算符重载就是给原有的运算符赋予新的意义。
理论上可以随便乱写,比如把加号内部写成减法,当然大多数时候这并没有什么意义。

重载的目的,是让自定义类型可以像内置类型一样直接使用运算符,
这也是为什么一般遵循符号原本的意义。

# 4.1 四:赋值重载

六个默认成员函数之四:赋值运算符重载。
以日期类举例:

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

	return *this;
}

默认生成的赋值重载,与拷贝构造行为类似。
对于内置类型,完成浅拷贝。
对于自定义类型,调用其赋值重载。

# 4.2 五:取地址重载与六:const取地址重载

最后两个默认成员函数:取地址运算符重载与const取地址运算符重载。
一般都不用自己写,知道存在即可。

// 取地址重载
// 固定会生成,形如:
Date* operator&() 
{
	return this;
}
// const取地址重载
const Date* operator&()  const
{
	return this;
}

# 4.3 前置++与后置++的重载区分

按前面的知识来看,前置++与后置++,他们的声明部分是一致的。
都是:

Date& operator++()
{
	// 内部实现
}

于是出现一个问题,他们的函数名和参数类型都一致,怎么重载?怎么区分前置++与后置++?

答案是:没什么好办法。

于是直接在语法上规定:
后置++,在参数内加上一个int,传参时也随便传一个整形。
根据参数的不同来重载实现后置。

这是规定,是语法,不存在合理的逻辑,是一个特殊的处理。
代码如下:

// 前置++
Date& operator++()
{
	// 具体实现
}

// 后置++
Date operator++(int)
{
	// 具体实现
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值