手动实现一个超级超级简单的编译器

213 阅读4分钟

前言

原本是想了解一下balel,发现这玩意本质就是一个编译器或者是代码转换器。然后正好看到了 the-super-tiny-compiler,这个项目就是以教程的形式来完成一个微型编译器。

然后根据它的教程,本人翻译外加自己的一点点理解写了第一篇文章。

概念

编译器工作主要步骤:

  • Parsing:将原始代码解析成代码的抽象表示(AST)。
  • Transformation :接受这个抽象表示并进行操作以执行编译器想要的任何操作。
  • Code Generation :获取代码的转换表示并将其转换为新代码。

Parsing

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

Lexical Analysis

词法分析将原始代码分割并转换为token

Tokens 是一组微小的对象,描述了语法的一个独立部分。 它们可以是数字、标签、标点符号、运算符等等。

Syntactic Analysis

Syntactic Analysis takes the tokens and reformats them into a representation that describes each part of the syntax and their relation

语法分析将tokens格式化为可以表现其语法和他们之间关系的抽象表示,可以称其为抽象语法树(Abstract Syntax Tree)

AST是一种深度嵌套的对象,它以易于使用并告诉我们大量信息的方式表示代码。

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

Transformation

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

AST内有结构相似的许多元素,这些节点具有定义的属性,这些对象具有类型属性。 其中每一个都称为 AST 节点,用于描述树的一个孤立部分。

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

我们可以通过增加、去除、替换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'
            }]
        }]
    }]
}
​
树结构如下
- Program
    - CallExpression (add)
        - NumberLiteral (2)
        - CallExpression (subtract)
            - NumberLiteral (4)
            - NumberLiteral (2)

对于以上AST,我们的遍历顺序是

  1. Program
  2. CallExpression (add)
  3. NumberLiteral (2)
  4. CallExpression (subtract)
  5. NumberLiteral (4)
  6. NumberLiteral (2)

Visitors

这里用到一种设计模式——访问者模式(Visitor Pattern)。

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

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

当我们遍历 AST 时,每当我们“enter”匹配类型的节点时,我们都会调用该visitor的方法。

当我们的树如下所示时。

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

当我们向下走时,我们将到达有死胡同的树枝。 当我们完成树的每个分支时,我们就“退出”它。 因此,沿着树向下走,我们“进入”每个节点,然后向上走,我们“退出”。

-> 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遍历一遍??enter函数具体是用来干啥的??

回顾一下之前说的:

“由于我们的目标可能是一种新的语言,所以我们需要根据具体的新语言创建全新的AST。”

我们的目的不是对原有的树进行修改,而是根据旧AST构建新AST。

Code Generation

代码生成器以多种不同的方式工作,一些编译器将重用之前的标记(tokens),其他编译器将创建代码的单独表示形式,以便它们可以线性打印节点,但据我所知,大多数编译器将使用我们刚刚创建的相同 AST, 这就是我们要关注的重点。

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

实战

接下来需要实现一个简单编译器,功能是将LISP的代码转换为我们常用的类C语言的形式。

LISPC
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))

按照之前说的概念我们可以将模块进行拆分,最终可以分别实现以下4个函数:

  1. tokenizer
  2. parser
  3. transformer
  4. codeGenerator

tokenizer

所有需要转换的代码我们都可以理解为一个长文本字符串,该函数的作用是将输入的字符串转换成tokens。

input:代码文本

output:tokens

函数框架如下所示:

function tokenizer(input) {
    let current = 0;
    let tokens = [];
    while (current < input.length) {
        let char = input[current];
        // 然后判断char是什么来做不同的事
        ...
    }
    return tokens;
}

然后我们来判断字符类型,先按简单的来,一段代码中我们一般会有以下几种类型字符:

  • 括号——"("、")",先不考虑中括号、大括号等。
  • 空格,代码分隔符,生成token过程中可以直接跳过
  • 数字,假设只有整数
  • 字符串,必须用双引号包裹("")
  • 变量,假设只有小写字母

