编译器是怎么工作的 —— 生成AST(抽象语法树)

虽然每天的工作中都会用到编译器,但我从来没有研究过编译器到底是怎么工作的,昨天阿润推荐了一个 the-super-tiny-compiler,看了之后对编译器有了一些初步的认识,写点笔记以作总结。

本文所说到的编译是指前端框架、前端工具所做的编译工作,不是源代码转二进制机器码的编译过程。

思考

react.js

class Index extends React.Component {
  render() {
    return (
      <h1>Hello React!</h1>
    )
  }
}

vue.js

<div id="app">
  {{ message }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

使用react.js和vue.js语法写出来的代码都是浏览器所不认识的,如果要交给浏览器运行,就要事先经过编译器的处理。

分析

为了理解vue、react以及其它编译器所做的复杂的工作,可以找一个小的切入点来了解这项工作的大致流程。比如 the-super-tiny-compiler 中的例子:

(add 2 (subtract 4 2)) => add(2, subtract(4, 2))

将LISP风格的代码转成浏览器能够正确解析的JS代码。

这项工作大体上又分为以下三个步骤:

  1. 源代码分析
  2. 分析结果转换
  3. 新代码生成

具体来说是这样的:

源代码分析阶段

第一步 词法分析

tokenizer.js

export default function tokenizer(input) {
  let current = 0;
  let tokens = [];
  while (current < input.length) {
    let char = input[current];
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '(',
      });
      current++;
      continue;
    }
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'number', 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;
    }
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'name', value });
      continue;
    }
    throw new TypeError('I dont know what this character is: ' + char);
  }
  return tokens;
}

test.js

import tokenizer from './tokenizer'

const tokenizer_res = tokenizer('(add 2 (subtract 4 2))')
console.log(tokenizer_res)
// [
//   {
//     "type": "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": ")"
//   }
// ]

tokenizer.js 所做的工作实际上就是进行词法分析,然后分词。按照语义将输入的代码拆分开来,每一个小块最终都将拼装成一个对象,该对象拥有 typevalue 属性,最终这些小块都被 push 进一个数组当中,这个数组实际上就是第一步词法分析的结果,tokenizer 方法最终将这个数组返回。

具体来说,tokenizer 方法将输入的代码使用 while 循环逐字遍历,遍历过程中使用条件判断语句分别查找 '('')',空格,数字,",字母。虽然是逐字遍历,但匹配到数字,",字母后的条件语句块内又有一个 while 循环,这个 while 循环的作用是:

  • 如果当前字符是数字的话,就继续看下一个字符是否为数字,如果是,就继续这一动作,一直找到不是数字为止。

    将找到的这些连续的数字作为 value,组装成 { type: 'number', value } 的对象,放进数组中。

  • 如果当前字符是 " 的话,就继续看下一个字符是否为 ",如果不是,就继续往下看,如果是,就终止。

    最终取出了双引号内的字符作为 value,组装成 { type: 'string', value } 的对象,放进了数组中。

    但该例中是没有 typestring 的内容的,所以也没有进这个 if 语句块。如果输入为 '(add 2 (subtract "4" 2))' 的话,就会进了。

  • 如果当前字符是字母的话,就继续看下一个字符,如果还是字母,就继续该动作,如果不是,if 语句块内的 while 循环就终止。

    最终拿出了连续的字母作为 value,组装成 { type: 'name', value } 的对象放进数组。

剩下的几种情况:

  • 如果是空格的话就跳过,继续下一个字符的判断。
  • 如果是 '(' 或者 ')',那么对应的对象的 type 就为 paren,表示它是括号,value 就是相应的 '(' 或者 ')'

第二步 句法分析(语法分析)

parser.js

