热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

不懂编译原理?本文教你从零实现最简编译模型!

原标题:不懂编译原理?本文教你从零实现最简编译模型!简介前两日我偶然间在GitHub上发现了一个项目:the-super-tiny-compi

原标题:不懂编译原理?本文教你从零实现最简编译模型!

简介

前两日我偶然间在 GitHub 上发现了一个项目:the-super-tiny-compiler,官方介绍说这可能是一个最简的编译器。刚好之前文章来源地址57462.html学过「编译原理」这门课,我的兴趣一下子就上来了,简单看了一下,这个项目是将一个 Lisp 表达式转化为 C 的表达式的编译器,中间涉及词法分析、语法分析、AST 树遍历转化以及最后的代码输出环节,下面我就带大家一起来简单实现一下。

词法分析

词法分析也叫解析,每一个编译器需要做的第一步都是词法分析,具体是什么意思呢?简单来说就是把要进行转化的「源代码」拆解开,形成一个一个小部件,称为 token。比如说如下将一个 Javascript 语句拆解开的例子:

let name = "touryung";

大致分解就可以得到一个 token 数组:[let, name, =, "touryung", ;],这样才有利于进行下一步操作。由于我们此次实现的是最简编译器,因此编译器内部只实现了对小括号、空格、数字、字符串、变量的识别。

整体框架

要实现词法分析器(解析器),首先我们需要先搭出一个框架。词法分析的整体思路就是遍历输入的字符串,然后识别不同的 token,将它保存到 token 数组

框架如下,不同部分的意思已在注释中标出:

const WHITESPACE = /\s/; // 空格
const NUMBERS = /[0-9]/; // 数字
const LETTERS = /[a-z]/i; // 变量
function tokenizer(input) {
let current = 0; // 当前识别到的下标
let tokens = []; // token 数组
while (current // 遍历
let char = input[current]; // 当前遍历到的字符
// 不同的 token 识别操作
throw new TypeError(`I dont know what this character is: ${char}`);
}
return tokens;
}

搭出框架,下一步就是识别不同的 token 了。

识别括号

识别括号很简单,当遍历到当前字符是左右括号时,将一个描述当前 token 的对象放入 token 数组即可。

// 识别左括号
if (char === "(") {
tokens.push({ type: "paren", value: "(" }); // 压入描述当前 token 的对象
current++;
continue;
}
// 识别右括号
if (char === ")") {
tokens.push({ type: "paren", value: ")" });
current++;
continue;
}

识别空格

这里需要注意,因为空格实际上对编程语言的语法来说是无关紧要的,这就是为什么将 Javascript 代码压缩之后仍然能够正常运行。因此当我们识别到空格的时候,不需要将其放入 token 数组进行下一步的操作。

实际上,在词法分析这一步,类似空格、注释、换行符这类不影响程序语法的 token 就不会送入下一步进行处理了。

因此,当我们识别到空格的时候,只需要继续遍历即可:

// 空格,不处理
if (WHITESPACE.test(char)) {
current++;
continue;
}

识别数字/变量/字符串

我为什么要把这三种 token 写在一起呢?原因是从数字开始,这三种 token 的匹配逻辑都很相似,由于匹配的 token 可能不再是单个字符,因此需要在内部继续循环直到匹配完整个 token 为止。

// 数字,循环获取数值
if (NUMBERS.test(char)) {
let value = ""; // 匹配的数字赋初值
while (NUMBERS.test(char)) { // 遍历,如果还能匹配就累加
value += char;
char = input[++current];
}
tokens.push({ type: "number", value }); // 压入描述当前 token 的对象
continue;
}
// 变量,和 number 类似
if (LETTERS.test(char)) {
let value = "";
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: "name", value });
continue;
}
// 字符串,前后的 "" 需要跳过
if (char === '"') {
let value = "";
char = input[++current]; // 跳过前面的引号
while (char !== '"') { // 结束条件,匹配到末尾的引号
value += char;
char = input[++current];
}
char = input[++current]; // 跳过后面的引号
tokens.push({ type: "string", value });
continue;
}