括号

这里只需提取字符,所以左右括号一样

if (char === '(' || char === ')') {
    tokens.push({
        type: 'paren',
        value: char,
    });
    current++;
    continue;
}

空格

使用正则表达式匹配空格

let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
    current++;
    continue;
}

数字

使用正则表达式匹配数字。

注意:1、123、321654这些都算一个数字。

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;
}

Parser

将tokens解析为抽象语法树

input:tokens

output:AST

解析函数的整体如下所示,非常的简短,本质上就是遍历tokens然后将其添加到ast.body中。具体的节点是怎么生成的关键在walk函数。

function parser(tokens) {
​
    let current = 0;
​
    function walk() {}
    
    let ast = {
        type: 'Program',
        body: [],
    };
    while (current < tokens.length) {
        // walk??
        ast.body.push(walk());
    }
    return ast;
}

walk函数的作用是遍历tokens生成树形节点。

对于number和string类型的token只需直接返回节点对象。

对于表达式的解析会比以上两者稍微复杂点。在LISP中表达式都是由括号包裹组成的,所以一旦找到第一个左括号就意味着接下来是段调用表达式。回顾一下LISP和C对比的表格,不难发现LISP的调用表达式由三部分组成:调用函数名、左值、右值。(没用过LISP,这里讲的不对的地方可以帮忙指出)

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
        token = tokens[++current];
        let node = {
            type: 'CallExpression',
            name: token.value,
            params: [],
        };
        // 下一个token一般为左值
        token = tokens[++current];
        // 寻找与当前左括号对应的右括号,递归可以会帮我们处理括号内部其余的括号
        while (
            (token.type !== 'paren') ||
            (token.type === 'paren' && token.value !== ')')
        ) {
            node.params.push(walk());
            // 这里current不需要加,因为walk函数结束都会自动+1
            token = tokens[current];
        }
​
        current++;
        return node;
    }
    throw new TypeError(token.type);
}

transformer

转换主要做两件事:1. 遍历旧树,2. 遍历过程中构建新树

input:ast

output:newAst

traverser

按照深度优先遍历整个ast,其代码如下所示。

如果节点是一段程序或者一个调用表达式那么可能会有子节点,递归调用traverseNode遍历其子节点。

function traverser(ast, visitor) {
    function traverseArray(array, parent) {
        array.forEach(child => {
            traverseNode(child, parent);
        });
    }
    function traverseNode(node, parent) {
        let methods = visitor[node.type];
        if (methods && methods.enter) {
            methods.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);
        }
        if (methods && methods.exit) {
            methods.exit(node, parent);
        }
    }
    traverseNode(ast, null);
}

以上代码只是在遍历,更加关键的一个问题是遍历过程中中enter到底是干嘛的,当然是为了生成新节点,具体怎么做呢。

这里主要是传入vistor对象,就是之前提到的观察者。visitor需要根据不同的节点类型做不同的事。

function transformer(ast) {
    let newAst = {
        type: 'Program',
        body: [],
    };
    ast._context = newAst.body;
    traverser(ast, {
        NumberLiteral: {
            enter(node, parent) {
                parent._context.push({
                    type: 'NumberLiteral',
                    value: node.value,
                });
            },
        },
        StringLiteral: {
            enter(node, parent) {
                parent._context.push({
                    type: 'StringLiteral',
                    value: node.value,
                });
            },
        },
        CallExpression: {
            enter(node, parent) {
                let expression = {
                    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);
            },
        }
    });
    return newAst;
}

codeGenerator

input:newAst

output:code

代码生成器也通过递归调用得到完整的代码字符串。

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);
    }
}

compiler

然后就可以把这些模块组合起来完成tiny-compiler啦

function compiler(input) {
    const tokens = tokenizer(input);
    const ast = parser(tokens);
    const newAst = transformer(ast);
    const output = codeGenerator(newAst);
    return output;
}

写在最后

源码大家可以直接去github原作者那查看,代码都很短