🐱作者:一只大喵咪1201
🐱专栏:《C++学习》
🔥格言:你只管努力,剩下的交给时间!
今天开始,我们就开始刷副本了,进行打怪升级,这第一个副本就是类与对象,首先我们要干的就是了解类和对象是什么,还有一些与类和对象相关的基础知识。
面向过程:
在写C程序的时候是不是有种感觉,细节好多啊,细节上稍微出一点错最后运行结果就会出错,需要我们一步的构思好。
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。

就像上图中的洗衣服过程,需要确定每一步都要做什么,这就是面向过程,C语言就是面向过程的语言。
面向对象:
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

同样是洗衣服,
- 以面向对象的思想来看:
- 一共有四个对象:人,衣服,洗衣机,洗衣粉
- 整个洗衣服的过程:人将衣服放进洗衣机,倒入洗衣液,启动洗衣机,然后洗衣机就会完成洗衣并且甩干
- 整个过程:人、衣服、洗衣粉、洗衣机四个对象之间交互完成的,人不需要关注洗衣机的具体工作过程
面向对象是不是很爽?我们都不用关心实现的具体细节,只需让涉及到的几个对象进行交互即可。
- C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。
比如:之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现,会发现struct中也可以定义函数。

如上图中的代码,在结构体中定义了栈的一系列函数,并且定义了和栈相关的一些变量。
在C语言中,结构体中只能定义蓝色蓝色框中的成员变量,而不能在结构体中定义红色框中的成员函数。
- 但是在C++中,成员变量和成员函数可以在同一个结构体中定义。
- 这样一个包含成员函数和成员变量的结构体我们称之为类。
- 在类中,成员变量又可以被叫做类的属性,成员函数也可以被叫做类的方法。
说明:
上面代码中我们就成功的创建了一个类,接下来就是怎么使用这个类了,看代码:
struct Stack
{
//栈初始化
void Init(int capacity = 5)
{
_arr = (DataType*)malloc(capacity * sizeof(DataType));
if (_arr == nullptr)
{
perror("malloc fail");
}
_top = 0;
_capacity = capacity;
}
//压栈
void Push(DataType data)
{
_arr[_top] = data;
_top++;
}
//.......
DataType* _arr;
int _top;
int _capacity;
};
int main()
{
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
s.Push(5);
//.......
cout << s._top<< endl;
return 0;
}

- 用类创建的变量在C++中被称为对象,也就是程序中的变量s,我们把它叫做对象s。
- 在调用类的方法(成员函数)的时候,就像C语言中访问结构体变量那样,用点操作符来调用,传参什么的和正常调用函数是一样的。
- 访问类的属性的时候,还是和C语言是一样的,因为C++是兼容C语言的。
- 细心的小伙伴可能发现了一个点,这里直接使用结构体的标签来创建的类,这在C语言中是不允许的:
红色框中的是C语言中的写法,绿色框中的是C++中的写法,但是如果C语言中采用C++这样的写法是结对不允许的。
C++在这个方面也是进行了一定的简化,也不用我们再用typedef去给结构体变量重命名为一个使用比较方便的一个短的类型名,直接使用标签就可以。
通过上面的描述,已经将类这一概念成功的建立起来了,但是此时有小伙伴就有疑问了,为什么我平时见到的类的关键字是class?不是struct呢?
- 类是在C语言结构体的基础上扩展得到的,也是为了弥补C语言的一些不足,为了兼容C语言,类的关键字可以使用struct。
- 但是在C++中,我们更喜欢用class关键字来表示类,它和struct的作用是相同的,都是创建一个类,当然存在一些区别,我们后面讲解。
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
上面就是类的定义,再啰嗦一遍:
- class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。
- 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
类的定义有俩中方式:
class Person
{
public:
//显示基本信息
void ShowInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
char* _name;
char* _sex;
int _age;
};
上面代码中,声明和定义就都是放在类体重的。
注意:
成员函数如果定义在类中,编译器可能会将其当作内联函数处理,具体是否当作内联函数还要看代码的长度,和普通的内联函数是一样的,就是相当于在类体的函数定义前面加了一个inline关键字。

如上图中,将类的定义是放在头文件中的,类的方法只有一个声明,类的属性也是定义在这里的,类的方法的具体实现是在cpp文件中的。
注意:
在cpp文件中定义的类方法,其中函数名的前面必须使用域作用限制符::,域作用限制符前面要加类名,来明确这个函数是哪个类中的定义。
下面举一个例子:

