c++ 类 (要学习类这一篇就够了 )

7 篇文章 0 订阅
4 篇文章 0 订阅

类是c++里面极其重要的组成部分,因为类的产生才有面向对象编程。

C语言的结构体缺点

我们回看C语言的结构体,我们会得到很多它的问题:

A:我们创建的对象无法在结构体内部写函数,导致大部分函数写在全局中,显得杂乱不好归类整理。

B:结构体里面的数据可以随便访问和修改,显得十分的不规范。一些返回结构体里面数据的函数显得可有可无。

C:我们用结构体写数据结构的时候,会因为两个数据结构的同名函数而烦恼,需要增加函数名长度来起到区分。例如链表和顺序表的打印要写成list_print、seq_print。

D:我们写一些数据结构会写init初始化和destroy销毁,这种必须要有的东西,我们每次要自己写,显得繁琐,而且有时候还会忘记。

F......

于是我们的本贾尼博士就改进了结构体,将结构体升级形成类和新的结构体。

C++类

类的格式

类主要分为类成员和类成员函数。里面的权限主要有private,public,protected三种权限限制符,其中private和protected的类成员是不能在类外面访问的,只能在内里面和同类之间访问,public是可以在类外访问的。而其中protected和private的区别主要在继承方面,这个这里先不讲。

类空间在没有权限符限制时,默认是私有的

这里可以这样写

将几个分块,但是显得杂乱,建议不这样写。

成员函数定义的规范化

1、我们在定义成员变量时,一般在变量名前面加一个_或者后面加一个_来区分我们的传入值变量。

2、同时我们的成员最好设为私有的或者保护的,防止随便访问。

3、最后,我们还建议将成员变量放到类的最下面,就是public在上,private在下面。

 这样可以提高我们代码的可读性。

成员函数的声明定义

成员函数可以在类内声明同时定义了,或者我们在类内声明后,在内外进行定义。

类内

类外

另外值得注意的是,类里面的函数都是内敛函数(内敛函数是给编译器的建议,对短小的函数有效,长的函数是没有效果的)。但是如果我们是类外定义就不是内敛函数了,定义和声明分开了,编译器在编译的时候看不到函数的定义,只能看到它的声明。

这里另提一下类域

类域

类域如果大家不懂可以看这一篇

类域就是将这个类作为一个单独的空间,我们要访问里面的函数时,要么用创建的类变量来调用,要么就是要用空间限定操作符,所以在上面的sum类外创建的时候,我们要进行域的限定。

C++的struct

struct并不是没有改变,它和类是一样的,可以在里面写函数。它和类唯一不同的就是默认的空间权限,类是私有的,它是公有的。

因为结构体和类除了这个,其它都是一样的,所以下面的我都用类class来讲。

类的内存对齐规则

首先,我们的成员变量还是遵循着c语言里面的对齐方式

但是我们的成员函数是遵循什么对齐的呢?

事实上,它并不是存储在类里面的,而是存到一个类的公共代码区的。原因是我们类写好后其中的创建n个这个类的同名函数功能都是一样的,所以我们只要给这n个类一个函数内存就行了。否则我们如果每个类存储一个函数指针将会浪费很多的内存空间。

做个验证

下面的类例子,如果我们函数内存是跟着类内存的,那么这里应该是1+3(对齐无效空间)+4+8(64位下)=16字节。

如果函数内存是单独存储的话就是1+3(无效空间)+4=8。

答案是8,也验证了我们的想法。

类的实例化

我们创建了一个类是否是真的投入使用了呢?

并不是,我们只是创建了一个蓝图,而并没有正真的创建一个变量,相当于我们创建了一个新的变量。

而我们要创建一个变量才是正真的投入使用了

类的this指针

我们上面说了一个类的所有实例化成员都共用一个"函数",那么我的一个实例化的类调用这个函数,这个函数是怎么知道是谁调用的呢?

例如下面的这个sum函数,如果我们创建一个p对象,然后通过p来调用sum函数,它怎么知道是p调用的?

这里我们就要提到this指针,这个指针用来指向实例化类变量的地址。我们每个类成员函数都会默认传this指针。

