链式结构需要继续访问先前操作建立的对象。复制 pointer 只保留地址,并不会复制对象,也不会延长对象的生命周期。后续遍历能否到达有效节点、是否丢失部分结构、是否访问已经释放的存储,都取决于这些区别。
C 把这些选择明确地写在程序中:pointer 连接对象,malloc 取得可以在函数返回后继续存活的存储,struct 则把操作必须共同维护的字段组织起来。
ADT 可以向调用者隐藏这些字段,但其实现仍须在每次更新后维持正确状态,并明确存储的所有权。
Pointer 保存的是地址,不是值
定义 0.1.1
(Pointer)
Pointer 变量保存的是某个对象的地址,而不是该对象本身的值。
下面两个运算符区分对象与地址:
&x表示“x的地址”;*p表示“pointerp所指向地址中的值”。
这两个概念一定不能混淆。Pointer 不是 object 本身,而是 object 所在位置的记录。
例题 0.1.2
(逐行读一个 pointer trace)
考虑以下赋值过程:
int firstvalue = 5, secondvalue = 15;
int *p1, *p2;
p1 = &firstvalue;
p2 = &secondvalue;
*p1 = 10;
*p2 = *p1;
p1 = p2;
*p1 = 20;
应该逐行理解:
p1先指向firstvalue,p2指向secondvalue;*p1 = 10把firstvalue改成10;*p2 = *p1把10复制到secondvalue;p1 = p2并不是复制整数,而是把p1重新指向secondvalue;*p1 = 20现在就会改变secondvalue。
最后得到:
firstvalue = 10secondvalue = 20
追踪一条 pointer 状态序列
逐条执行赋值。留意哪些指令改变保存的值,哪些改变地址。
设定int firstvalue = 5; int secondvalue = 15; int *p1, *p2;
- p1 = &firstvalue;
- p2 = &secondvalue;
- *p1 = 10;
- *p2 = *p1;
- p1 = p2;
- *p1 = 20;
两个整数已存在,但两个 pointer 仍未持有合法地址。
- 这一条
- int firstvalue = 5; int secondvalue = 15; int *p1, *p2;
- 改变
- —
- *p1
- 未定义(p1 未初始化)
- *p2
- 未定义(p2 未初始化)
未定:p1 与 p2 保存同一个地址
p1 与 p2 仍未初始化,暂时没有地址可比较。
要留意*p2 = *p1; 复制的是整数值,p1 = p2; 复制的是地址;只有后者令两个 pointer 指向同一个变量。地址以名称表示(&secondvalue);实际的数字地址视乎机器,这里不显示。
读 pointer trace 时,最好一直把两件事分开:
- 每个 pointer 现在指向哪里?
- 那个地址里的值现在是多少?
Dereference 是经地址去读或写
当 pointer 已经持有合法地址之后,dereference 才有意义。
int x = 7;
int *p = &x;
*p = 12;
最后 x 会变成 12。*p = 12 不是建立一个新整数,而是经 p 所记录的地址,直接写入原来的对象。
常见错误
(把 pointer 赋值与经 pointer 赋值混淆)
p = q 会改变 p 保存的地址。
*p = *q 会把 q 指向位置中的值复制到 p 指向的位置。
两句看起来很像,但作用完全不同。
局部对象与动态分配对象有不同的生命周期
地址只有在该地址中的对象仍然存活时才有意义。在代码块内声明的局部对象通常具有 automatic storage duration;执行离开该代码块后,对象的生命周期便结束。因此,返回局部变量的地址,只会留下一个仍然记得旧位置、却不再指向存活对象的 pointer。
int *bad_address(void) {
int local = 7;
return &local; /* 函数返回后立即成为 dangling pointer */
}
由 malloc 得到的空间具有 allocated storage duration。即使建立它的函数已经返回,这块空间仍然存在,直至程序把该次 allocation 交给 free。这种较长的生命周期,让 linked structure 可以保留先前操作建立的节点。但它并不表示节点可以永久使用:free(x) 之后,x 及所有保存同一地址的 alias
都成为 dangling pointer。Scope 回答“这个 pointer 变量可以在哪里被命名”,
lifetime 回答“它指向的对象现在是否仍存在”;两者不能混为一谈。
malloc 在 heap 上分配空间
本节用 malloc 在操作需要新节点时取得存储空间。这是一种实现选择;预先准备的节点也可以使用,前提是容量和生命周期满足操作约定。
定义 0.1.3
(用 malloc 做 heap allocation)
malloc(n) 会向 runtime 请求 n bytes 的空间,并返回该区块起始位置的
pointer;如果请求失败,则返回 null pointer。新取得的 bytes 不会自动成为已初始化的结构字段。
典型写法是:
struct node *x = malloc(sizeof *x);
if (x == NULL) {
/* 报告 allocation failure,或把失败传给调用者 */
}
在成功路径上,x 指向一段足以容纳一个 struct node 的新空间。
sizeof *x 直接跟随 x 的声明类型,不必重复写类型名称。在 C 中,
<stdlib.h> 声明 malloc,其返回的 void * 不需要强制转换。
但要记住三件事:
- dereference 之前先检查
x != NULL; - 在其他代码读取字段之前,逐一初始化所需字段;
- 明确哪一部分程序拥有该节点,并负责最终释放它。
例题 0.1.4
(linked structure 节点的存储与生命周期)
假设 stack 用 linked list 表示。每次 push 都可能要建立一个在操作返回后仍然存活的新节点。本例用 malloc 获取具有这种生命周期的存储。在 push
内声明的自动局部节点对象不能活过它所属的代码块;也可以使用静态节点池,或由调用者提供、生命周期涵盖每次使用的节点。必要条件是节点在整个使用期间都有有效存储,而不是必须调用 malloc。完整的 push 还需要失败路径、字段初始化与 ownership 规则;仅仅分配空间并没有完成这次操作。
Ownership 让 allocation 成为完整流程
面对每一次 allocation,都应该问三个问题:谁建立它,哪些 pointer 只是暂时借用访问权,最后由谁释放它?Owner 负责恰好一次结束该 allocation。 Borrowed pointer 可以在 owner 的规则下读写存活对象,却不能比对象活得更久,也不能在没有转移 ownership 的情况下自行释放对象。
如果最后一个可用 pointer 被覆盖,仍然存活的 allocation 会变得不可到达,形成 memory leak。如果 owner 已经释放对象,其他 alias 再访问它便是 use-after-free。如果两条路径都误以为自己是 owner,则可能发生 double free。 C 不会自动阻止这些错误,因此 ADT 实现必须让每个操作的 ownership contract 保持清楚。
typedef 让接口更易读
Tutorial 也复习 typedef,因为 ADT 接口经常要用简短名称去遮蔽又长又底层的 pointer type。
typedef struct node *nodePtr;
typedef int stackElementT;
typedef 不会建立新的 runtime 对象;它只会建立新的类型名称。好处是:
- function prototype 更短;
- header file 更容易扫读;
- ADT 边界更清楚。
当课程写 stackADT 时,这个名字本身就是 abstraction 的一部分。它要求
client 把这个对象理解为“一个 stack”,而不是“某个具体 struct 的 pointer”。
不过 type alias 本身不会令表示自动变成私有。真正的 representation hiding
需要在 interface 中只声明不完整的结构类型,再把字段定义放进 implementation
file。typedef 为 client 提供易读的 handle,而 incomplete type 才会阻止
client 直接访问内部字段。
struct 把相关字段组成一个 record
定义 0.1.5
(Structure)
struct 可以把多个字段组成同一个 record type。
例如:
struct node {
int data;
struct node *next;
};
这就是 linked-list node 的典型形状:一个字段放 payload,一个字段记录下一个 node 的地址。
这个定义可以 self-reference,是因为节点并没有直接包含另一个完整节点。
Compiler 读取 struct node 期间,已经可以用该 tag 声明
struct node *next,因为 object pointer 的大小是已知的。如果改成
struct node next;,每个 node 都要内含另一个完整 node,类型大小便永远无法确定。
对 node pointer p 而言,p->next 等价于 (*p).next。更新这个字段,是在
linked structure 的 memory graph 中改动一条边,而不是搬动 node 本身;改变的是从 p 出发,下一步可以到达哪个 node。
重点不只是语法上的分组,而是它让你能精确建模数据结构需要维持的状态。
例题 0.1.6
(Queue node 怎样支持 queue 操作)
若 queue 使用 linked node,常见写法是
struct cellT {
queueElementT element;
struct cellT *next;
};
而 queue 对象本身通常还会保存:
headpointertailpointer- 可能还有长度字段
所以 enqueue、dequeue 与 QueueLength() 等 ADT 操作,实际上就是在更新少量 pointer 字段。非空 queue 的 enqueue 应先执行
tail->next = fresh,再执行 tail = fresh;空 queue 则要让 head 与
tail 同时指向 fresh。Dequeue 必须在释放旧 head 前先保存
head->next,然后把所保存的 pointer 装成新 head。若移除的是最后一个
node,还必须令 tail = NULL。更新次序属于 correctness,而不只是代码风格。
定理 / 命题
定理 0.1.7
(存活对象的 dereference 边界)
只有当 p 指向一个类型相容、仍在生命周期内而且允许执行该次访问的对象时,经 *p 或 p->field 读写才是有效的。Null、indeterminate、指向数组末端后一个位置的 pointer,以及 dangling pointer 都没有跨过这条边界,不能被
dereference。
定理 0.1.8
(Linked-node 可到达性与更新次序不变量)
假设 linked ADT 恰好拥有从指定 root pointer 可到达的节点。一次 mutation 要保持 ownership,就必须确保所有应继续留在结构中的节点,在旧 link 被覆盖或旧 node 被释放时仍然可到达。因此,破坏旧路径之前必须先保存所需 successor,并先装上维持可到达性所需的新 link。
证明思路
第一个命题来自 dereference 的含义:它要访问 pointer 所指明的对象。若没有一个类型相容且仍存活的对象可供访问,C 的执行模型便没有合法目标,该操作是
undefined behavior。函数返回或 free 之后,即使 pointer 仍保存旧地址的数值,也不会延长原对象的生命周期。
第二个命题可把每个 node 看成 vertex,把每个 pointer field 看成 directed
edge。覆盖通往某段仍需保留的 sublist 的唯一 edge,会使该 sublist 不可到达;在 free(old) 之后才读取 old->next,则违反第一个命题。因此 dequeue 要先保存 successor 再释放旧 head;enqueue 要先接上 fresh node 再推进 tail。空 queue 与单节点 queue 还要单独更新 root,因为这时 head 与 tail 指向同一
node。
这些工具怎样直接连到 ADT 设计
课堂的 C 语言 review 与 ADT lecture 不是两条分开的线。
- Pointer 让一个 object 可以指向另一个 object;
malloc让节点可以按操作需要动态建立;struct让多个相关字段可以被当成一个整体维护;typedef让 ADT 接口可以把实现细节藏起来。
所以当课程说 ADT 要暴露 “what” 而隐藏 “how” 时,上面几样工具正是 C 里实现这种分离的方法。
常见错误
常见错误
(未初始化的 pointer 不是合法对象)
写 int *p; 只表示建立一个 pointer 变量,并不表示它已经指向安全空间。在赋值之前就去 dereference,属于未定义行为。
常见错误
(malloc 不会替你建好节点内容)
malloc 只提供 raw storage。节点字段仍然要由你自己逐一初始化。
常见错误
(两个 pointer 可以看到同一个 object)
若两个 pointer 指向同一块内存,经其中一个 pointer 写入数据,另一个 pointer 之后读到的也会是更新后的结果。
常见错误
(NULL 是 sentinel,不是 object)
测试 p == NULL 是安全的;当 p == NULL 时求值 *p 或 p->field 则不安全。Linked structure 可以用 NULL 表示末端,正因为那里没有 node 可供访问。
常见错误
(经一个 alias 释放,会令所有 alias 失效)
free(p) 后把 p = NULL,只能防止经这一个变量再次误用;其他保存相同地址的 pointer 不会随之改变。它们仍是 dangling pointer,不能再被 dereference,也不能再次交给 free。
快速检查
快速检查
(p = q 与 *p = *q 有什么分别?)
用地址与值来回答。
解答 · 答案
p = q 会复制地址到 p;*p = *q 会把 q 指向位置中的值复制到 p
所指向的位置。
快速检查
(为什么 malloc(sizeof(struct node)) 比手写 byte 数更可靠?)
想想结构字段如果之后变化会发生什么。
解答 · 答案
因为 sizeof(struct node) 会自动跟随真正结构大小。如果字段之后改动,
allocation 大小仍然正确。
一个更完整的 struct trace
下面的 record 包含两个固定大小的字符串 buffer 与一个整数字段。读取流程先检查 allocation、文件打开和转换结果,再使用 record,最后释放取得的资源。
typedef struct Student_Data {
char name[50];
int age;
char address[50];
} Sdata;
Sdata *p = malloc(sizeof *p);
if (p == NULL) return EXIT_FAILURE;
FILE *fp = fopen("example.txt", "r");
if (fp == NULL) {
free(p);
return EXIT_FAILURE;
}
int fields = fscanf(fp, "%49s %d %49s",
p->name, &p->age, p->address);
fclose(fp);
if (fields != 3) {
free(p);
return EXIT_FAILURE;
}
/* 使用已经初始化的 record */
free(p);
p = NULL;
可以把 trace 拆成四个问题:
p指向哪里?p->name和p->address是写入哪个 buffer?&p->age为什么要加 address?- 读完之后谁负责清理?
Allocation 成功后,p 指向一个存活的 Sdata object。两个 array field
传给 fscanf 时会提供首元素 pointer;整数转换则需要 &p->age。Width
limit 保护各有 50 个元素的 array,不让过长 token 越界;返回值确认三个
conversion 都成功。这个 format 读取三个以 whitespace 分隔的 token,并不适合含空格的完整地址。fclose 释放 file resource,free 结束动态对象的
lifetime。这样阅读,才是把语法放进同一个 ownership-and-state trace,而不是逐句死记。
总结
- Pointer 保存地址;dereference 会在类型与访问规则允许时,读写该地址中的存活对象。
- Automatic local object 在离开代码块后消失;dynamic object 存活到
free。变量仍在 scope 内或地址数值仍被保存,都不会延长 object lifetime。 - 每次 allocation 都需要检查失败、初始化字段、指定 owner,并且最终恰好释放一次。
- Self-referential node 用 pointer 保存 link;linked structure 的操作是 memory graph 更新,次序必须维持从 ADT root 出发的可到达性。
typedef改善 interface 用词;真正隐藏表示的是 incomplete type 与分开的 implementation。
练习
快速检查
(追踪这段 code 的最后结果:int x=1,y=2; int *p=&x; int *q=&y; *p=*q; q=p; *q=9;)
先分清楚值复制与地址重定向。
解答 · 引导解答
*p=*q 先令 x 变成 2。之后 q=p 令 q 也指向 x。最后 *q=9
是经 q 改写 x,所以最终 x=9、y=2。
快速检查
(解释为什么 linked-list node 几乎一定要有指向下一个 node 的 pointer。)
把答案连到 traversal 与动态增长。
解答 · 引导解答
如果没有 next pointer,一个 node 就无法连到下一个 node,list 也无法沿着链结逐步走访。动态增加 node 时,也会失去把新 node 接到既有 structure
的方法。