其中需要注意,识别字符串类似上面两种,但是也有两点不同:


  1. 在字符串识别时需要跳过前后的引号,只匹配中间具体的值;

  2. 在中间进行遍历的时候结束条件是匹配到末尾的引号。

有人可能会问,如果跳过的前后的引号以后要怎么知道它是字符串呢,这时候压入数组的 token 描述对象作用就出来了,它有一个 type 属性可文章来源站点https://www.yii666.com/以指定当前 token 的类型。

小总结

至此,词法分析的工作就做完了,其实相对来说还是很好懂的,那么能不能直观的观察词法分析输出的 token 数组是什么样子的呢?当然可以,只需要编写一个样例测试一下就行了,比如:

let source = "(add 2 (subtract 4 2))"; // 源代码
let tokens = tokenizer(source);
console.log(tokens);

这是一个计算 2+(4-2) 的 Lisp 语句,将它作为输入得到的 token 数组如下所示:

[
{ "type"www.yii666.com: "paren", "value": "(" },
{ "type": "name", "value": "add" },
{ "type": "number", "value": "2" },
{ "type": "paren", "value": "(" },
{ "type": "name", "value": "subtract" },
{ "type": "number", "value": "4" },
{ "type": "number", "value": "2" },
{ "type": "paren", "value": ")" },
{ "type": "paren", "value": ")" }
]

这样就完美的达到了我们开头所说的将源代码进行拆解的目的。

语法分析

接下来就是语法分析了,语法分析的作用是根据具体的编程语言语法来将上一步输出的 token 数组转化为对应的 AST(抽象语法树),既然涉及到树结构,那么这个步骤自然少不了递归操作。

整体框架

通用的,语法分析部分也需要先搭出一个框架。整体思路就是遍历 token 数组,递归地构建 AST 树,框架如下:

function parser(tokens) {
let current = 0;
function walk() {
let token = tokens[current];
// 将不同的 token 转化为 AST 节点
throw new TypeError(token.type);
}
let ast = {
// 此为 AST 树最外层结构,是固定的
type: "Program",
body: [],
};
while (current // 遍历 token 数组,构建树结构
ast.body.push(walk());
}
return ast;
}

构建数字和字符串节点

这两种节点的构建较为简单,直接返回描述节点的对象即可:

// 构建整数节点
if (token.type === "number") {
current++;
return {
type: "NumberLiteral",
value: token.value,
};
}
// 构建字符串节点
if (token.type === "string") {
current++;
return {
type: "StringLiteral",
value: token.value,
};
}

构建函数调用节点

懂 Lisp 的人都知道,在 Lisp 中括号是精髓,比如函数调用类似于这种形式: (add 1 2)。因此我们需要以括号来进行识别,具体的代码如下:

if (token.type === "paren" && token.value === "(") {
// 左括号开始
token = tokens[++current]; // 跳过左括号
let node = {
// 函数调用节点
type: "CallExpression",
name: token.value,
params: [],
};
token = tokens[++current]; // 跳过 name
// 只要不是右括号,就递归收集参数节点
while (!(token.type === "paren" && token.value === ")")) {
node.params.push(walk()); // 添加参数
token = tokens[current];
}
current++; // 跳过右括号
return node;
}

这里面需要注意的点是,某一个参数也可能是函数调用的结果,因此在解析参数时需要递归调用 walk 函数。

还有另外一点值得一提,那就是我们多次用到了这种代码结构:

if (value === "(") {
// ...
while (!value === ")") {
// ...
}
}

很明显,这种结构就是适用于遍历某个区间,因此我们在分析字符串、括号这种配对元素时就需要使用这种结构。

小总结

就进行这样简单的几个步骤,前面的 token 数组就会被我们转化成 AST 树结构了,感觉还是非常的神奇,此时,我们的输出以及编程了如下这样:

