【C语言进阶】从入门到入土(自定义类型详解 — 结构体)

前言:
我们知道,为了提高程序的可读性,C语言里支持用户自定义类型,或者叫改造类型。然而自定义类型最常见的就是结构体、枚举和联合体这三种类型。所以我们来详细理解一下自定义类型。本篇先介绍结构体。

一.结构体

1.结构体类型的声明

为了解决一些复杂对象的定义,我们就有了复杂类型结构体类型。比如:

人:姓名+年龄+身高+体重+身份证号码
书:书名+作者+出版社+日期+售价+书号

结构体是一种能够描述复杂对象的一个类型。struct就是结构体关键字。而结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。

//结构体声明形式
struct tag//结构体关键字+结构体名称
{
 member-list;//结构体成员列表
}variable-list//结构体声明的变量(可省略)

比如:

//结构体类型声明
struct Book//创建应该自定义变量book
{
	char name[20];//成员变量
	char author[20];
	int num;//不同类型
	float price;
}b1,b2;

struct Book b3;//创建结构体变量b3

int main()
{ 
   struct Book b4;//创建结构体变量b4
}

这里创建的b1,b2和b3是一样的,都属于全局变量,而b4则是一个在main函数里面的,所以是局部变量。

同时,我们还有匿名结构体类型,这种匿名结构体类型只能用一次,以后不能再用,也就是一个一次性用品,不能用看似相同的类型指针去存储结构体类型地址:

struct
{
 int a;
 char b;
 float c;
}x;

struct
{
 int a;
 char b;
 float c;
}* p;

int main()
{
  p = &x;//err,是两种不同的*
}

上面的两个结构在声明的时候省略掉了结构体标签(tag)。 p = &x会有警告: 编译器会把上面的两个声明当成完全不同的两个类型。 所以是非法的。


2.结构的自引用

在讲自引用之前,我们先理解一下数据结构的一些内容。在内存中,数据的的结构是不一样的。比如:

然后我们这里的自引用和链表有相同点。在链表中,我们怎么由一个节点找到下一个节点呢,一个节点里面包含一个节点是不行的,会无限套娃下去。**其实在链表每一个节点里面,一部分存数据一部分存了地址,也就是一个节点分为两部分,数据域和指针域名。**所以对于结构体:

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

所以结构体的自引用就是在结构体中可以找到同一类型的不同变量。而且对于结构体自引用中,不能省略结构体名称(匿名)。

typedef struct//匿名,错误
{
 int data;
 Node* next;
}Node;
typedef struct Node//先定义,后使用,正确
{
 int data;
 struct Node* next;
}Node;

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

① 结构体变量定义方法:刚刚在上面也已经有定义过了,这里我们再定义一次,也就是两种全局定义和一个局部定义。

struct Point
{   
    int x;
    int y;
}p1={5,6},p2={7,8};//第一种,定义全局变量,赋值

//初始化:定义变量的同时赋初值。
struct Point p3={1,2};//第二种,定义全局变量,赋值1 和 2

int main()
{
   struct Point p4={3,4};//第三种,定义局部变量,赋值3 和 4
   return 0;
}

当我们调试起来在监视窗口可以看见,p1,p2和p3都已经创建,因为他们是全局变量,而p4还没有产生。

再比如:

struct book
{
	char name[20];
	int price;
};

struct book b1 = { "c++",39 };

还有结构体嵌套初始化,以及输出:

struct Point
{
	int x;
	int y;
}p1;

struct Fun
{
	int date;
	char name[20];
	struct Point p;
	struct Node* next;
}n1 = { 15,"hello",{1,2},NULL};

int main()
{
	struct Fun n2 = { 20,"world",{3,4},NULL};
	printf("%d", n1.date);
	printf("%s", n2.name);
	printf("%d", n2.p.x);
	return 0;
}

这里就是在一个结构体创建成员变量的时候,嵌套一个结构体作为其成员变量,然后在结构体变量初始化的时候,嵌套的那个结构体初始化需要一个{}围起来,比如struct Fun n2 = { 20,"world",{3,4},NULL};。而在引用某结构体某一个量的时候,就需要先引用他的结构体变量名,然后以.的符合引用里面的某一个量。比如n2.name

同样的定义数组也是可以的噢:

struct book
{
	char name[20];
	int arr[20];

};

int main()
{
	struct book s = {"zhangsan de jia", {1,2,3} };
	printf("%s\n", s.name);
	int i = 0;
	for (i = 0; i < 20; i++)
	{
		printf("%d ", s.arr[i]);
	}

	return 0;
}

