C语言结构体详解

目录

前言

一. 结构体基本语法

1. 结构体的声明

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

3. 结构体成员访问操作符

3.1 结构体成员的直接访问

3.2 结构体成员的间接访问

4. 结构体的特殊声明

5. 结构体的自引用

二. 结构体内存对齐

1. 对齐规则

1.1 代码分析1(上面的问题解答)

1.2 代码分析2

1.3 代码分析3

1.4 代码分析4

2. 为什么存在内存对齐?

1. 平台原因 (移植原因)

2. 性能原因

3. 修改默认对齐数

三. 结构体传参

四. 结构体实现位段

1. 什么是位段

2. 位段的内存分配

2.1 内存分析示例

3. 位段的跨平台问题

4. 位段的应用

5. 位段使用的注意事项

写在最后


前言

C语言已经提供了内置类型,如:char、short、int、long、float、double等,但是只有这些内置类型还是不够的,假设我想描述学生,描述一本书,这时单一的内置类型是不行的。描述一个学生需要名字、年龄、学号、身高、体重等;描述一本书需要作者、出版社、定价等。

C语言为了解决这个问题,增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型。

结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量,如:标量、数组、指针,甚至是其他结构体。

一. 结构体基本语法

1. 结构体的声明

struct(结构体关键字) tag(结构体名字)
{
    member-list(结构体成员);
}variable-list(创建的变量);

描述一个学生:

struct Stu
{
    char name[20];//名字
    int age;//年龄
    char sex[5];//性别
    char id[20];//学号
};//分号不能丢

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

//结构体的定义
struct Point
{
    int x;
    int y;
}p1; //声明类型的同时定义变量p1
struct Point p2; //定义结构体变量p2

声明创建了一个包含整型变量x,y的名为point的结构体,并立即创建了名为p1的结构体变量

然后通过struct(结构体关键字)+结构体名字+变量名字创建了名为p2的结构体变量

//结构体的初始化
struct Point p3 = {10, 20};
struct Stu //类型声明
{
    char name[15];//名字
    int age; //年龄
};
struct Stu s1 = {"zhangsan", 20};//初始化
struct Stu s2 = {.age=20, .name="lisi"};//指定顺序初始化

可以在创建结构体变量时完成初始化,也可以在创建结构体变量时使用 .+成员变量 的方式完成指定成员初始化 

struct Node
{
    int data;
    struct Point p;
    struct Node* next;
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化

结构体内部也可以包含结构体

3. 结构体成员访问操作符

3.1 结构体成员的直接访问

结构体成员的直接访问是通过点操作符(.)访问的。

使用方式:结构体变量.成员名

点操作符接受两个操作数。如下所示:

#include <stdio.h>

struct Point
{
	int x;
	int y;
}p = { 1,2 };
int main()
{
	printf("x: %d\ny: %d\n", p.x, p.y);
	return 0;
}

通过在创建结构体类型point的同时创建结构体变量p并完成初始化,然后通过结构成员访问操作符完成对其内容的访问和打印

运行结果:

3.2 结构体成员的间接访问

有时候我们得到的不是一个结构体变量,而是得到了一个指向结构体的指针。如下所示:

使用方式:结构体指针->成员名

#include <stdio.h>

struct Point
{
	int x;
	int y;
};
int main()
{
	struct Point p = { 3, 4 };
	struct Point* ptr = &p;
	ptr->x = 10;
	ptr->y = 20;
	printf("x = %d\ny = %d\n", ptr->x, ptr->y);
	return 0;
}

创建结构体类型point,然后创建结构体变量p并完成初始化,然后创建结构体指针变量ptr指向这个结构体,然后通过指针修改了结构体成员的内容,最后通过结构成员访问操作符完成对其内容的访问和打印

运行结果:

4. 结构体的特殊声明

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

比如:

//匿名结构体类型
struct
{
	int a;
	char b;
	float c;
}x;

struct
{
	int a;
	char b;
	float c;
}a[20], * p;

上面的两个结构在声明的时候省略掉了结构体标签(tag)。

那么问题来了,下面的代码合法吗?

p = &x;

警告:

编译器会把上面的两个声明当成完全不同的两个类型,所以是非法的

匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使用一次

5. 结构体的自引用

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

比如,定义一个链表的节点:

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

上述代码正确吗?如果正确,那 sizeof(struct Node) 是多少?

所以是不行的,因为一个结构体中再包含一个同类型的结构体变量,这样结构体变量的大小就会无穷的大,是不合理的。

正确的自引用方式:

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

在结构体自引用使用的过程中,有时候会夹杂了 typedef 对匿名结构体类型重命名,也容易引入问题,看看下面的代码,可行吗?

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

答案是不行的,因为Node是对前面的匿名结构体类型的重命名产生的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这是不行的。

解决方案如下:定义结构体不要使用匿名结构体

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

二. 结构体内存对齐

我们已经掌握了结构体的基本使用了。

下面我们来看这一段代码:

#include <stdio.h>

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

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

S1的大小是多少?

很多人可能会认为,这个结构体包含两个char类型和一个int类型,不就是 2 * 1 + 4 = 6

所以应该是6个字节的大小

我们来看运行结果:

我们发现大小为12个字节,这就涉及到接下来的知识:结构体内存对齐

1. 对齐规则

要解答上面的问题,首先得掌握结构体的对齐规则:

1. 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处

2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。

对齐数 = 编译器默认的一个对齐数 与 该成员变量大小的较小值

