如果说数组是同一种类型的数据的连续排列的数据组织形式,那么对于不同类型的数据来说,他们被有机组织起来的方式有两种,分别是struct
和union
。
Struct
C语言的 struct
创建一种数据类型,将可能不同的数据类型的对象聚合到一个对象中。结构体中各个组成部分由名字来引用。类似于数组的实现,结构体的所有组成部分都存放在存储器中的一段连续的区域内,指向结构体的指针就是结构体第一个字节的地址。编译器维护每个结构类型的信息,指示每个字段(field
)的字节偏移。以这些偏移作为存储器引用中指令的位移,从而产生对结构体元素的引用。
一个例子:
struct rec{
int i;
int j;
int a[2];
int *p;
};
那么在内存中的排列是:
![430d01f59a76dc7e6c4cc8029205b641.png](https://i-blog.csdnimg.cn/blog_migrate/c8f7110ab236938719212eee02875665.png)
对于struct中的成员,我们可以使用结构地址 + 偏移量
的方式来进行访问。例如指向结构体 rec
的指针变量 r
存放在寄存器 %rdi
中,下面的代码将元素 r->i
复制到 r->j
movl (%rdi), %eax # 将 r->i 放到寄存器中
movl %eax, 4(%rdi) # 将寄存器的值复制到 r->j 中
可以看到,为了访问字段 j,代码将 r 的地址加上偏移量 4。
Union
联合体提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用同一个对象。联合体的语法和结构体一样,但语义差别很大——联合体用不同的字段来引用相同的内存。
考虑下面的声明:
struct S{
char c;
int i[2];
double v;
}
union U{
char c;
int i[2];
double v;
}
在一台x86-64 Linux 机器上编译时,字段的偏移量、数据类型的完整大小如下:
![ced482d6513e647fa1bf2e733cb35293.png](https://i-blog.csdnimg.cn/blog_migrate/c03b8672d6564ba2996bb64898fc1699.png)
可以发现:
- 对于类型结构体 U 的指针 p,
p->c
、p->i[0]
、p->v
都是引用数据结构的起始位置。 - 一个联合体的总的大小总是等于它的最大字段的大小。
union
的作用在于节省内存空间。举一个例子:
现在我们要设计一个特殊的二叉树的数据结构,它有一个特点:每个叶子节点有两个 double 类型的值,但是指针不指向任何地方。每个内部节点有左右子节点的指针,但是没有数据。如果声明如下:
// 每个节点都需要 32 个字节
struct node_s {
struct node_s * left;
struct node_s * right;
double data[2];
};
如果用上述结构体,那么每个节点都需要 32 个字节。每个类型的节点都要浪费一半的内存。如果我们这样声明:
union node_u {
struct {
union node_u *left;
union node_u *right;
} internal;
double data[2];
};
那么每个节点所需要的内存大小就仅为internal
和double[2]
二者所占最大的大小了,也就是16字节:
- 当它是中间节点:我们使用
n->internal.left
或n->internal.right
来访问子节点 - 当它是叶节点时:我们使用
n->data[0]
和n->data[1]
来访问
然而这样表示,我们是没办法确定一个给定的节点到底是叶子节点,还是内部节点。有一个方法,是引入一个枚举类型,定义这个联合中可能的不同选择,再用一个结构体将两者包含起来:
typedef enum {LEAF, INTERNAL} nodetype;
struct node_t{
nodetype type;
union {
struct
{
union node_t *left;
union node_t *right;
} internal;
double data[2];
} info;
};
这个结构共需24个字节:
- type是4个字节(int值)
union info
是16个字节- 他们二者之间需要做填充,因此是 16 + 8 = 24个字节
但是从这个例子来说,使用union
是得不偿失的,因为它并没有节省多少空间,但是它在编码带来了不少麻烦。
对于那些有较多字段的数据结构,这种空间上的节省可能才是足够吸引人的。
数据对齐
对齐主要是为了提高内存的访问效率。
计算机系统对于基本的数据类型的合法地址做出了一定的限制,要求地址必须是某个值的倍数(例如4、8、16)。这种对齐限制简化了处理器系统和存储器系统之间的接口硬件设计。
CPU在读取数据时,会从偶地址开始读取一定位的内存数据,如果一个数据存放的地址不是从偶地址开始的,那么为了读取这个完整的数据,CPU需要读取两次。
计算机通过将部分内存“闲置”的方法,来提高内存的访问效率,这种做法就叫做数据对齐。
计算机字节对齐的规则是:
![8005be0f6f6d3be268c7d1b6f5b0babf.png](https://i-blog.csdnimg.cn/blog_migrate/e064063392487dd6188d6a2d791bb289.jpeg)
一个例子是:
struct S{
char c;
int i[2];
double v;
} *p;
![de5ea05738f66962528083b4ee2d850e.png](https://i-blog.csdnimg.cn/blog_migrate/e843368f3ed0dbf80e9e208e0733d5c9.png)
它的总大小是24字节。显然不等于 sizeOf(c) + sizeOf(i) + sizeOf(v) = 1 + 4*2 + 8 = 17
,如果是这样的话,它的内存排布则是十分紧凑的:
![4bc1a40a751a57c05aedd39587000fb9.png](https://i-blog.csdnimg.cn/blog_migrate/d4fdf1e4a6d9001cfa7e92154c640ade.png)
实际上,编译器使用了对齐的手段。它在x86-64位上内存中的排布方式是:
![e0154b3e64108c141319329b1a42a81f.png](https://i-blog.csdnimg.cn/blog_migrate/af9e24f0a490ab93d3a38f6aa819f009.png)
int
值必须在以4的倍数的地址处被存储double
值必须在以8的倍数的地址处被存储
那么对于
struct S{
double v;
int i[2];
char c;
} *p;
则为,它在结尾会以8字节,即以struct内的double的对齐方式进行对齐:
![8be7f57c5ce976fbd389265e44fda0e8.png](https://i-blog.csdnimg.cn/blog_migrate/11dfc2edbd8213a9d73f96e6d711ad31.png)
原因在于,它基于一个假设,即,如果此结构体构建一个数组,且我们可以保证地址是8的倍数的话,这个结构体数组的每个元素都是合理对齐的。