通过近两个月的苦战,笔者终于将SICP(Structure and Interpretation of Computer Programs(计算机程序的构造和解释))一书读到了第四章过半,开始接触书中关于语言级抽象(metalinguistic abstraction)的介绍。在这个时点,我打算分享一下本身阅读本书的一部分心得,重点是第四章的第一小节,毕竟语言的解析、编译等方面的知识是我本身最感兴趣的。javascript
总的来讲,SICP的第四章讲的是关于如何用一种计算机程序来实现另外一种计算机程序的知识,以达到用语言来抽象,使一些计算问题变得简单的目的。java
这个概念经常与编译/compile造成相对的概念。二者的区别,我将其总结以下:git
eval(procedure, data) => output
compile(procedure)(data) => output
另外,翻译/interpret一词也经常用来表示解释(eval)这一律念。github
SICP的第四章所实现的是一个解释器/evaluator,也就是能够直接解释一段程序并输出结果的程序。算法
本文仅仅是关于SICP第四章第一小节的我的阅读心得,但这里有必要说明一下整个第四章的大体脉络。express
可见,4.1是整章的基础内容,想要有效地阅读整章内容,须要先阅读4.1。编程
这是本章所实现的Lisp解释器所采用的基础模型,也能够说是它的架构。数组
整个解释器的运行过程,就是eval/解释和apply/应用两个过程/procedure不断相互调用的过程。eval和apply这两个过程分别是如此定义的(译自SICP原书,如下术语较多,如不通顺请见谅并参照原书):bash
以上定义,在我第一遍阅读时也是一脸懵逼。只有在本身尝试去实现时,才开始慢慢地理解其中的含义。这里,我想结合一下本身的理解,对上面的定义做一些补充:数据结构
(proc arg1 arg2 ...)
所示,也就是外部有一个括弧围绕,括弧内的第一个部分表明的是须要调用的过程,剩余的部分表明的是调用过程时的实参。+
表示,也能够用(lambda (a b) (+ a b))
来表示;须要参与计算的参数也可能由(+ 2 3)
这样的表达式来表示,因此对于过程类表达式的eval,在apply这个表达式以前,须要将各个子表达式分别evalif
是特殊形式之一。例如,咱们有一个Lisp的if表达式形如(if pred thenAction elseAction)
,它的eval规则,不是将if, pred, thenAction, elseAction等子表达式分别eval,而是:首先eval表达式的pred部分(条件部分)并获得其结果,结果的真值为真时,eval表达式的thenAction(then分支);不然eval表达式的elseAction(else分支)。两个分支中有一个分支会做为不eval的程序部分被跳过。经过模仿和练习来学习,才能更好地巩固知识。这里介绍一下我使用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的语法一致性很高,具体特色是:
+
,-
等,也使用复合表达式来表达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" ] } ] } ] */
复制代码
完成了词法分析和语法分析后,咱们获得了一段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-if exp env)
, (eval-sequence (begin-actions exp) env)
等;但也有不须要依赖于环境的状况,例如((self-evaluating? exp) exp)
((cond? exp) (eval (cond->if exp) env))
eval是一个相对复杂的机制,所以咱们须要肯定一个较好的实现顺序,逐步实现eval的各个功能。实现步骤以下:
123
, ``hi`(+ 2 3)
,(= 4 5)
(display 1)(display 2)
,(begin (+ 2 3) true)
这类表达式属于两大类表达式中的基础表达式/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
}
复制代码
经过以上实现,咱们的解释器已经有了在没有变量的状况下,进行四则运算、逻辑运算、逻辑运算等的能力。
表达式序列指的是一系列平行的表达式,它们之间没有嵌套的关系。对于表达式序列,咱们只须要逐个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 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解释器后续的多种能力的基础:
在SICP第三章中已经讨论过环境应该如何实现:
初步实现以下:
// 环境所拥有的表(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表达式自己不做任何运算,它只负责新增一个变量,在特定的环境中将变量名和变量对应的值绑定起来。实现以下:
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。
与上面的define表达式的实现极为类似,只须要在断定表达式的函数上稍做修改,处理set!表达式时调用env.set
便可,这里再也不赘述。
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) // 结合环境,建立一个新的过程以备后续使用
}
复制代码
在上一小节"lambda表达式"中,makeProcedure
方法所建立的过程,能够被称为自定义过程。它们是与+
,display
等相对的,不属于Lisp语言原生提供的过程。要想让解释器能够正确地解释这些自定义过程,一方面除了实现lambda表达式以支持自定义过程的定义;另外一方面,咱们也须要修改apply方法,使得这类非原生过程也能够被正确地apply。
isProcedure是咱们已经实现的_eval中,用以处理全部非特殊形式的复合表达式时调用的。当一个复合表达式不是特殊形式时,它所表明的就是一个过程。原来的isProcedure的实现仅仅是为了支持原生过程的,其判断条件没法包含自定义过程,这里咱们修改以下:
// 只要当前正在eval的表达式是复合表达式(即它是ASTNode的实例)
// 而且它也不属于任何特殊形式(由于在_eval方法中已经先行进行了一系列特殊形式的判断,且ast并不属于它们)
// 那么当前表达式就是一个过程
var isProcedure = (ast) => {
return ast && ast.constructor && ast.constructor.name == 'ASTNode'
}
复制代码
在咱们已经实现的_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
分别调用三次_evaleval('+')
,这里须要返回一个能够被调用的对应原生过程的实现,例如在咱们的JavaScript版的实现中就是(a, b) => a + b
eval('2')
,isNumber返回为真,将返回JavaScript的Number类型的数字2eval('3')
,isNumber返回为真,将返回JavaScript的Number类型的数字3eval('(+ 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
分别调用三次_evaleval('(lambda (a b) (+ a b))')
,isLambda返回为真,将调用makeProcedure返回一个Proc对象,包含lambda表达式的形参定义、过程定义和当前环境eval('2')
,isNumber返回为真,将返回JavaScript的Number类型的数字2eval('3')
,isNumber返回为真,将返回JavaScript的Number类型的数字3eval('((lambda (a b) (+ a b)) 2 3)')
的剩余流程,即apply(<Proc对象>, [2, 3])
以前实现的apply方法也是仅仅服务于原生过程的。通过上面的一系列修改和梳理,咱们知道,apply方法接收的第一个参数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原生过程的函数实现便可。
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(ast, env)
,改进后为eval(ast)(env)
这些扩展均可以在上述实现的JavaScript版解释器上做进一步实现。限于篇幅,这里再也不一一说明。
本文算是我对于SICP一书阅读的一个阶段性总结。限于笔者水平和表达能力,加上解释器的实现自己也是一个比较复杂的机制。本文可能有诸多不通顺和表达不能尽意之处,但愿读者理解。
下方的url,是我基于以上思路,使用JavaScript实现的Lisp解释器的完整代码。代码中的模块(例如词法/语法分析、环境的数据结构、原生方法的实现)等已分隔至不一样的js文件。另外这个实现:
eval(ast, env)
这样的模型the eval-apply metacircular evaluator for Lisp implemented in JavaScript
但愿本文以及示例的实现代码能够给你们一些帮助。