SICP第四章阅读心得 - Lisp解释器的实现

1,805 阅读30分钟

经过近两个月的苦战,笔者终于将SICP(Structure and Interpretation of Computer Programs(计算机程序的构造和解释))一书读到了第四章过半,开始接触书中关于语言级抽象(metalinguistic abstraction)的介绍。在这个时点,我打算分享一下自己阅读本书的一部分心得,重点是第四章的第一小节,毕竟语言的解析、编译等方面的知识是我自己最感兴趣的。

总的来说,SICP的第四章讲的是关于如何用一种计算机程序来实现另一种计算机程序的知识,以达到用语言来抽象,使一些计算问题变得简单的目的。

关于解释/eval

这个概念常常与编译/compile形成相对的概念。两者的区别,我将其总结如下:

  • 解释一段程序时,输入是一段包含了过程与数据的程序,输出是其结果。用伪代码来说就是:eval(procedure, data) => output
  • 编译一段程序时,输入是一段包含过程的程序,输出是一段被编译出来的可执行程序,需要将数据输入至这个可运行程序,得到结果。用伪代码来说就是:compile(procedure)(data) => output

另外,翻译/interpret一词也常常用来表示解释(eval)这一概念。

SICP的第四章所实现的是一个解释器/evaluator,也就是可以直接解释一段程序并输出结果的程序。

关于SICP第四章的结构

本文仅仅是关于SICP第四章第一小节的个人阅读心得,但这里有必要说明一下整个第四章的大致脉络。

  • 4.1,介绍了实现Lisp解释器所使用的一种模型,即自循环解释器/metacircular evaluator。并用此模型实现了一个基本的Lisp的解释器
  • 4.2和4.3讨论的都是如何对于已实现的解释器,作行为上的修改,以实现新的语言特性
    • 4.2,实现了懒解释/lazy-evaluation特性,使得解释器可以在运行时,可以将当前暂时不需要其结果的子表达式的解释过程推迟
    • 4.3,实现了非确定性计算/non-deterministic computation的特性,使得解释器可以用amb、require等新引入的语法,将一些需要广泛搜索并使用回溯算法的问题优雅地表达出来
  • 4.4,讨论了一种新的编程范式,逻辑编程/logic programming

可见,4.1是整章的基础内容,想要有效地阅读整章内容,需要先阅读4.1。

eval-apply模型

这是本章所实现的Lisp解释器所采用的基础模型,也可以说是它的架构。

整个解释器的运行过程,就是eval/解释apply/应用两个过程/procedure不断相互调用的过程。eval和apply这两个过程分别是如此定义的(译自SICP原书,以下术语较多,如不通顺请见谅并参照原书):

  • 当我们需要eval一个表达式时(此表达式是一个不属于特殊形式/special form的任意组合表达式),我们需要将各个子表达式分别eval,并以子表达式的值为参数(这其中包含了一个操作符/operator和若干操作数/operands),调用apply
  • 当我们有了若干个参数,需要apply一个过程时,我们需要在一个新的环境中,eval此过程的过程体/body部分。新的环境,是通过将过程对象的环境扩展而得来的。扩展方法是增加一个新的frame,在此frame中将形参和实参绑定起来

以上定义,在我第一遍阅读时也是一脸懵逼。只有在自己尝试去实现时,才开始慢慢地理解其中的含义。这里,我想结合一下自己的理解,对上面的定义作一些补充:

  • eval是将表达式的结果解释出来的过程。由于一种计算机语言有各种各样类型的表达式(如声明、过程、条件等),eval需要有能力处理各种类型的表达式的解释问题。
  • 在eval-apply模型的设计中,eval的一般情况是针对过程类表达式的。过程类表达式在Lisp中,形式如(proc arg1 arg2 ...)所示,也就是外部有一个括弧围绕,括弧内的第一个部分代表的是需要调用的过程,剩余的部分代表的是调用过程时的实参。
  • 由于一个过程类表达式的各个组成部分本身也可能也是复合的(compound),比如一个二元加法,既可以用+表示,也可以用(lambda (a b) (+ a b))来表示;需要参与计算的参数也可能由(+ 2 3)这样的表达式来表示,所以对于过程类表达式的eval,在apply这个表达式之前,需要将各个子表达式分别eval
  • 特殊形式(special form),指的是一类表达式,这类表达式在eval时,需要遵循自身特殊的规则。例如在Lisp中,if是特殊形式之一。例如,我们有一个Lisp的if表达式形如(if pred thenAction elseAction),它的eval规则,不是将if, pred, thenAction, elseAction等子表达式分别eval,而是:首先eval表达式的pred部分(条件部分)并得到其结果,结果的真值为真时,eval表达式的thenAction(then分支);否则eval表达式的elseAction(else分支)。两个分支中有一个分支会作为不eval的程序部分被跳过。
  • apply是将一个过程表达式解释出来的过程。在eval-apply模型的设计中,一个过程表达式包含三个部分:形参/parameters、过程体/body和环境/environment
    • 形参(parameters):指的是声明过程表达式时的形参部分
    • 过程体(body):指的是此过程表达式时被apply时具体需要被执行的一系列表达式的部分
    • 环境(environment):指的是此过程表达式被定义时所在的环境
  • 环境/environment是eval操作的基础之一,任何对于某一表达式的解释,都是基于某一特定环境的。环境中存在着变量名与其所代表的实体的一系列绑定。apply操作时,会对当前所处环境进行扩展,并在新环境中进行eval操作(下文还有关于环境的进一步说明)

