【譯】小二百行 JavaScript 打造 lambda 演算解釋器

本文轉載自:衆成翻譯
譯者:文藺
連接:http://www.zcfy.cc/article/661
原文:http://tadeuzagallo.com/blog/writing-a-lambda-calculus-interpreter-in-javascript/javascript


最近,我發了一條推特,我喜歡上 lambda 演算了,它簡單、強大。java

我固然據說過 lambda 演算,但直到我讀了這本書 《類型和編程語言》(Types and Programming Languages) 我才體會到其中美妙。node

已經有許多編譯器/解析器/解釋器(compiler / parser / interpreter)的教程,但大多數不會引導你完整實現一種語言,由於實現徹底的語言語義,一般須要不少工做。不過在本文中, lambda 演算(譯者注:又寫做「λ 演算」,爲統一行文,下文一概做 「lambda 演算」)是如此簡單,咱們能夠搞定一切!git

首先,什麼是 lambda 演算呢?維基百科是這樣描述的:github

lambda 演算(又寫做 「λ 演算」)是表達基於功能抽象和使用變量綁定和替代的應用計算數學邏輯形式系統。這是一個通用的計算模型,能夠用來模擬單帶圖靈機,在 20 世紀 30 年代,由數學家奧隆索·喬奇第一次引入,做爲數學基礎的調查的一部分。編程

這是一個很是簡單的 lambda 演算程序的模樣:app

(λx. λy. x) (λy. y) (λx. x)

lambda 演算中只有兩個結構,函數抽象(也就是函數聲明)和應用(即函數調用),然而能夠拿它作任何計算。編程語言

1. 語法

編寫解析器以前,咱們須要知道的第一件事是咱們將要解析的語言的語法是什麼,這是 BNF(譯者注:Backus–Naur Form,巴科斯範式, 上下文無關的語法的標記技術) 表達式:ide

Term ::= Application
        | LAMBDA LCID DOT Term

Application ::= Application Atom
               | Atom

Atom ::= LPAREN Term RPAREN
        | LCID

語法告訴咱們如何在分析過程當中尋找 token 。可是等一下,token 是什麼鬼?函數

2. Tokens

正如你可能已經知道的,解析器不會操做源代碼。在開始解析以前,先經過 詞法分析器(lexer) 運行源碼,這會將源碼打散成 token(語法中全大寫的部分)。咱們能夠從上面的語法中提取的以下的 token :

LPAREN: '('
RPAREN: ')'
LAMBDA: 'λ' // 爲了方便也可使用 「\」
DOT: '.'
LCID: /[a-z][a-zA-Z]*/ // LCID 表示小寫標識符
                       // 即任何一個小寫字母開頭的字符串

咱們來建一個能夠包含 type (以上的任意一種)的 Token 類,以及一個可選的 value (好比 LCID 的字符串)。

class Token {
  constructor(type, value) {
    this.type = type;
    this.value = value;
  }
};

3. 詞法分析器( Lexer )

如今咱們能夠拿上面定義的 token 來寫 詞法分析器(Lexer) 了, 爲解析器解析程序提供一個很棒的 API

詞法分析器的 token 生成的部分不是很好玩:這是一個大的 switch 語句,用來檢查源代碼中的下一個字符:

_nextToken() {
  switch (c) {
    case 'λ':
    case '\\':
      this._token = new Token(Token.LAMBDA);
      break;

    case '.':
      this._token = new Token(Token.DOT);
      break;

    case '(':
      this._token = new Token(Token.LPAREN);
      break;

    /* ... */
  }
}

下面這些方法是處理 token 的輔助方法:

  • next(Token): 返回下一個 token 是否匹配 Token

  • skip(Token): 和 next 同樣, 但若是匹配的話會跳過

  • match(Token): 斷言 next 方法返回 true 並 skip

  • token(Token): 斷言 next 方法返回 true 並返回 token

OK,如今來看 「解析器」!

4. 解析器

解析器基本上是語法的一個副本。咱們基於每一個 production 規則的名稱(::= 的左側)爲其建立一個方法,再來看右側內容 —— 若是是全大寫的單詞,說明它是一個 終止符 (即一個 token ),詞法分析器會用到它。若是是一個大寫字母開頭的單詞,這是另一段,因此一樣爲其調用 production 規則的方法。遇到 「/」 (讀做 「或」)的時候,要決定使用那一側,這取決於基於哪一側匹配咱們的 token。

