> 文档中心 > 机械转码日记【12】C++类和对象(上)

机械转码日记【12】C++类和对象(上)

目录

前言

1.类的诞生

1.1结构名升级成了类名

1.2结构体里面可以定义函数

1.3类:class

2.访问限定符

2.1public和private

2.2struct和class中的默认访问权限

3.封装

3.1C语言没有封装所产生的缺陷

3.2C++的封装解决C语言缺陷

4.类的作用域

4.1"::"作用域解析符

4.2类的声明和定义

4.2.1声明和定义全部放在类体中

4.2.2声明和定义分离

5.类的实例化

5.1如何计算类对象的大小

5.2类对象的储存方式

6.this指针

6.1this指针不能被修改

6.2this指针可以为空指针

6.3this指针存储在栈区或者寄存器中


前言

本篇博客详细介绍了类,从类的由来到类如何声明和定义。介绍了面向对象三大特性的封装,并介绍了this指针。新人创作者,如果对我的博客有建议或者发现错误了,大佬们可以在评论区指出!本篇博客的相关代码已经上传到gitee了,有需要的老铁们欢迎自取:本篇博客代码

1.类的诞生

C++是从C语言一步步修改过来的,C++其实一开始叫C with Classes,这说明类非常重要,类一开始是根据结构体struct改的,C++能够兼容struct,同时也对struct进行了升级,一共有两点:

  • 结构体名升级成了类名
  • 结构体里面可以定义函数

1.1结构名升级成了类名

C++下的结构体名升级成了类名,比如下面的链表结构体:

struct ListNode{int val;//struct ListNode* next;//C语言类没有升级,只能使用struct ListNode*去定义结构体指针ListNode* next;//C++结构体名升级成了类名,可以用类名去定义结构体指针};

1.2结构体里面可以定义函数

在学习C语言的时候,我们的结构体中是不能定义函数的,但在C++中,结构体中可以定义函数:

struct Student{    //C++结构体中可以定义函数void Init(const char* name, const char* gender, int age){strcpy(_name, name);strcpy(_gender, gender);_age = age;}void Print(){cout << _name << " " << _gender << " " << _age << endl;}// 这里并不是必须加_// 习惯加这个,用来标识成员变量char _name[20];char _gender[3];int _age;};

1.3类:class

虽然C++可以用struct去定义类,但程序员们还是更喜欢用class去定义类,定义方法也很简单,只要把我们刚刚的struct改成class就行了:

class Student{void Init(const char* name, const char* gender, int age){strcpy(_name, name);strcpy(_gender, gender);_age = age;}void Print(){cout << _name << " " << _gender << " " << _age << endl;}// 这里并不是必须加_// 习惯加这个,用来标识成员变量char _name[20];char _gender[3];int _age;};

上面的程序中,class是类的关键字,student是类名,类里面的函数叫成员函数,类里面的变量叫成员变量。

2.访问限定符

面向对象有三大特征:封装、继承、多态。而封装需要借助我们C++新增加的访问限定符去实现,访问限定符有三个:public,private和protected,在这一篇博客里我们先介绍public和private(protected在现阶段可以认为和private差不多,到学到继承的时候protected会有区别)。

2.1public和private

public修饰的成员在类外可以直接被访问,private修饰的成员在类外不能直接被访问:

上图说明,我们并不能在类外访问被private修饰的类成员。 

上图说明,被public修饰的成员变量,我们可以在类外访问。

2.2struct和class中的默认访问权限

class的默认访问权限为private,struct为public(因为struct要兼容C,想想我们在学习C语言时是可以在结构体外部访问结构体成员的)

由上图可知,在类中,如果我们没有写任何访问限定符,那么默认的访问权限是private 。

上图我们的结构体没有写任何访问限定符,最后我们在main函数中能够成功访问结构体中的变量并打印出来,说明结构体的默认的访问权限为public。

3.封装

前面说到面向对象的三大特性:封装、继承、多态,那么封装到底是什么呢?封装是将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。封装的本质是一种管理,不想让外部访问的成员,我们就用private或者protected保护起来,只留下pubic的接口,按照程序设计者想要的正确的方式去访问被保护的成员。

3.1C语言没有封装所产生的缺陷

以前面我们学过的数据结构的栈C语言程序为例:

//数据和方法是分离的,分离就太过自由//太自由,不好,容易出错,struct Stack{int* _a;int _top;int _capacity;};void StackInit(struct Stack* ps){ps->_a = NULL;ps->_top = 0; // ps->_top = -1;ps->_capacity = 0;}void StackPush(struct Stack* ps, int x){}int StackTop(struct Stack* ps){}int main(){struct Stack st;StackInit(&st);StackPush(&st, 1);StackPush(&st, 2);StackPush(&st, 3);printf("%d\n", StackTop(&st));printf("%d\n", st._a[st._top]); // 可能就存在误用printf("%d\n", st._a[st._top - 1]); // 可能就存在误用}

假如我们想访问栈顶的元素,常规的方法是调用我们的StackTop函数去返回栈顶的元素,但不排除有这种行为:直接用下标去访问我们栈顶的元素,你可能会用a[st._top]或者a[st._top - 1],因为你并不知道_top它所指向的到底是栈顶的元素还是栈顶的下一个元素,这和我们的StackInit函数有关;如果_top初始值为-1;那么栈顶元素就是a[st._top];如果_top初始值为0;那么栈顶元素就是a[st._top-1];所以如果不采用接口函数StackTop去访问栈顶元素,就可能会出现错误。概括的来说,C语言在这里的不足就是数据和方法是分离的,程序员访问数据太过自由,太自由反而不好(无规矩不成方圆),容易出错,如何避免出错?只能靠程序员本身的代码素质去解决,因此,C++在这里做出了改进,引进了封装的概念:

3.2C++的封装解决C语言缺陷

还是以刚刚的栈的代码为例,我们用C++对它进行改进:

//C++设计出了类,可以封装,就不会出现误用的问题// 1、数据和方法封装到一起,类里面// 2、想给你自由访问的设计成公有,不想给你直接访问的设计成私有// 一般情况,设计类,成员变量都是私有或者保护,想给访问的函数是公有,不想给你访问时私有或保护class Stack{//想给访问的函数是共有,不想给你访问是私有或保护private:void Checkcapaicty(){}public:void Init(){}void Push(int x){}int Top(){}private:int* _a;int _top;int _capacity;};//低耦合:关联性弱int main(){Stack st;st.Init();st.Push(1);st.Push(2);st.Push(3);st.Push(4);cout << st.Top() << endl;//cout << st._a[st._top] << endl;//不能再这样实现了,私有类外访问不了return 0;}

可以看到,现在数据和方法被写在了类里面,写在了一起,没有分离;并且使用了public和private进行了封装,我们不能在类外直接通过下标_top访问栈顶元素了,因为它被private所修饰,只能通过public修饰下的Top函数去返回栈顶元素;封装就是这样:想给你自由访问的设计成公有,不想给你直接访问的设计成私有或者保护;使用封装就可以降低我们程序的耦合度,实现低耦合。一般情况下,类的成员变量都是private的,接口函数一般都是public的。

4.类的作用域

先来观察以下代码:

//类的作用域// 两个类域class Stack{public:void Push(int x){}};class Queue{public:void Push(int x){}};//两个push可以同时存在吗?两个push函数构成函数重载吗?

请问两个push函数可以同时被调用吗?他们是同名函数,那么他们构成函数重载吗?答案是他们可以被同时调用,他们也不构成函数重载。因为他们属于不同的作用域,函数重载的前提是他们属于同一个作用域,Stack类和Queue类创造出了两个类域,各自的push函数各属于各自的类域,并不会产生任何影响。

4.1"::"作用域解析符

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员,需要使用 :: 作用域解析符去指明成员属于哪个类域。例如刚刚声明的两个push函数,如果我们要在类外定义它,就必须这样做:

void Stack::Push(){}void Queue::Push(){}

4.2类的声明和定义

类由两种定义方式:

