上篇讲到对象的实例化
这里我们接着来探讨对象
目录
类域及成员函数在类域外的声明方法
内联
构造函数
先来看前三点:
无参调用格式
第四点函数重载
最后一点:没写构造时 自动生成 默认构造 并调用 《坑和补丁篇》
默认构造
析构函数
拷贝构造
运算符重载
-
类域及成员函数在类域外的声明方法
在前面的C嘎嘎,我们学了命名空间,命名空间有它自己的域;同样在这里声明了一个类,这个类同样有自己的类域,花括号中的区域就是类域
类域外不能直接调用到类域中的成员,并且想要在外部定义 类的成员函数,要使用域操作限定符具体如下:
成员函数一般在类中进行定义(写函数体),在自己的类域中定义这没有问题;但是有时候定义太长(函数体太大),直接写到类里面太长了不好看,要在类域外。在类域外要定义该类的成员函数,因为类有它自己的类域,在类外面直接写定义是不在类域里面的,编译器可能会认为他是一个普通函数而不是类的成员函数 —— 所以这个时候就要使用到我们的域操作限定符了 类名::函数名
屏幕剪辑的捕获时间: 2023/4/26 13:31
类操作限定符限定 Push 这个函数是在 Stack 这个类域中的,这样编译器才知道这是类中的成员函数而不是普通函数
同样如上是成员函数在类域外的声明方法
内联
在一个类中的成员函数先自动指定为 inline 函数,但是一般成员函数的定义较长,运行时系统也一般不会把它定为 inline 函数
(之前学的:虽然你写了 inline,但是最后到底是不是 inline 得由编译器决定)
屏幕剪辑的捕获时间: 2023/4/26 14:05
我们看到不管是类中的 Push 函数 还是Pop 函数,在汇编的时候都使用了 call,说明编译器不把他们当做内联函数来看待,虽然还没运行时成员函数被默认指定为内联,但是运行时经过编译器判断,最终代码不够短而被当做了普通函数
注:类中的成员函数只在类域中声明不在类域中定义时(如上的Push函数,定义在了其他源文件中),运行前不自动被指定为inline函数,虽然它在类中
错误演示:如果它在运行前自动被指定为inline函数,就是一个inline的
经典错误了
如果它是内联函数,编译阶段到函数调用向上找声明,找到声明它是内联但在这个源文件找不到定义,到后面就只有call了
而在Push函数的定义文件中,编译阶段找声明知道它是内联后就不形成函数符号列入符号表
这时候编译完在链接时,两文件符号表链接,因为函数文件没有对应的函数符号,链接不上出现链接错误
重点,重重重点:::::构造函数那些事!!!!!
日常中,我们可能会漏了初始化或销毁函数,初始化忘记写可能会导致程序直接崩崩,,直到调试然后说,小可爱忘记写初始化了;没写销毁函数更致命,使用了动态内存的没有及时释放会导致内存泄漏,关键是我们温柔的编译器它不给你报错,内存蟹肉放到公司是很致命的一种食物
但是就算我们平常没有忘记初始化和销毁,但是局限于函数调用的形式在一些地方不能直接调用,很繁琐而且只能写得有点锉但是没办法
祖师爷也是一样,在C的时候可能偶尔忘记写初始化或内存泄漏,并且在一些地方调用太死板繁琐小锉锉。。祖师爷想很多人也可能会忘记写,或者在一些地方写得不顺心,所以在C嘎嘎中出手了 —— 构造函数和析构函数
这两函数可以自动被调用,不用亲自调用初始化和销毁(构造和析构这里意思是初始化和销毁),这样不怕忘记而且不会再实现得那么挫
反正构造函数和析构函数都是祖师爷的亲儿子
-
构造函数
构造函数肥肠重要,让我们一起来好好学一下:
因为我们可能忘记调用初始化函数而导致程序崩溃,所以有了构造函数,也就是帮我们初始化的函数
这里要理解清楚构造函数在这里的意思,它是初始化函数:初始化对象的资源,而不是创建对象。不能说有个构造就是创建,它是初始化
来看看构造函数:
- 在创建对象的时候自动调用构造函数(自动调用初始化)
- 构造函数名字与类名相同(祖师爷让我们更好分辨,然后规定好的)
- 构造函数没有返回值(返回值为空所以干脆剃掉,也更好体现构造函数的特殊)
- 构造函数可以重载(健壮健壮,重载一直都很重要)
- 我们没有写构造函数的时候,系统会自动生成一个默认构造函数然后调用它(但是按C嘎嘎标准出来的默认函数很多情况下不能满足,这个是坑)
先来看前三点:
文字好抽象,直接上图
只是名字和类名相同,然后没有返回值;函数内容(定义)都和初始化函数差不多,正常写就行
屏幕剪辑的捕获时间: 2023/4/27 9:56
无参调用格式
讨论重载之前,看到上面的无参函数调用,是没有加括号的,无参调用构造不能加括号;错误示例如下:
加上括号编译器把它当成了什么?如果不能理解,那现在把 d1 换成 func ,变成 Date func(); 这是不是可以认为是一个函数声明啊
这里加了括号没有检查出问题是因为编译器把它当做了一个函数声明来看待,加括号不能被识别,所以用无参不加括号的形式区分,并且不加括号也更像是自动,
所以无参 调用构造 不加括号,这也是祖师爷亲儿子的特权
第四点函数重载
既然它能构成重载,那么就不能有重载的调用歧义还有缺省值的问题,这里用类再来复习一下 函数重载 还有 缺省 的调用歧义问题吧:
构成重载的函数,他们的参数列表应该不同,因为编译阶段经过函数名修饰规则,会把函数名和其参数列表(参数的类型)修饰成一个函数符号;如果函数的参数列表相同,会形成两个一样的函数符号,但是同一个作用域内不能有两个重名的符号,所以在编译阶段就会挂掉
如上的日期类,有可传的三个 int类型参数,如果一个形参是 (int year, int month) , 另一个形参是 (int month, int day),还是会报错,因为它们的参数列表相同(类型个数、种类等),不看你参数是什么,只与参数列表有关,不能形成两个相同的函数符号会有冲突
再加上缺省的调用歧义问题
研究它的调用歧义问题,我们锚定两个构成重载的函数来看:
- 第一种情况:函数重载理解不深刻问题
虽然给了缺省,但是两个函数的参数列表是相同的,形成的函数符号在同一个域中,会有冲突;不管缺省,这是函数重载问题,结果表现是函数重载比缺省前一个身位
但是要理解本质
- 第二种情况:布吉岛迷惑式烂尾的调用歧义问题
两个函数 不缺省的参数个数相同,一个函数后面没有再定义形参,另一个后面的参数全是缺省,这样在调用两个参数的时候就不知道调用哪个,你知道嘛?我不知道
- 第三种情况:无参和全缺省
一个无参,一个是全缺省,这是经典的缺省 调用歧义问题,一般保留全缺省
最后一点:没写构造时 自动生成 默认构造 并调用 《坑和补丁篇》
没有些构造函数时,用类创建对象时系统会自动生成一个构造函数然后去调用,看起来很好,但是这里有大坑啊
Visual Stdio 中
类中数据只有内置类型时,自动生成的默认函数不会对内置类型初始化 (编译器不同或版本不同可能会初始化)
因为当时祖师爷在设计的时候被没有让所有类型都要求默认函数自动给你初始化,认为随机值是一个标准,所以没有做要求
但是,但是,类中的数据中有自定义类型时,会对内置进行初始化,自定义类型就自动调用该自定义类型自己的构造函数
来看一下:
看到类中只有内置类型时,自动生成的自定义函数不会去初始化内置类型,都是随机值 (这里是在 Visual Stdio 中)
可以看到,有自定义类型时,创建对象时自动调用的构造函数会对自定义类型进行初始化
所以平常实现代码的时候,还是不要懒到连构造函数都不写,编译器默认生成的和你的标准不同,可能有编译器达得到你的要求,但是面试时或者公司出了问题该吗?该
还是得自己写构造,平常没特殊情况不能懒到连构造都不写吧
只有两种情况不用自己写:
- 类中的数据类型没有内置类型,只有自定义类型时
如用两个堆实现队列,或者两个队列实现堆的部分代码
- 有内置数据类型但是不用的话,好像很少很少遇到
好到这里,来重点认识默认构造
默认构造
刚刚系统自动生成的构造函数 就是一个 默认构造函数
但默认构造函数 不止是 系统自动生成的构造函数,无参的构造函数和全缺省的构造函数也是默认构造函数
为什么这样分类呢?让我们观察一下它们都有的特点(提示:重点看调用的时候):
系统自动生成的默认构造函数上面已给出,下图是无参和全缺省
无参:
全缺省:
可以观察到,系统自动生成的构造、无参和全缺省 这三个默认构造函数的特点是:调用时都可以不传参数(全缺省也可不传参虽然不唯一)
所以把构造函数归为默认构造的标准是什么?是看 创建对象自动调用构造 时,可以不用传参的构造即为默认构造
最后再次声明一下,构造函数有:全缺省的构造函数 、无参的构造函数 、系统自动生成的构造函数
默认构造的判断依据是:创建对象时是否需要传参
-
析构函数
既然初始化的问题解决啦,现在看祖师爷是怎么解决更加致命的销毁问题
祖师爷发明了析构函数来解决:
析构这小子听起来就和构造有很大关系,没错,祖师爷把 析构函数的函数名 搞成和 构造函数名字 一模一样,没错,都是类名
但是怎么区分 构造函数 和 析构函数 呢,我们可爱的祖师爷脑洞很大 —— 原本在 C 中的 " ~ " 表示取反,按位取反(如下以类 Stack 为例)
构造函数: Stack() { ··· }
析构函数:~Stack() { ··· }
好,对的没错,祖师爷把它放到了 类名前面 用来区分 构造 和 析构,哇,不行,祖师爷的脑洞太大了,好可爱,请允许我刷一啵祖师爷的照片
(好可爱,芜湖)
Stack() { ··· }
~Stack() { ··· }
反正好区分了,看着也简洁舒服
来具体看一下析构函数的特点吧:
- 和构造函数类似(针对对象),行为相反,析构函数是在对象的栈帧销毁前自动调用(针对的是每一个对象)
- 和构造函数一样,名字与类名相同,只是前面多了一个 " ~ " 用来区分
- 和构造函数一样,没有返回值
- 构造函数可能有参数,但是析构函数没有参数的
- 构造函数可以重载,析构函数不可以重载(都没有参数列表)(也省去了重载缺省的调用歧义问题)
- 和构造函数一样,没写析构函数时系统会自动生成一个析构函数 在对象要销毁时去调用它(默认生成的析构对于对象中内置类型不做处理,不管有没有动态;对于自定义类型是去调用它自己的析构)
一下是代码示例,体现了2 - 5 点:
理解析构重要特点前,必须了解一些前置知识:
析构函数是销毁函数,它销毁的是什么?
思考 该对象在栈区的变量、数组等 需不需要去销毁?它是销毁不了的,只能说赋值;但是赋值的话有咩有必要?弄清楚现在是处在生命周期要结束的时候,待会函数栈帧销毁,该作用域的栈区所有内容 都会还给操作系统;意思是等下不能用也拿不到那些 栈区中的那些变量和数组等东西,那给他们赋值有用吗,没用 —— 只能说让他们死得体面一点
那我们是去销毁什么呢?
栈区中的内容不用我们去销毁,但是堆区申请的动态空间是不是还在?如果不销毁是不是内存泄漏啦
所以我们析构函数主要的目的是归还动态存储空间给操作系统,去销毁堆区中的内容
第一点的具体细节需要理解清楚:
析构函数是在对象销毁前去调用它的析构,注意已经开始了销毁动作,该生命周期中的内容已经在慢慢还给操作系统了,当遇到是类的对象时,会去调用它的析构函数,而不是结束时一起调用,而是有多少个对象,就会去调用多少次析构函数
现在理解了:析构是在 对象销毁前,自动调用 该对象 的析构
还有第 6 点:没写析构 自动生成析构
和构造函数一样,析构函数在没自己实现的情况下,系统会自动生成一个析构函数
但是系统自动生成的一般有缺漏:
和构造函数一样:系统自动生成的析构不会去释放内置类型,自定义类型调用它们自己的析构
要清楚在对象中 我们申请动态空间很多都是内置类型的指针,去存储动态空间首地址( int (*) [] 、int* 等),这些是内置类型,自动生成的析构函数不会去释放它们,妥妥内存泄漏,所以写析构一定要慎重,不能随便不写
可以不写析构的几种情形(不推荐,内存泄漏不好搞哦)
- 对象中只有自定义类型时
- 对象中内置类型没有申请动态内存空间(内置类型没有要释放的)
-
拷贝构造
先讲C语言拷贝的原理:C语言默认的只有值拷贝(浅拷贝),浅拷贝指的是只把对象的值一个字节一个字节的全部拷贝过去,全部照搬;C语言默认的只有说浅拷贝
再讲在C嘎嘎中如果默认只有浅拷贝的一些问题:那对象中如果有动态内存空间(如 int* _p),把这个对象拷贝给另一个对象时,如果只有浅拷贝 一个字节一个字节全部照搬,另一个对象中的内容与原对象全部相同(包括这个新对象的 int* _p),那另一个对象的动态内存空间也和原对象是同一块内存空间(两个对象的 _p 指向堆区的同一块空间)
然而这并不是我们的想要的,我们要的拷贝只是内容一样,但是两个内容相互独立,不会相互影响到;而这里是指向了同一块动态内存空间,会相互影响,并且会出很多意想不到的乱子,比如一块内存空间释放了两次(这种浅拷贝和析构的运行原理有冲突),就会报错
所以有了拷贝构造,每次遇到类的对象要拷贝时,就会调用对应的拷贝构造(所有的要拷贝的地方都会调用,记住这句话),这是祖师爷给的解决办法
再说一遍:所有的要拷贝的地方都会调用,理解这句重要的话(如每次对象传参时)
再细说拷贝构造函数的参数必须是引用类型,不然会被编译器检查出错误,为什么要检查这个,因为不检查会导致无限递归。哎呦~编辑器真好
系统自动生成的默认拷贝构造:
- 对内置类型进行浅拷贝(一个一个字节照搬,内置类型中有申请动态内存空间就会出错);
- 对于自定义类型自动调用它对应的拷贝构造(汇编层面就是识别并且生成对应的汇编代码)
自己写拷贝构造函数时,函数的参数除了注意检查是不是引用,最好把传过来的源对象加上 const 进行修饰,以免有人对 this 指针或 传参规则 不明确,给拷贝反了
总结:可以这么说:拷贝构造适配于地址,不适配于空间,在类的对象的拷贝中,所有空间上的拷贝都要转化成地址来配合拷贝构造,可以用引用,可以用指针
运算符重载
内置类型是祖师爷定义的,赋值=、或 加减乘除 祖师爷自己知道这些运算符应该进行怎么样的操作,平常给我们直接用就好;但是自定义类型是我们自己定义的,可能有动态空间地址的内置类型,这些我们才知道,所以自定义类型我们才知道怎么来加减乘除,赋值等操作,祖师爷很好,规定运算符(+、-、* 等)可以重载,让我们自己去实现对象之间的运算符操作
内置类型有它们的运算符规则(祖师爷自己设计);我们的自定义出的类型我们才知道怎么来加减等运算符操作,所以自定义类型我们重载自己想的运算符,去实现自己的 类的对象 的操作符运算
基本格式:
函数返回值 (类访问限定)operator运算符( 参数列表 )
等号操作符重载
介绍完再说等号赋值操作符是类中的一个默认成员函数。所以不能写在类外面,必须是成员函数;其他运算符重载函数可以写在类中(构成成员函数),也可以写在类外面(使用 “有缘”,但是不推荐,因为“有缘”会破坏类的封装的特性)
自动生成的默认赋值运算符重载函数也是一样,对内置类型进行浅拷贝(有动态内存空间就不行,不然两对象会拥有同一块动态空间),自定义类型调用它们自己的赋值运算符重载函数
学操作符顺便把复用这个长期技巧学习一下,操作符重载很多地方也可以用到复用,爽歪歪
比如:模拟日期类的比较日期类大小的判断性质比大小,只需要实现大于(或小于)和 == ,在 >= 、<=、!=、< 等都可以复用原先的两个基本运算符(用 >和== 来完成 >=、<、<=、!=)
讲两个概念
- 什么是构造(拷贝构造)。很多地方也叫拷贝构造,标准是:一个新创建的对象被另一个对象初始化
- 什么是赋值。标准是:两个已经存在的对象,进行赋值的操作
不一样在于对象:一个是新创建的被初始化;一个是两个已经存在的对象进行赋值
为什么将这个?因为编译器是通过这个标准来决定语句生成对应的汇编代码,编译器不管你的实现是怎么写的:
etc.
类名 对象2 = 对象1; //其中对象1已创建好,现创建一个对象2
看起来用了 = 赋值运算符重载,但是发现汇编调用的是拷贝构造。这里不管有没有定义=赋值运算符重载函数,都只会去调用拷贝构造函数
原因就是:编译器是按那两个标准去生成汇编代码。这里是创建了一个新对象 对象2 ,将 对象1 的内容给对象2 去初始化,所以在这里判断为是拷贝构造,虽然我们用了 =,而没有去调用 =赋值运算符重载函数
编译器看的也是对象
再讲一下前置和后置(前置++ 和 后置++ ,减减同理的,具体讲++)
前置后置都是用的++,怎么来具象化区别呢?
先来看到内置类型的 前置++ 和 后置++ 的具体,库里面(对应到内置类型)实现前置和后置是通过返回值来实现前置和后置的效果和区别的
前置 和 后置 都是有副作用的,所以在函数定义内部,对于本体都是要进行自增操作的,但是返回值不同有不同效果:
- 直接返回本体,让本体返回到原语句中,再去执行相关操作,这就达到了前置的效果( arr[++i] = 1; )
- 返回自增前本体的拷贝,返回到原语句中,去执行相关操作,达到了后置的效果 ( arr[i++] = 1; )
因为返回值不同,达到了前置和后置的效果,并且区分出了前置和后置
知道了它的原理,再来看看怎么区分++:因为前置后置用的都是++,运算符重载都是用的++,但是怎么来区分前置后置,不可能说用一模一样的函数名,必须去区分一下
别担心,祖师爷规定好啦:
首先肯定是用 函数重载 来实现前置和后置,那参数列表呢?不能一样,祖师爷就规定:后置的那个函数参数列表多一个 int 来占位置
前置:operator++()
后置:operator++(int)
这里的int只是来占位,为了形成不同的函数符号,用来区分是前置还是后置(右移保留了 int,才知道你用的是(九转)后置)
所以自己实现后置的重载函数的时候,直接在参数列表中塞一个int就好了,编译器自然懂你意思(int ==== 暗号)
塞好int后,不用担心自己的重载函数不给你区分前置后置,编译器看到int暗号自然知道这是后置的,你直接给对象用前置后置++就好了
前置后置的 - - (自减)也是同理的
这里回来了,来继续探讨爽歪歪的复用(偷懒。bushi,学习人的事怎么能叫偷懒呢)
现在训练:实现一个日期类(巧用复用,嘻嘻嘻)
Date.h
Date.cpp: