AST是个什么东西?

Abstract Syntax Tree(抽象语法树),简称AST;是源代码的抽象语法结构的树状表现形式。

超级迷你编译器

今天,我们将一起编写一个编译器。但是不同于其他任何编译器…

这是一个超级小(迷你版)编译器!这个编译器有多小呢,如果您删除所有注释,此文件仅仅只有200行的实际代码。

我们将把一些类似Lisp的函数调用编译成一些类似C的函数函数调用。

如果您对其中一个或者多个不熟悉。我将会给您快速介绍。

LISP是什么? Lisp(历史上拼写为LISP)是具有悠久历史的计算机编程语言家族,有独特和完全括号的前缀符号表示法。起源于公元1958年,是现今第二悠久而仍广泛使用的高端编程语言。只有FORTRAN编程语言比它更早一年。Lisp编程语族已经演变出许多种方言。现代最著名的通用编程语种是Clojure、Common Lisp和Scheme。

Lisp用来作为计算机程序实用的数学表达。因为是早期的高端编程语言之一,它很快成为人工智能研究中最受欢迎的编程语言。在计算机科学领域,Lisp开创了许多先驱概念,包括:树结构、自动存储器管理、动态类型、条件表达式、高端函数、递归、自主(self-hosting)编译器、读取﹣求值﹣输出循环(英语:Read-Eval-Print Loop,REPL)。

“LISP”名称源自“列表处理器”(英语:LISt Processor)的缩写。列表是Lisp的主要数据结构之一,Lisp编程代码也同样由列表组成。因此,Lisp程序可以把源代码当作数据结构进行操作,而使用其中的宏系统,开发人员可将自己定义的新语法或领域专用的语言,嵌入在Lisp编程中。 Lisp是第一个函数式程序语言,区别于C语言Fortran等命令型程序语言和JavaC#Objective-C等面向对象程序语言。由于历史的原因,Lisp长期以来被认为主要用于人工智能领域,但Lisp并不是只为人工智能而设计,而是一种通用的程序语言。

如果我们有两个函数如addsubtract,代码如下:

  1. LISP C
  2. 2 + 2 (add 2 2) add(2, 2)
  3. 4 - 2 (subtract 4 2) subtract(4, 2)
  4. 2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))

是不是看上去很容易?

很好,因为这正是我们将要编译的内容。 虽然这既不是完整的LISP语法也不是C语法,但足以说明现代编译器的许多主要部分。

大多数编译器分为三个主要阶段:解析(Parsing),转换(Transformation),和代码生成(Code Generation)

  1. 解析 就是让原始(源)代码并将其转换为更抽象的代码表示
  2. 转换 就是把这些抽象的代码表示,去进行操作,转变成编译器想要的、能够理解的东西。
  3. 代码生成 把这些转换后的代码代码,重新将其转换为新的代码。

解析(parsing)

解析通常被分为两个阶段:词法分析语法分析

1、词法分析就是把原始(源)代码通过词法分析器拆分成可标记的程序(Tokens或称为词法单元)

也就是将字符流(char stream)转换为记号流(token stream)

Tokens 是由微小的对象组成的一个数组,描述了一个孤立的片断的语法。它们可以是数字,标签,标点符号,运算符,等等。

2、语法分析就是把token stream 重新格式化为 可以描述每一个部分的语法和它们之间的关系,这种被称为中间表示或者叫抽象语法树。

抽象语法树,简称AST,是一个深层嵌套的对象,以易于使用和表达的方式表示代码之间的关系,并告诉我们很多信息。

  1. * For the following syntax:
  2. *
  3. * (add 2 (subtract 4 2))
  4. *
  5. * Tokens might look something like this:
  6. * Tokens 可能看起来像如下的形式:
  7. *
  8. * [
  9. * { type: 'paren', value: '(' },
  10. * { type: 'name', value: 'add' },
  11. * { type: 'number', value: '2' },
  12. * { type: 'paren', value: '(' },
  13. * { type: 'name', value: 'subtract' },
  14. * { type: 'number', value: '4' },
  15. * { type: 'number', value: '2' },
  16. * { type: 'paren', value: ')' },
  17. * { type: 'paren', value: ')' },
  18. * ]

而抽象语法树(AST)如以下形式:

  1. * {
  2. * type: 'Program',
  3. * body: [{
  4. * type: 'CallExpression',
  5. * name: 'add',
  6. * params: [{
  7. * type: 'NumberLiteral',
  8. * value: '2',
  9. * }, {
  10. * type: 'CallExpression',
  11. * name: 'subtract',
  12. * params: [{
  13. * type: 'NumberLiteral',
  14. * value: '4',
  15. * }, {
  16. * type: 'NumberLiteral',
  17. * value: '2',
  18. * }]
  19. * }]
  20. * }]
  21. * }

