随笔——自定义类型:结构体

前言

C语言本身就支持一些类型,比如char,short,float,double,int,long,long,char*,int*,float*,double*等,像这种C语言本身就有的类型被称为“内置类型”;对于较简单的对象,仅仅这几个类型还勉强够用,但要是研究更为复杂的对象,仅仅这几个类型就不够用了,于是就有了自定义类型,自定义类型是以这些内置类型为基础,定义的全新类型。包括结构体(struct),枚举(enum),联合体(union);下面我们将详细讲讲结构体。

入门

结构体是一些数据的集合,这些数据的类型既可以是某些内置类型也可以是别的结构体
结构体的基础定义如下:

struct 结构体名
{
    数据类型 成员名1;
    数据类型 成员名2;
    ...
};

创建结构体变量的格式如下:

struct 结构体名 变量名;

也可以将二者结合:

//定义并创建一个基于该结构体类型的变量
struct 结构体名
{
    数据类型 成员名1;
    数据类型 成员名2;
    ...
}变量名1;

//定义并创建两个基于该结构体类型的变量
struct 结构体名
{
    数据类型 成员名1;
    数据类型 成员名2;
    ...
}变量名1,变量名2;

  ...

比如,去定义一个用来描述学生的结构体:

struct student
{
	char name[20];//姓名
	int age;//年龄
	char sex[6];//性别
	char id[20];//学号
};

现在有三个编号分别为s1,s2,s3,的学生,就可以这样创建对应的变量(这里s1和s2是全局变量,s3是局部变量):

struct student
{
	char name[20];//姓名
	int age;//年龄
	char sex[6];//性别
	char id[20];//学号
}s1,s2;

int main()
{
	struct student s3;
	return 0;
}

变量创建好了,接下来就是初始化了,有顺序和乱序两种:

struct student
{
	char name[20];//姓名
	int age;//年龄
	char sex[6];//性别
	char id[20];//学号
};

int main()
{
	struct student s1 = {"lihua", 16, "man", "202331400786"};//顺序
	struct student s2 = {.age = 19, .name = "xiaonan", .id = "202134934706", .sex = "woman"};//乱序
	struct student s3;//不初始化
	return 0;
}

甚至可以结构体定义,变量创建初始化一块写:

struct teacher
{
	char name[20];
	int age;//年龄
	char sex[6];
	char id[20];
}t = { "liangjin", 32, "woman", "201902331647" };

如果结构体的成员中有其他结构体,这样初始化(就是把整个大括号写进去):

struct student
{
	char name[20];//姓名
	int age;//年龄
	char sex[6];//性别
	char id[20];//学号
};

struct teacher
{
	char name[20];
	int age;//年龄
	char sex[6];
	char id[20];
};

struct people
{
	int number;
	struct teacher t;
	struct student s;
};

int main()
{
	//t = { "liangjin", 32, "woman", "201902331647" }
	// s1 = { "lihua", 16, "man", "202331400786" }
	struct people p = {2, { "liangjin", 32, "woman", "201902331647" } , { "lihua", 16, "man", "202331400786" } };
	return 0;
}

想要访问结构体中的成员,有两种方法,分别是直接访问和间接访问

前面初始化用的".“(点操作符)就是直接访问(嵌套结构体多用几个点);
如果有结构体的指针,考虑到解引用号”*“和点操作符”.“一起用太麻烦了,就可以使用”->",这是间接访问;

#include<stdio.h>

struct student
{
	char name[20];//姓名
	int age;//年龄
	char sex[6];//性别
	char id[20];//学号
};

typedef struct teacher
{
	char name[20];
	int age;//年龄
	char sex[6];
	char id[20];
}teacher;

struct people
{
	int number;
	struct teacher t;
	struct student s;
};

typedef struct
{
	char mame[20];
	char author[20];
	float price;
}b;


int main()
{
	//t = { "liangjin", 32, "woman", "201902331647" }
	// s1 = { "lihua", 16, "man", "202331400786" }
	struct people p = { 2, { "liangjin", 32, "woman", "201902331647" } , { "lihua", 16, "man", "202331400786" } };
	struct student s = { .age = 19, .name = "xiaonan", .id = "202134934706", .sex = "woman" };
	printf("%s %s %d %s\n", s.sex, s.name, s.age, s.id);
	printf("%d %s %s\n", p.number, p.s.name, p.t.name);
	printf("%d\n", (*(&p)).number);
	printf("%d\n", (&p)->number);

	return 0;
}

在这里插入图片描述

进阶

定义结构体时其实也可以没有结构体名,这种定义方式被称为“匿名定义”,由于没有名字(或许用“代称”这个词更形象),只有一种创建对应变量的形式(结构体定义,变量创建写一块),正常情况下只能用一次(那种不正常的情况其实是换了一种形式命名结构体),之后再想用,因为没有名字,所以没有东西来指代这个结构体,这个结构体也就无法使用了。

//初始化没有要求,依据上面说的方式初始化就行了,上面的方法都能用
struct
{
	char mame[20];
	char author[20];
	float price;
}book;

匿名定义还易出现下面的错误

struct
{
	char mame[20];
	char author[20];
	float price;
}book;

struct
{
	char mame[20];
	char author[20];
	float price;
}*p;//创建结构体指针

int main()
{
	p = &book;
	return 0;
}

因为没有名字,所以就算这两个匿名结构体里面长得一模一样,编译器还是会把它们看成不同的结构体,所以,编译器会报警,说它们类型不一样,无法把地址赋过去。

不正常的情况就是用关键字typedef对这个匿名结构体重命名(不还是加了一个名字了吗):

typedef struct
{
	char mame[20];
	char author[20];
	float price;
}b;

int main()
{
	b book;
	return 0;
}

既然匿名结构体可以用typedef重命名,非匿名结构体更可以了,而且这样的话创建变量时就不用再写struct了(好像很多教科书都喜欢这样写)

typedef struct teacher
{
	char name[20];
	int age;//年龄
	char sex[5];
	char id[20];
}teacher;

typedef struct
{
	char mame[20];
	char author[20];
	float price;
}b;

int main()
{
	b book;
	teacher t;
	return 0;
}

题外话
我学嵌入式的时候发现大家都喜欢用#define进行类型重命名,#define与其说是类型重命名,倒不如说是文本替换;如果用它来进行类型重命名有时候容易出问题,如果是非嵌入式领域的类型重命名,最好用typedef,至于嵌入式吗,好像#define别的用处还挺多的,那就随意了。
会出什么问题呢?比如下面这道题

#define INT_PTR int*
typedef int*int_ptr;
INT_PTR a,b;
int_ptr c,d;

问:哪个变量不是指针
答案是b,预处理的#define是查找替换,所以替换过后的语句是“int*a,b;”,其中b只是一个int变量,如果要让b也是指针,必须写成“int *a, *b;”。而typedef没有这个问题,所以c、d都是指针。

还有下面这道题(这是后来补的,如果看不懂可以先跳过):

题目内容:
有如下宏定义和结构定义

#define MAX_SIZE A+B
struct _Record_Struct
{
  unsigned char Env_Alarm_ID : 4;
  unsigned char Para1 : 2;
  unsigned char state;
  unsigned char avail : 1;
}*Env_Alarm_Record;
struct _Record_Struct *pointer = (struct _Record_Struct*)malloc(sizeof(struct _Record_Struct) * MAX_SIZE);

当A=2, B=3时,pointer分配( )个字节的空间。
A.20
B.15
C.11
D.9

答案选D
解析:结构体向最长的char对齐,前两个位段元素一共4+2位,不足8位,合起来占1字节,最后一个单独1字节,一共3字节。另外,#define执行的是查找替换, sizeof(struct _Record_Struct) * MAX_SIZE这个语句其实是3*2+3,结果为9,故选D;我第一次写算出来是15,因为我是按3*(2+3)来做的。


结构体的自引用

之后我们会学数据结构,数据结构指的是数据在内存中的存储和组织的结构
数据结构有多种,包括线性数据结构,树形数据结构,图……,
线性数据结构可以细分为顺序表,链表,栈,队列……
树形数据结构可以细分为二叉树……

其中链表就和结构体的自引用有些关系;

现在假设内存中要存五个数:12345;可以直接一次性开辟五个连续的整型空间把它们存进去,就像数组那样,这种就叫做顺序表(顺序表实质就是数组); 还有一种方法:找五处不连续的空间,把12345分别放进去,光把数值放进去还不行,这五处空间并不连续,如果光放数值,那么找到1之后就不好找2了,找到2之后就不好找3了……所以每个空间除了存放数值外,还必须要再添一位“向导”,这个向导会告诉我们下一个数据到底在哪,这样找到1,就相当于找到2,找到2,就相当于找到3……这种方式就叫做链表。我们把链表中的每处空间称为一个节点,一个节点包括两个部分:数据域和指针域,数据域就是用来存放数值的,指针域就是向导,指引我们下一个数据在哪里。我们就可以用结构体的自引用来实现一个节点。

结构体的自引用?听起来就是结构体自己引用自己,自己包含自己,就像

struct Node
{
	int data;
	struct Node next;
}

真的是这样吗?
让我们想想,这种定义方式叫做嵌套定义,还有哪些嵌套定义呢?我们之前学过递归,递归的要求是什么?

  • 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
  • 每次递归调用之后越来越接近这个限制条件。

类比递归发现,这种“结构体自引用”写法连限制条件都没有,更别提接近条件了实际上这种写法会导致结构体变量的大小无穷大,自然会出问题。(看见我前面的那个加粗的“别的”吗?)

前面都说过了是指针域吗,在结构体的自引用中,除了数值,另一个成员应该是结构体的指针而非结构体:

struct Node
{
	int data;//数据域
	struct Node* next;//指针域
};

结构体自引用的重命名这样写:

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

//或者这样:
struct Node
{
	int data;
	struct Node* next;
};

int main()
{
	typedef struct Node Node;
	return 0;
}

有人这样重命名:

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

对吗?不对。重命名是分号后才完成的。分号前不能把struct省略。
另外还要注意,匿名结构体没有自引用。原因还是没有名字,无法指代。

结构体内存对齐

变量都是有大小的,那一个结构体变量的大小是如何计算的呢?把里面的成员全加起来吗?

#include<stdio.h>

typedef struct test1
{
	char c1;
	int i;
	char c2;
}test1;

int main()
{
	test1 t = { 0 };
	printf("%zd\n", sizeof(t));
	return 0;
}

在这里插入图片描述
嗯,从结果来看应该不是这样。实际上,结构体的大小遵从着一些规则,我们将其称为“内存对齐”。我们先讲对齐规则,再说说它是怎么来的。

对齐规则

  1. 结构体的第⼀个成员对齐到和结构体变量起始位置偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
  3. 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。

注:

  • 对齐数=编译器默认的⼀个对齐数与该成员变量大小的较小值。
  • VS 中默认的值为 8;
  • Linux中gcc没有默认对齐数,对齐数就是成员自身的大小;
  • 如果成员中存在数组的话,先把数组第一个元素存进去,然后其它元素跟在第一个元素后面连续存就行,数组的对齐数就是元素的对齐数。

现在再来看之前的那个例子:

#include<stdio.h>

typedef struct test1
{
	char c1;
	int i;
	char c2;
}test1;

int main()
{
	test1 t = { 0 };
	printf("%zd\n", sizeof(t));
	return 0;
}

在这里插入图片描述

#include<stdio.h>

typedef struct test2
{
	char c1;
	char c2;
	int i;
}test2;

int main()
{
	test2 t = { 0 };
	printf("%zd\n", sizeof(t));
	return 0;
}

在这里插入图片描述
在这里插入图片描述

#include<stdio.h>

typedef struct test3
{
	double d;
	char c;
	int i;
}test3;

int main()
{
	test3 t = { 0 };
	printf("%zd\n", sizeof(t));
	return 0;
}

在这里插入图片描述
在这里插入图片描述

#include<stdio.h>

typedef struct test3
{
	double d;
	char c;
	int i;
}test3;

typedef struct test4
{
	char c1;
	test3 t3;
	double d;
}test4;


int main()
{
	test4 t = { 0 };
	printf("%zd\n", sizeof(t));
	return 0;
}

在这里插入图片描述
在这里插入图片描述

#include<stdio.h>

typedef struct test1
{
	char c1;
	char c2;
	int i[2];
}test1;

int main()
{
	test1 t1 = { 0 };
	printf("%zd\n", sizeof(t1));
	return 0;
}

在这里插入图片描述
在这里插入图片描述

为什么存在内存对齐


你可以直接看下面重点:

首先我们知道计算机有很多总线,比如根据功能和规范,总线可分为:
数据总线:用于传输实际操作的数据。
地址总线:用于指示数据在内存中的地址位置。
控制总线:用于控制各种设备的操作,发送各类控制信号。
扩展总线:用于主机与外部设备之间的连接,如PCI总线。
局部总线:直接或者通过桥接器与中央处理器相连,比如PCI总线和ISA总线等。
……
其中地址总线和数据总线是CPU和内存联系的纽带,简单来说,他们的使用过程如下:

  • 当CPU要读取内存中的数据时,它首先将要访问的内存地址通过地址总线发送给内存控制器。
  • 接收到地址后,内存控制器找到对应的存储单元,并将其中的数据通过数据总线发送回CPU。
  • 当CPU要向内存写入数据时,它将要写入的内存地址通过地址总线发送给内存控制器,并将要写入的数据通过数据总线发送给内存控制器。
  • 内存控制器接收到地址和数据后,将数据写入指定的存储单元。

在整个过程中,地址总线宽度决定了可寻址的内存范围,而数据总线的宽度决定了系统一次可处理的数据量,它们共同影响了系统的性能。

计算机一次可处理的数据量长度被称为“字”,计算机一次只能处理一定数量的数据,这个数量就是计算机的字长度。字长可以因硬件和架构的不同而变化。
以一些常见的计算机硬件为例:
在32位系统中,字长为4字节(32位)。
在64位系统中,字长为8字节(64位)。


上面是铺垫,下面是重点:(好像不看上面的也行)
为了提高CPU调用内存中数据的速度,内存会把自身划分成大小基于某个固定字节数量倍数的小区域,这样当需要某处的数据时,就可以更方便地找到它,如果要打个比方的话,现在我们将整个内存比作一栋楼,每层就相当于我前面说的小区域,每个房间就相当于一个单位字节空间,如果我要找一个位于302号房间的数据,我就可以直接略过1楼2楼,直接从3楼找起,找3楼的第二个房间就行了;如果没有这种对齐策略的话,就相当于这栋楼只有一层,而这一层却有这栋楼的全部房间,这样的话,如果我要找302房间的数据的话,我就要从一楼的第一个房间开始,一个一个房间往后数,直到找到房间302。内存的这种划分策略就被称为内存对齐。

而结构体是内存中的一个缩影,内存中的规则,自然也适用于结构体。现在我们把结构体看成一个小内存,如果结构体中的成员没有内存对齐,就很可能会出现一种状况,这个成员一部分在3层,另一部分在4层,我如果要访问这个数据,就得先爬到3楼,找到3楼的数据之后,再爬到4楼找另一部分,而且之后还要把这两部分数据碎片拼成一个整体,你就说麻不麻烦费不费时吧。

这种原因被称为性能原因,在一众的教科书中,它是这样描述的:

数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器
需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字
节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,
那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可
能被分放在两个8字节内存块中。

不过这并不意味着CPU只能进行对齐的内存访问,实际上,在许多现代硬件平台和系统架构中,CPU可以进行非对齐的内存访问。这基本上意味着CPU可以从任何地址开始读取数据。但非对齐内存访问费时费力,所以CPU主要还是进行对齐的内存访问。不过也确实有些老机子,只能进行对齐的内存访问。

这就是另一个原因,被称为平台原因(移植原因),那些教科书应该是这样写的:

不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特
定类型的数据,否则抛出硬件异常。

至于VS为什么把默认对齐数当成8字节,个人认为,可能是由于现在64位计算机是主流。

如果想节省一些空间,可以考虑把对齐数小的集中在一块放:

#include<stdio.h>

typedef struct test1
{
	char c1;
	int i;
	char c2;
}test1;

typedef struct test2
{
	char c1;
	char c2;
	int i;
}test2;

int main()
{
	test1 t1 = { 0 };
	test2 t2 = { 0 };
	printf("t1:%zd\nt2:%zd", sizeof(t1), sizeof(t2));
	return 0;
}

在这里插入图片描述

修改默认对齐数

当你认为VS的默认对齐数不太好时,可以通过#pragma这个预处理指令修改默认对齐数:

#include<stdio.h>

#pragma pack(1) //设置默认对⻬数为1
typedef struct test1
{
	char c1;
	int i;
	char c2;
}test1;
#pragma pack() //取消设置的对⻬数,还原为默认


int main()
{
	test1 t1 = { 0 };
	printf("%zd\n", sizeof(t1));
	return 0;
}

在这里插入图片描述
正常情况下,我们一般把默认对齐数修改为2的次方数,比如1,2,4之类。

结构体传参

不知道你有没有看过我的另一篇文章:函数栈帧的创建与销毁
里面有个思想,就是如果调用某个有参数的函数,那就有个名为压栈的操作,压栈就是把实参临时拷贝一份传给调用的函数,试想一下,结构体本身就有些空间的浪费,如果将结构体变量本体当做实参传入函数的话,那空间的浪费就更甚了,所以,如果调用的函数要用到上一级函数中的结构体的话,我们实际都是把结构体指针作为形参的。

#include<stdio.h>

typedef struct test1
{
	char c1;
	char c2;
	int i[2];
}test1;

void print(test1* p)
{
	int j = 0;
	printf("%c\n", p->c1);
	printf("%c\n", p->c2);
	for (; j < 2; j++)
	{
		printf("%d ", p->i[j]);
	}
}

int main()
{
	test1 t1 = { 'h','k',{1,2} };
	print(&t1);
	return 0;
}

在这里插入图片描述

位段

结构体实现位段

经过上面的学习,你应该已经明白,结构体的内存对齐是一种拿空间换时间的做法。
怎么在内存对齐的情况下尽可能节省空间呢?除了我们之前说的把对齐数小的成员放一块之外,还有一种究极的空间节省方案,那就是位段。
有些时候,我们要存储的数据根本用不着int甚至是char这么大的空间,只要用几个比特位(“位段”里的位指的就是这个)就行了,比如就1,我用一位也存的下呀,为什么非要用char int。


结构体实现位段的格式:

struct 结构体名
{
    数据类型 成员名1:位数;
    数据类型 成员名2:位数;
    ...
};

注意:

  1. 数据类型最好一样,位数不能超过超过前面数据类型的大小,比如对于char,位数不要超过8。
  2. 类型是char或者int家族(short,long,long long,unsigned int);C99标准后,也支持其他类型。
  3. 位段的成员名后边有⼀个冒号和⼀个数字。
#include<stdio.h>

