CLASS 11 · 2026-03-11 · 11-13 递归与解析

递归数据类型

递归数据类型是把抽象类型表达为若干变体并集的定义方式,它天然适合用递归函数处理。不可变列表 ImList 是典范:空列表与首元素加剩余列表的配对,构成了既简洁又支持共享的数据结构。

递归:从函数到数据

在 6.102 前面的课程里,你已经写过递归函数——每个调用包含基准情形(base case)与递归情形(recursive case)。递归数据类型遵循同样的思想:一个类型的定义中会引用它自身。这种自引用让数据结构可以表达任意深度,比如任意长度的列表、任意嵌套的公式。

不可变列表的四种基本操作

我们定义泛型接口 ImList<Element>,它提供四个基本操作:

因为列表是不可变的(immutable),cons 不会修改原有列表,而是返回一个新列表。这使得不同列表可以安全地共享尾部,而不用担心被彼此修改。

interface ImList<E> {
  static empty<E>(): ImList<E>;
  cons(first: E): ImList<E>;
  first(): E;
  rest(): ImList<E>;
}

// 构造列表 [1, 2, 3]
const list = ImList.empty<number>().cons(3).cons(2).cons(1);
console.log(list.first()); // 1
console.log(list.rest().first()); // 2

数据类型定义

要让递归结构精确,我们引入一种规范写法。

数据类型定义(data type definition):把一个抽象类型表达为若干变体(variant)的并集,每个变体包含若干具名字段。

不可变列表的数据类型定义写作:

\[ \texttt{ImList}\langle\texttt{Element}\rangle \;=\; \texttt{Empty} \;+\; \texttt{Cons}(\textit{first}\!: \texttt{Element},\ \textit{rest}\!: \texttt{ImList}\langle\texttt{Element}\rangle) \]

它读作:一个 ImList 要么是 Empty,要么是一个 Cons,后者包含一个首元素和一个递归指向 ImList 的尾部。

每个变体都实现 ImList 接口。Empty.first() 会抛异常,而 Cons.first() 直接返回字段 first

class Empty<E> implements ImList<E> {
  cons(first: E): ImList<E> { return new Cons(first, this); }
  first(): E { throw new Error("empty list has no first"); }
  rest(): ImList<E> { throw new Error("empty list has no rest"); }
}

class Cons<E> implements ImList<E> {
  constructor(private readonly first: E, private readonly rest: ImList<E>) {}
  cons(first: E): ImList<E> { return new Cons(first, this); }
  first(): E { return this.first; }
  rest(): ImList<E> { return this.rest; }
}

解释器模式:在递归类型上定义函数

处理递归数据类型最自然的风格是解释器模式(interpreter pattern):在抽象接口上声明操作,然后在每个变体中给出递归实现。以 size() 为例:

// 在接口中声明
interface ImList<E> {
  size(): number;
}

// Empty:基准情形,大小为 0
class Empty<E> { size(): number { return 0; } }

// Cons:递归情形,1 加上尾部大小
class Cons<E> { size(): number { return 1 + this.rest.size(); } }

写这类函数时,只需遵循一个原则:每种变体对应一种情况,递归调用处理子结构。这和数据类型定义的并集结构一一对应。

引用独立性与表示泄露

一个抽象类型的规约(spec)绝不应该提及它的表示(rep)。如果客户端代码依赖于「列表底层是数组还是链表」,那么表示就被泄露了,修改实现就会破坏客户端。

表示泄露(rep exposure):当实现细节(私有字段、内部类、变体结构)被客户端可见或可依赖时,就发生了表示泄露。

防御手段包括:把字段设为 private readonly、返回防御性副本、以及用不可变对象避免外部修改。

用哨兵对象替代 null

很多语言用 null 表示「没有值」,但这会导致 NullPointerException 和到处出现的空检查。6.102 的做法是引入哨兵对象(sentinel object)——Empty 类。

因为 EmptyCons 都是 ImList 的合法实例,客户端可以用统一的方式处理它们,而不必先判断「是不是 null」。这正是多态的力量。

静态类型 vs. 动态类型

TypeScript 的变量有静态类型(编译时确定的声明类型),而它指向的对象有动态类型(运行时实际所属的类)。

const list: ImList<number> = ImList.empty<number>().cons(1);
//    ^ 静态类型 ImList<number>     ^ 动态类型实际是 Cons<number>

静态类型保证我们能调用 ImList 上声明的方法;动态类型决定实际执行的是 Empty 还是 Cons 的代码。

尽量避免 instanceof 做变体特判

instanceof 判断对象属于哪种变体,再分别处理,是一种反模式:

// 不推荐:客户端自己分派变体
if (list instanceof Empty) { return 0; }
else if (list instanceof Cons) { return 1 + list.rest().size(); }

// 推荐:把操作放进接口,让多态分派
list.size();

把行为放进变体类里,不仅代码更集中,未来新增操作也无需修改所有客户端。

不可变类型的相等性

对于不可变类型,我们定义 equalValue() 来判断两个值在语义上是否相等。注意它与 ===(引用相等)不同:两个不同的 Cons 对象只要字段相等,就应该被视为相等。

另一个例子:布尔公式

递归数据类型不只限于列表。布尔公式是另一个典型:

\[ \texttt{Formula} \;=\; \texttt{Variable}(\textit{name}\!: \texttt{String}) \;+\; \texttt{Not}(\textit{formula}\!: \texttt{Formula}) \;+\; \texttt{And}(\textit{left}\!: \texttt{Formula},\ \textit{right}\!: \texttt{Formula}) \;+\; \texttt{Or}(\textit{left}\!: \texttt{Formula},\ \textit{right}\!: \texttt{Formula}) \]

用这个类型,我们可以求值、化简、收集自由变量——全部用解释器模式递归实现。

回溯搜索与不可变性的优势

不可变列表天然适合回溯搜索:在探索一条路径时,我们不必复制整个列表,只需用 cons 把新选择加到前面;回退时直接丢弃当前引用,原来的列表仍然完整。这种结构共享既节省时间又节省空间。

关键要点

  • 定义数据类型定义把抽象类型写成变体的并集,每个变体可含递归字段。
  • 模式解释器模式:在接口声明操作,在每个变体递归实现。
  • 不可变不可变列表通过 cons / first / rest 操作,支持安全共享。
  • 防御用哨兵对象代替 null,避免表示泄露,尽量少用 instanceof