你的类型,0:基本的合一 详尽注释

你的类型,0:基本的合一 详尽注释

霜月琉璃霜月琉璃
原址: 你的类型,0:基本的合一

作者:Belleve
链接:你的类型,0:基本的合一
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前言

本文可认为是个人笔记、代码注释,尽量以通俗易懂的方式解释 Belleve 的原文,由于水平有限以及深夜精神恍惚,可能会存在较多错漏,可在评论区指出。

引用部分为 Belleve 原文。

导读

第零章,基本的合一,本章实现一个简单的合一算法,能够为两个类型生成一个替换,这个替换在 js 中体现为一个 map。

为了方便地测试这个合一函数,我们还定义了一些类型构造函数,用于构造比较复杂的类型。

我们通过对两个不同的类型应用这个合一函数来生成一个替换(映射)。得到的这个替换具有如下性质:将它应用在这两个不同的类型上,可以得到两个相同的类型,这就是合一。

预备知识

如果你想更好地理解合一的概念,可以参考这个问题:如何通俗的解释计算机中“合一(unification)”这个概念 ?

1 目标与基本思路

简单粗暴的开始:用 JS 实现一个 ML 类的程序语言的类型推理系统。
在我们这里,所有的类型属于以下形式之一:

一个基础类型(Primitive)
一个自由变量(Slot)
一个复合类型(Composite),由一个构造器(ctor)和一个参数(argument)复合得到。对于 (->)、(\*) 之类的二元构造器,使用 Curryize 将他变换为嵌套的 Composite。

ML 系的语言使用的是Hindley-Milner类型推论算法来推测大多数值的类型,而不需要四处使用注解。有关 HM 类型推论算法,可以参考我的这篇翻译:(译) Algorithm W Step By Step,这篇文章原文的代码简单易懂,但是我觉得我翻译得很不好(哭)。

2 概念介绍

所谓的合一,指的是:给出两个类型 A 和 B,找到一组变量替换,使得两者的自由变量经过替换之后可以得到一个相同的类型 C。考虑
A = ((α → β) × [γ]) → [β], B = ((γ → δ) × [γ]) → ε
这两个类型可以合一,对应的替换是
〈α → α, β → β, γ → α, δ → β, ε → [β]〉

这是一个范例,无需注解。

3 合一算法

实现合一的算法基本思路就是维护一个 slot 的映射。对于任意的类型 a 和 b,以及「当前状态」的映射 m:

如果 a 和 b 都是 slot 并且 m[a] == m[b],那么 a b 可以合一,m 不变。
如果 a 和 b 都是 primitive 并且相同,那么 a b 可以合一,m 不变。
如果 a 是 slot,可以合一,并且需要 m[a] 设置为 b;反之亦然。
如果 a 和 b 都是 composite,检查两者的构造器和参数是否都能合一,m 会最多被设置两次。
对于其他一切情况,a 和 b 不能合一。

这个算法较为通俗易懂。下文默认 m 为映射表(替换)。

对于第一条,如果给定自由变量 a 与 b,而且均已被记入映射表(替换)中,并且记录在映射表中的值依旧相等,那么显然,a 与 b 可以直接消去(合一)。例如,若 m[a] == m[b] == c,那么我们不再需要 a 与 b 了,因为 c 可以取代二者。

对于第二条,是因为基本类型不记入映射表,也不被替换,因此只有在二者相等时才能消去。

第三条,如果 a 或者 b 二者之一是自由变量,我们可以便可以构造一个映射使得二者可以等价。例如我们有 a 与 b,那么无论是 m[a] = b 还是 m[b] = a 都是可以接受的;若我们有 a 与 [b],那么只要 m[a] = [b] 即可完成消去(合一)。

第四条,如果是复合类型,显然,由于柯里化,我们需要同时检查构造器与参数能否合一。

4 具体实现

