【C++编程】类和对象(一)---(类的初识引入以及定义 | 类的访问限定符及封装特性 | 类的作用域 | 类的实例化以及类对象模型 | this指针)
目录前言一、面向过程和面向对象初步认识二、类的引入三、类的定义四、类的访问限定符及封装4.1 访问限定符4.2 封装五、类的作用域六、类的实例化七、类对象模型7.1 如何计算类对象的大小7.2 类对象的存储方式7.3 结构体内存对齐规则八、this指针8.1 this指针的引出8.2 this指针的特性8.3 C语言和C实现Stack的对比九、总结前言上一篇【C编程】C入门三---内联函数 | auto关键词(C11) | 范围for循环(C11) | 指针空值nullptr(C11)-CSDN博客这个是C入门篇最后一篇接下来就是进入相当关键、核心的章节类和对象篇。整个篇章会相对难理解并且需要联系在一起所以需要每一步的知识点都要吃透。一、面向过程和面向对象初步认识C语言是面向过程的关注的是过程分析出求解问题的步骤通过函数调用逐步解决问题。C是基于面向对象的关注的是对象将一件事情拆分成不同的对象靠对象之间的交互完成。在深度理解面向对象这个东西还需要后面不断的学习这里再举个相对容易理解的例子比如说有一个外卖系统在面向过程思维中可以分为上架、点餐、派单、送餐这几个过程步骤。外卖会根据这些步骤写对应的函数。在面向对象思维中呢它会更加注重的是参与到这个过程的对象。比如在这个外卖系统中有商家、骑手、用户这3个对象 并且会更加注重这些对象之间的关系。根据这些来写对应的类来描述该对象的状态比如我们点完外卖时可以看地图中骑手的位置商家的位置以及我们用户的位置等这些关系。这就更像现实世界类和对象映射到虚拟计算机系统。可以认为面向对象是比面向过程更高级的语言。二、类的引入C语言结构体中只能定义变量。但是在C中结构体内不仅可以定义变量也可以定义函数。这是因为在C中结构体struct已经被升级成类了。比如在之前在数据结构与算法的基础篇C语言中的栈栈结构实现解析后进先出LIFO原理_栈后进先出-CSDN博客我们用C用struct类实现是怎样的呢这里需要注意一个点就是C是兼容C语言的所以struct以前的用法都可以用同时struct升级成了类。#includeiostream typedef int DataType; struct Stack { //定义变量 DataType* a; // 数组实现栈 int top; // 维护栈顶 int capacity; // 容量 }; int main() { // C兼容C语言所以struct以前的用法都可以继续用 struct Stack st1; // 比如在C语言中定义结构体strcut需要这样定义 // 同时struct升级成了类 Stack st2; // 类名可以直接用 return 0; }然后最大的改变就是在C类中是分为成员变量以及成员函数的。也就是可以在类中定义函数。#includeiostream typedef int DataType; struct Stack { // 成员函数 void Init(int defaultCapacity 4) { a (DataType*)malloc(sizeof(DataType) * defaultCapacity); if (a nullptr) { perror(malloc fail:); return; } top 0; capacity defaultCapacity; } void Push(int x) { // 扩容... a[top] x; } int Top() { return a[top - 1]; } void Destroy() { if (a) { free(a); a nullptr; capacity 0; top 0; } } // 成员变量 DataType* a; // 数组实现栈 int top; // 维护栈顶 int capacity; // 容量 }; int main() { struct Stack st1; st1.Init(20); Stack st2; st2.Init(); st2.Push(1); std::cout st2.Top() ; st2.Push(2); std::cout st2.Top() ; st2.Push(3); std::cout st2.Top() ; st1.Destroy(); st2.Destroy(); return 0; }从上图有一个注意点是成员变量写在哪有没有影响呢是没有的你可以将定义成员变量写在类中的前面或者夹在两个成员函数的中间或者最后面因为类是一个整体。我们还可以从上图可以看到我们在定义函数时可以不用像之前一样函数名前面加一个Stack比如STInit来区分了。如果要实现队列我们可以再创建一个队列的类struct Queue { void Init(); }; struct Stack { void Init(); };这样就可以区分哪个类需要哪个就在那个类定义哪一个。因为类也是一个作用域类域所以可以在不同作用域中实现同名函数。但是比起上面用struct来实现类在C中更喜欢用class来代替。三、类的定义class className { // 类体由成员函数和成员变量组成 }; // 一定要注意后面的分号class为定义类的关键字className为类的名字{}中为类的主体注意类定义结束时后面分号不能省略。类体中内容称为类的成员类中的变量称为类的属性或成员变量类中的函数称为类的方法或者成员函数。类的两种定义方式声明和定义全部放在类体中需注意成员函数如果在类中定义编译器可能会将其当成内联函数处理。类声明放在.h文件中成员函数定义放在.cpp文件中注意成员函数名前需要加 (类名::)由于在类里面的定义的函数如果较短会进行内联函数处理所以可以函数实现很简单的话可以直接在类中定义如果复杂的话就可以让声明与定义分离来增加可读性。一般情况下是采用第二种方式会更加正式并且更加工程化。不过我这里为了更好的演示就先采用第一种。成员变量命名规则的建议class Date { public: void Init(int year) { // 这里的year到底是成员变量还是函数形参 year year; } private: int year; };其中public和private下面会讲我们先观察到如果在类中的成员变量和成员函数的形参中变量名相同的话会比较难区分。所以我们一般会在成员变量前加一个_来表示是成员变量比如_year。可以写成任何形式目的只是为了做区分然后提高可读性。class Date { public: void Init(int year) { _year year; } private: int _year; };四、类的访问限定符及封装4.1 访问限定符我们会发现class和struct都是类但是在上面用struct实现的类变成class会出现报错这是因为权限的问题因为C要兼容C语言所以为了不用C的语法能正常使用所以struct默认都为public公有而class默认都为private私有其他作用域不能进行访问这又和C实现封装特性有一定的关系。C实现封装的方式用类将对象的属性与方法结合在一块让对象更加完善通过访问权限选择性的将其接口提供给外部的用户使用。【访问限定符说明】public修饰的成员在类外可以直接被访问。protected和private修饰的成员在类外不能直接被访问此处protected和private是类似的在继承中这两个限定符才有差异。访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现为止。如果后面没有访问限定符作用域就到 } 即类结束。class的默认访问权限为privatestruct为public因为struct要兼容C注意访问限定符只在编译时有用当数据映射到内存后没有任何访问限定符上的区别。即访问限定符是编译期约束运行时没有“访问权限检查”的开销。所以在这里我们就知道在C中struct和class的区别为C需要兼容C语言所以C中struct可以当成结构体使用。另外C中struct还可以用来定义类。和class定义类是一样的区别是struct定义的类默认访问权限是public公有class定义的类默认访问权限是private私有。注意在继承和模板参数列表位置struct和class也有区别后面再进行详解。4.2 封装面向对象的三大特性封装、继承、多态。封装将数据和操作数据的方法进行有机结合隐藏对象的属性和实现细节仅对外公开接口来和对象进行交互。封装本质上是一种管理让用户更方便使用类。比如对于电脑这样一个复杂的设备提供给用户的就只有开关机键、通过键盘输入显示器USB插孔等让用户和计算机进行交互完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。对于计算机使用者而言不用关心内部核心部件比如主板上线路是如何布局CPU内部是如何设计的等用户只需要知道怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时在外部套上壳子将内部实现细节隐藏起来仅仅对外提供开关机、鼠标以及键盘插孔等让用户可以与计算机进行交互即可。在C语言中实现封装可以通过类将数据以及操作数据的方法进行有机结合通过访问权限来隐藏对象内部实现细节控制哪些方法可以在类外部直接被使用。我们在代码上怎么实现这个封装呢也就是在类中访问限定符将类中的成员变量用protected保护或者private私有进行保护然后将类中的方法成员函数用public公有就是实现了封装。五、类的作用域类定义了一个新的作用域类的所有成员都在类的作用域中。在类体外定义成员时需要使用 :: 作用域操作符指明成员属于哪个类域。class Person { public: void PrintPersonInfo(); private: char _name[20]; char _gender[3]; int _age; }; // 这里需要指定PrintPersonInfo是属于Person这个类域 void Person::PrintPersonInfo() { std::cout _name _gender _age std::endl; }在不同的文件中定义函数也是这样实现在上面有提过。六、类的实例化用类类型创建对象的过程称为类的实例化类是对对象进行描述的是一个模型一样的东西限定了类有哪些成员定义出一个类并没有分配实际的内存空间来存储它比如入学时填写的学生信息表表格就可以看成是一个类来描述具体学生信息。一个类可以实例化出多个对象实例化出的对象占用实际的物理空间用于存储类成员变量Person是类名而非对象是不具有空间的非静态成员变量_age必须通过类的对象、指向对象的指针或引用来访问不能直接通过类名操作。做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子类就像是一个蓝图设计图只设计出需要什么东西但是并没有实体的建筑存在同样类也只是一个设计实例化出的对象才能实际存储数据占用物理空间所以在类中的成员变量是定义还是声明呢在变量中定义和声明的区别就是是否开空间所以在类中的写的成员函数都是声明它是不占空间的因为它只是一个“图纸”是不能住人的。可以认为“类本身是一种数据类型int、double等在没有定义对象前是不占用内存空间的定义对象的时候才会分配空间”。这里就是还有一个可能会让人导致疑惑的点int main() { Person p1; std::cout sizeof(Person) std::endl; std::cout sizeof(p1) std::endl; return 0; }我们会发现这两个计算的都是一样的那不是说类名没有空间吗为什么还是可以计算出来这个其实很简单把Person类想成一个设计图纸那里面设计的房子之类的不占用内存但是能不能根据你这个图纸计算你要建造房子对象的大小呢答案是可以的。而直接计算房子p1就更好理解了相对于拿的尺子直接计算这个房子有多大原理是一样的。七、类对象模型7.1 如何计算类对象的大小比如我们拿一个很简单的类来说class A { public: void PrintA() { std::cout _a std::endl; } private: char _a; };相比于之前C语言的结构体来说在C中类里面可以存在函数方法。那么问题就是类中既可以有成员变量又可以有成员函数那么一个类的对象中包含了什么如何计算一个类的大小先看结论结论一个类的对象中只包含成员变量所以计算一个类的大小就是根据C语言中结构体的内存对齐规则来计算大小的。即只算成员变量的大小根据内存对齐规则不考虑成员函数7.2 类对象的存储方式我们来思考一下为什么类创建的对象中只包含成员变量而不包含成员函数呢要明白这个问题先看下面这个案例class A { public: void PrintA() { std::cout _a std::endl; } private: char _a; }; int main() { A a1; A a2; A a3; a1.PrintA(); a2.PrintA(); a3.PrintA(); return 0; }类就拿我们上面写的简单的A类来演示。我们用A类创建了3个对象a1、a2、a3。那a1和a2以及a3中的变量_a是否一样肯定是不一样的因为我们就是要拿一个类来创建多个对象存储不同的数据。所以用类来创建对象时给每一个对象的成员变量分配内存空间是必要的。再看我们用对象a1、a2、a3分别都调用了PrintA函数方法那调用的这个PrintA函数是否一样呢我们发现只是传的值不同而已有必要在每一个创建的对象都创建这个函数吗是没必要的它们调用的都是同一份函数。如果每一个创建的对象中都保存这一份代码成员函数相同的代码保存多次会导致浪费空间。这里可以用一个抽象的例子来理解比如A类就是一个住宅区的设计蓝图那A类中的成员变量就是每一个房子的卧室、厨房等成员函数就是篮球场、健身房之类的。而用A类创建的对象就是一个家那每一家都要搞一个卧室的空间、厨房的空间等那篮球场和健身房需要每一家都有吗这是不需要的因为这样会很浪费。那成员函数到底去哪了呢所以在类中创建的对象是只保存成员变量成员函数存放在公共的代码区中。7.3 结构体内存对齐规则通过上面我们已经知道计算类对象和计算结构体的内存大小方式是一样的就只根据内存对齐规则来计算成员变量。这里我们来简单讲解一下结构体的内存对齐规则第一个成员在与结构体偏移量为0的地址处。其他成员变量要对齐到某个数字对齐数的整数倍的地址处。注意对齐数 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8结构体总大小为最大对齐数所有变量类型最大者与默认对齐参数取最小的整数倍。如果嵌套了结构体的情况嵌套的结构体对齐到自己的最大对齐数的整数倍处结构体的整体大小就是所有最大对齐数含嵌套结构体的对齐数的整数倍。根据上面的结构体的内存对齐规则我们简单练习一下比如有下面这个类class A1 { public: void f1() {} private: int _a; char _ch; };根据规则2和3我们首先求出该类的最大对齐数成员变量的类型为int(4字节)、char(1字节) -- 得出其中最大值为int(4字节) -- 最后比较这个最大值和编译器默认对齐数VS默认对齐数为8的最小值 -- 得出该类的最大对齐数为4。我们就知道每一个成员变量要对齐4的整数倍的地址处并且结构体的总大小也要为4的整数倍。最后得出该类对象的大小为8字节。但是如果_a和_ch换个顺序的话存储内存会变的不一样并且在一些情况下类对象的大小也会发生变化。虽然此时类对象的大小仍为8字节但若成员变量更多时不同排列顺序可能导致对象大小发生变化。不过还有一个特殊情况// 类中仅有成员函数 class A1 { public: void f1() {} }; // 类中什么都没有---空类 class A2 { }; int main() { std::cout sizeof(A1) std::endl; // 1 std::cout sizeof(A2) std::endl; // 1 return 0; }在上面代码中没有成员变量的情况下为什么类对象的大小还为1字节呢我们可以这样理解即使这个房子没有东西但是我们还是要给它留一个位置因为它确实在这里有个房子只是没有东西而已。即没有成员变量的类对象需要1字节是为了占位表示对象存在不存储有效数据。那现在我们再思考一个问题为什么要内存对齐我原本5字节就能存储的为什么还要对齐导致用8字节来存储内存对齐主要是为了提升CPU访问内存的效率。因为CPU从内存读取数据时并不是一个字节一个字节地读而是一次取多个连续字节称为“一次内存操作”。在32位系统中CPU一次通常取4字节32位 ÷ 8 4字节在64位系统中一次取8字节64位 ÷ 8 8字节。拿我们上面的例子假设CPU一次读4字节的情况下我们在每一次操作_ch或者_a的数据时在内存对齐时每次都只需要进行一次内存操作就拿到了该数据但是在内存不对齐时我们发现_a的数据要进行两次内存操作才能读出完整的数据。总结就是内存对齐是“用空间换时间”虽然会浪费一些空间导致内存空间利用率下降但是换来的是CPU访问速度提升、缓存效率优化、并行访问更高效。八、this指针8.1 this指针的引出我们先来定义一个日期类Date#includeiostream class Date { public: void Init(int year, int month, int day) { _year year; _month month; _day day; } void Print() { std::cout _year - _month - _day std::endl; } private: int _year; // 年 int _month; // 月 int _day; // 日 }; int main() { Date d1, d2; d1.Init(2026, 1, 15); d2.Init(2026, 3, 4); d1.Print(); d2.Print(); return 0; }对于上面这个日期Date类有一个问题Date类中有Init 与 Print 两个成员函数函数体中没有关于不同对象的区分那当d1调用Init函数时该函数是如何知道应该设置d1对象而不是设置d2对象呢从上图也可以看到调用的是同一个函数地址也相同但是怎么做的区分呢既然可以排除函数重载而且函数本身和对象也不会有关系那能导致不同的结果的可能就只能往参数那里想了。C中通过引入this指针解决该问题即C编译器给每个“非静态成员函数”增加了一个隐藏的指针参数让该指针指向当前对象函数运行时调用该函数的对象在函数体中所以“成员变量”的操作都是通过该指针去访问。只不过所有的操作对用户是透明的即用户不需要来传递编译器自动完成。8.2 this指针的特性this指针的特性this指针的类型类类型* const即成员函数中不能给this指针赋值。只能在“成员函数”的内部使用。this指针本质上是“成员函数”的形参当对象调用成员函数时将对象地址作为实参传递给this形参。所以对象中不存储this指针。this指针是“成员函数”第一个隐含的指针形参一般情况由编译器通过ecx寄存器自动传递vs下面对this指针传递进行优化对象地址是放在ecxecx存储this指针的值不需要用户传递。所以根据上面我们可以得出在上述日期类的代码中会变成这里出现报错的原因就是this指针是隐含的指针形参用户不能自己去传递。并且可以发现在函数内部使用this-_year等操作不会报错。也就是说this不能在形参和实参显示传递但是可以在函数内部显示使用。还有一点就是要区别类类型* const和类类型 const*的效果就拿日期类来说即Date* const和Date const*。简单来说就是Date* const指的是this这个指针是不能更改的但是this指向的内容是可以更改的。比如Init函数中我们使用this-_year year;进行初始化也就是指向内容是可以更改的。然后Date const* 就是反的来了this这个指针可以更改比如将this nullptr;赋值空指针但是this它指向的内容是不可以更改的。简单来说就是看 const 在 * 的哪一边如果在右边比如 Date* const 就是说明这个this指针是不能指向其他地方但是其指向的内容可以修改如果在左边比图 Date const* 或者 const Date* 都是说明this指针是可以指向其他地方的但是指向的内容是常量也就是不能修改接下来思考一些问题比如this指针存在哪里的这里可能很多人误以为this指针应该就在对象里面其实不是的从上面计算类对象的大小时并没有额外计算this指针所以肯定不是在对象里面的。我们可以发现this是形参所以this指针是跟普通参数一样存在函数调用的栈帧里面。然后接下来再来看看下面两个代码会出现什么问题第一个class A { public: void Print() { std::cout Print() std::endl; } private: int _a; }; int main() { A* p nullptr; p-Print(); return 0; }上面这个程序运行会出现什么问题呢是编译报错还是运行崩溃又或者是正常运行呢首先其中没有语法错误之类的即使因为空指针的问题运行不了那也是运行的问题编译是不可能报错的所以就排除了。再来看怎样会运行崩溃面对空指针如果对空指针进行解引用的话就会运行崩溃这里我们看看该程序是否有解引用可以看到在汇编指令中并没有出现对对象p解引用的指令而是简单的将p用寄存器传递然后调用成员函数传空指针是不会出问题的。并且成员函数也不在对象中所以可以正常调用。总结就是p调用Print不会发生解引用因为Print的地址不在对象中。p会作为实参传递给this指针。然后this指针是空的但是函数内没有对this指针解引用。所以是可以正常运行的。第二个class A { public: void PrintA() { std::cout _a std::endl; } private: int _a; }; int main() { A* p nullptr; p-PrintA(); return 0; }通过第一个的问题应该知道这个程序会出现什么问题了可以从汇编指令中看到dword这个解引用的操作解引用一个空指针肯定会运行崩溃的。总结p调用PrintA不会发生解引用因为PrintA的地址不在对象中。p会作为实参传递给this指针。然后this指针是空的但是函数内访问_a本质是this-_a。也就是对this这个空指针进行了解引用发生运行崩溃。8.3 C语言和C实现Stack的对比C语言实现这里可以看我前面写过的博客栈结构实现解析后进先出LIFO原理_栈的进出原则-CSDN博客typedef int STDataType; typedef struct Stack { STDataType* a; int top; int capacity; }ST; //初始化 void STInit(ST* ps) { assert(ps); ps-a (STDataType*)malloc(sizeof(STDataType) * 4); if (ps-a NULL) { perror(malloc fail); return; } ps-capacity 4; ps-top 0; //top代表栈顶元素的下一个位置 //ps-top -1; //top代表栈顶元素 } //销毁栈 void STDestroy(ST* ps) { assert(ps); free(ps-a); ps-a NULL; ps-top 0; ps-capacity 0; } //入栈 void STPush(ST* ps, STDataType x) { assert(ps); if (ps-top ps-capacity) { STDataType* tmp (STDataType*)realloc(ps-a, sizeof(STDataType) * ps-capacity * 2); if (tmp NULL) { perror(realloc fail); return; } ps-a tmp; ps-capacity * 2; } ps-a[ps-top] x; ps-top; } //判断栈是否为空 bool STEmpty(ST* ps) { assert(ps); return ps-top 0; } //出栈 void STPop(ST* ps) { assert(ps); assert(!STEmpty(ps)); ps-top--; } //返回栈的元素个数 int STSize(ST* ps) { assert(ps); return ps-top; } //返回栈顶元素 STDataType STTop(ST* ps) { assert(ps); assert(!STEmpty(ps)); return ps-a[ps-top - 1]; } int main() { ST s; STInit(s); STPush(s, 1); STPush(s, 2); STPush(s, 3); STPush(s, 4); printf(%d\n, STTop(s)); printf(%d\n, STSize(s)); STPop(s); STPop(s); printf(%d\n, STTop(s)); printf(%d\n, STSize(s)); STDestroy(s); return 0; }可以看到在C语言实现时Stack相关操作函数有以下共性每个函数的第一个参数都是ST*函数中必须要对第一个参数检测因为该参数可能会为NULL函数中都是通过ST*参数操作栈的调用时必须传递ST结构体变量的地址结构体中只能定义存放数据的结构操作数据的方法不能放在结构体中即数据和操作数据的方式是分离开的而且实现上相当复杂涉及到大量指针操作稍不注意可能就会出错。C实现typedef int STDataType; class Stack { public: //初始化 void Init(int defaultCapacity 4) { _a (STDataType*)malloc(sizeof(STDataType) * defaultCapacity); if (_a nullptr) { perror(malloc fail); return; } _capacity defaultCapacity; _top 0; } //入栈 void Push(STDataType x) { if (_top _capacity) { STDataType* tmp (STDataType*)realloc(_a, sizeof(STDataType) * _capacity * 2); if (tmp NULL) { perror(realloc fail); return; } _a tmp; _capacity * 2; } _a[_top] x; _top; } //出栈 void Pop() { if (Empty()) return; _top--; } //判断栈是否为空 bool Empty() { return _top 0; } //返回栈的元素个数 int Size() { return _top; } //返回栈顶元素 STDataType Top() { return _a[_top - 1]; } //销毁栈 void Destroy() { free(_a); _a nullptr; _top 0; _capacity 0; } private: STDataType* _a; int _top; int _capacity; }; int main() { Stack s; s.Init(); s.Push(1); s.Push(2); s.Push(3); s.Push(4); std::cout s.Top() std::endl; std::cout s.Size() std::endl; s.Pop(); s.Pop(); std::cout s.Top() std::endl; std::cout s.Size() std::endl; s.Destroy(); return 0; }C中通过类可以将数据 以及 操作数据的方法进行完美结合通过访问权限可以控制那些方法在类外可以被调用即封装在使用时就像使用自己的成员一样更符合人类对一件事的认知。而且每个方法不需要传递ST*参数编译器编译之后该参数会自动还原即C中ST*参数是编译器维护的C语言中需用用户自己维护。在学完this指针后我们也会知道s.push(x)和STPush(s,x)没有本质上的区别因为s.push(x)其实也是要传递地址也就是也要两个参数但是是隐含的。所以从这里可以看出来C语言和C本质上就是手动挡和自动挡的区别C语言需要自己控制离合即变速箱但是容易操作不当导致熄火而C有电脑程序控制变速箱会更加方便不容易出错。更何况C还有一个封装的特性。九、总结本章详细讲解了类的认识以及类的访问限定符的封装特性、作用域、实例化、对象模型怎么计算大小也描述了怎么内存对齐最后就是this指针。这篇作为类和对象的开篇还是非常有价值的。后一两篇会着重讲解类的默认成员函数、以及运算符重载。内容非常重要并且需要C篇以及本章节的知识所以希望各位好好学习这方面的知识。写文不易希望各位给个三连~言已至此感谢各位读者花费时间阅读本人浅学才疏如有文笔拙劣之处还望见谅~
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2423486.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!