{
"type": "Program",
"body": [
{
"type": "CallExpression",
"name": "add"文章来源地址57462.html,
"params": [
{
"type": "NumberLiteral",
"value": "2"
},
{
"type": "CallExpression",
"name": "subtract",
"params": [
{
"type": "NumberLiteral",
"value": "4"
},
{
"type": "NumberLiteral",
"value": "2"
}
]
}
]
}
]
}

遍历并转化 AST 树

此时我们已经得到了一棵 AST 树,编译器之所以能够将源代码转化为目标代码实际上就可以视作将源 AST 树转化为目标 AST 树,要实现这种转化过程,我们就需要对树进行遍历,然后对对应的节点进行操作。

遍历树

我们从上面可以看出,AST 树中的 body 属性和函数调用的参数实际上都是数组类型的,因此我们首先需要定义对数组类型的遍历方法,很简单,只需要遍历数组中的每个元素分别进行遍历就行了:

// 访问(参数)数组
function traverseArray(array, parent) {
array.forEach((child) => traverseNode(child, parent));
}

当遍历到具体的节点时,我们就需要调用此节点类型的 enter 方法来进行访问(转化 AST)操作,不同类型的节点 enter 方法是不一样的。

function traverseNode(node, parent) {
let method = visitor[node.type]; // 去除当前类型的方法
if (method && method.enter) {
// 执行对应 enter 方法
method.enter(node, parent);
}
switch (
node.type // 对不同类型节点执行不同的遍历操作
) {
case "Program":
traverseArray(node.body, node);
break;
case "CallExpression":
traverseArray(node.params, node);
break;
case "NumberLiteral":
case "StringLiteral":
break;
default:
throw new TypeError(node.type);
}
}

可能有人又要问,为什么执行 enter 方法时第二个参数需要传入父节点呢?这其实和后面的实际转化部分的逻辑相关,我们就放到后面来进行解释。

转化 AST 树


整体框架

一样的,我们可以首先搭出大体的框架,具体的同类型的节点访问(转化)方法后面再说。这里的转化思路就比较重要了:我们要如何在遍历旧的 AST 树时能将转化后的节点加入新的 AST 树?

这里的实现思路大体分为以下几步:


  1. 在旧的 AST 树中加入一个 _context 上下文属性,指向新的 AST 树的数组节点

  2. 当遍历旧 AST 数组节点的子元素时,将转化后的子元素放入它的父元素的 _context 属性中

  3. 根据 Javascript 引用类型的特点,此时就实现了将转化和的节点放入新 AST 树的目的。

在图中表示出来大概如下:

我相信这已经回答了上面执行 enter 方法时为什么第二个参数需要传入父节点的问题。

function transformer(ast) {
let newAst = www.yii666.com{
// 新 AST 树的最外层结构
type: "Program",
body: [],
};
// _context 用于遍历旧子节点时压入新 ast
ast._cOntext= newAst.body;
let visitor = {
// 不同类型的节点访问(转化)方法
};
traverser(ast, visitor); // 开始遍历旧 AST 树
return newAst;
}

数字和字符串的转化

{
NumberLiteral: {
enter(node, parent) {
parent._context.push({ // 压入新 AST 树
type: "NumberLiteral",
value: node.value,
});
},
},
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: "StringLiteral",
value: node.value,
});
},
}
}

函数调用节点的转化

函数调用节点特殊一点,由于它的参数可以视作它的子节点,因此需要将当前节点的 _context 属性指向新 AST 树对应的参数数组。

还有一点特殊的是,如果当前的函数调用不是嵌套在别的函数调用中,那么就可以再加一个 ExpressionStatement 信息,表示当前节点是一整个语句,比如 (add 2 (subtract 4 2)) 内层的括号就不能称作一个完整的语句,因为它是作为另一个函数的参数形式存在的。