// A monomorphic type
class Monomorphic {
	constructor() {}
	inspect() {} // Pretty print type
	applySub(m) {} // Apply a substitution m
	equalTo(t) {
		return false;
	}
}

没有什么特别的基类。

// Slots for free variables
class Slot extends Monomorphic {
	constructor(name) {
		super();
                this.name = name;
	}
	inspect() {
		return "#" + this.name;
	}
	applySub(m) {
		const r = m.get(this);
		if (!r || r === this) return this;
		return r.applySub(m);
	}
	equalTo(t) {
		return t && t instanceof Slot && this.name === t.name;
	}
}

inspect 为 pretty print方法,可以不去理会。

当我们对一个自由变量 a 套用替换(映射表)时,我们首先应当寻找是否有过记录过的替换,如果有,则返回 m[a].applySub(m),再应用一次替换,因为我们需要保证 m[a] 也被完全合一了。

if (!r || r === this) return this;

代表 a → a。

// Primitive types
class Primitive extends Monomorphic {
	constructor(name, kind) {
		super();
                this.name = name;
	}
	inspect() {
		return this.name;
	}
	applySub(m) {
		return this;
	}
	equalTo(t) {
		return t && t instanceof Primitive && this.name === t.name;
	}
}

因为是基本类型(相当于上面范例中的箭头和×),不能应用替换,所以 applySub 直接原样返回就行了。

// Composite types, like [(->) a b] or [List a]
class Composite extends Monomorphic {
	constructor(ctor, argument) {
		super();
		this.ctor = ctor;
		this.argument = argument;
	}
	inspect() {
		if (this.argument instanceof Composite) {
			return this.ctor.inspect() + " (" + this.argument.inspect() + ")";
		} else {
			return this.ctor.inspect() + " " + this.argument.inspect() + "";
		}
	}
	applySub(m) {
		return new Composite(this.ctor.applySub(m), this.argument.applySub(m));
	}
	equalTo(t) {
		return t && t instanceof Composite && this.ctor.equalTo(t.ctor) && this.argument.equalTo(t.argument);
	}
}

定义了“复合类型”,其构造是手动柯里化的,具体构造方式可以参考下文。

因此,在applySub中,由于柯里化,构造器 `ctor`和参数 argument 都应当能被执行合一。

// Unify two monomorphic types, p and q with slot mapping m.
function unify(m, s, t) {
	if (s instanceof Slot && t instanceof Slot && s.applySub(m).equalTo(t.applySub(m))) {
		return true;
	} else if (s instanceof Primitive && t instanceof Primitive && s.name === t.name && s.kind === t.kind) {
		return true;
	} else if (s instanceof Composite && t instanceof Composite) {
		return unify(m, s.ctor, t.ctor) && unify(m, s.argument, t.argument);
	} else if (s instanceof Slot) {
		m.set(s, t);
		return true;
	} else if (t instanceof Slot) {
		m.set(t, s);
		return true;
	} else {
		return false;
	}
}

这个方法就是对合一算法的一一对应。

// Slot symbol table
let st = {};
function slot(name) {
	if (st[name])return st[name];
	const t = new Slot(name);
	st[name] = t;
	return t;
}
// Primitive symbol table
let pt = {};
function pm(name, kind) {
	if (pt[name])return pt[name];
	const t = new Primitive(name, kind);
	pt[name] = t;
	return t;
}
// Composite types
function ct(ctor, argument) {
	const t = new Composite(ctor, argument);
	return t;
}
function arrow(p, q) {
	return ct(ct(pm("[->]"), p), q);
}
function product(p, q) {
	return ct(ct(pm("[*]"), p), q);
}

slot/pm 方法之前定义了变量表,方法内部只是往自由变量/基本类型表里加东西并返回一个构造出来的值而已。

ct 用于构造一个复合类型,注意到在使用方式上,是类似于手动柯里化的,可以方便我们处理。

arrow/product 用于构造我们较为复杂的类型,最后的结果是 `→ p q` 或者 `× p q`,也就是范例中的箭头与 ×。当然,可能更为直观的写法是