看上面的代码,我们定义了俩个类,都是在头文件中,一个类表示的一个人,另一个类表示的是一个学生。
我们需要定义俩个函数,分别将这俩个类的信息打印出来,但是对于一个人和一个学生,因为它们的身份不同,我们需要获取的信息也不一样。
但是俩个类的打印函数的名字都是ShowInfo,我们在.cpp文件中定义了这俩个打印函数,里面的内容是不一样的。
编译器在执行的时候就凌乱了,它发现.cpp中有俩个ShowInfo函数,到底哪个函数属于哪个类啊,它就不知道了。
- 为了避免这种混乱的情况,我们在.cpp中定义函数的时候,就要在函数名前面加数类名以及域作用限制符::
- 告诉编译器,哪个函数是属于哪个类中的,确定它们的所属关系。
所以,在.cpp中的代码就是:

你学废了吗?答:学废了。我们继续进入下一个内容。
类的封装:
在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?
- 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。
比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
- 在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
类的作用域:
- 类定义了一个新的作用域,类的所有成员都在类的作用域中。
- 在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。
这里类的作用域和我们前面讲到的命名空间的作用域是一样的,只是里面的内容不同而已。
这算是一个小插曲,同时也是对前面类中成员函数定义时要明确它所属类的一个补充。
访问限定符:
C++实现封装的方式:
- 用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。

C++中的访问限定符有三种,即public(公有),protected(保护),private(私有)。
访问限定符说明:
下面通过一个例子来说明这些问题:

看上面的代码:
- public和private之间(红色框)的内容是类方法,也就是成员函数,它们的访问权限是公有的,这部分是可以在类域外被访问的,上图中右侧的主函数就是类域外,在类域外可以正常的调用这些函数。
- private和结束括号}之间(绿色框)的内容是类属性,也就是成员变量,它们的访问权限是私有的,也就是只能在类域内使用,只能在类中的函数定义中去使用,主函数中的域外是无法访问这些成员变量的。
这里我们不区分protected和private的区别,认为它们的作用是一样,在学习到继承的时候再详细介绍它们的区别。- 上面类中,第一个访问限定符是public,它后面的内容是公有的,直到遇到了第二个访问限定符private后,private后面的内容就成了私有的
- 因为private后面再没有访问限定符了,所以从private到类的结束},全部都是私有的。
注意:
在开篇的时候,本喵用struct创建的类,里面没有写任何的访问限定符,但是我们在类域外,也就是主函数中是可以访问类中的任何内容的。但是使用class就不能这样,因为它们是有区别的。
使用struct和public创建类的区别:
- struct创建的类中,默认访问权限是public(共有的),所以在没有访问操作符的情况下域外可以随意访问
- class创建的类中,默认的访问权限是private(私有的),所以域外要想访问类中的成员,必须在类中对应成员处加上访问限定符public来改变它的访问权限。
访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
现在我们知道了封装是什么,以及使用访问限定符对类中的内容进行封装,但是为什么会封装呢?
答案是:因为有老六。

上图是我们用C语言实现的栈的头文件,它定义类栈的结构体,以及各种接口函数的声明。
我们可以看到,此时各种接口函数是独立的,并且和结构体中的成员变量也是独立的,我们可以随意访问任何一个变量,或者任何一个函数。
我们习惯上,对栈中成员变量进行操作的时候,都会通过接口函数去操作,并不会之间操作结构体中的成员变量,因为会出现很多错误,就像上面的取栈顶元素,不是函数的实现者你就不知道栈顶是最后一个元素还是最后一个元素的下一个元素。
如果都是有素质的人,那么相安无事,都是用接口函数来访问结构体中的成员变量,但是肯定会有老六,就像在景点乱图乱画的人。

所以在C++中,我们将类中的成员变量封装起来,不让类域外进行访问,只提供成员函数给域外使用。
这样一来,那些老六也只能通过提供的接口函数来操作类中的成员变量,就不会造成不好的影响。
直接上代码:

可以这样直接访问类中的成员吗?答案是不可以的。
在C语言中我们知道,结构体struct创建的是一个变量的类型,就是相当于一个图纸,在类中也是,类也是相当于一张图纸,还没有实例化,所以用类名是无法访问类中的成员的。
- 用类类型创建对象的过程,称为类的实例化
- 类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。
- 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量
做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。