比如这的sum函数,别看是一个参数都没有,它其实有一个默认的参数this指针用来指向谁调用它的,好拿取它的成员变量进项操作。sum( a* const p)

this指针是不能够改变的,所以是a *const p类指针。

我们在函数里面是可以调用this指针的:

例如我使用this来访问成员变量,或者用*this作返回值返回它本身类型的拷贝。

  
权限放大

如果我们定义一个const类的a,然后有一个返回它本身的函数

我们发现调用不了,原因就是权限放大。

我们的x是一个const a类型的,但是我们的this指针又只是 a const *类型的,通过这个this指针是可以改变值的,但是我们的const a是不能改变值的。所以就有一种方法可以解决

我们在函数后面跟一个const,就可以把函数内部传的this指针变成const a const*了,这里同时产生了重载,是const的类就进入下面这个,不是const类就进入上面那个函数。

类的默认成员函数⭐️

类里面除了默认的this指针,还有默认的成员函数。

分别是掌管初始化和清理的构造函数和析构函数,拷贝复制的拷贝构造函数、赋值函数以及取地址操作。

构造函数

构造函数就是创造一个类的实体,从而实现类的实例化的操作。相当于数据结构里面init函数的操作。

构造函数分为默认构造,带参构造、初始化列表和拷贝构造

它们公有的特点就是

1、函数名是类名。

2、没有返回值,所以不写返回值类型,直接写函数名。

3、支持重载。

4、构造函数是在类实例化时自动调用的。

5、我们不写构造函数,系统会产生一个默认构造函数。只要我们写了构造函数(包括下面的拷贝构造函数),系统就不会产生。

6、全局类现构造(因为程序先创建全局环境),然后再是局部按照行从上往下依次构造。

这几个点我会在下面几个函数重复强调一遍,要记牢。

首先我们讲

默认构造

默认构造分为系统默认构造,无参默认构造,缺省参数默认构造。

.系统默认构造

系统默认构造就是系统默认提供的。大概操作就是赋随机值。

我们用日期类来进行探究:

上面的日期类,我们是没有写默认构造函数的,所以编译器会自己产生一个,我们看看是怎么个赋值法:

在debug版本下:

在release版本下:

我们发现在debug下就是要么就是创建了内存给的初值

如果学了函数栈桢的创建和销毁(不知道的可以看一下这篇博客)的就可以知道内存划分后赋予的值就是0XCCCCCCC。说明编译器没有做处理。

在release下就是赋了一个值0。

假如想默认赋值其他的就不行,所以编译器的默认构造函数大多是情况是满足不了我们的需求的

这里当然有特殊情况,就是当我们的类成员全是自定义类型时(全是自己写的类作成员),这个类调用系统默认构造的时候,会继续调用各个自定义成员的默认构造。当然只要有一个不是自定义类型就不行。

.无参默认构造

那么我们知道了就要自己写默认构造函数了:

这里我们用类名作函数名,没有返回值。

最后也是目标结果。

如果有指针,那么就是开一个默认大小的空间。也是同理。

.缺省参数默认构造

另外我们的缺省参数也可以作默认构造,因为不穿值的时候就会拿缺省构造。

这样就完成了。同时两个默认构造构成了重载。

读者认为我们最后的打印结果是什么呢?

是不是不知道打印哪个默认构造的值。当然编译器也不知道该调用哪个。

所以我们只能留一个。

这样就完成了。

然后是下一个构造:

带参构造

默认构造只能创建默认值,所以我们要有用户自己的值构造。

分别有传参构造和缺省构造。

.传参构造

就是多了一个传参

.缺省参数构造

这个学了缺省参数的都知道。赋值了就带参了,没复制就是默认了。

我们发现默认构造和带参构造都可以写成这个缺省参数构造,所以图方便只写这个就够了。一个顶两。

最一个构造函数是:

初始化列表

初始化列表就是我们各个成员函数初始化的时候赋予的值,相当于成员函数内存开辟时首先给的值。为什么叫初始化列表就是因为有很多成员函数都要初始化,形成一条线或几条线像列表一样。

那么初始化列表也是最先开始的地方,现进行初始化列表,再进行有餐或默认构造。

它的语法是这样的:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。

 

