目录
前言
本文主要以较为粗略的形式讲解类与对象(内容较多较为繁琐),所以本文更像是一个有关类和对象的目录,当然本文讲解的内容是不完全的,至于不完全的内容都会在本文的第四部分——相关文章里一 一介绍(持续更新--2024.2.16记) 本文写于2024.2.16
一、类和对象是什么?
在介绍类和对象时,不妨想让我们看看比较熟悉的几个老朋友。对于C编译器,其内置了一些类型:int、double、float等
这些类型的基本作用是:
- 决定了数据对象需要占用的内存大小。 //如int占4字节大小,double占8字节大小
- 决定了如何解释内存中占用的位。 //int类型与float类型都占4字节大小但是其存储逻辑是不一样的,即其每一字节存储内容的含义不同
- 决定了数据对象可进行哪些操作。 //结构体成员访问符号只能被自定义类型使用,常规内置类型是无法进行使用的
如果以上几点理解起来没有什么麻烦,那么,我们就可以来解释“类”究竟为何物,类本质上是一种自定义类型,根据我们上述的三点,C编译器将对内置类型的相关操作内置,但是仅仅是内置类型有时不足以描述一件事物,所以给了用户自定义类型这一比较开放的数据类型,供用户自己设计:数据占内存大小、如何解释内存中的位。看到这里你也许会心生疑惑,博主所描述的与结构体的定义有什么区别?先别急,这一问题我们稍后解释。现在你只需要知道在C++中我们更倾向于使用类而非结构体(当然用结构体也不算错)。类是一种自定义的数据类型,而对象即为类实例化后的结果。举个例子:int a 对于int的定义我们可以理解为“类”的定义,对于a即int类型实例化的数据对象既可以对标理解为“类实例化后的对象”。
总结概括一下:类是一种自定义类型,在C++中使用class进行声明,对象是类实例化后的结果。
那么先让我们写一个简单的类(这并不是一个较为标准的类,实际上这个类有一些“小问题”,如果你有足够的耐心看完本文的化,也许你会发现):
二、类的基本知识
1.类的访问限定符
关于访问限定符注意以下事项:
- public修饰的内容在类外可以被访问
- protected、private修饰的内容不可以在类外直接被访问
- 访问限定符的作用域为该访问限定符开始至下一个访问限定符之间,如果没有其他访问限定符,则到“}”结束
- class的默认访问限定符是private,struct的默认访问限定符是public(因为要兼容C语言中的struct)。
- 同一对象中的成员函数可以不受限制的访问私有成员变量。
仔细看上面的代码,我们先来阐述public与private的区别 :我们通过报错可以发现只有由class 声明后实例化的A出现了不能访问成员的现象,反观B对象则没有出现这样的现象,正如
前文所示,struct与class声明的类存在默认访问限定符上的差异。
总结一下: 类描述看上去很像是包含成员函数以及public和private可见性标签的结构声明。实际上,C++对结构进行了扩展,使之具有 与类相同的特性。它们之间唯一的区别是,结构的默认访问类型是public,而类为private。C++程序员通常使用类来实现类描述,而把结构限制为只表示纯粹的数据对象(常被称为普通老式数据(POD,Plain Old Data)结构)。
2.类的意义——封装
在介绍访问限定符的时候,不难发现,对于C++更支持的使用class来声明类是可以防止其他用户更改和使用私密数据的。这一点也就体现C++的一个特性——面向对象编程,在C++中允许程序员在类中声明函数(这一点我们稍后介绍),这样的编程模式可以让使用者不必关心内部结构的同时,保护核心数据。
3.成员函数的声明和定义
C++允许我们在类中声明函数,类中的函数默认为内联函数(关于什么是内联函数这里就不多介绍了)。类的作用与如图4所示
我们在C语言学习函数的时候考虑的函数声明的美观性与可管理性常常令函数的声明和定义分离,那么对于类的成员函数我们该如何使函数的声明和定义分离呢?我们需要注意的使同名函数可能在不同的类中有不同的表现形式,所以我们在类外面声明其对应的成员函数时要使用“::”(域限定符),对其所属的类进行声明。
4.类的实例化
类的实例化是指用类定义一个对象的过程。
5.类大小的计算
类大小的计算与结构体计算大小的方式相似,也遵循内存对齐等原则,但是略有区别的是,类中的成员函数如何计算大小?
如图8所示,根据结果来看成员函数大小并不计算在对象中,事实上正是如此,函数的定义是不会在堆、栈上进行的,但是显然我们定义的对象是在栈空间上进行的,而成员函数实际上是在公共代码区中定义的。 此外空对象的大小为1(取决于编译器的实现),该1字节存储的是对象的地址来保证编译器确定唯一对象。
6.this指针
this指针的特性 :
- this指针的类型——类类型 *const
- 只能在非static修饰的成员函数使用
- this指针本质是成员函数的形参,当对象调用成员函数时,将对象的地址传递给成员函数而并非在对象中存储了一个this指针。
- this指针是成员函数第一个隐式参数,不需要用户自己传递,一般由编译器使用exc寄存器传递
注意:this指针是定义在栈上的,而且this指针可以为空。但是为空是不可以使用:
三、六大默认构造函数
默认成员函数的意思是,在程序员不自己实现的时候编译器自己实现,也就是我们上文所说的空类是不准确的,实际上,其包含这六大函数(取地址重载函数的有两个),此外,在上文我们使用类时,似乎定义对象后并没有对对象进行初始化,这一点不知道屏幕前的你是否注意到了,这其实是构造函数在“作祟”,这也就是前文所提到的“小问题”。
本部分使用一个与日期有关的类进行讲解:
1.构造函数
构造函数的定义方式:构造函数的名与类名相同,没有返回值,由编译器自己调用,适用于对象创建时以保证其有合法的数据值,此外,构造函数在对象整个生命周期中只调用一次。
默认构造函数对于类中内置类型不进行处理(准确的说是C++没有规定到底是否处理,取决于编译器实现,例如Vs2019就将内置类型设置成了0),对于自定义类型调用其构造函数。这样讲过于晦涩,本文接下来通过例子进行讲解。
当然构造函数对于本文中定义的日期类,无足轻重~。
此外C++11意识到了内置类型是否处理存在着一定的问题,构造函数要是连最简单内置类型处理都不确定那不是太挫了~~。所以推出了在声明私有成员变量的时候允许缺省赋值。
请注意:无参的构造函数、全缺省的构造函数、编译器自己生成的构造函数都可以称作默认构造函数,但是默认构造函数只能存在一个,当多个默认构造函数同时存在时会造成歧义。
2.析构函数
析构函数与构造函数相似,对于由编译器生成的析构函数,对于内置类型不进行处理,对于自定义类型调用其对应的析构函数,此外,析构函数由编译器自行调用。析构函数释放的是对象所占用的资源比如堆空间等。析构函数函数不可以进行重载。
特性:
- 析构函数名为类名前加上~。
- 没有返回值。
- 对于类来说,如果程序员没有自己定义析构函数,那么编译器会自己生成一个析构函数,析构函数不可以重载。
- 析构函数在对象生命周期结束时会由编译器自己调用。
对于日期类等这样无需申请“资源”的类析构函数显得较为多余(资源可以是堆等需要动态调度使用的资源):
但是对于模拟实现栈、队列等数据结构又是不可或缺的:
3.拷贝构造函数(是构造函数的重载):
拷贝构造函数函数名于类名相同,没有返回值,需要引用传递参数,防止发生无限递归。如果程序员没有自己实现拷贝构造函数,那么编译器会自己生一个对内置类型按字节存储
顺序拷贝,对自定义类型按其对应拷贝构造函数进行拷贝的拷贝构造函数:
将拷贝构造函数与构造函数进行一下对比:
不难发现对于本文实现的类而言二者似乎没有什么本质区别:
二者也仅仅是参数不同而已,那么是不是意味着拷贝构造函数就一点意义都没有呢??
请看一下情况:
请仔细想想以下代码是否有问题?
请读者先自行思考一下,如下情况:
有两个栈对象tmp、tmp1,tmp的初始化使用的是构造函数,而tmp1初始化使用的是拷贝构造函数,尽管二者使用的初始化方式不同但是二者最后都会使用析构函数对资源进行释放,由于两个对象的数组指针_arry是指向同一块内存空间,但是却被free了两次,而对同一块地址free两次是不被允许的,这也就是报错产生的原因。
那么如何解决这一问题呢?
其实我们只需要改变一下拷贝构造的逻辑即可,上文提到的拷贝构造的方式即为“浅拷贝”,这与编译器自己生成的拷贝构造没有太大的区别。所以如果要解决问题,实际上就是要解决如何令free不在释放同一块空间,那么在保证拷贝能顺利进行,且拷贝后结果可以使用的前提下,可以让两个指针指向不同的区域,但要保证指向区域内容的一致性:
那么如何更改拷贝构造函数呢?
这里只展示一种非最优写法:
最后请思考一下为什么拷贝构造需要引用传递参数:
假如是非引用传参,那么tmp1传递给sorce作为参数时又会触发拷贝构造,如此循环必将会有爆栈的风险,值得庆幸的是,编译器会检查这一步,如果不是引用返回会直接报错就像这样:
4.赋值拷贝函数
在介绍复制拷贝之前,需要先介绍一下C++的运算符重载,
[返回类型] operator[重载运算符]() //其中标标蓝的部分为函数名
注意:
- 重载运算符不可说未定义的运算符比如operator@就是非法的。
- 重载运算符必须要有一个类类型的参数。
- 重载运算符时也会隐式的传递this指针。
- 为了避免引发误会,原则上重载运算符是不应过大更改符号原意,比如+,人们的第一印象都是加法,但是如果你准备好被同事群起而攻之的话,完全可以将其实现为减法。
- 并非所有的运算符都可以重载,并非所有的运算符都可以是非成员函数定义:
- 这里需要特别注意一下赋值运算符“=”只被允许作为成员函数被重载,因为如果赋值运算被在类外重载为全局的运算符(不可为static修饰),类中也会由编译器自己生成一个赋值运算符重载。二者会相互冲突。
那么现在我们就可以介绍一下赋值拷贝函数了,复制拷贝函数是基于运算符重载的一种构造函数,具体一点就是基于“=”运算符重载的构造函数。如果我们不写,编译器会自己生成一个默认赋值拷贝函数,其与拷贝构造函数相似都是完成对数据的浅拷贝,当然也要注意对同意内存区域多次free的情况发生。赋值拷贝与拷贝构造的区别是,拷贝构造适用于一个对象为另一个对象初始化,而赋值拷贝适用于两个对象进行拷贝操作。具体区别看如下代码:
如果想要自己验证的可以自己调试代码观察调试对应语句调用哪个函数来确定结论:
这里仅以VS2022的演示结果作为结论:
#define SIZE 10
class stack
{
int _top=0;
int _capacity=10;
int* _arry;
public:
stack()
{
_arry = (int*)malloc(sizeof(int) * SIZE);
cout << "stack()" << endl;
}
stack(const stack& source)
{
_arry = (int*)malloc(sizeof(int) * (source._capacity));
_top = source._top;
_capacity = source._capacity;
for(int i=0;i<source._top;i++)
{
_arry[i] = source._arry[i];
}
cout << "我使用的拷贝构造哦~~" << endl;
}
stack& operator=(const stack&source)
{
_arry = (int*)malloc(sizeof(int) * (source._capacity));
_top = source._top;
_capacity = source._capacity;
memcpy(_arry, source._arry, (source._capacity));
cout << "我使用的是赋值拷贝哦~~" << endl;
return *this;
}
~stack()
{
free(_arry);
//_arry = nullptr;
cout << "~stack()" << endl;
}
void push(int x)
{
if (_top == _capacity)
{
//扩容
}
_arry[_top++] = x;
}
};
void test()
{
stack tmp;
tmp.push(1);
stack tmp1 = tmp;
stack tmp2;
tmp2=tmp;
return;
}
int main()
{
test();
return 0;
}
有了以上事实作为铺垫,我们可以试着理解一下赋值拷贝和拷贝构造。赋值拷贝是发生在两个已有对象间的赋值,而拷贝构造是发生在一个对象为另一个对象初始化的时候。从调用次数上来讲。正常情况下,拷贝构造往往只会被调用一次,而赋值拷贝可能调用不止一次。
四、一些相关文章
1.构造函数“内情” ——暂未更新
2.类与对象与static成员、友元、内部类、匿名对象、拷贝对象的优化 ——暂未更新
3.运算符重载 ——已更新
声明:本文涉及图片水印皆为原创,属现作者——木鱼不是木鱼