码迷,mamicode.com
首页 > 其他好文 > 详细

怎样写一个解释器

时间:2020-06-24 23:39:17      阅读:61      评论:0      收藏:0      [点我收藏+]

标签:莫名其妙   也会   png   个人   emacs   tps   lua   规律   表达式   

怎样写一个解释器

文章来源 http://www.yinwang.org/blog-cn/2012/08/01/interpreter

 

怎样写一个解释器

写一个解释器,通常是设计和实现程序语言的第一步。解释器是简单却又深奥的东西,以至于好多人都不会写,所以我决定写一篇这方面的入门读物。

虽然我试图从最基本的原理讲起,尽量不依赖于其它知识,但这并不是一本编程入门教材。我假设你已经理解 Scheme 语言,以及基本的编程技巧(比如递归)。如果你完全不了解这些,那我建议你读一下 SICP 的第一,二章,或者 HtDP 的前几章,习题可以不做。注意不要读太多书,否则你就回不来了 ;-) 当然你也可以直接读这篇文章,有不懂的地方再去查资料。

实现语言容易犯的一个错误,就是一开头就试图去实现很复杂的语言(比如 JavaScript 或者 Python)。这样你很快就会因为这些语言的复杂性,以及各种历史遗留的设计问题而受到挫折,最后不了了之。学习实现语言,最好是从最简单,最干净的语言开始,迅速写出一个可用的解释器。之后再逐步往里面添加特性,同时保持正确。这样你才能有条不紊地构造出复杂的解释器。

因为这个原因,这篇文章只针对一个很简单的语言,名叫“R2”。它可以作为一个简单的计算器用,还具有变量定义,函数定义和调用等功能。

我们的工具:Racket

本文的解释器是用 Scheme 语言实现的。Scheme 有很多的“实现”,这里我用的实现叫做 Racket,它可以在这里免费下载。为了让程序简洁,我用了一点点 Racket 的模式匹配(pattern matching)功能。我对 Scheme 的实现没有特别的偏好,但 Racket 方便易用,适合教学。如果你用其它的 Scheme 实现,可能得自己做一些调整。

Racket 具有宏(macro),所以它其实可以变成很多种语言。如果你之前用过 DrRacket,那它的“语言设置”可能被你改成了 R5RS 之类的。所以如果下面的程序不能运行,你可能需要检查一下 DrRacket 的“语言设置”,把 Language 设置成 “Racket”。

技术图片

技术图片

Racket 允许使用方括号而不只是圆括号,所以你可以写这样的代码:

(let ([x 1]
      [y 2])
  (+ x y))

方括号跟圆括号可以互换,唯一的要求是方括号必须和方括号匹配。通常我喜欢用方括号来表示“无动作”的数据(比如上面的 [x 1][y 2]),这样可以跟函数调用和其它具有“动作”的代码,产生“视觉差”。这对于代码的可读性是一个改善,因为到处都是圆括号的话,确实有点太单调,容易打瞌睡。

另外,Racket 程序的最上面都需要加上像 #lang racket 这样的语言选择标记,这样 Racket 才可以知道你想用哪个语言变种。

解释器是什么

准备工作就到这里。现在我来谈一下,解释器到底是什么。说白了,解释器跟计算器差不多。解释器是一个函数,你输入一个“表达式”,它就输出一个 “值”,像这样:

技术图片

比如,你输入表达式 ‘(+ 1 2) ,它就输出值,整数3。表达式是一种“表象”或者“符号”,而值却更加接近“本质”或者“意义”。我们“解释”了符号,得到它的意义,这也许就是为什么它叫做“解释器”。

需要注意的是,表达式是一个数据结构,而不是一个字符串。我们用一种叫“S 表达式”(S-expression)的结构来存储表达式。比如表达式 ‘(+ 1 2) 其实是一个链表(list),它里面的内容是三个符号(symbol):+1 和 2,而不是字符串"(+ 1 2)"

从 S 表达式这样的“结构化数据”里提取信息,方便又可靠,而从字符串里提取信息,麻烦而且容易出错。Scheme(Lisp)语言里面大量使用结构化数据,少用字符串,这是 Lisp 系统比 Unix 系统先进的地方之一。

从计算理论的角度讲,每个程序都是一台机器的“描述”,而解释器就是在“模拟”这台机器的运转,也就是在进行“计算”。所以从某种意义上讲,解释器就是计算的本质。当然,不同的解释器就会带来不同的计算。

CPU 也是一个解释器,它专门解释执行机器语言。如果你深刻理解了解释器,就可以从本质上看出各种 CPU 的设计为什么是那个样子,它们有什么优缺点,而不只是被动的作为它们的使用者。

抽象语法树(Abstract Syntax Tree)

用 S 表达式所表示的代码,本质上是一种叫做“树”(tree)的数据结构。更具体一点,这叫做“抽象语法树”(Abstract Syntax Tree,简称 AST)。下文为了简洁,我们省略掉“抽象”两个字,就叫它“语法树”。

跟普通的树结构一样,语法树里的节点,要么是一个“叶节点”,要么是一颗“子树”。叶节点是不能再细分的“原子”,比如数字,字符串,操作符,变量名。而子树是可以再细分的“结构”,比如算术表达式,函数定义,函数调用,等等。

举个简单的例子,表达式 ‘(* (+ 1 2) (+ 3 4)),就对应如下的语法树结构:

技术图片

其中,*,两个+1234 都是叶节点,而那三个红色节点,都表示子树结构:‘(+ 1 2)‘(+ 3 4)‘(* (+ 1 2) (+ 3 4))

树遍历算法

在基础的数据结构课程里,我们都学过二叉树的遍历操作,也就是所谓先序遍历,中序遍历和后序遍历。语法树跟二叉树,其实没有很大区别,所以你也可以在它上面进行遍历。解释器的算法,就是在语法树上的一种遍历操作。由于这个渊源关系,我们先来做一个遍历二叉树的练习。做好了之后,我们就可以把这段代码扩展成一个解释器。

这个练习是这样:写出一个函数,名叫tree-sum,它对二叉树进行“求和”,把所有节点里的数加在一起,返回它们的和。举个例子,(tree-sum ‘((1 2) (3 4))),执行后应该返回 10。注意:这是一颗二叉树,所以不会含有长度超过 2 的子树,你不需要考虑像 ((1 2) (3 4 5)) 这类情况。需要考虑的例子是像这样:(1 2)(1 (2 3))((1 2) 3) ((1 2) (3 4)),……

(为了达到最好的学习效果,你最好试一下写出这个函数再继续往下看。)

好了,希望你得到了跟我差不多的结果。我的代码是这个样子:

#lang racket

(define tree-sum
  (lambda (exp)
    (match exp                         ; 对输入exp进行模式匹配
      [(? number? x) x]                ; exp是一个数x吗?如果是,那么返回这个数x
      [`(,e1 ,e2)                      ; exp是一个含有两棵子树的中间节点吗?
       (let ([v1 (tree-sum e1)]        ; 递归调用tree-sum自己,对左子树e1求值
             [v2 (tree-sum e2)])       ; 递归调用tree-sum自己,对右子树e2求值
         (+ v1 v2))])))                ; 返回左右子树结果v1和v2的和

你可以通过以下的例子来测试它的正确性:

(tree-sum (1 2))
;; => 3
(tree-sum (1 (2 3)))
;; => 6
(tree-sum ((1 2) 3))
;; => 6
(tree-sum ((1 2) (3 4)))
;; => 10

(完整的代码和示例,可以在这里下载。)

这个算法很简单,我们可以把它用文字描述如下:

  1. 如果输入 exp 是一个数,那就返回这个数。
  2. 否则如果 exp 是像 (,e1 ,e2) 这样的子树,那么分别对 e1 和 e2 递归调用 tree-sum,进行求和,得到 v1 和 v2,然后返回 v1 + v2 的和。

你自己写出来的代码,也许用了 if 或者 cond 语句来进行分支,而我的代码里面使用的是 Racket 的模式匹配(match)。这个例子用 if 或者 cond 其实也可以,但我之后要把这代码扩展成一个解释器,所以提前使用了 match。这样跟后面的代码对比的时候,就更容易看出规律来。接下来,我就简单讲一下这个 match 表达式的工作原理。

模式匹配

现在不得不插入一点 Racket 的技术细节,如果你已经学会使用 Racket 的模式匹配,可以跳过这一节。你也可以通过阅读 Racket 模式匹配的文档来代替这一节。但我建议你不要读太多文档,因为我接下去只用到很少的模式匹配功能,我把它们都解释如下。

模式匹配的形式一般是这样:

(match x
  [模式 结果]
  [模式 结果]
   ...   ...
)    

它先对 x 求值,然后根据值的结构来进行分支。每个分支由两部分组成,左边是一个模式,右边是一个结果。整个 match 语句的语义是这样:从上到下依次考虑,找到第一个可以匹配 x 的值的模式,返回它右边的结果。左边的模式在匹配之后,可能会绑定一些变量,这些变量可以在右边的表达式里使用。

模式匹配是一种分支语句,它在逻辑上就是 Scheme(Lisp) 的 cond 表达式,或者 Java 的嵌套条件语句 if ... else if ... else ...。然而跟条件语句里的“条件”不同,每条 match 语句左边的模式,可以准确而形象地描述数据结构的形状,而且可以在匹配的同时,对结构里的成员进行“绑定”。这样我们可以在右边方便的访问结构成员,而不需要使用访问函数(accessor)或者 foo.x 这样的属性语法(attribute)。而且模式可以有嵌套的子结构,所以它能够一次性的表示复杂的数据结构。

举个实在点的例子。我的代码里用了这样一个 match 表达式:

(match exp
  [(? number? x) x]
  [`(,e1 ,e2)
   (let ([v1 (tree-sum e1)]
         [v2 (tree-sum e2)])
     (+ v1 v2))])

第二行里面的 ‘(,e1 ,e2) 是一个模式(pattern),它被用来匹配 exp 的值。如果 exp 是 ‘(1 2),那么它与‘(,e1 ,e2)匹配的时候,就会把 e1 绑定到 ‘1,把 e2 绑定到 ‘2。这是因为它们结构相同:

`(,e1 ,e2)
(  1   2)

说白了,模式就是一个可以含有“名字”(像 e1 和 e2)的结构,像 ‘(,e1 ,e2)。我们拿这个带有名字的结构,去匹配实际数据,像 ‘(1 2)。当它们一一对应之后,这些名字就被绑定到数据里对应位置的值。

第一行的“模式”比较特殊,(? number? x) 表示的,其实是一个普通的条件判断,相当于 (number? exp),如果这个条件成立,那么它把 exp 的值绑定到 x,这样右边就可以用 x 来指代 exp。对于无法细分的结构(比如数字,布尔值),你只能用这种方式来“匹配”。看起来有点奇怪,不过习惯了就好了。

模式匹配对解释器和编译器的书写相当有用,因为程序的语法树往往具有嵌套的结构。不用模式匹配的话,往往要写冗长,复杂,不直观的代码,才能描述出期望的结构。而且由于结构的嵌套比较深,很容易漏掉边界情况,造成错误。模式匹配可以直观的描述期望的结构,避免漏掉边界情况,而且可以方便的访问结构成员。

由于这个原因,很多源于 ML 的语言(比如 OCaml,Haskell)都有模式匹配的功能。因为 ML(Meta-Language)原来设计的用途,就是用来实现程序语言的。Racket 的模式匹配也是部分受了 ML 的启发,实际上它们的原理是一模一样的。

好了,树遍历的练习就做到这里。然而这跟解释器有什么关系呢?下面我们只把它改一下,就可以得到一个简单的解释器。

一个计算器

计算器也是一种解释器,只不过它只能处理算术表达式。我们的下一个目标,就是写出一个计算器。如果你给它 ‘(* (+ 1 2) (+ 3 4)),它就输出 21。可不要小看这个计算器,稍后我们把它稍加改造,就可以得到一个更多功能的解释器。

上面的代码里,我们利用递归遍历,对树里的数字求和。那段代码里,其实已经隐藏了一个解释器的框架。你观察一下,一个算术表达式 ‘(* (+ 1 2) (+ 3 4)),跟二叉树 ‘((1 2) (3 4)) 有什么不同?发现没有,这个算术表达式比起二叉树,只不过在每个子树结构里多出了一个操作符:一个 * 和两个 + 。它不再是一棵二叉树,而是一种更通用的树结构。

这点区别,也就带来了二叉树求和与解释器算法的区别。对二叉树进行求和的时候,在每个子树节点,我们都做加法。而对表达式进行解释的时候,在每一个子树节点,我们不一定进行加法。根据子树的“操作符”不同,我们可能会选择加,减,乘,除四种操作。

好了,下面就是这个计算器的代码。它接受一个表达式,输出一个数字作为结果。

#lang racket                                  ; 声明用 Racket 语言

(define calc
  (lambda (exp)
    (match exp                                ; 分支匹配:表达式的两种情况
      [(? number? x) x]                       ; 是数字,直接返回
      [`(,op ,e1 ,e2)                         ; 匹配提取操作符op和两个操作数e1,e2
       (let ([v1 (calc e1)]                   ; 递归调用 calc 自己,得到 e1 的值
             [v2 (calc e2)])                  ; 递归调用 calc 自己,得到 e2 的值
         (match op                            ; 分支匹配:操作符 op 的 4 种情况
           [‘+ (+ v1 v2)]                     ; 如果是加号,输出结果为 (+ v1 v2)
           [‘- (- v1 v2)]                     ; 如果是减号,乘号,除号,相似的处理
           [‘* (* v1 v2)]
           [‘/ (/ v1 v2)]))])))

你可以得到如下的结果:

(calc (+ 1 2))
;; => 3
(calc (* 2 3))
;; => 6
(calc (* (+ 1 2) (+ 3 4)))
;; => 21

(完整的代码和示例,可以在这里下载。)

跟之前的二叉树求和代码比较一下,你会发现它们惊人的相似,因为解释器本来就是一个树遍历算法。不过你发现它们有什么不同吗?它们的不同点在于:

  1. 算术表达式的模式里面,多出了一个“操作符”(op)叶节点:(,op ,e1 ,e2)

  2. 对子树 e1 和 e2 分别求值之后,我们不是返回 (+ v1 v2),而是根据 op 的不同,返回不同的结果:

    (match op
      [‘+ (+ v1 v2)]
      [‘- (- v1 v2)]
      [‘* (* v1 v2)]
      [‘/ (/ v1 v2)])
    

最后你发现,一个算术表达式的解释器,不过是一个稍加扩展的树遍历算法。

R2:一个很小的程序语言

实现了一个计算器,现在让我们过渡到一种更强大的语言。为了方便称呼,我给它起了一个萌萌哒名字,叫 R2。R2 比起之前的计算器,只多出四个元素,它们分别是:变量,函数,绑定,调用。再加上之前介绍的算术操作,我们就得到一个很简单的程序语言,它只有5种不同的构造。用 Scheme 的语法,这5种构造看起来就像这样:

  • 变量:x
  • 函数:(lambda (x) e)
  • 绑定:(let ([x e1]) e2)
  • 调用:(e1 e2)
  • 算术:(• e2 e2)

(其中,• 是一个算术操作符,可以选择 +-*/ 其中之一)

一般程序语言还有很多其它构造,可是一开头就试图去实现所有那些,只会让人糊涂。最好是把这少数几个东西搞清楚,确保它们正确之后,才慢慢加入其它元素。

这些构造的语义,跟 Scheme 里面的同名构造几乎一模一样。如果你不清楚什么是”绑定“,那你可以把它看成是普通语言里的”变量声明“。

需要注意的是,跟一般语言不同,我们的函数只接受一个参数。这不是一个严重的限制,因为在我们的语言里,函数可以被作为值传递,也就是所谓“first-class function”。所以你可以用嵌套的函数定义来表示有两个以上参数的函数。

举个例子, (lambda (x) (lambda (y) (+ x y))) 是个嵌套的函数定义,它也可以被看成是有两个参数(x 和 y)的函数,这个函数返回 x 和 y 的和。当这样的函数被调用的时候,需要两层调用,就像这样:

(((lambda (x)
    (lambda (y) (+ x y)))
  1)
 2)
;; => 3

这种做法在PL术语里面,叫做咖喱(currying)。看起来啰嗦,但这样我们的解释器可以很简单。等我们理解了基本的解释器,再实现真正的多参数函数也不迟。

另外,我们的绑定语法 (let ([x e1]) e2),比起 Scheme 的绑定也有一些局限。我们的 let 只能绑定一个变量,而 Scheme 可以绑定多个,像这样 (let ([x 1] [y 2]) (+ x y))。这也不是一个严重的限制,因为我们可以啰嗦一点,用嵌套的 let 绑定:

(let ([x 1])
  (let ([y 2])
    (+ x y)))

R2 的解释器

下面是我们今天要完成的解释器,它可以运行一个 R2 程序。你可以先留意一下各部分的注释。

#lang racket

;;; 以下三个定义 env0, ext-env, lookup 是对环境(environment)的基本操作:

;; 空环境
(define env0 ())

;; 扩展。对环境 env 进行扩展,把 x 映射到 v,得到一个新的环境
(define ext-env
  (lambda (x v env)
    (cons `(,x . ,v) env)))

;; 查找。在环境中 env 中查找 x 的值。如果没找到就返回 #f
(define lookup
  (lambda (x env)
    (let ([p (assq x env)])
      (cond
       [(not p) #f]
       [else (cdr p)]))))
       
;; 闭包的数据结构定义,包含一个函数定义 f 和它定义时所在的环境
(struct Closure (f env))

;; 解释器的递归定义(接受两个参数,表达式 exp 和环境 env)
;; 共 5 种情况(变量,函数,绑定,调用,数字,算术表达式)
(define interp
  (lambda (exp env)
    (match exp                                          ; 对exp进行模式匹配
      [(? symbol? x)                                    ; 变量
       (let ([v (lookup x env)])
         (cond
          [(not v)
           (error "undefined variable" x)]
          [else v]))]      
      [(? number? x) x]                                 ; 数字
      [`(lambda (,x) ,e)                                ; 函数
       (Closure exp env)]
      [`(let ([,x ,e1]) ,e2)                            ; 绑定
       (let ([v1 (interp e1 env)])
         (interp e2 (ext-env x v1 env)))]
      [`(,e1 ,e2)                                       ; 调用
       (let ([v1 (interp e1 env)]
             [v2 (interp e2 env)])
         (match v1
           [(Closure `(lambda (,x) ,e) env-save)
            (interp e (ext-env x v2 env-save))]))]
      [`(,op ,e1 ,e2)                                   ; 算术表达式
       (let ([v1 (interp e1 env)]
             [v2 (interp e2 env)])
         (match op
           [‘+ (+ v1 v2)]
           [‘- (- v1 v2)]
           [‘* (* v1 v2)]
           [‘/ (/ v1 v2)]))])))

;; 解释器的“用户界面”函数。它把 interp 包装起来,掩盖第二个参数,初始值为 env0
(define r2
  (lambda (exp)
    (interp exp env0)))

这里有一些测试例子:

(r2 (+ 1 2))
;; => 3

(r2 (* 2 3))
;; => 6

(r2 (* 2 (+ 3 4)))
;; => 14

(r2 (* (+ 1 2) (+ 3 4)))
;; => 21

(r2 ((lambda (x) (* 2 x)) 3))
;; => 6

(r2
(let ([x 2])
   (let ([f (lambda (y) (* x y))])
     (f 3))))
;; => 6

(r2
(let ([x 2])
   (let ([f (lambda (y) (* x y))])
     (let ([x 4])
       (f 3)))))
;; => 6

(完整的代码和示例,可以在这里下载。)

在接下来的几节,我们来仔细看看这个解释器的各个部分。

对基本算术操作的解释

算术操作一般都是程序里最基本的构造,它们不能再被细分为多个步骤,所以我们先来看看对算术操作的处理。以下就是 R2 解释器处理算术的部分,它是 interp 的最后一个分支。

(match exp
  ... ...
  [`(,op ,e1 ,e2)
   (let ([v1 (interp e1 env)]             ; 递归调用 interp 自己,得到 e1 的值
         [v2 (interp e2 env)])            ; 递归调用 interp 自己,得到 e2 的值
     (match op                            ; 分支:处理操作符 op 的 4 种情况
       [‘+ (+ v1 v2)]                     ; 如果是加号,输出结果为 (+ v1 v2)
       [‘- (- v1 v2)]                     ; 如果是减号,乘号,除号,相似的处理
       [‘* (* v1 v2)]
       [‘/ (/ v1 v2)]))])

你可以看到它几乎跟刚才写的计算器一模一样,不过现在 interp 的调用多了一个参数 env 而已。这个 env 是所谓“环境”,我们下面很快就讲。

对数字的解释

对数字的解释很简单,把它们原封不动返回就可以了。

[(? number? x) x]

变量和函数

变量和函数是解释器里最麻烦的部分,所以我们来仔细看看。

变量(variable)的产生,是数学史上的最大突破之一。因为变量可以被绑定到不同的值,从而使函数的实现成为可能。比如数学函数 f(x) = x * 2,其中 x 是一个变量,它把输入的值传递到函数体 x * 2 里面。如果没有变量,函数就不可能实现。

对变量最基本的操作,是对它的“绑定”(binding)和“取值”(evaluate)。什么是绑定呢?拿上面的函数 f(x) 作为例子。当我们调用 f(1) 时,函数体里面的 x 等于 1,所以 x * 2 的值是 2,而当我们调用 f(2) 时,函数体里面的 x 等于 2,所以 x * 2 的值是 4。这里,两次对 f 的调用,分别对 x 进行了两次绑定。第一次 x 被绑定到了 1,第二次被绑定到了 2。

你可以把“绑定”理解成这样一个动作,就像当你把插头插进电源插座的那一瞬间。插头的插脚就是 f(x) 里面的那个 x,而 x * 2 里面的 x,则是电线的另外一端。所以当你把插头插进插座,电流就通过这根电线到达另外一端。如果电线导电性能良好,两头的电压应该相等。

环境

我们的解释器只能一步一步的做事情。比如,当它需要求 f(1) 的值的时候,它分成两步操作:

  1. 把 x 绑定到 1,这样函数体内才能看见这个绑定。
  2. 进入 f 的函数体,对 x * 2 进行求值。

这就像一个人做出这两个动作:

  1. 把插头插进插座 。
  2. 到电线的另外一头,测量它的电压,并且把结果乘以 2。

在第一步和第二步之间,我们如何记住 x 的值呢?通过所谓“环境”!我们用环境记录变量的值,并且把它们传递到变量的“可见区域”。变量的可见区域,用术语说叫做“作用域”(scope)。

在我们的解释器里,用于处理环境的代码如下:

;; 空环境
(define env0 ())

;; 对环境 env 进行扩展,把 x 映射到 v
(define ext-env
  (lambda (x v env)
    (cons `(,x . ,v) env)))

;; 取值。在环境中 env 中查找 x 的值
(define lookup
  (lambda (x env)
    (let ([p (assq x env)])
      (cond
       [(not p) #f]
       [else (cdr p)]))))

这里我们用一种最简单的数据结构,Scheme 的 association list,来表示环境。Association list 看起来像这个样子:((x . 1) (y . 2) (z . 5))。它是一个两元组(pair)的链表,左边的元素是 key,右边的元素是 value。写得直观一点就是:

((x . 1)
 (y . 2)
 (z . 5))

查表操作就是从头到尾搜索,如果左边的 key 是要找的变量,就返回整个 pair。简单吧?效率很低,但是足够完成我们现在的任务。

ext-env 函数扩展一个环境。比如,如果原来的环境 env1 是 ((y . 2) (x . 1)) 那么 (ext-env x 3 env1),就会返回 ((x . 3) (y . 2) (x . 1))。也就是把 (x . 3) 加到 env1 的最前面去。

那我们什么时候需要扩展环境呢?当我们进行绑定的时候。绑定可能出现在函数调用时,也可能出现在 let 绑定时。我们选择的数据结构,使得环境自然而然的具有了作用域(scope)的特性。

环境其实是一个堆栈(stack)。内层的绑定,会出现在环境的最上面,这就是在“压栈”。这样我们查找变量的时候,会优先找到最内层定义的变量。

举个例子:

(let ([x 1])         ; env=‘()。绑定x到1。
  (let ([y 2])       ; env=‘((x . 1))。绑定y到2。
    (let ([x 3])     ; env=‘((y . 2) (x . 1))。绑定x到3。
      (+ x y))))     ; env=‘((x . 3) (y . 2) (x . 1))。查找x,得到3;查找y,得到2。
;; => 5

这段代码会返回5。这是因为最内层的绑定,把 (x . 3) 放到了环境的最前面,这样查找 x 的时候,我们首先看到 (x . 3),然后就返回值3。之前放进去的 (x . 1) 仍然存在,但是我们先看到了最上面的那个(x . 3),所以它被忽略了。

这并不等于说 (x . 1) 就可以被改写或者丢弃,因为它仍然是有用的。你只需要看一个稍微不同的例子,就知道这是怎么回事:

(let ([x 1])          ; env=‘()。绑定x到1。
  (+ (let ([x 2])     ; env=‘((x . 1))。绑定x到2。
       x)             ; env=‘((x . 2) (x . 1))。查找x,得到2。
   x))                ; env=‘((x . 1))。查找x,得到1。
;; => 3               ; 两个不同的x的和,1+2等于3。

这个例子会返回3。它是第3行和第4行里面两个 x 的和。由于第3行的 x 处于内层 let 里面,那里的环境是 ((x . 2) (x . 1)),所以查找 x 的值得到2。第4行的 x 在内层 let 外面,但是在外层 let 里面,那里的环境是 ((x . 1)),所以查找 x 的值得到1。这很符合直觉,因为 x 总是找到最内层的定义。

值得注意的是,环境被扩展以后,形成了一个新的环境,而原来的环境并没有被改变。比如,上面的 ((y . 2) (x . 1)) 并没有删除或者修改,只不过是被“引用”到一个更大的列表里去了。

这样不对已有数据进行修改(mutation)的数据结构,叫做“函数式数据结构”。函数式数据结构只生成新的数据,而不改变或者删除老的。它可能引用老的结构,然而却不改变老的结构。这种“不修改”(immutable)的性质,在我们的解释器里是很重要的,因为当我们扩展一个环境,进入递归,返回之后,外层的代码必须仍然可以访问原来外层的环境。

当然,我们也可以用另外的,更高效的数据结构(比如平衡树,串接起来的哈希表)来表示环境。如果你学究一点,甚至可以用函数来表示环境。这里为了代码简单,我们选择了最笨,然而正确,容易理解的数据结构。

对变量的解释

了解了变量,函数和环境,我们来看看解释器对变量的“取值”操作,也就是 match 的第一种情况。

[(? symbol? x) (lookup x env)]

这就是在环境中,沿着从内向外的“作用域顺序”,查找变量的值。

这里的 (? symbol? x) 是一种特殊的模式,它使用 Scheme 函数 symbol? 来判断输入是否是一个符号,如果是,就把它绑定到 x,然后你就可以在右边用 x 来指称这个输入。

对绑定的解释

现在我们来看看对 let 绑定的解释:

[`(let ([,x ,e1]) ,e2)                           
 (let ([v1 (interp e1 env)])              ; 解释右边表达式e1,得到值v1
   (interp e2 (ext-env x v1 env)))]       ; 把(x . v1)扩充到环境顶部,对e2求值

通过代码里的注释,你也许已经可以理解它在做什么。我们先对表达式 e1 求值,得到 v1。然后我们把 (x . v1) 扩充到环境里,这样 (let ([x e1]) ...) 内部都可以看到 x 的值。然后我们使用这个扩充后的环境,递归调用解释器本身,对 let 的主体 e2 求值。它的返回值就是这个 let 绑定的值。

Lexical Scoping 和 Dynamic Scoping

下面我们准备谈谈函数定义和调用。对函数的解释是一个微妙的问题,很容易弄错,这是由于函数体内也许会含有外层的变量,叫做“自由变量”。所以在分析函数的代码之前,我们来了解一下不同的“作用域”(scoping)规则。

我们举个例子来解释这个问题。下面这段代码,它的值应该是多少呢?

(let ([x 2])
  (let ([f (lambda (y) (* x y))])
    (let ([x 4])
      (f 3))))

在这里,f 函数体 (lambda (y) (* x y)) 里的那个 x,就是一个“自由变量”。x 并不是这个函数的参数,也不是在这个函数里面定义的,所以我们必须到函数外面去找 x 的值。

我们的代码里面,有两个地方对 x 进行了绑定,一个等于2,一个等于4,那么 x 到底应该是指向哪一个绑定呢?这似乎无关痛痒,然而当我们调用 (f 3) 的时候,严重的问题来了。f 的函数体是 (* x y),我们知道 y 的值来自参数 3,可是 x 的值是多少呢?它应该是2,还是4呢?

在历史上,这段代码可能有两种不同的结果,这种区别一直延续到今天。如果你在 Scheme (Racket)里面写以上的代码,它的结果是6。

;; Scheme
(let ([x 2])
  (let ([f (lambda (y) (* x y))])
    (let ([x 4])
      (f 3))))

;; => 6

现在我们来看看,在 Emacs Lisp 里面输入等价的代码,得到什么结果。如果你不熟悉 Emacs Lisp 的用法,那你可以跟我做:把代码输入 Emacs 的那个叫 *scratch* 的 buffer。把光标放在代码最后,然后按 C-x C-e,这样 Emacs 会执行这段代码,然后在 minibuffer 里显示结果:

技术图片

结果是12!如果你把代码最内层的 x 绑定修成其它的值,输出会随之改变。

奇怪吧?Scheme 和 Emacs Lisp,到底有什么不一样呢?实际上,这两种看似差不多的 “Lisp 方言”,采用了两种完全不同的作用域方式。Scheme 的方式叫做 lexical scoping (或者 static scoping),而 Emacs 的方式叫做 dynamic scoping。

那么哪一种方式更好呢?或者用哪一种都无所谓?答案是,dynamic scoping 是非常错误的做法。历史的教训告诉我们,它会带来许许多多莫名其妙的 bug,导致 dynamic scoping 的语言几乎完全没法用。这是为什么呢?

原因在于,像 (let ((x 4)) …) 这样的变量绑定,只应该影响它内部“看得见”的 x 的值。当我们看见 (let ((x 4)) (f 3)) 的时候,并没有在 let 的内部看见任何叫“x” 的变量,所以我们“直觉”的认为,(let ((x 4)) …) 对 x 的绑定,不应该引起 (f 3) 的结果变化。

然而对于 dynamic scoping,我们的直觉却是错误的。因为 f 的函数体里面有一个 x,虽然我们没有在 (f 3) 这个调用里面看见它,然而它却存在于 f 定义的地方。要知道,f 定义的地方也许隔着几百行代码,甚至在另外一个文件里面。而且调用函数的人凭什么应该知道, f 的定义里面有一个自由变量,它的名字叫做 x?所以 dynamic scoping 在设计学的角度来看,是一个反人类的设计 :)

相反,lexical scoping 却是符合人们直觉的。虽然在 (let ((x 4)) (f 3)) 里面,我们把 x 绑定到了 4,然而 f 的函数体并不是在那里定义的,我们也没在那里看见任何 x,所以 f 的函数体里面的 x,仍然指向我们定义它的时候看得见的那个 x,也就是最上面的那个 (let ([x 2]) ...),它的值是 2。所以 (f 3) 的值应该等于 6,而不是12。

对函数的解释

为了实现 lexical scoping,我们必须把函数做成“闭包”(closure)。闭包是一种特殊的数据结构,它由两个元素组成:函数的定义和当前的环境。我们把闭包定义为一个 Racket 的 struct 结构:

(struct Closure (f env))

有了这个数据结构,我们对 (lambda (x) e) 的解释就可以写成这样:

[`(lambda (,x) ,e)
 (Closure exp env)]

注意这里的 exp 就是 ``(lambda (,x) ,e)` 自己。

有意思的是,我们的解释器遇到 (lambda (x) e),几乎没有做任何计算。它只是把这个函数包装了一下,把它与当前的环境一起,打包放到一个数据结构(Closure)里面。这个闭包结构,记录了我们在函数定义的位置“看得见”的那个环境。稍候在调用的时候,我们就能从这个闭包的环境里面,得到函数体内的自由变量的值。

对调用的解释

好了,我们终于到了最后的关头,函数调用。为了直观,我们把函数调用的代码拷贝如下:

[`(,e1 ,e2)                                            
 (let ([v1 (interp e1 env)]             ; 计算函数 e1 的值
       [v2 (interp e2 env)])            ; 计算参数 e2 的值
   (match v1
     [(Closure `(lambda (,x) ,e) env-save)      ; 用模式匹配的方式取出闭包里的各个子结构
      (interp e (ext-env x v2 env-save))]))]    ; 在闭包的环境env-save中把x绑定到v2,解释函数体     

函数调用都是 (e1 e2) 这样的形式,e1 表示函数,e2 是它的参数。我们需要先分别求出函数 e1 和参数 e2 的值。

函数调用就像把一个电器的插头插进插座,使它开始运转。比如,当 (lambda (x) (* x 2)) 被作用于 1 时,我们把 x 绑定到 1,然后解释它的函数体 (* x 2)。但是这里有一个问题,函数体内的自由变量应该取什么值呢?从上面闭包的讨论,你已经知道了,自由变量的值,应该从闭包的环境查询。

操作数 e1 的值 v1 是一个闭包,它里面包含一个函数定义时保存的环境 env-save。我们把这个环境 env-save 取出来,那我们就可以查询它,得到函数体内自由变量的值。然而函数体内不仅有自由变量,还有对函数参数的使用,所以我们必须扩展这个 env-save 环境,把参数的值加进去。这就是为什么我们使用 (ext-env x v2 env-save),而不只是 env-save

你可能会奇怪,那么解释器的环境 env 难道这里就不用了吗?是的。我们通过 env 来计算 e1 和 e2 的值,是因为 e1 和 e2 里面的变量,在“当前环境”(env)里面看得见。可是函数体的定义,在当前环境下是看不见的。它的代码在别的地方,而那个地方看得见的环境,被我们存在闭包里了,它就是 env-save。所以我们把 v1 里面的闭包环境 env-save 取出来,用于计算函数体的值。

有意思的是,如果我们用 env,而不是env-save 来解释函数体,那我们的语言就变成了 dynamic scoping。现在来实验一下:你可以把 (interp e (ext-env x v2 env-save)) 里面的 env-save 改成 env,再试试我们之前讨论过的代码,它的输出就会变成 12。那就是我们之前讲过的,dynamic scoping 的结果。

(r2
(let ([x 2])
   (let ([f (lambda (y) (* x y))])
     (let ([x 4])
       (f 3)))))

;; => 12

你也许发现了,如果我们的语言是 dynamic scoping,那就没必要使用闭包了,因为我们根本不需要闭包里面保存的环境。这样一来,dynamic scoping 的解释器就可以写成这样:

(define interp
  (lambda (exp env)
    (match exp                                          
      ... ...
      [`(lambda (,x) ,e)                          ; 函数:直接返回自己的表达式
       exp]
      ... ...
      [`(,e1 ,e2)                                       
       (let ([v1 (interp e1 env)]
             [v2 (interp e2 env)])
         (match v1
           [`(lambda (,x) ,e)                     ; 调用:直接使用函数的表达式本身
            (interp e (ext-env x v2 env))]))]
      ... ...
)))

注意到这个解释器的函数有多容易实现吗?它就是这个函数的表达式自己,原封不动。用函数的表达式本身来表示它的值,是很直接很简单的做法,也是大部分人一开头就会想到的。然而这样实现出来的语言,就不知不觉地采用了 dynamic scoping。

这就是为什么很多早期的 Lisp 语言,比如 Emacs Lisp,都使用 dynamic scoping。这并不是因为它们的设计者在 dynamic scoping 和 lexical scoping 两者之中做出了选择,而是因为使用函数的表达式本身来作为它的值,是最直接,一般人都会首先想到的做法。

另外,在这里我们也看到环境用“函数式数据结构”表示的好处。闭包被调用时它的环境被扩展,但是这并不会影响原来的那个环境,我们得到的是一个新的环境。所以当函数调用返回之后,函数的参数绑定就自动“注销”了。

如果你用一个非函数式的数据结构,在绑定参数时不生成新的环境,而是对已有环境进行赋值,那么这个赋值操作就会永久性的改变原来环境的内容。所以你在函数返回之后必须删除参数的绑定。这样不但麻烦,而且在复杂的情况下很容易出错。

思考题:可能有些人看过 lambda calculus,这些人可能知道 (let ([x e1]) e2) 其实等价于一个函数调用:((lambda (x) e2) e1)。现在问题来了,我们在讨论函数和调用的时候,很深入的讨论了关于 lexical scoping 和 dynamic scoping 的差别。既然 let 绑定等价于一个函数定义和调用,为什么之前我们讨论对绑定的时候,没有讨论过 lexical scoping 和 dynamic scoping 的问题,也没有制造过闭包呢?