  VS编译器中默认的值为 8

  Linux中 gcc编译器没有默认对齐数,对齐数就是成员自身的大小

3. 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍。

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

1.1 代码分析1(上面的问题解答)

按照以上的对齐规则,我们再来思考上面的问题,可以猜测S1在内存中的存储方式可能是这样的:

S1在内存中可能的存储方式
S1在内存中可能的存储方式
VS编译器对结构体内存占用的分析
VS编译器对结构体内存占用的分析

c1和0偏移量对齐,对齐数为1,占一个字节

i对齐数为4,跳过三个字节,占5~8这四个字节

c2对齐数为1,对齐数为1,占一个字节

结构体总大小为最大对齐数4的倍数,为12

我们对其进行赋值,然后进行调试:

S1在内存中的存储方式
S1在内存中的存储方式

这恰好印证了我们的猜测,说明结构体在内存中的存储的确是按照这种规则进行对齐的 

1.2 代码分析2

#include <stdio.h>

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

int main()
{
	struct S2 s = { 01,02,03 };
	printf("sizeof(struct S2) = %zd\n", sizeof(struct S2));
	return 0;
}

c1和0偏移量对齐,对齐数为1,占一个字节

c2对齐数为1,对齐数为1,占一个字节

i对齐数为4,跳过两个字节,占5~8这四个字节

结构体总大小为最大对齐数4的倍数,为8

画图分析
画图分析
VS编译器对结构体内存占用的分析
编译器分析
内存监视
内存监视

运行结果:

1.3 代码分析3

#include <stdio.h>

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

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

d和0偏移量对齐,对齐数为8,占0~8这八个字节

c对齐数为1,对齐数为1,占一个字节

i对齐数为4,跳过三个字节,占13~16这四个字节

结构体总大小为最大对齐数8的倍数,为16

画图分析
画图分析
VS编译器对结构体内存占用的分析
编译器分析
内存监视
内存监视

 如果你对浮点数在内存中的存储感兴趣,可以看看我写的这一篇文章:

整数和浮点数在内存中存储

1.4 代码分析4

#include <stdio.h>

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

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

int main()
{
    struct S4 s = { 01,{02.00,03,04},05.00 };
	printf("sizeof(struct S4) = %zd\n", sizeof(struct S4));
	return 0;
}

c1和0偏移量对齐,对齐数为1,占一个字节

s3为结构体,由于嵌套了结构体,嵌套的结构体成员对齐到自己的成员中最大对齐数8的整数倍处,所以跳过7个字节,结构体的整体大小就是所有最大对齐数8的整数倍16,占9~24这16个字节

d对齐数为8,占25~32这8个字节

所以大小应该为32个字节

画图分析
画图分析
VS编译器对结构体内存占用的分析
编译器分析
内存监视
内存监视

2. 为什么存在内存对齐?

1. 平台原因 (移植原因)

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

2. 性能原因

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

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

那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到:

让占用空间小的成员尽量集中在一起

#include <stdio.h>

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

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

运行结果:

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

3. 修改默认对齐数

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

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

#include <stdio.h>
#pragma pack(1)//设置默认对⻬数为1

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

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

运行结果:

当我们想恢复默认对齐值时,可以使用以下代码: 

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

三. 结构体传参

#include <stdio.h>

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. 什么是位段

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

1. 位段的成员必须是 int、unsigned int 或signed int ,在C99中位段成员的类型也可以选择其他类型。

2. 位段的成员名后边有一个冒号和一个数字。

比如:

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

A就是一个位段类型。

那位段A所占内存的大小是多少?

可以看到,使用位段后,结构体占用的内存空间小于应该占的空间(4 * sizeof(int) = 16) 

2. 位段的内存分配

1. 位段的成员可以是 int unsigned int signed int 或者是 char 等类型

2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的

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

2.1 内存分析示例

我们来看下面一段代码:

#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("sizeof(struct S) = %zd\n", sizeof(struct S));
	return 0;
}

VS编译器上, 位段中的成员在内存中从右向左分配,当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,舍弃剩余的位

画图分析
画图分析
VS编译器对结构体内存占用的分析
编译器分析

 内存监视: 

 内存监视

运行结果:

3. 位段的跨平台问题

1. int 位段被当成有符号数还是无符号数是不确定的

2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。

3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义

4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的

总结:

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

4. 位段的应用

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

IP数据报文极度节省空间
IP数据报文极度节省空间

5. 位段使用的注意事项

位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的。

所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值

不能使用scanf直接给位段的成员输入值
不能使用scanf直接给位段的成员输入值

只能是先输入放在一个变量中,然后赋值给位段的成员

输入放在一个变量中,然后赋值
输入放在一个变量中,然后赋值

写在最后

C语言通过提供结构体这一类型,使几种基础数据类型结合起来,创造了多种多样的数据类型,从而适应了多种不同的应用场景。同时依靠指针,可以使不同的结构体之间发生联系,从而形成了各种数据结构,如:顺序表、链表、堆栈 等。这些数据结构各有特色,为程序开发提供了便利,因此,了解和熟练使用结构体对程序的开发有着重大意义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值