四、类的访问限定符及封装【⭐】
学习了上面的这些,你只是初步了解了什么是类,但是C++中的类远远不止将
struct
换成class
这简单,如果你自己观察的话,可以发现我在上面的Date类中加了【public:】和【private:】这两个东西,它们就叫做类的访问限定符
1、C++中的三类访问限定符
- 正式来说一说C++中的三类访问限定符【public】【protected】和【private】
- 其中,对于
public
来说只的是共有,表示从当前public到收括号};
为止的所有成员变量或者是成员函数均为公有的,什么是公有呢?就是类内类外都可以随意调用访问,不受限制 private
指的就是私有,这很直观,对于共有来说就是所有东西都是公开的,无论是谁都可以访问;那对于私有来说便是无法访问,谁无法访问呢?这里指的是外界无法访问,但类内还是可以访问的,例如就是类内的成员函数访问这些成员变量是不受限制的protected
指的是保护,代表它会将类内的这些东西保护起来,外界无法访问到。但对于这个限定来说暂时可以把它当成和private
是类同的,到了C++中的【多态】才会出现差异
- 光就上面这么说你一定会觉得有些抽象,其实读者可以将这个访问限定符看作是一把【锁】🔒,设想你家里装了一把锁,那么此时锁住的就是外面的人,对家里的人是不会有影响的
接下去再来看看有关访问限定符的一些特性指南
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到 } 即类结束。
- class的默认访问权限为private,struct为public(因为struct要兼容C)
- 主要还是来看一下这个第5点,在C语言中,我们在结构体中直接定义出一个成员变量的时候不需要去考虑是否可以访问到,而是直接就这访问了;但是在C++中,我们在访问类中的一个成员变量的时候,是会受到限制的,我们可以来看看
- 可以看出,即使我将类中的
private
去掉的话,还是会存在【不可访问】的现象,原因就是在于类内的定义的内容默认访问权限都是private
,外界是无法访问到的
但一定会有同学有这么一个疑问,那在加上
[private]
关键字后,这个成员变量也是私有的呀,为什么可以对他们去进行一个初始化呢?那不是访问到了这些成员变量了
- 这一点要注意,当我在初始化的时候,并没有直接去访问类内的【成员变量】,而是调用了【成员函数】,在成员函数的内部又调用了类内的【成员变量】,上面有说到过,对于私有的东西虽然类外是不可访问的,但类内还是可以访问的,这个🔒只是锁住了外来入侵者🗡,自己家里的人还是不受限制的
对于上面这一点来说,其实就又一些C++中类的封装思想了,接下去我们就正式来谈谈面向对象的三大特性之一 —— 【封装】
2、初探类的封装👈
【封装思想】:用类将对象的属性(数据)与操作数据的方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用
- ==封装本质上是一种管理,让用户更方便使用类==。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件
- 设想若是没有将电脑中的一些元件给封装起来,就是将内部的一些部件展现在用户的眼前,那么用户每次在将电脑开始的时候都需要那边点一下,这边开个开关,使用起来就会很麻烦,所以可以看出,对于电脑来说,也是存在这么一个封装的思想【==很好地将内部的细节给屏蔽起来了,方便管理==】
这里先初步地讲一下有关【类的封装】思想,文章的后半部分会不断地加强读者对这块的理解
五、类的实例化
当我们写好一个类之后,就要去把它给定义出来,就好比在C语言中,我们要使用一个变量的话,也是要把它定义出来才行,才可以使用,例如:结构体声明好了之后就要将其定义出来,否则是不用的
1、变量的声明与定义 - - 铁瓷还会铁吗?
- 首先我要问你一个问题,下面的这三个成员变量是已经被定义出来了?还是只是声明呢?
- 读者一定要理解【声明】和【定义】的区别,对于声明来说,只是告诉编译器存在这么一个东西,但它不是实际存在于内存中的;而对于定义来说就是==实实在在地把空间给开出来==,那么此时在内存中就有它的一席之地了
可能就这么说太好理解,我们通过一个形象一点的案例来说明💬
- 你呢,背井离乡在二线城市当一个程序员💻,工作了几年也赚了不少钱,此时你就想把一直以来的出租屋换成一个崭新的房子,想要在你所处的城市买个房,虽然交不起所有的钱,但首付还是可以的,不过呢还差那么几万块钱,于是呢就想到了你大学时候的室友,也是个铁瓷很要好的朋友,想找他结点钱💴
- 于是就打电话过去说:“兄弟呀,我最近想买个房,交个首付,不过手头上还差个几万块钱,你看你有没有一些不急着用的先借我点,之后赚来了再还给你。”那听到昔日的好友这么一番话,便说:“可以可以,好兄弟开口,那必须帮忙!”于是呢他就这么答应你了,不过也只是口头答应,也就是一种==承诺==。这个口头答应其实指得就是【声明】,你只能知道会有这么一笔钱给到你,但是这笔钱还没真正到你的手里
- 不过呢,过了好几天了,还是不见兄弟把钱打过来,眼看就要交首付了,只能再给他打一个电话过去说:“兄弟,上次和你说的那个钱怎么样了,后天就要交首付了,你看能不能先打过来。”当你说完这句话之后,其实就会出现两种情况Ⅱ
- 你的兄弟回道:“哦哦,不好意思,最后手头太忙可了,都给忘了,马上给你转过来。”此时就听到【
支付宝到账5万元
】的声音,那么这笔钱就真正地到你手里的,这是实实在在的,已经存在了的事,指的就是【定义】 - 你的兄弟回道:“啊呀,这个,真是不好意思啊,家里的钱都给媳妇管着呢😪,它不同意我也办法,对不住了兄弟,要不你再找找别人。”于是他便小心翼翼地挂掉了电话,你俩就没有再联系过,铁瓷也不铁了~
- 对于上面的第二种情况,就很像平常在写程序的时候出现链接错误的情况,那就是【==声明了但是未定义==】的这种行为。之前承诺了、声明了,但是找你要的时候实际却没有
- 对于函数而言就是有声明找不到定义
- 对于变量而言就是这个变量没开空间
- 所以对于这三个成员变量来说只是一个声明,不是定义,并没有开出实际的空间
那怎样才算定义呢?又是何时开出空间,让我们来瞧瞧【类对象的声明与定义】👇
2、类对象的声明与定义 - - 别墅设计图🏠
- 要实际地开出空间来,其实值得就是要将这个类定义出来,因为你的成员变量是声明在类里面的,那你把写好的这个类定义出来后,【成员变量】也就对应的在内存中开出了一块空间,它们是作为一个整体来进行定义的
int main(void) { Date d; //类对象的实例化 return 0; }
==用类类型创建对象的过程,称为类的实例化==
- 类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;
- 比如:入学时填写的【学生信息表】:bar_chart:,表格就可以看成是一个类,来描述具体学生信息
- 对于类来说就像是谜语一样,对谜底来进行描述,谜底就是谜语的一个实例。例如谜语:"年纪不大,胡子一把,主人来了,就喊妈妈“。这只是一个【描述】,但是实际要知道这个,谜语在描述写什么,这个类里面有什么东西,想要传达出什么,就要将它实例化出来,定义出来,那么谜底也就揭晓了 👉谜底:山羊
- 一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量
- 这个又怎么去理解呢?这里给读者举一个形象的例子:不知道你是否了解一个建筑物是如何从设计到建成的,重要要经过很多的步骤,但是在一开始建筑师一定是要设计出一张【设计图】来,好对这个房子的整体样式和架构先有一个大致的轮廓,在后面才可以一步一步地慢慢实施建设计划。
- 那其实对于这个类来说就和【设计图】是一样的,比方说现在我们要造一栋别墅🏠,那么一张图纸:bookmark_tabs:,即一个类中描述的就是这个别墅有几层,多少个房间,门朝哪儿开,是一个大致的框架,不过呢这也就仅仅是一个设计图罢了,还不是一个真实的别墅,不存在具体的空间,因此是不能住人的🛏
- 那要怎样才能住人呢?也就是建筑师通过这张设计图,找几个施工队真实地将别墅设计出来,那才可以真正地住人 - 但平常我们看到的那种高档小区中,可不止一栋这样的别墅,而是有几十栋,难道设计师也要画出几十张设计图才能建完这些别墅吗?当然不是,对于一张设计图来说是可以建造出无数的别墅,只要根据这个设计图来建就行。那上面说到对于设计图来说就是一个类,也就意味着一个类也是可以实例化出多个对象的🐘🐘🐘- 实例化出这个对象后也就实实在在地将空间给开出来了,那我们上面说到过的【成员变量】,此时也开出了空间,就可以存放对应的数据了
Date d; d.year = 2023; d.month = 3; d.day = 18;
- 但对于下面这种形式去初始化成员变量是不行的,若是还没有定义出一个对象的,成员变量不存在实际空间的,直接用类名去进行访问就会出错,不过后面的文章中我会讲到一种叫做静态成员变量,用
static
进行修饰,是可以的直接使用类名来进行访问的
六、类对象模型
1、成员函数是否存在重复定义?
- 上面,我们说到了对于一个成员变量来说,若是类没有被定义出来的话它是不存在具体空间的,那在一个类中除了成员变量外,还有【成员函数】,仔细观察可以发现,这似乎就已经把成员函数定义出来了呀,那空间不是已经存在了。 此时是外面再去实例化这个类的话,岂不是会造成重复定义了?
- 可是刚才我们有看过,在实例化出这个Date类的对象时,并没有报出重复定义这个错误,而且在调用这个
Init()
和Print()
函数的时候也不会有什么问题,这是为何呢?==难道这个【成员函数】和类没什么关系吗?它存在于类中吗?==
让我们带着这个疑问开始本模块的学习
2、计算类的大小【结构体内存对齐】
要想知道这个类中存在多少东西,其实我们去计算一个它的大小即可
- 还记得结构体内存对齐吗?忘记了就再去看看,下面是对应的规则👇
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的对齐数为8 3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。 4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
- 在C语言中,我们有去计算过一个结构体的大小,那上面我们在对于结构体和类做对比的时候说到对于
struct
和class
都可以去定义一个类,那么结构体内存对齐的规则也一样适用。不过我们只会计算成员变量的大小,那就来先计算一下这个【year】、【month】、【day】的大小
- 通过画图以及运行结果可以观察,可以得出类的大小和我们计算的【成员变量】大小竟然是一致的,那【成员函数】呢?没有算上去吗?还是根本不计算在内?
3、探究类对象的存储方式🔍
在看了上面惊人的一幕后,我们就来思考一下,对于这个类对象究竟是如何进行存储的。在下面,我给出了类对象存储方式的三种设计方式,你认为哪一种设计方式是正确的呢?
- 首先是第一种,也就是将类的【成员变量】和【成员函数】都存放在一起,其实对于每个类的成员变量来说都是不一样的,都有它们不同的空间,可调用的是同一份函数。,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。那么如何解决呢?
- 下面是第二种设计方式。代码只保存一份,在对象中保存存放代码的地址,这种方式似乎看起来不错,你认为可行吗?
- 再来看看第三射击方案。可以看到对于【成员变量】和【成员函数】完全就是分离了,存在了一个叫做公共代码区的地方,类的所有函数都放在一个类函数成员表中
- 对于每一个对象来说都是互相独立的,里面只存放了各自的成员变量,而要找成员函数的话就要通过当前这个类的对象去公共代码区进行调用
- 答案揭晓,那就是最后这一种,实现了成员与函数的分离,为什么要这么设计呢?上面其实有提到过,虽然每个对象的成员变量是不同的,各自各自的空间,但是对于成员函数来说,大家都是一样的,例如这个
Init()
函数,外界被定义出来的对象只需要调用一下这个函数去初始它自己的成员变量即可,不需要将其放在自己的类内。 - 设想若是每个类中都写一个这样相同函数的话,此时每个对象就会变得非常庞大,也就是我不去调用这个函数,只是将对象定义出来函数的空间就已经会存在了,这样的设计其实是不好的,所以我们应该采取第三种做法
感性理解:私有场所与共有场所
但是这么说一定是比较抽象了,我们再通过一个生活小案例来理解一下
- 就用刚才说到的这个别墅小区好了,那在每栋别墅里面都是房间的,像客厅、卧室、厨房、洗手间,每家每户基本都有,但是呢每一家都有它们自己家庭的设计,既然是个人别墅,那么一定不可能每栋房子的客厅、卧室、厨房、洗手间都在同一个位置吧,那就太单调了╮(╯▽╰)╭,这些房间呢值得就是【成员变量】
- 那在一个小区中,除了挨家挨户的的私人领域外,一定会存在公共区域,在这些公共区域中,会有一些公共场所,例如像篮球场、咖啡馆、游泳馆、小卖部或是健身器材等等,对于这个公共区域你可以理解为【公共代码区】,而对于这些公共场所、设施你可以理解为【成员函数】
- 那其实这就很形象了,【成员变量】是每个对象各自的,由于类的封装特性别人无法轻易访问,可是呢对于这个【成员函数】来说,是大家共有的,可以一起使用,所以不需要放在自己家里,除非你真的很有钱,在一个别墅小区再自己建一些私人的游泳池、篮球场和小卖部👈
4、空类大小计算【面试考点✒】
- 学习了如何去计算一个类之后,接下去请你来判别一下下面三个类的大小分别为多少
// 类中既有成员变量,又有成员函数 class A1 { void f1() {} private: int a; }; // 类中仅有成员函数 class A2 { void f1(){} }; // 类中什么都没有---空类 class A3 {};
- 首先是类
A1
,有一个成员变量,那经过上面的学习可以得知成员函数是不存在于类中,又因为整型占4个字节,所以很容易可以得知A3的大小为4 - 接下去对于类
A2
,只有一个成员函数f1()
,没有成员变量,那【sizeof(A2)】的结果会是多少呢?一会看运行结果后再来分析 - 接下去是类
A3
,对于这个类来说既没有成员函数也没有成员变量,那它的大小会是多少呢?0吗?
我们来看一下运行结果
- 通过观察可以得知,似乎只算对了第一个类A1的大小,但是前两个类的大小为什么都是1呢?这相信读者也是非常疑惑吧?立马为你来解答👇
- 一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐。但是对于空类的大小却不太一样,空类比较特殊,编译器给了空类==一个字节来唯一标识这个类的对象==【这1B不存储有效数据,为一个占位符,标识对象被实例化定义出来了】
上面的这个概念在笔试面试中都有可能会涉及,准备校招的同学要重视
七、this指针【⭐重点掌握⭐】
1、提问:如何区分当前初始化对象?
- 继续来回顾一下上面所写的Date日期类,有三个成员变量和两个成员函数
class Date { public: //定义 void Init(int year, int month, int day) { _year = year; _year = month; _year = day; } void Print() { cout << "year:" << _year << endl; cout << "month:" << _year << endl; cout << "day:" << _year << endl; } private: int _year; //仅仅是声明,并没有开出实际的空间 int _month; int _day; };
- 那现在我定义出一个变量后开始传递数据,然后初始化
d1
里面的【year】【month】【day】,然后在内部Init()函数中使用_year = year
这样的方式来进行初始化,此时右边的[year]
是外部传递进来的2023,[_year]
是内部的成员变量,但是仔细回忆一下,刚才我们有说到这个[_year]
只是类内部声明的,并没有被定义出来呀,那要怎么赋值初始化呢? - 有同学说:外面不是定义出这个对象d1了,那么三个成员变量的空间自然也就开出来了,是的,这没错
Date d1; d1.Init(2023, 3, 18);
- 可是现在呢,我又定义了一个对象,此时就会存在两个对象d1和d2,然后分别去调用这个Init()函数来初始化自己的成员变量,那外部传入实参的时候是可以分清的,但是传入到内部时
_year = year
中的[_year]
要怎么区分这是d1还是d2的成员变量呢?若有又定义了一个d3呢?如何做到精准赋值无误? - 在外部定义出来的对象调用的时候可以很明确是哪个对象调的,但是到了函数内部又是辨别的呢?对于成员函数来说存放在公共代码区,大家都可以调用,那即使调用了也没有传入当前对象的地址呀,韩素红内部怎么知道要给哪个对象初始化成员变量呢?
好,就让我们带着这些问题,进入下一小节的学习:book:
Date d1; Date d2; d1.Init(2023, 3, 18); d2.Init(2024, 3, 18);
2、深度探究this指针的各种特性【原理分析】
面对上面情况,其实就可以使用到C++中的
this指针
了,这个我在上面有提过一嘴,还有印象吗
- 上面讲了这么多不知读者是否关注到我说的一点:外界无法传入当前对象的地址给到被调用的成员函数
- 那我现在要说的是,其实这件事情是做了的,当这个成员函数被调用的时候,编译器就会自动给在这个函数的最前面加上一个形参,他就是专属于当前类的一个指针,就是
this指针
//void Init(int year, int month, int day) void Init(Date* this, int year, int month, int day)
- 那么形参部分改变了,实参也需要修改,那要传递什么呢?没错,就是当前对象的地址
//d1.Init(2023, 3, 18); d1.Init(&d1, 2023, 3, 18);
- 那么当this接受了当前对象的地址之后,编译器就将代码转换成了下面这种形式,【this】在英文单词中指的就是当前,那么意思就很明确了,为当前对象的
year
、month
和day
进行初始化。随着每次的传入的对象地址不同,this指针就会通过不同的地址去找到内存中对应的那块地址中的成员变量,进行精准赋值
- 不过通过观察可以发现,似乎我们自己去加上这一个参数好像是行不通,编译器报出了很多错误,
看看下面这段话就知道为什么了👇
- C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即==用户不需要来传递,编译器自动完成==
- 所以这个this指针我们是不可以加的,编译器会自动帮我们加上,并且传递当前对象的地址
不过,虽然我们不能传递观察,但可以通过这个隐藏的this指针来看看是否真的传递了当前对象的地址进去
了解了this指针的基本原理后,我们来聊聊它的相关特性
- this指针的类型:类类型* const(
Date* const
),即成员函数中,不能给this指针赋值
- 对于this指针来说,是被
const
常所修饰的,为【指针常量】,对于指针本身的指向是不可修改的,但是指针所指向的内容可以通过解引用的方式来修改。如果不是很清楚这一块可以看看常量指针与指针常量的感性理解
- 只能在“成员函数”的内部使用
- 这一点要牢记,对于this指针而言,只可以在类的成员函数内部进行使用,是不可以在外部进行使用的,因为它是作为一个成员函数的形参,若是没有传递给当前对象地址的话,那么它的指向是不确定的,但当进入成员函数内部时,编译器底层一定调用了这个this指针,为其传递了对象的地址,此时在内部再去使用的话是不会有问题的
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
- 这一点上面也有强调过,this指针是作为形参来使用,那对于函数形参,我们在函数栈帧一文有深入研究过它是需要进行压栈的,那就要建立函数栈帧,可以很明确它就是存放在栈区的,而不是存放在对象中,这一点下面有一道面试题也是涉及到,再做细讲
- 而且刚才在求解类的大小时,通过结构体内存对齐可以很明确地看出除了【成员变量】之外的其他的东西都是不计算在内的
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
- 这一点我们可以通过汇编指令来看:computer:
- 可以观察到,传递进函数
Init()
的参数都会被压入栈中,不过可以观察到,由于栈【先进后出】的性质,是从第4个参数开始压栈的,若是按照原本的三个参数来说应该会压三次,但是看到2023被Push
进去之后还有一个[d1]需要被lea(load effective address)
进去,不过并不是直接加载,而是放到一个寄存器ecx中再加载,这个d1指的其实就是对象d1的地址 - 通过汇编指令可以把底层的逻辑看得很清楚,观察到确实是存在this指针接受当前调用对象的地址
3、this指针的感性理解
说到了这么多有关this指针的特性,有些特性对大家来说可能还是比较难以理解,接下去我会通过三个生活中的小场景带你好好理解一下😄
- 夏天到了,呆在家里一定会很热,一天到晚打空调对身体又不好,此时就会想到去游泳馆游泳,那去游泳的话肯定要换上专门的衣物,去哪里换呢?当然是更衣室了,有过经历的同学一定知道当你去更衣室换衣服的时候,前台就会给你一个手环,可以识别感应里面的柜子,一个人一个柜子可以放置自己的私人物品。然后就把这个手环套在你的手上,最后当你游完泳后要怎么知道那个是你的柜子呢?那是通过这个手环来进行感应打开柜门取出自己的衣物【这个手环就是用来识别的,别人的手环打不开你的柜子】
- 在大学生活中,每个人一定都有自己的校园卡,这张校园卡呢可以用来吃饭、洗澡、接水,甚至可以代替人脸识别,所以在这个校园中,校园卡就是你的身份象征,每个人都是唯一的
- 住过小区的一定都知道,现在的小区管理制度是越来越严了,出入呢都需要这个门禁卡,才可以证明你的身份,是属于这个小区的,防止外来人员入室盗窃,所以这个门禁卡就是你身份的象征【有没带门禁进不去单元门的吗🐕】
通过上面的三个生活小案例,相信你对this指针一定有有了自己的理解