這個語法有點棘手的地方是:手寫的解析器一般是遞歸降低(recursive descent)的(咱們的就是),它們沒法處理左側遞歸。你可能已經注意到了, Application 的右側開頭包含 Application 自己。因此若是咱們只是遵循前面段落說到的流程,調用咱們找到的全部 production,會致使無限遞歸。

幸運的是左遞歸能夠用一個簡單的技巧移除掉:

Application ::= Atom Application'

Application' ::= Atom Application'
                | ε  # empty

4.1. 抽象語法樹 (AST)

進行分析時,須要以存儲分析出的信息,爲此要創建 抽象語法樹 ( AST ) 。lambda 演算的 AST 很是簡單,由於咱們只有 3 種節點: Abstraction (抽象), Application (應用)以及 Identifier (標識符)(譯者注: 爲方便理解,這三個單詞不譯)。

Abstraction 持有其參數(param) 和主體(body); Application 則持有語句的左右側; Identifier 是一個葉節點,只有持有該標識符自己的字符串表示形式。

這是一個簡單的程序及其 AST:

(λx. x) (λy. y)

Application {
  abstraction: Abstraction {
    param: Identifier { name: 'x' },
    body: Identifier { name: 'x' }
  },
  value: Abstraction {
    param: Identifier { name: 'y' },
    body: Identifier { name: 'y' }
  }
}

4.2. 解析器的實現

如今有了咱們的 AST 節點,能夠拿它們來建構真正的樹了。下面是基於語法中的生成規則的分析方法:

term() {
  // Term ::= LAMBDA LCID DOT Term
  //        | Application
  if (this.lexer.skip(Token.LAMBDA)) {
    const id = new AST.Identifier(this.lexer.token(Token.LCID).value);
    this.lexer.match(Token.DOT);
    const term = this.term();
    return new AST.Abstraction(id, term);
  }  else {
    return this.application();
  }
}

application() {
  // Application ::= Atom Application'
  let lhs = this.atom();
  while (true) {
    // Application' ::= Atom Application'
    //                | ε
    const rhs = this.atom();
    if (!rhs) {
      return lhs;
    } else {
      lhs = new AST.Application(lhs, rhs);
    }
  }
}

atom() {
  // Atom ::= LPAREN Term RPAREN
  //        | LCID
  if (this.lexer.skip(Token.LPAREN)) {
    const term = this.term(Token.RPAREN);
    this.lexer.match(Token.RPAREN);
    return term;
  } else if (this.lexer.next(Token.LCID)) {
    const id = new AST.Identifier(this.lexer.token(Token.LCID).value);
    return id;
  } else {
    return undefined;
  }
}

5. 求值(Evaluation)

如今,咱們能夠用 AST 來給程序求值了。不過想知道咱們的解釋器長什麼樣子,還得先看看 lambda 的求值規則。

5.1. 求值規則

首先,咱們須要定義,什麼是形式(terms)(從語法能夠推斷),什麼是值(values)。

咱們的 term 是:

t1 t2   # Application

λx. t1  # Abstraction

x       # Identifier

是的,這些幾乎和咱們的 AST 節點如出一轍!但這其中哪些是 value?

value 是最終的形式,也就是說,它們不能再被求值了。在這個例子中,惟一的既是 term 又是 value 的是 abstraction(不能對函數求值,除非它被調用)。

實際的求值規則以下:

1)       t1 -> t1'
     _________________

      t1 t2 -> t1' t2

2)       t2 -> t2'
     ________________

      v1 t2 -> v1 t2'

3)    (λx. t12) v2 -> [x -> v2]t12

咱們能夠這樣解讀每一條規則:

  1. 若是 t1 是值爲 t1' 的項, t1 t2 求值爲 t1' t2。即一個 application 的左側先被求值。

  2. 若是 t2 是值爲 t2' 的項, v1 t2 求值爲 v1 t2'。注意這裏左側的是 v1 而非 t1, 這意味着它是 value,不能再一步被求值,也就是說,只有左側的完成以後,纔會對右側求值。

  3. application (λx. t12) v2 的結果,和 t12 中出現的全部 x 被有效替換以後是同樣的。注意在對 application 求值以前,兩側必須都是 value。

5.2. 解釋器

解釋器遵循求值規則,將一個程序歸化爲 value。如今咱們將上面的規則用 JavaScript 寫出來:

首先定義一個工具,當某個節點是 value 的時候告訴咱們:

const isValue = node => node instanceof AST.Abstraction;

好了,若是 node 是 abstraction,它就是 value;不然就不是。

接下來是解釋器起做用的地方:

const eval = (ast, context={}) => {
  while (true) {
    if (ast instanceof AST.Application) {
      if (isValue(ast.lhs) && isValue(ast.rhs)) {
        context[ast.lhs.param.name] = ast.rhs;
        ast = eval(ast.lhs.body, context);
      } else if (isValue(ast.lhs)) {
        ast.rhs = eval(ast.rhs, Object.assign({}, context));
      } else {
        ast.lhs = eval(ast.lhs, context);
      }
    } else if (ast instanceof AST.Identifier) {
       ast = context[ast.name];
    } else {
      return ast;
    }
  }
};

代碼有點密,但睜大眼睛好好看下,能夠看到編碼後的規則:

  • 首先檢測其是否爲 application,若是是,則對其求值:

    • 若 abstraction 的兩側都是值,只要將全部出現的 x 用給出的值替換掉; (3)

    • 不然,若左側爲值,給右側求值;(2)

    • 若是上面都不行,只對左側求值;(1)

  • 如今,若是下一個節點是 identifier,咱們只需將它替換爲它所表示的變量綁定的值。

  • 最後,若是沒有規則適用於AST,這意味着它已是一個 value,咱們將它返回。

另一個值得提出的是上下文(context)。上下文持有從名字到值(AST節點)的綁定,舉例來講,調用一個函數時,就說你說傳的參數綁定到函數須要的變量上,而後再對函數體求值。

克隆上下文能保證一旦咱們完成對右側的求值,綁定的變量會從做用域出來,由於咱們還持有原來的上下文。

若是不克隆上下文, application 右側引入的綁定可能泄露並能夠在左側獲取到 —— 這是不該當的。考慮下面的代碼:

(λx. y) ((λy. y) (λx. x))

這顯然是無效程序: 最左側 abstraction 中的標識符 y沒有被綁定。來看下若是不克隆上下文,求值最後變成什麼樣。

左側已是一個 value,因此對右側求值。這是個 application,因此會將 (λx .x)y 綁定,而後對 (λy. y) 求值,而這就是 y 自己。因此最後的求值就成了 (λx. x)

到目前,咱們完成了右側,它是 value,而 y 超出了做用域,由於咱們退出了 (λy. y), 若是求值的時候不克隆上下文,咱們會獲得一個變化過的的上下文,綁定就會泄漏,y 的值就是 (λx. x),最後獲得錯誤的結果。

6. Printing

OK, 如今差很少完成了:已經能夠將一個程序歸化爲 value,咱們要作的就是想辦法將這個 value 表示出來。

一個簡單的 辦法是爲每一個AST節點添加 toString方法

/* Abstraction */ toString() {
  return `(λ${this.param.toString()}. ${this.body.toString()})`;
}

/* Application */ toString() {
  return `${this.lhs.toString()} ${this.rhs.toString()}`;
}

/* Identifier */ toString() {
  return this.name;
}

如今咱們能夠在結果的根節點上調用 toString 方法,它會遞歸打印全部子節點, 以生成字符串表示形式。

7. 組合起來

咱們須要一個腳本,將全部這些部分鏈接在一塊兒,代碼看起來是這樣的:

// assuming you have some source
const source = '(λx. λy. x) (λx. x) (λy. y)';

// wire all the pieces together
const lexer = new Lexer(source);
const parser = new Parser(lexer);
const ast = parser.parse();
const result = Interpreter.eval(ast);

// stringify the resulting node and print it
console.log(result.toString());

源代碼

完整實現能夠在 Github 上找到: github.com/tadeuzagallo/lc-js

完成了!

感謝閱讀,一如既往地歡迎你的反饋!

相關文章
相關標籤/搜索