👉面向过程和面向对象初步认识👈
C 语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
而 C++ 是面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
C 语言关注的是过程,简单来说就是这一步做完了,下一步做什么。而 C++ 关注的是对象,就是完成一件事有多少个对象以及对象之间的关系。
👉类的引入👈
C 语言结构体中只能定义变量,而在 C++ 中,结构体内不仅可以定义变量(兼容 C 语言的用法),也可以定义函数。比如:之前在数据结构初阶中,用 C 语言方式实现的栈,结构体中只能定义变量;现在以 C++ 方式实现,会发现struct
中也可以定义函数。
#include <iostream> using namespace std; typedef int DataType; struct Stack { // 成员函数/成员方法 void Init(size_t capacity) { _array = (DataType*)malloc(sizeof(DataType) * capacity); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _capacity = capacity; _size = 0; } void Push(const DataType& data) { // 扩容 _array[_size] = data; ++_size; } DataType Top() { return _array[_size - 1]; } void Destroy() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } // 成员变量/成员属性 DataType* _array; size_t _capacity; size_t _size; }; int main() { Stack s; s.Init(10); s.Push(1); s.Push(2); s.Push(3); cout << s.Top() << endl; s.Destroy(); return 0; }
因为 C++ 中的结构体里能定义成员变量(成员属性),也能定义成员函数(成员方法),所以 C++ 的结构体就升级成了类。而 C 语言的结构体只能定义成员变量,其成员函数不能在结构体中定义,通过这些成员函数来对成员变量进行修改。以数据结构的栈为例,C 语言的成员需要像StackInit这样来定义来区分不同的数据结构,而 C++ 的成员函数只需要像Init这样在类中定义就行了。
如果想要定义一个对象,C++ 可以直接用类名来定义。以栈为例,C++ 可以这样来定义一个栈Stack s,也可以这样来定义struct Stack s。所以在 C++ 中,就可以这样来定义链表,见下图:
struct ListNode { int val; ListNode* next; }
C++ 一开始是用struct
结构体来当做类,但是后来大佬又设计出来类class
。那么以上结构体的定义,在 C++ 中更喜欢class
来代替。
👉类的定义👈
class className { // 类体:由成员函数和成员变量组成 }; // 分号不能漏
class
为定义类的关键字,className 为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。
类体中内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或者成员函数。
所以,上面栈的结构体就可以改成下方代码的样子了。
class Stack { void Init(int N = 4) { //... } void Push(int x) { //... } int* a; int top; int capacity; };
类的两种定义方式:
- 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
- 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::
一般情况下,更期望采用第二种方式。注意:我们在平常写代码时可以使用方式一定义类,大家后序工作中尽量使用第二种。
以上我们讲到了成员函数声明和定义的方式,那对于类的成员变量有没有什么要求呢?其实也有,C++的类通常在成员变量的声明前面或者后面加上_。那为什么这样呢?其实是为了方便区分。比如日期类的初始化成员函数:
class Date1 { public: void Init(int year, int month, int day) { year = year; month = month; day = day; } private: int year; int month; int day; }; class Date2 { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };
像Date1
的声明成员变量的方式,初始化函数就比较难分清楚究竟是谁给谁赋值了,所以我们一般想Date2
那样来声明类的成员变量。
👉类的访问限定符及封装👈
访问限定符
C++ 实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
访问限定符说明:
public 修饰的成员在类外可以直接被访问。
protected 和 private 修饰的成员在类外不能直接被访问(此处protected和private是类似的)。
访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。
如果后面没有访问限定符,作用域就到 } 即类结束。
class 的默认访问权限为 private,struct 为 public (因为struct要兼容C)。
class Stack { public: void Init(int N = 4) { //... top = 0; capacity = 4; } void Push(int x) { //... } 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); //“Stack::top”: 无法访问 private 成员(在“Stack”类中声明) //st.top; //private修饰的成员变量不能在类外访问 }
注意:定义一个对象的时候,不要用class Stack
这样的方式来定义。
现在我们已经学习到了类的一点基础知识了,那现在我来问大家一个问题:C++中struct和class的区别是什么?
C++ 需要兼容 C 语言,所以 C++ 中struct可以当成结构体使用。另外 C++ 中 struct 还可以用来定义类。和 class 定义类是一样的,区别是 struct 定义的类默认访问权限是 public,class 定义的类默认访问权限是 private。注意:在继承和模板参数列表位置,struct 和 class 也有区别,后序给大家介绍。
注意:成员变量和成员函数的定义的顺序没有先后要求,对效率也没有影响,但是一般将成员函数定义在前面,成员变量定义在后面。因为 C++ 将类看成了一个整体,而 C 语言的变量和函数直接没有必然的联系,需要先定义后使用。
封装
面向对象的三大特性:封装、继承、多态。
在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如
主板上线路是如何布局的,CPU内部是如 何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以 及键盘插孔等,让用户可以与计算机进行交互即可。
在 C++ 中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
👉类的作用域👈
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用
::
作用域操作符指明成员属于哪个类域。
class Person { public: void PrintPersonInfo(); private: char _name[20]; char _gender[3]; int _age; }; // 这里需要指定PrintPersonInfo是属于Person这个类域 void Person::PrintPersonInfo() { cout << _name << " " << _gender << " " << _age << endl; }
如果我们的工程文件大了,类域就能够帮助我们做到函数的声明和定义分离,而声明和定义分离可以方便别人来看我们的代码。
// Test.h #pragma once #include <iostream> using namespace std; class Stack { public: void Init(int N = 4); void Push(int x); private: int* a; int top; int capacity; }; class Queue { public: void Init(int N = 4); void Push(int x); private: //... }; // Test.cpp #include "Test.h" void Stack::Init(int N) { //... top = 0; capacity = N; } void Stack::Push(int x) { //... } void Queue::Init(int N) { //... } void Queue::Push(int x) { //... }
注意:Stack 的 Init 函数和 Queue 的 Init函数不构成函数重载,因为它们不在同一个域里,构成函重载的函数一定在同一个域中。
👉类的实例化👈
用类类型创建对象的过程,称为类的实例化。
类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。
类就像谜语一样,对谜底来进行描述,谜底就是谜语的一个实例。谜语:“年纪不大,胡子一把,主人来了,就喊妈妈” 谜底:山羊。
一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量。
class Person { public: void PrintPersonInfo(); private: char _name[20]; char _gender[3]; int _age; }; int main() { Person._age = 18; //语法错误 return 0; }
注意:以上的成员变量均是声明,而不是定义,不能出现Person::_age = 18;或者Person._age = 18;这样的语句。只能通过先定义一个对象,然后修改该对象的公有的public成员变量。因为 Person 类是没有空间的,只有 Person 类实例化出的对象才有具体的年龄。
打个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。
👉类对象模型👈
如何计算类对象的大小
class A { public: void PrintA() { cout << _a << endl; } private: char _a; };
类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?
注:类也是要考虑内存对齐的。那么我们现在来想一下sizeof(A)为什么等于 1?其实sizeof(A)等于 1 ,也就是说明了类不包含成员函数。如果类包含了成员函数,也就是包含了函数指针,sizeof(A)应该为 8。
类对象的存储方式猜测
- 对象中包含类的各个成员
缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。
- 代码只保存一份,在对象中保存存放代码的地址
如果按照该方式存储,当一个类创建多个对象时,每个对象都要保存一份函数表,也不是最优的设计方式。
- 只保存成员变量,成员函数存放在公共的代码段
这种方式是类最好的设计方式,将公共的成员函数放在一个每个对象都能找到的地方,也就是常量区(代码段)。按照这种方式来存储,用一个类来创建多个对象时就不需要每个对象都保存一份代码或者保存一份函数表,这样就起到了节省空间的作用。
结构体内存对齐规则
- 第一个成员在与结构体偏移量为 0 的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。 注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的对齐数为 8。
- 结构体内存对齐规则
第一个成员在与结构体偏移量为 0 的地址处。
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。 注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的对齐数为 8。
【面试题】
- 结构体怎么对齐? 为什么要进行内存对齐?
- 如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
- 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景。
注:类也遵循以上的内存对齐规则
现在我们知道了内存对齐规则和类对象的存储方式,我们来看一下以下类的大小。
#include <iostream> using namespace std; // 类中既有成员变量,又有成员函数 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; A2 aa2; A2 aaa2; cout << &aa2 << endl; cout << &aaa2 << endl; return 0; }
结论:一个类的大小,实际就是该类中成员变量之和。当然要注意内存对齐。同时我们还要特别注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。
👉this指针👈
this指针的引出
我们先来定义一个日期类Date
:
#include <iostream> using namespace std; 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, d2; d1.Init(2022, 11, 3); d2.Init(2022, 11, 4); d1.Print(); d2.Print(); return 0; }
对于上述类,有这样的一个问题:Date类中有Init与Print两个成员函数,函数体中没有关于不同对象的区分,那当d1调用Init函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++ 中通过引入 this 指针解决该问题,即:C++编译器给每个非静态的成员函数增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
所以,以上的代码还可以改成下方代码的样子,但是没什么必要。注意:this指针的定义和传递,都是编译器干的活,我们不能去抢,但是我们可以在类里面用this指针
#include <iostream> using namespace std; class Date { public: void Init(int year, int month, int day) { this->_year = year; this->_month = month; this->_day = day; } //void Print(Date* this) // 错误的写法 // this指针的定义和传递,都是编译器干的活,我们不能去抢 // 但是我们可以在类里面用this指针 void Print() { cout << this->_year << "-" << this->_month << "-" << this->_day << endl; } private: int _year; // 年 int _month; // 月 int _day; // 日 }; int main() { Date d1, d2; d1.Init(2022, 11, 3); d2.Init(2022, 11, 4); //d1.Print(&d1) // 错误的写法 d1.Print(); d2.Print(); return 0; }
this指针的特性
- this 指针的类型:类* const,即在成员函数中不能给 this 指针赋值。
- 只能在成员函数的内部使用。
- this 指针本质上是成员函数的形参,当对象调用成员函数时,将对象地址作为实参传递给 this 形参。所以对象中不存储 this 指针。
静态成员函数没有 this 指针,只有非静态成员函数才有,且为隐藏指针。
非静态成员函数的第一个参数就是隐藏的 this 指针。
this 指针是成员函数第一个隐含的指针形参,一般是存在函数栈帧中。而 VS 编译器通过 ecx 寄存器自动传递,不需要用户传递。
几道面试题
this 指针存在哪里?
很多人的答案是常量区(代码段)或者是对象里面,但其实不是。this 指针一般存在非静态的成员函数的函数栈帧里面,而 VS 是通过 ecx 寄存器来传递,以提高效率。
this指针可以为空吗?
#include <iostream> using namespace std; // 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void Print() //A* const this { cout << "Print()" << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); //Print(p) return 0; }
我们知道,成员函数的地址不存在对象里,而是存在公共的代码段中,所以 p 不会发生解引用。因为 p 已经是类的指针了,所以直接将 p 传递给 this 指针。而 Print 函数也没有对 this 指针进行解引用,所以程序正常运行。
我们再来看另一道题目:
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void PrintA() { cout << _a << endl; //this->_a 发生了空引用的解引用 } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; }
这个代码和上面的代码挺相似的,但是也有点不一样。p 为空指针,但不会对 p 解引用找到 PrintA 函数,因为成员函数都在代码段里,不放在对象里。那么将 p 转给 this 指针,所以 this 指针为空指针。而 PrintA 函数里对 this 指针进行了解引用操作,所以导致运行崩溃的错误,我们可以通过调试来看一下。
所以 this 指针是可以为空指针的,但这时候就要避免对 this 指针的解引用操作。
C 语言和 C++ 实现 Stack 的对比
//Stack.h #pragma once #include <stdio.h> #include <stdlib.h> #include <assert.h> #include <stdbool.h> typedef int STDataType; typedef struct Stack { STDataType* a; int top; int capacity; }ST; void StackInit(ST* ps); void StackDestroy(ST* ps); void StackPush(ST* ps, STDataType x); void StackPop(ST* ps); STDataType StackTop(ST* ps); bool StackEmpty(ST* ps); int StackSize(ST* ps); //Stack.c #include "Stack.h" void StackInit(ST* ps) { assert(ps); ps->a = NULL; ps->capacity = ps->top = 0; } void StackDestroy(ST* ps) { assert(ps); free(ps->a); ps->a = NULL; ps->capacity = ps->top = 0; } void StackPush(ST* ps, STDataType x) { assert(ps); if (ps->capacity == ps->top) { int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2; STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType)); if (tmp == NULL) { perror("relloc fail"); exit(-1); } ps->a = tmp; ps->capacity = newcapacity; } ps->a[ps->top] = x; ps->top++; } void StackPop(ST* ps) { assert(ps); assert(!StackEmpty(ps)); ps->top--; } STDataType StackTop(ST* ps) { assert(ps); assert(!StackEmpty(ps)); return ps->a[ps->top - 1]; } bool StackEmpty(ST* ps) { assert(ps); return ps->top == 0; } int StackSize(ST* ps) { assert(ps); return ps->top; } //Test.c #include "Stack.h" void TestStack() { ST st; StackInit(&st); StackPush(&st, 1); StackPush(&st, 2); StackPush(&st, 3); StackPush(&st, 4); StackPush(&st, 5); printf("size = %d\n", StackSize(&st)); while (!StackEmpty(&st)) { printf("%d ", StackTop(&st)); StackPop(&st); } StackDestroy(&st); } int main() { TestStack(); return 0; }
可以看到,在用C语言实现时,Stack相关操作函数有以下共性:
每个函数的第一个参数都是Stack*
函数中必须要对第一个参数检测,因为该参数可能会为NULL
函数中都是通过Stack*参数操作栈的
调用时必须传递Stack结构体变量的地址
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。
//Stack.h #include <iostream> #include <assert.h> using namespace std; typedef int STDataType; class Stack { public: void Init(int N = 4); void Push(STDataType x); void Pop(); STDataType Top(); bool Empty(); int Size(); void Destroy(); private: STDataType* _a; int _top; int _capacity; }; //Stack.cpp void Stack::Init(int N) { assert(this); _a = (STDataType*)malloc(sizeof(STDataType) * N); if (_a == nullptr) { perror("malloc fail"); exit(-1); } _top = 0; _capacity = N; } void Stack::Push(STDataType x) { assert(this); if (_top == _capacity) { int newcapacity = _capacity * 2; STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType)); if (tmp == nullptr) { perror("realloc fail"); exit(-1); } _a = tmp; _capacity = newcapacity; } _a[_top] = x; _top++; } void Stack::Pop() { assert(this); assert(!Stack::Empty()); // 判断栈是否为空 _top--; } STDataType Stack::Top() { assert(this); assert(!Stack::Empty()); // 判断栈是否为空 return _a[_top - 1]; } bool Stack::Empty() { assert(this); return _top == 0; } int Stack::Size() { assert(this); return _top; } void Stack::Destroy() { assert(this); free(_a); _a = nullptr; _top = 0; _capacity = 0; } //Test.cpp void TestStack() { Stack st; st.Init(); st.Push(1); st.Push(2); st.Push(3); st.Push(4); st.Push(5); while (!st.Empty()) { cout << st.Top() << endl; st.Pop(); } st.Destroy(); } int main() { TestStack(); return 0; }
C++ 中通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限可以控制那些方法在类外可以被调用,即封装。在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。而且每个方法不需要传递 Stack* 的参数了,编译器编译之后该参数会自动还原,即 C++ 中 Stack * 参数是编译器维护的,C语言中需用用户自己维护。
👉总结👈
本篇博客主要讲解了面向过程和面向对象的区别、类的定义、作用域、大小以及 this 指针。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!💖💝❣️