p → q 与 p × q

但其实上面的写法只是省略了括号而已,正如前文所说,→ 与 × 是二元函数。

const type1 =
arrow(
	product(
 		arrow(slot("a1"), slot("a2")),
 		ct(pm("list"), slot("a3"))),
 ct(pm("list"), slot("a2")));

它制造的类型是 `→ (× (→ a1 a2) ([] a3)) ([] a2)`。我们可以直观地理解为如下形式:

( (a1 → a2) × [a3] ) → [a2]

其实并不复杂。

const type2 =
arrow(
	product(
 		arrow(slot("a3"), slot("a4")),
 		ct(pm("list"), slot("a3"))),
 slot("a5"));

它制造的类型是 `→ (× (→ a3 a4) ([] a3)) a5`,也就是

( (a3 → a4) × [a3] ) → a5

5 测试

测试代码就不贴了。

为更清除地理解这一算法,现在手动分析 `unify(m, type1, type2)` 这一过程。

  • ( (a1 → a2) × [a3] ) → [a2] 合一 ( (a3 → a4) × [a3] ) → a5,两者均为复合类型,因此检查构造器与参数是否能够合一。
  • 也就是拆分成为 (a1 → a2) × [a3] → 合一 (a3 → a4) × [a3] → 以及 [a2] 合一 a5 两个问题。
  • 首先检查 (a1 → a2) × [a3] → 合一 (a3 → a4) × [a3] →,两者均为复合类型,继续拆分,将基本类型 → 消去,得到 (a1 → a2) × [a3] 合一 (a3 → a4) × [a3],继续拆分。
  • 同理消去 ×,检查 a1 → a2 合一 a3 → a4,继续拆分。
  • 检查 a1 合一 a3,可以合一,因此将 a1 → a3 写入映射表。
  • 上溯到 a2 合一 a4,显然可以合一,将 a2 → a4 写入映射表。
  • 上溯到 [] a3 合一 [] a3,消去基本类型 [] 后,二者相等,不修改映射表。
  • 上溯到 [] a2 合一 a5,由于 a5 是 slot,因此可以合一,将 a5 → [a2] 写入映射表。
  • 合一完成,合一替换为 a1 => a3,a2 => a4,a5 => [a2]。

现考虑对 `type1`

( (a1 → a2) × [a3] ) → [a2] 

进行替换

a1 => a3,a2 => a4,a5 => [a2]
  • 首先,它是复合类型。因此我们需要对构造器 ( (a1 → a2) × [a3] ) → 和参数 [a2] 均进行替换。
  • 由于基本类型不能进行合一替换,因此当我们遇到一个 →、×或者 [] 做构造器的时候,可以直接消去它。因此我们可以对上一部的构造器进行简化,得到 (a1 → a2) × [a3]。
  • 继续考虑替换,我们拿到 (a1 → a2) × [a3] 后,检查 a1 → a2 与 [a3]。
  • 检查 a1 → 与 a2,分别替换成 a3 与 a4,并且重新组合为一个复合类型,a3 → a4。
  • 上溯,[] a3 无法替换,重新组合出 (a3 → a4) × [a3]。
  • 上溯,[] a2 替换为 [] a4,与 (a3 → a4) × [a3] 重新组合出 ( (a3 → a4) × [a3] ) → [a4]。
  • 替换完成。

结语与预告

我觉得最后一段的行文方式特别像老师上课一样,一步一步的讲……(

由于 Belleve 暴力而有效的行文方式(短句解释+直接贴代码),可能会劝退不少人,因此作了一些注释。后面几章也会慢慢更新。

本文没有经过任何人的校对,如有错误请直接指出。

一年前的东西重新挖出来,感觉就像盗墓贼一样。


下一篇《你的类型,1:基础推理》较长,可能会分成几篇发出来。

文章被以下专栏收录
4 条评论
推荐阅读