前言:
由于之前电脑“嗝屁”了,导致这之前一直没有更新博客,今天才拿到电脑,在这里说声抱歉。接下来就进入今天的学习,在之前我们已经对【C++】进行了初步的认识,有了之前的知识铺垫,今天我们将来带领大家学习我们【C++】中的一个重要知识,即“类和对象”的学习。这个知识点我将分为三期进行讲解。好了,废话不多说直接进入本期【类和对象(上)】的学习。
1.面向过程和面向对象初步认识
在正式开始学习之前,我们先来了解一个概念,那就是【面向对象和面向过程】的区别。
👉C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
我们通过日常生活中的洗衣服案例用通俗易懂的话来带领大家认识。
在生活中洗衣服通常要进行以上过程,而【面向对象】关心的就是将这些具体的过程/解决问题的步骤按部就班的进行下去,每个步骤可以将其封装成一个函数,这些函数按照一定的次序来调用,最终完成所需要做的事情,我们将这种思想称之为 【面向对象】。
👉C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
我们还是以洗衣服为例进行理解说明:
对于“我们”来说,洗衣服就很少会亲自动手去洗,至少“我”本人就是一直往洗衣机丢。我们将衣服,洗衣液,放到洗衣机里面(由洗衣机完成后续工作)而对于“我们”来说并不用关心衣服怎么来洗,洗衣机会帮我们完成接下来的任务,洗衣机实际是真正洗衣服的。我们不需要考虑其中具体过程,经过别的物品之手完成此事便可,我们将这种思想称之为【面向对象】,通过【面向对象】的方式处理,“人”“脏衣服”“洗衣机”“洗衣液”“水”均为对象,我们“洗衣服这件事情”是通过这些对象之间的“交互”把事情做完的。
2.类的引入
C++在C语言的基础上做了一些改进,使得C++具有了面向对象编程的特性。其中最重要的改进就是提供了类的概念。
2.1类的解读
👉类的基本思想是数据抽象和封装。是具有相同的属性和操作的一组对象的集合,它为属于该类的全部对象提供了统一的抽象描述。封装实现了类的接口和实现的分离。封装隐藏了类的实现,封装过后,用户只能访问类的接口,而不能访问类的实现。
在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
有了基本认识过后,我们可能会想到“类”是怎么来的呢?
在之前我们已经说过了,由于【C++】兼容我们的【C】,它最开始就是从结构体延伸而来的,在【C++】中照样可以使用结构体,但是实际上在【C++】中我们把结构体升级为了今天我们将会探讨的“类”。
我们还是以直观形象的代码为例进行理解,接下来我将通过【C】和【C++】两种语法使用情况下写出两种代码,具体如下:
// C++兼容C结构体用法 typedef struct ListNode { int val; struct ListNode* next; }LTN; // C++把结构体升级成了类 struct ListNode { int val; ListNode* next; };
👉解析:
在【C】语言中,我们定义的结构体,struct ListNode是它的类型,而在【C++】当中我们的类型就是ListNode,所以大家才会看到下面那种代码格式。
此时,大家可能就会好奇,既然【C++】中的类是由【C】语言中的结构体引入来的,那么是否这两者之间就是一样的呢?答案当然是否定的,大家试着想想如果都是一样的话,我们在【C】语言中已经学过了,在【C++】完全就没必要在引入新概念了。
👉那么类和结构体之间到底有什么区别呢?
1.C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数;
2.结构使用栈存储,而类使用堆存储。结构在声明的时候就已经为其分配栈上的内存了,而类需要用new为其分配堆上的内存。;
3.结构体内的变量和函数一般是 public 的,可以在结构体作用域之外的地方使用,但是类的变量和函数一般是 private 的,只能被类作用域内的函数使用,类外不可以直接获取到类中的变量。(public 和private 具体什么意思后面会讲)
具体什么意思呢?我们一点点的来进行理解,在我们之前学习数据结构的时候,学到过【栈】的基本知识,首先是定义了一个结构体,在定义了各功能函数紧接着去一一实现,即数据和方法是分离开的。但是在【C++】中方法可以定义在其里面,即功能函数的实现可以定义在“类”里面。通过代码大家仔细体会:
struct Stack { // 成员函数 void Init(int n = 4) { //... capacity = n; size = 0; } void Push(int x) { //... a[size++] = x; } // 成员变量 int* a; int size; int capacity; }; int main() { Stack zp; // 用类stack实例化出对象zp zp.Init(10); zp.Push(1); zp.Push(2); zp.Push(3); return 0; }
上面结构体的定义,在C++中更喜欢用【class】来代替。
3.类的定义
定义一个类,本质上是定义一个数据类型的蓝图。这实际上并没有定义任何数据,但它定义了类的名称意味着什么,也就是说,它定义了类的对象包括了什么,以及可以在这个对象上执行哪些操作。
👉class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。
👉类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
例如,我们使用关键字 【class】 定义 【student 】数据类型,如下所示:
class Date { private: int _year; int _month; int _day; };
关键字 【public】 确定了类成员的访问属性。在类对象作用域内,公共成员在类的外部是可访问的。您也可以指定类的成员为 【private 】或 【protected】,这个我们稍后会进行讲解。
👉 类中函数的两种定义方式:
1. 声明和定义全部放在类体中;
注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
2.类声明放在.h文件中,成员函数定义放在.cpp文件中(推荐)
注意:成员函数名前需要加类名::
对于第一种方法,就是我们上边写【栈】的那种方法。
至于第二种方法,就是声明和实现分开的方法,这种方法我们之前写数据结构时都是用的这种方法。(一般情况下,更期望采用第二种方式)
最后,说明一点,在类中定义成员函数以及成员变量时,不需要考虑定义的先后顺序,也就是说,即使成员变量放在成员函数的下面,成员函数中依然可以使用成员变量
4.类的访问限定符及封装
在上述类的定义的两种方式中,声明和定义方式下我们采用的是【struct】关键字,大家是否验证过当我们换成【C++】中的【class】之后是否还能正常编译成功呢?在这里给出大家答案,之后大家可以下去测试,当我们换成【class】之后程序跑起来就会出现报错的情况,具体如下:
但是当我们使用【class】之后,程序就会出现以下现象:
那么到底是什么原因呢?这就需要研究访问限定符的问题了。
4.1 访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
👉【访问限定符说明】
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问,现阶段我们只要会用public和private就可以(此处protected和private是类似的)
- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到 } 即类结束。
- class的默认访问权限为private,struct为public(因为struct要兼容C)
👉注意:
还有一个细节问题,就是我们的成员函数在定义时,为了防止之后的冲突,一般进行一些限制会在其前面或者后面加【_】(根据个人习惯)。
👉注意事项:
1.不能在类的声明中对数据变量进行初始化;
2.在类中声明的任何成员不能使用 extern、auto 和 register 存储类型关键字修饰;
3.类声明中可以给出成员函数的参数的默认值;
4.类中可以不含有任何成员变量和成员函数,这样的类被称为空类。
5. 访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
此外,在定义类的成员函数的时候,如果不在类的内部定义(在内部给出定义,默认为内联函数),使用具体如下:
返回值类型 类名::成员函数名(形参列表) { 函数体 }
5.类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 【::】作用域操作符指明成员属于哪个类域。
class Person { public: void PrintPersonInfo(); private: char _name[20]; char _gender[3]; int _age; }; // 这里需要指定PrintPersonInfo是属于Person这个类域 void Person::PrintPersonInfo() { cout << _name << " " << _gender << " " << _age << endl; }
6.类的实例化
用类类型创建对象的过程,称为类的实例化
- 类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;
- 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量
Person p;//p占有实际的物理空间,这里就是一个实例化的过程 Person p1; Person p2;
int main() { Person._age = 100; // 编译失败:error C2059: 语法错误:“.” return 0; }
Person类是没有空间的,只有Person类实例化出的对象才有具体的年龄。
7.类对象模型
7.1 如何计算类对象的大小
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; Date d2; d1.Init(2023, 2, 2); d1._year++; d2.Init(2022, 2, 2); d2._year++; cout << sizeof(d1) << endl; }
问题:类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?
我们直接运行程序,得到的结果如下:
此时,通过上述结合代码我们可以想到,打印出来的大小似乎只打印了成员变量的大小,而成员函数似乎不在我们的对象里面。要理解这个问题我们就需要探讨为什么成员变量在对象中,成员函数不在对象中呢?
7.2 类对象的存储方式
👉对象中包含类的各个成员,首先我们需要理解我们硬是要把成员函数的地址存到对象当中可以吗?答案当然是可以的,但是为什么不放到对象里面呢?大家可以想一想,当我们真的放到对象里面的时候会不会存在巨大的浪费,每个对象里面成员变量是独立的,但是成员函数是公共的,假设当我有10个函数,就有10个指针,就有40个字节,当我们每个对象都放一份时就会造成巨大的浪费。因此就没有必要在放到对象里面去,那么不当到对象里面应该放到哪去呢?我们可以放到一个公共的区域,这个公共的区域就是【代码段】,调用时就不到对象里面去找,而去这个公共的区域去查找。
因此在上述代码中就只需计算成员变量的大小,因此就为12.
因此实例化后的对象的大小,只需要计算成员变量大小即可,当然,类对象大小的计算与struct一样遵循结构体内存对齐规则。
我们通过一些例子来进行深入了解:
class A1 { public: void f1() {} private: int _a; }; // 类中仅有成员函数 class A2 { public: void f2() {} }; // 类中什么都没有---空类 class A3 {}; int main() { cout << sizeof(A1) << endl; cout << sizeof(A2) << endl; cout << sizeof(A3) << endl; return 0; }
输出结果为:
👉结论:
一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象用来占位,标识对象被实例化定义出来了。
8.this指针
8.1 this指针的引出
我们通过以下代码来引入指针问题:
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; Date d2; d1.Init(2023, 2, 2); d1._year++; d2.Init(2022, 2, 2); d2._year++; cout << sizeof(d1) << endl; }
👉对于上述类,有这样的一个问题:
Date类中有 Init 这个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init
函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
这就引入了即将要学的指针的知识:
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
编译器在编译过后,它会自主的做一件事(对我们来说不能做),增加一个【this】,并且调用的地方也会被改。所以这里的“年月日”并不是我们声明的,而是【d1 or d2】的,具体到底是【d1】的还是【d2】的,如果是【d1】调用即是赋值给【d1】的,【d2】同理。(在实参或形参处千万不能自己显示去加,这是编译器自己做的,但是函数体内部可以使用这个this指针。)
函数体内部使用这个this指针:
class Date { public: // 定义 void Init(int year, int month, int day) { cout << this << endl; this->_year = year;//这里可以使用 this->_month = month; this->_day = day; } private: int _year; // 声明 int _month; int _day; }; int main() { Date d1; Date d2; d1.Init(2023, 2, 2); d2.Init(2022, 2, 2); }
输出结果为:
8.2 this指针的特性
- this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
👉8.3 this指针存在哪里?
其实编译器在生成程序时加入了获取对象首地址的相关代码。并把获取的首地址存放在了寄存器【ecx】中(VC++编译器是放在ECX中,其它编译器有可能不同)。也就是成员函数的其它参数正常都是存放在栈中。而this指针参数则是存放在寄存器中。
👉8.4 this指针可以为空吗?
我们还是借助代码来进行理解:
class Date { public: // 定义 void Init(int year, int month, int day) { cout << this << endl; this->_year = year; this->_month = month; this->_day = day; } void func() { cout << this << endl; cout << "func()" << endl; } private: int _year; // 声明 int _month; int _day; }; int main() { Date d1; Date d2; d1.Init(2023, 2, 2); d2.Init(2022, 2, 2); Date* ptr = nullptr; //ptr->Init(2022, 2, 2); // 运行崩溃 ptr->func(); // 正常运行 (*ptr).func(); // 正常运行 }
👉解析:
1.对于【ptr->func();】这行代码执行起来,最后的结果是什么样的呢?
当我们运行代码后,我们的程序是正常运行的。我们一步步分析,开始时去调用这个【func】这个成员函数时会显得十分奇怪。为什么呢?因为这是一个【date】的指针,但是却是一个空指针,结果显示却是正常运行。**大家是不是都“蒙圈”了呀!!!**怎么会是正常运行呢?
2.对于【ptr->Init(2022, 2, 2); 】这行代码运行起来之后结果是怎么样的呢?
上面两个问题我们结合起来看。首先大家是否明白【func】和【Init】是否在对象里面,在【C】语言中我们学过在对象调用就用【.】,而指针调用则用【->】,我们这个函数显然是不在对象里面的,那么要调用【func】这个函数就要转换为【call】即一个地址,那么这个地址到哪里去找呢?我们在之前说过成员函数是在公共区域,所以是去公共区域部分去找,就是代码段,所以说虽然这里有个【->】,但并没有发生解引用操作。其次,我们调用成员函数需要传递【this】指针,所以【ptr->func();】并没有解引用,但是这个【ptr】传递给了【this】指针,所以这里的【this】是个空,并不会报错,所以【ptr->func();】这行运行起来之后是正常运行。而对于【ptr->Init(2022, 2, 2); 】这行代码而言,同理调用的时候不会崩,但是紧随之后用【this】去进行解引用操作了,所以程序就会崩溃。
最后看一行代码:【 (*ptr).func(); 】 这行代码运行后结果怎么样呢?
有了上面的知识我们知道,在调用的时候这里是不会发生错误的,大家注意这里的【ptr】是传递给【this】,所以是能够正常运行的!!!
我们通过调试,在汇编情况看下这两行代码:
大家会发现这两行代码的从汇编视角下看没有区别!!!
👉注意:
有没有解引用的行为取决于要访问右边的东西在不在对象里面,而不是用没用那个符号。千万不要被事物的表面现象所迷惑!!!
到此,类和对象(上)的学习便到此结束了。大家一定要结合知识点,多去总结和理解,争取消化掉这部分知识。
本期知识如果对你有帮助的话,记得点赞三连哟!!!