不足之处

现在你已经学会了如何写出一个简单的解释器,它可以处理一个相当强大的函数式语言。出于教学的考虑,这个解释器并没有考虑实用的需求,所以它并不能作为工业应用。在这里,我指出它的一些不足之处。

  1. 缺少必要的语言构造。我们的语言里缺少好些实用语言必须的构造:递归,数组,赋值操作,字符串,自定义数据结构,…… 作为一篇基础性的读物,我不能把这些都加进来。如果你对这些有兴趣,可以看看其它书籍,或者等待我的后续作品。

  2. 不合法代码的检测和报告。你也许发现了,这个解释器的 match 表达式,全都假定了输入都是合法的程序,它并没有检查不合法的情况。如果你给它一个不合法的程序,它不会马上报错,而是会真去算它,以至于导致奇怪的后果。一个实用的解释器,必须加入对代码格式进行全面检测,在运行之前就报告不合法的代码结构。

  3. 低效率的数据结构。在 association list 里面查找变量,是线性的复杂度。当程序有很多变量的时候就有性能问题。一个实用的解释器,需要更高效的数据结构。这种数据结构不一定非得是函数式的。你也可以用非函数式的数据结构(比如哈希表),经过一定的改造,达到同样的性质,却具有更高的效率。?另外,你还可以把环境转化成一个数组。给环境里的每个变量分配一个下标(index),在这个数组里就可以找到它的值。如果你用数组表示环境,那么这个解释器就向编译器迈进了一步。

  4. S 表达式的歧义问题。为了教学需要,我们的解释器直接使用 S 表达式来表达语法树,用模式匹配来进行分支遍历。在实际的语言里,这种方式会带来比较大的问题。因为 S 表达式是一种通用的数据结构,用它表示的东西,看起来都差不多的样子。一旦程序的语法构造多起来,直接对 S 表达式进行模式匹配,会造成歧义。?

    比如 (,op ,e1 ,e2) ,你以为它只匹配二元算术操作,比如 (+ 1 2)。但它其实也可以匹配一个 let 绑定: (let ([x 1]) (* x 2))。这是因为它们顶层元素的数目是一样的。为了消除歧义,你得小心的安排模式的顺序,比如你必须把 (let ([,x ,e1]) ,e2) 的模式放在 (,op ,e1, e2) 前面。所以最好的办法,是不要直接在 S 表达式上写解释器,而是先写一个“parser”,这个 parser 把 S 表达式转换成 Racket 的 struct 结构。然后解释器再在 struct 上面进行分支匹配。这样解释器不用担心歧义问题,而且会带来效率的提升。

 

===================