得到的运行结果:

这就是结构体的定义和初始化啦。


4.结构体内存对齐

我们已经掌握了结构体的基本使用了。接下来我们深入讨论一个问题:计算结构体的大小。这也是一个特别热门的考点: 结构体内存对齐

结构体内存对齐在很多公司,很多面试官中都是一个热门的考点,其中不乏腾讯,小米等大厂,所以这个知识点可谓是结构体中的重点内容。

在研究计算结构体大下之前,我们先自己摸索一下,结构体的大小是怎么计算的,我们先来两段代码测验一下:

//计算出打印的值各是多少?
struct S1
{
	char c1;
	int i;
	char c2;
};

struct S2
{
	char c1;
	char c2;
	int i;
};

int main()
{
	printf("%d\n", sizeof(struct S1));//sizeof计算结构体类型大小	
	printf("%d\n", sizeof(struct S2));

	return 0;
}

答案是:12 和 8。

为什么是这样子的呢,char类型不是1吗,int类型不是4吗,加起来也就6而已啊,而且两个结构体里面的变量一样,只是创建位置不一样就把结构体大小也变得不一样了,这就关乎于结构体的内存对齐了。

其实对于结构体的大小计算有如下规则:

  1. 结构体的第一个成员变量是永远放在结构体起始位置偏移量为0的位置。
  2. 结构体成员从第二个成员开始,总是放在偏移量为一个对齐数的整数倍处。
  3. 结构体总大小必须是各个成员的对齐数中最大那个对齐数的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

我们一步一步来了解结构体内存对齐的规则:

① 结构体的第一个成员变量是永远放在结构体起始位置偏移量为0的位置。

也就是说,当我们创建结构体的时候,向内存中申请一定的空间,创建成员变量的时候放进其中的空间,而创建的第一个成员变量,永远放在结构体起始位置偏移量为0的位置,也就是结构体存储空间的起始位置。

以S1为例子:

② 结构体成员从第二个成员开始,总是放在偏移量为一个对齐数的整数倍处。

接下来才是重头戏,我们要学习一个对齐数的概念:

对齐数等于编译器默认的对齐数和变量自身大小的较小值。在Linux环境下没有默认对齐数,所以在其环境下对齐数就是自身大小,而vs下默认对齐数是8。

比如说我们的S1中的成员变量(我们是在vs环境下):

然后什么叫放在整数倍上呢,比如对齐数是2,那我们可以放的就是偏移量为2,4,6等的地方,对齐数是4,就要放在4,8,16等的地方。

我们接着以S1为例子:

在这里,S1中创建了第二个成员变量类型是int,然后对比变量大小和默认对齐数后,得到对齐数是4,所以无论前面是否还有空间,只能放在对齐数的整数倍处。在这里就存储到了偏移量为4开始的空间,而偏移量为1 2 3的空间没有存储东西就跳过了,这样说我们计算结构体大小得到的值不是6的其中一个原因。

同理,我们将最后的char也放进去:

③ 结构体总大小必须是各个成员的对齐数中最大那个对齐数的整数倍。

然后这样子计算下来,结构体不是才9个字节吗,为什么S1最后的显示结果是12呢,答案就在这第三条规则。

当我们计算完成员变量的大小后,还有一个规则是构体总大小必须是各个成员的对齐数中最大那个对齐数的整数倍。也就是说,S1中最大的对齐数是int中的4,所以结构体总大小是4的倍数。这里的9不满足倍数要求,所以要再找到最近的倍数,才是结构体真正的大小。

所以S1结构体的大小是12,同样我们可以计算一次S2的大小:

所以在这里,不同的顺序创建成员变量,结构体的大小是有可能不一样的,浪费的空间也不一样。所以我们在创建结构体变量的时候,尽量把变量大小较小的变量放在前面,有利于减少对空间的浪费。

没错,结构体的内存对齐虽然在浪费空间,但我们也要在浪费的基础是不那么浪费。

④ 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

那么如果是嵌套,这里还有一条规则,我们来看一下:

struct S3
{
	double d;
	char c;
	int i;
};

struct S4
{
	char c1;
	struct S3 s3;
	double d;
};

int main()
{
	printf("%d\n", sizeof(struct S4));
	//猜猜我是多少?
	return 0;
}

先把S3弄了。S3这里就是正常的结构体计算大小:

然后到S4,这里就关系到第四条规则了,也就是嵌套结构体需要对齐嵌套结构体中最大的对齐数,而整个结构体需要是结构体和嵌套结构体所有对齐数中最大对齐数的倍数。:


Q:那为什么要有内存对齐呢?

其实到现在也没有官方解释,但是大部分的参考资料都是如是说的:

  1. 平台原因(移植原因): 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  2. 性能原因: 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

第一个原因就是当我们进行代码移植的时候,有一些硬件平台的存取只能在某些地址处取某些特定类型的数据,比如有些就读4的倍数的地址。第二个原因就是对齐后读取的时候更快速的读到需要的数据。

总体来说:

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


5.修改默认对齐数

既然上面学到了对齐数的概念,那么如果对齐数不适合我们的部分代码,我们应该如何去修改呢,其实我们可以对默认对齐数进行修改。

之前我们见过了 #pragma 这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数——#pragma pack()

#pragma pack()是一个修改默认对齐数的指令,在pack后的括号中填进去多少就是将默认对齐数修改为多少,比如

#include <stdio.h>
#pragma pack(8)//设置默认对齐数为8
struct S1
{
	char c1;
	int i;
	char c2
};

然后还有将前面的修改默认对齐数的指令取消,恢复为系统默认对齐数的指令,没错就是不带数字的它:

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

那下面这个代码又是什么意思呢:

#pragma pack(1)
struct S2
{
 char c1;
 int i;
 char c2;
};

其实在这里就是把默认对齐数设置为1,也就是所有变量的对齐数都会变成1,那么也就相当于取消了对齐,这样子就可以减少对空间的浪费,但同时也降低了效率。

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

6.结构体传参

我们已经找到变量传参是什么样子的了,那么这里我们再学习一下结构体传参,直接上代码:

struct S
{
	int data[1000];
	int num;
};

void print1(struct S tmp)
{
	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", tmp.data[i]);
	}
	printf("\nnum = %d\n", tmp.num);
}

int main()
{
	struct S s = { {1,2,3,4,5,6,7,8,9,10}, 100 };
	print1(s);

	return 0;
}

在这里我们把结构体变量s传参到print1中打印结构体内的成员变量,既然传过去的是结构体,那么接收也需要是结构体,所有我们创建了一个相同成员变量的结构体变量tmp,然后按照结构体的形式打印。

这里看似很正常也没什么问题,但是我们要注意到,这里或者说很多时候,由于结构体中不止一个成员变量,所以结构体的大小是比较大的,如果传参过去其他函数的时候,创建的临时空间消耗也是比较大的,比如这里,我们不考虑它的内存对齐,就单是变量已经是4004字节了。

所以,我们可以使用另一种方法:地址传参。

struct S
{
	int data[1000];
	int num;
};

void print2(const struct S* ps)
{
	//->
	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", ps->data[i]);
	}
	printf("\nnum = %d\n", ps->num);
}

int main()
{
	struct S s = { {1,2,3,4,5,6,7,8,9,10}, 100 };
	print2(&s);

	return 0;
}

那么传地址过去的时候,我们只是一个地址,所以大小比将整个结构体传过去节省了非常多的空间,从而优化代码的整体。如果说将结构体整个传过去的优点是创建临时空间,主函数里的值不会被修改,更安全。那我们在传地址的时候,加上一个const,这样地址的内容也不会被修改了。

对于结构体传参的时候,首选print2这样的函数。

结论:

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

7.结构体实现位段(位段的填充&可移植性)

结构体讲完就得讲讲结构体实现 位段 的能力。

那么什么是位段?

位段的声明和结构是类似的,有两个不同:

  1. 位段的成员必须是 int、unsigned int 或signed int 等整形家族。
  2. 位段的成员名后边有一个冒号和一个数字。

比如下面的 A 就是一个位段类型:

struct A
{
 int _a:2;
 int _b:5;
 int _c:10;
 int _d:30;
};

按常理来说,结构体A中的成员变量是_a _b _c _d,那后面的冒号和数字表示什么,这个结构体的大小又是多少,我们来一一揭晓:

首先我们得出结构体总大小是8:

接着是揭晓冒号和数字:

其实冒号和数字表示的就是这个成员变量的大小,比如说int _a:2,就是说明_a的大小是两个bit,也就是两个二进制位。

为什么要有这设定呢?原因就在于节省内存的浪费,比如说有一些表达不需要那么大的范围,如性别,男/女/保密这也只有三种,用两个二进制位可以有4种数字表达,绰绰有余。所以说:位段是可以节省空间的!

