读一些无用的书,做一些无用的事,花一些无用的时间,都是为了在一切已知之外,保留一 个超越自己的机会。人生中一些很了不起的变化,就是来自这种时刻
💖1.类的6个默认成员函数
大家认为下面这个Data类有没有成员函数?注意,它是有的。 如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,编译器都会自动生成下面6个默认成员函数。
🌟2. 构造函数
🔥2.1 概念
我们正常初始化是不是写一个Init成员函数,然后在外部调用初始化呀。 在平常工作或者练习中,我们是很容易忘记初始化的,也有可能在我初始化之前,别人就调用print了,结果就会打印出随机值,甚至程序会崩溃掉。(如写一个栈,指针没有初始化直接Push数据) 并且如果每次创建对象都调用Init设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢? C++为此设计出了一个构造函数。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有 一个合适的初始值,并且在对象的生命周期内只调用一次。
??2.2 构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数的虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。 其特征如下:
-
函数名与类名相同。 -
无返回值。 -
对象实例化时编译器自动调用对应的构造函数。 -
构造函数可以重载 假设我们想指定数值去初始化它呢,可以重载嘛。但是它的调用的地方也很特殊,参数是在对象的后面。 注:无参的构造函数,调用时不能像Date d1(); 这样写,因为编译器认为你没有创建对象,编译器无法区分这行代码是函数的声明还是对象的定义。 -
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。 全缺省构造函数很好用,传参可以根据需求只传几个。 如果我们写了个带参数的构造函数,那么会报错,因为它不属于默认生成的构造函数: -
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。 -
好,我们重新回收开头:不是说自动生成一个无参的默认构造函数嘛,但是它好像啥都不干(成员变量未初始化是随机值)呀,我要它干啥? C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语法已经定义好的类型:如 int、char…,自定义类型就是我们使用class、struct、union自己定义的类型。默认生成的构造函数对内置类型不处理,对自定义类型才处理。 如下图,Data会去调A的构造函数。
??2.3 默认生成的构造函数的实际应用场景
也就是说默认生成的构造函数,我们平时也不敢随便用它。如果它带有内置类型的成员就要我们自己写构造函数。那什么情况下会用默认生成的呢?大家请看下图:
假设我想用两个栈实现队列,我们只需要写栈的构造函数,队列就不用写了。因为队列只有自定义类型成员。MyQueue的默认生成构造函数会自动去调Stack的构造函数进行初始化。 所以C++区分化处理的机制有点恶心,我们不写构造函数编译器会生成默认的,但这个默认的不一定有用。 总结: 1.如果一个类中的成员全是自定义类型,我们就可以用默认生成的构造函数。 2. 如果有内置类型的成员,或者需要显示传参初始化,那么都要自己实现构造函数。 3. 如果Stack的构造函数是带参的,那么MyQueue也无法生成默认构造函数,因为Stack没有默认构造函数。 这就需要用到初始化列表,我们以后再讲。
🐮3.析构函数
🐸3.1 概念
前面通过构造函数的学习,我们知道一个对象时怎么来的,那一个对象又是怎么没呢的? 析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作
🌺3.2 析构函数的特性
析构函数是特殊的成员函数。 其特征如下:
-
析构函数名是在类名前加上字符 ~。 -
无参数无返回值。 -
一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。 大家猜一猜如果我们日期类和栈类不写析构函数,默认生成的析构函数会不会做资源清理工作?不会,它也是对内置类型不处理,对自定义类型才处理,大家可以类比默认生成的构造函数。 而且编译器也不敢随便处理,因为不知道你的指针是malloc的,还是fopen的。如果通通free掉,会出问题。 大家看下图: 我们发现MyQueue自动生成并调用了默认生成的析构函数。 -
对象生命周期结束时,C++编译系统系统自动调用析构函数。 我们来看一下编译器有没有自动调用析构函数。 那析构函数是不是销毁这个对象呢?记住,它不是销毁这个对象本身。假设我们在d1后面定义了一个int i; i需要你去销毁吗?同理,d1也在main函数这个栈帧空间里面,不需要你去销毁,出了作用域自动销毁。 析构函数是去完成类的一些资源清理工作。 什么是资源清理呢?我们的日期类不需要资源清理,因为年月日属于对象本身,对象属于栈帧,栈帧结束了,它就销毁了。 析构函数是对malloc、new、fopen这些资源进行清理。比如说我们刚刚写的栈类,动态开辟的空间是不是要进行处理啊。大家请看下图: 这也就意味着有些类需要我们写析构函数,有些类不需要我们写析构函数。 大家仔细想一想,构造函数、析构函数就是我们以前写的Init、Destroy呀。只是用面向对象的思想写在类里面呀。 大家再来注意一个小细节,假设我定义了两个栈对象st1,st2,构造函数肯定是顺序执行,那析构函数呢?因为main函数会开辟栈帧空间,里面写的代码都属于栈帧,所以就要符合后进先出的原则,后定义的先析构。 大家请看下图:
🍀4. 拷贝构造函数
🌾4.1 概念
我们创建对象时,可否创建一个与一个对象一某一样的新对象呢?
构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
内置类型的成员会完成值拷贝(浅拷贝就像用memcpy的一样),自定义类型的成员,去调用这个成员的拷贝构造。
🌼4.2 拷贝构造函数的特征
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。
这个地方编译器不允许,我们画图来说明使用传值传参为什么会引发无穷递归调用:要想把d1拷贝给d2就要调拷贝构造函数,拷贝构造又要传参,传值传参又要拷贝构造。 所以拷贝构造函数必须使用引用传参,为了保护引用实体,我们一般在引用前加const,我再用一个Func函数帮助大家理解: 调用Func要先传参,我们用同类型的对象d1去初始化d,就要先调用拷贝构造函数。传值传参是要先调用拷贝构造函数的。 那我们不想调用它,怎么优化呢?用引用传参 我们发现此时调用函数,就没有去调用拷贝构造函数了。为什么呢? 因为d是d1的别名,d和d1本质上是同一块内存空间。
-
若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。 -
那么编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试? 我们发现程序直接崩了,为什么? 栈不能用默认生成的拷贝构造函数,要有一些特定的技巧,这个技巧叫深拷贝。 我们知道栈动态开辟了一个空间,如果我们还是像日期类一样使用默认生成的拷贝构造函数,那st1、st2就指向了同一块空间,这不是我们所期望的。因为我st2改变了,我的st1也改变了。最重要的是我的程序会崩,因为出了作用域,st2会自动调用析构函数,_a开辟的空间直接被释放了,st1还指向这块空间,st1继续调用析构函数,又free了一次。同一块空间释放了两次。
继续调试,程序崩溃: 这就需要深拷贝实现,我们以后再讲。
我们再来看一个稍微复杂的例子:
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
assert(_a);
_top = 0;
_capacity = capacity;
}
Stack(const Stack& st)
{
cout << "Stack(const Stack& st)" << endl;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_capacity = _top = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue
{
private:
int _size = 0;
Stack _st1;
Stack _st2;
};
int main()
{
Stack st1;
Stack st2;
MyQueue mq1;
MyQueue mq2(mq1);
}
这个时候又深套了一层,假设栈的深拷贝我们没有写好,此时不仅仅Stack会有问题,MyQueue也会有问题。mq1和mq2中的栈是浅拷贝,指向同一块空间。mq2析构又会去调用Stack的析构函数,mq2的st1、st2释放,mq1的st1、st2再释放,这个时候就会出问题。
🎃4.3 总结
- 一般的类,自己默认生成的拷贝构造就够用了。
- 只有栈这种直接管理资源的才要自己写拷贝构造函数,其他的都不需要。
MyQueue也不需要,因为MyQueue默认生成的拷贝构造函数最终还是会去调栈的拷贝构造函数,只要栈的拷贝构造完成了自己的资源管理,MyQueue也就完成了自己的资源管理。
🎉5.赋值运算符重载
🎁5.1 运算符重载
假设我们自己定义的日期类也想进行>、<、==、++、+、-等运算,我们还能像内置类型一样直接用吗?大家请看下图:
显然是不能的,C++为了自定义类型可以使用各种运算符,增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似,运算符重载可以自己规定运算规则。 函数名字为:关键字operator后面接需要重载的运算 。 函数原型:返回值类型 operator操作符(参数列表) 。
比如说:
bool operator==(Date d1, Date d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
但是我们无法在类外用对象去访问私有的成员变量: 解决方法有四种:
- 把私有的成员变量改成公有,太戳,不建议。
- 在类里写三个公有的成员函数:Getyear、Getmonth、Getday,调用函数进行访问,但是写起来很烦。
- 友元也可以解决,但是破坏了封装。
- 其实最终都要把运算符重载放进类里,这样访问就不受限制,放进类里又有变化,我先把成员变量改为公有向大家演示。
这就是普通函数的一个调用嘛,有啥。但是operator==(d1,d2)我们这样用,用着感不感觉别扭呀,完全不像在用一个运算符。那我还不如写成这样:
bool DateEqual(Date d1, Date d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
所以我们不会这样用,重载的意义就是让我们可以像内置类型一样去用,所以我们更多的是像第二种方法那样用:if (d1 == d2) ,编译器如果看到操作数是自定义类型,会自动去调用运算符重载。
这条语句等价于if (operator==(d1,d2))
但是终极归宿都是把运算符重载放进类里,如果我们单纯把运算符重载函数拷进类里面,你会发现编译不通过: 为什么会报二进制“operator ==”的参数太多 这个错误呢?,因为成员函数还有一个隐含的this指针,实际上有三个参数。所以写进类里面就要少写一个参数,调用的时候也要改变,正确写法: 此时if (d1 == d2) 会先去类里面找有没有运算符重载,如果没有才会去全局找。全局的运算符重载和类里的运算符重载是可以同时存在的,因为是两个不同的作用域。
现在就等价于if (d1.operator==(d2)) 。编译器是很聪明的哦,它会帮你把事情摆平:
但是我们这里是传值传参会调用默认生成的拷贝构造函数,我们没必要调默认生成的拷贝构造函数,就用引用进行传参,如果不想改变,就把const加上。
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
博主再把<的运算符重载奉上,其余运算符重载大家可以自己思考一下:
bool operator<(const Date& d)
{
if ((_year < d._year)
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && d._day < d._day))
{
return true;
}
else
{
return false;
}
}
注意:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型或者枚举类型的操作数
- 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的
- 操作符有一个默认的形参this,限定为第一个形参
"*" 、"::" 、"sizeof" 、"?:" 、"." 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
🎅5.2 赋值运算符重载
int main()
{
Date d1(2022, 5, 16);
Date d2(2022, 5, 18);
Date d3(d1);
Date d3 = d1;
d2 = d1;
return 0;
}
这个简单,我们直接上手写:
Date operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
但是这样写不够全面,因为像C语言的赋值表达式是有返回值的,为了支持连等赋值:
int i = 0, j, k;
k=j=i;
正确的写法:*this就是d2,为了防止传值返回进行拷贝,我们用引用返回。另外为了防止自己给自己赋值,我们可以用地址来判断一下。
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
因为赋值运算符重载函数也是类的默认成员函数之一,所以一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。 具体行为细节类比默认生成的拷贝构造函数。
🎄6.日期类的实现
我们自己再来实现一个日期类来巩固这方面的知识: 手把手教你写一个日期计算器(C++)
🔮7.const修饰类的成员函数
还是老生常谈的权限问题:因为Func的参数是const Date的,再调用Print把地址传给隐含的this指针时,权限就被放大了。要解决这个问题,只要给Print的参数在*之前再加一个const就行了。 但是我们能不能加?不能,因为this指针是隐含的。怎么办? 我们在函数后面加const就行了:void Print() const 其等价于void Print(const Date* const this) 这行代码。 这样两种情况都可以适用,一个权限不变,一个权限变小,都是可行的。 既然如此,那我们是不是要把所有的成员函数后面都加上const呢? 不能因为有些函数要去修改对象的成员变量,所以你也不敢随便加哦。 在成员函数后面加const的原则: 只要不修改对象的成员变量的函数都可以加上,比如关系运算符都可以加。
📷8. &及const &操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ;
int _month ;
int _day ;
};
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容。
|