lotuc
首发于lotuc
Programming Languages: Application and Interpretation【译15中】

Programming Languages: Application and Interpretation【译15中】

初翻: @lotuc

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


15.3 对核心的扩展

现在我们已经有了基础的静态类型语言,下面探索一下如何将其扩展成为更有用的编程语言。

15.3.1 显式的参数多态

下面哪些是相同的?


  • List<String>
  • List<String>
  • (listof string)

事实上,上面任何两个都不太一样。但是第一个和第三个非常相似,因为第一个是Java代码而第三个是我们的静态语言代码,而第二个,是C++代码,和其它两个不同。清楚了吗?不清楚?很好,继续往下读!

15.3.1.1 参数化类型

我们所使用的编程语言已经展示了参数多态的价值,例如,map函数的类型可以这样给出:

(('a -> 'b) (listof 'a) -> (listof 'b))

意思是,对于任意类型'a'bmap读入一个从'a'b的函数,一个'a的链表,生成对应的'b的链表。这里,'a'b不是具体的类型;它们是类型变量(我们的术语中,这应该被称为“类型标识符”,因为它们在实例化过程中不会变化;但是我们还是使用传统术语)。

可以换种方式理解它:实际上有一族无穷多的这样的map函数。例如,其中一个map的类型是这样的:

((number -> string) (listof number) -> (listof string))

另一个的类型是这样的(没有限制说其中的类型必须是基本类型):

((number -> (number -> number)) (listof number) -> (listof (number -> number)))

还有这样的(也没有限制说'a'b必须不同):

((string -> string) (listof string) -> (listof string))

以此类推。由于它们的类型不同,名字也需要不同:map_num_strmap_num_num->nummap_str_str等。但是这会让它们变成不同的函数,于是我们总得使用某个特定map,而不是直接使用比较一般的那个。

显然,不可能将所有这些函数放到我们的标准库中:毕竟它们有无穷多个!更好的方式是能按需获取我们需要的函数。我们的命名规则给出了一点提示:map接受两个参数,它们都是类型。给定了两个类型作为参数,我们可以得到针对特定类型的map函数。这种类型的参数化被称为参数多态

注意不要和对象“多态”搞混,后面会讨论它。

15.3.1.2 显式声明类型参数

换句话说,我们相当于说map实际上是有四个参数的函数,其中两个是类型,另外两个是实际的值(函数和链表)。在需要显式声明类型的语言中,我们需要写成类似这样:

(define (map [a : ???] [b : ???] [f : (a -> b)] [l : (listof a)]) : (listof b)
  ...)

但是这会产生一些问题。首先,???处应该填什么?它是ab的类型。但是如果a和b本身将被类型替换,那么类型的类型是什么?其次,我们真的希望每次调用map的时候传入四个参数吗?再者,我们真的希望在接收任何实际值之前先接收类型参数吗?对于这些问题的答案能延伸出关于多态类型系统巨大的讨论空间,其中的大部分我们这里将会涉及。

推荐阅读Pierce的《Types and Programming Languages(类型和编程语言)》,获取易懂、现代的介绍。

注意到一旦我们引入参数化,很多预期之外的代码都将被参数化。例如,考虑平平无奇的cons函数的类型。它的类型需要基于链表中值的类型进行参数化(尽管它实际上并不依赖于这些值——稍后会解释这一点),于是每次使用cons时都需要正确地进行类型实例化。说到这,即使用empty创建空链表也必须类型实例化!当然,Java和C++程序员应该对这个痛点很熟悉了。

15.3.1.3 一阶多态

我们将只讨论这个空间中一个特别有用且易于理解的点上,也即 Standard ML 的类型系统、同时是本书使用的静态类型语言和早期版本的
Haskell 的类型系统,有范型加成的 Java 和 C# 以及引入了模版的C++
也差不多获得了这种类型系统的大部分能力。这类语言定义了被称为谓词一阶或者叫前缀多态的东西。关于上小节的问题它的答案是不填、没有、是。下面我们来探讨一下。

我们首先将类型的世界分成两组。第一组包含我们目前用到的静态类型语言,另外加上类型变量;它们被称为 monotype(单型)。第二组包含参数化的类型,被称为 polytype(多型);按惯例它们是这样写的:前缀,一组类型变量,再跟一个类型表达式,表达式中可以使用这些类型变量。因此,map的类型将写作:

∀ a, b : (('a -> 'b) (listof 'a) -> (listof 'b))

由于“”是逻辑符号“对于所有的”的意思,于是上面的东西可以读作:“对于所有类型'a'bmap的类型为……”。

在一阶多态(rank-1 polymorphism)中,类型变量只能被monotype替换。(此外,它们只能被具体类型替换,否则剩下的类型变量将无法被替换掉。)因此,在类型变量参数和常规参数之间我们有了明确的界线。我们不需要为类型变量提供“类型注解”,因为我们知道它们可以是什么。这样得到的语言相对简洁,但仍提供了相当的表达能力。

非直谓性语言(Impredicative language)取消了monotypepolytype的区别,因此类型变量可以使用另一个多态类型实例化。

注意到由于类型变量只能被monotype替换,他们全相互对立。于是,类型参数可以全被提到参数表的前面。这使我们可以使用形如∀ tv, ... : t的类型,其中tv是类型变量,tmonotype(其中可以引用这些类型变量)。此语法的意义就在这里,这也是之前称其为前缀多态的原因。而且后面也将看到这对其实现也很有用。

15.3.1.4 通过去语法糖实现一阶多态解释器

该特性最简单的实现就是将其视为一种去语法糖的形式:C++ 实际上就是这么做的。(具体来说,因为 C++ 有一个叫做模版的宏系统,所以使用模版,它非常巧合地达成了一阶多态。)举个例子,如果我们有一个语法形式define-poly,它接收名字、类型变量和表达式。当传入类型的时候,它将表达式中对应类型变量替换为此类型,因此:

(define-poly (id t) (lambda ([x : t]) : t x))

通过将id定义为多态的方式定义了一个恒等(identity)函数:给t传入递任意具体类型,就得到一个单参数的类型为(t -> t)的函数(其中t被替换)。我们可以使用各种类型实例化id

(define id_num (id number))
(define id_str (id string))

从而获得针对这些类型的恒等函数:

(test (id_num 5) 5)
(test (id_str "x")  "x")

与之相对,像

(id_num "x")
(id_str 5)

这样的表达式将不能通过类型检查(而不是运行时出错)。

如果你好奇的话,下面给出了实现。简单起见,我们假设只有一个类型参数;很容易使用...实现多个参数的情形。我们不仅将define-poly定义为宏,还会定义宏:

(define-syntax define-poly
  (syntax-rules ()
    [(_ (name tyvar) body)
     (define-syntax (name stx)
       (syntax-case stx ()
         [(_ type)
          (with-syntax ([tyvar #'type])
            #'body)]))]))

因此,对于:

(define-poly (id t) (lambda ([x : t]) : t x))

该语言将创建名为id:对应(define-syntax (name ...) ...)的部分(对于这个例子,nameid)。id的一个实例,如(id number),将类型变量t、宏里面的typvar替换成给定的类型。因为要规避卫生,我们用with-syntax来确保所有对于类型变量(typvar)的使用被替换为给定的类型。因此,实际效果是,

(define id_num (id number))

被转换成了

(define id_num (lambda ([x : number]) : number x))

然而这种方式有两个重大局限性:


  1. 来试试定义递归的多态函数,比如说filter。之前我们说过,每个多态值(例如consempty)都需要类型实例化,但是为了简洁起见我们将依赖静态类型语言实现这点,而仅专注于filter的类型参数。对应代码是:

(define-poly (filter t)
(lambda ([f : (t -> boolean)] [l : (listof t)]) : (listof t)
(cond
[(empty? l) empty]
[(cons? l) (if (f (first l))
(cons (first l)
((filter t) f (rest l)))
((filter t) f (rest l)))])))

注意到递归的使用filter时,必须使用恰当的类型对其实例化。

上面的定义完全正确,只有一个问题,当我们尝试使用它时——如:

(define filter_num (filter number))

DrRacket 将不会终止,更准确的说,是宏展开不会终止,因为它将不断的尝试创建filter代码的副本。不过如果用下面这种方式定义该函数,展开会终止——

(define-poly (filter2 t)
(letrec ([fltr
(lambda ([f : (t -> boolean)] [l : (listof t)]) : (listof t)
(cond
[(empty? l) empty]
[(cons? l) (if (f (first l))
(cons (first l) (fltr f (rest l)))
(fltr f (rest l)))]))])
fltr))

但是这给开发人员徒增了不必要的痛苦。实际上,一些模版展开程序会缓存之前展开的值,避免对于相同的参数反复生成代码。(Racket
做不到这点,因为一般来说,宏表达式可以依赖可变变量和值,甚至可以执行输入输出,因此 Racket 无法保证同样的输入表达式总是产生相同输出。)


  1. 考虑恒等函数的两个实例。我们无法比较id_numid_str,因为它们类型不同,但即使它们类型相同,使用eq?比较它们也不同:

(test (eq? (id number) (id number)) #f)

这是因为对id每次实例化都会创建一份新的代码副本。即使使用了上面提到的优化,同一种类型对应代码只有一份副本,但是不同类型的对应代码体还是会被重新生成【注释】——但这也是没必要的!例如,id的实现的部分其实没任何东西依赖于参数的类型。实际上,id这一族无穷多个的函数可以共享同一个实现。简单的去语法糖策略实现不了这点。

事实上,C++模版因代码膨胀的问题而臭名昭著,这是原因之一。


换种说法,基于去语法糖的策略本质上是使用替换的实现方式,它有着和我们之前函数调用时使用替换的方式实现相同的问题。不过,其它情况下,替换策略能达成我们关于程序行为的期望;对于多态也是一样,正如我们将看到的一样。

注意去语法糖策略的一个好处就是它不需要类型检查器“理解”多态。我们的核心语言仍可以是单态的(monomorphic),所有的(一阶)多态完全由宏展开处理。这提供了一种廉价的将多态添加到语言中的策略,但正如C++所示,它也引入了很大的开销。

最后,虽然这里我们只关注了函数,但前面的讨论同样适用于数据结构。

15.3.1.5 其它实现方式

有些其他实现策略不会遇到此类问题。这里我们不会深入讲解它们,但是其中一些策略的本质就是上面提到过的“缓存”方法。因为可以确定的是,对于给定的同一组类型参数,应该得到相同的实现代码,不需要对相同的类型参数实例化多次。这避免了无限循环。如果我们检查了使用特定类型实例化的代码一次,后续相同类型参数的实例化结果就无需再进行类型检查(因为它不会发生改变)。此外,我们无需保留实例化后的源码:一旦我们检查了展开后的程序,就可以将其丢弃,运行时也只需要保留一份实例化的副本。这样可以避免上述纯去语法糖策略中讨论过的所有问题,同时保留它的好处。

其实我们有点过分了。静态类型的好处之一就是能选择更精确的运行时表示。例如,静态类型可以告诉我们用的是数是32位的还是64位的甚至1位的(也就是布尔值)。然后编译器可以利用位的布局方式(例如,32个布尔值可以**打包*进一个32位字)为每种表示生成专用代码。因此,在对每种使用的类型进行检查之后,多态实例化程序可以跟踪函数或数据结构使用时用到的特定类型,并将这些信息提供给编译器用于代码生成。这会导致生成相关函数的若干副本,彼此都互不eq?——但这么做有充分的理由,因为它们要执行的操作的确不同,所以这是正确的。

15.3.1.6 关系型参数

我们还需解决关于多态的最后一个细节。

早先我们说过像cons这样的函数不依赖于其参数的具体值。这一点对mapfilter等也成立。mapfilter接收一个函数作为参数,当它们要对单个元素进行操作时,实际上使用该函数进行操作,即该函数负责做出如何处理元素的决定;mapfilter本身只是遵从该函数参数。

“检验”这种情况是否属实的一种方法是,替换不同类型的值链表及对应的函数作为参数。也就是说假设两组值之间有映射关系;我们根据此关系替换链表元素和参数函数。问题是,mapfilter的输出结果是否可以通过该关系预测?如果对于某些输入,map的输出和关系预测的结果不同,这说明map肯定侦测了实际值并根据相关信息做出了处理。但事实上,这不会发生在map上,或者说实际上也不会发生在大多标准多态函数上。

遵从这类型关系准则的函数被称为关系型参数(Relational Parametricity)【注释】。这是类型赋予我们的另一个非常强大的能力,因为它们告诉我们这种多态函数可以执行的操作很受限制:它们可以删除、复制或重新排列元素,但是不能考察这些元素,也不能对它们进行具体操纵。

请参阅Wadler的《Theorems for Free!》和Reynolds的《Types, Abstraction and Parametric Polymorphism》。

起初这听起来非常令人印象深刻(确实如此!),但细查,你可能会意识到这与经验并不一致。例如,在Java中,多态方法依然可以使用instanceof在运行时检查、获得特定类型的值,并相应的改变行为。这种方法就不是关系型参数了!【注释】事实上,关系型参数也能被看作是语言弱点的一种表述:它只允许一组有限的操作。(你仍可以检查类型——但不能根据你获取的信息进行相关行动,这样检查就没有意义了。因此运行时系统如果想要模拟关系型参数,必须要移除类似instanceof及它的替代行为:例如,对值进行加一操作并捕获异常以判断它是数。)然而,这是个非常优雅和令人吃惊的结果,显示了使用丰富类型系统能获得的强大程序推理能力。

网上,你会经常发现这个属性被描述为函数不能检查其参数——这是不正确的。

15.3.2 类型推断

手工书写每处多态类型的实例参数是一个令人沮丧的过程,很多版本的Java和C++用户可以证明这点。想象一下,每次使用firstrest时都需要传入类型参数是个什么场景!我们之所以能够避免这种命运,是因为我们的语言实现了类型推断。这使我们可以编写定义:

(define (mapper f l)
  (cond
    [(empty? l) empty]
    [(cons? l) (cons (f (first l)) (mapper f (rest l)))]))

然后编程环境自动声明

> mapper
- (('a -> 'b) (listof 'a) -> (listof 'b))

它不仅是正确的类型,而且是非常一般的类型!从程序结构中派生出这种一般类型的过程感觉几乎就是魔法。我们来揭示其幕后。

首先,我们来了解类型推断做了什么。有些人错误的认为,有类型推断的语言无类型声明,其被类型推断取而代之了。这混淆了多个层面的东西。首先,即使在有类型推断的语言中,程序员仍被允许声明类型(并且为了文档更为清晰,通常会鼓励这样做——就像你之前被鼓励的一样)【注释】。此外,在没有这些声明的情况下,推断的实际含义并不显明。

有时(类型)推断是不可判定的,这时程序员别无选择只能声明某些类型。最后,显式的书写类型注解能够大大减少难以辨认的错误信息。

相反,最好将底层语言看作需要完整地显式声明类型的——就如我们刚才研究的多态语言。然后我们说,在:后类型注解部分可以留空,编程环境中的某个特性会为我们填充这些。(如果走得更远,我们可以丢弃:及额外的修饰,它们都会被自动插入。因此,类型推断只是为用户提供的一种便利,减轻编写类型注解的负担,而底层的语言仍然是显式声明类型的。

我们怎么考虑类型推断做的是什么呢?假设我们有个表达式(或者程序)e,由显式声明类型语言书写:也就是说在任何需要类型注解的地方都有写出。现在假设我们擦除e中所有的类型注解,然后使用函数infer将它们推断回来。

思考题

infer应该有何种属性?

我们可以要求很多东西。其中之一为,它要产生和e原来恰好一样的注解。这在很多方面都是有问题的,尤其是当e本就不能通过类型检查的情况下,怎么能推断回它们(应该)是什么?你可能觉得这是个学究式的玩笑:毕竟,如果e本就不能通过类型检查,如果能在删除其注解之后还能还原回来呢?反正两者都不能通过类型检查,谁在乎啊?

思考题

这个推理正确吗?

假设e是:

(lambda ([x : number]) : string x)

它显然不能通过类型检查。但是如果我们擦除类型注解——得到

(lambda (x) x)

——这个函数显然可以合法地添加类型!因此,更合理的需求可以是,如果原始的e能通过类型检查,那么对应的使用了推导出的注解的版本也必须能。这种单向的含义的用途体现在两方面:


  1. 它没有说e未通过类型检查应该怎样,也即它不会排除前述的类型推断算法,其会将例子中类型错误的恒等函数变成类型正确的。
  2. 更重要的是,它向我们保证,使用类型推断将不会使我们失去任何东西:之前能通过类型检测的程序不会被推断后而不能。这意味着我们可以在想要的地方显式添加类型注解,但不会被迫这样做。
当然,这只在程序推断可判定的情况下才成立。

我们还可能希望两者类型是相同的,但这不是能做到的:函数

(lambda ([x : number]) : number x)

类型为(number -> number),而擦除类型注解后推导出的类型要一般得多。因此,将这些类型关联并给出类型相等的定义并不简单,尽管如此后面将简要讨论此问题。

有了这些准备,我们下面进入对类型推断机制的研究。最需要注意的地方,前述的简单递归下降的类型检查算法将不再起作用。它之前能起作用,是因为所有函数的边界处都有类型注解,所以我们下降进入函数体,同时用类型环境携带这些注解中包含的信息。没了这些注解,就不知如何递归下降了。

事实上,目前还不清楚哪个方向更合理。像上面mapper的定义,各代码段之间互相影响。例如,从empty?cons?firstrestl的调用都可以看出它是链表。但是是什么的链表呢?从这些操作看不出来。然而,对于其每个(或者应该说,任意)first元素调用了f这点可以看出,链表成员的类型必须可以被传给f。同理,由emptycons我们可以知道(mapper的)返回表达式必须为链表。它的成员类型是什么呢?必须为f的返回类型。最后,请注意最微妙的地方:当参数链表为空时,我们返回empty而不是l(这时我们是知道其被绑定到empty)。使用前者,返回值的类型可能是任意类型的链表(仅受f返回类型的约束);使用后者,返回的类型就被迫和参数链表的类型相同。

所有这些信息都包含在函数里。但是我们如何系统地提取出这些信息呢,而且使用的算法必须会终止,并满足前面陈述属性?我们分两步来做。首先,根据程序表达式生成其必须要满足的类型约束。然后,通过合并散布在函数体各处的约束、识别其中的不一致,最终解决约束。每一步都相对简单,但是组合起来创造了魔力。

15.3.2.1 约束生成

我们最终的目标是给每个类型注解位置填入类型。将会证明,这也等同于找到每个表达式的类型。简单想想就知道,这本来也是必要的:比如,在不知道函数体类型的情况下,如何能确定函数本身的类型?这也是足够的,因为如果每个表达式的类型都被计算得出,其中必然包括了那些需要被注解的表达式。

首先,我们需要生成(待解决的)约束。这一步会遍历程序源码,为每个表达式生成恰当的约束,最后返回这组约束。为了简单,使用递归下降的方式实现;它最终生成约束的集合,所以原则上遍历和生成的顺序是无关紧要的——因此我们选择了相对简单的递归下降方式——当然,为了简单起见,我们使用链表表示这个集合。

约束是什么呢?就是关于表达式类型的陈述。此外,虽然变量绑定并不是表达式,但我们仍需计算其类型(因为函数需要参数和返回值类型)。一般来说,对于表达式的类型我们知道些什么呢?


  1. 它和某些标识符的类型有关。
  2. 它和某些其它表达式的类型有关。
  3. 它是数。
  4. 它是函数,其定义域(domain)和值域(range)类型可能受到进一步的约束。

因此,我们定义如下两个数据结构:

(define-type Constraints
  [eqCon (lhs : Term) (rhs : Term)])

(define-type Term
  [tExp (e : ExprC)]
  [tVar (s : symbol)]
  [tNum]
  [tArrow (dom : Term) (rng : Term)])

接下来定义约束生成函数:

<constr-gen> ::= ;约束生成

    (define (cg [e : ExprC]) : (listof Constraints)
      (type-case ExprC e
        <constr-gen-numC-case>
        <constr-gen-idC-case>
        <constr-gen-plusC/multC-case>
        <constr-gen-appC-case>
        <constr-gen-lamC-case>))

当表达式为数时,唯一能说的是,我们希望该表达式的类型为数类型:

<constr-gen-numC-case> ::=

    [numC (_) (list (eqCon (tExp e) (tNum)))]

听上去很微不足道,但我们不知道的是,其他包含它的表达式是什么。因此,某个更大的表达式可能会与此断言——这个表达式的类型必须是数型——相矛盾,从而导致类型错误。

对于标识符,我们只是简单地说,表达式的类型就是我们所期望该标识符应有的类型:

<constr-gen-idC-case> ::=

    [idC (s) (list (eqCon (tExp e) (tVar s)))]

如果上下文限制了其类型,该表达式的类型将自动受到限制,并且必须与上下文的期望一致。

加法是我们第一个遇到的上下文约束。对于加法表达式,首先需要确保我们生成(并返回)其两个子表达式的约束,而子表达式可以是复杂的。这两个约束中,我们期望什么?需要每个子表达式是数类型的。(如果其中一个子表达式不是数类型的,应该导致类型错误。)最后,我们断言整个表达式的类型为数。

<constr-gen-plusC/multC-case> ::=

    [plusC (l r) (append3 (cg l)
                          (cg r)
                          (list (eqCon (tExp l) (tNum))
                                (eqCon (tExp r) (tNum))
                                (eqCon (tExp e) (tNum))))]
append3append的三参数版本。

multC的情况与之相同,区别只在名字上。

下面我们来看另外两个有趣的情况,函数声明和调用。两种情况下我们都需要生成和返回子表达式的约束。

在函数定义中,函数的类型是函数(“箭头/arrow”)类型,其参数类型是形参的类型,其返回类型是函数体的类型。

<constr-gen-lamC-case> ::=

    [lamC (a b) (append (cg b)
                        (list (eqCon (tExp e) (tArrow (tVar a) (tExp b)))))]

最终,考虑函数调用。我们不能直接陈述函数调用的类型约束。不过,我们可以说,函数接受的参数类型必须和实际参数的类型相同,并且函数返回的类型就是调用表达式的类型。

<constr-gen-appC-case> ::=

    [appC (f a) (append3 (cg f)
                         (cg a)
                         (list (eqCon (tExp f) (tArrow (tExp a) (tExp e)))))]

完成了!我们已经完成约束的生成;现在只需解出它们。

15.3.2.2 使用合一求解约束

求解约束的过程也被称为合一(unification)。合一器的输入是等式的集合,其中每个等式是变量到项(term)的映射,项的数据类型在上面定义了。注意到一点,我们实际上有种变量。tvartExp都是“变量”,前者很明显,注意后者同样也是,因为我们需要求解此类表达式的类型。(另一种方式是为每个表达式引入新的类型变量,但我们仍需一种方法确定这些变量与表达式之间的对应关系,而现在这已经能通过对表达式进行eq?操作自动完成了。另外这会产生大得多的约束集,不好进行人工检查。)

就我们的目的而言,合一是为了是生成替换(substitution),或者说将变量映射为不包含任何变量的项。这听起来应该很耳熟:我们有一组联立方程,其中每个变量都是线性使用的;这种方程组可以使用高斯消元法求解。该情形中,我们清楚最终可能遇到缺少约束(under-constrained)或过度约束(over-constrained)的情况。这种事情同样也将发生这里。

合一算法会遍历约束集合。由于每个约束有两项,每个项有四种可能的类型,因此有十六种情况需要考虑。幸运的是,我们实际可以用比较少的代码覆盖这十六种情况。

算法从所有约束的集合和空替换开始。每个约束都会被处理一次,并从集合中删除,因此原则上终止判据应该非常简单,但是实际处理起来还有点小麻烦。随着约束被处理,替换集合会逐渐增长。当所有的约束都被处理完后,合一过程返回最后的替换集合。

对于给定的约束,合一器检查等式左边,如果它是变量,那么这时它就可以被消除了,合一器将该变量(等式)的右侧添加到替换中,为了真正完成消除,还需要将替换集中所有该变量的出现替换成该右侧。实践中,实现需要考虑效率;例如,使用可变值表示这些变量可以避免搜索—替换过程。然而我们可能需要进行回溯(我们在后面确实会需要),可变值表示也有缺点。

思考题

注意到上面微妙的错误了吗?

这个微妙的错误是,我们说合一器通过替换变量的所有实例来消除它。不过,我们假设等式右侧不包含该变量的实例。不然的话,我们将得到循环定义,这将使替换变得不可能。出于这个原因,合一器会进行出现检查(occurs check):检查某个变量是否出现在等式两侧,如果是,则拒绝合一。

思考题

构造一个其约束会触发出现检查的项。

还记得ω吗?

下面考虑合一的实现。惯例使用希腊字母Θ表示替换。

(define-type-alias Subst (listof Substitution))
(define-type Substitution
  [sub [var : Term] [is : Term]])

(define (unify [cs : (listof Constraints)]) : Subst
  (unify/Θ cs empty))

首先把简单的东西写出来:

<unify/Θ> ::=

    (define (unify/Θ [cs : (listof Constraints)] [Θ : Subst]) : Subst
      (cond
        [(empty? cs) Θ]
        [(cons? cs)
         (let ([l (eqCon-lhs (first cs))]
               [r (eqCon-rhs (first cs))])
           (type-case Term l
             <unify/Θ-tVar-case>
             <unify/Θ-tExp-case>
             <unify/Θ-tNum-case>
             <unify/Θ-tArrow-case>))]))

现在可以实现合一的核心了。我们需要一个辅助函数extend-replace,其签名为(Term Term Subst -> Subst)。它将执行出现检查,如果检查得出没有环路,则扩展替换集合,并将替换集合中所有出现的第一个项(第一个参数)替代为第二个项(第二个参数)。同样,我们假设lookup: (Term subst -> (optionof Term))存在。

练习题

定义extend-replacelookup

如果约束等式的左侧是个变量,我们先在替换集合中寻找它。如果存在,我们将当前约束换成新的约束;否则我们扩展替换集合。

<unify/Θ-tVar-case> ::=

    [tVar (s) (type-case (optionof Term) (lookup l Θ)
                [some (bound)
                      (unify/Θ (cons (eqCon bound r)
                                     (rest cs))
                               Θ)]
                [none ()
                      (unify/Θ (rest cs)
                               (extend+replace l r Θ))])]

同样的逻辑也适用于表达式的情况:

<unify/Θ-tExp-case> ::=

    [tExp (e) (type-case (optionof Term) (lookup l Θ)
                [some (bound)
                      (unify/Θ (cons (eqCon bound r)
                                     (rest cs))
                               Θ)]
                [none ()
                      (unify/Θ (rest cs)
                               (extend+replace l r Θ))])]

如果是基本类型,例如数,我们就需要检查等式右边。有四种可能:


  • 如果是数,那么该等式声明类型num等于num,这恒为真。因此我们可以忽略该约束——它没有告诉我们什么有用信息——继续检查剩下的。
    当然,首先得解释为什么会出现这种约束。显然,我们的约束生成器不会生成这种约束。然而,前面替换集合的扩展会导致这种情况。事实是实践中我们会遇到好几个这种情况。
  • 如果是函数类型,显然存在类型错误,因为数和函数类型不相交。同样,我们不会直接生成这样的约束,一定是由先前的替代产生。
  • 它可能是两种变量类型之一。不过,我们的约束生成器经过了仔细的安排,不会将它们放在右侧。此外,替代过程也不会在右侧引入它们。因此,这两种情况不会发生。

于是得出这样的代码:

<unify/Θ-tNum-case> ::=

    [tNum () (type-case Term r
               [tNum () (unify/Θ (rest cs) Θ)]
               [else (error 'unify "number and something else")])]

最后还剩下函数类型。这里的论点几乎和数类型完全一样。

<unify/Θ-tArrow-case> ::=

    [tArrow (d r) (type-case Term r
                    [tArrow (d2 r2)
                            (unify/Θ (cons (eqCon d d2)
                                           (cons (eqCon r r2)
                                                 cs))
                                     Θ)]
                    [else (error 'unify "arrow and something else")])]

请注意,我们并没有严格地缩小约束集合,因此仅通过约束集合的大小不足以判断这个过程会终止。需要同时综合考虑约束集合的大小以及替换的大小(包括其中变量的个数)。

上面的算法非常通用,不仅对数和函数,对于各种类型项也都适用。我们使用数代表各种基础类型;同样,使用函数代表各种构造类型,例如listofvectorof

这就完成了。合一产生了替换。现在我们可以遍历这些替换,找到程序中所有表达式的类型,然后插入对应的类型注解。有定理(这里不证明)指出,上面过程的成功意味着程序通过了类型检查,因此我们无需对该程序显式地再跑一遍类型检查。

不过请注意,类型错误的性质在这里发生了巨大变化。之前,我们的递归下降算法利用类型环境遍历表达式。类型环境中的绑定是程序员定义的类型,因此可以被当作(期望的)权威的类型规范(specification)。因此,所有的错误都应归咎于表达式,类型错误的报告很简单(而且很好懂)。然而这里,类型错误无法通知。合一错误是两个智能算法——约束生成和合一——共同导致的,因此程序员不一定能理解。特别是,由于约束的本质是等式,报告的错误位置和“真实”的错误位置可能相差甚远。因此,生成更好的错误信息仍然是个活跃的研究领域。

实践中,算法会维护涉及到的程序源码的元信息,并可能也会保存合一的历史,以便溯源错误回源程序。

最后,请记住,约束可能不会精确指明所有变量的类型。如果方程组过度约束,可能会有冲突,导致类型错误。如果缺少约束,这意味着我们没有足够的信息对所有表达式做出明确的类型声明。例如,对于表达式(lambda (x) x),没有足够的约束指明x的类型,从而无法以指明整个表达式的类型。这并非错误;它只是意味着x可以是任意类型。换句话说,该表达式的类型是“x的类型->x的类型”,无其它约束。这些欠约束标识符的类型以类型变量的方式展示,于是上面表达式的类型可以表示为('a -> 'a)

合一算法实际上有个很好的属性:它能自动计算表达式最通用的类型,也被称为主类型(principal type)。这就是说,表达式可以有的任何实际类型都可以通过(用实际类型)替换推导出的类型中的类型变量的得到。这是个异乎寻常的结果:没人能生成比前述算法得出的更为一般的类型!

15.3.2.3 Let-多态

很不幸,尽管这些类型变量表面上看和我们之前遇到的多态有诸多相似之处,但它们并不同。考虑下面的程序:

(let ([id (lambda (x) x)])
  (if (id true)
      (id 5)
      (id 6)))

如果加上显式的类型注解,它能通过类型检查:

(if ((id boolean) true)
    ((id number) 5)
    ((id number) 6))

然而,如果使用类型推断,它将不能通过类型检查!因为id中的类型'a——取决于约束处理的顺序——要么和boolean合一,要么和number合一。对应的,那时id的类型要么是(boolean -> boolean)要么是(number -> number)。当使用另一个类型调用id时,就会发生类型错误!

这是因为我们通过合一推断出来的类型实际并不是多态的。这点很重要:将其称为类型变量不会使你获得多态!类型变量可以在下次使用时合一,彼时,最终得到的还只是单态函数。而真正的多态只有在能真正进行类型变量实例化时才会获得。

所以在具有真正多态的语言中,约束生成和合一是不够的。相反,像ML和Haskell这种语言,甚至我们使用的静态类型语言也是,都实现了俗称let-多态的东西。这种策略中,当包含类型变量的项在词法环境中被绑定时,该类型被自动提升为量化类型。每次使用时,该项被自动实例化。

很多实现策略可以做到这点。最简单(而不令人满意)的方式只需复制绑定标识符代码的代码;这样,上面每次id的使用都会得到自己的(lambda (x) x)副本,所以每个都有它自己的类型变量。第一个的类型可能是('a -> 'a),第二个是('b -> 'b),第三个是('c -> 'c),等等。这些类型变量互不冲突,因此我们得到多态的效果。显然,这不仅增加了程序的大小,而且在存在递归的情况下也不起作用。然而,这给我们提供了通往更好解决方案的思路:不是复制代码,而是复制类型。因此在每次使用时,我们创建推导出类型的重命名版本:第一次使用时,id的类型('a -> 'a)变成了('b -> 'b),以此类推,这种方式实现了拷贝代码相同的效果且没有它的包袱。不过,因为这些策略实质都是效仿代码拷贝,因此它们只能在词法环境下工作。

文章被以下专栏收录