• 文章主题:结构体类型详解
  • 所属专栏:深入理解C语言
  • 作者简介:更新有关深入理解C语言知识的博主一枚,记录分享自己对C语言的深入解读。
  • 个人主页:[₽]的个人主页

自定义类型详解

  • 前言
  • 自定义类型
    • 结构体
      • 简介
      • 声明
        • 普通的声明
        • 特殊的声明
      • 结构体的自引用
        • 正确的自引用方式
          • 注意
      • 结构体变量的定义和初始化
      • 结构体内存对齐
        • 考点:如何计算结构体在内存中的大小?
        • 对齐规则
          • 对齐出现的原因
          • 在结构体当中如何既满足对齐又一定程度上的减少空间上的浪费?
          • 修改默认对齐数
      • 结构体传参
    • 位段
      • 简介
      • 什么是位段
      • 位段的内存分配
      • 位段的跨平台问题
      • 位段的应用
    • 枚举
      • 简介
      • 枚举变量的定义
      • 枚举的优点
      • 枚举的使用
    • 联合(共用体)
      • 简介
      • 联合类型的定义
      • 联合的特点
      • 联合体大小的计算
    • 数组
  • 结语

前言

自定义类型是C语言中很重要的一个知识,很多的程序都离不开自定义类型,下面是我关于自定义类型的详细解析。

自定义类型

结构体

简介

结构体是一些值的集合,这些值称为成员变量。和数组不同,结构体的每个成员可以是不同类型的变量。


声明

逻辑上和普通的变量一样既可在main函数之外声明也可在main函数之内声明,外声明才有机会定义全局变量,内声明作用域受限只能定义局部变量。1