  1. 声明和定义全部放在类体中
  2. 声明放在.h文件中,类的定义放在.cpp文件中

4.2.1声明和定义全部放在类体中

如果声明和定义全部放在类体中,需要注意的是:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。比如我们输入以下代码:

//Stack.hclass Stack{//在类里面定义的函数默认是内联public:void Init(Stack* ps){ps->_a = nullptr;ps->_top = 0; ps->_capacity = 0;}}//test.cppint main(){Stack st1;st1.Init(&st1);return 0;}

在我们调试转到反汇编之后,发现使用init函数时,没有出现call,所以也就没有访问函数表,说明init在编译期间就被展开了,也就是内联了。我们上一篇博客说内联是一种建议,只有短小函数才会被采纳这个建议,所以实际中,一般情况下,短小函数可以直接在类里面定义,长一点的函数声明和定义分离。

4.2.2声明和定义分离

声明和定义分离的话,就要用到我们上面说的::作用域解析符了,很简单,代码如下:

//Stack.hclass Stack{private:void Checkcapaicty(){}public:void Init();void Push(int x);int Top();private:int* _a;int _top;int _capacity;};//Stack.cpp#include"Stack.h"//不会限制,这个还是在类的作用域里面,因为使用了类作用解析符void Stack::Init(){}void  Stack::Push(int x){}int  Stack::Top(){}

5.类的实例化

用类类型创建对象的过程,称为类的实例化

