【译】小二百行 JavaScript 打造 lambda 演算诠释器

本文转载自:众成翻译
译者:文蔺
链接:http://www.zcfy.cc/article/661
原文:http://tadeuzagallo.com/blog/writing-a-lambda-calculus-interpreter-in-javascript/

近来,我发了一条推特,我喜好上 lambda 演算了,它简朴、壮大。

我固然听说过 lambda 演算,但直到我读了这本书 《范例和编程言语》(Types and Programming Languages) 我才体会到个中美好。

已有许多编译器/剖析器/诠释器(compiler / parser / interpreter)的教程,但大多数不会指导你完整完成一种言语,由于完成完整的言语语义,一般须要许多事情。不过在本文中, lambda 演算(译者注:又写作“λ 演算”,为一致行文,下文一概作 “lambda 演算”)是云云简朴,我们能够搞定统统!

起首,什么是 lambda 演算呢?维基百科是如许形貌的:

lambda 演算(又写作 “λ 演算”)是表达基于功用笼统和运用变量绑定和替代的运用盘算数学逻辑情势体系。这是一个通用的盘算模子,能够用来模仿单带图灵机,在 20 世纪 30 年代,由数学家奥隆索·乔奇第一次引入,作为数学基本的观察的一部份。

这是一个异常简朴的 lambda 演算顺序的模样容貌:

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

lambda 演算中只要两个构造,函数笼统(也就是函数声明)和运用(即函数挪用),然则能够拿它做任何盘算。

1. 语法

编写剖析器之前,我们须要晓得的第一件事是我们将要剖析的言语的语法是什么,这是 BNF(译者注:Backus–Naur Form,巴科斯范式, 上下文无关的语法的标记技术) 表达式:

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

完成了!

谢谢浏览,自始自终地迎接你的反应!

    原文作者:文蔺
    原文地址: https://segmentfault.com/a/1190000005811466
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