使用JavaScript实现eval-apply解释器

通过模仿和练习来学习,才能更好地巩固知识。这里介绍一下我使用JavaScript实现一个基于以上模型的Lisp解释器的思路和过程。

词法分析和语法分析

SICP中实现Lisp解释器时所使用的语言,是Lisp语言自身。其输入并不是一段扁平的Lisp程序的文本,而是Lisp的List和Pair等数据结构所描述的程序结构。也就是说,书中所讨论的eval的解释的实现,是建立在需要解释的程序的抽象语法树(Abstract syntax tree,以下简称AST)已经得到,不需要作语法分析或语法分析的基础上的。

使用JavaScript实现Lisp的解释器的情况下,由于JavaScript不可能原生地将类似于(+ 2 (- 3 5))这样的字符串输入自动地转化为两个相互嵌套的数据结构,因此我们必须自行实现语法分析和语法分析,以Lisp程序的字符串形式为输入,解析出其对应的AST。

词法分析

词法分析可将Lisp的程序字符串切割成一个个有意义的词素,又称token。例如输入为(+ 2 (- 3 5))时,词法分析的输出为一个数组,元素分别为['(', '+', '2', '(', '-', '3', '5', ')', ')']

Lisp的关键字较少,原生操作符也被划入原生过程,对标记符可包含的字符的限制少,因此词法分析比较简单。只需要将程序中多余的换行去除,将长度不一的空白统一到1个字符长度,再以空白为分隔符,切割出一个一个的token即可。

程序如下:

// 对Lisp输入代码进行格式化,以便于后续的分词
var lisp_beautify = (code) => {
  return code
  .replace(/\n/g, ' ') 		// 将换行替换为1个空格
  .replace(/\(/g, ' ( ') 	// 在所有左括号的左右各添加1个空格
  .replace(/\)/g, ' ) ') 	// 在所有右括号的左右各添加1个空格
  .replace(/\s{2,}/g, ' ') 	// 将所有空格的长度统一至1
  .replace(/^\s/, '') 		// 将最开始的一处多余空格去除
  .replace(/\s$/, '') 		// 将最后的一处多余空格去除
}

// 通过上面的格式化,Lisp代码已经完全变为以空格隔开的token流
var lisp_tokenize = (code) => {
  return code.split(' ')
}

语法分析

语法分析以上面的词法分析的结果为输入,根据语言的语法规则,将token流转换为AST。

Lisp的语法一致性很高,具体特点是:

  • 表达式分为两大类,基础表达式/primitive expression复合表达式/compound expression。前者是语言中不可再分的最小表达式单位,后者是前者通过括弧组合起来的表达式
  • 在其他语言中使用原生操作符表达的计算过程,例如+,-等,也使用复合表达式来表达
  • 复合表达式全部使用前置记述法/prefix notation,这种记述法的特点是,表示操作符的表达式位于最前面,表示操作数的表达式位于后面。例如2+3需要表示为(+ 2 3)

通过上面的分析,我们可以将AST的节点设计为如下结构:

// AST中,每一个AST节点所属的类
class ASTNode {
  constructor(proc, args) {
    this.proc = proc // 表示一个复合表达式中的操作符部分,即“过程”
    this.args = args // 表示一个复合表达式中的操作数部分,即“参数”
  }
}

语法分析的实现如下:

// 读取一个token流,转换为相应的AST
var _parse = (tokens) => {
  if (tokens.length == 0) {
    throw 'Unexpected EOF'
  }
  var token = tokens.shift()
  // 当读取时遇到'('时,则在遇到下一个')'之前,在一个新建的栈中不断推入token,并递归调用此函数
  if (token == '(') {
    var stack = []
    while (tokens[0] != ')') {
      stack.push(_parse(tokens))
    }
    tokens.shift()
    
    // 所读取的每个'('和')'之间的token,第一个为操作符,其余为操作数
    var proc = stack.shift()
    return new ASTNode(proc, stack)
  } else if (token == ')') {
    throw 'Unexpected )'
  } else {
    return token
  }
}

// 语法分析函数,这里考虑了所输入的Lisp程序可能被解析成多个AST的情况
var lisp_parse = (code) => {
  code = lisp_beautify(code)
  var tokens = lisp_tokenize(code)
  var ast = []
  while (tokens.length > 0) {
    ast.push(_parse(tokens))
  }
  return ast
}

词法分析和语法分析的结果

通过以上实现,我们可以将一段Lisp程序的字符串表示,转化为AST。其调用例子如下:

var code = "(+ 2 (- 3 5))"
var ast = lisp_parse(code)

console.log(JSON.stringify(ast, null, 2))

/*
[
  {
    "proc": "+",
    "args": [
      "2",
      {
        "proc": "-",
        "args": [
          "3",
          "5"
        ]
      }
    ]
  }
]
*/

eval的实现

完成了词法分析和语法分析后,我们得到了一段Lisp程序的结构化表示。现在我们可以开始着手实现一个解释器了。

在eval和apply两大方法中,eval是解释过程的起点。我们假定将要实现的Lisp解释器,可以通过以下方法来使用:

var lisp_eval = (code) => {
  var ast = lisp_parse(code) // 语法分析(其中包含了词法分析),得到程序AST
  var output = _eval(ast) // 分析AST,得到程序的结果
  return output
}

如何实现_eval方法呢。阅读SICP4.1可知,Lisp版的eval的代码如下