或者这样

第二种如果函数成员多的话就会拉老长,不建议。

注意⚠️!!

这里我们看到了已经有了传参和不传参,所以初始化列表也是属于带参构造和默认构造的一种。关键看构造函数的参数是什么样的

写传值的初始化列表

我们用两个例子来看看:

我们发现直接就完成了上面带参和默认的所有功能,也是十分的厉害。其实这里的不传参形式也是默认构造,属于是第四种默认构造了以及第三种有参构造。

如果我们不在括号里面传入参数会怎么样呢?

不写传值的初始化列表

我们来调试看一下:

调试会现进入初始化列表,发现进过这个过程的成员都默认赋值给了0,其他的还是原初值

所以我们这样写就是初始化为0

我们再加一个指针看看:

发现指针初始化为空。

不写初始化列表

不初始化列表有两种情况:

没有指针成员:

有指针成员:

估计是怕有指针不初始化会成野指针,所以索性全部初始化一下。

同时我们有一个默认初始化值,写在我们规定成员的后面:

这个主要是在没有自己写初始化列表的情况下自己给的默认值

初始化列表建议

一般初始化列表会和我们的另外两个构造联合使用,当然单独使用也是可行的

这里的整个构造流程就是:

先进行初始化列表,没有显示初始化列表看有没有在成员声明那有默认值,没有就是0XCCCCCCC或者0,有就是默认值;有初始化列表但是不传值就是赋值0,指针就是空;有初始化列表且传值就是用传的值初始化。初始化列表走完后,就进行下面的赋值操作,即默认构造函数或者有参构造函数的传值。

这里我们可以发现,初始化列表就可以完成整个操作,默认构造和有参构造显得有点多余,那么我们赋值操作区域就可以做一些其他的操作。例如下面的情况

总之,能初始化就用初始化来解决,资源消耗更少。在一些不方便直接初始化的地方,例如要额外开辟空间的时候就用赋值。

我们就可以用初始化列表创建空间,然后用赋值操作来赋值。

拷贝构造函数

拷贝也是我们写代码时候经常用到的,创建一个变量时用另一个变量的值进行构造,就是拷贝构造。有时候是传参的时候有形参的拷贝,有时候是函数返回的时候有返回值的拷贝。总之,用到拷贝的地方有很多。

拷贝函数注意的几个点:

1、拷贝函数就是构造函数的重载

2、拷贝函数必须是传引用,传参会无穷递归下去

3、自定义类型进行拷贝行为的时候必须调用对应的拷贝构造函数,包括自定义类型的传值传参和自定义类型的值返回

4、如果没有显示写拷贝构造函数,编译器也会默认写一个拷贝函数,属于浅拷贝

5、类似上面的构造函数,在有自定义类型的类发生拷贝时,自定义类型成员会自动调用自定义类型的拷贝构造函数。

6、函数返回值的时候会发生拷贝,那么就可以返回引用,但是如果我们返回的值在当前函数里面是局部变量的时候,出了函数就会销毁,从而发生越界访问。所以我们使用引用返回的时候要注意这一点。

浅拷贝

浅拷贝就是没有类存申请的指针类的拷贝,我们只需要将现在的数据赋值过去就行了。记得要用引用,否则会产生无穷递归。因为传的形参也会发生拷贝,产生的拷贝传入值又是形参又会产生拷贝,就这样无穷递归下去了。

因为系统的默认拷贝就是将成员函数的数据一个字节一个字节的拷贝过去,所以如果是浅拷贝我们不必要写拷贝。(如下图)

我们使用拷贝的时候,有两种写法如图红框。

深拷贝

当有指针等额外指向堆区内存的情况时,我们拷贝就要注意:

调试我们看到,b拷贝a的时候,只是把地址的值拷贝过去了,导致a成员指针指向的内存有两个指针指向着。如果这种情况发生在顺序表,那么这个变量size改变了,另一个变量也指向这个内存,虽然内存跟着改变,但是size并没有改变,就出现了问题。所以必须要深拷贝。

这样b就不会产生问题了。

升级一下用初始化列表也可以完成相关操作。

析构函数

我们讲了构造函数,与之对应的就是析构函数,即销毁实例化,类似c的destroy函数。

