C语言梦开始的地方16:结构体

文章目录

C语言梦开始的地方16:结构体

结构体类型的声明

结构体是什么?

我们在了解C语言的数据类型章节时说到过C语言大致分为2种类型一种是内置类型即char,int这种,一种是自定义类型,我们本章节的结构体就是C语言的自定义类型。

那么结构体又是有什么组成的呢?

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

结构体的声明

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

比如我们现在要描述一个学生:

c 复制代码
struct Stu{
    char name[20]; // 名字
    int age;       // 年龄
    char sex[2];   // 性别
    char id[20];   // 学号
};

结构体变量创建和初始化

我们刚才已经创建了一个学生的结构体那么现在该如何对他进行初始化呢?

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

struct Stu
{
    char name[20]; // 名字
    int age;       // 年龄
    char sex[2];   // 性别
    char id[20];   // 学号
};

int main()
{
    // 按结构体成员的顺序初始化
    struct Stu s = {"张三", 18, "男", "123456789"};
    printf("name: %s\n", s.name);
    printf("age : %d\n", s.age);
    printf("sex : %s\n", s.sex);
    printf("id : %s\n", s.id);

    // 按照指定的顺序初始化
    struct Stu s2 = {.age = 18, .name = "李四", .sex = "男", .id = "234567890"};

    printf("name: %s\n", s2.name);
    printf("age : %d\n", s2.age);
    printf("sex : %s\n", s2.sex);
    printf("id : %s\n", s2.id);

    return 0;
}

结构体的特殊声明

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

c 复制代码
struct {
	int a;
	char b;
	float c;
}x;

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

这种声明常用于使用次数不多的场景,比如只需要调用一次结构体的场景我们就可以使用上述声明,这种声明在C语言中叫匿名结构体

声明的两个结构体参数都是一样的,但是他们互不认识比如:

c 复制代码
p = &b;

这里就会导致程序编译报错。因为编译器在遇到第一个struct {...}时,会在内部生成一个唯一的、隐式的类型名(比如叫 __Anonymous_Struct_1)。当你再写第二个 struct {...} 时(即使成员一模一样),编译器会生成另一个完全不同的类型名(__Anonymous_Struct_2)。

结构体的自引用

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

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

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

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

正确的自引用方式:

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

为什么这样就可以呢?因为指针长度是固定的平台是32位就是4字节,64位就是8字节

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

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

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

解决方法:定义结构体不要使用匿名结构体了

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

那么该代码和上述正常定义的结构体的区别在哪里?难道就是多了个typedef吗?

区别在于后续编写代码时我们可以使用该结构体的定义的别名:Node ,当我们在编写一个名字很长的结构体时很容易将名字打错,我们就可以使用typedef来起别名。

结构体内存对齐

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

现在我们深入讨论一个问题:计算结构体的大小。

这也是一个特别热门的考点: 结构体内存对齐

对齐规则

首先我们要了解对齐规则:

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

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

    • 对齐数 = 编译器默认的一个对齐数 与 该成员变量大小的较小值。
    • VS 中默认的值为 8
    • Linux中 gcc 没有默认对齐数,对齐数就是成员自身的大小
  3. 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍。

  4. 每个成员的对齐数,只取'自己大小'和'默认数'的较小值;整个结构体的大小,最后再按所有成员中'最大的那个对齐数'来补齐。

  5. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍

    处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。

注意:当结构体成员变量大小超过了默认对齐数时按默认对齐数,

练习1:

c 复制代码
struct S1 { char c1; int i; char c2; };
  • c1 占第0字节。
  • i 大小为4,对齐数为4,必须从偏移量为4的整数倍(即4)开始,所以偏移量1~3被浪费(填充)。
  • c2 大小为1,对齐数为1,紧跟 i 后面,偏移量为8。
  • 总大小为最大对齐数(4)的整数倍,当前为9,向上取整到最接近的4的倍数 → 12字节。

练习2:

c 复制代码
struct S2 { char c1; char c2; int i; };
  • c1 在0,c2 大小为1,对齐数为1,紧跟在偏移量1。
  • i 对齐数为4,必须从偏移量4开始,偏移量2~3被浪费。
  • 总大小为4的整数倍,偏移量4+4=8 → 8字节。

对比发现,S1和S2成员一样,但顺序不同,大小差了4字节,证明"让占用小的成员集中"能省空间。所以在定义结构体时同类型应该要集中在一起

为什么存在内存对齐?

  • 硬件原因:某些CPU(如ARM或老式x86)在读取未对齐的 int 或 double 时会触发总线错误或进行多次读取拼接。

  • 性能原因:现代CPU访问内存时,通常以"字长"(如8字节)为单位读取。对齐的数据可以一次性载入寄存器;未对齐的数据可能跨越两个内存块,需要两次读取再拼接,效率大打折扣。结构体对齐本质是用空间换取时间。

修改默认对齐数

当需要节省空间(如网络通信数据包、文件存储结构)时,可以使用 #pragma 来设置默认对齐。此时结构体大小就是所有成员大小之和(无填充)。但代价是CPU读取效率会下降。

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

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

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

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

结构成员访问操作符

结构成员访问操作符:

  • . 操作符:左操作数是结构体变量(或结构体数组的元素),右操作数是成员名。编译器会根据成员类型计算相对于结构体起始地址的偏移量,然后进行寻址。
  • -> 操作符:左操作数必须是结构体指针。它等价于 (*ps).num,但书写更简洁。在操作系统中,频繁传递大结构体的指针时,-> 的使用频率极高。