(define (eval exp env)  ;; eval一个表达式,需要表达式本身,以及当前的环境
(cond
    	;; 是否是一个不需要eval即可获得其值的表达式,如数字或字符串字面量
    	((self-evaluating? exp) exp) 
    	;; 是否是一个变量,如果是则在环境中查找此变量的值
        ((variable? exp) (lookup-variable-value exp env))
    	;; 是否是一个带有引号标记的list,这是Lisp中的一种特殊的列表,我们的实现中未包括
        ((quoted? exp) (text-of-quotation exp))
    	;; 是否是一个形如(set! ...) 的赋值表达式,如果是则在当前环境中改变变量的值
        ((assignment? exp) (eval-assignment exp env))
    	;; 是否是一个形如(define ...) 的声明表达式,如果是则在当前环境串中设定变量的值
        ((definition? exp) (eval-definition exp env))
    	;; 是否是一个形如(if ...) 的条件表达式,如果是则先判断条件部分的真值,再作相应分支的eval
        ((if? exp) (eval-if exp env))
    	;; 是否是一个lambda表达式,如果是则以其形参和过程的定义,结合当前环境创建一个过程
        ((lambda? exp) (make-procedure (lambda-parameters exp)
                                       (lambda-body exp)
									env))
		;; 是否是一个形如(begin ...)的表达式,如果是则按顺序eval其中的表达式,以最后一个表达式所得值为整个表达式的值
    	((begin? exp)
             (eval-sequence (begin-actions exp) env))
  		;; 是否是一个形如(cond ...)的条件表达式,如果是则先转化此表达式为对应的if表达式,再进行eval
         ((cond? exp) (eval (cond->if exp) env))
    	;; 是否是一个不属于以上任何一种情况的,需要apply的表达式,如果是则将其各个子表达式分别eval,再调用apply
         ((application? exp)
             (apply (eval (operator exp) env)
                    (list-of-values (operands exp) env)))
    	;; 否则报错
        (else
        (error "Unknown expression type: EVAL" exp))))

由上面的代码我们可以看出以下特点:

  • eval是对一段表达式进行解释处理的过程,其参数是exp和env。exp指的是需要处理的已经结构化的表达式,也就是上面的语法分析所得到的AST。env指的是解释所依赖的环境 
  • eval需要对各种不同类型的特殊形式/special-form和一般形式作出分别处理,因此整个eval的结构中存在着大量的条件判断
  • 大部分情况下,eval的进一步处理依然是eval的一种,所以依然需要依赖环境,例如(eval-if exp env), (eval-sequence (begin-actions exp) env)等;但也有不需要依赖于环境的情况,例如((self-evaluating? exp) exp)
  • 对于一些可以被转化为更基础形式的表达式,其处理方式是先转化,再eval。例如((cond? exp) (eval (cond->if exp) env))

eval是一个相对复杂的机制,因此我们需要确定一个较好的实现顺序,逐步实现eval的各个功能。实现步骤如下:

  1. 数字或字符串字面量,如123, ``hi`
  2. 原生过程,如(+ 2 3),(= 4 5)
  3. 表达式序列和begin,如(display 1)(display 2),(begin (+ 2 3) true)
  4. if
  5. 环境的实现和define以及set!
  6. lambda和非原生apply的实现
  7. 各类可用转化来eval的其他语法的实现,如cond, define的语法糖和let等

数字或字符串字面量

这类表达式属于两大类表达式中的基础表达式/primitive expression,在AST中会作为一个叶子节点存在(与此相反,复合表达式/compound expression是AST中的根节点或中间节点)。因此,我们可以实现如下:

// 当前阶段,还没有实现env(环境)机制,所以eval只接收一个AST作为参数
var _eval = (ast) => {
  if (isNumber(ast)) {
    return evalNumber(ast)
  } else if (isString(ast)) {
    return evalString(ast)
  } else {
   	...
  }
}

var isNumber = (ast) => {
  return /^[0-9.]+$/.test(ast) && ast.split('.').length <= 2
}

var isString = (ast) => {
  return ast[0] == '`'
}

var evalNumber = (ast) => {
  if (ast.split('.').length == 2) {
    return parseFloat(ast)
  } else {
     return parseInt(ast)
  }
}

var evalString = (ast) => {
  return ast.slice(1)
}

原生过程

在一些其他的语言中,需要表达一些基础的计算时,使用的是运算符,包括 +,-等数学运算符、==,>等关系运算符、!,&&,||等逻辑运算符,以及其他各种类型的运算符。在Lisp中,这些计算能力都由原生过程提供。例如+这个过程,可以认为是Lisp在全局环境下默认定义了一个能将两个数相加的函数,并将其命名为+

通过语法分析,我们已经可以在解析形如(+ 2 3)这样的表达式时,得到{proc: '+', args: ['2', '3']}这样的AST节点。因此,只需要针对proc值的特殊情况,进行处理即可。

首先我们需要一个创建JavaScript对象,它用来保存各个原生过程的实现,及其对应的名称:

var PRIMITIVES = {
  '+': (a, b) => a + b,
  '-': (a, b) => a - b,
  '*': (a, b) => a * b,
  '/': (a, b) => a / b,
  '>': (a, b) => a > b,
  '<': (a, b) => a < b,
  '=': (a, b) => a == b,
  'and': (a, b) => a && b,
  'or': (a, b) => a || b,
  'not': (a) => !a
   ...
}

接着,由于原生过程需要通过apply才能得到结果,所以我们需要实现一个初步的apply。这时的apply还不需要区分原生过程和使用Lambda表达式自定义的过程。

