递归数据类型
递归数据类型是把抽象类型表达为若干变体并集的定义方式,它天然适合用递归函数处理。不可变列表 ImList 是典范:空列表与首元素加剩余列表的配对,构成了既简洁又支持共享的数据结构。
递归:从函数到数据
在 6.102 前面的课程里,你已经写过递归函数——每个调用包含基准情形(base case)与递归情形(recursive case)。递归数据类型遵循同样的思想:一个类型的定义中会引用它自身。这种自引用让数据结构可以表达任意深度,比如任意长度的列表、任意嵌套的公式。
不可变列表的四种基本操作
我们定义泛型接口 ImList<Element>,它提供四个基本操作:
empty():构造一个空列表。cons(first, rest):把一个元素添加到已有列表前面,返回新列表。first():返回列表的首元素。rest():返回除首元素外的剩余列表。
因为列表是不可变的(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 类。
因为 Empty 和 Cons 都是 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。