上图就形象的表示了类和实例化之间的关系。
int main()
{
Stack st;//类进行实例化
//访问
st.Init();
st.Push(1);
st._capacity = 10;
st._top = 5;
return 0;
}
正确的代码应该是这样的,只有进行实例化以后,类中的成员才会开辟物理空间,才会实实在在的存在,我们才能对类的成员进行访问。
class A
{
public:
void printA()
{
cout << _a << endl;
}
private:
char _a;
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
上面定义的类的大小是多少个字节?
你可能会认为是8个,因为函数占4个字节(函数的地址),char类型的变量偏移后占1个字节,结合结构体对齐,它的大小是8个字节。
我们来看运行结果:

运行结果是1,是不是很出乎意料。
要想解决上面这个问题,就得知道类对象在内存中是如何存储的。
这里有三种方式可供参考:

但是这样的方式有一个问题,我们知道,类只是一张图纸,按照这张图纸可以创建很多个对象,每个对象中的内容构成是一样的。
就像按照图纸盖房子一样,这个小区房子的格局都是一样的,只是里面住的人不一样。
类创建的对象也是,不同对象之间,成员的类型都是一样的,只是成员变量(类的属性)各不相同。
现在,这个小区里的人想锻炼身体,开发商需要解决这个问题,它肯定不会在每个房子里面建设一套健身器材,这样太浪费资源,所以它会在小区中建立一个公共的健身区域。
这个健身器材就像是类中的成员函数(类的方法),通过类创建的对象中,如果每个对象中都包含成员函数,同样会造成资源的浪费,因为这些不同的对象使用的成员函数都是相同的。
根据上面的分析,这种方法是不合适的,所以又有了第二种方法。

这样创建的所有对象都可以通过它们包含的地址去找到成员函数去使用,就像小区中的每一户都发了一把钥匙,只有用钥匙才能去使用公共的健身器材。
此时同样存在着资源的浪费,因为每个对象中都会有一个地址,而且这个地址是相同的,占用4个字节,完全是没有必要的。
此时就又有了第三种方法。

我们按照类创建了多个对象,这些对象中只包含它们各自的成员变量,不包含成员函数,也不包含放成员函数的公共地址。
第二种方法中讲到,每个对象中放成员函数公共地址是相同的,那么在第三张方法中就将这个地址也去掉。
不告诉对象这个地址在哪,让编译器自己去找。不用怀疑,编译器是能找的到的。
就像小区中的公共健身区域,会有人不知道它在哪吗?
这样一来,不管是哪个对象,只要使用到成员函数就去放成员函数的公共区域拿就行了。
比较三种方法,发现第三种方法是最好的,也是最节省资源的。类的存储也是使用的这种方法。
说明
在C语言的学习中我们知道,内存分为栈区,堆区,静态区,公共代码区。这里类中的成员函数就是放在公共代码区的。
在了解了类的存储方式后,我们知道,使用类创建的对象中只包含成员变量,所以上面代码中求类大小的结果是1就可以被理解了,因为创建的对象中只有一个成员变量char _a。
开篇在引入类的时候,本喵是通过结构体来引入的,我们也了解到,struct和class是一回事,只是有稍许的区别,所以类的对齐规则和结构体的对齐规则是一样的,因为它并不包含成员函数,只有成员变量。
结构体的对齐规则在本喵的文章自定义类型——结构体中有详细的讲解,本喵就不再啰嗦了,只做大概的提要:
我们来看几个类的大小:
class A
{
public:
void f1()
{}
int _a;
};
class B
{
public:
void f2()
{}
};
class C
{
public:
};
int main()
{
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
cout << sizeof(B) << endl;
return 0;
}
它们的运行结果是什么?4,0,0吗?

结果是4,1,1。
类B和类C中明明什么都没有啊,为什么它们的大小是1呢?
这里有一个规定:空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象
如果空类的大小是0,那么它在内存中就开辟0个字节的空间,也就是没有开辟内存空间。
但是我们按照这个类确实是创建了一个对象,如果是0个字节的话,在内存中就根本不存在这个对象。
所以我们给空类1个字节的大小来证明它创建的对象是存在的。
在学C语言的时候,我们学习了很多类型的指针,int*,char*,数组指针,函数指针等等,在C++中又多了一个this指针,下面本喵就来讲解一下this指针。
看一段代码:

通过对类对象在内存中的存储我们知道,一个类中的成员函数是放在公共代码区的,无论创建多少个对象,每个对象在使用到成员函数的时候都是调用公共代码区的同一个成员函数。
那么问题来了,上图中的程序,按照Date类创建了俩个对象d1和d2,并且都调用了Init和Print成员函数。
d1和d2调用的Init函数都是同一个,但是却能够给不同对象中的成员变量赋值,同一个函数的效果却是不一样,这时什么原因呢?
而且在后面d1和d2分别调用了Print函数,而且打印出的结果还是不同的,这时怎么做到的?难道不应该是打印出相同的结果吗?因为调用的是同一个函数啊!
原因是因为存在一个this指针,这个指针看不见摸不着,但是它确实存在。
实际上,调用函数的过程是这样的,

先拿Init成员函数来说,
- 调用成员函数的对象,在调用的同时会将自己的地址也当作实参传给成员函数,就像图中绿色方框中的那样。
- 类中的成员函数,在被调用的时候,除了接收其他形参,还会接收传过来的对象的地址,而接收这个地址的形参就是this指针,如图中的红色框。
- 在成员函数中访问对象中的任何数据的时候,都是通过this指针来访问的,如图中的橘色框。
再看Print成员函数,

- 在对象调用成员函数Print的时候,将自己的地址当作实参传了过去,如图中的绿色框。
- 成员函数Print中的形参接收传过来的地址,这个形参就是this指针,如图中的红色框。
- 在访问对象中的成员变量时,都是通过this指针访问的,如图中的橘色框。
通过分析这俩个成员函数的调用过程,是不是明白了为什么不同对象虽然调用的是同一个函数,却能达到不同的效果。
C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
在上面引出this指针的过程中,相信有小伙伴对这个指针还是有一些疑惑的,看下面的this指针特性,应该能够解答你的疑惑。
在上面代码中,成员函数的形参中,this指针的类型是Date* const this类型的,之所以用* const this,是防止this指针中的内容被改变,也就是对象的地址被改变而引起错误。
在引出this指针的时候,本喵就说过,this指针是看不见摸不着的,但是它确实存在。上图中代码里的this指针是为了更好的说明这个问题而写出来的,这样是编译不过去的,会报错。
无论是实参还是形参中都不能写出this指针,但是我们在成员函数中是可以使用this指针的。如下图:

- this指针在实参和形参中是不能写出来的,它是在编译阶段由编译器写出来的,我们不能抢编译器要干的活,否则就会报错。
- this指针在类体中的成员函数中是可以使用的,如图中的红色框,如果我们写了,编译器就不补充了,如果我们没有写,编译器也会补充成红色框中的样子。这样说明了this指针是确实存在的。
在引出this指针的时候,从图中的代码可以看出,this指针是一个形参,形参是存在栈区的,有的编译器,像本喵使用的VS2019,this指针是在寄存器ecp中的,所以说,this指针只是用来接收对象的地址,是一个指针变量,它不存在在对象中。
明白了this指针后我们来看俩个问题,看下面代码:
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
它的运行结果是什么?可以正常运行吗?面对这个问题肯定很多小伙伴认为它是无法运行的,因为对空指针解引用了。

但是正确答案是可以正常运行的,这是为什么呢?
我们将this指针给它还原到代码中:

- 在main函数中,将类A型的指针赋值为空指针,在调用成员函数Print的时候将这个空指针当作实参传过去,如图中绿色框中的内容。
- 这里没有发生解引用操作,它就像下面蓝色框中的内容一样,只是表示对象p在调用成员函数Print。
- 成员函数的形参this指针确实是接收到了一个空值,但是在成员函数内部并没有解引用的操作,是之间打印一个字符串
- 所以说整个过程都没有解引用,所以函数是正常执行的。
再看下面一段代码:
class A
{
public:
void Print()
{
_a = 10;
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
这段代码呢?先说明,这段代码和上面那段不一样。它的结果是什么?

可以看到,它在运行的时候奔溃了,这是为什么呢?
同样,我们再来还原this指针:

- 前面的和上面那段代码一样,都是将一个空指针传给了this指针
- 但是在这段代码中,在成员函数中对this指针进行了解引用,如图中红色框,对空指针是无法解引用的,所以就会运行崩溃。
- 对空指针解引用并不会编译报错,而是会运行奔溃,大家可以自己下去试试。
所以说,我们还是要关注this指针,不能因为它是编译器在自动处理就不关注它。