返回 登录
5

用25行JavaScript语句实现一个简单的编译器

原文:Implementing a Simple Compiler on 25 Lines of JavaScript
作者:Minko Gechev
译者:夜风轻扬

译者注:即使对于专业程序员来说,构造一个编译器也是颇具挑战性的任务,本文将会引导你抽丝剥茧,一探究竟!

我已经写了几篇与编程语言开发相关的文章,这让我非常兴奋!例如,在“关于 Angular 2 和 TypeScript 项目中的静态代码分析”[1]中,我研究了编译器前端的基本知识,解释了词法分析、语法分析和抽象语法树等各个阶段。

最近我发表了“开发静态类型编程语言[2] ”。本文展示了一个简单的、静态类型的函数式编程语言,它是受到了lambda微积分的启发。我将编译器开发的前端部分外包给了解析器生成器,并将注意力放在用于类型检查的模块的后端,以及用于代码生成的模块。

为什么我需要这个?

你可能想“为什么我需要知道如何开发编译器?”,有几个重要的原因:

  • 你将更好地理解所使用的编程语言是如何工作的。这将使你能够借助该语言开发出更高效的程序。
  • 经常的,为了不同的目的,你不得不重用下面所述的模块(例如,对配置文件进行解析、对网络消息进行解析等等)。
  • 创建一个DSL。在你的项目中创建一个特定领域的语言可能非常方便,以便简化任务,否则,使用通用编程语言,解决同样问题,你可能需要花费更多的时间。

我们要讨论的范围是什么?

在这篇博文中,我们将介绍从端到端的基础知识。我们将用25行JavaScript代码开发一个非常简单的编译器!我们的编译器将会包含:

  • 词法分析模块
  • 语法分析模块
    解析器将基于EBNF语法
    我们将使用递归下行解析算法来开发解析器
  • 代码生成器

我们将要探讨的语言对于开发有实际意义的软件程序并不是特别有用,但是它可以很容易地扩展成一个。

整个实现可以在我的GitHub介绍[3]中找到

image

引入一种简单的前缀语言

下面是我们语言中的一个示例表达式的样子:

mul 3 sub 2 sum 1 3 4

在本文的最后,我们将能经过任何编译器的典型阶段,将这些表达式转换为JavaScript语句。

为简单起见,我们需要遵循一些规则:

  • 我们只有函数:mul,sub,sum,div。
  • 每个字符串标记都被空格隔开。
  • 我们只支持自然数。

为了探究上述表达式背后的语义,我们定义一些JavaScript函数:

const mul = (...operands) => operands.reduce((a, c) => a * c, 1);
const sub = (...operands) => operands.reduce((a, c) => a - c);
const sum = (...operands) => operands.reduce((a, c) => a + c, 0);

mul接受多个操作数,并通过 =>操作符传递。函数只是将它们相乘,例如mul(2、3、4)==24。sub分别减去传递的参数,sum则是计算参数的总和。

上述的表达式可以转换为下列的JavaScript表达式:

mul(3, sub(2, sum(1, 3, 4)))

或者

3 * (2 - (1 + 3 + 4))

现在,在我们了解了语义之后,让我们从编译器的前端开始吧!

注意:类似的前缀表达式可以通过基于堆栈的算法进行简单的评估,但是在这种情况下,我们将关注概念而不是实现。

开发编译器的前端

任何编译器的前端通常都有词法分析模块[4]和语法分析模块[5]。在这一节中,我们将在几行JavaScript代码中构建两个模块!

开发一个词法分析器

词法分析阶段负责将程序的输入字符串(或字符流)划分为称为标记的小块。标记通常包含关于它们类型的信息(如果它们是数字、操作符、关键字、标识符等)、它们所代表程序的子串以及它们在程序中的位置。该位置通常用于报告用户友好的错误,以防出现无效的语法结构。

例如下列的JavaScript程序:

if (foo) {
  bar();
}

一个示例的JavaScript词汇分析器将生成输出:

[
  {
    lexeme: 'if',
    type: 'keyword',
    position: {
      row: 0,
      col: 0
    }
  },
  {
    lexeme: '(',
    type: 'open_paran',
    position: {
      row: 0,
      col: 3
    }
  },
  {
    lexeme: 'foo',
    type: 'identifier',
    position: {
      row: 0,
      col: 4
    }
  },
  ...
]

我们将保持我们的词法分析器尽可能简单。事实上,我们可以通过一条 JavaScript语句实现它:

const lex = str => str.split(' ').map(s => s.trim()).filter(s => s.length);

在这里,我们使用单一的空格来划分字符串,然后将产生的子串映射成修理版本并且过滤掉空串。

针对一个表达式调用lexer将产生一个字符串数组:

lex('mul 3 sub 2 sum 1 3 4')

// ["mul", "3", "sub", "2", "sum", "1", "3", "4"]

这完全实现了我们的目标!

现在让我们进入语法分析的阶段!

开发一个语法分析器

语法分析器(通常称为解析器)是一个编译器的模块,该编译器从一个标记列表(或流)中生成一个抽象语法树6。在这个过程中,语法分析器会对无效程序产生语法错误。

image

通常,解析器是基于语法[7]实现的。以下是我们语言的语法:

digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
num = digit+
op = sum | sub | mul | div
expr = num | op expr+

语法中包含有数字,这些数字组合在一起可以形成数字(num);有4个操作;一个表达式可以是一个数字,或者是操作,后面跟着一个或多个表达式。我们将把语法中的个体定义(例如num和op)作为规则。

这就是所谓的EBNF 语法[6]。稍微看一下语法,试着去理解它,然后完全忘记它!在解释解析器之后,我们将回到语法,你将看到所有东西是如何连接在一起的!

如前所述,解析器是一种工具,它将标记的列表转换为AST。
例如,对于我们的表达式:

mul 3 sub 2 sum 1 3 4

解析器将根据上面的语法生成下面的AST:
image

让我们来研究一下这个算法吧!

const Op = Symbol('op');
const Num = Symbol('num');

const parse = tokens => {

  let c = 0;

  const peek = () => tokens[c];
  const consume = () => tokens[c++];

  const parseNum = () => ({ val: parseInt(consume()), type: Num });

  const parseOp = () => {
    const node = { val: consume(), type: Op, expr: [] };
    while (peek()) node.expr.push(parseExpr());
    return node;
  };

  const parseExpr = () => /\d/.test(peek()) ? parseNum() : parseOp();

  return parseExpr();
};

坏消息是,有很多事情正在发生。好消息是,这是编译器中最复杂的部分!

让我们把代码分成各个部分,然后一步步查看每一个步骤。

节点类型

const Op = Symbol('op');
const Num = Symbol('num');

首先,我们定义了在AST中将要使用的不同节点类型,我们只需要数字和操作。例如,数字节点42将会是这样的:

{
  type: Num,
  val: 42
}

运算符sum,应用到2、 3、 4,将会是这样的:

{
  type: Op,
  val: 'sum',
  expr: [{
    type: Num,
    va: 2
  }, {
    type: Num,
    va: 3
  }, {
    type: Num,
    va: 4
  }]
}

这是多么简单啊!

语法分析器

在声明了节点类型之后,我们定义了一个名为parse的函数,该函数接受一个称为标记的参数。在它的内部,我们定义了另外五个函数:
- peek-返回与标记元素关联的本地变量c 的当前值。
- consum-返回与c本地变量和增量c的当前值相关联的标记元素。
- parseNum-获取当前的标记(即调用peek()),将其解析为一个自然数字,并返回一个新的数字标记。
- parseOp-我们会稍微研究一下。
- parseExpr - 检查当前标记与正则表达式/\d/(即是一个数字)是否匹配,如果成功则调用parseNum,否则返回parseOp。

解析操作

parseOp可能是上面解析器中最复杂的函数。这是因为存在循环和间接递归的情况。这里是它再一次的定义为了可以逐行对进行解释:

const parseOp = () => {
  const node = { val: consume(), type: Op, expr: [] };
  while (peek()) node.expr.push(parseExpr());
  return node;
};

当peek()的返回值不是数值时, parseExpr会调用parseOp,我们知道这是一个运算符,所以我们创建一个新的操作节点。注意,我们不会执行任何进一步的验证,但是,在实际的编程语言中,我们希望这样做,如果遇到一个未知的标记时,会报告语法错误。
无论如何,在节点声明中,我们将“子表达式”的列表设置为空列表(也就是[]),把操作名设置为peek()的值,把节点类型设置为Op。随后,通过将当前解析的表达式推到给定节点的子表达式列表中,我们循环遍历所有标记。最后,我们返回该节点。

牢记 while (peek()) node.expr.push(parseExpr());执行一个间接递归。我们得到如下表达式:

sum sum 2

这将会:
- 首先,调用parseExpr,结果会发现当期的标记(就是tokens[0])不是一个数(是sum),所以它会调用 parseOp。
- 在parseOp创建一个操作节点后,并且由于调用consume(),c值增加。
- 下一步,parseOp将会遍历节点,对于tokens[c],这里c对于1,将会调用parseExpr。
- parseExpr会发现当前的节点不是一个数,所以会调用 parseOp。
- parseOp会创建另外一个操作节点并且将c加1,并对所有的标记再来一次循环。
- parseOp 将会调用parseExpr,这时 c 不等于 2了。
- tokens[2] 是 “2”,parseExpr将会调用 parseNum,创立一个数值节点, 并将 c 变量加1。
- parseNum将会返回数节点并且推入到表达式数组的最后一个操作节点中,该节点通过最新一次的 parseOp 调用生成。
- 最后一次的 parseOp调用将会返回操作节点,因为 peek()将会返回undefined( parseNum将 c加到 3,tokens[3] === undefined)。
- 最后一次parseOp调用返回的节点将会返回给最外层的parseOp调用该调用也会返回操作节点。
- 最后,parseExpr 将会返回根操作节点。

