sizeof 算结构体大小,为什么结果总是比我算的大?

一个让我愣住的 sizeof

今天晚上继续磕学生管理系统。前面用数组分别存姓名、成绩、年龄,搞得我每次改学生信息都要同时操作三个数组,代码又臭又长。我就想,就不能把跟一个学生相关的所有信息打包在一起吗?

查了一下,C 语言里果然有个东西叫结构体(struct),专门干这个事。我兴奋地写了第一个结构体,定义了一个student类型:

struct student { char name[20]; // 名字,20字节 int age; // 年龄,4字节 float score; // 分数,4字节 };

然后我顺手用sizeof(struct student)看了一下这个类型占多大空间。心里的小算盘打得啪啪响:20 + 4 + 4 = 28 字节。结果一跑,输出 28?运气好还真是 28——但如果我换个成员顺序,结果就变了。我当时就愣住了:这又不是四则运算,怎么换顺序还影响结果?

后来才知道,这不是 C 语言 bug,是一个叫结构体对齐的东西在作祟。这是今天花最长时间搞明白的事。

先搞清楚结构体到底是什么

在这之前,先把结构体本身理清楚。int存整数,char存字符,这些是 C 语言给的基本类型。但真实世界里的数据从来不是一个一个独立存在的——一个学生同时有名字、年龄、成绩,你要是还像之前那样用三个数组去存,它们之间的联系全靠同一个下标硬撑着。

结构体干的事就是:自己造一个新的数据类型,把要用的成员打包在一起。语法长这样:

struct 结构体名 { 成员列表; }; // 这个分号千万别忘,忘了我已经替你试过了,编译器报错能看哭你

造好了类型之后,用法跟int那种基本类型一样——可以定义变量、数组、指针,还能当函数参数和返回值。下面对比一下:

int a; // 定义一个整数变量,开了4字节空间 struct student s; // 定义一个学生变量,开多大空间?这就是前面的问题了

定义变量的本质就是开空间,所以空间到底多大,自然就成了第一个绕不开的问题。

初始化、点运算符和箭头运算符,这些倒不复杂

初始化就按成员顺序挨个给值:

struct student s = {"tom", 18, 99.5}; // 按 name, age, score 顺序来

访问成员用.运算符:

s.name // 访问名字 s.age // 访问年龄 s.score // 访问分数

如果用到指针,用->运算符更清爽:

struct student *p = &s; p->name // 等价于 (*p).name,但少打两个字符,你懂的

有个挺方便的小细节:同类型的结构体变量可以直接用=赋值,成员会逐个拷贝过去,不用自己写循环:

struct student s1 = {"tom", 18, 99.5}; struct student s2 = s1; // 爽,一行搞定

不过函数传参时要注意,一般传地址而不是传结构体本身。你想想,一个结构体可能几十上百字节,如果传值就会把整个结构体复制一份传给函数,浪费时间和栈空间,没必要。

回到那个让我纠结了一晚上的对齐问题

好,现在回到开头那个让我怀疑人生的问题:为什么sizeof(struct student)不一定等于成员大小加起来?

我把 `student` 结构体里的成员换了个顺序来试:

struct demo { char a; // 1字节 int b; // 4字节 char c; // 1字节 }; // 我心算:1 + 4 + 1 = 6 字节 // 实际输出:12 字节。喂???

不是编译器出 bug,是 CPU 本身就不喜欢非对齐的数据。32 位 CPU 一次读 4 个字节,如果数据没有放在“自然边界”上,CPU 可能要多读一次才能完整取出数据。编译器为了性能,就自动帮我们做内存对齐

规则分两步:

第一步,每个成员要站在自己的自然边界上。什么叫自然边界?就是成员类型大小对应的地址要求:

