结构体
导读
大家好,很高兴又和大家见面啦!!!
经过前面的学习,我们对C语言中的数据类型有了一个比较清晰的认识。对于数据类型,我们可以简单的理解为数据在内存中存放的形式以及所占空间的大小。
我们现在接触的数据类型char、short、int、long、float、double、bool……
都是C语言提供的一些已经设定好的基础数据类型,我们可以将其称为内置数据类型。
在C语言中,除了这些内置数据类型之外,还有一系列的拓展数据类型。我们已经学过的有数组类型,与指针类型。
数组是由多个相同的数据类型的变量所组成的集合,如字符数组是由一个或多个字符类型的变量组成的集合,整型数组是由一个或多个整型变量组成的集合;
而指针类型则是表明了指针在一次操作中的权限大小 ,如字符指针在一次操作中只能够操作一个字节的内容,整型指针在一次操作中可以操作4个字节的内容。
除此之外,C语言中还存在一些自定义数据类型。那什么是自定义数据类型呢?
一、自定义类型
我们知道,计算机语言是程序员与计算机沟通的桥梁。程序员可以通过计算机语言来向计算机描述我们的现实生活中的事物,计算机则会根据计算机语言的描述将这些事物以数据的形式给展现出来。
但是对于现实世界中的事物而言,很少有能够通过单一的内置数据类型就能完成描述的事物,大部分的事物都需要两个或多个不同的数据类型才能完整的进行描述。
如描述一棵树:
我们可以通过单一的浮点型或整型来描述数的高度、宽度、叶子的数量……
我们也可以通过单一的字符型来描述树的品种、种植地、形状……
但是我们要描述一棵完整的树时,这时我们就需要同时借助浮点型或整型的数据与字符型的数据来描述这棵树的各种特征。
对于不同的事物,我们所需要的数据类型也有区别,因此为了完成不同事物的描述,我们就需要像创建函数一样创建一种新的数据类型,这种由程序员自己创建的数据类型就是 C语言中的自定义类型。
在C语言的自定义数据类型中,大致可以分为3种自定义数据类型——结构体、联合体与枚举类型。在今天的内容中我们将会学习第一种自定义数据类型——结构体类型。
二、结构体
2.1 什么是结构体
结构体是一种用来描述复杂对象的自定义的数据类型。
结构与数组一样也是一些值的集合,在结构体中这些值称为成员变量。与数组不同的是,结构的每个成员可以是不同类型的变量。
在结构体中的成员变量的数据类型可以是内置数据类型如字符型、整型、浮点型以及拓展数据类型如数组型、指针型、结构体类型……其中的一种、两种或多种不同的数据类型。
2.2 结构的声明
在函数中我们有学过,当我们要创建一个自定义行数时,我们需要先声明函数的返回类型、函数名以及函数的参数类型。
在结构体中也是如此。当我们要创建一个结构体类型时,我们需要声明结构体的类型名、结构体成员的数据类型以及成员名。
与创建自定义函数不同的是,在创建结构体时我们需要借助结构体关键字——struct
;
2.2.1 构体的声明格式
结构的声明指的是描述结构体的标签名,结构体的成员以及通过结构体定义的变量。格式如下:
struct tag {
member_list;
}variable_list;
//struct——结构体关键字,用于结构体的声明
//tag——结构体的名字,用于表示结构体的用途
//member_list——结构体成员列表
//variable_list——结构体变量列表(可有可无)
在声明一个结构体时,有几个点需要大家注意一下:
struct
这个关键字是不能省略的;- 结构体名可以根据实际情况来进行命名;
- 结构体成员可以是一个也可以是多个,成员的类型可以不相同;
- 结构体变量列表内的变量为全局变量,在声明结构体时可以不用定义变量;
- 结构体在声明完后的;是不能省略的;
- 结构体的{}是不能省略的;
2.2.2 结构体的特殊声明
与自定义函数不同的是,在声明结构体时我们也可以不完全声明。其格式如下:
struct {
member_list;
}variable_list;
//struct——结构体关键字,用于结构体的声明
//member_list——结构体成员列表
//variable_list——结构体变量列表
相比于正常的声明格式,特殊声明格式中可以省略结构体的名字,但是在这种情况下结构体变量列表式不能省略的,并且在完成了声明与变量创建后,我们无法再使用该结构体继续创建变量,因此这种特殊声明的结构体只能够使用一次。
2.3 关键字typedef
在C语言中有一个可以对数据类型进行重命名的关键字——typedef
。这个关键字可以将内置数据类型、拓展数据类型以及自定义数据类型的名字进行重新命名,如下所示:
可以看到当我们通过typedef
将整型int
重命名为I
之后,通过I
创建的变量a
的数据类型就是整型,并且I
所在的内存大小同样也是4个字节。
2.3.1 typedef
的作用
有朋友可能就会奇怪了,typedef
这个关键字的作用似乎有点鸡肋呀,我如果要创建一个整型变量的话,我直接使用int
不就完事了吗?简单又方便,何必将其重命名呢?
typedef
的作用可以将其总结为两点:1.简化数据类型名、2.方便代码修改。
第一个作用简化数据类型名,相信大家从上面的这个列子都能感受得到。对于int类型原先时三个字母,当我们通过typedef
重命名后,就变成了1个字母,确实时简化了数据类型名。
但是对于第二个作用方便代码修改,咱们却没啥感觉。下面我们来看一个列子:
在这个例子中我们通过两个函数分别完成值的交换以及输出,可以看到此时我们输出的是整型,当我想输出字符时,按照以往的编程方式,我们要么重新写一下同样的代码,要么将所有函数中的变量类型进行修改,但是借助typedef
后,我们只需要修改typedef
这一行的int
,如下所示:
可以看到,当我们将int改为char之后,函数在输出时由整数变成了字符。相比于之前的修改方式,很显然借助了typedef
后代码的整体修改就简单了很多。
2.3.2 typedef
的使用
当我们在使用typedef
时我们一定要注意,typedef
只能够重命名数据类型,它是无法重命名变量名的,如下所示:
可以看到,当我们通过typedef
来重命名变量时,系统会提示变量x
不是类型名,以及不允许使用类型名y
进行输出。这个大家一定要注意,typedef
只能够修改数据类型的名字。
2.3.2 typedef
在结构体中的应用
当我们在创建结构体时,我们需要注意的是我们创建的是一个自定义数据类型,因此,结构体的类型名字是可以通过typedef
来进行修改的。
这时有朋友就会说了,我为什么需要借助typedef
来修改呢?如果想名字简单点,我直接在声明结构体的时候设置一个简单点的名字不就好了吗?如下所示:
struct t {
int a;
char b;
short c;
};
void test4() {
t a;
}
这样不就很方便吗?完全用不上typedef
。
那真的是这样吗?下面我们来运行一下该代码,如下所示:
从系统提示中可以看到,我们在完成类型声明并通过该类型名创建变量后,程序居然报错了,报错的原因时't'
是一个未声明的标识符。为什么会这样呢?我们在前面不是已经声明了吗?
其实像我们这种使用方式是不对的,对于结构体这种自定义类型而言,其关键字就是该自定义类型的一部分,因此当我们在声明好一个结构体类型之后,我们在使用时,是需要加上关键字struct
,也就是说在这个例子中struct t
才是我们声明的结构体类型名,如下所示:
可以看到,此时我们才是完成了结构体类型的声明与结构体变量的创建。
也就是说,每当我们创建一个结构体变量时,我们都需要使用struct t
来完成创建,很显然这样的创建方式是比较繁琐的,因此我们就可以在声明结构体类型时借助typedef
来对结构体类型进行重命名,如下所示:
可以看到,此时我们再使用t来创建变量时就不会发生错误了。
2.4 结构体变量的创建和初始化
在结构体中,变量的创建有两种方式——1.在声明时创建,2.在使用时创建。如下所示:
//结构体变量的创建与初始化
struct test {
int a;
char b;
short c;
}t1, t2, t3;//在声明时创建了三个结构体变量
void test5() {
struct test t4, t5, t6;//在使用时创建了三个结构体变量
}
当我们要对其进行初始化时,同样也有两种方式
- 按默认顺序进行初始化——根据结构体类型声明时的成员列表中的成员顺序进行初始化
- 按指定顺序进行初始化——借助结构体成员访问操作符来指定初始化对象的顺序
这两种初始化方式如下所示:
可以看到,当我们在初始化时赋值一个0时,表示的是按结构体成员的默认顺序进行值为0的初始化,并且我们在初始化阶段,不管是按默认顺序进行初始化,还是按照指定顺序进行初始化,都不会影响结构体的初始化。
2.4.1 结构体变量创建的区别
当我们在创建结构体变量时,对于不同的创建方式,其效果也是有区别的:
- 当我们在声明结构体时创建变量,那么该变量的作用域与结构体声明时的作用域是一致的
- 当我们在使用结构体时创建变量,那么该变量的作用域与结构体使用时的作用域是一致的
如下所示:
可以看到,对于结构体struct test
而言,它是在全局域中进行声明的,因此其结构体变量t1/t2/t3
都是属于全局变量,而结构体struct test2
是在局部域中进行声明的,因此其结构体变量t7
属于局部变量;
对于变量t4/t5/t6/t8/t10
而言他们都是struct test
在局部域中使用时创建的变量,因此它们都是局部变量,同理,变量t9
是struct test2
在局部域中使用时创建的变量,因此t9
也是一个局部变量。
这里我们需要注意的是,t1/t2/t3
与t7
都是在进行结构体声明时创建的变量,它们实际上等价于先声明再使用的形式,如下所示:
这也就是为什么我们在声明时可以没有变量列表的原因。
2.4.2 重命名与变量创建
当我们对数据类型进行重命名时,有一点需要注意,重命名的过程中是无法创建变量的,如下所示:
因此,当我们在声明结构体类型并进行重命名时,我们是无法同步创建结构体变量的,如下所示:
可以看到,当我们对结构体完成重命名后,并尝试着像之前一样同步创建变量t11
时,结果出错了,错误的原因时在'='
前并未声明变量,也就是说此时的t11
并不是变量名,它实际上和test一样都是属于结构体struct test
重命名后的别名,也就是说此时我们是既可以通过test来创建变量,也可以通过t11
来创建变量的,如下所示:
也就是说当我们在声明结构体时同步创建变量的操作与重命名的操作只能够进行二选一:
- 如果我们要同步创建变量,那我们就不能够进行重命名;
- 如果我们要对结构体进行重命名,那我们就不能同步创建变量。
2.5 结构体的自引用
在前面我们有提过,结构体成员的数据类型可以是内置数据类型、拓展数据类型以及自定义数据类型,中的一种或多种类型。那也就是说这个自定义数据类型既可以指其它的结构体类型,同样也可以指本身的结构体类型。那是不是这样呢?下面我们来简单的测试一下:
可以看到,此时系统是报错的,报错的内容时语法错误,test
是一个标识符。这是为什么呢?test难道不应该是我们重命名后的数据类型名吗?
这是因为计算机并不具备跳跃性思维,它能够执行的操作就是一步一步的顺着代码从上往下运行。在这个例子中我们可以看到,此时的test在完成重命名的过程是在结构体成员后的,因此,当程序运行到这一行时,结构体并未完成重命名。那是不是说只要我加上了关键字struct
就行了呢?下面我们继续来测试:
从测试结果中我们可以看到,此时任然是有问题的,问题的原因时未定义的struct test
,但是我们在这句代码前已经完成了定义了呀,为什么会这样呢?
导致这个问题的原因是在结构体成员列表中,能够存在的自定义类型只能够是其它的结构体类型。当在结构体成员列表中含有自身的自定义类型时,我们可以设想一个场景,这个结构体需要占用的内存空间是多少?也就是sizeof(struct test)
的值是多少?
相信大家已经意识到这个问题了,在这种情况下,如果要计算该结构体的内存大小时,它会陷入到一个死循环中,这就会导致无法计算该结构体所占的内存空间大小。
那既然在结构体中无法使用自身的类型,那结构体的自引用是指的什么呢?
实际上结构体的自引用的含义是在结构体中包含一个与自身同类型的结构体指针,如下所示:
可以看到此时程序是能够正常运行的。那现在问题来了,这个自引用有什么含义呢?
这里就涉及到【数据结构】的相关内容了。所谓的数据结构我们可以理解为是数据与数据之间一种或多种关系,包括逻辑上的关系、存储上的关系以及数据与数据之间的运算方式。
数据与数据在逻辑上可能是一个挨着一个的线性关系,也有可能是像一棵树一样从一个数据发散开的树形关系,也有可能是像蜘蛛网一样的网状关系,如下所示:
数据与数据在存储上可能是紧挨着的顺序存储,也可能是由一根链条连接的链式存储,还有可能是根据相应的索引信息进行存放的索引存储,又或者可以通过相应关键字找到对应数据的散列存储,如下所示:
数据与数据之间可以进行算术运算、拷贝、连接、比较、增加、删除……
对于不同的数据结构,其数据在逻辑上、存储上、数据之间的运算上都有不同,这里我们需要介绍的是链式存储。
在链式存储中,每个数据都是存放在一个结点中,不同的数据之间是通过一根链条进行连接的,那我们在计算机语言中应该如何描述这根链条呢?
有朋友已经反应过来了,没错,就是借助指针来模拟链条。
我们知道指针存储的是数据的地址,我们可以通过指针中存储的地址来找到对应的数据。在链式存储中,由于结点都是分散的存放在内存中的,因此我们想要从一个结点找到另一个结点,那我们就可以将该结点的数据存放在自己的结点中,用C语言来描述的话就是结构体的自引用操作。
如下所示:
在链式存储中,由结构体创建的变量我们可以将其称为结点,在每个结点中我们都需要存放对应的数据,因此我们可以规定结构体中存放数据的区域为数据域,而我们还要能够从一个结点找到其它结点,因此每个结点中我们还需要一个存放下一个结点地址的指针,所以我们可以规定结构体中存放与指针的区域称为指针域,而对于每个结点来说,它们的都是由同一个结构体进行创建的,所以每个结点的数据类型都是一致的,因此结点指针域的数据类型是与自身相同数据类型的指针类型。
2.6 结构体传参
在函数中我们有学过,函数的传参方式有两种——传值传参与传址传参。这两种传参方式的区别是:
- 传值传参——函数形参是实参的一份临时拷贝
- 传址传参——函数形参是指向实参的指针
对于结构体变量而言,两种传参方式都是可行的,但是建议大家使用传址传参。有朋友可能会奇怪,为什么要使用传址传参呢?下面大家来看这个例子:
//结构体的传参
typedef struct test {
int data[100000];//数据域
struct test* next;//指针域
}test;
void func(test t) {
}
void test8() {
test t = { 0 };
func(t);
}
在这个例子中可以看到,结构体的一个结点的数据域就需要100000个整型空间,在这种情况下,如果我直接进行传值传参,那对于形参t来说,它就需要在内存中开辟至少100000个整型空间,很显然,这并不是一个明智之举。
想象一下,在func
函数中如果是进行递归操作,那么每一次递进都需要在内存中开辟至少100000个整型空间,这样就很容易导致栈溢出的问题。
但是如果我们是通过传址传参,每次传参只是传入的结点的地址,那也就是说,即使函数是执行的递归操作,那我们在函数中也只需要开辟一个能够存放地址的指针空间即可,这样我们就可以最大程度上的避免栈溢出的问题。
这时有朋友可能就会说了,如果我不想修改实参的数据,那使用传址传参的过程中如果操作失误的话不就容易导致实参中的数据也被修改了吗?
对于这个问题的处理很简单,我们只需要通过const
来修饰形参即可,如下所示:
//结构体的传参
typedef struct test {
int data[100000];//数据域
struct test* next;//指针域
}test;
void func(const test* pt) {
func(pt);//这里的pt已经是指针了,所以在递归的过程中就不需要取地址了
}
void test8() {
test t = { 0 };
func(&t);
}
这里要注意,我们用const
修饰的应该是*pt
,也就是说我们不同通过解引用的方式来修改实参的数据。
现在肯定有朋友会存在疑惑,为什么前面介绍的结构体的各种内容都是以正常声明的格式来介绍的,那对于通过特殊声明的格式声明的一个匿名结构体,我们又应该如何使用呢?下面我们就一起来探讨一下匿名结构体的使用;
2.7 匿名结构体的使用
前面我们有提过,当我们声明一个匿名结构体时,该结构体只能够使用一次,我相信有朋友对这个只能使用一次是不能理解的,下面我们就来解释一下为什么它只能够使用一次。如下所示:
可以看到我们第一次声明了一个匿名结构体,并创建了一个结构体变量t1
,之后我们又声明了一个结构体成员与上一个匿名结构体相同的匿名结构体,并创建了一个结构体变量t2
与一个结构体指针pt
。
在测试函数中我们可以看到我们通过指针pt来接收t2
的地址时,程序并未报错,说明对于结构体变量t2
与结构体指针pt
而言,它们的数据类型是相同的。
但是当我们用pt
来接收t1
的地址时,我们会发现此时程序报了警告,警告的内容是操作符两侧的类型不兼容。
也就是说,即使这两个匿名结构体的结构体成员是一致的,但是,系统会认为它们是两个不同的结构体类型。
这也就是为什么说匿名结构体只能够使用一次
。并且由于匿名结构体的这种使用特性,导致它是无法进行自引用操作
的。
这时有朋友可能就会说了,咱们不是可以通过typedef
来进行重命名吗?那我在声明匿名结构体时对其重命名不就好了吗?如下所示:
可以看到,当我们通过typedef
对匿名结构体进行重命名后,它就可以多次使用了。那是不是就意味着它也能够执行结构体自引用操作了呢?如下所示:
哇……居然一片红,这是为什么呢?
这个问题又回到了计算机的执行流程上了。
对于计算机而言,它只能够根据代码从上往下一行一行的执行,因此当程序走到t* next;
这一行时,此时的匿名结构体并未完成重命名,这时的t它也就是一个陌生的标识符,因此程序就出现了报错。
也就是说,结构体的声明失败了,那么对于函数中的结构体变量的创建也自然就不能成功创建,因此测试函数中的所有内容都是错误的,究其根本,是因为结构体的声明并未成功。
因此我们可以得到以下结论:
- 匿名结构体只能够在声明的同时创建变量,也就是匿名结构体只能使用一次;
- 当通过
typedef
将匿名结构体重命名后,该匿名结构体就能够多次使用; - 不管有没有对匿名结构体进行重命名,它都无法进行自引用操作;
结语
今天的内容到这里就全部结束了,在下一篇内容中我们将介绍《内存对齐与位段》的相关内容,大家记得关注哦!如果大家喜欢博主的内容,可以点赞、收藏加评论支持一下博主,当然也可以将博主的内容转发给你身边需要的朋友。最后感谢各位朋友的支持,咱们下一篇再见!!!