C语言自定义类型——结构体、联合和枚举



前言

C语言中的结构体是一种非常重要的自定义类型,C语言32个关键字中的struct用于定义结构体类型,结构体是更高级数据结构的基础。而联合体union和枚举enum又是另外两种重要的自定义类型。它们有着不同的用途和特点,但都相当重要。本篇文章就来讲述一下结构体、联合和枚举的基本知识。


一、结构体

C语言已经提供了许多内置类型:int、char、float、double……但是只有这些内置类型是不够的。对于某个对象,想要对于它的多种特点进行描述,单一的数据类型肯定不够,于是就有了结构体这样的自定义类型,将多种类型和值结合起来构成集合,形成一个新的类型来使用。

结构体声明

结构体的声明如下:

struct tag  //struct是关键字,tag代表自定义的结构体的名字
{
	member-list;  //成员列表
}variable-list;//结构体变量列表,可以省略,但分号不能丢!!!

成员可以有一个或多个,成员就是各种数据类型和变量,它们共同组成一个整体,成为一种新的数据类型。结构体声明可以在主函数外。

  • 结构体的特殊声明:
    结构体在声明的时候,可以用不完全的声明:匿名结构体类型。

    struct   //省略掉了结构体的名称
    {
    	member-list;
    }list;
    

    首先,这种类型可以用,但只能用一次,否则会报错;其次,在声明匿名结构体的时候,必须一起把想要创建的变量给创建了,这样才可以在main函数里使用匿名结构体变量。

  • 特别的,如果需要typedef重命名
    我们知道,typedef是一个关键字,可以对类型进行重命名来简化代码,那么它当然可以对结构体进行重命名,但它的重命名有一些不同的地方需要注意。

    typedef struct name
    {
    	member.list;
    }new_name;//对于结构体重命名,新的类型名要在声明时直接给出,在这个成员变量列表处
    

    如上,在结构体声明之后直接给出新名字(区分名字和成员变量),之后就可以用“new_name”直接代替整个“struct name”来使用这个结构体了。

结构体变量及访问

  • 结构体变量的创建
    结构体变量的创建有两种形式:

    • 在结构体声明的后面直接创建,构成一个变量列表。
    struct   
    {
    	member-list;
    }s1,s2,*p1,*p2; //注意,如果声明在main函数外,那创建的变量是全局变量!!
    
    • 在main函数里,用结构体类型创建
    struct Stu  //创建一个结构体记录学生信息
    {
    	char name[20];//名字
    	int age;//年龄
    	char id[20];//学号
    };
    int main()
    {
    	struct Stu s = { "张三", 20, "202503161234" };//按照成员列表顺序,用大括号初始化
    }
    

    其实还有另一种初始化方式,这就涉及到结构体成员的访问了。

  • 结构体成员的访问
    结构体成员的访问也有两种形式:

    • 结构体成员的直接访问:
      结构体成员的直接访问是通过点操作符(.)来完成的
    //借用前面的结构体:
    int main()
    {
    	struct Stu s = {.name = "李四",.age = 18.id = "202503161234"};
    	//这其实就是另一种初始化形式,利用直接对成员访问来直接初始化而不需要按顺序
    	printf("%d",s.age);//这也是直接访问
    }
    

    直接访问的使用方式:结构体变量名 . 成员名

    • 结构体成员的间接访问
      结构体成员的间接访问是通过间接访问操作符(->)来完成的。因为我们有时候得到的不是一个结构体变量,我们会用一个结构体的指针,这时候就要用到间接访问了
    struct str
    {
    	int x;
    	int y;
    };
    int main()
    {
    	struct str p = {2,3};
    	struct str * p1 = &p;
    	p->x = 5; //这就是间接访问了,利用指针去访问它
    	p->y =6;
    	return 0;
    }
    

    对于间接访问的指针变量,虽然可以直接创建,但那样的创建是未初始化的,有风险。如果是全局指针的话,默认指针为NULL,如果是局部的指针,那么一定要初始化,否则就是野指针了。重点在于要确保这个指针不是野指针或者为空,规避风险即可。

  • 结构体传参
    在了解了结构体的访问之后,我们有必要讨论一下结构体的传参。
    我们首先给出结论:在结构体的传参中,我们首选传递结构体的地址。

    struct S
    {
    	int data[1000];
    	int num;
    };
    void test(struct S* ps)
    {
    	ps->num = 6;
    }
    int main()
    {
    	struct S s = {{0},1};
    	test(&s);
    }
    

    我们知道,传参是如果是传值调用,就会创建额外的栈帧,额外占用内存空间,而结构体是多个不同的变量的集合,结构体的数据可以是很大的。如果额外占用空间运行函数,那么占用的空间会很大,系统的性能会受到影响。
    这其实也是结构体的一个好处,可以通过结构体指针传递大型数据进行函数调用,避免额外的内存拷贝,也提高了程序的效率。我们只需要一个结构体的传参,再通过指针和间接访问就能够操作大量的数据,避免了大量的传参,代码的可读性也大大提高。

  • 结构体自引用
    我们知道,结构体内部是各种类型的数据,而结构体本身也是一种自定义的类型,那么是否能够在结构体里包含一个结构体呢?答案是肯定的。我们再思考,包含不同的结构体类型当然是可以的,那么包含一个类型为该结构体本身的类型呢?

    struct Node
    {
    	int data;
    	struct Node s1;//s1是创建的变量
    };
    

    对于这样的代码,其实也很好分析,我们可以想一想sizeof(struct node)的结果。这样写代码的话只会是无限的包含结构体,理论上它的大小无限大,这是不可能的。要完成同类型的自引用,最好的办法是创建指针变量,用指针指向结构体类型。

    	struct Node
    {
    	int data;
    	struct Node* next;
    };
    

