第 12 章 · 指针与结构体

如果说前十章的指针是「单兵作战」,这一章开始它们有了编制:结构体把多个变量捆成一个对象,指针把对象串成链表、树、图,函数指针把行为挂到对象上。C 的整个数据结构世界、乃至「面向对象」的雏形,都从这三个零件长出来。原文小节编号错成 11.x,一并修正。

12.1 -> :先解引用再取成员的糖

struct Point { int x, y; };
struct Point pt = {3, 4};
struct Point *p = &pt;

p->x = 30;            // 等价 (*p).x = 30

. 用于结构体变量,-> 用于结构体指针,后者是 (*p).x 的简写。机器层一目了然(计组课第 10 章):p->x 就是 [p + offsetof(Point,x)],成员偏移编译期定死,一次地址运算,没有任何运行时查表。这是 C 结构体访问快过 Python 属性访问一两个数量级的原因:一个查偏移,一个查字典。

12.2 传值还是传指针:结构体的两条铁律

第 10 章讲过值传递,对结构体它意味着整块复印:

struct Big { char data[1024 * 1024]; };

void by_value(struct Big b) { ... }          // 拷 1MB,惨案
void by_ptr(struct Big *b) { ... }           // 8 字节门牌
void by_const(const struct Big *b) { ... }   // 8 字节 + 只读承诺

铁律两条:大结构体永远传指针;只读场景永远加 const。例外是小结构体(两三个 int)按值传反而更快(不用二次解引用),struct Point 这类「值语义」的小对象按值传是正常写法。分界线大致在「超过两三个机器字」,拿不准就传 const 指针。

12.3 自引用结构:链表长出来了

结构体里放「指向自己类型的指针」,数据结构课的世界从这一行代码开始:

struct Node {
    int data;
    struct Node *next;      // 自引用:合法(指针大小已知,不需要完整类型)
};

配套的操作把前面几章的技能全部串联:malloc 造节点(第 9 章)、二级指针改头(第 7 章)、遍历释放(第 6 章的纪律):

void push(struct Node **head, int v) {   // 传 &head:要改指针本身
    struct Node *n = malloc(sizeof *n);
    n->data = v;
    n->next = *head;
    *head = n;
}

void destroy(struct Node *head) {        // 先存 next 再 free(第 9 章的纪律)
    while (head) {
        struct Node *tmp = head;
        head = head->next;
        free(tmp);
    }
}

「不能直接放自身 struct Node next;」值得想一下为什么:完整类型的大小未定(套娃无限),而指针大小固定 8 字节,所以自引用只能是自引用指针。数据结构课第 2、5 章的链表、树,全是这个模式的直接复用。

12.4 对齐与填充:结构体的真实体积

结构体成员按声明顺序排,但每个成员要对齐到自身大小(的倍数)的地址,中间会垫字节:

struct S { char c; int i; short s; };
// 期望 1+4+2 = 7,实际 sizeof = 12
// c 占 1,垫 3;i 占 4(对齐 4 的倍数);s 占 2;尾部再垫 2(整个结构体按最大对齐 4 收尾)

用 offsetof(struct S, i) 可查每个成员的偏移。工程结论:成员按大小降序排列能省填充(int i; short s; char c; 只有 8 字节,省了 4);序列化、跨进程共享内存、文件格式这些「按字节抠」的场景,offsetof 和对齐是日常工具。数据结构课第 12 章的「指针税」表里,每个结构的真实体积都是 sizeof 说话,不是成员相加。

12.5 函数指针成员:面向对象的雏形

把数据和「操作它的函数」捆在一起,就是 C 版的类:

struct Vec {
    int *data;
    int  size;
    int  (*get)(struct Vec *, int);
    void (*set)(struct Vec *, int, int);
};

int vec_get(struct Vec *v, int i) { return v->data[i]; }
void vec_set(struct Vec *v, int i, int x) { v->data[i] = x; }

struct Vec v = { .data = NULL, .size = 0, .get = vec_get, .set = vec_set };
v.set(&v, 0, 42);      // 注意:C 没有 this,手动把 &v 传进去

那个手动传的 &v 正是 C++ 里 this 指针的真身(编译器自动帮你传),C++ 的虚函数表(vtable)也就是「把函数指针抽出来集中放一张表」。真实世界的 C 大型工程(Linux 内核的 file_operations、Linux 驱动模型、glib 的 GObject)全是这套写法,你以后读内核代码时看到结构体里成片的函数指针,那就是「方法表」。

常见错误

  • . 和 -> 用错。指针用 . 编译不过,值用 -> 同样不过;但typedef 后类型一眼难辨时容易写反,报错信息会救你。
  • typedef 匿名结构体后自引用写错。typedef struct { struct Node *next; } Node; 里 next 的类型名还没定义,要么写 struct Node *next 保留标签,要么前向声明。这个编译错误的信息经常让人摸不着头脑。
  • 对齐假设写进序列化代码。直接把结构体整体 fwrite 到文件,不同编译器/平台的填充不同,读回来可能错位;序列化要逐成员写,或显式 __attribute__((packed)) 并承担跨平台风险。

练习

  1. 动手:验证 12.4:声明三种成员顺序的等价结构体,打印 sizeof 和 offsetof,亲手数出填充字节;再按降序重排,验证瘦身。
  2. 实现:用 12.3 的链表节点实现「栈」(push/pop 都在头部),配 destroy;用 ASan 跑一轮 push/pop/destroy,确认零泄漏。
  3. 动手:给 12.5 的 Vec 补一个 void (*print)(struct Vec *) 方法,再做一个 struct VecV2(数据成员不同),验证「同一套函数指针表 + 不同数据结构」的玩法,体会它与 C++ 多态的异同。
  4. 思考:为什么 C 的 -> 要设计成显式操作(每次都看得见解引用),而 C++ 的 obj.method() 把 this 的传递藏起来?结合第 10 章「复印件世界」的模型,说说显式传 &v 对理解「对象是谁的」有什么帮助。