本篇本章正式进入C++的类和对象部分,本部分知识分为三小节。
复习:
- 结构体复习–内存对齐
- 编译和链接
- 函数栈桢的创建和销毁
一、面向过程与面向对象
1.1 面向过程
在之前学习的C语言是一种面向过程的语言,它关注的是求解问题的具体实现过程,一般通过函数调用逐步解决问题。就比如说我们以面向过程的方式分析淘米:
1.2 面向对象
C++就是一种面向对象的语言,它将一个问题分成多个对象,更强调对象与对象之间的联系。在淘米煮饭这个例子中,一共有四个对象:米、水、锅、人。
整个过程中,这四个对象之间是交互完成任务的。在面向对象中,我们更强调对象之间的连续,并不太在意其内在是如何完成的。
二、类的定义(class/struct)
2.1 类定义格式
- class是定义类的关键字,Stack是类的名称,{ }中是类的主体,注意类定义结束时后面的分号不能省略。类中的内容叫做类的成员,包括:类的属性或成员变量;类中的函数称为类的方法或成员函数。C语言中的结构体是类和函数分离的,但是C++不是。
#include<iostream>
using namespace std;
class className//类的定义方式1——class
{//成员函数void Push(int x);void Pop();int Top();//成员变量int* a;//数组int top;int capacity;
};
- C++将C语言中的结构体升级为类。C语言中结构体只能定义变量,在C++中,类不仅可以定义变量,还可以定义函数。
| struct 在C/C++中的用法不同点 | C语言 | C++ |
| — | — | — |
|
1. 可以定义的内容
| struct 只能定义变量 | struct可以定义变量、函数 |
|
2. 类型
| struct +结构体名 | 类名 |
|
3. 定义变量的方法
| struct +结构体名才可以定义变量 | 直接类名即可定义,不需要struct 关键字,也可以写(旧法) |
|
4. typedef
| 通常用typedef使类型名称简单些 | 直接类名就可以用 |
|
5. 能否定义类
| C语言中struct 可以定义类 | C++中struct 可以定义类 |
//test.c
//在C语言中,一般都会使用typedef
struct Node1//结构体
{struct Node1* next;//注意这里:struct Node1*才是类型名,才能用它定义变量nextint val;
};//test.cpp
#include<iostream>
using namespace std;
struct Node2//类的定义方式2——struct
{Node2* next;//区别改变在这里,直接用类名,int val;void top();
};//这一中在C语言中是不能通过的!!!!C++兼容C语言,C语言不兼容C++!!!
int main()
{//test.cppNode2 n1;//定义n1struct Node2 n2;//定义n2,C语言的用法C++也支持!return 0;
}
- 定义在类里面的函数默认为 inline 内联的。
也可以进行声明和定义的分离(比如声明和定义在不同文件中)
**a. 声明和定义没有分离。**注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
//1. 声明和定义没有分离:
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day){_year = year;month = month;day = day;}private:int _year;int _month;int _day;
};
b.声明和定义分离。声明放在头文件(.h)中,定义放在源文件(.cpp)中。 声明和定义分离需要指定类域,就使用了::域作用限定符。
//test.h
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day);//声明
private:int year;int month;int day;
};//test.cpp
void Date::Init(int year, int month, int day)//定义//声明和定义分离需要指定类域,就使用了域作用限定符
{ //Init是类Date的成员函数,只是声明和定义分离了而已year = year;month = month;day = day;
}
2.2 类的访问限定符
在C++中有三种访问限定符:public、private、protected 。
- public修饰的成语言在类外可以直接被访问;
- protected和private修饰的成员在类外不能直接被访问;
- 访问权限作用域从该访问限定符出现的位置开始一直到下一个访问限定符出现为止,如果后面没有访问限定符,作用域就到 } 即类结束;
- class的默认访问限定符是private,struct的默认访问限定符是public。(因为struct要兼容C)
- ⼀般成员变量都会被限制为private/protected,需要给别人使用的成员函数会放为public。protected一般在继承中比较常用。
#include<iostream>
using namespace std;
class Date
{
public://可以访问void Init(int year, int month, int day)//非成员变量{_year = year;_month = month;_day = day;}
private://不可以访问int _year;//成员变量int _month;int _day;
};
2.3 成员变量与非成员变量的区分
从下面的代码可以看出,类中的成员变量和非成员变量之间存在着重名冲突。
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day)//非成员变量{year = year;month = month;day = day;}
private:int year;//成员变量int month;int day;
};
C++标准并没有规定成员变量的命名规则。为了区分出成员变量,惯例上,我们在定义成员变量时会对其进行特定地修饰,比如_变量名或者是m_变量名或者变量名_。 不同公司可能都有一套自己的命名规则,但目的只是为了区分出成员变量。
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day)//非成员变量{_year = year;_month = month;_day = day;}
private:int _year;//成员变量int _month;int _day;
};
2.4 变量声明和定义的区别
-
变量的声明:告知变量的名称、类型,没有开辟空间;
-
变量的定义:开辟了空间。如果是定义,就有空间,就可以直接使用/访问。如下面main函数中的_year。
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day);//声明
private:int _year;//是定义还是声明????————声明int _month;int _day;
};//test.cpp
void Date::Init(int year, int month, int day)//定义
{ _year = year;_month = month;_day = day;
}
int main()
{Date::_year = 2024;//error,所以不能直接使用变量,说明没有开辟空间,_year是声明return 0;
}
三、类的作用域
3.1 类域的基本特点
- 同一个域里面不能定义同名变量,但是不同的域里面可以定义重名变量。
- 类域和命名空间域只影响名字隔离,不影响各自的生命周期。
- 类域影响的是编译的查找规则, 查找默认在局部和空间去找,不会在命名空间域中去查找,除非使用== ::限定符 ==指定了类域。
3.2 编译器的查找规则
- (图源于博主:vpurple__)
四、类对象模型
4.1 类对象的实例化
类:是对对象进行描述的、就像模型一样的东西。
类限定了类有哪些成员,类中的成员变量是一种声明,并没有分配实际的内存空间来存储它。就比如我们可以根据一张图纸(没有实际空间),创建出好几座不一样的房屋(有实际空间)。二者不一样的房子就是类实例化出的对象。
用类名定义对象的方式,称为类的实例化。单独的类并不占据实际空间。而且一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类的成员变量。
#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()
{//实例化出d1,d2两个对象Date d1;//Date类名就是一个类型,直接用来定义Date d2;d1.Init(2024, 7, 10);/d1.Print();d2.Init(2024, 1, 1);d2.Print();return 0;
}
4.2 类对象的存储
4.2.1 成员变量和成员函数的存储???
#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;Date d2;d1.Init(2024, 7, 10);//使用 . 操作符访问成员对象d1.Print();d2.Init(2024, 1, 1);d2.Print();return 0;
}
上面的代码,透过汇编观察可以发现,类定义的不同对象虽然成员变量不同,就需要各自存储他们;但是调用的成员函数其实是一样的(地址是一样的),如果在每个对象里面都存储一份,就会有空间的重复浪费。(例如:用类实例化出100个对象,每个成员函数都要重复地存储100次,实属浪费!)
其实,函数指针是不需要存储的。函数指针是一个地址,调用函数被汇编成汇编指令[call地址、jmp指令]。编译器在编译链接时就要找到函数的地址,不是在运行时找;而动态多态是在运行时找的,就需要存储函数地址。——跳转到下面的this指针
总结:
- 类定义出的不同对象是各自存储成员变量的。
- 通过汇编发现,成员函数是在编译链接时找到函数地址,函数地址存储在一个公共的地方,然后编译成汇编指令[如call地址、jmp指令等]的。
- 对象中只存储成员变量,而且函数体中并没有关于不同对象的区分,那么不同对象在调用成员函数Init、Print时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?——这里就引出了this指针!!!(先跳转学习this指针!)
4.3 类对象的大小
4.3.1 一般类的计算
C++的内存对齐规则和C语言结构体中的内存对齐规则一模一样!
· 第一个成员在与结构体偏移量为0的地址处;
· 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处;
· 注意:对齐数=编译器默认的一个对齐数与该成员大小的较小值;
· VS中默认的对齐数为8;
· 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍;
• 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小;
·就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍;
C语言:自定义类型——结构体(✿༺小陈在拼命༻✿)(这一部分知识可以参考这位优秀博主)
计算A实例化对象的大小:
#include<iostream>
using namespace std;
class A
{
public:void Print(){cout << _ch << endl;}
private:char _ch;int _i;
};
int main()
{A a;cout << sizeof(a) << endl;cout << &a << endl;return 0;
}
运行结果:
画图分析:
Q:为什么要内存对齐?
A:CPU读取内存数据时,并不是从任意位置开始读取的,读取与CPU的字长、数据总线有关,
如果CPU可以从任一位置读取,那么内存对齐规则不仅麻烦,还浪费时间。为什么还要有对齐规则呢?
读取数据与数据总线有关,32根数据总线(4Byte,数据总线数量与机器型号有关)----计算机组成原理。规定:从起始点开始,不管数据有多少,CPU一次都读取32个数据,但是这里面也许只有1个数据是CPU真正想读取的。
综合来看,内存对齐规则减少访问次数、提高了CPU的效率。
4.3.2 空类的计算
空类:类中只有成员函数或者什么也没有时,这个类就叫做空类。
//计算⼀下B/C实例化的对象是多⼤?
#include<iostream>
using namespace std;
class B
{
public:void Print(){//...}
};
class C
{};
int main()
{B b;C c;cout << sizeof(b) << endl;cout << &b << endl;cout << sizeof(c) << endl;cout << &c << endl;return 0;
}
运行结果:
B和C都是空类。首先类对象的大小不能为0,否则不能表示该对象存在过,而且还要通过B对象开空间去实例化呢。因此,C++给空类1Byte的空间用来占位标记这个类的对象的存在,实际操作中的使用性也很少。
五、this指针
5.1 this指针的引入
先观察这段代码:
#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;Date d2;d1.Init(2024, 7, 10);//使用 . 操作符访问成员对象d1.Print();d2.Init(2024, 1, 1);d2.Print();return 0;
}
运行结果:
根据前面 3.2.1 的分析可知,类中的成员函数并没有存储在类的对象中,而且函数体中并没有关于不同对象的区分,那么函数是如何知晓自己应该访问哪个对象的呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数”增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象)(因此,this指针是一个当前类类型的指针,传参就要传入&类名定义的对象名,如 &d1)。
在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。这个隐藏的指针参数就是this指针。
类的成员函数中访问成员变量,本质都是通过this指针来访问的(编译时编译器自动会处理)。如Init函数中给_year 赋值:this -> _year = year;
加入this指针后的原型代码如下面的注释:
#include<iostream>
using namespace std;
class Date
{
public://原型:void Init(Date* const this, int year, int month, int day)void Init(int year, int month, int day){_year = year;_month = month;_day = day;}//原型:void Print(Date* const this)//const修饰的是thisvoid Print(){cout << _year << "-" << _month << "-" << _day << endl;//原型可以显示写:cout << this -> _year << "-" << this -> _month << "-" << this -> _day << endl;}
private:int _year;int _month;int _day;//this->_year = year;原型//this->_month = month;//this->_day = day;//这个this->可以显示地写出来,因为调用函数体中的this指针
};
int main()
{Date d1;Date d2;d1.Init(2024, 7, 10);//原型:d1.Init(&d1, 2024, 7, 10);d1.Print(); //原型:d1.Print(&d1);d2.Init(2024, 1, 1); //原型:d2.Init(&d2,2024,1,1);d2.Print(); //原型:d2.Print(&d2);return 0;
}
5.2 this指针的特点
- this指针的类型:this指针是一个当前类类型的指针, const ,所以成员函数中,不能给this指针赋值,不能修改this,可以修改this指针指向的内容的;*
- this指针本质上是成员函数第一个隐含的指针形参(所以this指针存储在函数栈桢中),当对象调用成员函数时,将对象地址作为实参传递给this形参,所以对象中不存储this指针;(之前计算对象实例化大小的时候都没有计算this指针的大小)
- this指针是指针形参,一般情况下都由编译器通过ecx寄存器自动传递,不需要用户和传递;(VS编译器)
- C++规定不能在实参和形参的位置显示的写this指针(编译时编译器会处理),但是可以在函数体内显示使用this指针。(如上面的代码例子)
5.3 Question
5.3.1 Q1
- 下面程序编译运行结果是(C.)
- A.编译报错
- B. 运行崩溃
- C. 正常运行
#include<iostream>
using namespace std;
class A
{
public:void Print(){//cout << this << endl;//输出空指针0000000,不会报错,因为没有使用!!cout << "A::Print()" << endl;}
private:int _a;
};
int main()
{A* p = nullptr;//p是一个nullptr指针空值,类型为 A* ,是类类型//mov ecx pp->Print();//call 地址//p->Print(p);//这里不需要取地址!!!//p -> _a = 1;//error这个才是空指针解引用,_a存储在对象里面的(_a是public时)return 0;
}
运行结果:,???
**程序正常运行,**分析过程:
- 编译器被编译后,底层都是将其转换成汇编指令。p->Print(); 转化的底层汇编是 call 地址,因为成员函数的地址指针不存储在对象里,该地址不在 p 对象里,编译成汇编指令后在符号表里面。
- 调用函数需要传递参数,参数传递 this 指针,this是类类型的一个指针,这里不需要取地址,因为 p (实参,它已经是地址了,不用取地址!)就是类类型指针,传递给行参,但是并没有使用该空指针,因此没有空指针报错!p->Print(); 并不是空指针解引用!!
- 成员函数并不存放在类对象中,而是存放在公共代码段。虽然我们表面看上去解引用,但实际上编译器不需要通过解引用去找对应函数,只需要去公共代码区执行对应函数即可。因此并不是空指针解引用!
5.3.2 Q2
- 下面程序编译运行结果是(B.)
- A.编译报错
- B. 运行崩溃
- C. 正常运行
#include<iostream>
using namespace std;
class A
{
public:void Print(){cout << "A::Print()" << endl;cout << _a << endl;//这里的_a是通过this指针解引用访问的,空指针解引用!!!! }
private:int _a;
};
int main()
{A* p = nullptr;p->Print();//p->_a = 1;//error,这里就是在类外面访问的,本来是被限制了的return 0;
}
运行结果:
程序运行崩,过程分析:
访问对应的成员变量,会传递对应对象的地址。而这里的地址为nullptr
,通过nullptr->_a
引起程序崩溃。
六、 C++和C语言实现Stack对比
6.1 封装
面向对象有三大特性:封装、继承、多态。
封装的概念: 用类将对象的属性(数据)与操作数据的方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
封装本质上是一种管理,让用户更方便使用类。 比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
类也是一样,我们使用类将数据和方法都封装起来。不想对外开放的就用 protected/private 封装起来,用 public 封装的成员允许外界对其进行合理的访问。 所以封装本质上是一种管理。
6.2 C语言实现Stack
//Stack.c
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{STDataType* a;int top;int capacity;
}ST;
void STInit(ST* ps)
{assert(ps);ps->a = NULL;ps->top = 0;ps->capacity = 0;
}
void STDestroy(ST* ps)
{assert(ps);free(ps->a);ps->a = NULL;ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{assert(ps);// 满了, 扩容if (ps->top == ps->capacity){int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *sizeof(STDataType));if (tmp == NULL){perror("realloc fail");return;}ps->a = tmp;ps->capacity = newcapacity;}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--;
}
STDataType STTop(ST* ps)
{assert(ps);assert(!STEmpty(ps));return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{assert(ps);return ps->top;
}
int main()
{ST s;STInit(&s);STPush(&s, 1);STPush(&s, 2);STPush(&s, 3);STPush(&s, 4);while (!STEmpty(&s)){printf("%d\n", STTop(&s));STPop(&s);}STDestroy(&s);//s.a[s.top]可以直接访问栈顶元素,但是不规范//但是这种访问方式并不好,万一栈是空栈时,越界!//所以用C语言这样的实现不怎么规范,而且存在风险!!!return 0;
}
6.3 C++实现Stack
//Stcak.cpp
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:// 成员函数void Init(int n = 4){_a = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == _a){perror("malloc申请空间失败");return;}_capacity = n;_top = 0;}void Push(STDataType x){if (_top == _capacity){int newcapacity = _capacity * 2;STDataType* tmp = (STDataType*)realloc(_a, newcapacity *sizeof(STDataType));if (tmp == NULL){perror("realloc fail");return;}_a = tmp;_capacity = newcapacity;}_a[_top++] = x;}void Pop(){assert(_top > 0);--_top;}bool Empty(){return _top == 0;}int Top(){assert(_top > 0);return _a[_top - 1];}void Destroy(){free(_a);_a = nullptr;_top = _capacity = 0;}
private:// 成员变量STDataType * _a;size_t _capacity;size_t _top;
};
int main()
{Stack s;s.Init();s.Push(1);s.Push(2);s.Push(3);s.Push(4);while (!s.Empty()){printf("%d\n", s.Top());s.Pop();}s.Destroy();return 0;
}
C++中数据和函数都放到了类里面,通过访问限定符进性了限制,不能再随意通过对象直接修改数据,这是C++封装的⼀种体现,这个是最重要的变化。这里的封装的本质是一种更严格规范的管理,避免出现乱访问修改的问题。
想想我们是如何管理陕西省的兵马俑的。我们若什么都不管,兵马俑就被随意破坏了。所以我们建立了一座房子将兵马俑封装起来。但是我们封装的目的不是为了不给别人看,所以我们开放了售票通道,人们可以通过买票突破封装,在合理的监管机制下进去参观。
创作不易,喜欢的uu记得三连支持一下哦!