【C++类和对象解密】面向对象编程的核心概念(中)
在之前的文章里,我们大概了解了类的外壳,现在,让我们共同来了解下类的内部有什么东西…
类的默认成员函数
默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数(半自动化)。一个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们只需稍微了解即可。其次就是C++以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们之后再做了解。
默认成员函数很重要也很复杂,我们要从两方面去学习:
- 我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求。
- 编译器默认生成的函数不满足我们的需求时,我们如何自己实现?
6个默认成员函数:
初始化和清理: | 构造函数主要完成初始化工作 |
析构函数主要完成清理工作 | |
拷贝复制: | 拷贝构造是使用同类对象初始化创建对象 |
赋值重载主要是把一个对象赋值给另一个对象 | |
取地址重载: | 主要是普通对象和const对象取地址,这两个很少会自己实现 |
一、初始化与清理
首先我们来看进行初始化和清理工作的构造函数与析构函数:他们分别类比于之前我们写过的Init()与Destory()。Init()是所有的类都需要写,Destory()是有些结构需要写,而有些结构不需要写。
1.1构造函数
构造函数是特殊的成员函数,需要注意的是,构造函数虽然叫构造,但是构造函数的主要任务并不是开空间创建对象。构造函数的本质是要代替我们以前Stack和Date类中写的Init函数功能,构造函数自动调用的特点就完美的替代了Init函数。
构造函数的特点:
- 函数名与类名相同。
- 无返回值(返回值什么都不需要给,也不需要写void,不需要纠结,规定即如此)。
- 对象实例化时系统会自动调用对应的构造函数。
- 构造函数可以重载。
我们以前调用一个函数是使用函数名加实参的方式,但是,构造函数不同,构造函数是在对象实例化之时直接调用:由于下图中的两个函数构成函数重载,所以这里根据参数不同,进行参数匹配,进行调用。
以前的调用是函数名加实参列表,现在是对象名加实参。
有同学可能会问调用无参构造时,要不要在对象d1后面加一个括号呢?
这里是不可以的,这里又引申出了其他问题:假设我们调用无参构造时在对象后加了一个括号,你又想在这里做一个函数声明,这样的话,两个地方谁是无参构造,谁又是函数声明傻傻分不清楚。。为了更好区分,如果你想调用无参构造,就不要加括号,否则编译器也会分不清楚。
构造函数的意义:对象实例化一定会调用对应的构造,保证了对象实例化出来就一定被初始化了! 之后就不存在我们定义了一个对象之后还会出现随机值的情况了。
在写构造函数下一个特点之前,我们要先提及一个重要概念:
C++把类型分为内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如int、char、double、指针(所有类型的指针,包括类类型的指针)等,自定义类型就是我们使用class、struct等关键字自己定义的类型。
构造函数的特点(续)
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
由上图可以看出,我们显式地写了带参的构造函数,那么编译器就不会再自动生成无参的构造函数了。由于实例化一定会调用构造,我们现在没有无参的构造,编译器就会报错。
只有当我上面的带参构造也没写时,编译器才会生成默认无参构造。
可以看到编译器自动生成的默认构造的行为是什么呢?默认构造对内置类型初始化为了随机值。
- 我们不写,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说不一定会对内置类型初始化,主要还是看编译器。对于自定义类型的成员变量,要求调用这个成员变量的默认构造函数来初始化。如果这个自定义成员没有默认的构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决。
typedef int STDataType;class Stack{public:Stack(int n = 4){cout << "Stack(int n = 4)" << endl;_a = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == _a){perror("malloc申请空间失败");return;}_capacity = n;_top = 0;}//~Stack()//{// cout << "~Stack()" << endl;// free(_a);// _a = nullptr;// _capacity = _top = 0;//}private:STDataType* _a;size_t _capacity;size_t _top;};// 两个栈实现一个队列class Myqueue{private:// 自定义类型Stack _pushst;Stack _popst;};
- 上面所指的默认构造有以下几种:无参构造函数、全缺省构造函数、我们不写构造时编译器自动生成的构造。但这三个函数有且只有一个存在。无参构造函数和全缺省构造函数虽然构成函数重载可以同时存在,但在调用时会存在歧义。
- 注意:默认构造函数不仅仅包括(我们不写时)编译器默认生成的无参构造函数。简单来说,不传实参就可以调用的构造就叫做默认构造。
- 编译器生成的默认构造函数大多都不满足我们的需要,所以构造函数大多数都需要自己实现,只有少数myqueue这种才编译器生成。
1.2析构函数
析构函数与构造函数的功能相反,有构造就一定有析构。析构函数不是完全对对象本身的销毁,比如局部对象是需要栈帧的,函数结束栈帧销毁,它就释放了,不需要我们管。C++规定对象在销毁之前会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前接触过的Destory功能,是用来释放资源的函数。
构造函数并非开辟空间,是对实例化对象的初始化,既无空间的开辟,那么析构函数就并非是对对象的销毁。
对比以下两个类,严格来说,Date类是不需要析构的,而Stack类需要,因为它开辟了空间。
析构函数的特点:
- 析构函数是在类名前加上字符 ~(按位取反) 。
- 无参数无返回值(这里与构造函数类似,同样不需要加void)。
- 一个类只能有一个析构函数,不存在重载的概念,若没有显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,系统会自动调用析构函数。
- 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用它的析构函数。
- 还需要注意的是我们显式写析构函数,对于自定义类型成员,也会调用它的析构,也就是说自定义类型成员无论什么情况都会去调用析构函数。
- 后定义的对象会先被析构。
析构函数的意义:对象生命周期结束时,系统会自动调用析构函数,不需要我们人为去写Destory方法,避免忘记,保证了不会存在内存泄露。
(这里提一句,内存泄漏的风险是很大的,持续的内存泄漏会使可用内存越来越少,系统最后可能会崩,软件会坏)
总结:大部分类需要写构造,小部分类不需要写;大部分类不需要写析构,小部分类需要写析构。
二、拷贝复制
现实生活中,我们还存在着一个场景:对对象进行拷贝。
2.1拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数。
拷贝构造的特点:
- 拷贝构造函数是构造函数的一个重载。
- 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器会直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
现在我们来探讨一下,为什么这里的传值传参会引发无穷递归?
要调用func(),(假设)进行传值传参,传值传参调用拷贝构造,如果本次拷贝构造结束,相当于d1作为形参传递给了实参d,传参结束才能继续调用func()。。
但是,每次调用拷贝构造函数之前要先传值传参,传值传参是一种拷贝,又形成了一个新的拷贝构造,如此往复,就形成了无穷递归调用。。。也就是说上图中的(2)总是持续进行中,生成了好多好多的传值传参…无法回到(3)。
相反地,传引用就不会出现这种情况,为什么呢?
拷贝构造的特点(续):
- C++规定的自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成。
- 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节地拷贝),对自定义类型成员变量会调用它的拷贝构造。
下图中我们并未显式定义拷贝构造,说明拷贝构造是会由编译器自动生成的;还有一点,由于日期类中只包含了内置类型变量,在进行拷贝构造之时只需要进行简单的值拷贝,所以我们不写的情况下,编译器自动生成的拷贝构造函数是完全够用的。
对于非内置类型变量,也就是自定义类型的拷贝,我们是否也可以不写,而是直接使用编译器自动生成的拷贝构造函数呢? 答案是不能。会存在两个较大的问题,下面给大家看一个实例:
- 一个修改会影响另一个:当st2对st1进行了拷贝,_top、_capacity两个内置类型(int)的参数可以直接被浅拷贝 ,但是,如果第一个参数_a(指针类型变量)也使用了浅拷贝,最后的结果就是st1与st2指向的空间是相同的,如果我们人为地改变了st2中下标为【3】的数,也就是将4改为5,st1中下标为【3】的数同样被改变了。。
- 析构两次资源,导致程序崩溃:还有一点,我们在析构函数那里讲到,后定义的对象会先被析构,当st2的生命周期结束,由于他是后定义的,先被析构,st2被析构之后,由于与st1占用着同一块空间,st1也就自然被析构了。。
既然栈这种结构不能局限于浅拷贝,那我们就需要人为地实现深拷贝,当然深拷贝的形态、情况又有很多了,我们目前仅仅来思考一下栈这里的深拷贝:
首先是开辟一个与拷贝内容一样大的空间——》检查malloc是否成功——》最后对内置类型变量进行拷贝。
对于Myqueue这个类,由于它是由两个栈实现而成,不需要写析构,也不用写拷贝构造。
// 两个栈实现一个队列class Myqueue{private:// 自定义类型Stack _pushst;Stack _popst;};
总结:一般当你需要写析构,那么也一定需要写拷贝构造。
再强调一点:
传值返回会产生一个临时对象调用拷贝构造,传值引用返回,返回的是对象的别名(也就是引用)没有产生拷贝。但如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似于野指针。
传引用返回可以减少拷贝,但是一定要确保返回对象在当前函数结束后还在,才能引用返回。
三、赋值运算符重载
运算符重载也是本贾尼博士针对类和对象设计的一个特性,运算符重载主要指我们在实际生产过程中,希望对类类型进行一些计算,我们可以运用函数来进行比较,但是我们不能直接使用运算符,假设我们想对两个类型进行比较,由于内置类型比较简单,他们的运算规则也是由库函数直接设计好的,进行计算时,编译器将其直接转换成对应的指令即可,但自定义类型由人为创造,很复杂,没有对应的指令,那么我们就可以写一个函数来进行两类型的比较即运算符重载。
3.1运算符重载
运算符重载是具有特殊名字的函数,是由operator和后面要定义的运算符共同构成,也具有返回值类型和参数列表以及函数体。
重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数
现在,我们简单地实现一个==函数:
目前有一个很可悲的问题,当我们在类以外定义函数时,由于类中的成员变量均被private修饰,所以类外面是难以访问的,我们只得先将成员变量先设置为公有:
红色框框这里存在一个弊端,对于内置类型传值传参代价比较小,但是传参还是要调用拷贝构造,就是一种浪费,所以这里我们可以传引用,这样就减少了拷贝,提高了代码效率。
这里还有三个问题:
1、==运算符很容易写成=赋值运算,这样就会违背了函数本身的含义,我们应该在Date前加上const修饰,假设写错了,我们也能更好的查找错误。
2、还有一种情况,如果Date类型的变量被const修饰了,那么传参时必须传引用,否则就是权限的放大。
3、这里还涉及运算符优先级的问题:(流插入)> (==),所以这里要(d1==d2)。
再回头看,我们刚刚提到“当我们在类以外定义函数时,由于类中的成员变量均被private修饰” ,除了将类中的内置类型设为公有,有没有其他办法呢?
1、提供getXXX函数
2、友元
3、放在类里
编译运行后,我们又发现一个问题:
我们在学习this指针时曾提到,编译器在编译时都会在参数列表中添加一个Date const* this修饰,这时,如果我们再次传参,就会有两个重复的x1,和一个x2,即9个变量,有同学会问,那怎样修改呢?
※要知道:
如果一个重载运算符是成员函数,则它的第一个运算对象默认传给隐含的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。
所以这里我们可以直接将参数列表变为const Date&x。
这里就转换成了函数调用的形式了。
注意事项:
- 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,其中二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。
- 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。
- 不能通过连接语法中没有的符号来创建新的操作符:比如operate@。
- 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如:
int operator(int x,int y)
- 一个类需要哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意义,但是重载operator*就没有意义。
- .*(成员函数的调用) :: sizeof ?: .(对象.成员) 这五个运算符不能重载。
3.2赋值运算符重载
(6:58)赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象之间的拷贝赋值,要注意赋值运算符重载与拷贝构造的区分,拷贝构造用于将一个对象拷贝初始化给另一个即将要创建的对象。
- 以下调用了构造:
- 以下调用了拷贝构造:
赋值运算符重载的特点:
- 赋值运算符重载是一个运算符重载,规定必须重载为成员函数。参数建议写成const 当前类型引用,否则会传值传参,调用拷贝。
- 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。
(1)的返回值为d2,
(2)的返回值为d1。
- 没有显式实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节地拷贝),对自定义类型成员变量会调用它的赋值运算符重载。
- 所以像日期类这种全是内置类型的成员变量且没有指向什么资源的类,编译器自动生成的默认赋值运算符重载就可以完成拷贝。这里有一个技巧:如果一个类显示实现了析构并释放资源,那么他就需要写赋值运算符重载,否则就不需要(这里与拷贝构造的行为类似)。
- 以下调用了赋值运算符重载:
完成了赋值重载:
还有一种极特殊情况:自己给自己赋值,对于这种情况,我们可以写个判断,避免这种情况的浪费。
还有一点:拷贝构造的形式不是唯一的:
Date d2(d1);
Date d2 = d1;
//同为拷贝构造使得
Date d2 = d1 + 100;
//可读性更强!
- 流插入/流提取
由于内置类型是可以穷举的,这是C语言的弊端,到了C++这个阶段,自定义类型是无法穷举的,光用%d、%s、%f等一系列格式说明符不能实现所有类型的输出和输入,所以C++新增了流插入<<和流提取>>,它们在C语言阶段是移位运算符,在这里作为流插入、流提取运算符方便了任意类型的输入输出。
cout<<d1; 等价于 cout.operator<<(d);
- 所有类型的字符、字符串都往我们的控制台(也就是黑框框)中输出
- 当然所有的内置类型都不支持使用运算符,想用的运算符都需要我们自己重载,但是iostream已经将cin、cout提前定义好,这也是我们在使用之前#include<iostream>直接就可以调用的原因
- cin、cout均在std这个命名空间,我们使用之前要展开命名空间,或者指定命名空间
- 我们之前说过,cin、cout可以自定识别类型,所谓的自动识别类型并没有我们想象的那么神秘,只是因为函数重载,cout<<d1; 等价于 cout.operator<<(d);
cout<<i<<d<<endl;
这里是三次函数调用,从左往右依次返回cout,返回之后再调用,以此类推。这里也是cout比printf效率略低的原因。
现在,我们对<<和>>进行重载:
我们在Date类(这是一个关键点,一会我们会写到)中声明。只要一个类重载了流插入流提取,任何类型都可以输入输出。 注意:这里的out只是cout的别名,是形参
声明:
定义:
这里存在一个问题,必须重载为全局函数:重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout,不符合使用习惯和可读性。
重载为全局函数把ostream/iostream放到第一个形参位置就可以了,第二个形参位置当类类型对象。
当定义为全局函数后,对类类型的private变量进行调用就会报错:
为了解决这个问题,除了GetXXX的用法,还有一个方法,这里引出了下一个知识点——友元函数。
我们在初学C语言时,肯定会为时常对scanf("%d",&a);忘记取地址而苦恼,我们在C++学习阶段又不免有了其他困惑,为什么cin>>就不需要取地址了呢?
cin>>i;这里的i就作为实参传给了形参val,而形参是实参的别名,改变了形参就会改变实参 ,所以C++这个部分是不需要取地址的。注意,这里不能加const,由于形参可能会被改变,加了const就会犯权限放大的错误。
四、取地址运算符重载
4.1const成员函数
实例化一个对象,并调用打印:
但如果我们这里的实例化对象是被const修饰的,那么这里编译就会报错:
为什么会这样呢?其实这里涉及到了权限的放大 。
由于const在Date*的左侧,所以限制的是Date*指向的内容,也就是其指向的内容不能改变。
基于这种情况(权限不能放大,可以缩小),我们试图把print函数的形参类型由Date* this变为const Date* this,但形参实参是不可以显式写this的,编译会不通过,于是本贾尼博士在成员函数的后面加了一个const,这个成员函数就叫做const成员函数,这里的const修饰this指针指向的内容。
- 将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后面。
- const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。const修饰Date类的print成员函数,print隐含的this指针由Date* const this 变为const Date* const this
那么,什么样的函数参数列表后面可以加const呢?只要不改变调用对象的函数都可以把const加上:比如+、-、==、!=、>、<、等等。但+=以及-=这类会改变自身的函数不可以加const。
4.2取地址运算符重载(构成函数重载)
当二者同时存在时,既可以调用const,也可以调用非const,优先考虑最合适的。
但二者是默认成员函数,一般编译器自动生成的就足够使用。
未完待续。。。点个赞呗~