用25行JavaScript语句实现一个简单的编译器
原文:https://www.iteye.com/news/32680
译者注:即使对于专业程序员来说,构造一个编译器也是颇具挑战性的任务,本文将会引导你抽丝剥茧,一探究竟!
我已经写了几篇与编程语言开发相关的文章,这让我非常兴奋!例如,在“关于 Angular 2 和 TypeScript 项目中的静态代码分析”[1]中,我研究了编译器前端的基本知识,解释了词法分析、语法分析和抽象语法树等各个阶段。
最近我发表了“开发静态类型编程语言[2] ”。本文展示了一个简单的、静态类型的函数式编程语言,它是受到了lambda微积分的启发。我将编译器开发的前端部分外包给了解析器生成器,并将注意力放在用于类型检查的模块的后端,以及用于代码生成的模块。
为什么我需要这个?
你可能想“为什么我需要知道如何开发编译器?”,有几个重要的原因:
- 你将更好地理解所使用的编程语言是如何工作的。这将使你能够借助该语言开发出更高效的程序。
- 经常的,为了不同的目的,你不得不重用下面所述的模块(例如,对配置文件进行解析、对网络消息进行解析等等)。
- 创建一个DSL。在你的项目中创建一个特定领域的语言可能非常方便,以便简化任务,否则,使用通用编程语言,解决同样问题,你可能需要花费更多的时间。
我们要讨论的范围是什么?
在这篇博文中,我们将介绍从端到端的基础知识。我们将用25行JavaScript代码开发一个非常简单的编译器!我们的编译器将会包含:
- 词法分析模块
- 语法分析模块
- 解析器将基于EBNF语法
- 我们将使用递归下行解析算法来开发解析器
- 代码生成器
我们将要探讨的语言对于开发有实际意义的软件程序并不是特别有用,但是它可以很容易地扩展成一个。
引入一种简单的前缀语言
下面是我们语言中的一个示例表达式的样子:
- 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。在这个过程中,语法分析器会对无效程序产生语法错误。
通常,解析器是基于语法[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:
让我们来研究一下这个算法吧!
- 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!
结论
本文详细介绍了一种非常简单的编译器(或transpile)的开发,将前缀表达式编译为JavaScript。虽然这只是解释了编译器开发的一些基本知识,但是我们介绍了一些非常重要的概念:
- 词法分析
- 语法分析
- 源代码生成
- EBNF语法
- 递归下降解析
如果你有兴趣做进一步的阅读,我向你推荐:
用25行JavaScript语句实现一个简单的编译器的更多相关文章
- javascript编写一个简单的编译器(理解抽象语法树AST)
javascript编写一个简单的编译器(理解抽象语法树AST) 编译器 是一种接收一段代码,然后把它转成一些其他一种机制.我们现在来做一个在一张纸上画出一条线,那么我们画出一条线需要定义的条件如下: ...
- 使用JavaScript实现一个简单的编译器
在前端开发中也会或多或少接触到一些与编译相关的内容,常见的有 将ES6.7代码编译成ES5的代码 将SCSS.LESS代码转换成浏览器支持的CSS代码 通过uglifyjs.uglifycss等工具压 ...
- Mark: 如何用Haskell写一个简单的编译器
作者:aaaron7 链接:https://www.zhihu.com/question/36756224/answer/88530013 如果是用 Haskell 的话,三篇文章足矣. prereq ...
- 30行JavaScript代码实现一个比特币量化策略
精简极致的均线策略 30行打造一个正向收益系统 原帖地址:https://www.fmz.com/bbs-topic-new/262 没错!你听的没错是30行代码!仅仅30行小编我习惯先通篇来看看 代 ...
- 闲来无事,用javascript写了一个简单的轨迹动画
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...
- 初学javascript,写一个简单的阶乘算法当作练习
代码如下: <script> var a = prompt("请输入值"); function mul(a){ if(a==1){ return 1; } return ...
- 只有20行Javascript代码!手把手教你写一个页面模板引擎
http://www.toobug.net/article/how_to_design_front_end_template_engine.html http://barretlee.com/webs ...
- tp5 r3 一个简单的SQL语句调试实例
tp5 r3 一个简单的SQL语句调试实例先看效果核心代码 public function index() { if (IS_AJAX && session("uid&quo ...
- curl太复杂难用记不住?来试试Httpie一个简单的现代化命令行Http客户端
HTTPie 是一个简单的现代化命令行 HTTP 客户端. 交互友好,JSON支持,语法高亮,类wget下载,支持拓展等 功能特性 自然而且简单的命令语句 格式化且高亮显示输出内容 内置 JSON 支 ...
随机推荐
- 【Java】变量命名规范
Java是一种区分字母的大小写的语言,所以我们在定义变量名的时候应该注意区分大小写的使用和一些规范,接下来我们简单的来讲讲Java语言中包.类.变量等的命名规范. (一)Package(包)的命名 P ...
- 虚拟对抗训练(VAT):一种用于监督学习和半监督学习的正则化方法
正则化 虚拟对抗训练是一种正则化方法,正则化在深度学习中是防止过拟合的一种方法.通常训练样本是有限的,而对于深度学习来说,搭设的深度网络是可以最大限度地拟合训练样本的分布的,从而导致模型与训练样本分布 ...
- CyclicBarrier 解读
简介 字面上的意思: 可循环利用的屏障. 作用: 让所有线程都等待完成后再继续下一步行动. 举例模拟: 吃饭人没到齐不准动筷. 使用Demo package com.ronnie; import ja ...
- 吴裕雄 Bootstrap 前端框架开发——Bootstrap 表单:文本框(Textarea)
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title> ...
- 【转载】Oracle创建数据库和用户
以前开发的时候用得比较多的是mysql和sql server,oracle用的比较少,用起来比较生疏,mysql和sql server用起来比较类似,就oracle的使用方式和他们不同,oracle在 ...
- 不要在PHP7中踩这些坑
PHP是当今仍然是最流行的Web开发语言,目前在所有使用服务端编程语言的网站中,超过83%的站点在使用PHP.PHP7在性能方面实现跨越式的提升,然后有些坑我们还是要提醒PHPer不要踩. 1. 不要 ...
- Hadoop基准测试(二)
Hadoop Examples 除了<Hadoop基准测试(一)>提到的测试,Hadoop还自带了一些例子,比如WordCount和TeraSort,这些例子在hadoop-example ...
- 第1节 kafka消息队列:7、kafka的消费模型
- windows系统下 VUE cli手脚架环境安装
1.安装 node.js环境 (cmd命令工具里输入 node -v 检测是否安装成功) 2.安装VUE 全局环境 npm install --global vue-cli (cmd命令工具里面安装 ...
- 树莓派frp添加为服务管理
1.下载frp https://github.com/fatedier/frp/releases 我是1代的B+,下载arm版的,新的可以用arm64的 frp_0.29.0_linux_arm.ta ...