来源 https://www.cnblogs.com/luckcs/articles/2754930.html

 

  这段时间心里有话说,写了不少博文,可是最后发现,真正对人有价值的其实没有几篇。大部分不过是一个技术人员的无病呻吟。有句话说得好,我曾经为自己没有鞋而苦恼,直到我看见一个没有脚的人。

  早上把《狂人日记》拿出来看了一遍,发现其实国人并不需要我的博文,也不需要先进的技术,因为中国社会实质上正在再次沦为一个人吃人的社会。这个社会需要的书和文章,早在一百年前就已经有人写好了,我在这里凑个什么数。技术再好,有世界上最快的超级计算机,发射了宇宙飞船,有什么用呢?不要忘了,人活着不是为了技术,也不是为了国家的荣耀,人活着是为了自己。现在国人连放心的食物都没得吃,放心的房子都没得住,无论什么先进技术,其实都没什么用。中国需要的不是科学家和工程师,而是文化,法律和正义。一想起这些,就感觉不可承受之重,而这些,我都无能为力。

  我不得不说,博客和微博,是传播愚昧最有效的工具。只看新浪博客和微博那些随处冒出来的低俗广告就知道。说的越多,做的越少,所以我不想写了。写了也是白写,看了也白看。只留下一篇博文《怎样写一个解释器》,是我觉得唯一有价值的。

  文章:怎样写一个解释器 

  卖了好久关子了,说要写一个程序语言理论的入门读物,可是一直没有下笔。终于狠下心来兑现一部分承诺。今天就从解释器讲起吧。

  解释器是比较深入的内容。虽然我试图从最基本的原理讲起,尽量让这篇文章不依赖于其它的知识,但是这篇教程并不是针对函数式编程的入门,所以我假设你已经学会了最基本的 Scheme 和函数式编程。如果你完全不了解这些,可以读一下 SICP 

  的第一,二章。当然你也可以继续读这篇文章,有不懂的地方再去查资料。我在这里也会讲递归和模式匹配的原理。如果你已经了解这些东西,这里的内容也许可以加深你的理解。

  解释器其实不是很难的东西,可是好多人都不会写,因为在他们心目中解释器就像一个 Python 解释器那样复杂。如果你想开头就写一个 Python 解释器,那你多半永远也写不出来。你必须从最简单的语言开始,逐步增加语言的复杂度,才能构造出正确的解释器。这篇文章就是告诉你如何写出一个最简单的语言(lambda calculus) 的解释器,并且带有基本的的算术功能,可以作为一个高级计算器来使用。

  一般的编译器课程往往从语法分析(parsing)开始,折腾 lex 和 yacc 等工具。Parsing 的作用其实只是把字符串解码成程序的语法树(AST)结构。麻烦好久得到了 AST 之后,真正的困难才开始!而很多人在写完 parser 之后就已经倒下了。鉴于这个原因,这里我用“S-expression”来表示程序的语法树(AST)结构。S-expression 让我们可以直接跳过 parse 的步骤,进入关键的主题:语义(semantics)。

  这里用的 Scheme 实现是 Racket。为了让程序简洁,我使用了 Racket 的模式匹配(pattern matching)。如果你用其它的 Scheme 实现的话,恐怕要自己做一些调整。

  解释器是什么

  首先我们来谈一下解释器是什么。说白了解释器跟计算器差不多。它们都接受一个“表达式”,输出一个 “结果”。比如,得到 ‘(+ 1 2)之后就输出3。不过解释器的表达式要比计算器的表达式复杂一些。解释器接受的表达式叫做“程序”,而不只是简单的算术表达式。从本质上讲,每个程序都是一台机器的“描述”,而解释器就是在“模拟”这台机器的运转,也就是在进行“计算”。所以从某种意义上讲,解释器就是计算的本质。当然,不同的解释器就会带来不同的计算。

  需要注意的是,我们的解释器接受的参数是一个表达式的“数据结构”,而不是一个字符串。这里我们用一种叫“S-expression”的数据结构来表示表达式。比如表达式‘(+ 1 2) 里面的内容是三个符号:‘+, ‘1 和 ‘2,而不是字符串“(+ 12)”。从结构化的数据里面提取信息很方便,而从字符串里提取信息很麻烦,而且容易出错。

  从广义上讲,解释器是一个通用的概念。计算器实际上是解释器的一种形式,只不过它处理的语言比程序的解释器简单很多。也许你会发现,CPU 和人脑,从本质上来讲也是解释器,因为解释器的本质实际上是“任何用于处理语言的机器”。

  递归定义 (recursive definition)

  解释器一般都是“递归程序”。之所以是递归的原因,在于它处理的数据结构(程序)本身是“递归定义”的结构。算术表达式就是一个这样的结构,比如:‘(*(+ 1 2) (* (- 9 6)4))。每一个表达式里面可以含有子表达式,子表达式里面还可以有子表达式,如此无穷无尽的嵌套。看似很复杂,其实它的定义不过是:

  “算术表达式”有两种形式:

  1) 一个数

  2) 一个 ‘(op e1 e2) 这样的结构(其中 e1 和 e2 是两个“算术表达式”)

  看出来哪里在“递归”了吗?我们本来在定义“算术表达式”这个概念,而它的定义里面用到了“算术表达式”这个概念本身!这就构造了一个“回路”,让我们可以生成任意深度的表达式。

  很多其它的数据,包括自然数,都是可以用递归来定义的。比如常见的对自然数的定义是:

  “自然数”有两种形式:

  1) 零

  2) 某个“自然数”的后继

  看到了吗?“自然数”的定义里面出现了它自己!这就是为什么我们有无穷多个自然数。

  所以可以说递归是无所不在的,甚至有人说递归就是自然界的终极原理。递归的数据总是需要递归的程序来处理。虽然递归有时候表现为另外的形式,比如循环(loop),但是“递归”这个概念比“循环”更广泛一些。有很多递归程序不能用循环来表达,比如我们今天要写的解释器就是一个递归程序,它就不能用循环来表达。所以写出正确的递归程序,对于设计任何系统都是至关重要的。其实递归的概念不限于程序设计。在数学证明里面有个概念叫“归纳法”(induction),比如“数学归纳法”(mathematicalinduction)。其实归纳法跟递归完全是一回事。

  我们今天的解释器就是一个递归程序。它接受一个表达式,递归的调用它自己来处理各个子表达式,然后把各个递归的结果组合在一起,形成最后的结果。这有点像二叉树遍历,只不过我们的数据结构(程序)比二叉树复杂一些。

  模式匹配和递归:一个简单的计算器

  既然计算器是一种最简单的解释器,那么我们为何不从计算器开始写?下面就是一个计算器,它可以计算四则运算的表达式。这些表达式可以任意的嵌套,比如‘(* (+ 1 2) (+ 3 4))。我想从这个简单的例子来讲一下模式匹配(pattern matching) 和递归(recursion) 的原理。

  下面就是这个计算器的代码。它接受一个表达式,输出一个数字作为结果,正如上一节所示。

  (define calc

  (lambda (exp)

  (matchexp                               ; 匹配表达式的两种情况

  [(? number? x)x]                      ; 是数字,直接返回

  [`(,op ,e1,e2)                        ; 匹配并且提取出操作符 op 和两个操作数 e1, e2

  (let ([v1 (calce1)]                  ; 递归调用 calc 自己,得到 e1 的值

  [v2 (calce2)])                 ; 递归调用 calc 自己,得到 e2 的值

  (matchop                           ; 分支:处理操作符 op 的 4 种情况

  [‘+ (+ v1v2)]                    ; 如果是加号,输出结果为 (+ v1 v2)

  [‘- (- v1v2)]                    ; 如果是减号,乘号,除号,相似的处理

  [‘* (* v1 v2)]

  [‘/ (/ v1 v2)]))])))

  这里的 match 语句是一个模式匹配。它的形式是这样:

  (match exp

  [模式结果]

  [模式结果]

  ...  ...

  )

  它根据表达式 exp 的“结构”来进行“分支”操作。每一个分支由两部分组成,左边的是一个“模式”,右边的是一个结果。左边的模式在匹配之后可能会绑定一些变量,它们可以在右边的表达式里面使用。

  一般说来,数据的“定义”有多少种情况,用来处理它的“模式”就有多少情况。比如算术表达式有两种情况,数字或者 (op e1e2)。所以用来处理它的 match 语句就有两种模式。“你所有的情况,我都能处理”,这就是“穷举法”。穷举的思想非常重要,你漏掉的任何一种情况,都非常有可能带来麻烦。所谓的“数学归纳法”,就是这种穷举法在自然数的递归定义上面的表现。因为你穷举了所有的自然数可能被构造的两种形式,所以你能确保定理对“任意自然数”成立。

  那么模式是如何工作的呢?比如 ‘(,op ,e1 ,e2) 就是一个模式(pattern),它被用来匹配输入的 exp。模式匹配基本的原理就是匹配与它“结构相同”的数据。比如,如果 exp 是 ‘(+ 1 2),那么 ‘(,op ,e1 ,e2)就会把 op 绑定到 ‘+,把 e1 绑定到 ‘1,把 e2 绑定到 ‘2。这是因为它们结构相同:

  ‘(,op ,e1 ,e2)

  ‘( +  1  2)

  说白了,模式就是一个可以含有“名字”(像 op, e1 和 e2)的“数据结构”,像 ‘(,op ,e1,e2)。我们拿这个带有名字的结构去“匹配”实际的数据(像 ‘(+ 12))。当它们一一对应之后,这些名字就自动被绑定到实际数据里相应位置的值。模式里面不但可以含有名字,也可以含有具体的数据。比如你可以构造一个模式‘(,op ,e1 42),用来匹配第二个操作数固定为 42 的那些表达式。

  看见左边的模式,你就像直接“看见”了输入数据的形态,然后对里面的元素进行操作。它可以让我们一次性的“拆散”(destruct)数据结构,把各个部件(域)的值绑定到多个变量,而不需要使用多个访问函数。所以模式匹配是非常直观的编程方式,值得每种语言借鉴。很多函数式语言里都有类似的功能,比如 ML 和 Haskell。

  注意这里 e1 和 e2 里面的操作数还不是值,它们是表达式。我们递归的调用 interp1 自己,分别得到 e1 和 e2 的值 v1 和 v2。它们应该是数字。

  你注意到我们在什么地方使用了递归吗?如果你再看一下“算术表达式”的定义:

  “算术表达式”有两种形式:

  1) 一个数

  2) 一个 ‘(op e1 e2) 这样的结构(其中 e1 和 e2 是两个“算术表达式”)

  你就会发现这个定义里面“递归”的地方就是 e1 和 e2,所以 calc 在 e1 和 e2 上面递归的调用自己。如果你在数据定义的每个递归处都进行递归,那么你的递归程序就会穷举所有的情况。

  之后,我们根据操作符 op 的不同,对这两个值 v1 和 v2 分别进行操作。如果 op 是加号 ‘+,我们就调用 Scheme 的加法操作,作用于 v1 和 v2,并且返回运算所得的值。如果是减号,乘号,除号,我们也进行相应的操作,返回它们的值。

  所以你就可以得到如下的测试结果:

  (calc ‘(+ 1 2))

  ;; => 3

  (calc ‘(* 2 3))

  ;; => 6

  (calc ‘(* (+ 1 2) (+ 3 4)))

  ;; => 21

  一个计算器就是这么简单。你可以试试这些例子,然后自己再做一些新的例子。

  什么是 lambda calculus?

  现在让我们过渡到一种更强大的语言:lambdacalculus。它虽然名字看起来很吓人,但是其实非常简单。它的三个元素分别是是:变量,函数,调用。用传统的表达法,它们看起来就是:

  变量:x

  函数:λx.t

  调用:t1 t2

  每个程序语言里面都有这三个元素,只不过具体的语法不同,所以你其实每天都在使用 lambda calculus。用 Scheme 作为例子,这三个元素看起来就像:

  变量:x

  函数:(lambda (x) e)

  调用:(e1 e2)

  一般的程序语言还有很多其它的结构,可是这三个元素却是缺一不可的。所以构建解释器的最关键步骤就是把这三个东西搞清楚。构造任何一个语言的解释器一般都是从这三个元素开始,在确保它们完全正确之后才慢慢加入其它的元素。

  有一个很简单的思维方式可以让你直接看到这三元素的本质。记得我说过,每个程序都是一个“机器的描述”吗?所以每个 lambdacalculus 的表达式也是一个机器的描述。这种机器跟电子线路非常相似。lambda calculus 的程序和机器有这样的一一对应关系:一个变量就是一根导线。一个函数就是某种电子器件的“样板”,有它自己的输入和输出端子,自己的逻辑。一个调用都是在设计中插入一个电子器件的“实例”,把它的输入端子连接到某些已有的导线,这些导线被叫做“参数”。所以一个 lambda calculus 的解释器实际上就是一个电子线路的模拟器。所以如果你听说有些芯片公司开始用类似 Haskell 的语言(比如 Bluespec System Verilog)来设计硬件,也就不奇怪了。

  需要注意的是,跟一般语言不同,lambda calculus 的函数只有一个参数。这其实不是一个严重的限制,因为 lambdacalculus 的函数可以被作为值传递 (这叫 first-classfunction),所以你可以用嵌套的函数定义来表示两个以上参数的函数。比如,(lambda (x) (lambda (y) y))就可以表示一个两个参数的函数,它返回第二个参数。不过当它被调用的时候,你需要两层调用,就像这样:

  (((lambda (x) (lambda (y) y)) 1) 2)

  ;; => 2

  虽然看起来丑一点,但是它让我们的解释器达到终极的简单。简单对于设计程序语言的人是至关重要的。一开头就追求复杂的设计,往往导致一堆纠缠不清的问题。

  lambda calculus 不同于普通语言的另外一个特点就是它没有数字等基本的数据类型,所以你不能直接用 lambdacalculus 来计算像 (+ 1 2) 这样的表达式。但是有意思的是,数字却可以被 lambda calculus 的三个基本元素“编码”(encoding)出来。这种编码可以用来表示自然数,布尔类型,pair,list,以至于所有的数据结构。它还可以表示 if 条件语句等复杂的语法结构。常见的一种这样的编码叫做 Church encoding。所以 lambda calculus 其实可以产生出几乎所有程序语言的功能。中国的古话“三生万物”,也许就是这个意思。

  求值顺序,call-by-name, call-by-value

  当解释一个程序的时候,我们可以有好几种不同的“求值顺序”(evaluationorder)。这有点像遍历二叉树有好几种不同的顺序一样(中序,前序,后序)。只不过这里的顺序更加复杂一些。比如下面的程序:

  ((lambda (x) (* x x)) (+ 1 2))

  我们可以先执行最外层的调用,把 (+ 1 2) 传递进入函数,得到 (* (+ 1 2) (+ 12))。所以求值顺序是:

  ((lambda (x) (* x x)) (+ 1 2))

  => (* (+ 1 2) (+ 1 2))

  => (* 3 (+ 1 2))

  => (* 3 3)

  => 9

  但是我们也可以先算出 (+ 1 2) 的结果,然后再把它传进这个函数。所以求值顺序是:

  ((lambda (x) (* x x)) (+ 1 2))

  => ((lambda (x) (* x x)) 3)

  => (* 3 3)

  => 9

  我们把第一种方式叫做 call-by-name (CBN),因为它把参数的“名字”(也就是表达式自己)传进函数。我们把第二种方式叫做 call-by-value (CBV),因为它先把参数的名字进行解释,得到它们的“值”之后,才把它们传进函数。

  这两种解释方式的效率是不一样的。从上面的例子,你可以看出 CBN 比 CBV 多出了一步。为什么呢?因为函数 (lambda (x)(* x x)) 里面有两个 x,所以 (+ 1 2) 被传进函数的时候被复制了一份。之后我们需要对它的每一拷贝都进行一次解释,所以(+ 1 2) 被计算了两次!

  鉴于这个原因,几乎所有的程序语言都采用 CBV,而不是 CBN。CBV 常常被叫做“strict”或者“applicativeorder”。虽然 CBN 效率低下,与它等价的一种顺序 call-by-need 却没有这个问题。call-by-need 的基本原理是对 CBN 中被拷贝的表达式进行“共享”和“记忆”。当一个表达式的一个拷贝被计算过了之后,其它的拷贝自动得到它的值,从而避免重复求值。call-by-need 也叫“lazy evaluation”,它是 Haskell 语言所用的语义。

  求值顺序不只停留于 call-by-name, call-by-value,call-by-need。人们还设计了很多种其它的求值顺序,虽然它们大部分都不能像 call-by-value 和 call-by-need 这么实用。

  完整的 lambda calculus 解释器

  下面是我们今天要完成的解释器,它只有 39 行(不包括空行和注释)。你可以先留意一下各个部分的注释,它们标注各个部件的名称,并且有少许讲解。这个解释器实现的是 CBV 顺序的 lambdacalculus,外加基本的算术。加入基本算术的原因是为了可以让初学者写出比较有趣一点的程序,不至于一开头就被迫去学 Churchencoding。

  ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

  ;;; 以下三个定义 env0, ent-env, lookup 是对环境(environment)的基本操作:

  ;; 空环境

  (define env0 ‘())

  ;; 扩展。对环境 env 进行扩展,把 x 映射到 v,得到一个新的环境

  (define ext-env

  (lambda (x v env)

  (cons `(,x .,v) env)))

  ;; 查找。在环境中 env 中查找 x 的值

  (define lookup

  (lambda (x env)

  (let ([p(assq x env)])

  (cond

  [(not p) x]

  [else (cdr p)]))))

  ;; 闭包的数据结构定义,包含一个函数定义 f 和它定义时所在的环境

  (struct Closure (f env))

  ;; 解释器的递归定义(接受两个参数,表达式 exp 和环境 env)

  ;; 共 5 种情况(变量,函数,调用,数字,算术表达式)

  (define interp1

  (lambda (exp env)

  (match exp                     ; 模式匹配 exp 的以下情况(分支)

  [(? symbol? x) (lookup x env)]           ; 变量

  [(? number? x) x]                 ; 数字

  [`(lambda (,x) ,e)                 ; 函数

  (Closure exp env)]

  [`(,e1 ,e2)                    ; 调用

  (let ([v1 (interp1 e1 env)]

  [v2 (interp1 e2 env)])

  (match v1

  [(Closure `(lambda (,x) ,e) env1)

  (interp1 e (ext-env x v2 env1))]))]

  [`(,op ,e1 ,e2)                  ;算术表达式

  (let ([v1 (interp1 e1 env)]

  [v2 (interp1 e2 env)])

  (match op

  [‘+ (+ v1 v2)]

  [‘- (- v1 v2)]

  [‘* (* v1 v2)]

  [‘/ (/ v1 v2)]))])))

  ;; 解释器的“用户界面”函数。它把 interp1 包装起来,掩盖第二个参数,初始值为 env0

  (define interp

  (lambda (exp)

  (interp1 expenv0)))

  ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

  测试例子

  这里有一些测试的例子。你最好先玩一下再继续往下看,或者自己写一些新的例子。学习程序的最好办法就是玩弄这个程序,给它一些输入,观察它的行为。有时候这比任何语言的描述都要直观和清晰。

  (interp ‘(+ 1 2))

  ;; => 3

  (interp ‘(* 2 3))

  ;; => 6

  (interp ‘(* 2 (+ 3 4)))

  ;; => 14

  (interp ‘(* (+ 1 2) (+ 3 4)))

  ;; => 21

  (interp ‘(((lambda (x) (lambda (y) (* x y))) 2) 3))

  ;; => 6

  (interp ‘((lambda (x) (* 2 x)) 3))

  ;; => 6

  (interp ‘((lambda (y) (((lambda (y) (lambda (x) (* y 2))) 3)0)) 4))

  ;; => 6

  ;; (interp ‘(1 2))

  ;; => match: no matching clause for 1

  在接下来的几节,我们来看看这个解释器里主要的分支(match)表达式的各种情况。

  对基本算术操作的解释

  算术操作在解释器里是最简单也是最“基础”的东西,因为它们不能再被细分为更小的元素了。所以在接触函数,调用等复杂的结构之前,我们来看一看对算术操作的处理。以下就是这个解释器里处理基本算术的部分,它是 interp1 的最后一个分支。

  (matchexp

  ... ...

  [`(,op ,e1 ,e2)

  (let ([v1 (interp1 e1env)]           ; 递归调用 interp1 自己,得到 e1 的值

  [v2 (interp1 e2env)])          ; 递归调用 interp1 自己,得到 e2 的值

  (matchop                           ; 分支:处理操作符 op 的 4 种情况

  [‘+ (+ v1v2)]                    ; 如果是加号,输出结果为 (+ v1 v2)

  [‘- (- v1v2)]                    ; 如果是减号,乘号,除号,相似的处理

  [‘* (* v1 v2)]

  [‘/ (/ v1 v2)]))])

  你可以看到它几乎跟刚才写的计算器一模一样,不过现在 interp1 的调用多了一个参数 env 而已。这个 env 是什么,我们下面很快就讲。

  变量和函数

  我想用两个小节来简单介绍一下变量,函数和环境。稍后的几节我们再来看它们是如何实现的。

  变量(variable)的产生是数学史上的最大突破之一。因为变量可以被绑定到不同的值,从而使得函数的实现成为可能。比如数学函数f(x) = x * 2,其中 x 是一个变量,它把输入的值传递到函数的主体“x *2”里面。如果没有变量,函数就不可能实现。

  对变量的最基本的操作是对它的“绑定”(binding)和“取值”(evaluate)。什么是绑定呢?拿上面的函数 f (x)作为例子吧。当 x 等于 1 的时候,f(x) 的值是 2,而当 x 等于 2 的时候,f(x) 的值是 4。在上面的句子里,我们对x 进行了两次绑定。第一次 x 被绑定到了 1,第二次被绑定到了2。你可以把“绑定”理解成这样一个动作,就像当你把插头插进电源插座的那一瞬间。插头的插脚就是 f (x) 里面的那个 x,而 x * 2 里面的x,则是电线的另外一端。所以当你把插头插进插座,电流就通过这根电线到达另外一端。如果电线导电性能良好,两头的电压应该几乎相等。有点跑题了……反正只要记住一点:绑定就是插进插座的那个“动作”。

  那么“取值”呢?再想一下前面的例子,当我们用伏特表测电线另外一端的电压的时候,我们就是在对这个变量进行取值。有时候这种取值的过程不是那么明显,比如电流如果驱动了风扇的电动机。虽然电线的另外一头没有显示电压,其实电流已经作用于电动机的输入端子,进入线圈。所以你也可以说其实是电动机在对变量进行取值。

  环境

  我们的解释器是一个挺笨的程序,它只能一步一步的做事情。比如,当它需要求 f (1) 的值的时候,它做以下两步操作:1) 把 x 绑定到1; 2) 进入 f 的函数体对 x * 2 进行求值。这就像一个人做出这两个动作:1)把插头插进插座,2)走到电线的另外一头测量它的电压,并且把结果乘以 2。在第一步和第二步之间,我们如何记住 x 的值呢?它必须被传递到那个用来处理函数体的递归解释器里面。这就是为什么我们需要“环境”,也就是 interp1 的第二个参数 env。

  环境记录变量的值,并且把它们传递到它们的“可见区域”,用术语说就叫做“作用域”(scope)。通常作用域是整个函数体,但是有一个例外,就是当函数体内有嵌套的函数定义的时候,内部的那个函数如果有同样的参数名,那么外层的参数名就会被“屏蔽”(shadow)掉。这样内部的函数体就看不到外层的参数了,只看到它自己的。比如(lambda (x) (lambda (x) (* x 2))),里面的那个 x 看到的就是内层函数的x,而不是外层的。

  在我们的解释器里,用于处理环境的主要部件如下:

  ;; 空环境

  (define env0 ‘())

  ;; 对环境 env 进行扩展,把 x 映射到 v

  (define ext-env

  (lambda (x v env)

  (cons `(,x .,v) env)))

  ;; 取值。在环境中 env 中查找 x 的值

  (define lookup

  (lambda (x env)

  (let ([p(assq x env)])

  (cond

  [(not p) x]

  [else (cdr p)]))))

  这里我们用的是 Scheme 的 association list 来表示环境。Association list 看起来像这个样子:((x . 1) (y . 2) (z . 5))。也就是一个两元组(pair)的链表,左边的元素是 key,右边的元素是 value。写的直观一点就是:

  ((x . 1)

  (y . 2)

  (z . 5))

  查表操作就是从头到尾搜索,如果左边的 key 是要找的变量,就返回整个 pair。简单吧?

  ext-env 扩展一个环境。比如,如果原来的环境是 ((y . 2) (z . 5)) 那么 (ext-env x1 ((y . 2) (z .5))),就会得到 ((x . 1) (y . 2) (z . 5))。也就是把 (x . 1)放到最前面去。值得注意的一点是,环境被扩展以后其实是形成了一个新的环境,原来的环境并没有被“改变”。比如上面红色的部分就是原来的数据结构,只不过它被放到另一个更大的结构里面了。这叫做“函数式数据结构”。这个性质在我们的解释器里是至关重要的,因为当我们扩展了一个环境之后,其它部分的代码仍然可以原封不动的访问扩展前的那个旧的环境。当我们讲到调用的时候也许你就会发现这个性质的用处。

  你也可以用另外的,更高效的数据结构(比如 splaytree)来表示环境。你甚至可以用函数来表示环境。唯一的要求就是,它是变量到值的“映射”(map)。你把 x 映射到 1,待会儿查询x 的值,它应该仍然是 1,而不会消失掉或者别的值。也就是说,这几个函数要满足这样的一种“界面约定”:如果 e 是 (ext-env‘x 1 env) 返回的环境,那么 (lookup ‘x e) 应该返回 1。只要满足这样的界面约定的函数都可以被叫做 ext-env 和 lookup,以至于可以它们用来完全替代这里的函数而不会导致其它代码的修改。这叫做“抽象”,也就是“面向对象语言”的精髓所在。

  对变量的解释

  了解了变量,函数和环境,让我们来看看解释器对变量的操作,也就是 interp1 的 match 的第一种情况。它非常简单,就是在环境中查找变量的值。这里的 (? symbol? x) 是一个特殊的模式,它使用 Scheme 函数 symbol? 来判断输入是否匹配,如果是的就把它绑定到 x,查找它的值,然后返回这个值。

  [(? symbol? x) (lookup x env)]

  注意由于我们的解释器是递归的,所以这个值也许会被返回到更高层的表达式,比如 (* x 2)。

  对数字的解释

  对数字的解释也很简单。由于在 Scheme 里面名字 ‘2 就是数字 2(我认为这是 Scheme 设计上的一个小错误),所以我们不需要对数字的名字做特殊的处理,把它们原封不动的返回。

  [(? number? x) x]

  对函数的解释

  对函数的解释是一个比较难说清楚的问题。由于函数体内也许会含有外层函数的参数,比如 (lambda (y) (lambda (x) (* y2))) 里面的 y 是外层函数的参数,却出现在内层函数定义中。如果内层函数被作为值返回,那么 (* y 2) 就会跑到y的作用域以外。所以我们必须把函数做成“闭包”(closure)。闭包是一种特殊的数据结构,它由两个元素组成:函数的定义和当前的环境。所以我们对(lambda (x) e) 这样一个函数的解释就是这样:

  [`(lambda (,x) ,e)

  (Closure exp env)]

  注意这里的 exp 就是 `(lambda (,x) ,e)自己。我们只是把它包装了一下,把它与当前的环境一起放到一个数据结构(闭包)里,并不进行任何复杂的运算。这里我们的闭包用的是一个 Racket 的 struct 结构,也就是一个记录类型(record)。你也可以用其它形式来表示闭包,比如有些解释器教程提倡用函数来表示闭包。其实用什么形式都无所谓,只要能存储 exp 和 env 的值。我比较喜欢使用 struct,因为它的界面简单清晰。

  为什么需要保存当前的环境呢?因为当这个函数被作为一个值返回的时候,我们必须记住里面的外层函数的参数的绑定。比如,(lambda (y)(lambda (x) (* y 2)))。当它被作用于 1 之后,我们会得到内层的函数 (lambda (x) (* y 2))。当这个函数被经过一阵周折之后再被调用的时候,y应该等于几呢?正确的做法应该是等于1。这种把外层参数的值记录在内层函数的闭包里的做法,叫做“lexicalscoping”或者“static scoping”。

  如果你不做闭包,而是把函数体直接返回,那么在 (lambda (x) (* y 2)) 被调用的位置,你可能会另外找到一个y,从而使用它的值。在调用的时候“动态”解析变量的做法,叫做“dynamic scoping”。事实证明 dynamicscoping 的做法是严重错误的,它导致了早期语言里面出现的各种很难发现的 bug。很多早期的语言是 dynamicscoping,就是因为它们只保存了函数的代码,而没有保存它定义处的环境。这样要简单一些,但是带来太多的麻烦。早期的 Lisp,现在的 Emacs Lisp 和 TeX 就是使用 dynamic scoping 的语言。

  为了演示 lexical scoping 和 dynamic scoping 的区别。你可以在我们的解释器里执行以下代码:

  (interp ‘((lambda (y) (((lambda (y)(lambda (x) (* y 2))) 3) 0)) 4))

  其中红色的部分就是上面提到的例子。在这里,(* y 2) 里的 y,其实是最里面的那个 (lambda (y) ...)里的。当红色部分被作用于 3 之后。 (lambda (x) (* y2)) 被作为一个值返回。然后它被作用于 0(x 被绑定到 0,被忽略),所以 (*y 2) 应该等于 6。但是如果我们的解释器是 dynamic scoping,那么最后的结果就会等于 8。这是因为最外层的 y 开头被绑定到了 4,而 dynamic scoping 没有记住内层的 y 的值,所以使用了外层那个 y 的值。

  为什么 Lexical scoping 更好呢?你可以从很简单的直觉来理解。当你构造一个“内部函数”的时候,如果它引用了外面的变量,比如这个例子里的 y,那么从外层的 y 到这个函数的内部,出现了一条“信道”(channel)。你可以把这个内部函数想象成一个电路元件,它的内部有一个节点 y 连接到一根从外部来的电线 y。当这个元件被返回,就像这个元件被挖出来送到别的地方去用。但是在它被使用的地方(调用),这个 y 节点应该从哪里得到输入呢?显然你不应该使用调用处的某个 y,因为这个 y 和之前的那个 y,虽然都叫 y,却不是“同一个y”,也就是同名异义。它们甚至可以代表不同的类型的东西。所以这个 y 应该仍然连接原来的那根 y 电线。当这个内部元件移动的时候,就像这跟电线被无限的延长,但是它始终连接到原来的节点。

  对函数调用的解释

  好,我们终于到了最后的关头,函数调用。函数调用都是 (e1 e2) 这样的形式,所以我们需要先分别求出 e1 和 e2 的值。这跟基本运算的时候需要先求出两个操作数的值相似。

  函数调用就像把一个电器的插头插进插座,使它开始运转。比如,当 (lambda (x) (* x 2)) 被作用于 1 时,我们把 x 绑定到 1,然后解释它的函数体 (* x2)。但是这里有一个问题,如果函数体内有未绑定的变量,它应该取什么值呢?从上面闭包的讨论,你已经知道了,其实操作数 e1 被求值之后应该是一个闭包,所以它的里面应该有未绑定变量的值。所以,我们就把这个闭包中保存的环境(env1)取出来,扩展它,把 x 绑定到 v2,然后用这个扩展后的环境来解释函数体。

  所以函数调用的代码如下:

  [`(,e1,e2)                                            

  (let ([v1 (interp1 e1 env)]

  [v2 (interp1 e2 env)])

  (match v1

  [(Closure `(lambda (,x) ,e) env1)  ;用模式匹配的方式取出闭包里的各个子结构

  (interp1 e (ext-env x v2env1))]  ; 在闭包的环境中把 x 绑定到 v2,解释函数体

  ))]

  你可能会奇怪,那么解释器的环境 env 难道这里就不用了吗?是的。我们通过 env 来计算 e1 和 e2 的值,是因为 e1 和 e2 里面的变量存在于“当前环境”。我们把 e1 里面的环境 env1 取出来用于计算函数体,是因为函数体并不是在当前环境定义的,它的代码在别的地方。如果我们用 env 来解释函数体,那就成了 dynamic scoping。

  实验:你可以把 (interp1 e (ext-env x v2 env1)) 里面的 env1 改成 env,再试试我们之前讨论过的代码,它的输出就会是 8:

  (interp ‘((lambda (y) (((lambda (y) (lambda (x) (* y2))) 3) 0)) 4))

  另外在这里我们也看到环境用“函数式数据结构”表示的好处。闭包被调用时它的环境被扩展,但是这并不会影响原来的那个环境,我们得到的是一个新的环境。所以当函数调用返回之后,函数的参数绑定就自动“注销”了。如果你用一个非函数式的数据结构,在绑定参数时不生成新的环境,而是对已有环境进行赋值,那么这个赋值操作就会永久性的改变原来环境的内容。所以你在函数返回之后必须删除参数的绑定。这样不但麻烦,而且在复杂的情况下几乎不可能有效的控制。每一次当我使用赋值操作来修改环境,最后都会出现意想不到的麻烦。所以在写解释器,编译器的时候,我都只使用函数式数据结构来表示环境。

  下一步

  在懂得了这里讲述的基本的解释器构造之后,下一步可以做什么呢?其实从这个基本的解释器原型,你可以进一步发展出很多内容,比如:

  • 在这个解释器里加一些构造,比如递归和状态,你就可以得到一个完整的程序语言的解释器,比如 Scheme 或者 Python。
  • 对这个解释器进行“抽象”,你就可以对程序进行类型推导。感兴趣的话可以参考我实现的这个 Hindley-Milner 系统,或者 Python 类型推导
  • 对这个解释器进行一些改变,就可以得到一个非常强大的 online partialevaluator,可以用于编译器优化。

  如果有问题的话,欢迎跟我联系:shredderyin@gmail.com。另外需要指出的是,学会这个解释器并不等于理解了程序语言的理论。所以在学会了这些之后,还是要看一些语义学的书。

 

================= End

 

怎样写一个解释器

标签:莫名其妙   也会   png   个人   emacs   tps   lua   规律   表达式   

原文地址:https://www.cnblogs.com/lsgxeva/p/13190420.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!