自定义类型:结构体

目录

[1 · 结构体类型的声明](#1 · 结构体类型的声明)

[1 - 1 · 结构体的声明与简单介绍](#1 - 1 · 结构体的声明与简单介绍)

[1 - 1 - 1 · 结构体变量与初始化](#1 - 1 - 1 · 结构体变量与初始化)

[1 - 2 · 结构体的特殊声明](#1 - 2 · 结构体的特殊声明)

[1 - 3 · 结构体的自引用](#1 - 3 · 结构体的自引用)

[2 · 结构体内存对齐](#2 · 结构体内存对齐)

[2 - 1 · 对齐规则](#2 - 1 · 对齐规则)

[2 - 2 · 为什么存在内存对齐](#2 - 2 · 为什么存在内存对齐)

[2 - 3 · 修改默认对齐数](#2 - 3 · 修改默认对齐数)

[3 · 结构体传参](#3 · 结构体传参)

[4 · 结构体实现位段](#4 · 结构体实现位段)

[4 - 1 · 什么是位段](#4 - 1 · 什么是位段)

[4 - 2 · 位段的内存分配](#4 - 2 · 位段的内存分配)

[4 - 3 · 位段的跨平台问题](#4 - 3 · 位段的跨平台问题)

[4 - 4 · 位段的应用](#4 - 4 · 位段的应用)

[4 - 5 · 位段使用的注意事项](#4 - 5 · 位段使用的注意事项)

总结


C语言中有内置类型,也有自定义类型,内置类型是C语言自带的,自定义类型是我们可以自己设计的。

结构体就是一种自定义类型。

1 · 结构体类型的声明

1 - 1 · 结构体的声明与简单介绍

我们之前在详解操作符那篇中简单介绍过结构体,我们这里快速回顾一下:

结构体是一些值的集合,这些值被称为成员变量,这些成员变量的类型可以是整型,浮点型,数组,指针,甚至是结构体。

结构体的声明:

复制代码
struct tag
{
 member-list;
}variable-list;

拿描述一个学生来举例:

复制代码
struct student
{
	char name[20];
	double high;
	int age;
};//分号不能丢

1 - 1 - 1 · 结构体变量与初始化

cpp 复制代码
#include <stdio.h>

struct student
{
	char name[20];
	double high;
	int age;
}s1;//分号不能丢

int main()
{
	struct student s1 = { "zhangsan",1.73,18 };//按顺序初始化
	struct student s2 = { .age = 19,.name = "lisi",.high = 1.81 };//不按顺序初始化
	printf("%s \n", s1.name);
	printf("%d \n", s1.age);
	printf("%lf \n", s1.high);
	printf("%s \n", s2.name);
	printf("%d \n", s2.age);
	printf("%lf \n", s2.high);
	return 0;
}

运行一下:


1 - 2 · 结构体的特殊声明

在声明结构体时,可以不完全的声明。

比如:

cpp 复制代码
struct
{
	int a;
	double b;
	char c;
}a = { 5,3.14,'x' };

这个结构体在声明的时候省略了标签名(tag),是个匿名结构体。因为不知道这个结构体的标签名,所以要想创建这种结构体的变量,只能在声明的大括号之后。

那么我们这样写:

cpp 复制代码
#include <stdio.h>
struct
{
	int a;
	double b;
	char c;
}a = { 5,3.14,'x' };

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

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

这里两个匿名结构体有着同样的成员变量,让指针 p 指向 a ,可以看到,报警告了:


编译器会把上⾯的两个声明当成完全不同的两个类型,所以是⾮法的。
匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使用⼀次。


1 - 3 · 结构体的自引用

在一个结构体中包含一个类型为该结构体本身的成员变量可以吗?

比如我们这样写:

cpp 复制代码
struct Node
{
	int data;
	struct Node next;
};

那么我们思考一下:如果这样写,sizeof(struct Node) 是多少?

所以这样写是错误的,会导致sizeof(struct Node)无穷大。

所以正确的写法应该是包含一个该结构体的指针,如下:

cpp 复制代码
struct Node
{
	int data;
	struct Node* next;
};

在结构体自引用使用的过程中,夹杂了 typedef 对匿名结构体类型重命名,也容易出问题,比如:

cpp 复制代码
typedef struct
{
	int data;
	Node* next;
}Node;

这样写是不行的,
因为Node是对前面的匿名结构体类型的重命名产⽣的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这样写是不行的。
正确写法如下:定义结构体不要用匿名结构体。

cpp 复制代码
typedef struct Node
{
	int data;
	struct Node* next;
}Node;

这样写的话 将结构体重命名为 Node 这样以后想使用这个结构体类型时,写Node即可。


2 · 结构体内存对齐

结构体的大小应该如何计算,是否是成员变量的大小相加呢?

这就涉及到 结构体内存对齐 了。

2 - 1 · 对齐规则

  1. 结构体的首个成员变量需要对齐到结构体起始地址偏移量为0的地址

2.结构体的其他成员变量需要对齐到对齐数的整数倍的地址(由于结构体的起始位置就是对齐的边界,所以只需要找对齐数)

3.对齐数是编译器的一个默认值与该成员大小中的较小值,VS2022中默认值为8字节

4.结构体的总大小需要为最大对齐数(全部成员变量的对齐数中最大的那个)的整数倍

5.如果有嵌套结构,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构
体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
下面我们举个栗子:

cpp 复制代码
#include <stdio.h>
struct S1
{
	char c1;
	int i;
	char c2;
};

int main()
{
	printf("%d\n", sizeof(struct S1));
	return 0;
}

我们画个图方便理解。

结构体的首成员变量是 c1,放在偏移量为0的地址处,占1字节,接下来是 i ,int 的大小是4字节,默认对齐数是8,所以 i 的对齐数为4,放在偏移量为4的倍数的地方,向后离得最近的是偏移量为4的地方,占4个字节,最后是 c2 ,大小是1字节,默认对齐数是8,所以 c2 的对齐数为1,放在向后里的最近的偏移量为1的倍数的地方,是偏移量为8的地方,占1字节。

那么此时总字节数为 从偏移量为0到偏移量为8的这一块,总字节数为9,但是最大对齐数是4,结构体的整体大小需要为4的倍数,所以该结构体大小为12。

运行一下看看:

如果我们调换一下成员变量的顺序,结果可能是不一样的。

比如这里我们将 i 和 c2 进行调换:

cpp 复制代码
#include <stdio.h>
struct S1
{
	char c1;
	char c2;
	int i;
};

int main()
{
	printf("%d\n", sizeof(struct S1));
	return 0;
}

运行一下:


2 - 2 · 为什么存在内存对齐

那我们上面第一个例子来说,存放1个int 类型,2个char类型,理论上只需要6个字节,但结构体却占了12个字节,这不是白白浪费了6个字节吗

那么为什么存在内存对齐呢?
大部分的参考资料都是这样说的:
平台原因 (移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说 :结构体的内存对齐是拿空间 来换取时间的做法。
那我们在设计结构体的时候,既要满足对齐,又要节省空间,那就可以让内存占用小的成员尽量集中在一起,合理分配。
就像上面我们的两个例子,成员是相同的,但是顺序不同,导致了结构体的大小不同。


2 - 3 · 修改默认对齐数

#pragma 这个预处理指令,可以改变编译器的默认对齐数。

cpp 复制代码
#pragma pack(1)

这样写就是将默认对齐数设置成了1

那我们这样写:

cpp 复制代码
#include <stdio.h>
#pragma pack(1)
struct S1
{
	char c1;
	int i;
	char c2;
};

int main()
{
	printf("%d\n", sizeof(struct S1));
	return 0;
}

修改了默认对齐数,那么 i 的 对齐数就是1了,这样的话结构体占的大小就是6了。

运行一下:

结构体在对齐方式不合适的时候,我们就可以自己修改默认对齐数。


3 · 结构体传参

结构体传参可以传结构体,也可以传地址。如下:

cpp 复制代码
#include <stdio.h>

struct S
{
	int a;
	char c[20];
};

void Print1(struct S s)
{
	printf("%d\n", s.a);
	printf("%s\n", s.c);
}

void Print2(struct S* ps)
{
	printf("%d\n", ps->a);
	printf("%s\n", ps->c);
}

int main()
{
	struct S s1 = { 10,"abcdef" };
	Print1(s1);//传结构体
	Print2(&s1);//传结构体的地址
}

运行一下:

可以看到,这两种传参方法都是可行的,但是 我们的 Print1 和 Print2 哪个更好一点呢?

答案是 Print2 。

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


4 · 结构体实现位段

结构体是可以用来实现位段的

4 - 1 · 什么是位段

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

  1. 位段的成员必须是 int 、 unsigned int 或 signed int ,在C99中位段成员的类型也可以选择其他类型。
  2. 位段的成员名后边有⼀个冒号和⼀个数字。
    比如:
cpp 复制代码
struct S
{
	int a : 2;
	int b : 5;
	int c : 20;
	int d : 30;
};

S就是一个位段类型。

位指的是二进制位,冒号后的数字指的是占多少 bit 位。

原本 int 类型的数据占4字节 也就是32bit位 但有时我们实际使用不会将32位全部用上,这就造成了空间的浪费。

int a : 2;

这样 a 就只占2个bit位。

所以位段是用来节省内存的。

那么S的大小是多少呢,我们测试一下:

cpp 复制代码
#include <stdio.h>
struct S
{
	int a:2;
	int b : 5;
	int c : 20;
	int d : 30;
};

int main()
{
	printf("%zd\n", sizeof(struct S));
	return 0;
}

运行一下:


4 - 2 · 位段的内存分配

位段的成员可以是 int unsigned int signed int 或者是 char 等类型
位段的空间上是按照需要的方 式来开辟的。
比如放入一个 int 类型,如果不够,就开辟四个字节的空间,放入一个char类型,如果不够,就开辟一个字节的空间。
位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
我们举个栗子来一步步看是如何分配的:

cpp 复制代码
struct S
{
 char a:3;
 char b:4;
 char c:5;
 char d:4;
};

假设我们先拿一块内存空间出来。

此时要分配第一个成员变量 a 的空间,但现在没有空间,所以要开辟一块,由于 a 是char 类型,所以开辟8 bit 位空间。假设黄色是我们第一次开辟的空间:

接下来我们要给a 分配空间,但是有一个问题,应该从左向右分配还是从右向左?很遗憾,C语言中对此无明确规定,所以不同的编译器是有差异的,在我使用的VS2022中是从右向左,所以我们这里用从右向左演示,给a 分配3bit位空间:

接下来是给b分配空间,需要分配4个bit位,我们所开辟的空间足够,那就接着分配:

接下来是给c分配空间,由于我们开辟的空间现在仅剩1bit位没被占用,此时c是放不下的,那么就要再次开辟,根据类型开辟8bit位(假设紫色是第二次开辟的空间),然后给c分配5bit位空间。但是此时又有一个问题,我们第一次开辟的空间有1bit位没有被使用,那么这个剩余的空间是浪费掉还是继续使用呢?在VS2022中,是浪费掉,所以给c分配空间如下图:

最后给 d 分配空间由于第二次开辟的空间不足以分配d的4bit位,所以再次开辟,舍弃剩余3bit位。如下:

那么此时我们也能看到这个位段的大小是3个字节,测试一下看看:

cpp 复制代码
#include <stdio.h>
struct S
{
	char a : 3;
	char b : 4;
	char c : 5;
	char d : 4;
};

int main()
{
	printf("%zd\n", sizeof(struct S));
	return 0;
}

运行一下:

那么我们最开始的位段的大小为8现在也能够理解了,这里简单画一下:

那么现在我们再运行一下下面这段代码:

cpp 复制代码
#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;
	return 0;
}

此时内存中存的是什么呢?我们分析一下:

给成员变量a赋值10,10的二进制表示是 00001010,但是a只被分配了3bit位,所以发生截断,存010。

给 b 赋值12,12二进制表示是 00001100,存 1100。

同理,c和d 分别存 00011 和 0100

对于那些被浪费的空间,放的就是0,没有更改。

我们画个图便于理解:

然后二进制转十六进制,为 0x62 03 04

我们通过调试看看:

可以看到,的确是 0x620304


4 - 3 · 位段的跨平台问题

  1. int 位段被当成有符号数还是 无符号数是不确定的。
  2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会
    出问题。
  3. 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
  4. 当⼀个结构包含两个位段,第⼆个位段成员比较大,无法容纳于第⼀个位段剩余的位时,是舍弃
    剩余的位还是利用,这是不确定的。
    所以 跟结构体相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。

4 - 4 · 位段的应用

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


4 - 5 · 位段使用的注意事项

位段的几个成员共有同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。
所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输入放在⼀个变量中,然后赋值给位段的成员。

cpp 复制代码
#include <stdio.h>
struct S
{
	int a : 2;
	int b : 5;
	int c : 10;
	int d : 30;
};

int main()
{
	struct S s = { 0 };
	scanf("%d", &s.a);//不能这样写
	return 0;
}

这样写是错误的。正确写法如下:

cpp 复制代码
int main()
{
	struct S s = { 0 };
	int b = 0;
	scanf("%d", &b);
	s.a = b;
	return 0;
}

总结

以上简单介绍了结构体相关内容,关于C语言的其余内容,请期待后续更新


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
feng_you_ying_li1 小时前
linux之程序地址空间
开发语言
Shingmc32 小时前
【Linux】序列化与反序列化
开发语言·c++
圆山猫2 小时前
[AI] [RISCV] 用 Rust 写一个 RISC-V BootROM:从 QEMU 到真实硬件
开发语言·rust·risc-v
一个天蝎座 白勺 程序猿2 小时前
AI入门踩坑实录:我换了3种语言才敢说,Python真的是入门唯一选择吗?
开发语言·人工智能·python·ai
Hui_AI7202 小时前
保险条款NLP解析与知识图谱搭建:让AI准确理解保险产品的技术方案
开发语言·人工智能·python·算法·自然语言处理·开源·开源软件
杜子不疼.2 小时前
用 Python 搭建本地 AI 问答系统:避开 90% 新手都会踩的环境坑
开发语言·人工智能·python
执于代码2 小时前
python 常见的框架
开发语言·python
AI老李2 小时前
【Python】6 种方法轻松将 Python 脚本打包成 EXE 应用
开发语言·python
大G的笔记本2 小时前
redis常用场景-java示例
java·开发语言·redis