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

Programming Languages: Application and Interpretation【译15上】

初翻: @lotuc

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


15 静态地检查程序中的不变量:类型

当程序变得更大或者更为复杂时,程序员希望能有工具帮助他们描述、验证程序中的不变量。顾名思义,不变量指的就是关于程序组成元素的那些不会发生改变的陈述。例如,当我们在静态类型语言中写下x : number时,表示 x 中存放的总是数,程序中依赖 x 的部分都可以认定它是数的这个事实不会改变。我们将会看到,类型只是我们想要陈述的各类不变量中的一种,静态类型检测——一个分支众多的技术家族——也只是用于控制不变量的众多方法中的一个。

15.1 静态类型规则

本章我们将专注于静态类型检查:即在程序执行前检查(声明的)类型。之前使用的静态类型语言已经让我们积攒了一些这种形式程序的经验。我们将探索类型的设计空间及这些设计中的权衡取舍。尽管类型是控制不变量的一种非常强大且有效的方法,最后我们还是会考察一些其它可用的技术。

考虑下面这段静态语言写就的程序:

(define (f [n : number]) : number
  (+ n 3))

(f "x")

程序开始执行前我们就会得到一个静态类型错误。使用普通 Racket 写就的同样的程序(去除类型注解)只会在运行时出错:

(define (f n)
  (+ n 3))

(f "x")

练习题

如何判断错误是在程序执行前还是运行时抛出的?

考虑下面这段 Racket 程序:

(define f n
  (+ n 3))

它也是在程序执行前就遇到错误——语法解析错误——终止。尽管我们认为语法解析和类型检查有所不同——通常是因为类型检测是针对已经被解析好的程序做的——但是将语法解析看作一种最简单形式的类型检查也很有用:它(静态地)判定程序是否遵守某个上下文无关语法。随后,类型检查判定它是否遵守某个上下文相关(或者一个更丰富的)语法。简而言之,类型检查从某种程度上看是语法解析的泛化,它们都是通过语法控制程序遵循指定的规则。

15.2 关于类型的经典看法

我们先介绍传统的包含类型的核心语言;然后我们将探索其扩展和变种。

15.2.1 简单的类型检查器

要定义类型检查器,我们先需要就两件事达成一致:我们静态类型核心语言的语法,对应的类型的语法。

先回到我们之前实现过的函数作为值的那一版语言,其中并不包含赋值等其它稍复杂的东西(后面将讲到添加其中的一些)。我们需要为该语言添加类型注解。按惯例,我们不对常量或基本操作(如加法)强加类型注释;相反,我们把类型注释加在函数或方法的边界上。在本章讨论的过程中,我们将探讨为什么这么做。

鉴于此决定,我们静态类型的核心语言变成了:

(define-type TyExprC
  [numC (n : number)]
  [idC (s : symbol)]
  [appC (fun : TyExprC) (arg : TyExprC)]
  [plusC (l : TyExprC) (r : TyExprC)]
  [multC (l : TyExprC) (r : TyExprC)]
  [lamC (arg : symbol) (argT : Type) (retT : Type) (body : TyExprC)])

每个函数都添加了其参数及返回值类型的注解。

现在我们需要对类型语言作出选择。我们遵从传统定义,即类型是一组值的集合的抽象。我们的语言中有两类值:

(define-type Value
  [numV (n : number)]
  [closV (arg : symbol) (body : TyExprC) (env : Env)])

因此我们有两种类型:数和函数。

即使数类型也并不那么简单直接:数类型应该记录何种信息?大部分语言中,实际上有很多数类型,甚至没有哪个类型表示“数”。然而,我们忽略了数的层级结构(译注,第三章),对于我们来说有一种数的类型足矣。这样决定之后,我们是否需要记录哪种数的信息? 原则上可以,但这样我们很快就会遇到可判定性问题。

至于函数,我们有更多信息:参数的类型,返回值的类型。我们不妨记录下这些信息,除非事后证实这些信息没有用处。结合这些,我们得出这样的类型的抽象语言:

(define-type Type
  [numT]
  [funT (arg : Type) (ret : Type)])

既然已经确定了语言中项和类型的结构,接下来我们来确定语言中哪些算是类型错误(并且,如果程序中不包含这里列出的类型错误,它就会通过类型检查)。显然有三种形式的类型错误:


  • +的参数不是数,即不是numT
  • *的参数不是数。
  • 函数调用时函数位置的表达式不是函数,即不是funT

