Evanalysis
0.1预计阅读时间: 17 分钟

0.1 Pointer、内存与 struct

回顾资料结构课反复会用到的 C 工具:address、dereference、malloc、typedef 与 struct 布局。

课程目录

CSCI2520 虽然不要求你只能用 C 作答,但课堂确实经常用 C 去解释资料结构。原 因很直接:pointer、heap allocation 与 data layout 可以把资料结构在内存 里的行为直接展示出来。

这一节不是一般程序语言教程,而是为了让你后面读 stack、queue、hash table 实现时,不会因为看不懂 C 而失去整个概念。

动机

本地 tutorial 材料强调:考试与书面作业并不限制你一定使用某种语言。但 lecture 与 tutorial 仍然用 C 去说明:

  • pointer 实际保存什么;
  • 节点怎样用 malloc 建立;
  • struct 怎样把字段组织成一个 record;
  • typedef 怎样协助 ADT 接口隐藏底层表示。

如果这几样读错,问题不只是 syntax,而是你会看不见资料结构究竟怎样在内存 里运作。

Pointer 保存的是地址,不是值

定义

Pointer

Pointer 变量保存的是某个对象的地址,而不是该对象本身的值。

Tutorial 1 特别区分两个符号:

  • &x 表示“x 的地址”;
  • *p 表示“pointer p 所指向地址中的值”。

这两个概念一定不能混淆。Pointer 不是 object 本身,而是 object 所在位置 的记录。

例题

逐行读一个 pointer trace

考虑 tutorial 里的例子:

int firstvalue = 5, secondvalue = 15;
int *p1, *p2;

p1 = &firstvalue;
p2 = &secondvalue;
*p1 = 10;
*p2 = *p1;
p1 = p2;
*p1 = 20;

应该逐行理解:

  1. p1 先指向 firstvalue,p2 指向 secondvalue;
  2. *p1 = 10 把 firstvalue 改成 10;
  3. *p2 = *p1 把 10 复制到 secondvalue;
  4. p1 = p2 并不是复制整数,而是把 p1 重新指向 secondvalue;
  5. *p1 = 20 现在就会改变 secondvalue。

最后得到:

  • firstvalue = 10
  • secondvalue = 20

边读边试

追踪一条 pointer 状态序列

这个 tracer 让你改动初始整数,再逐步重播 pointer tutorial 的状态变化。

步骤 1

int firstvalue = ...; int secondvalue = ...; int *p1, *p2;

firstvalue = 5

secondvalue = 15

p1 指向 unassigned

p2 指向 unassigned

两个整数已存在,但两个 pointer 仍未持有合法地址。

读 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。

定义

用 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;
  • 在其他代码读取字段之前,逐一初始化所需字段;
  • 明确哪一部分程序拥有该节点,并负责最终释放它。

例题

为什么 linked structure 离不开 malloc

假设 stack 用 linked list 表示。每次 push,都可能要建立一个新节点。因 为节点数量事前未知,固定 local variable 根本不够,必须靠 malloc 逐个 向 heap 申请新节点。不过完整的 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

定义

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。

重点不只是语法上的分组,而是它让你能精确建模资料结构需要维持的状态。

例题

Queue node 怎样支持 queue 操作

若 queue 使用 linked node,常见写法是

struct cellT {
   queueElementT element;
   struct cellT *next;
};

而 queue 对象本身通常还会保存:

  • head pointer
  • tail pointer
  • 可能还有长度字段

所以 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,而不只是代码风格。

定理 / 命题

定理

存活对象的 dereference 边界

只有当 p 指向一个类型相容、仍在生命周期内而且允许执行该次访问的对象时, 经 *p 或 p->field 读写才是有效的。Null、indeterminate、指向数组末端后 一个位置的 pointer,以及 dangling pointer 都没有跨过这条边界,不能被 dereference。

定理

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

student 例子值得再慢读一次,因为它同时牵涉 pointer、struct、file input 和 ownership。把成功与清理边界都写出来后,流程会更完整:

先看这几行:

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 拆成四个问题:

  1. p 指向哪里?
  2. p->name 和 p->address 是写入哪个 buffer?
  3. &p->age 为什么要加 address?
  4. 读完之后谁负责清理?

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 的方法。

练习

先自行作答,再检查答案。你可以修改后重试。

加载中…

先备知识

这一节可以独立阅读。

本单元重点词汇