结构体的大小与内存对齐

学习一个类型,尤其是对于这样的不同寻常的自定义类型,自然需要弄清楚类型的大小,以了解结构体在内存中的存储,更好的去使用结构体完成更复杂的数据结构。而结构体的大小本身也存在特殊的地方。
我们可以先看两个例子:
在这里插入图片描述

在这个例子里,我们可以清楚的看到哪怕结构体内是同样的几个成员,但其大小依旧不同。在调试以后,我可以直接给出两个结构体在内存里的存储情况:
在这里插入图片描述
这张图大致描述了上述两个结构体在内存中的存储,我们可以看到,存在内存的浪费现象,结构体内部的变量并不是紧挨在一起申请空间的。这是因为一个特殊的结构体存储规则:结构体内存对齐

  • 结构体内存对齐
    规则:

    • 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处。
    • 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址(偏移量)处。
      • 对齐数=编译器默认的⼀个对齐数 与 该成员变量大小的较小值。
      • 对于不同的编译器,默认对齐数有所不同:vs中默认为8;gcc编译器中没有默认的值,对齐数就是成员自身大小
    • 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的整数倍。
    • 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
    • 补充:对于以上规则,所谓地址都是相对于起始地址的偏移量

    根据对齐规则,对于上述的例子,我们就可以分析了

    struct S1
    {
    	char c1;//1字节  放在第一个字节,偏移量为0的起始地址
    	char c2;//1字节  对齐数为1 
    	int n;//4字节   对齐数为4
    };
    

    对于这个结构体,变量c1作为第一个变量,存在偏移量为0的第一个空间,c2放在1的倍数的偏移量的地址处,所以紧接着存放,而对于n,对齐数为4,放在4的倍数的地址偏移量处,按顺序,就从第4个字节开始,占4个字节,中间的两个字节并没有使用,浪费了。到这里,整体已经8个字节,最大对齐数为4,那么总空间就是4的倍数也就是8个字节。

    struct S2
    {
    	char c1;//放在0处
    	int n;//对齐数为4
    	char c2;//对齐数1
    };
    

    对于这个结构体,c1依旧放在第一个,而n的对齐数是4,就直接要放到起始地址偏移量为4的地方,占4个字节,c2的对齐数为1,那就可以紧接着放在n的4个字节下面,但是这时已经占用了总共9个字节,最大对齐数是4,总空间只能是4的倍数,所以总空间是12个字节。
    这就对应上了上图的存储情况了。

    相信在了解了内存的对齐规则之后,应该能够正确计算出结构体的大小了,虽然给出的例子相对简单,但只要掌握规则,相信对于任意的结构体,计算大小也就不会困难了。

  • 为什么存在内存对齐?
    总体来说,有两个原因:

    1. 平台原因(移植原因)
      不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定
      类型的数据,否则抛出硬件异常。
    2. 性能原因
      数据结构(尤其是栈)应该尽可能地在⾃然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。

    简单来说就是:结构体的内存对齐是拿空间来换取时间的做法,让程序效率整体提高。
    那在设计结构体的时候,我们既要满足对齐,又要节省空间,那么可以:让占用空间小的成员尽量集中在⼀起

  • 修改默认对齐数
    上述内存对齐规则中,最重要的一个新概念是对齐数,VS的默认对齐数是8,gcc编#译器没有默认对齐数。但是,如果我们实在是认为结构体的对齐方式不合适的话,我们也可以自行修改默认对齐数

    #pragma pack(4)//设置默认对齐数为4
    #pragma pack()//取消设置的对齐数,还原为默认
    

    如上,C语言提供了一个预处理指令:#pragma pack(),当我们在括号内部写了数字那就重新设置了默认对齐数,但如果没有写数字,那就是还原默认对齐数,取消设置。