思考题

还有其它形式的类型错误吗?

事实上我们遗漏了一个:


  • 函数调用时实参的类型和函数形参的类型不一致。

我们的语言中的所有其他程序似乎都应该通过类型检查。

关于类型检查器的签名,初步设想,它可以接受表达式作为参数,返回布尔值指明该表达式是否通过检查。由于我们知道表达式中包含标识符,所以很显然我们还需要一个类型环境,它将名字映射到类型,类似于我们之前用到的值环境。

练习题

定义与类型环境相关的数据类型以及函数。

于是,我们开始写下的程序结构大致是这样:

<tc-take-1> ::=  ;;类型检查,第一次尝试

    (define (tc [expr : TyExprC] [tenv : TyEnv]) : boolean
      (type-case TyExprC expr
        <tc-take-1-numC-case>
        <tc-take-1-idC-case>
        <tc-take-1-appC-case>))

正如上面程序中列出的要处理几种情形所表明的,这种方法行不通。我们很快将知道这是为什么。

首先处理简单的情形:数。单独的一个数能通过类型检查吗?显然可以;它所处的上下文可能想要的不是数类型,但是这种错误应该在其它地方被检查出。因此:

<tc-take-1-numC-case> ::=

    [numC (n) true]

下面处理标识符。如何判断标识符是否通过类型检查呢?同样,就其自身来说,如果是绑定标识符,总是通过检查的;它可能不是上下文要求的那种类型,但是这种错误应该在其它地方检查。因此,我们得出:

