1.面向过程和面向对象
C语言是面向过程的,关注的是过程,分析求解问题的步骤,通过函数调用逐步解决问题
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成
两种思想的设计方式截然不同,例如设计简单外卖系统:面向过程:关注实现下单、接单、送餐等过程。体现到代码层面就是函数(方法),总体关注过程
面向对象:关注实现类对象及类对象间的关系。用户,商家,骑手,以及他们之间的关系,提现到代码层面就是类的设计和类之间的关系
C++是基于面向对象的语言:它可以面向过程和面向对象混编,原因是 C++ 兼容 C
但是对于 java 等纯面向对象语言:只有面向对象 。
2.类的定义
C++ 中定义类有两个关键字 struct/class .
struct Stu
C++ 兼容 C 中结构体的用法,同时 struct 在 C++ 中也升级成了类 。
在 C 中创建结构体局部变量,需要写成:
struct Stu s1;
但是升级为类之后,Stu 就直接变为类的名称,当定义局部变量时,可以写为 Stu s2 ;但是也可以像上面那么写,因为 C++ 是兼容 C 的。
struct Stu s1; // 兼容 c Stu s2; // 升级到类,Stu 为类名,也是类型
同样,对它们进行访问也没问题
C++中的 struct(类)和结构体不同的是:除了可以定义成员变量还可以成员函数,成员函数可以访问成员变量,但是如果成员函数中的形参和成员变量相同 ,就像这样:
struct Stu { char name[10]; int age; int id; void init(const char* name, int age, int id) {} };
但这样分不清形参和成员变量,所以C++引入 ‘_’ 定义变量名,以作区分:
struct Stu { char _name[10]; int _age; int _id; void init(const char* name, int age, int id) {} };
由于C++是面向对象的,所以一般把定义的变量叫做对象 ,虽然变量也对,但是最好叫对象。应用我们学的知识简单写一个类:
struct Stu { char _name[10]; int _age; int _id; void init(const char* name, int age, int id) { strcpy(_name, name); _age = age; _id = id; } void print() { cout << _name << endl; cout << _age << endl; cout << _id << endl; } }; int main() { struct Stu s1; Stu s2; s1.init("sherry", 19, 1); s2.init("kathy", 20, 2); s1.print(); s2.print(); return 0; }
但上面结构体的定义,在C++中更喜欢用class
来代替。
其中class为定义类的关键字,className为类的名字,{}中为类的主体,注意定义结束时加上后面的分号;
类中的元素称为类的成员:类中的数据称为类的属性或者成员变量,类中的函数称为类的方法或者成员函数。
2.1 类的定义方式
1、声明和定义全部放在类体中。需要注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。(cpp规定)2、声明放在头文件(.h)中,定义放在源文件(.cpp)中。注意
:一般情况下,更期望采用第二种方式。
3.类的访问限定符及封装
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
面向对象的三大特性:封装、继承、多态。
3.1封装的特性:
在类中,类的数据和方法都放到一起
而访问限定符是封装的一个很厉害的特性,基于访问限定符,可以对 对象 进行 严格管控
3.1.1访问限定符访问限定符说明:
1.public修饰的成员在类外可以 直接被访问
2.protected 和 private 修饰的成员在类外不能直接被访问(类似,等学到继承时区分不同)
3.访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
4.如果后面没有访问限定符,作用域就到 } 即类结束。
5.class的默认访问权限为private,struct为public(因为struct要兼容C)
6.默认访问限定符,即不写时,类中的默认访问权限;一般在定义类时,建议明确定义访问限定符 ,不要用 class/struct的默认限定
7.访问限定符是约束外面的,对于类中,则没有限定,类里面可以全局访问。
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
3.1.2封装
封装就是让数据和方法放在一起,进行 更好 的管理。对于 C 是不封装的,是一种较 松散 的管理。C++ 是将数据和方法封装到类里面,C 是数据和方法分离的(数据访问控制是自由的,不受限制的)
那么C++ 如何进行严格管理?假设定义一个栈:
class Stack { private: int* _a; int _top; int _capacity; public: void Init() { _a = nullptr; _top = _capacity; } void Push() {} void Pop() {} void Top() {} }; int main() { Stack st1; Stack st2; st1.Push(); st2.Pop(); }
如果对于 C 语言,进行 取top 其实可以有两种方式,就像我们实现的 栈 一样,
1.可以通过下标(st2.a[s._top-1];)进行访问;2.也可以调用 top接口[int top=st2.Top();]放元素
但是像1.这种松散的方式,若不清楚 Stack 本身的状况贸然使用 很容易出错 ,就比如博客顺序栈的C语言实现中的 top 有两个位置,一不小心就会使用出错,访问出界到随机值。
C语言只是推荐调用接口函数,并没有起到强制性的管理作用,如果硬是要强行访问结构,很容易出现问题
但如果 C++ 将结构部分 定义成私有 ,方法定义成共有 ,进行严格管理,就不会出现之前的情况。就比如:
private: int* _a; int _top; int _capacity;
这些是被 private 修饰,封装在类里面的,如果直接进行操作,即访问结构,就会报错,因为这时成员变量为私有,不让访问 。
总结
:封装就是数据和方法都封装到类里,能访问定义成共有;不能访问定义成私有。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
4.类的作用域
class Person { public: //显示基本信息 void ShowInfo(); private: char* _name; //姓名 char* _sex; //性别 int _age; //年龄 }; //这里需要指定ShowInfo是属于Person这个类域 void Person::ShowInfo() { cout << _name << "-" << _sex << "-" << _age << endl; }
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员,需要使用“::”作用域解析符指明成员属于哪个类域。
5.类的实例化
用类类型创建对象的过程,称为类的实例化。
类是对 对象 进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它 。
一个类可以实例化多个对象:就好比类是图纸,根据图纸可以建造出多个楼房设计图只设计出需要什么东西,但是并没有实体的建筑存在。同样类也只是一个设计,只有实例化出的对象才能实际存储数据,占用物理空间。
对于类创建出来的对象,可以访问类中成员;但是对于类本身,是不能访问成员与方法的
所以对于类仅仅起 描述作用 而已,真正使用还是要类对象 。我们可以认为类这些代码存在代码段,是公共的。
6.类对象
6.1类对象大小
一个类当中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?类的大小又是如何计算的呢?
class Stack { public: void Init(); void push(int x); // ... private: int* _a; int _capacity; int _top; };
6.2类对象存储方式
猜测一:对象中包含类的各个成员缺陷: 每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。
猜测二:只保存成员变量,成员函数存放在公共的代码段。缺陷: 每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。
猜测二:只保存成员变量,成员函数存放在公共的代码析:
// 类中既有成员变量,又有成员函数 class A1 { public: void f1(){} private: int _a; }; // 类中仅有成员函数 class A2 { public: void f2() {} }; // 类中什么都没有---空类 class A3 {};
通过sizeof来获取这三个对象的大小,结果A1的大小为4个字节,A2的大小为1个字节,A3的大小也为1个字节。
对于空类和只有成员函数的类也有自己的地址,并不是空,所以一定有大小,编译器给了空类 1 字节来唯一标识空类(当然也有类的大小也为1,具体看实现)
这 1 字节是为了占位,并不存储有效数据,标识对象被实例化定义了,表示存在 。
1.类中只计算成员变量的大小,计算方式满足C语言结构体内存对齐,详细可以跳转 1.6 结构体内存对齐
2.空类和只具有成员函数的类大小为 1
总结:计算类或类对象的大小,只看成员变量,并考虑内存对齐,C++内存对齐规则跟 C 结构体一致。而类中成员函数就和普通的函数一样存在于公共代码区。
7.this 指针
7.1 引入
先写出一个日期类:
class Date { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Init(2023, 1, 31); d1.Print(); Date d2; d2.Init(2023, 2, 1); d2.Print(); return 0; }
上述代码调用成员函数传参时,看似只传入了一些基本数据,实际上还传入了指向该对象的指针:
编译器进行编译时,看到的成员函数实际上也和我们所看到的不一样,每个成员函数的第一个形参实际上是一个隐含的this指针,该指针用于接收调用函数的对象的地址,用this指针就可以很好地访问到该对象中的成员变量。
上面讲过 类的实例化 后,我们知道类实例化处的每个对象是独立的,所以对象的成员变量是独立的,但是多个类对象都使用共同的成员函数 :
我们调试起来转到反汇编看一下:
看到 call 指令这一行,发现函数的地址是相同的,也印证了我们的说法:不同对象使用相同成员函数。
7.2 特性
但是有个问题,就拿 Print 函数来说,当 d1 调用 Print 函数时,打印的是 d1 的成员变量;当 d2 调用时,打印的是 d2 的成员变量。而类对象中的成员变量是独立的,如何做到每次调用都可以输出正确的成员变量的?
这就依靠的是 this 指针 :
1.对于 d1._year ,即对象访问成员变量,意义是在类对象这块空间中,访问到 _year 这个成员变量,对其进行操作
2.而对于 d1.Print() ,则是访问成员函数,是到公共区域代码段上找到成员函数,找到变为 call 指令,进行调用,这里有两层,第一层就是我们前面说的;第二层就是 this 指针。
当代码被编译之后,编译器会对成员函数进行处理,例如这里的 Print 函数,就有一个隐藏的 this 指针 ,类似:
void Print(Date* const this) // const 是因为 this 指针不可改,this 是指针,所以直接用 const 修饰 this { cout << this->year << "-" << this->_month << "-" << this->_day << endl; } // 调用 d1.Print(&d1);
当不同的对象调用时,根据传过来的地址,this 指针会指向不同的对象
但是注意一点,虽然原理是这样,但是我们不能这么写,例如 d1.Print(&d1) 就会报错,因为 this 指针是隐藏的,统一规定就别写,由此提炼出两点:
1.调用成员函数时,不能显示传实参给 this
2.定义成员函数时也不能声明形参 this
即形参和实参不能写,但是在成员函数中,是可以显示写的,但是很少用:
cout << this->year << "-" << this->_month << "-" << this->_day << endl; • 1
甚至打印 this 指针也是可以的:
void Print(Date* const this) // const 是因为 this 指针不可该,this 是指针,所以直接用 const 修饰 this { cout << this << endl; cout << this->year << "-" << this->_month << "-" << this->_day << endl; }
这里打印的 this 指针的地址:就是对应对象的地址。但是我们一般不这么写,在一些场景下,需要显示用 this ,这个我们之后再看。
this 指针在哪里?一般情况下在栈区,因为 this 指针是隐含的形参,this 指针并不在对象中 ,所以指针跟普通指针参数一样存在函数调用的栈帧里面。而 this 指针不需要处理,一般会直接转换为指令,我们不用担心。
但是有时也会特殊处理:
在 vs 下,有些情况会将对象的地址(即 this 指针)放到寄存器 ecx 中,因为在调用成员函数时 this 指针要被经常使用(this->_year)。
7.3 考题
接下来,通过这几个题目看看对于类的理解
q1 :
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void PrintA() { cout << "Print()" << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); return 0; }
q2 :
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void PrintA() { cout << _a << endl; } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; }
q3:
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void PrintA() { cout << "PrintA()" << endl; } private: int _a; }; int main() { A* p = nullptr; (*p).PrintA(); return 0; }
你可能看到指针p是一个空指针,而q1代码和q2代码都通过操作符“->”,间接性的执行了对p的解引用操作,所以可能会认为程序崩溃。但其实并不是
答案揭晓:
q1 :C 正常运行
q2:B 运行奔溃
q3:C 正常运行
可是,为什么?q1分析:Print() 是成员函数,成员函数在代码段,在公共区域;虽然 p 是空指针,但是访问公共区域并不会报错,因为不需要到对象里面找。
调用函数,会把 p 当做 this 指针传过去,在 Print() 函数中,并没有解引用,所以没有问题,只不过此刻的 this 指针为空而已。
指针p确实是一个类的空指针,但当执行第一句代码时,程序并不会崩溃。第一句代码并没有对空指针p进行解引用,因为Print() 是成员函数,地址并没有存到对象里面,成员函数的地址是存在公共代码段的。
注意 :
这里可以定义变量访问 Print 函数,也可以用指针,例如 d.Print(); 和 p->Print(); 都是可以的;但是千万不要写成 Print(); 的形式,因为类是有作用域的,在调用函数时,只会默认在全局找,得规定在哪个类中,二是因为 this 指针的问题,因为此刻没有对象,this 指针也不清楚,所以这样是错误的.
q2 分析:
同理 Print() 在公共区域,所以调用时是没有问题的,问题在于当 this 指针传递过去后,函数中是这样的:cout << _a << endl; 这里实际上为 cout << this->_a << endl ,对空指针进行了解引用,这就崩溃了。
当程序执行第二句代码时,会因为内存的非法访问而崩溃。执行第二句代码时,调用了成员函数Print,这里并不会产生什么错误(理由同上),但是Print函数中打印了成员变量_a,成员变量_a只有通过对this指针进行解引用才能访问到,而this指针此时接收的是nullptr,对空进行解引用必然会导致程序的崩溃。
q3 分析:
Print() 是公共的,不在对象里面;这里表面上看 (*p).Print() 是对空指针进行解引用了,其实并没有,编译器对其进行了处理,这里是把 p 传递给了 this ,而这里本质上和 p->Print() 是相同的
总结:这里的崩溃与否取决于访问的东西是否在对象中,如果访问的是公共区域,那么就再看传递的 this 指针为空指针时,会不会对 对象 进行解引用。
7.4 优点
而this指针的存在就像自动挡和手动挡的区别,方便我们使用优点
:解决1.初始化和销毁经常忘记 2.有些地方写起来很繁琐 的问题
8.总结:
今天我们初识了面向过程和面向对象,了解了什么是类,认识并具体学习了有关类的命定义、访问限定符及封装、作用域、实例化、类对象的知识。接下来,我们将继续学习类和对象的相关知识。希望我的文章和讲解能对大家的学习提供一些帮助。
当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~