该系列为本人的学习笔记,主要由本人整理书写而成。部分内容来自教材、视频课程等,不能保证完全原创性。

萌新的学习笔记,写错了恳请斧正。

关于结构体的基本内容 (包括结构体的声明、创建、初始化、结构成员访问) 已经在笔记 #15 中讲述,不再赘述。

# 在定义结构体时起别名

定义结构体时前面直接加 typedef 进行起别名的操作不会影响结构体的创建。

typedef struct a
{
    int a;
    float b;
    char c;
} sta;

这就是定义了一个结构体类型 struct a, 然后给它起别名为类型 sta

但是这样就不能在定义结构体类型的时候直接创建结构体变量了

注意 (相关内容看下面):

  • 匿名结构体可以起别名,这样就能正常使用了
  • 自引用同时起别名不能用别名自引用 (创建的优先顺序高于起别名)

# 匿名结构体

结构体在声明时,其实可以省略结构体标签 (名称)。如下:

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

这就是匿名的创建了一个结构体并声明了一个该类型的结构体变量。

注意,在不起别名的情况下:

  • 匿名创建结构体如果没有直接声明几个对应的结构体变量,之后就再也不能申请了。
  • 匿名创建结构体之后也再也无法找到这个结构体类型了。
  • 两个成员相同的匿名结构体不会被认为是同一种结构体,比方说:
struct
{
    int a;
    float b;
    char c;
} a;
 
struct
{
    int a;
    float b;
    char c;
} b, *p;

在上面这种情况下,如果令 p 等于 &a 就是非法的,因为两个匿名结构体类型不一样。

但是我们可以在创建匿名结构体的时候起别名,这样就能通过别名正常使用结构体了:

typedef struct
{
    int a;
    float b;
    char c;
} st;

比方说上面这段代码就能继续通过 st 这个类型名继续进行创建变量等操作

# 结构体的自引用

结构体可以自引用,常用于链表 (以后讲)

当然这不是说结构体的成员可以是该结构体本身

(如果这样就无限套娃了,大小无穷大)

结构体的自引用指的是结构体的成员变量可以是该结构体的指针类型

比方说:

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

如果起别名和自引用同时进行的话,自引用的地方不能用别名

比方说这样是不行的:

typedef struct chain
{
    int data;
    st* next;
} st;

应该写成这样 (这边顺便把指针起别名了):

typedef struct chain
{
    int data;
    struct chain* next;
} st;
 
typedef st* pst;

# 结构体的内存对齐

我们现在研究一下结构体的内存大小

结构体类型占内存的大小是不是等于所有成员变量占内存的和呢?

我们写一段程序验证一下:

#include <stdio.h>
 
struct S1
{
	char c1;
	int i;
	char c2;
};
 
int main()
{
	printf("%d\n", sizeof(struct S1));
	return 0;
}

这段代码在 Win11 VS2022 x64 Debug 的环境下输出 12

而我们知道如果单纯的成员变量大小相加,答案应该为 6

所以,结构体内存究竟是如何排布的呢?

其实结构体在内存中的排布,遵循内存对齐规则:

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

  2. 其他成员变量要对齐到某个数字 (对齐数) 的整数倍的地址处
    对齐数 = 编译器默认的对齐数该成员变量大小较小值

     VS2022 的默认对齐数为 8,Linux gcc 没有默认对齐数
    
  3. 结构体总大小为结构体成员中最大的对齐数的整数倍

  4. 如果结构体嵌套了结构体,嵌套的结构体成员对齐到自己成员中最大对齐数的整数倍处,并且占据单独计算自身大小那么大的空间 (因为内存对齐浪费的空间不会被下一个成员利用)。结构体总大小就是所有对齐数 (包括嵌套结构体中的成员) 中最大对齐数的整数倍

# offsetof
// 定义于头文件 <stddef.h> 
#define offsetof(type, member) /*implementation-defined*/

在 stddef.h 头文件中有一个宏 offsetof, 可以返回一个成员在结构体中的偏移量

其第一个参数是结构体类型名,第二个参数是成员变量名

其返回值可以用 % zd、% zu 接收

# 内存对齐练习
#include <stdio.h>
 
struct S1
{
	char c1;
	int i;
	char c2;
};
 
struct S2
{
	char c1;
	char c2;
	int i;
};
 
struct S3
{
	double d;
	char c;
	char i;
};
 
struct S4
{
	char c1;
	struct S3 s3;
	char d;
};
 