<tc-take-1-idC-case> ::=

    [idC (n) (if (lookup n tenv)
                 true
                 (error 'tc "not a bound identifier"))]  ;不是绑定标识符

上面的代码你可能感觉不太对:如果标识符未绑定的话,lookup会抛出异常,因此没必要再去重复处理该情况(事实上,代码永远不会执行到error调用那个分支)。但是让我们先继续。

下面来处理函数调用。我们应该首先检查函数位置,确定它是个函数,然后确保实际参数的类型和该函数定义时声明的形式参数类型相同。例如,函数可能需要参数是数,但调用给的是个函数,或者反之,在这两种情况下,我们都需要防止错误的函数调用。

代码该怎么写?

<tc-take-1-appC-case> ::=

    [appC (f a) (let ([ft (tc f tenv)])
                  ...)]

对于tc的递归调用只能让我们知道函数位置是否通过类型检查。如果它通过了,怎么知道它具体是什么类型的呢?如果是个简单的函数定义的话,我们可以直接从语法上取得其参数和返回值的类型。但是如果是个复杂的表达式,我们就需要一个函数能计算出表达式类型。当然,只有这个表达式是个类型正确的表达式时,该函数才能返回类型结果;否则的话它将不能得出正确的结果。换句话说,“类型检查”是“类型计算”的一种特殊情形!因此,我们应该增强tc的归纳不变量:即,不仅仅返回表达式是否能通过类型检查,而是返回表达式的类型。事实上,只要有返回值,就说明该表达式通过了类型检查;否则它会抛出错误。

下面我们来定义这个更完善的类型“检查器”。

<tc> ::=

    (define (tc [expr : TyExprC] [tenv : TyEnv]) : Type
      (type-case TyExprC expr
        <tc-numC-case>
        <tc-idC-case>
        <tc-plusC-case>
        <tc-multC-case>
        <tc-appC-case>
        <tc-lamC-case>))

现在填充具体实现。数很简单:它的类型就是数类型。

<tc-numC-case> ::=

    [numC (n) (numT)]

与之相似,标识符的类型从环境中查询得到(如果其未被绑定则会抛出错误)。

<tc-idC-case> ::=

    [idC (n) (lookup n tenv)]

到此,我们可以观察到该类型检查器与解释器之间的一些异同:对于标识符,两者做的事情其实一样(只不过这里返回的是标识符的类型而不是一个实际的值),对于数的情况,这里返回了抽象的“数”而不是具体的数。

下面考虑加法。必须确保其两个子表达式都具有数类型;如果满足该条件,则加法表达式本身返回的是数类型。

<tc-plusC-case> ::=

    [plusC (l r) (let ([lt (tc l tenv)]
                       [rt (tc r tenv)])
                   (if (and (equal? lt (numT))
                            (equal? rt (numT)))
                       (numT)
                       (error 'tc "+ not both numbers")))] ;+不都是数

通常在处理完加法的情形之后,对于乘法我们就一笔带过了,但是这里显式处理一下它还是很有教益的:

<tc-multC-case> ::=

    [multC (l r) (let ([lt (tc l tenv)]
                       [rt (tc r tenv)])
                   (if (and (equal? lt (numT))
                            (equal? rt (numT)))
                       (numT)
                       (error 'tc "* not both numbers")))] ;*不都是数

思考题

看出其中的区别了吗?

是的,基本上完全没区别!(仅有的区别是在type-case时使用的分别multCplusC,以及错误提示信息稍有不同)。这是因为,从(此静态类型语言)类型检查的角度来说,加法和乘法没有区别,更甚,任意接受两个数作为参数返回一个数的函数都没有区别。

注意到代码解释和类型检查之间另一个不同点。它们的参数都得是数。解释器返回加或者乘它们得到的确切数值,但是类型检查器并不在乎具体的数值:因此该表达式的计算结果((numT))是个常数,两种情形返回都是该常数。

最后还剩下两个难一点的情形:函数调用和函数。我们已经讨论过怎么处理函数调用:计算函数以及参数表达式的值;确保函数表达式为函数类型;检查参数类型和函数形参类型相容。如果这些条件满足,函数调用得到的结果类型就是函数体的类型(因为运行时最终的返回值就是计算函数体得到的值)。

<tc-appC-case> ::=

    [appC (f a) (let ([ft (tc f tenv)]
                      [at (tc a tenv)])
                  (cond
                    [(not (funT? ft))
                     (error 'tc "not a function")] ;不是函数
                    [(not (equal? (funT-arg ft) at))
                     (error 'tc "app arg mismatch")] ;app参数不匹配
                    [else (funT-ret ft)]))]

最后还剩下函数定义。函数有一个形参,函数体中一般会用到;除非它被绑定到环境中,不然函数体应该不太可能通过类型检查。因此我们需要扩展类型环境,添加形参与其类型的绑定,然后在扩展后的环境中检查函数体。最终计算得到的函数体类型必须和函数定义中指定的函数返回值类型相同。如果满足了这些,该函数的类型就是指定参数类型到函数体类型的函数。

练习题

上面说的“不太可能通过类型检查”是什么意思?
<tc-lamC-case> ::=

    [lamC (a argT retT b)
          (if (equal? (tc b (extend-ty-env (bind a argT) tenv)) retT)
              (funT argT retT)
              (error 'tc "lam type mismatch"))] ;λ类型不匹配

注意到解释器与类型检查器另一个有趣的不同点。解释器中,函数调用负责计算参数表达式的值,扩展环境,然后对函数体求值。而这里,函数调用的情形中的确也检查了参数表达式,但是没有涉及到环境的处理,直接返回了函数体的类型,而没有遍历它。对函数体的遍历检查过程实际是在检查函数定义的过程中进行的,因此环境也是在这个地方才实际被扩展的。

15.2.2 条件语句的类型检查

考虑为上面的语言添加条件语句,即使最简单的 if 表达式都会引入若干设计抉择。这里我们先讨论其中的两个,后面会回过头讨论其中的一个。


  1. 条件表达式的类型应该是什么?某些语言中它必须计算得到布尔值,这种情况下需要为我们的语言添加布尔值类型(这可能是个好主意)。其它语言中,它可以是任意值,某些值被认为是“真值”,其它的则被视为“假值”。
  2. then-else-两个分支之间的关系应该是什么呢?一些语言中它们的类型必须相同,因此整个 if 表达式有一个确定无歧义的类型。其它语言中,两个分支可以有不同的类型,这极大地改变了静态类型语言的设计和它的类型检查器,而且也改变了编程语言本身的性质。

练习题

为该静态类型语言添加布尔值。至少需要添加些啥?在典型的语言中还需要加什么?

练习题

为条件语句添加类型规则,其中条件表达式应该计算得到布尔值,且then-else-分支必须有相同的类型,同时该类型也是整个条件语句的类型。

15.2.3 代码中的递归

现在我们已经得到了基本的编程语言,下面为其添加递归。之前我们实现过递归,可以很容易的通过去语法糖实现。这里的情况要更复杂一些。

15.2.3.1 递归的类型,初次尝试

首先尝试表示一个简单的递归函数。最简单的当然就是无限循环。我们可以仅使用函数实现无限循环吗?可以:

((lambda (x) (x x))
 (lambda (x) (x x)))

因为我们的语言中已经支持将函数作为值。

练习题

为什么这会构成无限循环?它是如何巧妙地依赖于函数调用的本质的?

现在我们的静态类型语言要求我们为所有函数添加类型注解。我们来为该函数添加类型注解。简单起见,假设从现在开始我们写的程序使用的语法是静态类型的表层语法,去语法糖将帮我们将其转换为核心语言。

首先注意到,我们有两个完全一样的表达式,它们互相调用。历史原因,整个表达式被称为Ω(希腊字母大写欧米茄),那两个一样的子表达式被称为ω(希腊字母小写欧米茄)。两个一样的表达式并非得是同种类型的,因为这还依赖于具体使用环境中对于不变量的定义。这个例子中,观察到 x 被绑定到ω,于是ω将出现在在(x x)式子的第一个和第二个部分。即,确定其中一个表达式的类型,另一个式子的类型也被确定。

那么我们就来尝试计算ω的类型;称该类型为γ。显然它是一个函数类型,而且是单参数的函数,所以它的类型必然是φ -> ψ这种形式的。该函数的参数是什么类型?就是ω的类型。也即,传入φ的值的类型就是γ。因此,ω的类型是γ,也即φ -> ψ,展开即(φ -> ψ) -> ψ,进一步展开得((φ -> ψ) -> ψ) -> ψ,还可以继续下去。也就是说,该类型不能用有限的字符串写出来!

思考题

你注意到了我们刚做的的微妙但重要的跳跃吗?

15.2.3.2 程序终止

我们观察到,试图直接地计算Ω的类型,需要先计算γ的类型,这似乎导致了严重的问题。然后我们就得出结论:此类型不能用有限长度的字符串表示,但是这只是直觉的结果,并非证明。更奇怪的事实是:在我们迄今定义的类型系统中,根本无法给出Ω的类型

这是一个很强的表述,但事实上我们可以给出更强的描述。我们目前所用的静态类型语言有一个属性,称为强归一化(strong normalization):任何有类型的表达式都会在有限步骤后终止计算。换句话,这个特殊的(奇特的)无限循环程序并不是唯一不可获得类型的程序;任何无限循环(或潜在存在无限循环)程序都无法求得类型。一个简单的直觉说明可以帮助我们理解,任何类型——必须能被有限长度的字符串表示——只能包含有限个->,每次调用会去除一个->,因此我们只能进行有限次数的函数调用。

如果我们的程序只允许非转移程序(straight-line program),这点也无足为奇。但是,我们有条件语句,还有可以当做值任意传递的函数,通过这些我们可以编码得到任何我们想要的数据结构。然而我们仍能得到这个保证!这使得这个结果令人吃惊。

练习题

试着使用函数分别在动态类型和静态类型语言中编码实现列表。你看到了什么?这说明此类型系统对于编码产生了何种影响?

这个结果展示了某种更深层次的东西。它表明,和你可能相信的——类型系统只是用来避免一些程序 BUG 在运行时才被发现——相反,类型系统可能改变语言的语义。之前我们一两行就能写出无限循环,现在我们怎么都写不出来。这也表明,类型系统不仅可以建立关于某个特定程序的不变量,还能建立关于语言本身的不变量。如果我们非常需要确保某个程序将会终止,只要用该语言来写然后交由类型检查器检查通过即可。

一门语言,用其书写的所有程序都将终止,有什么用处?对于通用编程来说,当然没用。但是在很多特殊领域,这是非常有用的保证。例如,你要实现一个复杂的调度算法;你希望知道调度程序保证会终止,以便那些被调度的任务被执行。还有许多其他领域,我们将从这样的保证中受益:路由器中的数据包过滤器;实时事件处理器;设备初始化程序;配置文件;单线程
JavaScript 中的回调;甚至编译器或链接器。每种情况下,我们都有一个不成文的期望,即这些程序最终会终止。而现在我们有一个语言能保证这点——且这点是不可测试的。

这不是假想的例子。在Standard ML语言中,链接模块基本上就是使用这种静态类型语言来编写模块链接规范。这意味着开发人员可以编写相当复杂的抽象概念——毕竟可以将函数作为值使用——且同时链接过程被保证会终止,产生最终的程序。

15.2.3.3 静态类型的递归

这就意味着,之前我们可以只通过去语法糖来实现rec,现在则必须在我们的静态类型语言中显式的实现。简单起见,我们仅考虑rec的一种特殊形式——它涵盖了常见用法,即递归标识符被绑定到函数。因此,表层语法中,我们可能写出如下的累加函数:

(rec (Σ num (n num)
        (if0 n
             0
             (n + (Σ (n + -1))))) ;译注,原文如此,+应前置
  (Σ 10))

其中,Σ是函数名,n为其参数,num为函数参数以及返回值的类型。表达式(Σ 10)表示使用该函数计算从 10 累加到 0 的和。

如何计算这个表达式的类型?显然,求类型过程中,n在函数体中的类型需要绑定(但是在函数调用处就不需要了);这一点计算函数类型的时候我们就知道了。那么Σ呢?显然,在检查(Σ 10)的类型时,它应该在类型环境中被绑定,类型必须为num -> num。不过,在检查函数体时,它同样需要被绑定到此类型。(还要注意,函数体返回值的类型需要和事先声明的返回类型相同。)

现在我们可以看到如何打破类型有限性的束缚。程序代码中,我们只能编写包含有限数量->的类型。但是,这种递归类型的规则在函数体中引用自身时复制了->,从而供应了无穷的函数调用。这是包含无穷箭矢的箭筒。

实现这种规则的代码如下。假设f被绑定到函数的名字,aT是函数参数的类型,rT为返回类型,b是函数体,u是函数的使用:

<tc-lamC-case> ::=

    [recC (f a aT rT b u)
          (let ([extended-env
                 (extend-ty-env (bind f (funT aT rT)) tenv)])
            (cond
              [(not (equal? rT (tc b
                                   (extend-ty-env
                                    (bind a aT)
                                    extended-env))))
               (error 'tc "body return type not correct")] ;函数体类型错误
              [else (tc u extended-env)]))]

15.2.4 数据中的递归

我们已经见识了静态类型的递归程序,但是它还不能使我们创建递归的数据。我们已经有一种递归数据——函数类型——但是这是内建的。现在还没看到如何创建自定义的递归数据类型。

15.2.4.1 递归数据类型定义

当我们说允许程序员创建递归数据时,我们实际在同时谈论三种东西:


  • 创建新的类型
  • 让新类型的实例拥有一个或多个字段
  • 让这些字段中的某些指向同类型的实例

实际上,一旦我们允许了第三点,我们就必须再允许一点:


  • 允许该类型中非递归的基本情况的存在

这些设计准则的组合产生了通常被称为代数数据类型(algebraic datatype)的东西,比如我们的静态语言中支持的类型。举个例子,考虑下面这个数二叉树的定义:【注释】

(define-type BTnum
  [BTmt]
  [BTnd (n : number) (l : BTnum) (r : BTnum)])
后面我们会讨论如何参数化类型。

请注意,如果这个新的数据类型没有名字,BTnum,我们将不能在BTnd中引用回该类型。同样地,如果只允许定义一种BTnum构造,那么就无法定义 BTmt,这会导致递归无法终止。当然,最后我们需要多个字段(如BTnd中的一样)来构造有用、有趣的数据。换句话说,所有这三种机制被打包在一起,因为它们结合在一起才最有用。(但是,有些语言确实允许定义独立结构体。后文我们将回来讨论这个设计决策对类型系统的影响)。

我们关于递归表示的初步讨论暂告一个段落,但这里有个严重的问题。我们并没有真正解释这个新的数据类型BTum的来源。因为我们不得不假装它已经在我们的类型检查器中实现了。然而,为每个新的递归类型改变我们的类型检查器有点不切实际——这就好比需要为每个新出现的递归函数去修改解释器!相反,我们需要找到一种方法,使得这种定义成为静态类型语言的固有能力。后面我们会回来讨论这个问题。

这种风格的数据定义有时也被称为乘积的和,“乘”指代字段组合成不变量的方式:例如,BTnd的合法值是传递给BTnd构造器的每个字段合法值的叉乘。“和”是所有这些不变量的总数:任何给定的BTnum值是其中之一。(将“乘”想作“且”,“加”想作“或”。)

15.2.4.2 自定义类型

想一想,数据结构的定义会产生哪些影响?首先,它引入了新的类型;其次它基于此类型定义若干构造器、谓词和选择器。例如,在上面的例子中,首先引入 BTnum,然后使用它创建以下类型:

BTmt : -> BTnum
BTnd : number * BTnum * BTnum -> BTnum
BTmt? : BTnum -> boolean
BTnd? : BTnum -> boolean
BTnd-n : BTnum -> number
BTnd-l : BTnum -> BTnum
BTnd-r : BTnum -> BTnum

观察几个显著的事实:


  • 这里的构造器创建BTnum的实例,而不是更具体的东西。稍后我们将讨论这个设计抉择。
  • 这里的谓词函数都接受BTnum类型参数,而不是“Any”(任意值)。这是因为类型系统已经可以告诉我们某个值的类型是什么,因此我们只需要区分该类型的不同形式。
  • 选择器只能作用于类型中相关形式的实例——例如,BTnd-n只对BTnd的实例有效,对BTmt的实例则不行——但是由于缺乏合适的静态类型,我们无法在静态类型系统中表示这点。

递归类型中还有很多值得讨论的东西,我们不久将回到这个话题。

15.2.4.3 模式匹配和去语法糖

类型定义的讨论告一段落,剩下要提供的功能就是模式匹配。例如,我们可以这样写:

(type-case BTnum t
    [BTnum () e1]
    [BTnd (nv lt rt) e2])

我们知道,这可以用前述的函数来实现。用 let 就可以模拟此模式匹配所实现的绑定:

(cond
    [(BTmt? t) e1]
    [(BTnd? t) (let ([nv (BTnd-n t)]
                     [lt (BTnd-l t)]
                     [rt (BTnd-r t)]
                 e2)])

总之,它可以通过宏实现,所以模式匹配不需要被添加到核心语言中,直接用去语法糖即可实现。这也意味着一门语言可以有很多不同的模式匹配机制。

不过,这不完全正确。生成上面代码中的cond表达式时,宏需要通过某种手段知道BTnd的三个位置选择器分别是BTnd-nBTnd-lBTnd-r。这些信息在类型定义时显式给出,但是在模式匹配时是隐含的(划重点)。因此,这些信息必须要从类型定义处传过来。因此宏扩展器需要使用类似类型环境的东西完成其任务。

此外,还要注意,例如e1e2这样的表达式无法类型检查——事实上,甚至不能被可靠地识别为表达式——直到宏扩展器完成了type-case的扩展之后。因此,扩展依赖于类型环境,而类型检查依赖于扩展的结果。换句话说这两者是共生关系,不仅仅是并行运行,而是同步运行。因此,静态类型语言中进行去语法糖操作时,如果语法糖需要对相关类型作出推测,要比动态类型语言中更复杂一些。

15.2.5 类型、时间和空间

明显,类型已经赋予了类型安全语言一些性能优势。因为一些本来需要运行时执行的检查(例如,检查加法的两个参数的确是数)现在是静态执行的。在静态类型语言中,类似:number的注解已经回答了关于某个值是否是特定类型这种问题;无需在运行时再去检查。因此,类型级别的谓词以及程序中对它们的使用将会(并且需要)完全消失。

对于开发者来说这需要付出一些代价,他们必须说服静态类型系统他们的程序不会导致类型错误;由于可判定性的限制,有些可以正确运行的程序也可能与类型系统冲突。不过,类型系统为满足了它要求的程序提供了可观的运行时性能优势。

接下来我们来讨论空间。到目前为止,语言的运行时系统需要对每个值附加存储其类型信息。这也是其实现类型级别谓词如 number? 的基础,这些谓词既可被开发人员使用也可被语言内部使用。如果不需要这些谓词,那么这些为了实现它们而存储的信息所占据的空间也将不再需要。因此(静态语言)不需要类型标签。

然而,垃圾回收器仍然需要它们,但其他表示法(如BIBOP(译注BIg Bag Of Pages))能极大减少它们对空间的需求。

类型变体相关的谓词仍要保留:如上面例子中的BTmt?BTnd?。它们的调用需要在运行时求值。例如,如前所述,选择器BTnd-n就需要执行这种检查。当然,进一步的优化是可能的。考虑模式匹配去语法糖后生成的代码:其中的三个选择器就无需执行这些检查,因为只有BTnd?返回真值时才会执行对应代码片。因此,运行时系统可以给去语法糖层面提供特殊的不安全(unsafe)指令,也就是不执行类型检查的版本,从而生成如下所示的代码:

(cond
  [(BTmt? t) e1]
  [(BTnd? t) (let ([nv (BTnd-n/no-check t)]
                   [lt (BTnd-l/no-check t)]
                   [rt (BTnd-r/no-check t)])
               e2)])

但最终的结果是,运行时系统仍然需要存储足够的信息来准确回答这些问题。不过,相比于之前需要使用足够的位来区分每种类型及类型变体,现在,由于类型被静态地隔离了,对于没有变体的类型(例如,只有一种类型的字符串),不再需要存储任何变体相关的信息;这意味着运行时系统可以使用所有可用位来存储实际的动态值。

与之相对,如果类型存在变体,运行时系统需要牺牲一些空间用于区分不同变体,不过一个类型中变体的数量显然比所有类型和其变体的数量要小得多。在上面的例子中,BTnum只有两个变体,因此运行时系统只需要使用一个比特来记录某个值是BTnum的哪个变体。

特别要注意的是,类型体系的隔离可以防止混淆。如果有两种不同的数据类型,每种都有两种变体,在动态类型的世界中,所有这四种变体都需要有不同的表示法;与之相对,在静态类型的世界中,这些表示法可以跨类型重叠,因为静态类型系统会保证一种类型中的变体和另一种类型中的不被混淆。因此,类型系统对于程序的空间(节约表示所需空间)和时间(消除运行时检查)上都有实打实的性能提升。

15.2.6 类型和赋值

我们已经覆盖了核心语言中除赋值之外的大部分基本特性。从某些方面看,类型和赋值之间的相互作用很简单,这是因为在经典环境中,它们根本不相互作用。例如,考虑下面动态类型程序:

(let ([x 10])
  (begin
    (set! x 5)
    (set! x "某物")))

x的“类型”是什么?它并没有确定的类型,它在一段时间内是数,后来(注意里面蕴含时间意味)是字符串。我们根本无法给它定类型。一般来说,类型检查是种非时间性的活动:它只在程序运行之前执行一次,因此必须独立于程序执行的特定顺序。因此,跟踪贮存中的精确值超出了类型检查程序的能力范围。

上面的例子当然可以简单的静态的被理解,不过我们不能被简单的例子误导。考虑下面的程序:

(let ([x 10])
  (if (even? (read-number "输入数字"))
      (set! x 5)
      (set! x "某物")))

现在,静态检查不可能得到关于x的类型的结论,因为只有在运行时我们才能获得用户输入的值。

为了避免这种情况,传统的类型检查器采用了一个简单策略:赋值过程中类型必须保持不变。也就是说,赋值操作,不论是变量赋值还是结构体赋值,都不能改变被赋值的量的类型。因此,上面的代码在我们当前的语言中将不能通过类型检查。给程序员提供多少灵活性就取决与语言了。例如,如果我们引入更加灵活的类型表示“数或字符串”,上面的例子将能通过类型检查,但是x的类型就永远不那么精确,所有使用x的地方都需要处理这种降低了的精度,后面我们会回到这个问题。

简而言之,在传统的类型系统中赋值相对容易处理,因为它采用了简单的规则,值可以在类型系统指定的限度下进行改变,但是类型不能被改变。在像set!这种操作的情况下(或者我们的核心语言中的setC),这意味着赋值的类型必须和变量的类型匹配。在结构体赋值的情况下,例如box,这意味着赋值的类型必须和box容器内容的类型匹配。

15.2.7 中心定理:类型的可靠性

之前我们说过,一些静态类型语言可以为其书写的程序所能达成某些特性作出很坚实的证明:例如,该语言书写的程序肯定会终止。当然,一般来说,我们无法获得这样的保证(事实上,正是为了能写出无限循环我们才添加的通用递归)。然而,一个有意义的类型系统——事实上,任何值得类型系统这一高贵头衔的东西【注释】——应该为所有静态类型程序提供某种有意义的保证。这是给程序员的回报:通过给程序加上类型,她可以确保某些不好的事情不会发生。没有类型的话,我们也能找到bug;这是有用的,但它不足以提供构建高级别工具(例如要保证安全性、隐私性或健壮性)的必要基础。

我们一再使用“类型系统”这个术语。类型系统通常是三个组件的组合:类型的语言、类型规则,以及将这些规则应用于程序的算法。我们的讨论中将类型规则放入函数中,因此模糊了第二者和第三者之间的区别,但它们仍然可以在逻辑上加以区分。

我们可能希望类型系统给我们提供什么样的保证呢?请记住,类型检查器在程序运行前静态地对程序进行检查。这意味着它本质上是对程序行为的预测:例如,当它指出某个复杂表达式的类型为num,它实际是在预测程序运行时,该表达式将产生一个数值。我们怎么知道这个预测是正确的呢,也就是说检查器从不撒谎?每种类型系统都应该附带一个证明这一点的定理。

对于类型系统存疑有一个很好的理由,不是怀疑主义的那种。类型检查器和程序求值器工作方式上有很多不同:


  • 类型检查器能见到的只有程序文本,求值器运行在真实的存储器上。
  • 类型环境将标识符绑定到类型,求值器的环境则绑定标识符到值或者存储位置。
  • 类型检查器将值的集合(甚至是无限集合)压缩成类型,而求值器处理的是值本身。
  • 类型检查器一定会终止,求值器不一定会。
  • 类型检查器仅需检查表达式一遍,求值器运行时某个表达式的运行次数可能从零次到无穷次。

因此,我们不应假设这两者将始终对应!

对于给定的类型系统,我们希望达到的核心目标是——该类型系统是可靠的(sound)。它的意思是:给定表达式(或者程序)e,类型检查得出其类型为t,当我们运行e时,假设得到了值v,那么v的类型是t

证明这个定理的标准方法是分两步进行,进展(progress)和保持(preservation)。进展的意思是,如果一个表达式能够通过类型检查,那么它应该能进行进一步求值得到新的东西(除非它本身就是值);保持的意思是,这个求值步骤前后类型不变。如果我们交错进行这些步骤(先进展再保持,不断重复),可以得出一个结论,最终的结果和最初被求值的表达式类型相同,因此类型系统确实是可靠的。

例如,考虑表达式:(+ 5 (* 2 3))。它的类型为num。在一个可靠的类型系统中,进展证明,由于该表达式能通过类型检查,且其当前不是值,它可以进行一步求值——这里它显然可以。进行一步求值之后,它被规约成了(+ 5 6)。不出所料,正如保持给出的证明,它的类型也为num。进展表明它还能进行一步求值,得到11。保持再次表明它的类型和上一步的表达式类型相同,都为num。现在,进展发现我们已经得到最终结果,无后续要进行的求值步骤,该值的类型和最初的表达式类型相同。

但这不是完整的故事。有两点需要说明:


  1. 程序可能不会得出最终的结果,它可能永远循环。这种情况下,该定理严格来说并不适用。但是我们仍能看到,计算得到的中间表达式类型将一直保持不变,因此即使程序没有最终产生一个值,它仍在进行着有意义的计算。
  2. 任何特性足够丰富的语言中都存在一些不能静态决定的属性(有些属性也许本来可以,但是语言的设计者决定将其推迟到运行时决定)。当这类属性出错时——比如,数组的索引越界——关于这种程序没有很好的类型可以约束它们。因此,每个类型完备性定理中都隐含了一组已发布的、允许的异常或者可能发生的错误条件。使用该类型系统的开发者隐式的接受了这些条件。

作为第二点的一个例子,典型的静态类型语言中,都会指明对于向量的寻址、列表的索引等操作可能抛出异常。

后面这个说明好像站不住脚。事实上,我们很容易忘记这其实是一条关于运行时不能发生的事情的陈述:这一组异常之外的异常将能被证明不会产生。当然,对最开始就设计为静态类型的语言,除了不那么严格的类比外,可能搞不清这组异常具体是什么,因为一开始本就无须定义它们。但是当我们将类型系统添加到已有的语言时——特别是动态类型语言,如Racket或Python——那么这里已经有一组明确定义的异常,类型检查器将会指明其中一些异常(像“函数调用位置不是函数”或者“未找到方法”)不会发生。这就是程序员接纳类型系统语法上限制所得到的回报。

编辑于 2018-02-18

文章被以下专栏收录