分析执行分离
介绍分析式求值器:把表达式的语法分析与执行分开,语法只分析一次,从而大幅提升解释器效率。
分析式求值器
要动手实践本节内容,请先获取分析式元循环求值器(analyzing metacircular evaluator):
cp ~cs61as/lib/analyze.scm .
第 12 课中的元循环求值器实现虽然简单,但效率很低。问题出在表达式的语法分析与执行交错在一起:程序每执行一次,它的语法就要被重新分析一遍。下面看一个例子。
假设我们定义了下面这个 factorial 函数:
(define (fact num)
(if (= num 0)
1
(* num (fact (- num 1)))))
那么计算 (fact 3) 时会发生什么呢?
eval (fact 3)
self-evaluating? ==> #f
variable? ==> #f
quoted? ==> #f
assignment? definition?
if? ==> #f
lambda? ==> #f
begin? ==> #f
cond? ==> #f
application? ==> #t
eval fact
self-evaluating? ==> #f
variable? ==> #t
lookup-variable-value ==> <procedure fact>
list-of-values (3)
eval3 ==> 3
apply <procedure fact> (3)
eval (if (= num 0) ...)
self-evaluating? ==> #f
variable? ==> #f
quoted? ==> #f
assignment? ==> #f
definition? ==> #f
if? ==> #t
eval-if (if (= num 0) ...)
if-predicate ==> (= num 0)
eval (= num 0)
self-evaluating? ==> #f
...
if-alternative ==> (* num (fact (- num 1)))
eval (* num (fact (- num 1)))
self-evaluating? ==> #f
...
list-of-values (num (fact (- num 1)))
...
eval (fact (- num 1))
...
apply <procedure fact> (2)
eval (if (= num 0) ...)
求值器不得不先后四次检查过程体:判断它是个 if 表达式,抽出各个组成部分,再对它们求值(而每个部分的求值,又要先判断它属于哪种表达式)。
这正是解释型语言远比编译型语言慢的原因之一:解释器一遍又一遍地对程序做语法分析;而编译器只分析一次,编译后的程序直接执行那些依赖变量实际取值的计算即可。本节我们要学习的分析式求值器(analyzing evaluator),目的就是避免对程序语法的重复分析。
分离的思路
eval 接受两个参数:一个表达式和一个环境(environment)。其中表达式参数在我们重新遇到同一个表达式时是不变的,而环境每次都会不同。举个例子:计算 (fact 3) 时,我们在一个 num 等于 3 的环境中求值 fact 的过程体;这个过程体里又包含递归调用 (fact 2),此时求值的是同一个过程体,但环境变成了 num 绑定为 2 的环境。
我们的方案是:梳理整个求值过程,找出那些只依赖 exp 而不依赖 env 的部分,并且只做一次。承担这项工作的过程叫做 analyze(分析)。
analyze 的返回结果是什么呢?它必须能与某个环境结合,最终产出一个值。解决办法是:让 analyze 返回一个只接受 env 参数的过程,由它完成剩下的求值工作。
也就是说,原来的形式
(eval exp env) ==> value
变成了两步:
1. (analyze exp) ==> exp-procedure
2. (exp-procedure env) ==> value
检验理解
analyze 返回的过程接受什么类型的参数?
以后再次求值同一个表达式时,只需要重复第 2 步。这有点像记忆化(memoization):我们记住某个计算的结果,避免重复计算。区别在于,这里记住的只是整个问题解法的一部分,而不是完整的答案。
只要稍作改写,就能复刻原来 eval 的效果:
(define (eval exp env)
((analyze exp) env))
analyze
analyze 的结构与原来的 eval 十分相似:
(define (analyze exp)
(cond
((self-evaluating? exp)
(analyze-self-eval exp))
((variable? exp)
(analyze-var exp))
...
((foo? exp) (analyze-foo exp))
...))
区别在于:原来像 eval-if 那样同时接受表达式和环境两个参数的过程,被换成了像 analyze-if 这样只接受表达式一个参数的过程。这些分析过程是怎么工作的呢?作为理解的中间步骤,先看一个完全照搬 eval-if 结构、一点时间也不省的 analyze-if 版本:
eval-if:
(define (eval-if exp env)
(if (true? (eval (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env)))
analyze-if:
(define (analyze-if exp)
(lambda (env)
(if (true? (eval (if-predicate exp) env))
(eval (if-consequent exp) env)
(eval (if-alternative exp) env))))
这个版本的 analyze-if 返回一个以 env 为参数的过程,过程体与原来的 eval-if 完全一致。因此,如果我们执行
((analyze-if some-if-expression) some-environment)
结果与在原来的元循环求值器中写
(eval-if some-if-expression some-environment)
完全相同。
但我们还想改进这个初版 analyze-if,因为它实际上并没有省掉任何工作。每次调用 analyze-if 返回的过程时,它都会把原来 eval-if 的全部工作重做一遍。
初版 analyze-if 里有三处对 eval 的调用。每一处调用都是先对表达式做分析,再在给定环境中算出值。我们要做的,是把每处 eval 调用拆成两个独立的阶段,第一阶段只做一次,不必每次都做:
(define (analyze-if exp)
(let ((pproc (analyze (if-predicate exp)))
(cproc (analyze (if-consequent exp)))
(aproc (analyze (if-alternative exp))))
(lambda (env)
(if (true? (pproc env))
(cproc env)
(aproc env)))))
在这个最终版本里,analyze-if 返回的过程已经不含任何分析步骤了。所有组成部分在调用它之前就分析完毕,不需要再做任何分析。
效率提升最大的地方在于 lambda 表达式的处理方式。在原来的元循环求值器中(为清晰起见,这里省略了一些数据抽象),代码是这样的:
(define (eval-lambda exp env) (list 'procedure exp env))
除了记住过程的文本和创建它时所处的环境,求值器对 lambda 表达式几乎什么都不做。但在分析式求值器中,我们会用 analyze-sequence 过程去分析过程体;此时作为过程表示形式存储的内容不再包含它的文本!取而代之,求值器在元循环 Scheme 中把一个过程表示成底层 Scheme 的一个过程,外加形式参数和定义环境。
(务必阅读 SICP 4.1.7 节,看看所有语法分析过程具体是怎么实现的。)
层次混淆
分析式求值器会把下面这样的表达式
(if A B C)
转变成一个过程
(lambda (env)
(if (A-execution-procedure env)
(B-execution-procedure env)
(C-execution-procedure env)))
这看起来像是倒退:我们本想实现 if,结果得到的却是一个做 if 的过程。这不是无限倒退吗?
并不是。执行过程里的那个 if 是由底层 Scheme 处理的,而不是由元循环 Scheme 处理的。因此不存在倒退——我们不会为那个 if 再去调用 analyze-if。况且底层 Scheme 的 if,比在元 Scheme 里给那个 if 做语法分析要快得多。
那又如何?
表达式语法分析是编译器工作的大头。从某种意义上说,这个分析式求值器就是一台编译器!它把 Scheme 编译成 Scheme,所以算不上实用的编译器。不过,把它改成编译成别的东西——比如某台计算机的机器语言——也并不会难多少。
结构与之类似的编译器叫做递归下降(recursive descent)编译器。如今在实践中,大多数编译器采用另一种技术(称为栈机器,stack machine),因为这种方式可以自动生成语法分析器。(我之前在讲数据导向程序设计时提到过它。)但如果是手写语法分析器,递归下降是最容易的选择。
(继续往下之前,务必先读 SICP 4.1.7 节。)
一个例子
下面用分析式求值器求值 factorial,是个很好的例子。先看这段 Scheme 代码:
(define factorial
(lambda (n)
(if (= n 1)
1
(* (factorial (- n 1)) n))))
(factorial 2) ;; 参数取小一点,例子才不会太长
这里有两条语句:一条定义,一条应用。
我们先从定义开始,这里把它记为 d(即 '(define (factorial n) ...)'):
(eval d env)
((analyze d) env)
((analyze-definition d) env)
analyze-definition 会先分析 definition-value,然后创建一个执行过程;该过程被执行时,会把变量名 define 到分析后的 definition-value 上。
这一点很关键:我们赋给 factorial 的不是普通的 lambda,而是一个已分析的 lambda。这会在日后带来可观的性能收益。
那么,为了算出 factorial 的值,我们自然要用 analyze-lambda(经由 analyze 中的分发)来分析这个 lambda。
analyze-lambda 的收益,关键在于过程体只分析一次:它把分析后的过程体(一个 Scheme 过程)装进过程抽象数据类型,而不是像旧 eval 那样存一串简单的指令。
要点在于:以后每次调用我们的 lambda,都不必再做语法分析。语法分析只在创建 lambda 时做一次。
下面看它如何运作。
(注意:我会用 := 表示存储:
var := value
这并不是真正的 Scheme 语法,但我觉得比写一堆 let 语句更清楚。)
(analyze-lambda '(lambda (n) ...)')
现在我们需要 analyze 过程体,然后把它存起来,这样以后就不必重复分析:
analyzed-body := (analyze (lambda-body '(lambda (n) (if ...))'))
(analyze-if '(if (= n 1)
1
(* (factorial (- n 1)) n))')
analyze-if 会分析传给它的全部内容,存好结果,然后用这些存好的值创建一个新的执行过程。
if-pred := (analyze '(= n 1)')
; 这就是执行过程:(lambda (env)
; (execute-application (analyzed/= env)
; ...))
if-true := (analyze '1')
; 这就是执行过程:(lambda (env) 1)
if-false := (analyze '(* (factorial (- n 1)) n)')
; 这个太长了,写不出来,不过和 if-pred 差不多
;; 这是我们返回的执行过程:
;; 把这个执行过程叫做 'analyzed-fact-if'
(lambda (env)
(if (true? (if-pred env))
(if-true env)
(if-false env)))
知道了这个结果,我们再回到 analyze-lambda:
analyzed-body := analyzed-fact-if
(analyze-lambda '(lambda (n) ...)')
=> (lambda (env) (make-procedure '(n) analyzed-body env'))
我们把最后一个表达式存进 factorial 变量,factorial 的定义就完成了。注意,过程体只分析一次——就在分析阶段。求值阶段从来不 analyze!这意味着求值时每次调用这个 factorial 函数,我们都知道它的过程体里有一个 if 语句,知道这个 if 判断 n 是否等于 0(以及判断为真和为假时各自该做什么)。
接下来正式求值 factorial,你会看到之前那些费解的分析工作如何带来回报。
(eval '(factorial 2) env') ; env 里有 factorial 的定义
((analyze '(factorial 2)') env)
((analyze-application '(factorial 2)') env)
((lambda (env) (execute-application ...)) env)
((procedure-body {内部的 factorial 值})
(extend-environment ...)) ; extend-environment 和旧 eval 里一样
;; 把这个扩展后的环境叫做 env2
(analyzed-body env2) ; analyzed-body 来自上面的定义
((lambda (env)
(if (true? (if-pred env))
(if-true env)
(if-false env)))
env2)
(if (true? (if-pred env2)) ; (= n 0)
(if-true env2) ; 1
(if-false env2)) ; (* (factorial (- n 1)) n)
这里 n = 2 != 0,所以我们会去执行 (if-false env2)。if-false 会把 * 应用到 (factorial (- n 1)) 和 n 上,但这些参数早就 analyze 过了(在我们做 analyze-lambda 的时候)。于是我们对分析后的 (factorial (- n 1)) 求值,也就是:
(analyzed-factorial {调用 analyzed (- n 1) 的结果})
(analyzed-body env3)
;env3 := env2 扩展 n := (- {上一个 n} 1) = (- 2 1) = 1
(if (true? (if-pred env3)) ; (= n 0)
(if-true env3) ; 1
(if-false env3)) ; (* (factorial (- n 1)) n)
我们用同样的方式继续递归:
(if (true? (if-pred env4)) ; (= n 0)
(if-true env4) ; 1
(if-false env4)) ; (* (factorial (- n 1)) n)
这一次 n 真的等于 0,于是我们调用 (if-true env4)。if-true 不理睬 env4,直接返回数字 1。然后我们回到前面那些 execute-application 的原始应用,把所有东西乘起来。
结果是……2。
大功告成。
请注意:整个求值阶段中,我们从未检查过任何语句的语法。语法早已看过并 analyze 过了,我们只是照着这些 analyzed 语句的指示行事。想想看,计算 (factorial 100) 这类东西时,这样的效率提升有多可观。