产生的AST如下所示:

{
  type: "Op",
  val: "sum",
  expr: [{
    type: "Op",
    val: "sum",
    expr: [{
      type: "Num",
      val: 2
    }]
  }]
}

最后一步是遍历这棵树并生成JavaScript!

递归下降解析

现在,让我们将单独的函数与上面定义的语法联系起来,看看为什么语法有意义。让我们来看看EBNF语法的规则:

digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
num = digit+
op = sum | sub | mul | div
expr = num | op expr+

现在它们更清晰一些了?expr看起来很像parseExpr,这里我们或者解析出一个数或者是一个操作。类似的,op expr+看起来很像parseOp和num类似parseNum。实际上,解析器常常直接从语法中生成解析器,因为它们都与递归下降解析算法[8]有直接的联系。

事实上,我们刚刚开发了一个简单的递归下降解析器!我们的解析器非常简单(我们语法中只有4个产生式规则),但是你可以想象一个真实的编程语言的解析器是多么复杂。

相较于编写真正的解析器,观察其简化模型,开发一种语言的语法是非常方便的。解析器包含大量细节(例如,你的开发语言的许多语法结构),这与极其简化和简约的语法形成了鲜明的对比。

开发编译器

在本文的这一部分中,我们将遍历语言的AST并生成JavaScript。整个编译器只有7行JavaScript代码(字面上的!)

const transpile = ast => {
  const opMap = { sum: '+', mul: '*', sub: '-', div: '/' };
  const transpileNode = ast => ast.type === Num ? transpileNum(ast) : transpileOp(ast);
  const transpileNum = ast => ast.val;
  const transpileOp = ast => `(${ast.expr.map(transpileNode).join(' ' + opMap[ast.val] + ' ')})`;
  return transpileNode(ast);
};

让我们来逐行探究一下实现的细节。

首先,我们定义了一个函数称为transpile。它接受由解析器生成的AST作为参数。然后在opMap中,我们第一了数据操作和语言中操作的映射。基本上,sum 映射为+,mul映射为*,等等。

下一步,我们定义函数transpileNode,该函数接受AST节点。如果节点是是一个数,我们调用transpileNum函数,否则,调用transpileOp。

最后,我们定义两个函数,来处理单个节点的转译:
- transpileNum - 把一个数转换成JavaScript 中的数 (简单的直接返回这个数)。
- 将操作转换为JavaScript算术运算。注意这里有一个间接的递归(transpileOp->transpileNode->transpileOp)。对于每个操作节点,我们都希望首先转换其子表达式。我们通过调用 transpileNode 函数来做到这一点。
在transpile函数体的最后一行,我们返回transpileNode的结果,形成这棵树的根节点。

将一切都结合在一起

以下是我们如何将所有部分连接在一起的方法:

const program = 'mul 3 sub 2 sum 1 3 4';

transpile(parse(lex(program)));
// (3 * (2 - (1 + 3 + 4)))

我们调用 lex(program),生成标记列表,此后我们将这些标记传递给 parse函数,生成抽象语法树(AST),最后,我们将AST转译成JavaScript!
image

结论

本文详细介绍了一种非常简单的编译器(或transpile)的开发,将前缀表达式编译为JavaScript。虽然这只是解释了编译器开发的一些基本知识,但是我们介绍了一些非常重要的概念:

  • 词法分析
  • 语法分析
  • 源代码生成
  • EBNF语法
  • 递归下降解析

如果你有兴趣做进一步的阅读,我向你推荐:

参考文献

  1. Static Code Analysis of Angular 2 and TypeScript Projects - https://mgv.io/ng-static-analysis.
  2. Developing Statically Typed Programming Language - https://mgv.io/typed-lambda
  3. Tiny Compiler - https://mgv.io/tiny-compiler
  4. Lexical Analysis - https://mgv.io/wiki-lex
  5. Syntax Analysis - https://mgv.io/wiki-parser
  6. Abstract Syntax Tree - https://mgv.io/wiki-case-ast
  7. EBNF grammar - https://mgv.io/wiki-ebnf
  8. Recursive Descent Parsing - https://mgv.io/wiki-recursive-descent-parser
评论