上一篇博客我们介绍了一些C++入门相关的一些知识点,这期博客我们就继续往后去学习有关类和对象的一些知识。
一、类的引入
在C语言中我们没有类这个概念,所以在使用结构体时定义的是一个类型,并且里面只能定义变量,而在C++中引入了类可以理解为C语言中的结构体,但有一点区别,这个不急,下面我会详细的跟大家解释,类中不仅可以定义变量,还可以定义函数。
就拿数据结构中C语言实现栈时,结构体中只能定义变量:
typedef int Datatype;
typedef struct Strack
{//定义一个结构体,确定栈的类型与结构,这里的定义可以参考顺序表的实现
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize+6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
现在我们用C++的类来进行实现:
typedef int Datatype;
typedef struct Strack
{
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
在这个Strack这个类中,声明的变量与函数顺序没有先后之分,即使函数中所使用的变量在声明的变量之前, 系统仍然可以找到。
下面让我们仔细研究一下这个类:
从上面的运行结果看,一点问题都没有,想要使用类中的内部函数就和结构体使用内部的变量一样,相比起C语言还要方便不少。
但C++是C++不是C语言,C语言里习惯用struct来定义结构体,而C++则习惯用class来表示类 。
二、类的定义
在C++中struct与class都可以用来表示类,但在C++中还是以class为标准为好,至于为什么别急,下面会提到。
用struct和class两个关键字定义类的具体操作为:
//在C++中并不推荐
struct classname
{
//存放成员变量以及成员函数的类体
}; //后面的分号记住一定不能丢
class classname
{
//存放成员变量以及成员函数
};//后面的分号记住一定不能丢
看上去定义的操作基本差不多,但其实他们还是有一定的差别的,下面就让我们用一些例子来给大家演示一下,以更好的理解它们的区别,这里我们将函数在类中的声明与函数的定义分开,再进行相关的操作:
Strack.h:我们在该文件中声明函数
Strack.cpp:我们在该文件中定义函数
注意:这里我们没有在类中定义函数,所以我们使用类名的命名空间用来说明这个函数来自于类中是局部函数而不是全局函数。
下面让我们编译一下上述代码试试看,发现没有问题:
那么我们将这里的struct换成class试试看还能不能正常编译:
可以看到这里发生了一些问题:
这里的错误显示无法访问private成员,为什么会发生这种情况,这就涉及到访问限定符了。
三、类的访问限定符以及封装
3.1 访问限定符
我们在创建一个类的时候,其内部有它的默认访问方式,而我们可以通过访问限定符来改变其内部的访问方式。
访问限定符一共有三种:public(公用)、private(私有)、protect(保护)。
&注意:
1.public修饰的成员可以在类之外直接被访问。
2.private与protect修饰的成员在类之外不能被直接访问(这里的private与protect的功能类似)
3.访问权限的作用域从遇到的第一个访问限定符的位置开始到下一个访问限定符的位置结束。
4.如果作用域的后面没有访问限定符的话,则到“ } ”为止,即类的结束。
5.class的默认的访问权限为private,而struct的默认访问权限为public(为了兼容C)。
知道上面有关类的访问限定符的知识点后让我们再回来看上述的代码,这里我们对Strack.h的类进行一些修改:
这里我们再编译一下试试,发现成功了:
3.2 封装
面向对象一共具有三大特性:封装、多态、继承(对于这些点后期会详细介绍,咱先从封装开始),在类和对象阶段主要研究封装这一特性,那么问题来了,什么是封装你呢? 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。封装本质上是一种管理,让用户更方便的使用类。
举个例子吧:就比如现实中的汽车,我们开车的时候只需知道油门、刹车、方向盘、挂挡以及一些小功能是怎么操作与使用的,知道这些我们就可以开车了,但对于汽车如何动,以及具体的操作原理我们是不清楚的,实际操作的实现依赖于发电机与燃油机等一些精密仪器的操作实现的,但对于这些我们无需了解,所以那些厂家就为车子装上外壳,只展示关键的部件与按钮,其他的东西都被隐藏了起来,这就是封装;亦或者我们推销产品的时候,往往也会进行封装,将项目的优点展现出来,缺点等一些地方将它隐藏起来,这样可以提高顾客的购买欲。
而在C++中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来限制访问,隐藏对象内部控制细节,控制哪些方法可以在类的外部直接被使用。
四、类的实例化
我们在用定义的类创建一个对象时就是将类进行实例化,如果把它近似成C语言中的结构体来理解的话,这里的类可以看成结构体,而这里的对象则指的是结构体变量,类比成我们熟悉的C语言就比较好理解了。
#include <stdio.h>
#include <assert.h>
#include <string.h>
#include <iostream>
typedef int Datatype;
typedef class Strack
{
public:
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
private:
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
int main()
{
Strack S;
S.StrackInit(&S);
S.StrackPush(&S,50);
S.StrackPush(&S,60);
return 0;
}
上面的Strack S就是将类进行实例化,生成了一个名为S的对象。
五、类对象内存的计算
那么问题来了,我们所创建的对象内存有多大呢?是否要像结构体一样要内存对齐呢?类中的成员函数是否占内存呢?
下面我们来一步一步的进行解答:
由上述的运行结果可以看出来,类和结构体一样,内部函数不占内存空间,只有其内部的成员变量以内存对齐的方式占据内存空间。
5.1 结构体内存对齐
对结构体内存对齐的方式不熟悉的小伙伴可以点击下面的连接:
结构体内存计算详解,及scanf函数返回值和匿名结构体的解析_结构体函数返回值问题-CSDN博客
这里就简单的口述一下结构体内存对齐的方式是怎么样的吧:就拿上述的代码为例吧,对于上述代码的结构体成员变量:
struct strack
{
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
struct strack
{
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int maxsize;//表示栈所能储存的最大的长度
};
上述的两种结构体看似差不多,但所占的大小的空间内存的大小是不一样的,可以看一些运行的结果:
可以看到一个是24,另一个为16,发生这种情况的原因就是结构体内存对齐,在计算结构体内存时我们要知道每个结构体成员的初始存储位置对于结构体存储的初始位置是有差别的,被称为偏移,偏移的多少被称为偏移量,这样也可以得出一个结论,结构体中成员的内存不是一个挨着一个的,他们是按一定规则进行对齐的,详细的规则我就不多赘述了,简单一点说,对于一个结构体的成员在一块内存中,第一个成员在该内存的首位,也就是第0位,从第二个成员开始其需要将自身大小与环境的默认对齐数进行比较,之后取小的那一个作为偏移值,在VS编译环境对齐数的默认值为8,如果这时的成员为int型,4比8要小,所以这里对齐数按4取,所以将第二个成员放在距第一个元素差四个内存的位置,在这之后的所有成员都按4这个对齐数没四个空间存储一个,这样结构体成员的内存分布就会更加有序,更方便于查找,但内存消耗会增大,相当于牺牲空间来增加效率,所以建议把较小的变量放在结构体中,具体的细节部分可以看上面推荐的那一篇博客,那里面讲的更加详细。
5.2 函数在类中不占内存空间
我们可以试着想一下,当我们用类创建了好多好多不同的对象,每个对象都有内部函数,且都占内存空间,但是这些函数都是同一个函数,执行的操作都是相同的,那这样不就造成空间的巨大浪费吗?
5.3 空类的内存空间定义
当一个类中只有成员函数,或者什么都没有,没有函数没有成员变量,其内存应该怎么计算?下面举个例子说明:
class A//类中什么都没有,没有成员变量也没有成员函数
{
};
class B
{//类中只有成员函数,没有其他的成员变量
void add()
{
return;
}
};
运行结果如下:
这里我们看到仅有成员函数或仅有成员变量是类只占一个字节的内存空间(编译器通过一个字节来唯一的表示这些类的对象)
六、this指针
6.1 this指针的引出
我们还是看一下Strack这个类吧:
typedef int Datatype;
class Strack
{
public:
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个类的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
private:
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
int main()
{
Strack S1;
Strack S2;
S1.StrackInit(&S1);
S2.StrackInit(&S2);
return 0;
对于上述的类有着这样的问题:Strack类中有StrackInit()函数,但函数体中没有区别对象的操作,那么当S1调用StrackInit()函数时,该函数怎么知道是设置S1对象还是S2对象呢?
C++通过使用this指针来解决上述的问题:即C++编译器给每个“非静态的成员函数”增加了一个隐藏的this指针参数,让该指针指向当前的对象(即此时调用该函数的对象),在函数体中所有“成员变量”相关的操作都是由this指针来进行访问的,只不过这里面所有的操作都是透明的,用户不用去担心怎么去传递,这些编译器会自己完成。
下面我用图示来解释一下this指针的原理:(实际上写代码不能按下面的方法进行操作)
虽然我们不能手动添加this指针,但在类中我们是可以使用它的,下面这段代码可以很好的考出this指针的存在:
class Strack
{
public:
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个类的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
cout << this << endl;
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
private:
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
int main()
{
Strack S1;
Strack S2;
S1.StrackInit(&S1);
cout << &S1 << endl;
S2.StrackInit(&S2);
cout << &S2 << endl;
return 0;
}
运行结果:
6.2 this指针的特性
1. this指针的类型为类型* const,是不可以修改的,即成员函数中,不能给this指针赋值。
2. 由于this指针是类中成员函数的形参指针,故只能在类中的“成员函数”的内部使用,在类的外部无法直接使用。
3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针,类中存储,详情可以看上面的概念图。
4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过栈/ecx寄存器(vs环境下)自动传递,不需要用户传递。这里引用了这篇博客的知识,详情可以看一下原篇。
原文链接:https://blog.csdn.net/m0_70811813/article/details/129653900
6.3 关于this指针一些其他问题
6.3.1 this指针存放在哪里?
答:this指针作为形参存放在栈中。
6.3.2 this指针空值的问题
首先我们看两个例子:
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
运行结果如下:
这里的p指针就相当于this指针,这里将this指针置空由下图可以将其理解为红线没了,因为置空了嘛,所以谁也不指了,但由于这里的Print()函数是存在于代码段中的,所以可以通过p指针来找到它的位置,也就是下图的蓝线,此时传入的this指针为空,由于此时函数内没有对this指针进行解引用操作,所以可以执行函数的内容。
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a=5;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
这里看到程序运行崩溃了,因为这里的函数要打印对象成员变量的值,但此时this指针已经置空,而使用对象成员变量时必须要有this指针来指明当前调用函数的对象的地址然后进行解引用操作才能使用成员比变量,所以就崩溃了。
七、C语言与C++实现栈的对比
C语言实现:
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
typedef int Datatype;
typedef struct Strack
{//定义一个结构体,确定栈的类型与结构,这里的定义可以参考顺序表的实现
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackDestory(Strack* ps)
{//栈的销毁
assert(ps);//传入的指针不能为空
free(ps->Data);//将指向栈地址的指针释放
ps->maxsize = 0;//因为栈被销毁,所以栈的最大长度变为0
ps->top = 0;//因为栈被销毁,所以栈的实际长度同样也变为零
ps = NULL;//将指针置空,防止野指针的产生
printf("栈销毁成功\n");
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize+6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
void StrackPop(Strack* ps)
{//出栈操作
assert(ps);//传入的指针不能为空
if (ps->top > 0)
--ps->top;//用top控制元素的出栈入栈,top-1代表删了栈顶元素,至于里面值是多少不管
else
printf("此表已空\n");
}
Datatype StrackTop(Strack* ps)
{//获取栈顶元素数据
assert(ps);//传入的指针不能为空
assert(ps->top > 0);//栈不能为空
return ps->Data[ps->top - 1];//返回栈顶元素
}
int StrackSize(Strack* ps)
{//获取栈中有效元素个数
assert(ps);//传入的指针不能为空
return ps->top;//此时栈顶元素下标-1就为栈中有效元素个数
}
void StrackEmpty(Strack* ps)
{//判断栈是否为空栈
assert(ps);//传入的指针不能为空
if (ps->top == 0)
{
printf("栈空\n");
}
else
printf("栈不为空\n");
}
void Test()
{//测试函数
Strack s;
StrackInit(&s);
StrackEmpty(&s);
StrackPush(&s, 1);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 2);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 3);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 4);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackDestory(&s);
StrackEmpty(&s);
}
int main()
{
Test();
}
可以看到,在用C语言实现时,Stack相关操作函数有以下共性:
1. 每个函数的第一个参数都是Stack*
2. 函数中必须要对第一个参数检测,因为该参数可能会为NULL
3. 函数中都是通过Stack*参数操作栈的
4. 调用时必须传递Stack结构体变量的地址
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。
C++实现:
#include <stdio.h>
#include <assert.h>
#include <string.h>
#include <iostream>
using namespace std;
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
typedef int Datatype;
class Strack
{//定义一个类,确定栈的类型与结构,这里的定义可以参考顺序表的实现
public:
void StrackInit(Strack* ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
assert(ps);//传入的指针不能为空
ps->Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps->Data == nullptr)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps->maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps->top = 0;//此时栈为空栈,实际栈长为0
}
void StrackDestory(Strack* ps)
{//栈的销毁
assert(ps);//传入的指针不能为空
free(ps->Data);//将指向栈地址的指针释放
ps->maxsize = 0;//因为栈被销毁,所以栈的最大长度变为0
ps->top = 0;//因为栈被销毁,所以栈的实际长度同样也变为零
ps = NULL;//将指针置空,防止野指针的产生
printf("栈销毁成功\n");
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == nullptr)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
void StrackPop(Strack* ps)
{//出栈操作
assert(ps);//传入的指针不能为空
if (ps->top > 0)
--ps->top;//用top控制元素的出栈入栈,top-1代表删了栈顶元素,至于里面值是多少不管
else
printf("此表已空\n");
}
Datatype StrackTop(Strack* ps)
{//获取栈顶元素数据
assert(ps);//传入的指针不能为空
assert(ps->top > 0);//栈不能为空
return ps->Data[ps->top - 1];//返回栈顶元素
}
int StrackSize(Strack* ps)
{//获取栈中有效元素个数
assert(ps);//传入的指针不能为空
return ps->top;//此时栈顶元素下标-1就为栈中有效元素个数
}
void StrackEmpty(Strack* ps)
{//判断栈是否为空栈
assert(ps);//传入的指针不能为空
if (ps->top == 0)
{
printf("栈空\n");
}
else
printf("栈不为空\n");
}
void Test()
{//测试函数
Strack s;
StrackInit(&s);
StrackEmpty(&s);
StrackPush(&s, 1);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 2);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 3);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPush(&s, 4);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackPop(&s);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", StrackSize(&s), StrackTop(&s));
StrackEmpty(&s);
StrackDestory(&s);
StrackEmpty(&s);
}
private:
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
int main()
{
Strack S;
S.Test();
}
C++中通过类可以将普通数据以及操作数据的方法进行完美结合,通过访问权限可以控制哪些方法在类外被调用,哪些不可以,也就是所谓的封装,在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。 而且每个方法不需要传递Stack*的参数了,编译器编译之后该参数会自动还原,即C++中 Stack*参数是编译器维护的,而C语言中则需用用户自己维护。
八、构造函数
8.1 构造函数的使用
在模块七,我们用C++实现了栈的相关操作,而我们知道,在实现栈的相关操作之前必须得将栈进行初始化,如果在进行相关操作之前忘记了对栈的初始化操作而运行程序的话,会发生崩溃。但是吧每次实例化一个类构成一个栈的时候都得手动将它初始化,不觉得太麻烦了吗?有没有什么方法可以让系统自动对实例化对象进行初始化操作呢?
显然是有的,这里就需要用到构造函数了,这里的构造函数是一种特殊的成员函数,其名字与类名相同,没有类型包括void类型,在创建对象时由编译器自动调用以保证每个成员数据都有合适的初始值,在对象的整个生命周期内构造函数只能调用一次。
关于构造函数的特性的总结:
1. 函数名与类名相同。
2. 无返回值,即没有类型,包括void类型也没有。
3. 对象实例化时编译器自动调用对应的构造函数
4. 构造函数可以进行函数重载
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
6. 构造函数虽然叫“构造”,但它的实际功能并不是用来构造对象的,而是用来“初始化”对象的,但这里初始化先打个双引号,因为这里的初始化不是真正的初识化,具体咱后面细说。
7. 构造函数是在创建对象的时候调用的,创建几次对象就会调用几次构造函数。
就拿上面栈的程序来举个例子理解一下这几个构造函数的性质:
class Strack
{//定义一个类,确定栈的类型与结构,这里的定义可以参考顺序表的实现
public:
Strack(Strack& ps)
{//初始化一个栈,这里传入的ps表示指向Stack这个结构体的指针
ps.Data = (Datatype*)malloc(sizeof(Datatype) * 6);//动态开辟6个大小为DataType的内存空间
if (ps.Data == NULL)
{//若申请内存空间不成功则退出程序
printf("动态申请内存空间失败\n");
exit(-1);
}
//若申请空间成功
ps.maxsize = 6;//因为动态申请了6个空间,所以最大栈长为6
ps.top = 0;//此时栈为空栈,实际栈长为0
}
Strack(){}
~Strack(){}
void StrackDestory(Strack* ps)
{//栈的销毁
assert(ps);//传入的指针不能为空
free(ps->Data);//将指向栈地址的指针释放
ps->maxsize = 0;//因为栈被销毁,所以栈的最大长度变为0
ps->top = 0;//因为栈被销毁,所以栈的实际长度同样也变为零
ps = NULL;//将指针置空,防止野指针的产生
printf("栈销毁成功\n");
}
void StrackPush(Strack* ps, Datatype x)//尾插法
{//数据的入栈与栈的扩容
assert(ps);//传入的指针不能为空
if (ps->top == ps->maxsize)//判断栈是否满容
{
if (ps->maxsize == 0)
{//避免栈内存空间为零的情况使扩容出现0*2=0的bug
ps->maxsize == 1;
}
Datatype* temp = (Datatype*)realloc(ps->Data, (ps->maxsize + 6) * sizeof(Datatype));//申请一个临时指针进行扩容
if (temp == NULL)//判断系统空间是否有内存空间进行扩容
{
printf("扩容失败\n");//如果没有空间进行扩容,则返回扩容失败
}
ps->Data = temp;
}
//元素入栈
ps->Data[ps->top] = x;
++ps->top;//插入一个数据后实际栈长加1
}
void StrackPop(Strack* ps)
{//出栈操作
assert(ps);//传入的指针不能为空
if (ps->top > 0)
--ps->top;//用top控制元素的出栈入栈,top-1代表删了栈顶元素,至于里面值是多少不管
else
printf("此表已空\n");
}
Datatype StrackTop(Strack* ps)
{//获取栈顶元素数据
assert(ps);//传入的指针不能为空
assert(ps->top > 0);//栈不能为空
return ps->Data[ps->top - 1];//返回栈顶元素
}
int StrackSize(Strack* ps)
{//获取栈中有效元素个数
assert(ps);//传入的指针不能为空
return ps->top;//此时栈顶元素下标-1就为栈中有效元素个数
}
void StrackEmpty(Strack* ps)
{//判断栈是否为空栈
assert(ps);//传入的指针不能为空
if (ps->top == 0)
{
printf("栈空\n");
}
else
printf("栈不为空\n");
}
private:
Datatype* Data;//定义一个指针,指向栈的首元素的地址
int top;//表示栈中存入的实际元素个数,注意这里的top是整型,在书上或者资料我们容易把它理解为指针,实际上不是,这个应该和数组的下标更相似
int maxsize;//表示栈所能储存的最大的长度
};
int main()
{
Strack S;
Strack::Strack(S);
S.StrackEmpty(&S);
S.StrackPush(&S, 1);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPush(&S, 2);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPush(&S, 3);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPush(&S, 4);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPop(&S);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPop(&S);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackPop(&S);
printf("栈内元素个数:%d,栈顶元素数据:%d\n", S.StrackSize(&S), S.StrackTop(&S));
S.StrackEmpty(&S);
S.StrackDestory(&S);
S.StrackEmpty(&S);
}
由于构造函数支持重载,所以这里定义了一个无参函数一个有参函数。因为这里我们显示定义了一个传址的构造函数,所以这里系统就不会默认生成构造函数,但这里我们如果要创建一个对象直接用Strack S是行不通的,如果这里是这样创建对象系统会认为你想调用一个没有参数的构造函数,但是你没有定义,所以这里就会报错,这里如果你想创建对象时能调用这个传址的初始化的函数就得再定义一个无参函数,定义完之后就可以进行对象的创建,之后再将创建的对象的地址传进去就可以了。
现在我们在主函数中进行实例化试试:
8.2 默认构造函数
从构造函数的特性我们可以看到:如果我们没有显示定义构造函数,那么C++系统编译器会自动生成一个默认无参函数,但如果我们显示定义了构造函数,那么系统的编译器就不会生成默认的无参函数。
注意:我们没进行显示定义编译器自动生成的构造函数、无参的构造函数以及全缺省的构造函数都可以被称为默认构造函数,并且默认构造函数只能存在一个。如果同时存在会出现一些问题,因为无参函数与全缺省函数都可以不用传值,所以当系统创建对象的时候会不知道调用哪个函数而报错。
class text
{
public:
text()
{
cout << "塔塔开!" << endl;
}
};
class Data
{
public:
Data ()
{
cout << _a<<"/"<<_b<<"/"<<_c << endl;
}
private:
int _a,_b,_c;
text _t;
};
int main()
{
Data S;
return 0;
}
运行结果如下:
可以看到这里的类成员_a、_b、_c并没有被初始化,而这里的text_t被初始化了,这就会让人有疑问了,难道系统的默认构造函数并不是所有类成员都能进行初始化吗?想要知道这里面的一些门道就得了解一些知识点:对于C++,其将类型分为内置类型(基本类型)和自定义类型。这里的内置类型指的是int、char等系统给的基本类型,而自定义类型指的是自己通过class/struct等自定义的类型。而默认构造函数的初始化操作只对于自定义类型,内置类型不好进行相关操作,所以这里只有我们自定义的text_t成员变量被初始化,而对于其他的基本类型变量并没有初识化。从上述的运行结果也可以看出,对于自定义类型的变量编译器生成的默认的构造函数会调用它的,默认构造函数。
注意:这里的对象创建不能写成Data S()Z这样子创建对象的话编译器不知道你是一个函数函数名还是一个对象,所以不能这么写。
8.3 细说构造函数
上面我们不是不是留了一个问题嘛,构造函数的初始化语句是真的初始化操作吗?
赋值与初始化的区别在于:赋值可以重复多次的赋值,但初始化则只能进行一次初始化的操作。
我们通过一个例子来验证一下:
class Data
{
public:
//构造函数的作用是用来初始化对象的
Data(int a = 1, int b = 2,int c=3)
{//构造函数体的语句是不是初始化?
_a = a;
_b = b;
_c = c;
_a = a;
}
可以看见,上述的代码编译成功了,这意味着函数体中的语句不是初始化,而是赋值。
8.3.1 构造函数体赋值
在创建对象的时候,编译器通过调用构造函数对成员变量进行初始化,也就是给他们一个初始值。构造函数主要作用是完成对对象进行初始化操作的,但在函数体里的操作并不是初始化操作,而是赋值操作,那么如何去判断函数体里的语句操作是赋值函数初始化呢?这里我们就想到了引用,因为我们在使用引用时,在定义时必须要进行初始化,不然就会报错,所以我们在private里面引入一个引用类型的成员变量,只要看编译器编译时会不会发生错误即可,显然这里是肯定会报错的啦~
class Data
{
public:
Data(int a = 1, int b = 2,int c=3)
{
_a = a;
_b = b;
_c = c;
}
private:
int _a,_b,_c;
text _t;
};
虽然上面的成员变量被构造函数进行了初始化,但这里不是初始化,通俗的一点说就是赋初值,因为初始化操作只能进行一次,而赋值操作可以进行多次。所以为了方便定义类成员,在构造函数中多了一个初始化列表的概念。
8.3.2 初始化列表
初始化列表:以一个冒号开始,接着是以逗号分开的数据成员列表,每个“成员变量”的后面跟上一个放在括号里面的初始值或者表达式。
如下图的代码所示:
class Date
{//友元函数
friend istream& operator>>(istream& in, Date& d);
friend ostream& operator<<(ostream& out, const Date& d);
public:
Date(int year = 0, int month = 0, int day = 0)
:
_year(year),
_month(month),
_day(day)
{}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
private:
int _year;
int _month;
int _day;
};
➹但是我们在使用初始化列表的时候要注意:
1. 每个成员只能在初始化列表中出现一次(初始化不像赋值,只能初始化一次)。
2. 引用成员变量、const类型成员变量、自定义类型成员变量(且类中没有默认构造函数),必须放在初始化列表上进行初始化。
例如:
class A
{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
class B
{
public:
B(int a, int ref)
:_aobj(a)
, _ref(ref)
, _n(10)
{}
private:
A _aobj; // 没有默认构造函数
int& _ref; // 引用
const int _n; // const
};
3. 成员变量的初始化顺序是其在类中定义的顺序,与其在初始化列表中的顺序无关。
例如:
class date
{
public:
date(int n= 0)
//初始化列表
:
_month(n),
_year(_month)
{}
void Print()
{
cout << _year << " " << _month << endl;
}
private:
int _year;
int _month;
};
int main()
{
date d1;
d1.Print();
return 0;
}
运行结果如下: 可以看到,我们对_year先定义就先进行初始化,而此时_month此时还没有进行初始化,所以打印出来的是_year是一个随机值。
8.3.3 explicit关键字
我们来看一个代码的例子:
class date
{
public:
date(int n = 0)
//初始化列表
:
_month(n),
_year(2024)
{}
void Print()
{
cout << _year << " " << _month << endl;
}
private:
int _year;
int _month;
};
我们可以这样进行实例化对象(调用构造函数):.
date d1(0);
我们同样也可以这样实例化对象:
date d1=7;//隐形类型转换
该方法被称为隐形类型转换。 说是这么说,那么这里的int类型的7是怎么转换成date类型的呢?
原因在于,7这个常量在编译的时候开辟了一个空间,这个空间自动将int类型的7转换成date类型的数据,再由拷贝构造函数将该空间的值拷贝到d1中。
对于老编译器来说是上面的做法,但对于新的编译器来说,新编译器会将7作为形参直接传给构造函数,这样就完成了隐形转换。
上面的构造函数只有一个参数(C++98),那多参数的构造函数怎么进行隐形类型转换呢?
使用{}将想要传的值放进去就行了(C++11):
class date
{
public:
date(int n = 0,int x=8)
//初始化列表
:
_month(n),
_year(x)
{}
void Print()
{
cout << _year << " " << _month << endl;
}
private:
int _year;
int _month;
};
int main()
{
date d1{4,2024};//多参数类型转换
d1.Print();
return 0;
}
如果我们不想让系统进行类型转换则可以用到explicit关键字:
class date
{
public:
explicit date(int n = 0)
//初始化列表
:
_month(n),
_year(2023)
{}
void Print()
{
cout << _year << " " << _month << endl;
}
private:
int _year;
int _month;
};
int main()
{
date d1=4;
d1.Print();
return 0;
}
这样我们用等于传入不同类型的数据时就不会发生隐形类型转换了:
九、析构函数
现在我们知道一个对象是怎么来的,那一个对象是怎么没的我们还不知道呢?这个时候就会用到析构函数。析构函数与构造函数相反,构造函数是初始化成员变量,而析构函数是完成对对象进行销毁,局部变量的销毁工作是由编译器来实现的,对象的在销毁时会自动调用析构函数,完成类的资源清理工作。
以下是析构函数的一些特性:
1. 析构函数名是在类名前面加个“~”
2. 析构函数无参数无返回类型
3. 一个类只能有一个析构函数,若用户没有进行显示定义的话,系统将自动生成默认的析构函数,注意:析构函数不能进行函数重载,因为由第二个特性可知,析构函数没有参数,即没有参数类型,没有参数类型那何来的函数重载。
4. 对象生命周期结束时,C++编译系统会自动调用析构函数。
这里我们举个析构函数的例子来看看 :
class Data
{
public:
Data(int a = 1, int b = 2,int c=3)
{
_a = a;
_b = b;
_c = c;
cout << "open" << endl;
}
~Data()//无参数,无返回类型
{
_a = 0;
_b = 0;
_c = 0;
cout << "finish" << endl;
}
private:
int _a,_b,_c;
};
int main()
{
Data S;
return 0;
}
运行结果如下:
可以看出,系统在对象的生命周期结束的时候自动调用了析构函数来进行内存的释放。
9.1 默认析构函数的相关知识点
由析构函数的特性可知:若无显示定义的话那系统编译器会自动生成默认的析构函数。
而系统生成的析构函数与系统生成的构造函数一样:对于自定义的变量进行调用,而对于系统内置的变量不予以相关的操作。
注意:析构函数可以不写,直接使用系统生成的析构函数,但如果碰到那种有资源申请的比如链表或者顺序表那种需要开辟内存空间的就必须得使用析构函数在对象结束的时候对内存进行释放。
十、拷贝构造函数
我们在创建对象的时候可否创建一个与当前所创建的一模一样的对象呢?或者在我们进行变量之间的赋值的时候,有没有想过类之间的赋值是怎么进行的呢?
前者的答案是可以的,但创建的过程没有想象的那么简单,而后者也是可以实现的,这里用到的就是拷贝构造函数。
在创建一个与所创建的对象一模一样的新对象的时候,我们需要拷贝已存在对象的数据,那么我们这里直接让编译器进行相关操作可以吗?
答案是否定的,因为编译器只能做到浅拷贝,例如链表、顺序表之类的怎么放心的直接拿给编译器进行拷贝,这样做不仅会发生两个对象使用同一块空间的情景,也会使两个对象生命周期都结束时析构函数会将同一块空间释放多次,这显然是有问题的!
这个时候就需要我们亲自使用一个在类中定义的拷贝构造函数来进行对象之间的拷贝,具体怎么定义就需要我们自己去上手实践了。
拷贝构造函数:函数名为类的名字,无返回值,只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰,防止程序员出错修改形参),在用已存在的类型对象创建新对象时由编译器自动调用。
拷贝构造函数的特性:
1. 拷贝构造函数是构造函数的一种重载的形式。
2. 拷贝构造函数有且仅有一个且必须是引用类型的参数,如果所传的参数是一个值的话系统会报错,因为传值会引发无穷递归的调用。
3. 若用户没有进行显示定义,则系统会自动生成一个默认的拷贝构造函数,该函数可以将存储的内存数据按字节序进行拷贝,这种拷贝叫做浅拷贝,也叫值拷贝。
我们来举一个拷贝构造函数的例子吧:
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& data)//拷贝构造函数
{
_year = data._year;
_month = data._month;
_day = data._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 4, 1);
Date d2(d1);
d2.Print();
return 0;
}
运行结果如下:
从上述的代码可以看到,我们在实例化d2的时候通过调用拷贝构造函数将d1对象的引用作为参数传入,然后一步一步的将d1的数据拷给d2,而这里为什么不进行值的传参,原因是传值会使数据层层拷贝,导致无限递归的生成,原因在于当我们进行传值的时候,形参是没有值的,需要实参进行赋值,而类对象的赋值需要用到复制构造函数,所以就会进行调用,而这个复制构造函数也需要实参进行赋值,这时候又会调用复制构造函数,以此往复不断的进行递归,所以不可以。
当然除了函数传参的方式也可以通过“=”来进行对象的拷贝:
这两种方法,没有什么区别,底层都会默认调用拷贝构造函数。这就更加的形象的说明了对象的赋值是通过拷贝构造函数来实现的。包括以下的情况也是会调用拷贝构造函数的。
这里之所以会调用是因为这里的函数Func的参数类型是一个对象,而对一个对象类型的形参进行赋值就需要相对应的对象实参进行赋值,好这里就回到上面了,对象之间的赋值需要什么呢?大声一点,没错就是需要拷贝构造函数来进行对象之间赋值的。
说来说去,这个拷贝构造函数不就是对对象的内置成员进行浅拷贝用的吗?那直接用系统编译器不就可以了吗,还要定义拷贝构造函数干嘛,对吧?
注意:我们在无资源申请的时候是否定义拷贝构造函数是没有限制的,也就是都可以的意思;但如果有资源的申请的话,就必须要用到拷贝构造函数,我们可以这样理解,我们定义了析构函数进行内存的清理,就得有相关的拷贝构造函数,这里并非绝对,具体情况具体分析。
十一、赋值运算符的重载
10.1 运算符的重载
在我们使用C++对内置类型进行运算时会用到一些基本的运算符如:“*、/、==、+、-”,但是对于我们的自定义的类型就不能用上述的这些运算符进行运算了。
例如:
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& data)//拷贝构造函数
{
_year = data._year;
_month = data._month;
_day = data._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 4, 1);
Date d2(2024, 3, 31);
d2 == d1;
return 0;
}
代码调试的结果如下:
这里用等号来比较两个自定义类型的关系,在这里显然是不行的。这里我们总不能去使用一个函数来比较d1、d2这两个对象吧,显然这样就有点,麻烦了。
而实际上呢,C++是可以用上面的基本运算符来进行相关运算的,像上面的==也可以,只不过我们在进行使用前得先将运算符进行重载。
ps:C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。 函数名字为:关键字operator。 函数原型:返回值类型 operator操作符(参数列表)
我们这里举一个运算符重载的例子:
class Date
{
public:
Date(int year=0, int month=0, int day=0)
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d)
{
return _day == d._day && _month == d._month && _year == d._year;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 4, 1);
Date d2(2024, 3, 31);
cout << (d2 == d1)<<endl;
return 0;
}
运行结果如下:
我们看到了这里我们对==进行了重新定义,当两边参数的类型为Date类型的时候,系统会自动调用该函数进行判断。
这里的operator==也是一个函数,为什么不对它进行调用呢?例如这样:operator==(d1,d2),这样子和自己创建一个普通的比较函数进行比较有什么区别呢?这有违我们的初衷,我们对运险费进行重载就是增加程序的可读性,上述的代码我是将运算符重载函数定义在类中的,当然它也可以定义在外面,定义在外面的参数就得变成两个了,传参就变成这样operator==(d1,d2),而之所以在类中只传一个参数,是因为类中有this指针,如果不是一个参数的话就会报错了。
不过还得注意以下的一些点:
不能通过连接其他符号来创建新的操作符:比如operator#
重载操作符必须有一个类型参数用于内置类型的运算符,其含义不能改变,例如:内置的整型“-”,不能改变其含义,是减号不能说成是加号。
作为类成员进行函数重载时,会发现操作数的数目会少1,这是因为成员函数的第一个参数为this指针。
.* :: sizeof ?: . 以上五个关键字不能进行重载,这个在笔试题中经常出现。
10.2 =赋值运算符的重载
在实现运算符重载的时候难免会遇到对=进行重载。
下面我们就接上面的代码来对=进行重载:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
void operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
运行结果如下:
可以看到,可以运行,那如果我这样子写呢?
可以发现这里报错了,为什么呢?因为我们每用一次重载运算符相当于调用一个函数,而调用函数则会产生一个返回值,如果你定义的重载函数的返回值为空那么当你连续赋值的时候就相当于第一次赋值返回一个空给下一个赋值对象,然后就会报错,而这里我们定义的是一个void类型的重载函数所以这里会出现问题,所以我们这里做一点小小的修改:
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
这样就没有什么问题了。
10.3 (<<)流插入运算符的重载
下面我们借上面的代码进行举例:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
可以看到,我们在对自定义对象进行打印的时候就出现了一些问题。这里的cout的流插入运算符并不能对自定义类型进行输出,所以我们这里要对流插入运算符(<<)进行重载。
下面我们对运算符进行重载:
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
//我们在类中定义一个流插入运算符的重载函数
void operator<<(ostream& out)//ostream是一种流类型,目前会用就行。
{
out << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
我们来看一下运行结果:
emmmm,看起来运行的很成功对吧,但是是不是哪里感觉怪怪的呢,正常情况应该是cout<<d3,但是这里却是d3<<cout,原因在于类中运算符的重载是按参数的默认顺序来使用的,而第一个参数默认是*this,难道我们就只能如此别无他法了吗?
显然是有的,我们讲重载函数变成非成员函数而是全局函数不就没有*this这个参数了吗。
我们来试一下:
这里就出现了一个新的问题,我们这里如何访问类的私有成员呢?
这里我们就用到了友元函数:friend
具体使用方法为:friend 已定义的函数 (在类中定义)
我们对上面的程序进行修改试试:
class Date
{
friend void operator<<(ostream& out, const Date& d);
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
private:
int _year;
int _month;
int _day;
};
//我们在定义一个流插入运算符的全局重载函数
void operator<<(ostream& out,const Date& d)//ostream是一种流类型,目前会用就行。
{
out << d._year << "/" << d._month << "/" << d._day << endl;
}
让我们看一下运行结果:
看起来不错,但是还有一点问题,我们看下面的代码:
就和上面的重载的赋值运算符一样,我们这里不能用<<对自定义类型进行连续的输出,原因也和上面的原因一样,在于我们定义的重载函数的返回是一个空,所以我们在第一次调用该函数进行自定义类型的打印后会给下一次调用传一个空值,这时候函数调用的时候就会报错,所以为了解决这个问题,我们对重载函数定义一个ostream&返回类型就行了,因为传入的cout参数在函数结束后还是存在的:
class Date
{
friend ostream& operator<<(ostream& out, const Date& d);
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
private:
int _year;
int _month;
int _day;
};
//我们在定义一个流插入运算符的全局重载函数
ostream& operator<<(ostream& out,const Date& d)//ostream是一种流类型,目前会用就行。
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
int main()
{
Date d1(2024, 4, 1);
Date d2(2024, 3, 7);
Date d3(2024, 3, 7);
d3 = d2 = d1;
cout << d3<< d2;
}
注意上面的友元函数的函数返回类型也记得改一下,别漏了,然后运行结果如下:
10.4 (>>)留输入运算符的重载
有了上述的流插入运算符的重载的经验我们在进行流输入运算符的重载就得心应手了。
class date
{
//友元函数
friend ostream& operator<<(ostream& out, const date& d);
friend istream& operator>>(istream& in, date& d);
public:
date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, const date& d)//流插入(ostream)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
istream& operator>>(istream& in, date& d)//流输入(istream)
{
in >> d._year >> d._month >> d._day;
return in;
}
运行没问题~
十二、const 成员
我们在类和对象中会遇到这类问题:在创建一个被const修饰的对象结果在编译器调用打印函数的时候显示类型不兼容。我们仔细想一下就会发现,我们传入一个不可修改的常量,但打印函数的第一个默认函数是this指针,所以说类型不兼容很正常。解决这种问题的方法就是在类函数的最后加个const就行了。
📌像这样将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
十三、static 成员
对于static这个关键字我相信大家不会太陌生,但static成员在类中有着不一样的作用,下面让我们来仔细说说吧:
现在有个面试题:让你统计一个类一个创建了多少个对象。
对于这种问题,我们可以创建一个全局变量,每创建一个对象的时候就在构造函数或者构造拷贝函数中对其加一即可,但这种方法显得未免太过于简单,或者说低级了,我们这里采用static成员试试:
class A
{
public:
A()
{
++_a;
}
void Print()
{
cout << _a << endl;
}
private:
static int _a;
};
int A::_a = 0;
运行结果如下:
我们创建了一个类数组a[10](数组中的每个元素都是一个对象),以及一个对象d1,总共创建了11个对象,在每次创建对象的时候都会对其内部的static成员变量进行++,以此来统计一共创建了多少的对象。
由上述的运行结果图可以看见,我们定义的这个static成员变量是属于整个类的,无论我们使用哪个对象,其内部的static成员都是同一个_a,这里要注意的是类中的金泰变量不能在声明处进行赋值,一定要在类的外面进行初始化!
对于类中的函数我们也可以用static进行修饰:
class A
{
public:
A()
{
++_a;
}
static void Print()
{
cout << _a << endl;
}
private:
static int _a;
};
int A::_a = 0;
❗注意:静态成员函数没有隐藏的this指针,不能访问任何非静态成员。
对于类中的静态成员函数我们可以使用类名和作用域限定符(::)来进行访问(使用创建的对象名加.也可):
十四、匿名对象
这里我们临时加一个小点,我们在调用类中的函数的时候,如果不是static类型所修饰的,我们就要创建一个对象,然后通过这个对象调用所需的函数。
class date
{
public:
date(int n = 0)
//初始化列表
:
_month(n),
_year(2023)
{}
void Print()
{
cout << _year << " " << _month << endl;
}
private:
int _year;
int _month;
};
int main()
{
date d1=4;//创建了一个对象d1
d1.Print();//通过d1对象来调用Print()函数
return 0;
}
这样子看上去挺麻烦的,我们换种简单一点的方法试试,我们来创建一个没有名字的匿名对象。
对于类实例化所创建的对象,我们可以不对其进行命名:
class A
{
public:
A()
{
++_a;
}
void Print()
{
cout << _a << endl;
}
~A()
{
cout << "end" << endl;
}
private:
static int _a;
};
int A::_a = 0;
int main()
{
A().Print();
return 0;
}
注意这里的static类型的成员变量要在类的外边进行初始化,类的里面只是声明。
这样我们就不用创建一个有名字的对象来进行调用了,不过匿名对象的生命周期只是在那一行,超过了这一行就会自动销毁。上面的时候出了匿名对象的那行后,系统自动调用析构函数进行销毁,有时候我们需要让它的生命周期长一点我们就可以用const &来接收匿名对象,这时匿名对象的生命周期就会延长到接收对象的作用域了:
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
};
int main()
{
const A& t = A();
cout << "pause" << endl;
return 0;
}
运行结果如下:
我们看到匿名对象不是马上析构而是在接收对象t生命周期结束后在进行析构。
十五、友元
友元提供了一种突破封装的方式,有时可以提供便利,但友元会增加耦合度,破坏封装,所以不宜多用。
友元分为:友元函数与友元类。
15.1 友元函数
友元函数我们在上面的知识点中有提到过,这里我再进行介绍一下:
友元函数可以直接访问类中的私有成员,它是定义在类外部的普通函数,它并不属于任何类,但需要在类的内部进行声明,声明的时候需要加上friend的关键字。
具体使用方法:friend 已定义的函数(在类中声明) 。
例如:
class Date
{//友元函数
friend istream& operator>>(istream& in, Date& d);
friend ostream& operator<<(ostream& out, const Date& d);
public:
Date(int year = 0, int month = 0, int day = 0)
:
_year(year),
_month(month),
_day(day)
{}
Date& operator=(const Date& d)//赋值运算符重载
{
_day = d._day;
_month = d._month;
_year = d._year;
return *this;
}
private:
int _year;
int _month;
int _day;
};
注意:
友元函数可以访问类的私有和保护成员,但它不是类中的成员函数。
友元函数不能用const进行修饰。
友元函数可以在类中的任意地方进行声明,不受访问限定符的限制。
一个函数可以是多个类的友元函数。
友元函数的调用和普通函数的调用原理相同。
15.2 友元类
既然被friend关键字修饰的函数可以访问一个类中的私有成员,那用friend关键字修饰的类是不是可以访问另一个类的所有成员呢?
答案肯定是可以的啦!
例如:
class A
{
public:
friend class B;
A()
{
++_a;
}
int Print()
{
cout << _a << endl;
}
~A()
{
cout << "end" << endl;
}
private:
static int _a;
};
int A::_a = 0;
class B
{
public:
void Print()
{
cout << _A._a << endl;//访问_A对象中的私有成员
}
private:
A _A;
};
注意:
友元类中的所有函数都可以成为另一个类的友元函数,都可以访问另一个类的非公有成员。
友元关系是单向的,不具有双向性。(比如上述的A类、B类,B为A类的友元类,所以可以访问A类的私有成员,但反之A类访问不了B类的私有成员)
友元关系不具备传递性。(如果C是B的友元,B是A的友元,但不能说C是A的友元)
友元关系不能继承。关于继承相关的点以后我们再细说。
十六、内部类
内部类的定义:在一个类的内部定义的类被称为内部类。
内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象来访问内部类的私有成员。外部类对于内部类没有任何优越的访问权限。
class A
{//外部类
public:
void APrint()
{
cout << _a << endl;
B b;
b.BPrint(*this);//无法通过cout<<b._b;来打印,访问不了B的私有成员
}
private:
class B
{//内部类
public:
int x = 0;
void BPrint(const A&a)
{
cout << a._a << endl;//B是A的友元可以访问A类的私有成员
}
private:
int _b=5;
};
int _a=6;//外部类的私有成员
};
结果如下:
注意:内部类就是外部类的友元类,详情可以参考友元类的定义,内部类可以通过外部类的对象参数访问外部类的所有成员,但是外部类不是内部类的友元。
我们来看看A类实例化对象的大小:
我们可以发现,A的实例化对象是不包含B的,这样可以表明内部类是一个独立的类。
❗下面要注意一点:内部类可以定义在外部类的public、protected、private都是可以的,但是受外部类作用限定符的约束。
本期的博客到此结束,若有问题欢迎大家批评指正!我们下期见~