Skip to content

Fire Code

[译]通过例子学子类型推理(五):递增的可达性

上周,我们介绍了 cubiml 类型检查器核心的实现,也就是类型检查器维护程序类型图、检查和传播类型约束的部分。然而,类型检查器核心中有一块,即流检查器,留下了。今天,我们将介绍 cubiml 类型推理算法的最后一块 - 流检查器的实现。

类型图

回想一下,在 cubiml 中,类型用指向可变的、可能是循环的图中的节点指针来表示。图中有两种可能的边类型。第一种是代表结构类型组件的边。例如,如果节点代表函数类型,它将有指向该函数的相应参数类型和返回类型的边。

第二种类型的边是流边,代表给定值类型的程序值流向给定用类型的用约束,因此值类型必须与该用类型兼容。此外,变量被表示为图中的中间节点,类型推理算法负责确保流转关系是传递的。

类型检查器核心分为两部分。第一部分在上周的文章中介绍,包括每个图节点所代表的类型信息,以及从类型构造器“头”到其组件类型的关系。今天介绍的第二部分,只负责跟踪流边和维护流边的传递性。它被封装在名为 Reachability 的类中,其公共接口如下:

impl Reachability {
    pub fn add_node(&mut self) -> ID;
    pub fn add_edge(&mut self, lhs: ID, rhs: ID, out: &mut Vec<(ID, ID)>);
}

递增的可达性

这个类被称为 Reachability ,因为它解决了*(定向)增量可达性问题*,一个关于图的通用问题,它要求你保持传递性,即对于所有的边 a ->b b ->c,也有一条边 a ->c ,因为边是由外部源插入的。

天真算法

首先,我们将尝试用最简单的算法来解决增量可达性问题,不管速度如何。每当一条新边 A -> B 插入图中时,我们首先检查这条边是否已经存在(如果存在,那么显然不需要做什么)。如果插入了一条新边,我们可能需要插入额外的边来保持传递性。

具体来说,对于每个有边 C -> A 的节点 C,需要插入边 C -> B。同样,对于每个有边 B -> D 的节点 D,需要插入边 A -> D。最后,需要插入所有 C -> D 形式的边。为了做到这一点,需要一种方法来轻松地找到给定节点有边的所有节点(称为下集)和有边的到给定节点的所有节点,即上集。这就导致了一个相当简单的算法。

type ID = usize;
#[derive(Debug, Default, Clone)]
pub struct Reachability {
    upsets: Vec<HashSet<ID>>,
    downsets: Vec<HashSet<ID>>,
}

为了表示图的状态,只需将每个节点的上集和下集直接存储为 HashSetupsetsdownsets 字段存储了哈希集的列表,分别给出了每个节点的上集或下集的索引。

pub fn add_node(&mut self) -> ID {
    let i = self.upsets.len();

    let mut set = HashSet::with_capacity(1);
    set.insert(i);

    self.upsets.push(set.clone());
    self.downsets.push(set);
    i
}

add_node 的实现很简单 - 只需在每个列表中添加一个新的集合并返回新的索引。有一个小小的复杂之处。正如最初所说,有三种类型的边要添加:C -> BA -> D,和 C -> D,其中 A -> B 是原始边,CA 上集的任何成员,DB 下集的任何成员。为了简化算法,我们把每个节点都存储在它自己的上集和下集中,因此我们只需要担心一种边,即 C -> D 边。

pub fn add_edge(&mut self, lhs: ID, rhs: ID, out: &mut Vec<(ID, ID)>) {
    if self.downsets[lhs].contains(&rhs) {
        return;
    }

    // 获取 lhs 的所有祖先,包括 lhs 本身
    let mut lhs_set: Vec<ID> = self.upsets[lhs].iter().cloned().collect();
    lhs_set.sort_unstable();

    // 获取 rhs 的所有后裔,包括 rhs 本身
    let mut rhs_set: Vec<ID> = self.downsets[rhs].iter().cloned().collect();
    rhs_set.sort_unstable();

    for &lhs2 in &lhs_set {
        for &rhs2 in &rhs_set {
            if self.downsets[lhs2].insert(rhs2) {
                self.upsets[rhs2].insert(lhs2);
                out.push((lhs2, rhs2));
            }
        }
    }
}

最后,add_edge 的实现。很简单 - 要插入边 lhs -> rhs,首先检查这个边是否已经存在。如果它不存在,就从 upsets[lhs] 的每个成员到 downsets[rhs] 的每个成员插入一个新边。

值得注意的是对 sort_unstable 的调用。Rust 的 HashSet 是随机排序的,这意味着如果在迭代之前不对集进行排序,就会以随机的顺序插入新的边,并以随机的顺序返回给调用者。这本身并不是不正确的,但它有一些不理想的效果。例如,如果输入程序恰好有多个类型错误,那么以随机顺序处理流边会导致检测到随机类型错误并报告给用户。同一编译器对同一代码的不同运行可能会有不同的结果,这显然是个坏主意。确保代码是确定性的,还有很多其他的好处,比如让代码更容易调试,确保更可预测的性能。

天真算法的复杂性

上面的代码是可行的,但速度慢、效率低。如果 n 是图中的节点数,那么每个上集和下集的大小可能达到 n,这意味着在上集和下集中迭代每个对的结果可达 n^2 对。因此,每个单独边插入的最坏情况复杂度为 O(n^2)。由于最多可以有 O(n^2) 条边,所以天真可达性算法的总体复杂度为 O(n^4)

这与原代数子类型化论文中的去掉指数部分(let 多态和简化)的算法的 O(n^4) 复杂度相似。代数子类型化做了很多不同的事情,所以两者并不能直接比较,但是 O(n^4) 的缓慢来自于一个非常相似的问题。幸运的是,在立方双合一的情况下,加快类型检查算法的速度就像选择更快的可达性算法一样简单。

