文章目录
承接上文:
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)
{
// 具体实现
}