然后我们了解一下位段的内存分配规则:

  1. 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
  2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的
  3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

注意第二点中,位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。所以我们的结构体A得到的才是8:

也就是按照需要,第一个是int先创建了一个整形大小,然后将所有变量往里存,当不够存的时候,再向内存中申请了_d的一个整形大小,所以无论页面没有存完,结构体总大小就是两个整形也就是8字节。

那么还有一个问题,这里不过内存又开辟了一个int大小,然后_d存进去的时候到底是两个整形各存了一半还是都存进了新开辟的空间呢?

我们来用这一段代码研究一下:

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("%d", sizeof(struct S));//3
	return 0;
}

存储是从低位向高位存储的。这里得到的结构体大小是3,如果成员变量在内存中是连续存放的,则只需要两个字节,但这里是3,说明当前面的内存不够下一个成员变量存储的时候,会申请新的内存并将下一个成员变量全部存储到里面。而前面的空间就被浪费掉。

然后成员变量的值是如何存储的呢,对于超出的值会怎么样,我们接着往下看,实际上存储的是什么:

所以在这里存储进去的最终结果就是01100010 00000011 00000100,二进制转十六进制每4位一个,所以结果应该是62 03 04,我们可以在vs编译器中运行看一下:

最后我们注意一下第三点:

位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

因为位段的存储在c语言中也没有明确规定,在不同平台上可能存储也不一样,所以有很多不确定因素,是不跨平台的。对于位段跨平台的操作问题:

位段的跨平台问题:

1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。)
3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位 还是利用,这是不确定的。

而位段的应用场景就在于一些数据的传输,比如在网络上发送信息,当我们在网络上发生信息之后,网络会对我们所发送的数据进行封装,那么对于那么多的封装信息,就会造成数据浪费,传输速率也会变慢。如果采用位段的方式,那么如下的版本号,首位长度等就能放在一个字节内,传输速率也变快了。

所以总结一下:

跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。


好啦,本篇的内容就先到这里,关于自定义类型详解,本篇只讲述了结构体,下一篇会讲到另外两种自定义类型——枚举,联合。还请继续关注,一起努力学习!。关注一波,互相学习,共同进步。

还有一件事:

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
C语言中,结构体是一种自定义数据类型,可以将不同类型的变量组合在一起,形成一个新的数据类型结构体定义的基本形式如下: ```c struct 结构体名 { 数据类型 成员名1; 数据类型 成员名2; // ... }; ``` 其中,结构体名是用户自定义的名称,成员名是结构体中每个成员的名称,数据类型可以是任意C语言的数据类型,包括基本数据类型自定义数据类型结构体变量的定义方式如下: ```c struct 结构体名 变量名; ``` 读取结构体中的成员变量可以通过“.”运算符来实现,例如: ```c #include <stdio.h> struct Person { char name[20]; int age; }; int main() { struct Person p; printf("请输入姓名:"); scanf("%s", p.name); printf("请输入年龄:"); scanf("%d", &p.age); printf("姓名:%s,年龄:%d\n", p.name, p.age); return 0; } ``` 枚举是一种特殊的数据类型,用于定义一组常量。枚举的定义方式如下: ```c enum 枚举名 { 常量名1, 常量名2, // ... }; ``` 其中,枚举名是用户自定义的名称,常量名是枚举中每个常量的名称。枚举常量的值默认是从0开始自动递增的,也可以手动指定值。例如: ```c #include <stdio.h> enum Weekday { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday }; int main() { enum Weekday today = Tuesday; printf("今天是星期%d\n", today + 1); return 0; } ``` 联合是一种特殊的数据类型,它的成员变量共享同一块内存空间。联合的定义方式如下: ```c union 联合名 { 数据类型 成员名1; 数据类型 成员名2; // ... }; ``` 其中,联合名是用户自定义的名称,成员名是联合中每个成员的名称,数据类型可以是任意C语言的数据类型,但所有成员的大小不能超过联合的大小。例如: ```c #include <stdio.h> union Number { int i; float f; }; int main() { union Number n; n.i = 123; printf("int: %d, float: %.2f\n", n.i, n.f); n.f = 3.14; printf("int: %d, float: %.2f\n", n.i, n.f); return 0; } ``` 以上就是C语言自定义数据类型中的结构体、枚举、联合的基本用法和注意事项。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

恒等于C

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值