var apply = (proc, args) => {
  return PRIMITIVES[proc].apply(null, args)
}

最后,我们需要把eval的方法补全,初步地实现上文中提到的eval的这个定义:当我们需要eval一个表达式时,我们需要将各个子表达式分别eval,并以子表达式的值为参数,调用apply

var _eval = (ast) => {
  if (isNumber(ast)) {
    return evalNumber(ast)
  } else if (isString(ast)) {
    return evalString(ast)
  } else if (isProcedure(ast)) {
    var proc = ast.proc // 对过程进行eval,但因为现阶段只有原生过程,所以暂不实现
    var args = ast.args.map(_eval) // 对每个过程的参数进行eval
    return apply(proc, args) // 调用apply
  } else {
	...
  }
}

var isProcedure = (ast) => {
  return ast.proc && PRIMITIVES[ast.proc] !== undefined
}

通过以上实现,我们的解释器已经有了在没有变量的情况下,进行四则运算、逻辑运算、逻辑运算等的能力。

表达式序列和begin表达式

表达式序列指的是一系列平行的表达式,它们之间没有嵌套的关系。对于表达式序列,我们只需要逐个eval即可。

begin表达式是一种将表达式序列转化成一个表达式的特殊形式,在eval这种表达式时,被转化的表达式序列会依次执行,整个表达式的结果以序列中的最后一个表达式的eval结果为准。

实现如下:

var _eval = (ast) => {
  ...
  if (isBegin(ast)) {
    return evalBegin(ast)
  } else if (isSequence(ast)) {
    return evalSequence(ast)
  } else {
     ...
  }
}

// 特殊形式(special-form)的表达式,其AST节点的操作符部分都是固定的字符串,因此可以作如下判断
var isBegin = (ast) => {
  return ast.proc == 'begin'
}

// 表达式序列,在AST中表现为AST的数列
var isSequence = (ast) => {
  return Array.isArray(ast)
}

// begin表达式所封装的表达式序列在AST的args属性上
var evalBegin = (ast) => {
  var sequence = ast.args
  return evalSequence(sequence)
}

// 将表达式序列依次eval,返回最后一个eval结果
var evalSequence = (sequence) => {
  var output
  sequence.forEach((ast) => {
    output = _eval(ast)
  })
  return output
}

为了验证上面的实现,我们可以引入一个带有副作用的,名为display的原生过程。它可以将一个表达式的结果打印到控制台,并且调用本过程没有返回值。在上文的PRIMITIVES对象中增加以下内容:

var PRIMITIVES = {
  ...
  'display': (a) => console.log(a)
  ...
}

在此基础上,我们尝试eval这个表达式:

(display `hi)
(+ 2 3)

得到结果为

hi // 第一个表达式eval的过程带来的效果
5 // 第二个表达式eval的结果

if表达式

在大部分其他语言中,if相关的语法单元被称为if语句/if statement。一个if语句,往往包含一个条件部分/predicate条件满足时执行的语句块/then branch以及条件不满足时执行的语句块/else branch。这三个部分中,只有条件部分因为需要产生一个布尔值,所以是表达式。其他两部分是待执行的指令,并不一定会产生结果,所以是语句或语句块。

Lisp中,整个if语法单元是一个复合表达式,对if表达式进行eval一定会产生一个结果。eval的逻辑是,首先eval条件部分,如果值为真,则eval其then部分并返回结果;否则eval其else部分并返回结果。

实现如下:

var _eval = (ast) => {
  ...
  if (isIf(ast)) {
    return evalIf(ast)
  } else {
     ...
  }
}

var isIf = (ast) => {
  return ast.proc == 'if'
}

var evalIf = (ast) => {
  var predicate = ast.args[0]
  var thenBranch = ast.args[1]
  var elseBranch = ast.args[2]

  if (_eval(predicate)) {
    return _eval(thenBranch)
  } else {
    return _eval(elseBranch)
  }
}

环境

到目前为止,我们实现的Lisp解释器,已经支持了包括四则运算、关系运算、逻辑运算等在内的多种运算,并可以通过if、begin等表达式来实现一定程度上的流程控制。现在,我们需要引入eval所需要的另一个重要机制,也就是解释运行一段程序所需要的环境。

实现环境机制,是实现Lisp解释器后续的多种能力的基础:

  • 变量的定义和使用
  • 用于声明一个变量的define表达式,以及用于重新为一个变量赋值的set!表达式
  • lambda表达式以及使用lambda表达式来自定义过程并使用
环境的数据结构

在SICP第三章中已经讨论过环境应该如何实现:

  • 环境是一个表结构的链。
  • 每个环境的实例拥有一个自己的表(SICP在书中称此概念为frame),其中存放变量名称和其所对应的实体
  • 每个环境还保存了自己的父级环境的引用,这使得环境在寻找一个变量的对应值时,可以不断向其父级环境寻找,这会导致以下两种结果之一:1. 在环境链的某一位置上找到一个合适的对应值,2. 遍历了环境链而无法找到值

初步实现如下:

// 环境所拥有的表(Frame)所属的类,具有保存key/value数据的能力
class Frame {
  constructor(bindings) {
    this.bindings = bindings || {}
  }
  set(name, value) {
    this.bindings[name] = value
  }
  get(name) {
    return this.bindings[name]
  }
}

// 环境所属的类
class Env {
  constructor(env, frame) {
    this.frame = frame || new Frame()
    this.parent = env
    // 查找一个变量对应的值时,通过this.parent属性,沿父级环境向上
    // 不断在环境链中查找此变量,并返回其对应值
    this.get = function get(name) {
      var result = this.frame.get(name)
      if (result !== undefined) {
        return result
      } else {
        if (this.parent) {
          return get.call(this.parent, name)
        } else {
          throw `Unbound variable ${name}`
        }
      }
    }
    // 设置一个变量对应的值时(假设已经定义了此变量),通过this.parent属性,沿父级环境向上
    // 不断在环境链中查找此变量,并修改所找到的变量所对应的值
    this.set = function set(name, value) {
      var result = this.frame.get(name)
      if (result !== undefined) {
        this.frame.set(name, value)
      } else {
        if (this.parent) {
          return set.call(this.parent, name, value)
        } else {
          throw `Cannot set undefined variable ${name}`
        }
      }
    }
    
    // 声明一个变量并赋初值。注意它与上面的set不同
    // set只针对已定义变量的操作,define则会无条件地在当前环境下声明变量
    this.define = (name, value) => {
      this.frame.set(name, value)
    }
  }
}
环境的引入

有了上面所实现的Env类,我们便可以真正地引入eval操作所需要的另一个要素:环境。

首先,将之前实现的eval方法修改如下:

// 调用eval时新增了一个参数env
var _eval = (ast, env) => {
  if (isNumber(ast)) {
    // 表达式是数字时,是不需要环境即可eval的
    return evalNumber(ast)
  } else if (isString(ast)) {
    // 同上,字符串类不需要环境
    return evalString(ast)
  } else if (isBegin(ast)) {
    // eval一系列以begin所整合的表达式,需要环境
    return evalBegin(ast, env)
  } else if (isSequence(ast)) {
    // 按顺序eval多个表达式,需要环境
    return evalSequence(ast, env)
  } else if (isIf(ast)) {
    // eval一个if表达式,因为条件部分和两个分支部分各自都是表达式,所以需要环境
    return evalIf(ast, env)
  } else if (isProcedure(ast)) {
    // 应用一个原生过程前,需要对各个实参进行eval,需要环境
    var proc = ast.proc
    var args = ast.args.map((arg) => {
      return _eval(arg, env)
    })
    return apply(proc, args)
  } else {
    throw 'Unknown expression'
  }
}

接着,将之前实现的各个evalXXX方法中,调用到_eval(ast)的部分均修改为_eval(ast, env)。这里不再全部列出。

最后,我们需要为第一次调用_eval(ast, env)提供合适的参数。其中ast依然是经过语法分析后的目标程序的AST,env则是整个eval过程最开始所需要的环境,即全局环境/global environment

将上文中提到的调用Lisp解释器的方法lisp_eval修改为:

var globalEnv = new Env(null)

var lisp_eval = (code) => {
  var ast = lisp_parse(code)
  var output = _eval(ast, globalEnv)
  return output
}
define表达式

define表达式本身不作任何运算,它只负责新增一个变量,在特定的环境中将变量名和变量对应的值绑定起来。实现如下:

var _eval = (ast, env) => {
  ...
  if (isDefine(ast)) {
    return evalDefine(ast, env)
  } else {
     ...
  }
}

var isDefine = (ast) => {
  return ast.proc == 'define'
}

var evalDefine = (ast, env) => {
  var name = ast.args[0]
  var value = _eval(ast.args[1])
  env.define(name, value)
}
变量的读取

变量也是表达式的一种,只不过它不是复合表达式,在我们实现的AST上表现为一个叶子节点。在Lisp中,它和一个字符串字面量很相近,区别是后者有一个固定的`字符前缀。实现如下

var _eval = (ast, env) => {
  ...
  if (isVariable(ast)) {
    return lookUp(ast, env)
  } else {
     ...
  }
}

// 如果是一个变量
var isVariable = (ast) => {
  return typeof ast == 'string' && ast[0] != '`'
}

// 则在环境中查找它的对应值
var lookUp = (ast, env) => {
  return env.get(ast)
}

我们已经实现了环境,以及对环境的读和写的操作。现在,尝试使用解释器来解释类似这样一段Lisp程序:(define x (+ 1 1)) (define y 3) (* x y),解释器将会返回6。

set!表达式

与上面的define表达式的实现极为相似,只需要在判定表达式的函数上稍作修改,处理set!表达式时调用env.set即可,这里不再赘述。

lambda表达式

lambda表达式是许多计算机语言都有的语言特性。关于lambda表达式到底是什么,各种概括和总结很多。比较学术的定义建议参考维基百科

另一方面,通过学习如何实现lambda表达式的eval过程,可以加深对于这个概念的理解,这一点是毋庸置疑的。

Lisp中,lambda表达式的语法类似于(lambda (param1 param2 ...) body),包含了一个固定的lambda标志,一个形参的定义部分,和一个过程的定义部分。这说明,当我们解释一个lambda表达式时,至少需要了解其形参定义和过程定义这两方面。

而参考上文中提到的SICP中给出的_eval代码可知,当解释一个lambda表达式时,逻辑如下:

;; 是否是一个lambda表达式,如果是则以其形参和过程的定义,结合当前环境创建一个过程
((lambda? exp) (make-procedure (lambda-parameters exp)
                               (lambda-body exp)
							env))

;; make-procedure 仅仅是将一个lambda表达式的形参定义、过程定义以及当前环境整合成了一个元组
(define (make-procedure parameters body env)
    (list 'procedure parameters body env))

make-procedure使用到了一个lambda表达式的三个方面,形参定义、过程定义和lambda表达式被定义时所在的环境。之所以需要了解lambda表达式被定义时所在的环境,是因为lambda表达式所代表的过程在被应用时,其过程体中所包含的程序段需要在一个新的环境中被eval。新环境是由lambda表达式被定义时所在的环境扩展而来,扩展时增加了形参到实参的绑定。

过程/procedure是SICP中惯用的一个概念。个人认为此概念近似于函数/function一词,但区别在于函数是数学概念,表达的是从x到y的一对一映射关系,并且函数是无副作用的,调用一个函数/invoke a function时只要实参相同,结果总是相同。而过程则更像是计算机科学概念,表达的就是对一系列计算过程的抽象,并且应用一个过程/apply a procedure时不排除有副作用的产生。

由于lambda表达式自身只是对于一段计算过程的表示,当它没有和实参结合在一起成为一个复合表达式时,不需要考虑apply的问题。实现如下:

// 引入一个新的类,作为所有非原生过程的数据结构
class Proc {
  constructor(params, body, env) {
    this.params = params
    this.body = body
    this.env = env
  }
}

var _eval = (ast, env) => {
  ...
  if (isLambda(ast)) {
    return makeProcedure(ast, env)
  } else {
     ...
  }
}

var isLambda = (ast) => {
  return ast.proc == 'lambda'
}

// 这是一个工具函数,将ASTNode转化为一个形如 [ast.proc, ...ast.args] 的数组
// 这是因为在我们的语法解析的实现中,凡是被括弧包围的语法单元都会产生一个ASTNode
// 但lambda表达式中的形参定义部分,形式类似于`(a b c)`,它所表达的只是三个独立的参数
// 而并没有a是操作符,b和c是操作符的语义在里面
var astToArr = (ast) => {
  var arr = [ast.proc]
  arr = arr.concat(ast.args)
  return arr
}

var makeProcedure = (ast, env) => {
  var params = astToArr(ast.args[0]) // 获得一个lambda表达式的形参定义部分
  var body = ast.args[1] // 获得一个lambda表达式的过程定义部分
  return new Proc(params, body, env) // 结合环境,创建一个新的过程以备后续使用
}

支持自定义过程的apply

在上一小节"lambda表达式"中,makeProcedure方法所创建的过程,可以被称为自定义过程。它们是与+,display等相对的,不属于Lisp语言原生提供的过程。要想让解释器可以正确地解释这些自定义过程,一方面除了实现lambda表达式以支持自定义过程的定义;另一方面,我们也需要修改apply方法,使得这类非原生过程也可以被正确地apply。

isProcedure的修改

isProcedure是我们已经实现的_eval中,用以处理所有非特殊形式的复合表达式时调用的。当一个复合表达式不是特殊形式时,它所代表的就是一个过程。原来的isProcedure的实现仅仅是为了支持原生过程的,其判断条件无法包含自定义过程,这里我们修改如下:

// 只要当前正在eval的表达式是复合表达式(即它是ASTNode的实例)
// 并且它也不属于任何特殊形式(因为在_eval方法中已经先行进行了一系列特殊形式的判断,且ast并不属于它们)
// 那么当前表达式就是一个过程
var isProcedure = (ast) => {
  return ast && ast.constructor && ast.constructor.name == 'ASTNode'
}

apply调用前的修改

在我们已经实现的_eval中,当一个表达式是非特殊形式的复合表达式(也就是isProcedure返回为真)时,原逻辑是

var _eval = (ast, env) => {
  ...
  if (isProcedure(ast)) {
    var proc = ast.proc // 需要修改
    var args = ast.args.map((arg) => {
      return _eval(arg, env)
    })
    return apply(proc, args)
  }
  ...
}

需要修改的行已用注释标出,这里也是仅仅为了支持原生过程而实现的临时逻辑:我们并没有对一个复合表达式的操作符部分进行eval,而是直接拿来用了。将此行修改为var proc = _eval(ast.proc, env)即可。

在继续实现之前,这里梳理一下我们即将实现的复合表达式的新的eval过程,包括原生过程和非原生过程两种情况。 (注意:以下为方便说明,会用字符串来表示一个AST节点,并略去环境参数。例如eval('(+ 2 3)')指的是以一个可以表达(+ 2 3) 这个表达式的ASTNode作为参数ast,以一个合法的环境作为参数env,调用_eval)

  • 原生过程的情况下,以表达式(+ 2 3)为例
    • 解释器调用eval('(+ 2 3)'),isProcedure返回为真(它属于一个非特殊形式的复合表达式),接下来会针对操作符和各个操作数,也就是+,2,3分别调用三次_eval
    • 解释器调用eval('+'),这里需要返回一个可以被调用的对应原生过程的实现,例如在我们的JavaScript版的实现中就是(a, b) => a + b
    • 解释器调用eval('2'),isNumber返回为真,将返回JavaScript的Number类型的数字2
    • 解释器调用eval('3'),isNumber返回为真,将返回JavaScript的Number类型的数字3
    • 三个子eval调用完成并全部return后,将继续执行eval('(+ 2 3)')的剩余流程,即apply((a, b) => a + b, [2, 3])
  • 非原生过程的情况下,以表达式((lambda (a b) (+ a b)) 2 3)为例(这个表达式所执行的计算和上面的例子是一样的,都是将2与3相加,只不过用lambda表达式作了一层包装)
    • 解释器调用eval('((lambda (a b) (+ a b)) 2 3)'),isProcedure返回为真(它属于一个非特殊形式的复合表达式),接下来会针对操作符和各个操作数,也就是(lambda (a b) (+ a b)),2,3分别调用三次_eval
    • 解释器调用eval('(lambda (a b) (+ a b))'),isLambda返回为真,将调用makeProcedure返回一个Proc对象,包含lambda表达式的形参定义、过程定义和当前环境
    • 解释器调用eval('2'),isNumber返回为真,将返回JavaScript的Number类型的数字2
    • 解释器调用eval('3'),isNumber返回为真,将返回JavaScript的Number类型的数字3
    • 三个子eval调用完成并全部return后,将继续执行eval('((lambda (a b) (+ a b)) 2 3)')的剩余流程,即apply(<Proc对象>, [2, 3])

apply的修改

之前实现的apply方法也是仅仅服务于原生过程的。经过上面的一系列修改和梳理,我们知道,apply方法接收的第一个参数proc,可能包括以下两种情况:

  • 一个原生过程的实现
  • 一个包装了自定义过程的Proc对象

为了同时支持两种情况,我们将apply方法修改如下:

var apply = (proc, args) => {
  // 如果是原生过程,则直接apply
  if (isPrimitive(proc)) {
    return applyPrimitive(proc, args)
  } else {
    var { params, body, env } = proc // 否则,将包装在Proc对象中的自定义过程的信息取出来
    var newEnv = env.extend(params, args) // 创建一个新的环境,该环境是该自定义过程所属环境的扩展,其中新增了形参到实参的绑定
    return _eval(body, newEnv) // 在新的环境中,eval该自定义过程的过程体部分
  }
}

我们将会为Env类新增一个extend方法,该方法专门用来在作上述的扩展环境操作

class Env {
  constructor(env, frame) {
    ...
    // extend方法接受两个数组,分别为一组变量名和一组对应的变量值
    this.extend = (names, values) => {
      var frame = new Frame()
      for (var i = 0; i < names.length; i++) {
        var name = names[i]
        var value = values[i]
        frame.set(name, value)
      }
      // 在一个新的Frame中将变量名和变量值对应储存起来后,返回一个新的环境,它的父级环境为当前环境
      var newEnv = new Env(this, frame)
      return newEnv
    }
  }
    ...
}

原生过程的重新实现

我们还需要实现上一小节的代码中所依赖的isPrimitive,applyPrimitive等方法,以及进行一些适当的封装,来重新实现原生过程的eval,并兼容已经实现的自定义过程的eval。

封装原生过程

原来的实现中,原生过程的名称和其所对应的实现都放在了PRIMITIVES这个全局对象上,不太优雅。这里封装一下:

// 新增一个类用以代表原生过程,一个原生过程包含了自己的名称和其实现
class PrimitiveProc {
  constructor(name, impl) {
    this.name = name
    this.impl = impl
  }
}
原生过程添加至全局环境中

回顾之前的一个修改:

var _eval = (ast, env) => {
  ...
  if (isProcedure(ast)) {
    var proc = _eval(ast.proc, env) // 修改处
    var args = ast.args.map((arg) => {
      return _eval(arg, env)
    })
    return apply(proc, args)
  }
}

对于复合表达式的操作符部分,我们新增了一次eval操作。对于一个使用了原生过程的复合表达式来说,操作符部分在eval前是一个类似于+,display这样的字符串,eval后是它所对应的原生过程实现。为了打通这个逻辑,我们可以简单地将原生过程添加至全局环境中即可。这样,一个类似+这样的Lisp表达式,就是一个默认存在于全局环境下的变量。由于我们已经实现了isVariable和lookUp这样的逻辑,解释器会返回+所对应的二元加法的实现。这里修改如下:

// 这里是原来所实现,解释器在开始运行时,所依赖的全局变量的初始化逻辑
var globalEnv = new Env(null)
// 增加逻辑,将PRIMITIVES中包括的所有原生方法,以PrimitiveProc对象的形式添加到全局环境中
for (var method in PRIMITIVES) {
  var implementation = PRIMITIVES[method]
  globalEnv.define(method, new PrimitiveProc(method, implementation))
}

// 另外,一些原生值及其对应实现,也需要添加到全局环境中,例如true和false
globalEnv.define('true', true)
globalEnv.define('false', true)

var lisp_eval = (code) => {
  var ast = lisp_parse(code)
  var output = _eval(ast, globalEnv)
  return output
}
原生过程的判断和apply

最后,将上述代码中依赖的判断原生过程和apply原生过程的函数实现即可。

var isPrimitive = (ast) => {
  return ast && ast.constructor && ast.constructor.name == 'PrimitiveProc'
}

var applyPrimitive = (proc, args) => {
  var impl = proc.impl
  return impl.apply(null, args)
}

测试

经过以上实现,基本上我们需要的Lisp的核心语法已经全部实现了。现在,我们可以测试一下解释器是否可以返回我们需要的结果。

以下这段定义阶乘方法并实际调用的程序,包括了上述很多已实现的语法,例如if、表达式序列、变量的存取、lambda表达式等。并且调用的过程还是递归的。经测试:

var code = `
(define factorial
  (lambda (n)
    (if (= n 1)
        1
        (* n (factorial (- n 1))))))
(factorial 6)
`
var result = lisp_eval(code)
console.log(result)

结果返回720。

后续改进

上述已实现的Lisp解释器,还有很多可以改进的方向。

支持更多原生过程

要想让上述解释器支持更多原生过程,我们只需要在PRIMITIVES对象上新增对应的过程名,以及其对应的JavaScript实现即可。例如,cons,car,cdr等方法的引入,使得Lisp可以处理列表这种数据结构。这里给出一个实现的例子:

// 将两个数据组成一个Pair,这里用闭包实现
var cons = (a, b) => (m) => m(a, b)
// 返回Pair的前一个数据
var car = (pair) => pair((a, b) => a)
// 返回Pair的后一个数据
var cdr = (pair) => pair((a, b) => b)
// 一个Lisp的原生值,用以代表空的List
var theEmptyList = cons(null, null)
// 用以判断是否参数中的List是空
var isListNull = (pair) => pair == null
// 将数量不定的参数结合成一个List
var list = (...args) => {
  if (args.length == 0) {
    return theEmptyList
  } else {
    var head = args.shift()
    var tail = args
    return cons(head, list.apply(null, tail))
  }
}

var PRIMITIVES = {
  ...
  'cons': cons,
  'car': car,
  'cdr': cdr,
  'list': list,
  'null?': isListNull
  ...
}

// 对于theEmptyList,因为它是一个原生值,所以要像true和false那样单独添加到全局环境中
globalEnv.define('the-empty-list', theEmptyList)

我们可以用下面的代码测试以上原生实现:

// 定义一个名叫list-ref的方法,用以访问List中指定索引位置的元素
var code = `
(define list-ref
  (lambda (list i)
    (if (= i 0)
        (car list)
        (list-ref (cdr list) (- i 1)))))
(define L (list 5 6 7))
(list-ref L 2)
`
var result = lisp_eval(code)
console.log(result)

以上执行结果为7(名为L的List的第3个元素)。

语法糖

Lisp还有一些语法,如cond,let,以及define后接一个形参定义加过程定义用来直接定义一个过程等,都是上述已实现语法的派生(derived expression,SICP上有同名章节可供参考),也就是俗称的语法糖。对于它们的eval,一般处理思路是将其转换为已实现的语法,再对其eval即可。

实现语法转换时,不需要依赖环境,只需要按一定规则提取出原语法中的部分,再重新按新的语法规则组合起来即可。

以cond为例,cond表达式的语法为:

(cond
  (pred1 action1)
  (pred2 action2)
  ...
  (else elseAction))

用if表达式来写时,等价于:

(if pred1
    action1
    (if pred2
        action2
	(if ...
            elseAction)))

也就是else部分会不断嵌套,以包含下一个条件表达式predN,直到原cond表达式中else所指定的action出现。

我们可以实现以下将cond转化为if的方法:

// 将cond转化为if
var condToIf = (ast) => {
  var clauses = ast.args

  // 将cond体中包含的各个条件表达式和对应的操作表达式抽出来
  var predicates = clauses.map((clause) => clause.proc)
  var actions = clauses.map((clause) => clause.args)

  // 调用下面的helper方法
  return _condToIf(predicates, actions)
}

// 此方法将递归调用,直至遇到一个名为else的条件表达式,最终返回一个else部分嵌套的if表达式的AST
var _condToIf = (predicates, actions) => {
  if (predicates.length != 0 && predicates.length == actions.length) {
    var pred = predicates.shift()
    var action = actions.shift()
    if (pred == 'else') {
      return action
    } else {
      return new ASTNode('if', [pred, action, _condToIf(predicates, actions)])
    }
  }
}

接着在_eval中增加相关处理逻辑即可:

var _eval = (ast, env) => {
  ...
  if (isCond(ast)) {
    return _eval(condToIf(ast), env)
  } else {
     ...
  }
}

var isCond = (ast) => {
  return ast.proc == 'cond'
}

我们可以验证以上实现如下:

var code = `
(define a 5)
(cond
  ((< a 5) \`case1)
  ((= a 5) \`case2)
  ((else \`case3)))
`
var result = lisp_eval(code)
console.log(result)

返回为"case2"

其他改进

在SICP中,还介绍了众多基于eval-apply模型的Lisp解释器的扩展和改进,例如

  • 将语法分析从解释的执行中抽离出来,我认为这个改进可以抽象地描述为:原来的eval调用类似于eval(ast, env),改进后为eval(ast)(env)
  • 懒解释/lazy-evaluation, 非确定性计算/non-deterministic computation等上文中已提到的,更为深层次的扩展

这些扩展都可以在上述实现的JavaScript版解释器上作进一步实现。限于篇幅,这里不再一一说明。

结语

本文算是我对于SICP一书阅读的一个阶段性总结。限于笔者水平和表达能力,加上解释器的实现本身也是一个比较复杂的机制。本文可能有诸多不通顺和表达不能尽意之处,希望读者理解。

下方的url,是我基于以上思路,使用JavaScript实现的Lisp解释器的完整代码。代码中的模块(例如词法/语法分析、环境的数据结构、原生方法的实现)等已分隔至不同的js文件。另外这个实现:

  • 只实现了SICP的第4章第1节所介绍的解释基本功能,不包括后续章节中的懒解释/lazy-evaluation, 非确定性计算/non-deterministic computation等功能
  • 未实现语法分析的抽离,即eval的调用仍然是基于eval(ast, env)这样的模型
  • 语法糖方面,仅实现了cond和define定义过程两项,其他的语法如let等未实现

the eval-apply metacircular evaluator for Lisp implemented in JavaScript

希望本文以及示例的实现代码可以给大家一些帮助。