普通的声明
struct tag{member-list;//成员表}variable-list//变量表,typedef重命名时则为变量重命名书写处,//除开数组不能重命名之外,其余变量重名名均在原//定义变量表时的变量表处

例:
用结构体记录一个学生的个人信息

struct Stu{char name[20];//名字int age;//年龄char sex[5];//性别char id[20];//学号(所有的自定义类型最后一个成员/可能取值的;/,书写时均可加或不加)}; //分号不能丢
特殊的声明

在声明结构的时候,可以不完全的声明(即可以省略某种结构体的具体类型名(自定义类型中叫这种类型名中去掉了类型关键字的部分为该类型的标签(tag),自定义类型在不用typedef的时候默认的类型名的组成格式就是:自定义类型关键字 + 该类型的这种特定组成形式下的标签(tag)),对结构体进行匿名声明)。
例:

//匿名结构体类型struct{int a;char b;float c;}x;struct{int a;char b;float c;}a[20], *p;
  • 上面的两个结构在声明的时候省略掉了结构体标签(tag),这种省略了标签的做法就构成了结构体的匿名声明。
  • 此种结构体类型在编译器看来则为:struct ,其指针类型为:struct *,不同的匿名结构体对应的类型也均是这两个。2

结构体的自引用

在结构中包含一个类型为该结构本身的成员是否可以呢?

//代码1struct Node{int data;struct Node next;};//可行否?如果可以,那sizeof(struct Node)是多少?无法确定,结构体越大时,它自己就会更大。
正确的自引用方式
//代码2struct Node{int data;struct Node* next;};

通过结构体指针来自引用同结构体类型的成员,因为指针值的大小和类型无关始终是确定的,只会和运行机器的位数或其选取的运行该程序的位数有关,所以在既确定了该结构体大小的情况下还能够利用不影响其大小的同类型的指针值指向不同内存的同一种类型的结构体变量,在成员为该指针时,因为指针大小不受不同类型而改变编译器也认为结构体类型已经定义好了,从而可以直接通过其指针来进行自引用了。

注意
//代码3typedef struct{int data;Node* next;}Node;//这样写代码,可行否?//不行结构体在还没重命名的情况下是不能用其重命名之后的变量名来进行自引用的,//至少得用运行到该句时它已经经过的初始的该结构体的类型名的指针来定义其成员类型//才能刚好做到编译不错误的前提下又进行了结构体自身的自引用的效果//解决方案:typedef struct Node{int data;struct Node* next;}Node;//即先用其原始结构体类型名去进行自引用后再符合语法的在其自引用之后';'//之前的原是变量表的位置写下其重命名,这样才能符合语法的既实现了自引用//又对该结构体类型进行了重命名

结构体变量的定义和初始化

有了结构体类型(通过声明确定好结构体类型后),如何定义变量,其实很简单。

struct Point{int x;int y;}p1;//声明类型的同时定义变量p1struct Point p2;//定义结构体变量p2//初始化:定义变量的同时赋初值。struct Point p3 = {x, y};struct Stu//类型声明{char name[15];//名字int age;//年龄};struct Stu s = {"zhangsan", 20};//初始化struct Node{int data;struct Point p;struct Node* next;}n1 = {10, {4,5}, NULL};//结构体嵌套初始化struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化

结构体内存对齐

结构体内存对齐是属于结构体较为深入的一个知识,也是一个比较热门的考点。

考点:如何计算结构体在内存中的大小?

不用说本质上考察的就是结构体在内存中的对齐规则,因为你得先知道结构体在内存中的具体分布规则(整体上叫这种风格的规则为对齐),才能够计算出结构体在内存中的大小

对齐规则
  1. 第一个成员在与结构体变量偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。(VS中默认的值为8,数组是默认对齐数与其一个数组元素,即其数组元素的变量类型的大小中的较小值(因为可认为数组本身就是一个很多同类型元素的集合而已,本身内部就是对齐的,所以避免空间的浪费对于重复性较高且又是内部对齐的数组只需要其中的一个元素作为和默认对齐数比较的数字就行,自定义类型集合形式的其余三种(除开作为变量储存在结构体中只占4字节的枚举变量那一种定义变量时为非集合类型和嵌套的结构体情况之外),因其内部成员重复性不高所以虽然内部有些仍算是对齐而有些不会算,仍是拿它的整体的大小和默认对齐数比去取其最小值为其对齐数的))
  3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数,)的整数倍。
  4. 自定义类型中如果嵌套了结构体/位段/联合的情况,嵌套的结构体/位段/联合对齐到自己的最大对齐数的整数倍处(原因是和其自身大小相比都是对齐的结果,在效率都高且差不多,可以一次性读完所有内存的前提下,取最大对齐数整数倍还可以节省更多空间,不和默认比是因为已经是上次比较后的结果了,至于改变默认在嵌套的情况则另当别论,估计是会取那个又比较了一次之后得出的结果),与数组的取法类似(数组取的是单个元素和8比较后的较小值,反正一般情况下最后在没比,或比了变了的情况下都会再比一次),在既减小了将整体大小作为对齐数浪费较大内存的情况下又同时提高了差不多大小的运行效率,结构体的整体大小就是所有对齐数中的最大对齐数(含嵌套结构体的对齐数)的整数倍(这么做也是为了方便一口气走完和当其充当成别的结构体的成员时也能够再用同样的方法节省内存的前提下让编译器能够一口气有始有尾的访问完该结构体(取其最大对齐数为编译器一次访问内存的大小时,就能够方便一次性高效的有始有尾的将这个结构体的内存一次就给访问完))。
对齐出现的原因

1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常,对齐便于提高该结构体部分在不同平台的可移植性。
2.性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐(计算机字长的倍数,计算机是几位的机器就是几位的字长读取方法,因为内存的读取的初始位置只能为从该内存处探取的字长的非负整数倍开始读取,如果用的是对齐的储存方法的话就能够有更大的概率一次性就读取到,本质是通过对齐尽量使储存的位置与计算机读取内存的字长的非负整数倍相契合,浪费一定量的空间使该变量用更少的读取次数就被全部读取到,通过减少读取内存的次数,提高内存读取的效率,花费更少的次数更快地就可以将其所有的数据全部读取到)。

总体来说:结构体内存对齐是拿空间换时间的做法。

在结构体当中如何既满足对齐又一定程度上的减少空间上的浪费?

让占用空间小的成员尽量集中到一起,这种方法就能够使所有类型的结构体变量都在一定程度上减少其相应的储存空间(单独的一种情况去分析时可能还会出现一些不全其中但省更多空间的做法,但这种集中的做法可以比随意排序相比减少很多的空间,是一种普适的方法)。
例:

//例如:struct S1{char c1;int i;char c2;};struct S2{char c1;char c2;int i;};

S1和S2类型的成员一模一样,但是S1和S2所占空间的大小有了一些区别。

修改默认对齐数

运用#pragma这个预处理指令来改变我们的默认对齐数

#include #pragma pack(8)//设置默认对齐数为8struct S1{char c1;int i;char c2;};#pragma pack()//取消设置的默认对齐数,还原为默认#pragma pack(1)//设置默认对齐数为1struct S2{char c1;int i;char c2;};#pragma pack()//取消设置的默认对齐数,还原为默认int main(){ //输出的结果是什么? printf("%d\n", sizeof(struct S1)); printf("%d\n", sizeof(struct S2));return 0;}

结论: 结构体在对齐方式不合适的时候,我么可以自己更改默认对齐数。


结构体传参

例:

struct S{int data[1000];int num;};struct S s = {{1,2,3,4}, 1000};//结构体传参void print1(struct S s){printf("%d\n", s.num);}//结构体地址传参void print2(struct S* ps){printf("%d\n", ps->num);}int main(){print1(s);//传结构体print2(&s); //传地址return 0;}

上面的 print1 和 print2 函数哪个好些?
答:print2。
原因:

  1. 函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
  2. 如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。

结论: 结构体传参的时候,要传结构体的地址。


位段

简介

是一种运用结构体的框架实现的更节省空间但单位成员储存信息内存得较小的新的类型


什么是位段

顾名思义:对于一个某种类型的成员去一个小于该类型大小的几bit位为一段的内存进行储存(至于大于该数据的存储自然就是把先被截断后的数据给存入进去)
位段的声明和结构是类似的,有两个不同:

  1. 位段中实现位段效果的成员必须是 char、int、unsigned int 或signed int 。
  2. 位段中实现位段效果的成员名后边有一个冒号和一个数字(结构体框架中非上述成员采用这种位段成员书写方式就会报错,在结构体框架中一个上述成员采用了这种书写方式就可以认为其是位段,但是在大多数编译器下至少得是关键字不同类型相同的同类型成员集中在一起定义该位段时,位段的节省空间方面的效果才会出现)
  3. 一般常用的位段会全都由合适位段的类型成员采用位段的书写形式构成的
    比如:
struct A{int _a:2;int _b:5;int _c:10;int _d:30;};

A就是一个位段类型。 那位段A的大小是多少?

printf("%d\n", sizeof(struct A));

位段的内存分配

  1. 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
  2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的(因为能实现位段效果的只能是int、char)。
  3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
//一个例子struct S{char a:3;char b:4;char c:5;char d:4;};struct S s = {0};s.a = 10;s.b = 12;s.c = 3;s.d = 4;//空间是如何开辟的?

在vs中非同类型位段会仍按照结构体对齐的方法排序,集中的同类型位段会先创造一个该类型大小的空间放置多个同类型的成员当单个位段成员放置不下时再按照对齐的方法由下一个位段成员按照对齐规则创造空间放置自己及之前那个未放置的,如果下一个不是位段则将上一个成员还是按照对齐的方法放置完之后再对齐找位置储存下一个成员。

  1. 简单概括:位段类型的作用相当于只是确定单次创造内存的大小,决定单个成员内存大小的是:后的数字(几bits),同类型位段书写格式成员会趋向尽可能的塞入一个该类型大小的空间中,若塞不下再试着第一个储存再后一个根据对齐规则创造的空间中,如若后一个是不同类型或者不是位段书写形式的成员就按照普通的对齐规则自己先建立一个该类型大小的空间先存着(这时的储存方式就跟将其当做一个普通int型大小变量在一个结构体中对齐存储没有差别了)。
  2. 结论:位段中只有同类型的位段数据放在一起时才会产生节省空间的塞入效果,不同类型的交错放置不仅不会有该效果,储存方式和普通的结构体对齐没有差别,还会使单个的成员的内存受数字限制,储存不下该类型大小的数据,超过数字限制的数据储存后就会失真,用起来就不会是该内存值了。

注意:

  1. 位段中大小为0bits的成员必须未命名(unnamed)。
  2. 位段成员大小数字的选择不能超过其类型对应的大小。
  3. 将一个大于成员位段数的数据赋值给该成员时数据会像大数据赋给小类型一样产生从对应第一个低位开始的截断效果。

位段的跨平台问题

  1. int 位段被当成有符号数还是无符号数是不确定的(因为数据确实是原封不动的存储过去了,但计算机一般将一个类型大小的内存块的最高bit位当作符号位,所以位段是塞入储存的,一些位段的最高位一定不会在一个类型大小的内存的最高位,在跨平台后被别的平台解析这些成员时不一定能够对应准确的得到其有无符号的数据)。
  2. 位段中最大位的数目不能确定(16位机器最大16,32位机器最大32,写成27,在32位跨16位机器时就会出问题,正常的int在跨不同位平台时会有处理方法,但这种位段数据过于随机,没有好的处理方法)。
  3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
  4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是
    舍弃剩余的位还是利用,这是不确定的(vs中是舍弃)。

总结:跟结构体相比,位段可以达到同样的效果,效率上也基本相同,但是可以很好的节省空间,但是有跨平台的问题存在,并接成员类型和内存打下范围上会比结构体的窄。


位段的应用

聊天平台大量聊天数据节省空间的储存:


枚举

简介

枚举顾名思义就是一一列举。
把可能的取值一一列举。


枚举变量的定义

enum Day//星期{Mon,Tues,Wed,Thur,Fri,Sat,Sun};enum Sex//性别{MALE,FEMALE,SECRET}enum Color//颜色{RED,GREEN,BLUE};

以上定义的 enum Day , enum Sex , enum Color 都是枚举类型。
{}中的内容是枚举类型的可能取值,也叫 枚举常量 。
这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值。
例如:

enum Color//颜色{RED=1,GREEN=2,BLUE=4};
  1. 如果赋初值之前还有可能取值就会还是按照从0开始的顺序给这些数据值。
  2. 赋初值之后还有只就会是从最后一个赋初值的只开始仍以枚举中递增1的形式确定后面的枚举常量的值。

枚举的优点

我们可以使用 #define 定义常量,为什么非要使用枚举?
枚举的优点:

  1. 增加代码的可读性和可维护性
  2. 和#define定义的标识符比较枚举有类型检查,更加严谨。
  3. 防止了命名污染(封装)
  4. 便于调试
  5. 使用方便,一次可以定义多个常量

枚举的使用

enum Color//颜色{RED=1,GREEN=2,BLUE=4};enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。clr = 5; //ok" />联合(共用体) 

简介

联合也是一种特殊的自定义类型
这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。

联合类型的定义

//联合类型的声明union Un{char c;int i;};//联合变量的定义union Un un;//计算连个变量的大小printf("%d\n", sizeof(un));

联合的特点

union Un{int i;char c;};union Un un;// 下面输出的结果是一样的吗?printf("%d\n", &(un.i));printf("%d\n", &(un.c));//下面输出的结果是什么?un.i = 0x11223344;un.c = 0x55;printf("%x\n", un.i);

结果:11223355
原因:共用一块空间,由小端字节序,44被覆盖掉了,效果其实就和用不同的指针强制类型转换1块内存中部分内存的内存值,然后再把这一整块内存打印出来的效果一样。
所以根据这个原理,它们两个多和地址指向的内存直接相关和内存中存储的位置也是直接相关,本质上的原理也是一模一样的,所以他们两个都可以被拿来用作检测一个机器是小端还是大端字节序(字节序就是由内存中的值和内存存储位置之间紧密相关而共同作用产生的一个概念)。

联合体大小的计算

  1. 联合的大小至少是最大成员的大小。
  2. 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。(自定义类型中除开数组和枚举之外的三种本质都相同的自定义类型的大小都是这个原则,有最大对奇数的概念,且最后整体的大小都要和最大对齐数比较,如果不是整数倍,则保存数据的前提下就会需要浪费一些空间来构成最大的对奇数的整数倍了)(在vs最新版中不会和最大对齐数比较大小了,就是最大成员的大小,但前面的结构体和位段还是会遵循此条规律,且嵌套是这三个取的还会是最大对齐数的整数倍)。
    比如:
union Un1{char c[5];int i;};union Un2{short c[7];int i;};//下面输出的结果是什么?printf("%d\n", sizeof(union Un1));printf("%d\n", sizeof(union Un2));

数组

详见:深入理解C语言(1):数据在内存中的存储


结语

以上就是对自定义类型的深度解析,希望对你的C语言学习有所帮助!作为刚学编程的小白,可能在一些设计逻辑方面有些不足,欢迎评论区进行指正!看都看到这了,点个小小的赞或者关注一下吧(当然三连也可以~),你的支持就是博主更新最大的动力!让我们一起成长,共同进步!


  1. 可从该结论进而推出其声明所确定的该变量类型的作用域效果与其生命周期和定义某一变量a时其作用域和生命周期的情况完全相同,原因应该是虽然一个是定义一个已经确立好的具体的变量而一个是刚声明好其的变量类型,但这两者却在内存中的底层实现逻辑中是相同的,都是在以栈区中变量创建与销毁的逻辑进行声明变量和创建变量的,且因为C语言创建变量根据编译器逻辑在不加static关键字的情况下只会是在栈区创建的,且又因为加了的情况也应会是和在栈区的一模一样(声明逻辑上没有指针,较难考证),所以可以认为其就是遵循的C语言中的变量创建与销毁的逻辑进行声明变量和创建变量的,所以分析其声明的类型的作用域和生命周期时可完全去套用这套逻辑,只是声明的变量类型不存在指针类型在定义在静态区时不能强制访问而已。 ↩︎

  2. 老版编译器下 p = &x不符合语法规范因为直接对匿名结构体进行取地址的&x的类型在编译器看来和匿名结构体指针的struct 类型不同,会在编译时就报错,新版则认为是一样的。
    不同结构体组成的匿名结构体的变量名均为struct ,所以在互相赋值时不会编译报错,但因为其本质所对应的变量类型及其组成肯定不同,运行时肯定会有越界访问类型的运行错误,所以正因为此处逻辑不够清晰,可能会让程序造成一些莫名其妙的错误,或者让编程者写出一些逻辑不清可能错误或者一些编译器读不清的语句,匿名结构体只会出现在一些程序特殊的情况少量的运用,一般不建议用,应较少运用。 ↩︎