转换(Transformation)

编译器的下一个阶段类型就是转换,同样,这只是从最后一步获取AST并对其进行更改。它可以用同一种语言操作AST,也可以将其翻译成一种全新的语言。

让我们看看如何转换AST。

您可能会注意到,我们的AST中有一些看起来非常相似的元素。这些对象具有type属性。每个节点都称为AST节点。这些节点上定义了描述树的一个独立部分的属性。

我们有一个“NumberLiteral”的一个节点:

  1. {
  2. type: 'NumberLiteral',
  3. value: '2',
  4. }

或者是一个“CallExpression”节点:

  1. {
  2. type: 'CallExpression',
  3. name: 'subtract',
  4. params: [...nested nodes go here...],
  5. }

在转换AST时,我们可以通过“添加/删除/替换”属性来操作节点,我们可以添加新节点、移除节点,或者我们可以不使用现有的AST,并基于它创建一个全新的AST。

因为我们的目标是一种新的语言,所以我们将专注于创建一个全新的、特定于目标语言的AST。

遍历(Traversal)

为了浏览所有这些节点,我们需要能够遍历它们。This traversal process goes to each node in the AST depth-first.

  1. * {
  2. * type: 'Program',
  3. * body: [{
  4. * type: 'CallExpression',
  5. * name: 'add',
  6. * params: [{
  7. * type: 'NumberLiteral',
  8. * value: '2'
  9. * }, {
  10. * type: 'CallExpression',
  11. * name: 'subtract',
  12. * params: [{
  13. * type: 'NumberLiteral',
  14. * value: '4'
  15. * }, {
  16. * type: 'NumberLiteral',
  17. * value: '2'
  18. * }]
  19. * }]
  20. * }]
  21. * }

因此,对于以上述所说的AST,我们可以得出:

1、Program - 一颗抽象语法树的最顶层
2、CallExpression(add)- Program主体 的第一个元素
3、NumberLiteral(2)-

如果我们直接操作这个AST,而不是创建一个单独的AST,我们可能会在这里引入各种抽象概念。但是只要访问树中的每个节点就足够了。

我之所以使用“访问”这个词是因为有这样一种模式来表示对象结构元素上的操作。

Visitors


这里的基本思路就是,我们将创建一个“visitor”对象,该对象中有可以接受不同的节点类型的方法。

  1. * var visitor = {
  2. * NumberLiteral() {},
  3. * CallExpression() {},
  4. * };

当我们遍历AST时,每当我们“进入”一个匹配类型的节点时,我们将调用这个访问者上的方法。
为了使其更加实用,便捷,我们也将传递当前节点和父节点之间的引用。

  1. * var visitor = {
  2. * NumberLiteral(node, parent) {},
  3. * CallExpression(node, parent) {},
  4. * };
  1. * -> Program (enter)
  2. * -> CallExpression (enter)
  3. * -> Number Literal (enter)
  4. * <- Number Literal (exit)
  5. * -> Call Expression (enter)
  6. * -> Number Literal (enter)
  7. * <- Number Literal (exit)
  8. * -> Number Literal (enter)
  9. * <- Number Literal (exit)
  10. * <- CallExpression (exit)
  11. * <- CallExpression (exit)
  12. * <- Program (exit)

为了支持以上观点,我们的Visitors最终的形式应该像:

  1. * var visitor = {
  2. * NumberLiteral: {
  3. * enter(node, parent) {},
  4. * exit(node, parent) {},
  5. * }
  6. * };

代码生成(Code Generation)

编译器的最后一个阶段是代码生成。有时编译器会做一些与转换重叠的事情,but for the most part code generation just means take our AST and string-ify code back out.

代码生成器有几种不同的工作方式,有些编译器会重用以前的词法单元(tokens),有些编译器会创建一个单独的代码表示(中间表示),以便可以线性地打印节点,但据我所知,大多数编译器将使用我们刚刚创建的同一个AST,这也是我们要关注的。

实际上我们的代码生成器知道如何去“打印”AST中的所有不同节点类型,它将递归地调用自己来打印嵌套节点,直到所有内容都打印成一个代码长串。

大概就是这样,这就是编译器所有不同的部分。

当然并不是说每个编译器看起来都和我在这里描述的完全一样。
编译器有许多不同的用途,它们可能比我描述的需要更多、更为详细的步骤。

但现在您应该对大多数编译器的外观有一个大致的了解。

既然我已经解释了所有这些,你们都可以自己编写编译器了吧?

开玩笑,这将是我要帮助你来完成的一件事;
So let’s begin…

参考文章:

词法分析中的单词,其实应该叫做一个标记(token)比较合适,像let,var,while等,这些就可以叫做单词,因为他们具有特殊意义,像诸如===&&+等非字母连接起来的字符串,应该叫做标记比较合适(token)