struct S
{
	char _a : 3;
	char _b : 4;
	char _c : 5;
	char _d : 4;
};

int main()
{
	struct S s = { 0 };
	s._a = 10;
	s._b = 12;
	s._c = 3;
	s._d = 4;
	printf("%zd",sizeof(s));
	return 0;
}

题外话:我这里的"_"是为了强调它们是结构体里的成员,防止结构体成员与其他变量重名。视情况使用。


位段如何开辟内存?

特别注意:以下图片中的数字指的是比特位,而非地址,地址是针对单位字节来说的。同样的,大小端字节序是对大于单位字节序
的数据来说的,这里没有它俩的事。另外,以下说的步骤是逻辑步骤,是用来帮助理解的,而实际是,编译器会一次性创建好这个
结构体的内存。

它会依据前面的数据类型先开辟出一个单位大小的空间,如果前面的数据类型是char,单位大小就是一个字节,8比特位;如果是int家族,单位大小就是int大小,4字节,32比特位,我们的示例是char,所以先创建8比特位。
在这里插入图片描述
在这里插入图片描述
然后再遵循某种顺序把数据存进去,比如从左向右或者从右向左,这里我们使用的VS 2022,使用的是从右向左存。
_a是三个比特位,存入的是10(二进制八位补码为:0000 1010),存不下,就把低处的三比特位存入了。
在这里插入图片描述
然后再看:_b是4比特位,存入的是12(二进制八位补码为:0000 1100),就把低处的四比特位存入了。
在这里插入图片描述
随后,_c是5比特位,结果就剩一比特位了,我们是把_b拆成两部分,一部分一比特位,另一部分4比特位,不把这剩下的一比特位浪费掉,还是直接跳过呢?不同的编译器有不同的选择,但VS是直接掉过不用的,在这种情况下,由于剩下一比特位没有被覆写,所以就是0(因为结构体初始化为0),之后会再创建一个单位大小的空间。
在这里插入图片描述
_c是5比特位,存入的是3(二进制八位补码:0000 0011),存入低处五比特位。
在这里插入图片描述
剩下三比特位,而_d是四比特位,不够,剩下的三比特位跳过,没被覆写都是0,并且再创建一个单位大小空间。
在这里插入图片描述
_d是4比特位,存入的是4(二进制八位补码为:0000 0100),依旧存入低处四位。剩下4位,没有被覆写,还是0。在这里插入图片描述
所以,结构体的大小为24比特,即3字节。存储内容为0110 0010 0000 0011 0000 0100,换算成十六进制就是0x62 03 04。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
从刚才的这些过程中,你应该已经认识到,由于C语言标准没有对位段做出严格要求,所以在位段的创建过程中存在着许多不确定性,比如在单位空间中,是从左往右还是从右往左存,剩余位数不够了,这部分是应该浪费掉还是用掉,这些问题,不同的编译器厂商都有不同的解决方案,并且有些不确定性还未在刚才的演示过程中体现,比如,在32位和64位机器中,int是4字节的,而在16 位机器中int是2字节的,对于32位和64位机器来说,允许出现int _i:30;但在16位机器中,这样写明显是错的。

这就是我为什么称它为一种究极的空间解决方案,我承认,它确实省空间,但平台移植性很差,尽量不要写位段。

位段初始化

由于位段是以比特位为单位的,而地址是对单位字节来说的,所以位段没有地址这回事,自然不能用取地址符,如果想要初始化(赋值),要这样写:

#include<stdio.h>

struct A
{
	int _a : 2;
	int _b : 5;
	int _c : 10;
	int _d : 30;
};
int main()
{
	struct A sa = { 0 };
	//这是错误的写法
	/*scanf("%d", &sa._b);*/

	//正确的⽰范
	int b = 0;
	scanf("%d", &b);
	sa._b = b;
	return 0;
}

位段的主要应用场景

下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个比特位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小⼀些,对网络的畅通是有帮助的。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值