那么什么样的场景使用它们俩呢?左边是 struct 变量就用 .,左边是 struct* 指针就用 ->

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

struct Stu
{
    char name[20]; // 名字
    int age;       // 年龄
    char sex[2];   // 性别
    char id[20];   // 学号
};

int main()
{
    struct Stu s = {"张三", 18, "男", "123456789"};
    printf("name: %s\n", s.name);
    printf("age : %d\n", s.age);
    printf("sex : %s\n", s.sex);
    printf("id : %s\n", s.id);


    struct Stu* s2 = &s;

    printf("name: %s\n", s2->name);
    printf("age : %d\n", s2->age);
    printf("sex : %s\n", s2->sex);
    printf("id : %s\n", s2->id);

    return 0;
}

结构体传参

同样结构体也可以当作函数的参数使用

那么现在有个问题就是结构体这个自定义类型是传值好还是传址好呢?

我们看下面这个代码:

c 复制代码
#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;
}

运行程序的时候感觉没事区别,结果都是瞬间出来。

这是因为现代CPU处理数据都非常快,快到你点击运行程序时程序就已经跑完了。

那么我们从底层来看到底谁更好?

  • 传值调用(print1)
    实参 s 的内容(包含一个1000个int的数组)会被完整复制到栈帧(函数调用栈)中。这涉及 1000 * 4 = 4000 字节的内存拷贝,极其耗费CPU时间和栈空间,容易导致栈溢出。
  • 传址调用(print2)
    只传递一个指针(4或8字节),压栈开销极小。函数内部通过指针间接访问数据,效率极高。

总结:当结构体成员很少时比如只有一个char类型这种情况以外,永远优先传递结构体指针 。如果你不想让函数修改原结构体内容,可以在形参前加 const 修饰,如 void print2(const struct S* ps)

结构体实现位段

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

什么是位段

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

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

冒号后的数字表示该成员占用的比特位数(不能超过该类型的总位数)。

比如:

c 复制代码
struct A { 
	int _a:2; 
	int _b:5; 
	int _c:10; 
	int _d:30; 
};

这里A就是位段类型。

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

c 复制代码
printf("%d\n", sizeof(struct A));  

答案是8,为什么呢?

  • 成员类型是 int,编译器按 4 字节(32 bit)开辟空间。
  • _a(2) + _b(5) + _c(10) = 17 bit,小于 32,都放在第一个"字"里。
  • _d 需要 30 bit,但第一个字只剩 32 - 17 = 15 bit,放不下 30 bit,所以舍弃剩余 15 bit,重新开辟第二个 32 bit 给 _d。
  • 总大小 = 8 字节(两个 int)

位段的内存分配

为什么上述代码得出的是8字节?如果这里给任意的整型会发生什么呢?

要了解这个问题我们就需要先了解位段的内存是如何分配的。

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

2的意思是当前变量是什么类型就按当前这个类型来开辟空间,所以创建位段时成员变量最好都是同类型的。

那我们了解了位段的内存分配,那么空间是如何开辟的呢?

请看下面示例:

c 复制代码
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;
}

上述环境为visual studio2022 平台上

位段的跨平台问题

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

总结:

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

位段的应用

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

除了上述用途以外位段的应用还有单片机,文件格式等场景。位段是"距离硬件最近的C语言特性",除非你在写底层驱动、芯片寄存器映射或文件系统,否则在普通工程里,请用位运算(&、|、<<)来代替位段,保证代码绝对可移植。

位段使用的注意事项

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

所以不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输入放在一个变量中,然后赋值给位段的成员。

c 复制代码
#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;
}

为什么不能直接&sa.b呢?

因为C语言中是按字节编号的。位段的成员可能只占据一个字节中间的某几个bit,比如从第3bit到第5bit,这个位置没有对应的字节地址。因此 &sa._b 是非法的。

正确的输入方法 :定义一个普通整型变量 int b;,用 scanf("%d", &b); 接收用户输入,然后通过 sa._b = b; 将值赋给位段成员(赋值时会自动截断至指定的bit位)。

相关推荐
polarislove02141 小时前
Windows安全中心网络盘打开文件提示“打开这些文件可能会对你的计算机有害”的解决方法
网络·windows·安全
2401_862880821 小时前
Linux应用层开发 --- 多任务
linux·服务器·c语言
x861 小时前
Go 1.27 正式发布:泛型方法、encoding/json/v2、后量子签名 ML-DSA 与 SIMD
开发语言·golang
杨丰玮4181 小时前
暑假Java知识点回顾:类与对象知识总结
java·开发语言
心仪久了会沦陷1 小时前
数据结构入门系列——顺序表详解:概念、分类与动态实现
c语言·数据结构·经验分享·笔记·开源
Patrick在香港2 小时前
Python asyncio vs 多线程 vs 多进程——同一份 HKOpenDataClient 实测 5/30/100 endpoint,谁是真的银弹
开发语言·python
witton2 小时前
xmake中将proto文件生成C/C++文件然后再编译链接进项目
c语言·c++·mingw·protobuf·xmake·protobuf-c·protoc-gen-c
牛油果子哥q2 小时前
C++异常处理万字详解:try-throw-catch机制、栈展开、标准异常体系、自定义异常、工程规范、内存泄漏避坑
开发语言·jvm·c++
Metaphor6922 小时前
使用 Python 设置 Excel 文件属性
开发语言·python·excel