Re:从零开始学C++(四)类和对象·上篇:定义、访问限定、对象大小、this指针

57
26·
新星杯·14天创作挑战营·第18期9.7w人浏览119人参与


◆ 博主名称: 晓此方-CSDN博客
大家好,欢迎来到晓此方的博客。
⭐️个人专栏:
◆数据结构系列
◆C语言系列
◆C++系列
⭐️踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰
目录
0.1概述&前言
从本篇开始,我们将正式踏入C++ 面向对象编程的核心。本文是Re0系列的第四期,讲解类与对象的基础知识,涵盖类的定义、访问控制、对象内存布局以及this指针等关键概念,同时初次接触面向对象三大特性之一——封装,为后续的深入学习打下坚实基础。讲解深入骨髓,细节无微不至,以真诚换真心,现在,让我们开始吧。
一,类的定义
1.1类的定义和成员
类的定义和和C语言中的结构体非常相似,但是C++中的类除了定义变量外还可以定义函数。
class为定义类的关键字,{}中为类的主体。(注意:类定义结束时后面的分号不能省略。)
类体中的内容称为类的成员:
- 类中的变量称为类的属性或成员变量。
- 类中的函数称为类的方法或成员函数。
1.1.1举例:定义”狗“这个类
class Dog { private: char name[5]; int age; public: int Age() { return age; } };代码中定义了成员变量char[]表示狗的名称,定义age表示狗的年龄,定义Age方法用于知道狗的年龄。
1.2类访问限定符
1.2.1介绍
C++ 一种实现封装的方式,用类将对象的属性与方法结合在一起,让对象更加完善,通过访问权限选择性地将其接口提供给外部的用户使用。
三种类访问限定符简单解析:
| private | 私有 |
| protected | 受保护 |
| public | 公有 |
public修饰的成员在类外面可以被直接访问,private和protected都是禁止类外部访问,现阶段没有任何区别,推荐目前写类的时候都使用private,在后面的继承中会详细介绍他们的区别。
1.2.2访问权限作用域
访问权限作用域从该访问限定符出现的位置开始,直到下一个访问限定符出现时为止;如果后面没有其他访问限定符,作用域就到},即类结束。
class Date{public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; }private: int _year; int _month; int _day;}上述代码中,public作用域从public所在行开始,到private所在行止。private作用域从private所在行开始,直到}结束类为止。
一般成员变量都会被限制为private/protected,需要给别人使用的成员函数会放为public。
我不期望用户随便修改我的数据。但是我的方法你可以随便用。——常见规范
1.3定义的习惯
1.3.1相对位置
各种公司中习惯将类的方法定义在上面,把类的属性定义在下面。
1.3.2变量命名
错误示范:
//错误示范:class Date{public: void Init(int year, int month, int day) { year = year; month = month; day = day; }private: int year; int month; int day;};如上代码,如果我们把变量的名称设计地和参数一致,就会发生冲突,事实上我们可以把参数设计为:
void Init(int y, int m, int d)但是这样同时也降低了代码的可读性。(其他人看你的代码不能够一目了然)
C++并没有规定具体的命名规则,于是在各个公司中,就有了各种各样的约定俗称的命名习惯:
| _name | 前下划线 | 最常见 |
| name_ | 后下划线 | 较为常见 |
| m_name | member+下划线 | 少见 |
| mName | 驼峰法 | 少见 |
1.4struct与class
C++中struct也可以定义类,C++兼容C中struct的用法,同时struct升级成了类,明显的变化是struct中可以定义函数,一般情况下我们还是推荐用class定义类。
1.4.1C++的兼容性
typedef struct ListNode_C{ int val; struct ListNode_C* next;} LTNode;struct ListNode_CPP{ int val; ListNode_CPP* next;};如上两种结构体写法在C++都适用。
第一种:保留了C语言的传统,使用struct+结构体名的方式命名指针,使用typedef重命名结构体名称以便后续代码编写方便。
第二种:C++在C语言的基础上做出改进,命名结构体指针不再需要struct关键字。自然省略了typedef的过程。
1.4.2struct和class定义类的区别
class和struct几乎没有区别。为数不多的区别是class默认私有,struct默认公有。举例如下:
struct Stack//class Stack{ void Push(int x){ }public: void pop(){ } int top(){ return ; }private: int* a; int top; int capacity;};如果是struct定义的这个栈。那么push方法就是默认是公有的,如果是class定义的这个栈,那么push就默认是私有的。
1.5类域
1.5.1成员函数的声明定义分离
讲类域之前,先插播一个小细节:定义在类里面的成员函数默认为inline。
有的场景下,如果不希望自己的成员函数是内联函数,为了解决这个问题:我可以对同文件中的函数声明定义分离。直接定义在类里面的是内联函数,声明和定义分离的不是内联函数。
class Data{public: void Init(int year, int month, int day);private: int _year; int _month; int _day;};void Init(int year, int month, int day){ _year = year; _month = month; _day = day;}分离后,函数Init中的变量_year等会优先去函数的局部域中找参数的定义,然后再去全局域中找定义,都找不到,会报错。这是因为类的创建天然的得开辟了一块域(类域),所以必须加上域访问操作符。这是说明这个Init不是一个全局函数而是一个类的成员函数。只是声明和定义分离:
void Date::Init(int year, int month, int day){1.5.2类域的定义
类定义了一个新的作用域,类的所有成员都在类的作用域中,在类体外定义成员时,需要使用:: 作用域操作符指明成员属于哪个类域。
1.5.3类域的意义
类域是天然的命名隔离
class Queue{ void Push(int x){ }};class Stack{ void Push(int x){ }}定义一个队列,定义一个栈,他们都有相同名称的函数方法,如果没有类域的隔离,就会发生冲突。
类域和命名空间域只是名称隔离,不影响声明周期。
二,类的实例化
2.1类的实例化的定义
用类类型在物理内存中创建对象的过程,称为类实例化出对象
举例:如下代码:其中d1就是实例化的Date类的对象。
class Date{ public: 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; return 0;}类是对象进行一种抽象描述,是一个蓝图一样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间。类实例化出对象时,才会分配空间。一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量。
Data::_year=2024;如上代码必然报错,_year只是声明没有定义开辟空间,不能这样访问。
打个比方:类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,设计图规划了多少个房间、房间大小功能等,但是并没有实体的建筑存在,也不能住人,用设计图修建出房子,房子才能住人。同样类就像设计图一样,不能存储数据,实例化出的对象分配物理内存存储数据。

2.2对象大小的计算
2.2.1对象中有哪些需要计算
分析一下类对象中哪些成员呢?类实例化出的每个对象,都有独立的数据空间,所以对象中肯定包含成员变量,那么成员函数是否包含呢?首先函数被编译后是一段指令,对象中没办法存储,这些指令存储在一个单独的区域(代码段),那么对象中非要存储的话,只能是成员函数的指针。
再分析一下,对象中是否有存储指针的必要呢,Date实例化d1和d2两个对象,d1和d2都有各自独立的成员变量_year/_month/_day存储各自的数据,但是d1和d2的成员函数Init/Print指针却是一样的,存储在对象中就浪费了。
如果用Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。
这里需要再额外哆嗦一下,其实函数指针是不需要存储的,函数指针是一个地址,调用函数被编译成汇编指令[call 地址],其实编译器在编译链接时(生成call的时候),就要找到函数的地址,不是在运行时找,只有动态多态是在运行时找,就需要存储函数地址,这个我们以后会讲解。
补充:函数被编译完之后是一段指令,函数的指针可以被认为是第一句指令。但是严格来说不是第一句指令!在VS下函数不是第一句指令,call()指向的是一句中间指令:jmp,然后才是第一句指令。

2.2.2最根本的方法:内存对齐
C++中的内存对齐原理与C语言中的结构体内存对齐完全一致。
想要详细了解内存对齐原理的同学可以看我这篇文章,这里只是简单讲一下计算方式。
2.2.3结构体的内存对齐规则:
结构体的第一个成员将被放置在和结构体变量起始位置偏移量为0的地址处。
其他成员变量需要对其到某个对齐数的整数倍的地址处。这里的对齐数是编译器默认的一个对齐数与该成员变量大小之间的较小值。具体来说,在Visual Studio 中,默认的对齐数值为8;而在Linux中使用gcc编译时,默认没有设定对齐数值,此时对齐数就是成员自身的大小。
结构体总的大小需要调整为最大对齐数(即结构体中每个成员变量都有一个对齐数,所有这些对齐数中的最大值)的整数倍。
如果结构体中嵌套包含了其他结构体类型的成员,那么嵌套的结构体成员要对其到其自身成员的最大对齐数的整数倍处。而整个包含嵌套结构体的结构体大小则需调整为所有最大对齐数(包括嵌套结构体内部成员的对齐数)的整数倍。
2.2.4内存对齐简单案例
class S2 { private: char c1; char c2; int i; };int main(){ cout<<sizeof(struct S2)<<endl; return 0;}
2.2.5为什么要内存对齐
平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
2.2.6两个特殊案例
class B{public: void Print(){ //... }};class C{};上面的程序运行后,我们看到没有成员变量的B和C类对象的大小是1,为什么没有成员变量还要给1个字节呢?因为如果一个字节都不给,怎么表示对象存在过呢!所以这里给1字节,纯粹是为了占位标识对象存在。
三,this指针
3.1引入
class Date{ public: void Init(int year){ _year = year; } void print(){ cout<<"printf()"<<endl; } int _year;};int main(){ Date d1; Date d2; return 0;}上面的代码中,Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用Init和Print函数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?那么这里就要看到C++给了一个隐含的this指针解决这里的问题。
3.2this指针的定义
编译器编译后,类的成员函数默认都会在形参第一个位置,增加一个当前类类型的指针,叫做this指针,this传递的是调用这个函数的对象的指针,(在调用的时候他会悄悄的把调用者的地址传过去。)比如Date类的Init的真实原型为:
void Init(Date* const this, int year, int month, int day)所以你的函数的第一个参数不是你那个year,也不是有的函数没有参数,他们都有一个隐含的参数this,(注意:这个const修饰的是this指针本身。)所有的非静态的成员函数都会增加这么一个隐含的this指针。静态的成员函数以后会学习。

以d1举例,this传过去的是d1的指针。 那么怎么知道后面的参数是d1的年月日呢?
this指向调用对象,类的成员函数中访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值:
this->_year = year;3.3this指针的注意事项:
C++规定不能在实参和形参的位置显示的写this指针(编译时编译器会处理),但是可以在函数体内显示使用this指针。(但是目前不会这么写,在后期的学习中你会发现,在少数情况下需要。)
void Init(int year, int month, int day){ this->_year = year; this->_month = month; this->_day = day; }如果你显示的写出了这个函数:就会报错
3.4this指针的特点
this的值是不可以修改的,this的指向的内容是可以被修改。
3.5this指针的存储位置
部分编译器会选择把this存放在寄存器里面的,这是编译器自己的优化。原因是this的使用频率是比较高的。但是传统意义的来说,this指针是存储在栈里面的,(根据他的性质来分析的结果)。当然,传统并不排除特殊的优化。
// d2.Init(&d2, 2024, 7, 5);d2.Init(2024, 7, 5);00FF6128 push 500FF612A push 700FF612C push 7E8h00FF6131 lea ecx,[d2]00FF6134 call Date::Init (0FF143Dh)代码如上,可见:this指针就是对d2取地址,把他放在寄存器ecx里面。并不是所有的编译器都会这么优化,所有我们默认认为是在栈里面
3.6一对常考易错面试题
#include<iostream>using namespace std;class A{public: void Print(){ cout << "A::Print()" << endl; //cout << _a << endl; } int _a;};int main(){ A* p = nullptr; p->Print(); return 0;}
- 以上程序运行结果:A,正常运行,B,运行崩溃,C,编译报错
- 如果加上cout<<_a<<endl;运行选择什么?
正确答案是:A,B。肯定会有人问:这不是空指针解引用?实际上不是的。
首先:成员函数的地址并不是存放在对象里面的,而是在编译的时候就形成了call指令,所以这里并没有用空指针解引用调用函数。那么要p有什么用?p->是为了访问这个类域。(为了通过过编译)
第二题运行崩溃是因为,cout<<_a<<endl实际上是cout<<this->_a<<endl,其中this就是p指针,即——this是空指针,空指针不可以访问。
代码被编译后形成汇编代码,汇编是什么才是什么。表面上解引用的,实际上不是的。
四,C++与封装规范管理
面向对象三大特性:封装、继承、多态,下面的对比我们可以初步了解一下封装。
4.1C和C++实现栈的区别
通过下面两份代码对比,我们发现C++实现Stack形态上还是发生了挺多的变化,底层和逻辑上没啥变化。
1,首先,C语言表面上传指针,实际也传指针。C++表面上不传指针,实际上传指针(this指针)
2,此外,C++中数据和函数都放到了类里面,通过访问限定符进行了限制,不能再随意通过对象直接修改数据。
这是C++封装的一种体现,这个是最重要的变化。这里的封装的本质是一种更严格规范的管理,避免出现乱访问修改的问题。
(当然封装不仅仅是这样的,我们后面还需要不断的去学习。)
3,最后,C++中有一些相对方便的语法,比如Init给的缺省参数会方便很多,成员函数每次不需要传对象地址,因为this指针隐含的传递了,方便了很多,使用类型不再需要typedef用类名就很方便
在我们这个C++入门阶段实现的Stack看起来变了很多,但是实质上变化不大。等着我们后面看STL中的用适配器实现的Stack,大家再感受C++的魅力。
4.2C语言的不规范性
我曾经在一本书上看到过这中写法:
s.a[s.top-1]//获取栈顶元素如上:确实可以达成直接获取栈顶元素的作用。但是假如top=0程序就会越界访问。C语言没有封装确实可以能这么做,但是C++不行,C++建议把成员变量变成私有的。自然没有办法访问top。这就是:“封装规范管理”。
好了,本期内容到此结束,如果对你有帮助,还请点赞收藏三联一波,我是此方,我们下期再见。


















1254























