12 表示层抉择

回去看看我们将函数作为值的那个解释器,你能找到其中不一致的地方吗?

思考题

找到了吗?

考虑一下我们是怎么表示这两种值的:数和函数。忽略其外面numVclosV这一层,注意它们底层的数据表示。我们使用Racket中的数来表示要解释的语言中的数,但是我们没有使用Racket中的函数(闭包)来表示要解释的语言中的函数(闭包)。

这就是不一致的地方。更一致的做法是,要么都用Racket中的值表示,要么都用。那么我们为什么要做出这种决定呢?

这么做是要说明一个问题。本章我们就讨论此问题。

12.1 改变表示

我们暂且探究一下数。Racket中数很强大所以我们重用它:它支持任意大小的整数(bignum)、有理数(这点受益于整数的bignum表示)、复数等等。因此,它能表示出大部分常规语言中的数系统。然而,这并不意味着它就是我们想要的:它可能过于简单或者过于复杂:

  • 如果我们需要的是某种受限的数系统,它就过于复杂了。例如Java中规定了一组定长的数的表示(如:int被指定为32位的)。超出这个规定范围的数在Java中将不能直接被表示,同时算术运算也遵循此范围(例如:由于溢出,1加2147483647将不能得到2147483648)。
  • 如果我们需要更为丰富的数系统,它又会捉襟见肘,比如包含四元数或者和概率相关的数。

糟糕的是,我们根本没有想过自己的需求,就直接轻率的使用Racket中的数作为我们语言中数的表示。

之所以这样做,是因为我们并不关心数本身;我们关心的是诸如将函数作为值这样的编程语言特性。然而,作为语言设计者,你应当在最开始的时候就考虑到这些问题。

接下来讨论闭包的表示。我们其实可以利用Racket的闭包来表示目标语言中的对应概念,与之对应的,用Racket中最基本的函数调用来实现目标语言中的函数调用。

思考题

使用Racket函数替换之前闭包的实现。

答案在此:

(define-type Value
  [numV (n : number)]
  [closV (f : (Value -> Value))])

(define (interp [expr : ExprC] [env : Env]) : Value
  (type-case ExprC expr
    [numC (n) (numV n)]
    [idC (n) (lookup n env)]
    [appC (f a) (local ([define f-value (interp f env)]
                        [define a-value (interp a env)])
                  ((closV-f f-value) a-value))]
    [plusC (l r) (num+ (interp l env) (interp r env))]
    [multC (l r) (num* (interp l env) (interp r env))]
    [lamC (a b) (closV (lambda (arg-val)
                         (interp b
                                 (extend-env (bind a arg-val)
                                             env))))]))

练习

注意到一个有趣的变化。之前的实现中,环境是在解释appC时被扩展的。这里它是在lamC的解释过程中被扩展的。是这两个中有一个出错了吗?如果不是的话,为什么会出现这种情况?

这种实现方式显然更为简洁,但是我们失去了一项重要的东西:理解。告诉别人源语言中的函数对应于lambda等于什么都没说:如果我们已经知道lambda是干嘛的我们可能就不会花时间去研究它;如果不知道的话,这种直接映射的实现方式也不会教给我们啥(而且很可能会让本来就对该概念一无所知的我们更加困惑)。出于同样的理由,我们没有使用Racket中的状态去理解各种对状态的操作。

然而,一旦我们理解了某个特性,使用它来表示将不再是问题。实际上,这样做会使得我们的解释器更为简洁,毕竟我们不再手工实现所有事情。事实上,如果不使用这种表示方式,后面的一些解释器会变得毫无可读性。【注释】尽管如此,我们还是应该注意防范过度使用宿主语言的特性可能招致的风险。

有点像是,“现在我们已经能够通过加一来理解加法,我们可以用加法来定义乘法:不再需要使用加一来定义乘法。”

12.2 错误

当程序出错时,程序员需要得到相应的错误信息。直接使用宿主语言特性可能导致用户收到宿主语言中抛出的错误,这些错误将无法被理解。因此,我们需要谨慎的将各种情况的错误翻译成我们语言的用户所能理解的术语,且不让宿主语言中的错误信息“泄漏过来”。

更糟糕的情形是,那些本应出错的程序可能不会报错!例如,假设我们设计时决定让函数只出现在顶层位置,如果我们没有特意地检测这点,其被去语法糖后得到lambda,最后可能在解释器中被解释得到结果,而它本来应该使解释器出错停止。因此,我们应该极其注意,仅允许符合期望的表层语言被映射到宿主语言中

再举个例子,考虑不同的赋值操作。在我们的语言中,给未绑定的变量赋值会导致错误。但是在有些语言中,这种操作会导致该变量被定义。语言设计者常犯的错误是没有很好的确定想要的语义,然后推脱说“它就是实现出来的那个样子”。这种态度(a)是懒惰、马虎的,(b)可能招致不可预料、负面的后果,(c)它使得将语言从一个实现平台移到另一个实现平台变得困难。不要犯这个错误!

12.3 改变含义

将作为值的函数映射为lambda之所以可行是因为我们本来就希望它们拥有相同的含义。但是这种实现方式使得改变函数的含义变得极为困难。让我给你设想一个情形:假设我们想要实现动态作用域。【注释】在我们原来的解释器中,这很简单(历史告诉我们,简直太简单了)。试着在使用了lambda的解释器中实现动态作用域。同样的,将及早求值(eager evaluation)特性映射到惰性求值(lazy application)的语言中(译注,第17章)也是挺有难度的,或者说至少不太容易。

只是假设而已。

练习

将上面的解释器改成动态作用域的。

重点是,使用自己构造的数据结构并不会使事情更为简单,但一般来说也不会使事情变得更为复杂;与之相对,映射成语言本身特性的方式会使某些特性——通常是宿主语言中已有的特性——的实现极为简单,但是使其他特性的实现变得微妙或困难。还有一个风险是,我们可能并不十分清楚宿主语言的某个特性具体实现了些什么(比如,“lambda”是否真的实现了静态作用域?)。

教训是,仅当我们想要“保留”底层语言的意义时,这才是好用的——甚至是特别明智的,因为它确保我们不会意外地改变其意义。但是,如果我们要利用基础语言的重要组成部分,而只是扩展它的含义,那么其他的实现策略可能也不错(译注,第13章),而不是编写解释器。

12.4 另一个例子

我们再考虑改变一个特性的表示方式。还记得环境是什么吗?

环境是名字到值(如果有赋值的话,那么是名字到地址)的映射。我们通过自建的数据结构实现了这种映射,但是我们可以通过其他方式实现映射吗?当然可以,使用函数就行!这样,环境就变成了读入名字为参数、返回其绑定值(或者报错)的函数:

(define-type-alias Env (symbol -> Value))

空的环境是什么?对于任何名字的查询都抛出错误的函数:

(define (mt-env [name : symbol])
  (error 'lookup "name not found"))

(原则上我们应该给它的返回值添加类型注解,应该是Value,但是在这里没啥意义)。给环境添加新的绑定就是创建新函数,该函数检查该名字是不是正在扩展的那个绑定;如果是,直接放回对应的绑定值,如果不是,往被扩展的环境传就行。

(define (extend-env [b : Binding] [e : Env])
  (lambda ([name : symbol]) : Value
    (if (symbol=? name (bind-name b))
        (bind-val b)
        (lookup name e))))

最后,怎么再环境中查询某个名称呢?调用该环境即可。

(define (lookup [n : symbol] [e : Env]) : Value
  (e n))

大功告成!

results matching ""

    No results matching ""