生成树算法

已发表的最快的增量可达性问题算法来自于一篇1986年的论文,其最坏情况下的复杂度为 O(n^3),因此将“立方”放在了立方双合一中。这个算法要复杂得多,它涉及到将每个节点的下集表示为生成树,而不是普通的哈希集。尽管这篇论文已经发表了 34 年,但目前还没有更快的算法。事实上,似乎 O(n^3) 是这个问题的最佳可能,尽管证明复杂性的无条件下限是众所周知的困难。

cubiml 的最初版本使用了这种算法。然而,在编写本教程的过程中,我偶然发现了一个更好的算法,在一篇所有内容的指针别名分析论文中。(感谢 Reddit!) 当然这个算法仍然需要 O(n^3) 时间,但它比生成树算法简单得多,所以它是今天要使用和介绍的算法。(如果你好奇,你可以在这里看到生成树算法的原始实现)。

最终算法

这个算法与天真算法基本相同,只是没有插入所有的 C -> D 边,也就没有迭代每条边插入的 O(n^2) 对,而是只递归地插入 C -> BA -> D 边。但在进入这个问题之前,我们要先定义一个辅助数据结构。

有序集

回想一下,Rust 的 HashSet 是随机排序的,所以为了保持天真算法中的确定性行为,我们在迭代之前对集合进行了排序。然而,有一个更好的解决方案。我们不只使用 HashSet,还维护一个集合一个有序列表。每当插入一个元素时,就把它插入到集合追加到列表中。每当需要对值进行迭代时,只需对列表进行迭代,而列表是确定有序的。(我们仍然需要这个集合,这样就可以在常数时间内检查值是否已经存在,但不再需要迭代这个集合)。

struct OrderedSet<T> {
    v: Vec<T>,
    s: HashSet<T>,
}
impl<T: Eq + std::hash::Hash + Clone> OrderedSet<T> {
    fn insert(&mut self, value: T) -> bool {
        if self.s.insert(value.clone()) {
            self.v.push(value);
            true
        } else {
            false
        }
    }

    fn iter(&self) -> std::slice::Iter<T> {
        self.v.iter()
    }
}

事实上,这个模式非常有用,我已经把它包装在上面的 OrderedSet 类中。这让我们可以在任何地方使用 OrderedSet 代替 HashSet,并获得确定性行为。

注: 许多语言都提供了开箱即用的有序集。例如,Java 有 LinkedHashSet。在 JavaScript 和 Python 3.6+ 中,原生的集合类型是自动有序的。由于需要处理集的删除,这些实现略有不同,再加上需要保持插入顺序,而不是任何任意的确定性顺序,这意味着使用的列表必须是链表,而不是数组列表(Vec)。使用链表具有相同的渐近复杂度,但在实际中要慢得多,即具有较高的常数因子,所以如果可以的话,最好避免这样做,如本例。

立方可达性

说完这些,实际算法出奇的简单。首先,Reachabilityadd_node 的定义与之前基本相同,只是不再将每个节点添加到它自己的上集和下集中。相反,上集和下集一开始是空的。(Default::default() 返回类型的默认值,在这种情况下是空集)。

pub struct Reachability {
    upsets: Vec<OrderedSet<ID>>,
    downsets: Vec<OrderedSet<ID>>,
}
impl Reachability {
    pub fn add_node(&mut self) -> ID {
        let i = self.upsets.len();
        self.upsets.push(Default::default());
        self.downsets.push(Default::default());
        i
    }
}

概念上,当插入边 A -> B 时,首先检查该边是否已经存在。如果没有,就插入它,然后检查上下集。对于 A 上集中的每个 C,递归调用 add_edge(C,B)。对于 B 下集中的每个 D,递归调用 add_edge(A,D)。它是如此简单,以至于用代码描述比用散文描述几乎更容易。

一个复杂的问题是,实际实现是迭代的,而不是递归的。我们没有实际递归地调用 add_edge,只是维护一个待插入边的栈,然后从栈中弹出项并处理它们,直到栈为空。

pub fn add_edge(&mut self, lhs: ID, rhs: ID, out: &mut Vec<(ID, ID)>) {
    let mut work = vec![(lhs, rhs)];

    while let Some((lhs, rhs)) = work.pop() {
        // 如果边已经存在,插入返回 false
        if !self.downsets[lhs].insert(rhs) {
            continue;
        }
        self.upsets[rhs].insert(lhs);
        // 通知调用者增加了一个新边
        out.push((lhs, rhs));

        for &lhs2 in self.upsets[lhs].iter() {
            work.push((lhs2, rhs));
        }
        for &rhs2 in self.downsets[rhs].iter() {
            work.push((lhs, rhs2));
        }
    }
}

就算法复杂度而言,每插入一个边,不管这个边是来自外部调用者还是我们自己的递归调用,我们都会迭代最大大小 O(n)upsets[lhs]downsets[rhs]。因此,每次边插入都要做 O(n) 的工作。由于最多存在 O(n^2) 条边,这导致 O(n^3) 的总体最坏情况复杂度,因此,这是立方双合一名字的由来。

代码生成

类型检查完成后,实现编译器剩下的唯一一步就是实际生成代码,这样输入的程序才能被执行。这很复杂、繁琐,而且与你所实现的特定编译器高度相关,我在这里就不赘述了,但你可以在这里这里查看 cubiml 生成代码的初始版本。

我们现在已经经历了一个完整的例子,在一个最小编程语言中实现了立方双合一来检查类型。然而,现在这个语言还不是很有用。下周,我们将在该语言中添加整型、浮点型、字符串字面量和运算符。