结构体实现位段

结构体基本概念讲述的差不多的时候,我们再来了解一下结构体实现位段的能力

  • 什么是位段?
    位段和结构体的声明是基本相同的,只有两个不同:

    • 位段的成员基本都是int、unsigned int、signed int、char类型
    • 位段成员名后面有一个冒号和一个数字,代表位段和位段成员大小,单位是bit位
    struct A
    {
    int _a:2; //a要使用2个bit位
    int _b:5; //b要使用5个bit位
    int _c:10; //c要使用10个bit位	
    int _d:30; //d要使用30个bit位
    };
    

    位段使得结构体成员可以再bit位级别申请空间,精细化内存管理,这是好处,比如在解析网络协议或者对硬件内存的一些操作中,位段有着相当大的作用。但它也有诸多不确定性因素。

  • 位段的内存分配
    位段的内存分配首先是申请空间,但是一般都是按照需要以4个字节(int)或者一个字节(char)来申请的,然后我们再进行分配,既然是对于bit位的操作,那么就要针对数据的二进制来分析,也就涉及到了数据在内存中的存储,对于位段大多数的整形类型,那就是原码反码补码。那么既然我们申请的是字节的空间,位段却是把bit位级的存储数据,那么这样的形式也带来了许多的问题和风险:

    • 对于申请到的字节级空间,我们位段成员是从8个bit位的左边开始使用还是右边开始使用,这是没有标准不确定的,不同的环境或者说编译器是不同的。而不同的使用方式得到的结果肯定是不一样的。
    • 对于使用了的空间,单个字节里的空间不够下一个位段成员使用的时候,是继续使用还是浪费,这也是不确定的。
    • 对于位段成员,将整形或者字符等数据对位段成员赋值的时候,很多情况是会出现溢出的,这就会出现截断,就需要我们对数据的二进制序列进行分析,分析二进制成员里面到底存储的是那几个bit位的数据
    struct S1 //暂且假定位段从字节的右边向左使用   
    {
        unsigned char d;
        unsigned char d0 : 1;
        unsigned char d1 : 2;
        unsigned char d2 : 3;
    }*p1;
    int main()
    {
        unsigned char arr[4];
        p1 = (struct S1*)arr;
        memset(arr, 0, 4);//字符数组置空
        p1->d = 2;
        p1->d0 = 3;
        p1->d1 = 4;
        p1->d2 = 5;
        printf("%02x %02x %02x %02x\n", arr[0], arr[1], arr[2], arr[3]);//按16进制打印
        return 0;
    }
    
    结构体的4个成员分别是无符号的一个字符变量和三个字符位段成员,分别占用1、2、3个bit位。
    我们设置一个结构体指针指向了一个无符号字符数组空间,这时候通过指针对结构体进行操作就相当于
    操作这个数组了。这时我们来赋值,第一个是字符变量,赋值为2那么16进制还是2,打印出来就是02。
    接下来是位段成员,我们可以看到总共只有6个bit位,那么就是在下一个字节空间(也就是下一个数组
    元素)内赋值,对于3,对应的二进制为:00000011,但d0只有一个bit位,那么取第一个1放入这一个
    字节的第一个bit位:
    00000001
    d2有2个bit位的空间,对于4:00000100来说,取前两个bit位放入,但全是0,所以:
    00000001
    d3有三个bit位,对于5:00000101来说,取前三个bit位放入:
    00101001 ——这就是这个字节最终的二进制序列
    转换成16进制就是:29
    所以4个字节的空间应当为: 02 29 00 00
    

    在这里插入图片描述
    如图,我们的推算是正确的。位段这中结构体形式,完全可以做到对内存精确的访问和使用,只要我们使用得当,规避掉位段使用的风险即可。但位段的使用更大的问题在与跨平台的问题

  • 位段的跨平台问题
    在不同的平台上,也许位段的跨平台性并没有那么好:

    • int 位段被当成有符号数还是无符号数是不确定的。
    • 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,机器位数不同,如果超出了机器的位数会出问题)
    • 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
    • 当⼀个结构包含两个位段,第⼆个位段成员⽐较⼤,⽆法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利⽤,这是不确定的。
    • 很多情况下,位段其实不太支持跨平台的,想要写出移植性比较好的程序应当避免使用位段。