析构有几点:

1、析构函数就是在构造函数名前面加一个取反符号~,可以理解成构造的取反就是析构。

2、和构造一样没有返回值

3、一个类只有一个析构,自己不写系统会自动生成析构

4、对象生命周期结束的时候就会调用析构

5、如果我们类里面有自定义成员,那么就会在析构的时候调用它的析构函数。

6、析构函数主要是清理动态规划出来的内存(堆区上),系统自己的析构足以满足全部在栈上面内存的销毁。

7、后定义的先析构。

所以我们写析构主要针对有内存申请的情况去写:

析构只要记住上面几点就行了。

自定义类型类的构造析构

上面我说明了类里面自定义类会在构造时调用它的构造函数,在析构时会调用它的析构函数。我们来看一看。

我们再创建一个类double_date,里面的所有成员变量都是自定义类型date:

我们用两个打印来验证。

最后打印结果确实和预期一样。

赋值重载

我们将已经创建好的自定义变量值替换成另一个自定义变量的值,就叫赋值。

赋值重载注意一下几点:

1、赋值重载要写成成员函数,参数可以是形参,但是会产生拷贝,所以尽量用引用。

2、赋值重载有返回值,结合标准类型可以实现连等,我们可以返回引用。

3、如果我们不是显示实现,编译器也会自己创建一个赋值重载,属于是浅拷贝。

4、还是一样的,我们用自定义类型创建的类进行赋值的时候,系统会自动调用自定义成员变量的赋值重载函数。

5、赋值重载和拷贝构造容易搞混 date a=b是拷贝构造,date a;a=b;是赋值重载。

下图就是没有指针申请内存的情况,我们可以不用写赋值重载。

但是遇到有指针的情况,我们就要写,不然就是和上面浅拷贝构造一样发生内存共用的问题。

可能动态内存有不同的操作手段,这里的指针也只是做个例子。具体问题要有具体的拷贝构造,和赋值构造来解决相关问题。

取地址重载


取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,一般这两个函数编译器自动生成的就可以够我们用了,不需要去显示实现。

友元函数

有时候我们的外部函数必须要访问类内的成员,那么我们就可以在类里面声明这个函数为友元函数:

友元有以下几点需要注意

1、友元提供了一种突破类访问限定符封装的方式,友元分为:友元函数和友元类,在函数声明或者类声明的前面加friend,并且把友元声明放到一个类的里面。

2、外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,他不是类的成员函数。

3、友元函数可以在类定义的任何地方声明,不受类访问限定符限制。

4、一个函数可以是多个类的友元函数。

5、友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员。

6、友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元。

7、友元类关系不能传递,如果A是B的友元, B是C的友元,但是A不是B的友元。

8、有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。

友元我们可以用朋友来理解。

重载⭐️

运算符重载

自定义类也可以进行相关的计算,那么我们可以重载相关的运算符提高代码的可读性,同时我们写的也很舒心。

注意的几点有:

1、上面的赋值重载也是运算符重载,因为属于系统默认函数,所以放上面单独讲了。剩下的操作符系统没有默认的,需要自己写。

2、运算符重载是具有特名字的函数,他的名字是由operator和后面要定义的运算符共同构成。

3、重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

4、如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。

5、运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。

6、不能通过连接语法中没有的符号来创建新的操作符:比如operator@。

7、.*   ::    sizeof   ?:    . 注意以上5个运算符不能重载。

8、一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如date类重载operator-就有意
义,但是重载operator+就没有意义。

9、重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。

10、重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。

那么我就以简单的两个int成员组成的类来实现相关的重载:

+=、-= 号

+ -号

自增自减运算符

++类就是:(++a)

类++就是:

同理--类,类--。

逻辑运算符

逻辑运算符我们只要实现=和一个大小比较符就可以用这两个重载把其它的实现了。

流重载

因为重载函数参数和符号左右参数对应(例如operator+(dint a,dint b)->a+b),所以这里流重载写在类里面就不可以:我们写在类里面第一个参数默认是this指针,所以就和我们流的使用搞反了,只能写在类的外面,如果写在外面我们就要用友元:

其它重载