{
CallExpression: {
enter(node, parent) {
let expression = { // 新 AST 树的函数调用节点
type: "CallExpression",
callee: {
type: "Identifier",
name: node.name,
},
arguments: [],
};
node._cOntext= expression.arguments; // 参数数组处理
// 如果当前的函数调用不是嵌套在别的函数调用中
if (parent.type !== "CallExpression") {
expression = {
type: "ExpressionStatement",
expression: expression,
};
}
parent._context.push(expression);
},
},
}

小总结

截至目前,我们已经完成了 AST 树的遍历和转化工作,这部分的工作量不小,但是也是整个编译中最精华的部分,如果顺利的话,我们现在可以得到如下转化后的新 AST 树:

{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "add"
},
"arguments": [
{
"type": "NumberLiteral",
"value": "2"
},
{
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "subtract"
},
"arguments": [
{
"type": "NumberLiteral",
"value": "4"
},
{
"type": "NumberLiteral",
"value": "2"
}
]
}
]
}
}
]
}

这就是对应 C 代码的 AST 树的结构了,将它与之前 Lisp 的 AST 树相比,还是可以看出很多不同的。

代码生成

最后,就是最激动人心的时刻了,生成目标代码!这一步相对轻松,根据上一步生成的 AST 树,对它进行递归遍历并生成最终的代码:

function codeGenerator(node) {
switch (node.type) {
case "Program":
return node.body.map(codeGenerator).join("\n");
case "ExpressionStatement":
return `${codeGenerator(node.expression)};`;
case "CallExpression": // 生成函数调用式
return `${codeGenerator(node.callee)}(${node.arguments
.map(codeGenerator)
.join(", ")})`;
case "Identifier": // 生成变量名
return node.name;
case "NumberLiteral":
return node.value; // 生成数字
case "StringLiteral":
return `"${node.value}"`; // 生成字符串(别忘了两边的引号)
default:
throw new TypeError(node.type);
}
}

最终,我们实现了从 Lisp 的示例代码 (add 2 (subtract 4 2)) 到 C 语言代码 add(2, subtract(4, 2)) 的转化。

大总结

本篇文章带大家从零实现了一个编译器最基本的功能,涉及了词法分析、语法分析、AST 树遍历转化等内容。编译原理听似高深(确实高深),但是基础的部分就是那些内容,啥词法分析语法分析的,最终都会回归到对字符串的处理。

我研究的方向是前端,那别人可能认为平时可能都不会涉及到编译原理的内容,但是实际上一旦深入研究的话,类似 Babel 将 ES6+ 的代码转化为 ES5 之类的工作实际上都是编译器做的工作,还有最近很火的 esbuild,只要涉及到代码的转化,肯定都会涉及编译,甚至 Vue 内部也有一个编译器用于模板编译。

说了这么多,本意还是希望大家在平时的学习中要多多涉猎新领域的知识,扩展自己的技能面,这样才能提高自己的技术视野和上限。

最后,推荐一个我最近在学习的最简 Vue 模型项目,也可以在这里面学习到 Vue 中模板编译的原理。

https://github.com/cuixiaorui/mini-vue

(全文终)

来源于:不懂编译原理?本文教你从零实现最简编译模型!


