3.9 异构数据结构(Heterogeneous Data Structures)
3.9.1 结构(struct)
C 语言的 struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。
结构的所有组成部分都存放在内存中一段连续的区域(不同字段之间由于字节对齐的要求,可能存在一定长度的“间隙”、和末尾的“填充”——不被使用的内存字节);
指向结构的指针就是结构第一个字节的地址。
🌰
struct rec{ int i; int j; int a[2]; int *p; };
// 这个结构包括4个字段,一共24个字节长度。
为了访问结构的不同字段,编译器产生的代码要讲结构的地址加上适当的偏移。
# 假设struct rec*类型的变量r放在寄存器%rdi中
## 将i复制到j
# Registers: r in %rdi
# 字段i的偏移量是0,访问j加上一个偏移量4
movl (%rdi), %eax # Get r->i
movl %eax, 4(%rdi) # Store in r->j
## 获得&(r->a[i])的值
# Registers: r in %rdi, i %rsi
leaq 8(%rdi,%rsi,4), %rax # Set %rax to &r->a[i]
## 实现代码:r->p = &r->a[r->i + r->j];
# Registers: r in %rdi
movl 4(%rdi), %eax # Get r->j
addl (%rdi), %eax # Add r->i
cltq # Extend to 8 bytes
leaq 8(%rdi,%rax,4), %rax # Compute &r->a[r->i + r->j]
movq %rax, 16(%rdi) # Store in r->p
【总结】结构的各个字段的选取完全是在编译时处理的。(机器代码不包含关于字段声明、名字的信息)
3.9.2 联合(union)
允许以多种类型来引用一个对象——联合的大小是它最大字段的大小。
指向联合的指针的表达式由多种,🌰设指针 p
是指向 union U3
的指针,那么表达式 p
、p->c
、p->i[0]
、p->v
引用的都是联合 U3 的起始位置。
union U3{ char c; int i[2]; double v; };
【应用一】联合的优点在于可以节省一部分的内存空间👇(尤其是字段很多的时候,节省空间的效果越明显)
🌰:实现一个二叉树的结点,每个结点都有两个 double 类型的数据值,还有两个孩子结点的指针。
/* 👇每个结点需要32个字节
当结点表示数据时,两个指针占据的内存就是一种浪费;
当结点表示指针时,两个double占据的内存也是一种浪费。*/
struct node_s {
struct node_s *left;
struct node_s *right;
double data[2];
};
/* 改进,用联合:👇每个结点只需要16个字节
如果n是一个指针,指向union node_u*类型的结点,
可以用n->data[0]和n->data[1]来引用叶子结点的数据,
而用n->internal.left和n->internal.right来引用内部结点的孩子。*/
union node_u {
struct {
union node_u *left;
union node_u *right;
} internal;
double data[2];
};// 缺点:没有办法确定一个给定的结点到底是叶子结点还是内部数据。
/* 进一步改进,引入枚举:👇每个结点需要16+4+(+4)=24个字节(+4是字节对齐需要) */
typedef enum { N_LEAF, N_INTERNAL } nodetype_t; // 标签字段,类型int
struct node_t {
nodetype_t type;
union {
struct {
struct node_t *left;
struct node_t *right;
} internal;
double data[2];
} info;
};
【应用二】可以用来访问不同数据类型的位模式
🌰使用简单的强制类型转换:将一个 double 类型的值 d 转换为 unsigned long 类型的值 u:
double d;
unsigned long u = (unsigned long) d;
结果:
值 u 是 d 的整数表示,且 u 和 d 具有一样的位级表示,包括符号位字段、指数和尾数。
(d 的值等于 0.0 时除外)
【应用三】判断字节顺序
可以用 union 来判断字节顺序,是大端的还是小端的。
double uu2double(unsigned word0, unsigned word1){
union{
double d;
unsigned u[2];
} temp;
temp.u[0] = word0;
temp.u[1] = word1;
return temp.d;
}
在小端法机器上,参数 word0
是 d
的低位 4 个字节,参数 word1
是 d
的高位 4 个字节。大端法机器上则相反。
3.9.3 数据对齐
为啥需要数据对齐?
(1)可移植性:某些型号的 Intel 和 AMD 处理器对于有些实现多媒体操作的 SSE 指令,如果数据没对齐,就无法正确执行——导致异常。
(2)提升性能:CPU 每次读取内存时是以“块”为单位的,如果数据不对齐,目标对象可能就跨越了两个内存块,CPU 要把两个块都读进来,再去掉无关字节,再拼接起来,这降低了 CPU 的读取数据的效率。
对齐原则:任何 K 个字节的基本对象的地址必须是 K 的倍数。
K | 类型 |
---|---|
1 | char |
2 | short |
4 | int,float |
8 | long,double,char* |
16 | SSE 指令,大多数函数的栈帧边界, alloca、malloc、calloc、realloc 生成的块的起始地址 |
编译器在汇编代码中放入命令来指明全局数据所需的对齐:.align 8
,这保证了其后面的数据的起始地址是 8 的倍数。
数据对齐,会在字段之间产生了一些“间隙”、“末尾填充”,这些是未被使用的空间(也估计是不会被使用的空间)。
🌰计算结构体的字节总量:
struct{
char *a; short b; double c; char c; float e; char f; long g; int h;
} rec;
字节总量
=
[
8
]
+
[
2
+
6
]
+
[
8
]
+
[
1
+
4
+
1
+
(
2
)
]
+
[
8
]
+
[
4
+
(
4
)
]
=
56
=[8]+[2+6]+[8]+[1+4+1+(2)]+[8]+[4+(4)]=56
=[8]+[2+6]+[8]+[1+4+1+(2)]+[8]+[4+(4)]=56,其中圆括号里的是数据对齐产生的“间隙”和“末尾填充”。
重新排列,最小化使用空间总量:
可能的排列之一:
struct{
char *a; double c; long g; int h; float e; short b; char c; char f;
} rec;
最小的字节总量 = [ 8 ] + [ 8 ] + [ 8 ] + [ 4 + 4 ] + [ 2 + 1 + 1 + ( 4 ) ] = 40 =[8]+[8]+[8]+[4+4]+[2+1+1+(4)]=40 =[8]+[8]+[8]+[4+4]+[2+1+1+(4)]=40。