  • char—— 1 字节对齐,随意放;
  • short—— 2 字节对齐,地址必须能被 2 整除;
  • int/long/float—— 4 字节对齐,地址必须能被 4 整除;
  • double—— 8 字节对齐,地址必须能被 8 整除。

回头看我那个demo结构体:char a从地址 0 开始(占 1 字节),下一个是int b,它要求地址必须是 4 的倍数,所以地址 1、2、3 都满足不了,编译器直接跳过 3 个字节,让b从地址 4 开始。然后char c从地址 8 开始。

第二步,整个结构体的大小要跟最大成员对齐。int b是 4 字节对齐,所以整个结构体的大小必须是 4 的整数倍。目前 a(1) + 填充(3) + b(4) + c(1) = 9 字节,9 不是 4 的倍数,编译器又在最后补了 3 字节,凑成 12。

总的来说,结构体大小不是简单把成员大小相加,编译器会在成员之间和末尾偷偷塞“填充字节”,让每个成员站在自己的自然边界上。听起来很烦,但本质上是在帮 CPU 跑得更快。

共用体:同一个房间,不同时间段住不同的人

结构体是每个成员各有各的房间,共用体(union)是所有成员轮流用同一间房。语法跟结构体差不多:

union demo { int a; // 4字节 float b; // 4字节 char c; // 1字节 }; // 三个变量共用一块空间,空间大小取最大的那个成员——也就是4字节

初始化时只能给一个值,默认给第一个成员:

union demo d = {10}; // 给第一个成员 a 赋值为 10

这里有个容易踩的坑:如果你给d.a = 100;然后再给d.c = 'A';,那d.a的值就没了——大家住同一间房,后面住进来的人会把前面的人的东西清出去。

共用体一个经典的用法是测试电脑是大端还是小端

union test { int a; char b; }; union test t; t.a = 1; // int 和 char 的起始地址相同,b 读到的是 a 的最低字节 if (t.b == 1) { printf("小端模式\n"); // 低位在低地址 } else { printf("大端模式\n"); // 低位在高地址 }

还有一个实用场景:学生有score,老师有salary,但同一个人不可能同时是学生又是老师,所以这俩字段不用同时存在:

struct member { char name[20]; int age; union { float score; // 学生用 float salary; // 老师用 } data; }; // 省下了一个 float 的空间

枚举:给那些只有几种可能值的变量正经起个名

如果一个变量取值就那么几种,比如交通信号灯就红、绿、黄三种。你用012去代表,过两周回来看代码,满脑子问号:0 是啥意思来着?红灯还是绿灯?

枚举就是来解决这个的:

enum light { RED, // 默认从 0 开始 GREEN, // 自动 = 1 YELLOW // 自动 = 2 };

好处很明显:代码可读性直接提升一个档次,看到RED就知道是红灯,不用猜数字。还可以自己指定起始值:

enum light { RED = 10, GREEN, // 接着变成 11 YELLOW // 接着变成 12 };

枚举本质是int类型,但它跟宏定义比起来有个很大优势:枚举是正儿八经的类型,编译阶段发挥作用,有类型检查;宏呢,只是预处理阶段做文本替换,没有任何类型概念,出错了编译器连个像样的提示都给不了。

今天容易搞混的几个点

容易搞混的情况为什么容易错
结构体大小直接用成员大小加编译器会做内存对齐,成员之间和末尾可能有填充字节,换一下成员顺序结果就可能不一样
结构体传参直接传变量本身值传递会把整个结构体复制一份,大的结构体浪费栈空间还慢,传地址更高效
共用体当成结构体来用,以为各存各的共用体所有成员共用一块内存,给第二个成员赋值会把第一个成员的值覆盖掉
共用体大小以为是所有成员之和共用体大小等于最大成员的大小,不是加起来的,别跟结构体搞混
枚举和宏定义混着用,感觉差不多枚举有类型检查,编译阶段生效;宏只是预处理文本替换,没有类型概念,调试起来更痛苦
结构体定义最后忘写分号};这个分号一漏,编译器报一堆莫名其妙的错,一开始看会特别懵

今日学习感悟

今天花时间最多的不是写代码,是搞清楚 sizeof 为啥对不上我口算的结果。刚开始觉得对齐就是编译器在折磨我,后来理解了 CPU 读内存的方式才觉得它确实是在帮我们做优化。结构体、共用体、枚举其实都是在解决同一个问题:怎么把数据组织得更合理。用数组硬撑确实能跑,但写到后面就知道痛苦了。慢慢开始体会到,好的数据结构比多写几行代码重要得多。

Day15 学习记录,明天继续。