C语言结构体相互引用为什么能工作

1
2
3
4
5
6
7
8
9
typedef struct UIBase {
	struct Page* parent;
	// 其他字段
} UIBase;

typedef struct Page {
	UIBase base;
	// 其他字段
} Page;

这段代码涉及了结构体的相互引用,为什么能通过编译并工作呢?

编译器是从上到下逐行扫描代码的,我们来看看当它遇到这两个结构体的时候发生了什么。

1
2
3
4
typedef struct UIBase {
	struct Page* parent;
	// 其他字段
} UIBase;

当编译器读到这里,它需要知道 UIBase 的大小,第一个字段是个指针。 在 32 位系统上,指针永远是 4 字节;在 64 位系统上,指针永远是 8 字节。虽然编译器完全不知道 Page 结构体是什么样的,也不影响它计算出 UIBase 的大小,所以这一段是编译通过了。

1
2
3
4
typedef struct Page {
	UIBase base;
	// 其他字段
} Page;

当编译器读到这里,它同样需要知道 Page 的大小。因为 Page 包含了 UIBase(而不是 UIBase*),所以 UIBase 的大小也是需要确切知道的。 好在,UIBase 的定义此时已经被编译器阅读完毕了,所以 Page 的大小也是能被确切计算得到的。

不能工作的情况

1
2
3
4
5
6
7
struct A {
    struct B b; // A 包含 B 的实体
};

struct B {
    struct A a; // B 包含 A 的实体
};

要计算 A 的大小,就要先计算 B 的大小; 要计算 B 的大小,同样需要先计算 A 的大小。 这就变成了套娃。

再看一开始能工作的情况:一个是实体,另一个只是个指针。关键在于指针的大小是固定的,只要前面的声明使用的是后面声明类型的指针,编译就能通过。

为什么只需要知道类型大小,就能通过编译并运行?

这里涉及一个计算机科学的原理: 类型 (Type) 只是给编译器看的,对于 CPU 而言,一切只是内存块 (Memory Block) 和地址偏移 (Offset)。

当我们通过代码创建一个类型的实例,会通过编译器告诉 CPU 预留足够的空间: 假设 UIBase 占 60 Byte,编译器会生成指令 SP = SP - 60,这会在栈上预留 60 Byte 的空间。 这就结束了,CPU 并不关心这里面存储的到底是 Page* 还是别的什么类型的数据,CPU 只需要知道分配多少空间。

当我们使用这个变量,如 ui.isPage = true; ,CPU 也不需要知道这个字段代表什么,因为编译器告诉它的是: 请把这块内存区域(也就是前面分配的 60 Byte 内存块)中偏移量 xn 字节数据的内存改为 1 (true)。

也就是说,CPU 读写是通过基地址 + 偏移量完成的,是编译器让变量和成员有了程序员所理解的“意义”。CPU 并不关心这些意义,它只是按照设定的方式执行机器码。

假设我们执行了代码:

1
uibase.parent->x = 100;

这行代码通过一个 UIBase 变量访问了其中 Page* 指针所指向对象的一个 Page 的成员,但 CPU 其实并不关心什么是 UIBase 或者 Page,它只关心:地址固定的数字

CPU 先到 uibase 对象所在的内存地址,按照 parent 的偏移读取到了 parent 指向的地址,然后使用这个地址按照 x 的偏移获取到了最终需要操作的地址,最后把这个地址对应的内存块设置为代表了数值 100 的二进制数值。