章节目录

语言:一组值,也可以成为一个值

本节阅读量:

第六章把 record 定义为不可变、可变长度的聚合值。它的字段数量写在创建表达式中:

1
2
3
4
5
expr := ...
      | (record expr ...)
      | (record? expr)
      | (size expr)
      | (get expr nonnegative-index)

... 表示零个或多个字段,因此下面三种写法都合法:

1
2
3
(record)
(record 40)
(record 10 20 30)

这里的 record 不是有字段名的 struct,也没有方法、继承或更新操作。字段只按从 0 开始的位置编号。

创建 record

求值 (record e0 e1 ... en) 时,语言依次完成:

  1. 在当前环境中求值 e0;
  2. 再求值 e1,依此类推;
  3. 所有字段都成功求值后,分配一个新 record;
  4. 把得到的 Value 按原顺序存入字段。

例如:

1
(record (+ 20 20) (sub1 3) (+ 1 2))

先得到 40,再得到 2,最后得到 3,结果显示为:

1
(record 40 2 3)

左到右顺序现在看似不重要,因为本章还没有可变状态;第八章加入 set! 后,这个顺序会直接影响结果。现在把语义固定下来,后续就不必改写 record。

若某个字段求值失败,后面的字段不会继续求值,也不会创建半成品对象。

空 record 是真正的对象

(record) 没有字段,但它不是数字 0,也不是一个全局共享的 nil。每次求值都创建一个新的零字段 record:

1
2
3
(record? (record))
(size (record))
(eq? (record) (record))

它们依次得到 1、0 和 0。最后一个结果为零,是因为两个 (record) 分别创建了两个对象。空 record 只是字段数为零的普通 record,因此对象模型不需要额外的“空值例外”。

若程序想把空 record 当作某种数据结构的结束标记,可以先把同一个对象绑定给变量,再传递这份引用。语言本身不把它预设成 Lisp 的唯一空表。

字段保存完整 Value

record 字段不限于整数:

1
2
3
(record
    (record 40 2)
    (lambda x (+ x 1)))

第一个字段保存另一个 record 的引用,第二个字段保存函数值。外层对象不会把内层对象展开复制,它们形成一张对象图:

1
2
3
outer record
├── field 0 ──> inner record
└── field 1 ──> function

例如可以连续读取:

1
(get (get (record 0 (record 40 2)) 1) 0)

外层 index 1 得到内层 record,再从内层 index 0 得到 40。

record?:安全地询问值的种类

record? 先把唯一的子表达式求值一次,再检查结果的种类:

1
2
3
4
(record? (record 1 2))
(record? (record))
(record? 42)
(record? (lambda x x))

结果依次是 1、1、0、0。

它不会因收到非 record 而报错,而是返回源语言整数 1 或 0。这也是统一 tagged value 的一个直接用途:生成代码先检查机器字低位是否为 heap-object tag;只有通过后才读取 header 并检查 record kind,不会把整数当地址解引用。

size:读取实际字段数

size 先求值对象表达式,确认结果是 record,再读取 header 中的 payload count:

1
2
3
(size (record))
(size (record 10))
(size (record 10 20 30))

结果依次是 0、1、3。

它只接受 record:

1
(size 42)

解释器会报告类型错误;编译后的程序会走统一失败出口,以状态 70 结束。size 返回的是普通源语言整数,因此可以继续参与算术和条件:

1
(+ (size (record 10 20 30)) 39)

结果是 42。

get:按实际长度安全读取

get 的求值规则是:

  1. 求值第一个子表达式,得到一个 Value;
  2. 检查它是否为 record;
  3. 从 record header 取得实际字段数;
  4. 检查 index < field_count;
  5. 返回对应字段中保存的完整 Value。

例如:

1
2
(get (record 10 20 30) 0)
(get (record 10 20 30) 2)

结果依次是 10、30。

get 的 index 必须直接写成非负整数字面量:

1
2
3
4
(get values 2)
(get values -1)
(get values index)
(get values (+ 1 1))

第一行是合法语法;其余三行分别使用负数、变量和表达式作为 index,都会被 parser 拒绝。

index 不是表达式,不会在运行时求值。把它保留为 AST 和 IR 中的整数元数据,能让本章集中讲动态对象长度,而不必同时引入动态索引计算。

语法合法不等于读取必然成功:

1
(get (record 10 20) 2)

这里 index 2 是合法的非负字面量,但对象只有两个字段。解释器报告越界错误;生成代码在真正访问内存前检查 header,并以状态 70 结束。它不会从对象后方读出随机机器字。

对非 record 执行 get 同样是动态类型错误:

1
(get 42 0)

record 是不可变的

本章只有创建和观察操作:

1
2
3
4
record    创建
record?   判断种类
size      观察长度
get       观察字段

没有 set-field!。一旦创建,字段数量和字段值都不会改变。把 record 绑定给另一个变量,只会复制引用:

1
2
3
(let p (record 40 2)
  (let q p
    (eq? p q)))

p 与 q 指向同一对象,所以结果是 1。

eq? 比较对象身份

整数仍按数值比较;record 按身份比较。字段序列相同的两个新对象并不相等:

1
(eq? (record 1 2) (record 1 2))

结果是 0。

包括空 record:

1
(eq? (record) (record))

结果同样是 0。

这种语义不会递归遍历字段,不会因嵌套结构变深而改变成本,也为后续共享、更新和垃圾回收保留稳定的对象身份。

函数仍不属于本章 eq? 支持的比较范围。把函数交给 eq? 会报错;record 字段能够保存函数,并不等于函数获得了相等语义。

record 在条件中为真

延续前文规则,只有整数 0 为假;非零整数、函数和所有 record 都为真。空 record 也是真值:

1
(if (record) 42 0)

结果是 42。

要判断 record 是否为空,应读取长度:

1
(eq? (size r) 0)

不要把空 record 与刚创建的 (record) 比较,因为身份比较会得到 0。

解释器的显示形式

mini run 使用与源码相近的格式显示 record:

1
2
3
(record)
(record 40 2)
(record (record) 42)

显示会递归格式化字段,便于观察对象内容;这不改变 eq? 的身份语义。两个对象可能显示完全相同,仍然互不相等。

为什么不再加入 vector、struct 或 class

可变长度 record 已经足以演示本书当前需要的能力:

  • 在堆上创建大小由程序决定的对象;
  • 把任意 Value 放进 payload;
  • 建立嵌套和共享的对象图;
  • 通过 header 保存 kind 与长度;
  • 安全地做类型判断和边界检查。

源语言不需要再增加 vector、struct 或 class。后文代码中的 std::vector 只是 C++ 实现容器,用来拥有数量不定的 AST 子树、Value 或 IR operand;它不是源语言新增的集合类型。

下一节会把这些语言规则翻译成机器表示。重点不再是“二字段对象占 24 字节”,而是一条适用于零个、两个或任意多个 payload 的统一布局公式。


6.0 record 与堆上的数据

上一节

6.2 值表示:一个机器字怎样引用不同的堆对象

下一节