总结来讲,跟正常的结构相比,位段能达到同样的效果,并且可以很好的节省空间,更精确的对内存进行操作,但是跨平台的问题是不可忽视的。

二、联合和枚举

上文讲述完了结构体的基本知识,这里我们来介绍一下另外两种自定义类型:联合和枚举

1、联合体

对于联合体,作为32个关键字之一的union也是一种自定义类型,它跟结构体有诸多类似的地方,比如也是将一些数据结合在一起成为一种特殊的数据结构,但联合体当然也有它本身的诸多特性和用途。

联合体声明

联合体的声明和结构体的形式是相似的:

union Un
{
	member-list;
};  //分号一样不能忘
int mian()
{
	union Un n = {...} //联合体变量的创建和初始化样式
}

它和结构体的声明大致都是一样的,包括我们用typedef重定义类型:

typedef union Un
{
	member-list;
}new_name; //用新名字代替即可

这就是联合体的声明

联合体的特点

联合体和结构体一样,也是有一个或多个成员构成的,它们可以是不同的类型,但不同点在于:编译器只给最大的成员分配足够的空间,这就是联合体最大的特点:所有成员共同使用同一片空间。所以联合体又叫共用体。所有成员的数据相当与存储在同一片内存空间中,一个成员发生变化,那么其他成员也会随之变化。
在这里插入图片描述
如图,编译器只给联合体分配了int类型的4个字节,整个联合体共用了这4个字节。在这里插入图片描述
如图,还是同一个联合体,我们对于两个成员和联合体变量打印地址,也是相同的,这足以清晰的证明共用内存的特点。
在这里插入图片描述
还是同一个联合体,我们对i赋值11223344,之后对c的一个字节赋值55,可以看到,内存中,被共用的第一个字节最后被改成了55。这再次反应了联合体共用的特点。
在这种共用的特点下,我们可以对不同对象公有属性来统一描述,这样就节省了内存

联合体的大小

联合体中,其实也存在对齐数的概念,但是用法不同,对于联合体的大小

  • 联合体的大小至少是最大成员的大小
  • 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。

相对于结构体对于每个成员都有不同的空间,联合体的大小是相对好算很多的。
在这里插入图片描述

2.枚举类型

枚举也是一个自定义类型,枚举——顾名思义就是把数据一一列举出来。

枚举类型的声明

对于枚举,关键字是enum,它的声明形式还是类似的:

enum name
{
	member-list; //常量列表
}

枚举的内部是我们想要列举的数据,是枚举类型的可能取值,这些取值也叫枚举常量。这些可能取值都是有值的,默认从0开始,依次递增1,当然,也可以在声明枚举变量的时候赋初始值。

枚举类型的优点

枚举类型内部的成员都是常量,都具有常量属性,它们都将这些可能取值转化为了对应的确定数值。这其实和#define定义常量是一个道理,将一个值用另一个量来代替。那么我们既然有#define这样的定义常量工具,那为什么要用枚举呢?

  • 枚举的优点
    • 增加代码的可读性和可维护性
    • 和#define定义的标识符比较枚举有类型检查,更加严谨。
    • 便于调试,预处理阶段会删除 #define 定义的符号
    • 使用方便,⼀次可以定义多个常量
    • 枚举常量是遵循作用域规则的,枚举声明在函数内,只能在函数内使用

枚举的用处还是有的,只是并不能说常见,但是还是需要一定的了解


总结

结构体、联合和枚举这三种自定义类型都向我们展现了高效处理大规模数据的能力,尤其是结构体,是数据结构的基础。本片文章讲述了三种类型的基本知识,希望能够帮助大家更好的学习C语言

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值