int main()
{
	printf("%d\n", sizeof(struct S1));
	printf("%d\n", sizeof(struct S2));
	printf("%d\n", sizeof(struct S3)); 
	printf("%d\n", sizeof(struct S4));
	return 0;
}

在 Win11 VS2022 x64 Debug 的环境下,4 段输出为 12、8、16、32

# 为什么要内存对齐

其实这是一种空间换时间的做法

# 平台原因

不是所有的硬件平台都能访问任意地址上的任意数据, 某些平台只能在某些地址处 (对齐的位置) 取对应类型的数据,否则硬件异常。

# 性能原因

内存对齐的情况下访问速度一般会更快

访问未对齐内存的数据,处理器可能需要作两次内存访问 (内存是一段一段访问的,数据不对齐可能存放在两个内存的访问段内), 而对齐的内存访问仅需要一次访问

# 书写规范

所以为了节省空间,我们创建结构体应该尽量使较小的成员变量在前面,较大的放在后面

# 修改默认对齐数

我们可以使用预处理指令 #pragma 来修改编译器默认的对齐数

#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;
}

上面这段代码的输出结果就为 6

# 结构体传参

与其他数据类型类似,结构体传参也分为直接传参与传地址两种

#include <stdio.h>
 
struct S
{
	int data[3];
	int num;
} s = { {1,2,3}, 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;
}

两种方式作用相同,但是我们优先使用传地址的方式

因为函数传参时需要拷贝实参作为形参压栈,如果传递结构体本身会占用较多的内存

# 位段 (位域)

# 位段的概念

位段是一种特殊的结构体类型,其成员的内存宽度可以被我们规定

位段成员必须是 int、signed、unsigned 之间的一种 (C99 以前)

C99 标准开始,位段成员也可以使用布尔类型

# 位段的声明

位段的声明与结构体类似,但是成员名 (可以省略代表直接浪费一段空间) 后有一个冒号和数字:

#include <stdio.h>
 
struct A
{
	int _a : 2;
	signed _b : 5;
	unsigned _c : 10;
	int _d : 30;
};
 
int main()
{
	printf("%d\n", sizeof(struct A));
	return 0;
}

这段程序在 Win11 VS2022 x64 Debug 的环境下的输出结果为 8

为什么呢?这与位段的内存分配有关

# 位段的内存分配

位段声明中冒号后面的数字就代表了其被规定占据多少个比特位

而整个位段总大小是按 4 个字节 (int 类) 或者 1 个字节 (_Bool) 逐步分配

上述代码中位段 A, 内存申请了一次 4 个字节 (32 位)。这 32 位填充了_a、_b、_c 后只剩下 15 位了,发现不够继续填充_d, 就再次申请了 4 个字节用来填充数据_d, 所以总共占据了 8 个字节。

# 位段的特殊声明

相邻的几段如果类型占据的空间大小一致可以打包起来写在一起 (通常可以), 比方说:

struct B
{
	int _a : 2, _b : 5,  _c : 10;
	int _d : 30;
};    // 宽 8

规定空间可以省略,代表占据一整个类型的空间,比方说:

struct B
{
	int _a : 2, _b : 5,  _c : 10;
	int _d;
};    // 宽 8

这里_d 就占据了整个 4 字节 (32 位) 的空间

成员名可以省略用来占据一定不被使用的空间:

struct C
{
	unsigned _a : 2;
	signed _b : 5;
	int _c : 30, _d : 1, _e : 3;
	int _f : 3, :2, _g : 4;
};    // 宽 12

这里_f 和_g 直接有两个比特是被占位的

如果宽度规定为 0 (即零位域,必须未命名) 代表直接开始下一个分配单元,这边剩下来的丢掉:

struct D
{
	unsigned _a : 2, :0;
	signed _b : 5, :0;
	int _c : 30, _d : 1, _e : 3;
	int _f : 3, : 2, _g : 4;
};    // 宽 16
# 位段的跨平台性

位段跨平台性很差,原因如下:

  • int 位段被当做有符号还是无符号是不确定的
  • 机器的位数不一样导致类型宽度不一样
  • 位段在每个分配单元中数据从左往右填还是从右往左填不确定
  • 还有很多其他原因

所以,虽然位段很省空间,没事还是不要用位段

# 位段注意事项

位段不能取地址,不能有指针变量,会报错

因为位段的成员的起始位置可以不在整字节处,没有地址