zoukankan      html  css  js  c++  java
  • 【译】超级微型编译器

    目录

    引子

    在看 babel 文档的时候,接触到 The Super Tiny Compiler,其中的注释感觉解释的蛮容易理解,翻译记录一下。

    为什么要关注编译器

    大部分的人在他们的日常工作中,实际上没有必要去思考编译器相关的东西,不关注编译器很正常。然而,编译器在你的身边很常见,你使用的很多工具,都是借鉴了编译器的概念。

    编译器很可怕

    编译器的确很可怕。但是这是我们(那些写编译器的人)自己的错误,我们舍弃了简单合理,并且让它变得如此复杂可怕,以至于大部分的人认为是完全无法接近的事情,只有书呆子可以明白。

    该从那里开始?

    从编写一个最简单的编译器开始。这个编译器非常的小,如果你移除所有的注释,也只有 200 行代码。

    我们准备写一个编译器,它的作用是将一些 LISP 方法调用的形式转换成 C 语言里面方法调用的的形式。

    如果你对其中的语言不太熟悉,我将会简单介绍一下。

    如果我们有两个方法 addsubtract,它们会像下面这样书写:

    example LISP C
    2 + 2 (add 2 2) add(2, 2)
    4 - 2 (subtract 4 2) subtract(4, 2)
    2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))

    很容易,对吧?很好,这正是我们准备要编译的。虽然这个不是完整的 LISP 或 C 语法,但它的语法足以演示大部分现代编译器的主要部分。

    编译的三个阶段

    大部分的编译可以划分为 3 个主要阶段:解析(Parsing),转换(Transformation),代码生成(Code Generation)。

    • 解析:获取每一行代码,并将其变成更加抽象的代码。
    • 转换:获取抽象的代码,按照编译器的意图进行操作。
    • 代码生成:获取转换后表现的代码,并将其变换成新的代码。

    解析(Parsing)

    解析通常分为两个阶段:词法分析(Lexical Analysis)和语法分析(Syntactic Analysis)。

    • 词法分析:获取代码,并且用分词器(tokenizer)将其分解成单独的记号(tokens)。记号是一个数组,里面是描述语法各个部分的对象。它们可能是数字、文本、标点符号、操作符等等。
    • 语法分析:获取那些记号,并将其转换为另外一种表现形式,这种表现形式描述了自己的语法和相互联系。这被称为中间表示或抽象语法树(Abstract Syntax Tree 或 AST)。抽象语法树是一个深层嵌套的对象,代表代码运行的一种方式,它提供给我们很多信息。

    例如下面的语法:

    (add 2 (subtract 4 2))
    

    记号看起来可能像这样:

    [
      { 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: ')'        },
    ]
    

    抽象语法树看起来可能像这样:

    {
      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',
          }]
        }]
      }]
    }
    

    转换(Transformation)

    编译的下一个阶段就是转换。这一步只是获取上一步的 AST 并且再一次改变它。可以用相同的语言操作 AST,或者把 AST 转换成一个完全新的语言。

    让我们看看如果转换一个 AST。

    你可能发现我们的 AST 里面有些元素很相似。这里有一些拥有 type 属性的对象,每一个这样的对象被称为 AST 节点(AST Node)。这些节点定义了树上每个单独部分的属性。

    我们有一个 NumberLiteral 节点:

    {
      type: 'NumberLiteral',
      value: '2',
    }
    

    或者可能是一个 CallExpression 节点:

    {
      type: 'CallExpression',
      name: 'subtract',
      params: [...nested nodes go here...],
    }
    

    当转换 AST 时,我们可以对节点的属性进行添加/移除/替换操作,我们可以添加新的节点,移除节点,或者基于已存在的 AST 创建一个完全新的 AST。

    因为我们的目标是一个新的语言,所以我们将要针对新的语言,创建一个完全新的 AST。

    遍历(Traversal)

    为了能够找到所有的节点,我们需要遍历它们。这个遍历的过程要到达 AST 的每一个节点。

    {
      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'
          }]
        }]
      }]
    }
    

    上面的 AST,我们将这样遍历:

    1. Program - 从 AST 最顶层级开始
    2. CallExpression (add) - 移动到 Program 的 body 里面的第一个元素
    3. NumberLiteral (2) - 移动到 CallExpression(add) 的 params 的第一个元素
    4. CallExpression (subtract) - 移动到 CallExpression(add) 的 params 的第二个元素
    5. NumberLiteral (4) - 移动到 CallExpression(subtract) 的 params 的第一个元素
    6. NumberLiteral (2) - 移动到 CallExpression(subtract) 的 params 的第二个元素

    如果直接操作这个 AST,这里可能要介绍各种抽象。但是我们正在尝试做的事情,访问到树的每个节点就足够了。

    访问者(Visitors)

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

    var visitor = {
      NumberLiteral() {},
      CallExpression() {},
    };
    

    当我们遍历 AST 时,只要“进入(enter)”到一个匹配的类型节点,我们将调用这个 visitor 的方法。

    为了让这个想法可行,我们将传入一个节点和其父节点的引用。

    var visitor = {
      NumberLiteral(node, parent) {},
      CallExpression(node, parent) {},
    };
    

    然后,这里还存在“退出(exit)”的可能性。想象一下我们这样的树结构:

    - Program
      - CallExpression
        - NumberLiteral
        - CallExpression
          - NumberLiteral
          - NumberLiteral
    

    当我们遍历下去,最终会到达一个死胡同。所以当我们完成树每个分支的遍历,我们就“退出(exit)”。因此,向下遍历树,“进入(enter)”到树节点,返回的时候,我们就“退出(exit)”。

    -> Program (enter)
      -> CallExpression (enter)
        -> Number Literal (enter)
        <- Number Literal (exit)
        -> Call Expression (enter)
          -> Number Literal (enter)
          <- Number Literal (exit)
          -> Number Literal (enter)
          <- Number Literal (exit)
        <- CallExpression (exit)
      <- CallExpression (exit)
    <- Program (exit)
    

    为了支持这种功能,最后我们的 visitor 看起来会像是这样:

    var visitor = {
      NumberLiteral: {
        enter(node, parent) {},
        exit(node, parent) {},
      }
    };
    

    代码生成(Code Generation)

    编译的最后一个阶段就是代码生成。有些时候编译器在这个阶段,会做跟转换重叠的事情,但是大部分的代码生成只是意味着获取 AST 并且转换成字符串代码。

    代码生成有几种不同的运行方式,一些编译器会重用之前的记号(tokens),有些会创建一个单独的代码表示,这样他们就可以线性打印节点,但是从我了解到的情况,大部分会使用我们刚才创建的 AST,也是我们将要关注的。

    我们的代码生成器将有效地知道如何“打印(print)” AST 的所有不同节点类型,并且它将递归地调用自己来打印嵌套的节点,直到将所有内容打印成一长串代码。

    就这样!这些就是编译器所有不同的部分。并不是每一个编译器都像我这里描述的那样。编译器用于不同的目的,它们可能需要比我描述的更多的步骤。但是,现在你应该对于大部分编译器是什么样的,有一个更高的认识。

    现在,我已经解释了这么多,你应该都能很好的写出自己的编译器,对吧?只是开个玩笑,这就是我要帮助的。那么就让我们开始吧!

    编译器示例

    the-compiler-js

    参考资料

  • 相关阅读:
    Kafka.net使用编程入门(三)
    Kafka.net使用编程入门(一)
    在linux机器上面安装anaconda和相关软件
    textrank的方法,大概懂了
    中文分词库及NLP介绍,jieba,gensim的一些介绍
    排序相关指标
    阿里NLP总监分享-NLP技术的应用与思考
    我一直跑的分类LSTM模型原来是这一个,新闻分类网络
    Vue.js@2.6.10更新内置错误处机制,Fundebug同步支持相应错误监控
    掌握 Async/Await
  • 原文地址:https://www.cnblogs.com/thyshare/p/14915003.html
Copyright © 2011-2022 走看看