今天,scheme-langserver( https://github.com/ufo5260987423/scheme-langserver)发布了一个最新版本 1.0.11 ,该版本中加入了 scheme 生态中第一个 gradual typing 实现。
以下内容摘自 scheme-langserver 的相关文档( https://github.com/ufo5260987423/scheme-langserver/blob/main/doc/analysis/type-inference.cn.md):
包括 Java 和 Typescript 等编程语言都有一个类型系统, 能帮程序员尽可能避免代码执行中的错误。这些系统上基于遵循Hindley-Milner Type System或者System F的一些基本理论构建。然而,艰涩的理论不能保证它们的全知全能。
对 scheme 语言这样一种“非类型语言(untyped language)”,很多类型系统需要的信息并不能够轻易的从代码中找到:
(lambda (a) (+ 1 a))
显然,这段代码无法显式给出参数a
的类型。或者说,a
的类型是一个包含所有可能类型的全集something ?
(后续会说明这是啥)。这是因为 scheme 语言不像许多其他语言一样在字面上给出函数的参数的类型。如果是 Typescript ,为了给a
标明类型number
:
function(a: number){return 1+a}
许多 scheme 语言的同类(比如 Typed-racket )会改变它们的语法并且要求像 Typescript 这样的语言一样在字面上给出类型信息。但是,scheme-langserver 认为可能有另外的手段——当下的大多数 scheme 代码都在尽量遵循r6rs标准,这意味着可以依据标准中给出的函数(或者按照 lisp 家族的术语来说,过程)所具有的语义来猜测类型信息。回归上面的那段 scheme 代码,我们有如下推导:
+
是 r6rs 标准中规定的一个函数。(number? <- (number? ...))
。其中<-
说明这是个函数的类型,它的左边是函数的返回值的类型,右边是函数的参数的类型列表。a
的类型就可以确定为number?
。这个推导过程将帮助 scheme-langserver 找到隐含的类型信息并给出类型推导结果。本文将从如下几个反面进行介绍:
让我们首先考虑字面量的类型判断,以下分为四种简单的情况:
#t
,3
, "string"
和'a
,我们能够轻易地编写代码把它们的类型用几个判断器( predicator )标注为boolean?
、 number?
(integer?
和fixnum?
也行)、 string?
和symbol?
。'(1 2 3)
和向量#(1 2 3)
。这也也没什么稀奇的,用一点递归的手段就能够把它们标注为'(number? number? number?)
和'#(number? number? number?)
。这样,一些更复杂的例子'(1 a "e")
就会有类型标注如'(number? symbol? string?)
。<-
表示表达式里面有一个函数;**1
类似正则表达式中的+
,代表前面的类型或类型表达式出现了一次以上;...
则类似*
,表示可以出现 0 次到无穷次。 something?
,它对任意待判断的数据都返回“真”。此外,上文讨论的这些判断器当然是 r6rs 中的标准判断器,然而我们不能保证在任意代码中它们不会撞上“真假美猴王”。因此,scheme-langserver 使用identifier-references来表示上面这些判断器,带上它们的上下文信息;并使用函数construct-type-expression-with-meta
构建他们。
在 scheme-langserver 的处理过程中,所有的代码通过一整套索引系统管理:代码文件被表示为document,其中的代码被解析为index-node。至此,一种工程上的直觉告诉我们,应当从这一套索引中收集类型相关的信息,为接下来的推导打基础。显然,这一步最困难的事情在于如何从字面量所给出的简单信息,一路火花带闪电,给出那些更为复合的信息。
前人在这个问题上已经水了很多论文,他们大多数用 lambda 算式的那一套理论摇唇鼓舌:说来说去就一件事,不论怎样形式的代码都可以转化为 lambda 算式那种形式的代码。本文不会在这里介绍什么$\alpha$转换,$\beta$规约。在这个小节,本文只介绍如下两个案例:
(let ((a 2)) (+ 1 a))
,可以轻易转换为((lambda (a) (+ 1 a)) 2)
。用 Typescript 写个例子的话,就是—— function (){
var a = 2;
return a+1;
};
//=================>
//可以转换为
function (){
return (function(a){return a+1;})(2);
};
其中2
是一个字面量,按照上文的讲法,把它的类型信息number?
传给了变量a
。
(lambda (a) (+ 1 a))
中,这决定于(+ 1 a)
。而后者的返回值类型又决定于+
。也就是我们在 r6rs 标准中标注的那些东西。就这样顺藤摸瓜,多少能知道一点其他的。在此基础上,收集信息的工作仍然有很长的路线要走,因为:
(let ((a 2)) (+ 1 a))
,它的类型信息需要综合处理。为此,我们在这个文件夹写了大量的规则来捕捉自定义带来的这些信息;call/cc
这样的高级过程控制无法判断其类型。但是大不了我们就不判断嘛。上文我们多次提到了这样几个概念“复合”、“复杂”、“多个不同部分”,它们实际上都指向了如下三个事实:
基于数学的一般原则,scheme-langserver 引入了variable。它会在收集类型信息的过程中占住identifier-reference在类型表达式里面的位置,这样就方便后面进行进一步的逻辑运算和推导。
虽然 lambda 算式和我们的那些规则已经指出了类型信息应该如何在代码的结构框架上传递。但是,从字面量到变量,从 r6rs 标准到自定义的代码,仍然有大量琐碎的逻辑运算和推导工作要做。这里就存在一个问题,即如何利用这些信息做燃料驱动一台引擎,这台引擎将吞食上述信息并突出我们想要的那些未知的类型信息。Hindley-Milner 类型系统描述的就是如何在 lambda 算式基础上做这些工作。它总结了如下几条规则:
Variable Access $$ \frac{(x:\sigma) \in \Gamma}{\Gamma \vdash (x:\sigma)} $$ 这一条说的是,如果横线上方的变量$x$已知类型$\sigma$,那么横线下方的逻辑运算成立:即遇到$x$类型系统就能够给出它的类型$\sigma$。这里$\Gamma$可以看做一台无情的机器,虽然上述逻辑运算很愚蠢,但是它仍然会忠诚的执行。$\in$实质上代表了一种扩展机器的知识库的行为——在这条推导规则中,它把$(x:\sigma)$塞进知识库。然后,$\vdash$指向$\Gamma$机械运算的结果。
Application
$$\frac{\Gamma \vdash (e_1:(\tau_1 \to \tau_2)) \quad\quad \Gamma \vdash (e_2 : \tau_1) }{\Gamma \vdash ((e_1\ e_2) : \tau_2)}$$
本规则经常用于函数的调用,如将函数e1
用于参数e2
——(e1 e2)
(类似(+ 1 a)
)——然后获得这个表达式的返回值的类型$\tau_2$。相关实现可以在application.sls中研究。注意,这里的$\to$就是上文中的<-
,只是方向相反而已。
Abstract
$$\frac{(\Gamma, ;(x:\tau_1)) \vdash (e:\tau_2)}{\Gamma \vdash ((\lambda\ x\ .\ e) : (\tau_1 \to \tau_2))} $$
本规则用于获得函数如(lambda (x) (+ 1 x))
的类型。如果一个函数$e$的参数$x$已知类型$\tau_1$,返回值类型$\tau_2$,则函数的类型为$\tau_1 \to \tau_2$。在 scheme-langserver 中,这个类型表示为(tau1 <- tau2)
。
Let $$\frac{\Gamma \vdash (e_1:\sigma) \quad\quad (\Gamma,,(x:\sigma)) \vdash (e_2:\tau)}{\Gamma \vdash ((\mathtt{let}\ (x = e_1)\ \mathtt{in}\ e_2) : \tau)} $$ 已知:表达式$e_1$具有类型$\sigma$;当变量$x$具有类型$\sigma$,函数$e_2$具有类型$\tau$。将$e_1$代入$e_2$表达式中的$x$,返回值类型仍然是$\tau$。
Subtype $$\frac{\Gamma \vdash (e: \sigma_1) \quad\quad \sigma_1 \sqsubseteq \sigma_2}{\Gamma \vdash (e : \sigma_2)}$$ 这条规则说的是如果$e$有类型$\sigma_1$,且$\sigma_1$是$\sigma_2$的一个子集(或者说$\sigma_1$继承了$\sigma_2$,$e$也将具有类型$\sigma_2$。
Type Generalize $$\frac{\Gamma \vdash (e: \sigma) \quad\quad \alpha \notin \mathtt{free}(\Gamma)}{\Gamma \vdash (e:(\forall\ \alpha\ .\ \sigma))}$$ 这一条规则用来拓展$\Gamma$关于类型的知识(在 scheme 语言中,大多用于解析 record type )。它呈现的逻辑是:若已知某类型变量$\alpha$“不自由”,且某表达式$e$有类型$\sigma$,则$e$同时可能有类型$\alpha$。其中$\alpha$是$\sigma$的任意子集。这一条规则实际上是 Subtype 的反演——“不自由”说的是在知识库中,存在一些变量具有$\alpha$这个类型,因此我们可以在横线下方一一枚举$\alpha$,看它们是否满足这个判断。有的读者可能对“不自由”和“枚举”没有清晰的认识:它本质上反映的是“排中律”的取消,即“两个互相矛盾的命题必有一真”,这个类似反证法的逻辑不一定成立。因为你必须把矛盾中的一个方面一一列举,才能证明其中一个是真的。对于本规则而言,一一列举“不自由”的$\alpha$在逻辑运算上比较经济实惠。
((variable : type)... (type1 < type2))
;(append old-knowledge-list with-new-knowledge-list)
;(vairbale = type-expression-with-variable)
;这就补全了前两种关系之间的空缺;首先总结上文,$\Gamma$机是一台吞下用index-node表示的静态代码,然后转化成类型表达式的机器。这里面就有一个算法来推导类型表达式里面啊那些未知量。在 scheme-langserver 中,我们的代码采用了 Robinson's Unification Algorithm:$\Gamma$机像蜘蛛一样在图数据构成的网上面爬来爬去,把$:$和$\sqsubseteq$传递的已知信息传递给未知量。在这个过程中,scheme-langserver 采用了 path-memory 和循环检测机制防止死循环。整个机制的细节总结如下:
(variable : type)
给出了已知类型信息。也就是说,当未知量已知,我们就不再进行进一步的“代换”。正如本文开头那个例子(lambda (a) (+ 1 a))
指出的,参数a
没有被显式标注类型,这就导致 Hindley-Milner 类型系统中的很多等价代换无法发挥作用。Scheme-langserver 使用Gradual Typing做了一个隐式变换:+
只接受有number?
类型的参数,那a
就等于被标注number?
类型了嘛。
但是,正如 Gradual Typing 作者 Jeremy Siek 所言(作者默默吐槽,其实没看他的论文前,我也发现了这个方法),这种隐式变换将破坏 subtype 规则。这是因为这种变换的前提是假设代码总是能够执行。不过在我看来,总是要有一点妥协的,关键看 scheme-langserver 能否根据类型推断的结果给出有价值的信息。