🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:
你仰望的星辰并非遥不可及,而是跋涉者脚印的倒影;你向往的远方未必需要翅膀,只要脚下始终有路,心中永远有光。
那么从本篇文章开始,c++就正式进入类和对象篇章了,而前面的内容我主要讲解围绕c++是兼容了c语言但是如何在c语言的基础上进行改善得到一些新的内容,比如命名空间以及函数的重载,那么之前的文章我们还可以找到c语言的一些影子,但是从这篇文章往后,c语言便和c++便逐渐分道扬镳了,我们终于能够见识到属于c++自己的特性,那么废话不多说,让我们进入正文的学习
面向对象
想必在学习c++之前,各位读者便听说这个名词,也就是面向对象,那么c++与c语言最大的不同便是c++是一门面向对象的语言,而c语言是一门面向过程的语言,那么此时就引出了本文的第一个疑问
什么是面向对象?什么是面向过程?
那么假设你现在要写一个程序,比如完成一个图书管理系统,那么如果你是用c语言来实现,也就是用面向过程的思想来解决的,那么此时你完成该图书管理系统你就会这么思考:
那么首先你第一步会先分析此时用户使用该图书管理的一个需求,比如用户使用图书管理系统无非就是去查询某本书的位置或者说要借阅或者归还某本书等等,那么分析出了这些需求之后,那么下一步,你便关注的是我解决这些需求涉及到的步骤,比如我要借阅某本书,那么第一步便是我得在图书管理系统中先注册用户身份然后登录,第二步则是我得查询该书是否在图书馆中,那么一旦查询到在该图书馆中,那么下一步便是可以借阅,所以你分析得到完成一个图书的借阅总共需要三个步骤,分别是先注册然后查询最后借阅,那么确地好步骤之后,那么下一步你将这个过程中的每一个步骤或者说动作分别定义一个函数来表示这个模块,比如注册定义一个函数,查询又定义一个函数,所以该需求的完整实现就是底层分别调用这几个函数即可
那么根据上面的例子,我们便能知道面向过程的一个思想就是我解决一个事物,我关注的是我解决事物的一个步骤,那么我们再来看看面向对象是怎么思考来完成图书管理系统的
那么面向对象的第一步同样也是先分析需求,那么用户用图书管理系统无非就是借阅以及查询等,然后分析完需求之后,此时面向对象关注的不再是解决这个需求的具体过程或者说步骤,而是关注此时涉及到的对象,比如借阅某本书,那么涉及到就是借阅者以及图书这两个对象,那么确定好对象之后,那么此时我们会将这两个对象给描述定义出来,比如这里的借阅者以及图书,那么定义出来之后,这些对象有自己的行为以及属性,那么让这两个对象之间进行交互即可实现该需求
那么根据上面的例子,我们便能理解面向对象的一个思想,那么以面向对象的方式来思考解决某个问题或者需求时,那么此时你关注的就是这个问题涉及到哪些对象,以及这些对象之间是怎么进行交互的,那么这就是面向对象。
所以现在我们学的是c++,所以以后我们得用面向对象的方式来写程序来解决问题,那么第一步我们就得知道在c++中我们如何去描述定义一个对象,而本文所讲的类便是用来描述对象。
什么是类
那么要学会面向对象编程的第一步便是得知道如何描述如何定义对象,而在我们现实生活中,我们知道我们向陌生人介绍你的朋友或者家人的时候,那么你如何让陌生人脑海中能够对你描述的人有一个画面或者认识,那么你采取的方式就是提取你要介绍的人的特征,比如他的年龄以及他的长相和工作等等,那么这些特征我们可以称之为一个人的属性
所以我们不管是描述人还是动物还是其他任何事物,那么描述的关键就是得先提取出这个事物的属性,那么对于程序员来说,程序员是怎么描述一个事物的呢,同样他也得提取出事物的属性,只不过这些属性比如年龄或者性别以及身高在计算机世界中的映射就是一个int类型或者char类型的数据,那么这些属性的集合就能够描述一个实体,那么c语言就是通过结构体来实现其内部封装不同类型的数据,比如定义一个struct person
struct person
{
int age;
char* name;
char sex;
};
当然c语言的结构体明显不能完整的描述一个对象,不然c语言为什么是面向过程的语言而不是面向对象的语言
之所以c语言的struct结构体无法完整的描述一个对象,是因为它只能描述对象的特征,但是无法描述对象的行为,而面向对象的核心就是对象之间的交互,那么对象之间要交互肯定对象就得具备所谓的行为,所以你只有属性的话,那么你定义出的对象只是一个静态的对象,所以这里c语言的struct结构体就做不到的,但c++的类便可以做到了
那么c++的类也是来描述对象,它内部封装了各种不同的类型的数据也就是像struct结构体那样,但是其中还能封装成员函数,没错,还能封装函数,那么这就是c语言的struct结构体无法做到的,而函数意味着就是该对象的行为,所以c++的类的构造是由成员变量+成员函数所构成
类的定义以及实例化
1.类的定义
那么接下来我们得知道如何定义类,那么我们要定义类,我们就得使用class关键字后面跟上类名,其中我们要注意的是,类是一个域,此前我的c++系列的第一篇文章讲命名空间的时候就说过,c++的域分为几种,分别是局部域以及全局域还有命名空间域以及这里的类域,并且我们访问一个变量的时候,那么编译器获取变量的定义则是从该变量所处的局部域往外搜索直到全局域,默认不会搜索类域以及命名空间域,但是如果要访问这两个域的内容,就得用域作用限定符::指定访问,但是这里如果指定访问类域中的变量会有一个坑,我这里埋一个伏笔,我在下文会讲解,那么我们先来看看一个类的定义:
class person
{
public:
int age;
int sex;
void hello()
{
cout<<"hello c++"<<endl;
}
};
那么我们仔细看这个类的定义,发现我们光是知道class关键字还不够,其中还有个什么public,那么这个public又是什么呢:
public是一个访问限定符
那么现在读者对访问限定符的疑问无非就两种,访问限定符是什么?有什么用?
1.访问限定符是什么?
那么先来回答第一个问题,也就是访问限定符是什么,访问限定符是C++中用于控制类成员访问权限的关键字,通过它们可以限制类中成员变量和成员函数被访问的范围,实现数据封装的核心机制。共有三种类型:
public(公有)
:成员在类内、外部均可直接访问。protected(保护)
:成员在类内和派生类(子类)中可访问,类外不可直接访问。private(私有)
:成员仅在类内可访问,类外及派生类中均不可直接访问。
那么现阶段也就是初学者阶段来说,其中我们就知道以及掌握public以及private的访问权限即可,而protected的使用场景现阶段还用不到,而类中的访问权限修饰的范围则是:
作用域起点:从访问限定符(public、protected、private)声明的位置开始,后续成员均受该限定符约束。
作用域终点:直到遇到下一个访问限定符或类的定义结束为止。
2.访问限定符有什么用?
那么我们知道被private修饰的成员变量以及成员函数只能在类中被访问,所以如果说我们定义了一个类,而对于该类来说,我们不希望该类的属性轻易被别人给访问到或者修改,那么我们可以将其用private修饰,但是如果外部还是有访问类里面的成员变量的需求,那么我们可以在类中定义一个public的函数,那么对方虽然无法直接获取到类中的属性,但是可以通过类向外部提供的接口也就是提供成员函数来访问到
class person
{
private:
int age;
char sex;
char* name;
public:
int getage()
{
return this->age;
}
int getsex()
{
return this->sex;
}
};
而我们常说的面向对象三大特性:封装,继承和多态,那么访问限定符就涉及到其中所谓的封装思想,我们可以举一个例子来理解,比如银行,那么银行内部肯定封装了有各种客户的现金,但是银行本身不会说客户有取钱的需求,那么就直接让客户进入银行的金库中,自己自觉去取钱,因为银行本身不信任客户,但是却要满足客户的需求,所以它会建立一个窗口,那么窗口对面就是客户服务人员来接收用户的响应,然后由银行自己的工作人员去取钱给客户而不是客户亲自去取,那么这就是封装思想的其中一个体现,就是保证了安全性
其次像我们的电脑以及汽车也是封装思想的其中一个体现,那么我们开车的人是不需要掌握汽车内部的构造以及发动机是怎么运转的,只需要知道如何使用汽车的方向盘以及刹车等汽车暴露给我们的接口如何使用即可,而这里封装思想的另一个体现就是它能够封装内部复杂的实现细节,给外部用户提供简单便捷的接口来使用
所以我们发现c++要实现这些封装的思想,那么一定离不开类的访问限定符
所以有了访问限定符之后,我们就知道如何定义一个简单的类了,那么就是用class关键字然后内部封装成员变量以及成员函数,然后再结合访问限定符给修饰限定成员变量以及成员函数
而如果我们没有显示的指定访问限定符,比如:
class person
{
int age;
char sex;
void hello()
{
cout<<"hello"<<endl;
}
};
那么此时默认该类的成员函数以及成员变量则默认设置为private修饰,而在c++中也将结构体升级为了类,也就意味着在c++中结构体可以定义成员变量以及成员函数,但是对于结构体如果我们不显示给访问限定修饰符,那么默认权限是public
2.类的实例化
我上文埋了一个小的伏笔,我们知道类也是一个作用域,而我们访问一个变量的内容的时候,那么编译器会从变量所处的局部域往外搜索直到全局域来搜索该变量的定义,那么不会默认搜索命名空间域以及类域,如果变量的定义在命名空间域的话,则需要域作用限定符指定访问,但是如果在类域中的话,是否也能通过域作用限定符来指定访问呢?
那么验证真理的最好方法便是实验,那么我们可以写一段简单的c++代码来实验一下,代码的逻辑很简答,定义了一个类然后用上文所说的方式来指定访问:
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
void hello()
{
cout<<"hello"<<endl;
}
};
int main()
{
cout << person::age << endl;
return 0;
}
那么发现编译阶段就报错了,那么为什么会报错呢?
那么是因为类域中包含的非静态成员变量只是声明而不是定义,那么既然只是声明的话,那么此时它不会被分配内存空间,那么我们是无法指定来访问它的内容的,而这里我前面很严谨的加了一个非静态的成员变量,那么至于被static修饰的静态成员变量又是怎么样的情况,那么我会在之后的文章中讲解,所以我这里先挖一个坑
所以类可以理解为一个房子的建造图纸,那么你要带着你的家人去参观房子的卧室以及客厅,那么肯定是带你的家人去参观按照这个图纸建造好的房子,那么其中建造好的房子会为卧室以及客厅分配空间,那么你不可能带你的家人来看图纸来参观
所以同理,我们要访问到类中的变量,那么就得先按照蓝图来建造房子,那么这个过程就是实例化,那么类的实例化和c语言的struct结构体实例化是一样的:
class person
{
public:
int age;
char sex;
void hello()
{
cout<<"hello"<<endl;
}
};
int main(){
//实例化
person a;
return 0;
}
那么对象的实例化就包括成员变量的内存分配,那么我们就可以通过实例化出来的a对象来访问比如a对象中的成员变量a:
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
void hello()
{
cout << "hello" << endl;
}
};
int main()
{
person a;
a.age = 10;
cout << a.age << endl;
return 0;
}
那么其中我们给该对象中的成员变量赋值的话那么是和c语言结构体是一样的
a.age=10;
cout<<a.sex<<endl;
类的相关细节补充
那么我们掌握了类的定义以及类的实例化之后,那么我们知道了要访问类中的非静态成员变量,那么只能通过该类的实例化出来的对象来访问,那么现在我们来计算一下按照我们上文定义的person类实例化出来的对象的大小,来看看实例化的对象的构造
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
void hello()
{
cout << "hello" << endl;
}
};
int main()
{
person a;
cout << sizeof(a) << endl;
return 0;
}
运行截图:
那么我们发现计算出的结果是8,那么计算出的结果为什么是8呢?
那么是因为对象内部只包含了两个成员变量分别是int类型的age以及char类型的sex,那么对象分配的内存空间也和c的struct结构体一样遵循内存对齐,那么我们来简单复习一下内存对齐的规则,那么内存对齐就是每一个成员变量在该对象中相对于起始位置的偏移量是按照ret(ret=min(编译器默认对齐数,该成员变量的数据类型的大小))的整数倍来对齐,而第一个成员变量起始位置就在对象的起始位置,那么这里vs下的默认对齐数是4,而第一个成员变量是int,那么其起始位置就和该对象的起始位置重合,那么分配4个字节的空间,而下一个成员变量sex是char类型,那么其数据类型的大小与默认的对齐数进行比较得到就是1,但是由于内存对齐规则规定对象整体的大小必须是最大对齐数的整数倍,而这里的最大对齐数是4,所以sex的起始位置的偏移量就是8个字节,然后其中分配一字节的空间给sex存储,所以计算得到该对象的大小是8个字节
而内存对齐的意义是因为CPU要从内存中读取数据,而从内存中获取的数据要从数据线传递给CPU,而数据线有32位,那么也就意味着CPU一次只能读4个字节的数据长度,而读取的效率就取决于读取的次数,那么如果不内存对齐,那么如果成员变量是挨着的话,那么此时CPU假设只要读取其中一个成员变量的数据,那么一次读取4个字节长度的数据,那么其中就会包含其他的成员变量的数据,那么它不是完整的数据,那么有了内存对齐就能完整访问数据并且减少可读取次数
所以通过这个结果我们知道实例化的对象中只给成员变量分配了空间而没有给成员函数分配空间,为什么这么设计的原因其实也容易理解,因为当我们实例化了一个对象之后,那么我们不一定就要调用该对象的所有的成员函数,所以没必要给成员函数开辟空间,别说成员函数了,其实我们的普通函数不也是这样设计的吗,当我们没有调用普通函数的时候,不会为其开辟栈帧,那么只有当调用普通函数的时候,才会为其在栈上开辟空间,所以这里实例化的对象中只包含成员变量其实是符合我们的预期的
那么我们就可以得出一个结论,那么同一个类实例出的不同的对象,那么他们是共享同一个成员函数的代码段的,那么这个结论我们可以定义不同的对象,然后调用同一个函数,然后查看该代码对应的汇编代码,那么会发现不同对象调用同一个成员函数对应的汇编指令,也就是call指令后面call的地址都是一样的,而后面call的地址就是成员函数的代码段所在的地址,那么博主确实整不来在vs上展示汇编码,无法给大家展示,读者可以下来自己实验一下
那么我们现在知道了同一个类实例化的不同对象是共享成员函数的,那么想必读者一定会有一个疑问,那么假设我在类中定义的成员函数会访问成员变量,那么虽然成员函数是共享的,但是成员变量却是每一个对象自己独立拥有的
那么现在问题就来了:
那么不同的对象调用同一个成员函数,那么该成员函数怎么知道该成员变量是属于那一个对象的呢?
那么其实c++的祖师爷肯定页想到了这一点,那么其实在类中的成员函数的参数列表中,会默认设置一个参数,那么该参数就是指向该对象的this指针,那么它是隐式设置的,也就是说我们看不到,它是在编译阶段生成的符号表中记录该成员函数的符号时自动添加该参数也就是this指针,所以当我们用对象调用成员函数的时候,那么会隐式的传递一个this指针,所以当我们成员函数中访问了成员变量,其实是通过this指针来解引用访问成员变量的
//你的视角:
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout<<"hello"<<endl;
}
void setage(int x)
{
age=x;
}
};
//编译器的视角:
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout<<"hello"<<endl;
}
void setage(person& this,int x)
{
this->age=x;
}
};
其中注意的是,这个this指针的参数是隐式设置的,意味着,我们不能显示的定义所谓的this指针或者显示的传this指针参数,那么这些工作都是编译器自动帮你完成好的,那么就不需要我们画蛇添足了,那么我们只能在成员函数体内部显示的调用this指针
//错误示范
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout << "hello" << endl;
}
void setage(person& this,int x)
{
this->age = x;
}
};
int main()
{
person a;
a.setage(&a,18);
cout << a.age << endl;
return 0;
}
//正确示范
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout << "hello" << endl;
}
void setage(int x)
{
this->age = x;
}
};
int main()
{
person a;
a.setage(18);
cout << a.age << endl;
return 0;
}
那么其中this的作用还不至于此,那么当我们在成员函数定义的形参与成员变量同名的时候,我们也可以用this指针加以区分,这样能避免混淆,正确赋值,但是希望大家永远不会遇到这样的场景,因为我们希望类的成员变量的命名尽可能避免这点,也就是与形参名冲突
class date
{
public:
int year;
int day;
int month;
void setyear(int year)
{
this->year=year;
}
};
那么知道了this指针之后,我们再来看一下与this指针有关,并且非常容易出错的使用场景:
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout << "hello" << endl;
}
void printage(int x)
{
cout<<age<<endl;
}
};
int main()
{
person* a=nullptr;
a->hello();
return 0;
}
那么根据上面的代码,那么此时我就有一个问题,那么就是如果运行上面的代码,那么此时读者你认为上面的代码的运行结果是什么?
A.编译错误
B.运行崩溃
C.正常运行
那么我跑一下这段代码,来揭晓一下答案:
那么正确答案是c,那么不知道你是否答对呢
那么我相信三个选项肯定都有读者来选,其中选第一个选项的读者,我猜测他们应该是这样思考的,因为此时代码中定义了一个指向person对象的一个指针,但是初始化的时候将其设置为指向为空,那么我们接下来解引用该指针,而该指针是空,没有指向任何对象,那么觉得编译器会报一个空指针解引用的编译错误,而之所以这么思考,就是因为它认为当初c语言就要求:我们要定义一个指针的时候,在解引用之前一定要初始化指向正确的对象,否则会出现空指针解引用或者野指针问题
那么如果选A的读者是这么思考的,那么我想说的就是,你的理解部分是对的,错的原因就是不了解c++中用对象的指针调用成员变量以及成员函数的区别以及不熟悉c++的编译器检查的严格程度
首先确实我们在c语言中,指针一定要初始化指向对象,但是这里我们这里指针为空,为什么能够成功运行成员函数的原因就是当我们用指针调用类中的成员函数的时候,由于成员函数不存在对象中存在,对象中只存在成员变量,而成员函数的代码则是在代码段中,所以这里通过指针去访问成员函数就和调用普通函数是一样的,只不过这里不会解引用该指针,而是直接调用成员函数并且将该指针作为实参传递给形参this指针,所以这里能够成功运行hello成员函数(这里还有一个伏笔,我会在下文说到)。
//你的视角:
a->hello();
//编译器的视角:
person::hello(a);
所以我们如果我们用指针a去访问成员变量比如age成员变量时,那么此时便会运行崩溃,那么原因就很简单,因为age成员变量的定义实在对象当中,只有当实例化出对象,那么才会为成员变量分配空间,所以这里如果用指针去访问成员变量,那么就会和c语言一样,那么它会解引用指针,然后访问对象中的成员变量,那么就会出现空指针解引用
#include<iostream>
using namesapce std;
class person
{
.......
};
int main()
{
person* a=nullptr;
a->age=10;
cout<<a->age<<endl;
}
那么如果你看懂了上面的场景之后,那么我再来稍加修改,那么你现在应该能够正确识别该场景下的结果了:
那么这次我调用的不是hello成员函数而是printage成员函数
#include<iostream>
using namespace std;
class person
{
public:
int age;
char sex;
char* name;
void hello()
{
cout << "hello" << endl;
}
void printage()
{
cout<<this->age<<endl;
}
};
int main()
{
person* a=nullptr;
a->printage();
return 0;
}
A.编译错误
B.运行崩溃
C.正常运行
那么对于该代码的运行结果的你认为是上面的那三个选项中的哪一个,那么这次相信各位读者应该不会出错了
那么答案选B,也就是运行崩溃了
此时我们用person指针a调用成员函数printage函数,那么编译器会将a指针隐式传递给形参this指针,然后由于这里在函数体内部要访问成员变量age,那么必然要解引用this指针,而this指针是一个空指针,那么解引用空指针必然导致程序崩溃,那么vs下c++的编译器的检查不够严格,此时虽然解引用了空指针,还是通过了编译但是最终导致运行崩溃,而c的编译器在检查空指针的解引用这方面就很严格,那么在编译阶段就阻止你空指针的解引用。那么这个场景也是关于this指针非常易错常考的一个陷阱了,那么希望你下一次遇到这种场景不会跳进坑里面去
结语
那么这就是本篇关于类和对象的讲解,那么类和对象的各种内容以及细节还没有结束,那么我打算做三期博客来讲解类和对象的知识点,那么我持续更新,希望你能够多多关注,如果本文有帮组到你的话,还请三连加关注哦,你的支持就是我创作的最大的动力!