另外还有下标[]符重载,%重载,*,~等,依照重载是否有意义等来考虑我们是否重载。

const修饰的成员变量

我们在定义const int的时候只能 const int a=0;不能const int a;a=0;因为第二种已经初始化了,后面的属于赋值操作,会报错。

在类里面的成员变量同理,必须在初始化列表的时候进行初始化。且不能赋值。

static修饰的成员变量

static的成员变量基本就是所有实例化对象共用的类,它是不能用初始化列表的默认参数的,就是下面这个:

因为如果每个类都不写显示初始化列表,走默认构造,这个静态的成员变量就会重置值n次。显然不合理。

除非这个静态成员是常量并且是整形相关的(这里static和const交换无所谓):

除了上面的这种特殊情况,其它情况需要在类外进行初始化:

 

内部类

内部类就是在一个类里面定义一个类。这个内部类不是这个外部类的成员,唯一变的就是内部类的作用域,作用域变成这个类里面的了:

这里要访问b就要双重空间限定。内部类可以作为外部类的专门辅助类,来实现相关的操作。

匿名对象

有些时候我们创建的对象只用一次就不用了,那么我们就可以用匿名对象来减小开销,同时省事.

1、用 类型(实参) 定义出来的对象叫做匿名对象,相比之前我们定义的 类型 对象名(实参) 定义出来的叫有名对象。

2、匿名对象生命周期只在当前一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象。

我以上面的dint对象来看,我们只想算一下三个类值相加就不用了:一个是带参构造,一个是默认构造一个是拷贝构造。

 隐式类型转换 ⭐️

用法

C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数,就是带参构造函数必须要有。

依旧用dint来举例:

这里的dint a=1也不像是拷贝构造,其实这个就是隐式类型转换:

首先会创造一个临时变量,将int类型的1转换成dint类型,然后再拷贝构造给a

当我们把上面的缺省参数构造函数注释掉后,这个转换就不起效果了。所以需要有相关内置类型为参数的构造函数。

我们看看运行结果:

发现没有产生拷贝构造,这是因为编译器做了优化,这个将会在下面最后来讲。

会优化成这个:

我们再看一个:

这里引用不能的原因是什么呢?

不是因为不能够转过来,而是已经发生了隐式类型转换,用一个中间存起来了,但是这个临时变量具有常性,所以我们必须用const引用来接收

如果是多成员的要怎么办呢?

我们可以用大括号来写,这个要和构造函数参数一一对应

如果我们反着来写构造函数的参数,这里就会反着来隐式类型转换。

那么这个有什么意义呢?

意义

我们在写stack的push时(存储的是dint类 ),会这么写:

是不是显得繁杂,那么我们学了匿名对象可能会写成这样:

但是我们还可以这样写

我们直接连类型也不需要写了,是不是又省了很多。

如果不想要这个

构造函数前面加explicit就不再支持隐式类型转换

编译器对拷贝构造的优化

现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下会尽可能减少一些传参和传参
过程中可以省略的拷贝。

如何优化C++标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编
译器对于连续一个表达式步骤中的连续拷贝会进行合并优化,有些更新更"激进"的编译还会进行跨
行跨表达式的合并优化。

我们在讲上面的隐式类型转化的时候就提到了这个东西。

那么我们再看一个案例

我们看下面的代码会走什么过程:

第一个应该是现带参构造(编译器优化成了一个),然后传给text拷贝构造,然后出函数析构

第二个应该是带参构造匿名函数,然后拷贝,最后出函数析构

第三个就是带参构造一个临时变量(隐式类型转换)拷贝给text作为形参。

但是我们编译器进行了优化,第一个优化了隐式类型转换,第二个第三个优化了带参构造,直接转给了形参进行带参构造。

编译器在某些情况下会很激进的优化:

理论上来说会有一次默认构造a,然后出函数作为返回值一次拷贝,然后给流cout作为返回值一次接收,就是一次默认构造,一次拷贝构造:

但是我们的编译器直接去除了a的构造,直接构造返回值返回了。

点赞关注一下吧!

写了9k字,可见作者用心,大家认真看完类绝对没有大问题。希望给个大赞鼓励一下作者😆

  • 21
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值