export default function parser(tokens) {
  let current = 0;
  function walk() {
    let token = tokens[current];
    if (token.type === 'number') {
      current++;
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }
    if (token.type === 'string') {
      current++;
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }
    if (
      token.type === 'paren' &&
      token.value === '('
    ) {
      token = tokens[++current];
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };
      token = tokens[++current];
      while (
        (token.type !== 'paren') ||
        (token.type === 'paren' && token.value !== ')')
      ) {
        node.params.push(walk());
        token = tokens[current];
      }
      current++;
      return node;
    }
    throw new TypeError(token.type);
  }
  let ast = {
    type: 'Program',
    body: [],
  };
  while (current < tokens.length) {
    ast.body.push(walk());
  }
  return ast;
}

test.js

import tokenizer from './tokenizer'
import parser from './parser'

const tokenizer_res = tokenizer('(add 2 (subtract 4 2))')
const parser_res = parser(tokenizer_res)
console.log(parser_res)

// {
//   "type": "Program",
//   "body": [
//     {
//       "type": "CallExpression",
//       "name": "add",
//       "params": [
//         {
//           "type": "NumberLiteral",
//           "value": "2"
//         },
//         {
//           "type": "CallExpression",
//           "name": "subtract",
//           "params": [
//             {
//               "type": "NumberLiteral",
//               "value": "4"
//             },
//             {
//               "type": "NumberLiteral",
//               "value": "2"
//             }
//           ]
//         }
//       ]
//     }
//   ]
// }

parser.js 所做的工作实际上就是,对词法分析之后的结果(tokenizer 返回的数组)再次进行分析,分析过程中将该数组按照特定的格式转换成一个对象,该对象描述了原输入语句('(add 2 (subtract 4 2))')中各部分的具体内容/类型以及它们之间的关系,这就是我们所说的句法分析,句法分析之后输出的对象就是 AST —— Abstract Syntax Tree,抽象语法树。

具体来说,该例中输出的 AST typeProgrambody 中存放的是句法分析的结果。

parser.js 中,使用了 while 循环对词法分析之后返回的数组进行遍历,每一次遍历都调用一次 walk 方法,并将 walk 方法返回的结果 push 进 body 中。walk 方法的内部主要进行了三项工作:

  • 当前遍历到的对象 token 如果为 number,那么 walk 方法的返回值就为:
{
  type: 'NumberLiteral',
  value: token.value
}
  • 当前遍历到的对象 token 如果为 string,那么 walk 方法的返回值就为:
{
  type: 'StringLiteral',
  value: token.value
}

当然前面已经说过了,这个例子中是没有字符串节点对象的。

  • 当前遍历到的对象 token 如果为 '(',处理起来会稍微复杂一些。首先该括号节点对象会有两个属相,typename。其中,type 固定为 CallExpressionname 的值取决于括号 '(' 之后紧跟着的操作类型,add 或者 subtract。再然后,句法分析程序会把括号内的内容(除去操作类型)作为当前括号节点对象的参数,具体来说是 params 属性。walk 方法的返回值就会是这样:
{
  type: 'CallExpression',
  name: 'add / subtract',
  params: [/*省略*/]
}

再来看 params 属性的具体内容,代码中还是使用了一个 while 循环来对 params 属性进行处理:

if (
  token.type === 'paren' &&
  token.value === '('
) {
  token = tokens[++current];
  let node = {
    type: 'CallExpression',
    name: token.value,
    params: [],
  };
  token = tokens[++current];
  // 这个while循环
  while (
    (token.type !== 'paren') ||
    (token.type === 'paren' && token.value !== ')')
  ) {
    node.params.push(walk());
    token = tokens[current];
  }
  current++;
  return node;
}

实际上就是在词法分析返回的数组中,对当前括号之内(括号之内可能还有括号)所有除去操作类型(add 或者 subtract)和 '(' 的对象(词法分析返回的数组中的元素)递归调用 walk 方法,这时将 walk 方法返回的结果 push 进 params 属性中。

如果 while 循环终止了,会继续执行外面的 while 循环分析之后的数组元素:

while (current < tokens.length) {
  ast.body.push(walk());
}

由于 current 是一个全局维护的变量,所以也不会重复分析和重复收集。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容