SICP第四章閱讀心得 - Lisp直譯器的實現

lqt0223發表於2018-03-31

經過近兩個月的苦戰,筆者終於將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

希望本文以及示例的實現程式碼可以給大家一些幫助。

相關文章