SICP第四章閱讀心得 - Lisp解釋器的實現

通過近兩個月的苦戰,筆者終於將SICP(Structure and Interpretation of Computer Programs(計算機程序的構造和解釋))一書讀到了第四章過半,開始接觸書中關於語言級抽象(metalinguistic abstraction)的介紹。在這個時點,我打算分享一下本身閱讀本書的一部分心得,重點是第四章的第一小節,畢竟語言的解析、編譯等方面的知識是我本身最感興趣的。javascript

總的來講,SICP的第四章講的是關於如何用一種計算機程序來實現另外一種計算機程序的知識,以達到用語言來抽象,使一些計算問題變得簡單的目的。java

關於解釋/eval

這個概念經常與編譯/compile造成相對的概念。二者的區別,我將其總結以下:git

  • 解釋一段程序時,輸入是一段包含了過程與數據的程序,輸出是其結果。用僞代碼來講就是:eval(procedure, data) => output
  • 編譯一段程序時,輸入是一段包含過程的程序,輸出是一段被編譯出來的可執行程序,須要將數據輸入至這個可運行程序,獲得結果。用僞代碼來講就是:compile(procedure)(data) => output

另外,翻譯/interpret一詞也經常用來表示解釋(eval)這一律念。github

SICP的第四章所實現的是一個解釋器/evaluator,也就是能夠直接解釋一段程序並輸出結果的程序。算法

關於SICP第四章的結構

本文僅僅是關於SICP第四章第一小節的我的閱讀心得,但這裏有必要說明一下整個第四章的大體脈絡。express

  • 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原書,如下術語較多,如不通順請見諒並參照原書):bash

  • 當咱們須要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

但願本文以及示例的實現代碼能夠給你們一些幫助。

相關文章
相關標籤/搜索