  1. 类只是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它
  2. 一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量
  3. 做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间

5.1如何计算类对象的大小

上面说到,我们创建的类只是图纸,用类实例化出的类对象才有空间,那么我们类对象的大小应该如何计算呢?我们可以猜想一下,类是由结构体升级而来,那它的大小会不会和结构体的大小有着类似的地方呢?直接来上代码计算一下吧:

上面的代码显示我这个类对象的大小是12,欸,好像还真和结构体的大小计算方式有关,好像把这个类改成结构体的话,它的大小也是12,那这么说,成员函数是不占用空间的。那再来做一个实验,如果我们的类里面一个成员变量都没有,它的大小又是多少呢?

可以看到,上面这个类一个成员函数都没有,但是它的类对象的大小是1,并不是0,如果是结构体,大小应该为0才对,说明类对象和结构体的大小既有相同也有不同的地方。

总结:类本身不占用空间,用类实例化出的类对象才占用空间;类对象的大小和结构体的大小计算方式相似,但是类对象的成员函数不占用空间,只有成员变量才占用空间,当类里面一个成员函数都没有的时候,编译器会给他们分配1byte占位,表示对象存在过。 

来复习一下结构体的内存对齐准则吧 :

  1. 第一个成员在与结构体偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
    VS中默认的对齐数为8
  3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

5.2类对象的储存方式

上面说到,每创建一个类对象,这个类对象的大小只与它的成员变量相关,那么类的成员函数储存到哪去了呢?其实成员函数也有空间,只不过他们是公共的空间,每个类对象皆有权限访问这块空间,它的储存方式如下图所示:

6.this指针

来观察一下下面这段代码:

class Date{public:void Print(){cout << _year << "-" << _month << "-" << _day << endl;}void Init(int year, int month, int day){_year = year;_month = month;_day = day;}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date d1;d1.Init(2022, 5, 11);Date d2;d2.Init(2022, 5, 12);d1.Print();d2.Print();//调用的都是同一个函数//它怎么知道第一个就是2022.5.11呢?第二个就是5.12呢?}

上面说到,类的成员函数是一块公共空间,每个类对象皆有权限访问,那么我们不禁有一个疑问:以上面代码为例,既然是公共的空间,为什么函数传参的时候,我能知道这是哪个对象调用的?当d1调用Init函数时,编译器为什么就能分辨得出这是d1调用的,而不是d2调用的呢?

C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。

它相当于下面这样:

class Date{public://隐含的this指针//你不能写在形参的位置上,这是编译器做的void Print(Date* const this){cout <_year << "-" <_month << "-" <_day <_year = year;this->_month = month;this->_day = day;}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date d1;d1.Init(&d1,2022, 5, 11);Date d2;d2.Init(&d2,2022, 5, 12);//调用的都是同一个函数//它怎么知道第一个就是2022.5.11呢?第二个就是5.12呢?//隐含的this指针d1.Print(&d1);d2.Print(&d2);}

其实编译器的底层是做了上面的改动的,但是我们人为这样写会报错,因为这是编译器要做的事,我们程序员不能这么写,这是隐式的this指针,但实际上我们也可以使用显式的this指针:

class Date{public:void Print(){cout <<"this: "<<this << endl;//可以直接在成员函数内部使用this指针cout << _year << "-" << _month << "-" << _day << endl;}void Init(int year, int month, int day){_year = year;_month = month;_day = day;}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date d1;d1.Init(2022, 5, 11);Date d2;d2.Init(2022, 5, 12);d1.Print();cout << "&d1: " << &d1 << endl;d2.Print();cout << "&d2: " << &d2 << endl;return 0;}

这里我们在Print()函数内部使用this指针,打印出d1,d2和他们相对应的this指针:

6.1this指针不能被修改

this指针是不能被修改的,以上面的日期类为例,Date* const this,this指针是被const这样修饰的,这样修饰表面this指针的指向不能被修改,但它所指的内容可以被修改:

6.2this指针可以为空指针

先来看看下面一段代码:

//this指针可以为空class A{public:void PrintA(){cout << _a << endl;}void Show(){cout << "Show()" <Show();}

运行一下:

是不是感觉很奇怪,为什么一个空的类指针居然可以用来调用类的函数,因为对象里面只有成员变量,没有成员函数,成员show()函数位于公共代码段,所以我们并没有通过这个空指针去访问变量,因此并不会报错。那再来观察下面这段代码:

这个时候我们就报错了,因为这个时候使用我们的空指针p去访问了_a变量,这个时候就不是访问公共代码段了,而是通过空指针访问变量了,程序是肯定会崩的。

总结:this指针可以为空指针,只要不通过空的this指针访问成员变量就不会报错。

6.3this指针存储在栈区或者寄存器中

使用this指针时它存放在哪呢?因为this指针是在函数内部进行使用,他是对象的地址(实参)转变为形参在函数内部使用的,所以它是在函数的栈帧里面创建的,因此它是存放在栈区,而有的编译器也会进行优化存放在寄存器中,例如VS2014:

通过汇编代码我们可以看到我们前面日期类的Init函数并没有连续通过四个push将四个形参都压栈,而是将d1的地址(也就是this指针)存放在ecx结构体中,因此this指针也可以存放在寄存器中。