推荐阅读
  • 本文介绍了Android中的assets目录和raw目录的共同点和区别,包括获取资源的方法、目录结构的限制以及列出资源的能力。同时,还解释了raw目录中资源文件生成的ID,并说明了这些目录的使用方法。 ... [详细]
  • 本文介绍了如何使用JSONObiect和Gson相关方法实现json数据与kotlin对象的相互转换。首先解释了JSON的概念和数据格式,然后详细介绍了相关API,包括JSONObject和Gson的使用方法。接着讲解了如何将json格式的字符串转换为kotlin对象或List,以及如何将kotlin对象转换为json字符串。最后提到了使用Map封装json对象的特殊情况。文章还对JSON和XML进行了比较,指出了JSON的优势和缺点。 ... [详细]
  • java.lang.Class.getDeclaredMethod()方法java.lang.Class.getDeclaredMethod()方法用法实例教程-方法返回一个Met ... [详细]
  • 本文主要解析了Open judge C16H问题中涉及到的Magical Balls的快速幂和逆元算法,并给出了问题的解析和解决方法。详细介绍了问题的背景和规则,并给出了相应的算法解析和实现步骤。通过本文的解析,读者可以更好地理解和解决Open judge C16H问题中的Magical Balls部分。 ... [详细]
  • 猜字母游戏
    猜字母游戏猜字母游戏——设计数据结构猜字母游戏——设计程序结构猜字母游戏——实现字母生成方法猜字母游戏——实现字母检测方法猜字母游戏——实现主方法1猜字母游戏——设计数据结构1.1 ... [详细]
  • [大整数乘法] java代码实现
    本文介绍了使用java代码实现大整数乘法的过程,同时也涉及到大整数加法和大整数减法的计算方法。通过分治算法来提高计算效率,并对算法的时间复杂度进行了研究。详细代码实现请参考文章链接。 ... [详细]
  • Android实战——jsoup实现网络爬虫,糗事百科项目的起步
    本文介绍了Android实战中使用jsoup实现网络爬虫的方法,以糗事百科项目为例。对于初学者来说,数据源的缺乏是做项目的最大烦恼之一。本文讲述了如何使用网络爬虫获取数据,并以糗事百科作为练手项目。同时,提到了使用jsoup需要结合前端基础知识,以及如果学过JS的话可以更轻松地使用该框架。 ... [详细]
  • 本文介绍了H5游戏性能优化和调试技巧,包括从问题表象出发进行优化、排除外部问题导致的卡顿、帧率设定、减少drawcall的方法、UI优化和图集渲染等八个理念。对于游戏程序员来说,解决游戏性能问题是一个关键的任务,本文提供了一些有用的参考价值。摘要长度为183字。 ... [详细]
  • 简述在某个项目中需要分析PHP代码,分离出对应的函数调用(以及源代码对应的位置)。虽然这使用正则也可以实现,但无论从效率还是代码复杂度方面考虑ÿ ... [详细]
  • 本文介绍了如何在给定的有序字符序列中插入新字符,并保持序列的有序性。通过示例代码演示了插入过程,以及插入后的字符序列。 ... [详细]
  • 【shell】网络处理:判断IP是否在网段、两个ip是否同网段、IP地址范围、网段包含关系
    本文介绍了使用shell脚本判断IP是否在同一网段、判断IP地址是否在某个范围内、计算IP地址范围、判断网段之间的包含关系的方法和原理。通过对IP和掩码进行与计算,可以判断两个IP是否在同一网段。同时,还提供了一段用于验证IP地址的正则表达式和判断特殊IP地址的方法。 ... [详细]
  • 本文介绍了一个React Native新手在尝试将数据发布到服务器时遇到的问题,以及他的React Native代码和服务器端代码。他使用fetch方法将数据发送到服务器,但无法在服务器端读取/获取发布的数据。 ... [详细]
  • Windows7企业版怎样存储安全新功能详解
    本文介绍了电脑公司发布的GHOST WIN7 SP1 X64 通用特别版 V2019.12,软件大小为5.71 GB,支持简体中文,属于国产软件,免费使用。文章还提到了用户评分和软件分类为Win7系统,运行环境为Windows。同时,文章还介绍了平台检测结果,无插件,通过了360、腾讯、金山和瑞星的检测。此外,文章还提到了本地下载文件大小为5.71 GB,需要先下载高速下载器才能进行高速下载。最后,文章详细解释了Windows7企业版的存储安全新功能。 ... [详细]
  • 可空类型可空类型主要用于参数类型声明和函数返回值声明。主要的两种形式如下: ... [详细]
  • python3下载mapbox矢量切片通过观察mapbox的页面开发者工具里的network可以发现,打开矢量切片和字体切片pbf和prite图标的链接, ... [详细]
author-avatar
dtd3795290
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有