【保姆级】200行代码-实现一个编译器(TypeScript,Vitest,TDD)

331 阅读8分钟

本文主要是记录自己的个人学习,废话不多说,我们现在开始

前言

为什么要学习编译器

  • 最近在看AST之类相关的知识,况且即将学习编译原理,主要想通过本次项目来加深自己对编译原理的理解。

  • 这里主要记录观看了b站UP🐷:阿崔cxr的视频来进行学习,阿崔的视频YYDS好吧!!

  • 我们将通过 The Super Tiny Compiler 源码解读,学习如何实现一个轻量编译器,最终实现将下面原始代码字符串(Lisp 风格的函数调用)编译成 JavaScript 可执行的代码

Lisp 风格(编译前)JavaScript 风格(编译后)
(add 2 2)add(2, 2)
(subtract 4 2)subtract(4, 2)
(add 2 (subtract 4 2))add(2, subtract(4, 2))

编译器工作流程

编译器工作流程.png

准备工作

本文采用TDD(测试驱动开发),首先安装Vitest

首先安装Vitest,安装命令如下:

npm init vitest
*# or* 
pnpm add vitest
  • 并设置对应脚本,package.json如下
{
  "devDependencies": {
    "vitest": "^0.26.2"
  },
  "scripts": {
    "test": "vitest --watch"
  }
}
  • 接着创建三个文件夹来放置我们对应三个阶段的代码,分别是Parsing,Transformation,CodeGeneration
  • 准备好本次项目用到的类型(可以实现功能后再来学习理解),这里在项目最外层ts文件,命名为AstType.ts
  • export enum NodeTypes {
      Program = 'Program',
      NumberLiteral = 'NumberLiteral',
      CallExpression = 'CallExpression',
      ExpressionStatement = 'ExpressionStatement',
      Identifier = 'Identifier',
    }
    export type ChildNode = NumberLiteralNode | CallExpressionNodeexport interface Node {
      type: NodeTypes
    }
    ​
    export interface RootNode extends Node {
      type: NodeTypes.Program
      body: ChildNode[]
      context?: ChildNode[]
    }
    ​
    export interface NumberLiteralNode extends Node {
      type: NodeTypes.NumberLiteral
      value: string
    }
    ​
    export interface CallExpressionNode extends Node {
      type: NodeTypes.CallExpression
      name: string
      params: ChildNode[]
      context?: ChildNode[]
    }
    ​
    export enum TokenTypes {
      Paren,
      Name,
      NumberLiteral,
    }
    ​
    export interface Token {
      type: TokenTypes
      value: string
    }
    ​
    export type ParentNode = RootNode | CallExpressionNode | undefinedtype MethodFn = (node: RootNode | ChildNode, parent: ParentNode) => void
    export interface VisitorOption {
      enter: MethodFn
      exit?: MethodFn
    }
    export interface Visitor {
      Program?: VisitorOption
      CallExpression?: VisitorOption
      NumberLiteral?: VisitorOption
      StringLiteral?: VisitorOption
    }
    ​
    

Parsing

词法分析

第一步,我们要实现我们的词法分析,首先编写我们的测试用例,在Parsing中创建tokenizer.spec.ts和tokenizer.ts

  • tokenizer.spec.ts
import { tokenizer } from './tokenizer'
import { TokenTypes } from '../AstType'
import { test, expect } from 'vitest'
​
test('tokenizer', () => {
  const code = `(add 2 (subtract 4 2))`
  const tokens = [
    { type: TokenTypes.Paren, value: '(' },
    { type: TokenTypes.Name, value: 'add' },
    { type: TokenTypes.NumberLiteral, value: '2' },
    { type: TokenTypes.Paren, value: '(' },
    { type: TokenTypes.Name, value: 'subtract' },
    { type: TokenTypes.NumberLiteral, value: '4' },
    { type: TokenTypes.NumberLiteral, value: '2' },
    { type: TokenTypes.Paren, value: ')' },
    { type: TokenTypes.Paren, value: ')' },
  ]
  expect(tokenizer(code)).toEqual(tokens)
})

通过测试用例我们可以知道,我们将原始代码通过字符串分割成了一个个token,他们可以是数字,符号和运算符。那具体函数如何实现呢?其实很简单,上代码!

这里我们通过正则表达式来判断空格、数字和字母,将他们区分开来,然后分别创建对应的类型加入到对象中,最后push到数组

  • tokenizer.ts
import { Token, TokenTypes } from '../AstType'export function tokenizer(code: string) {
  const tokens: Token[] = []
  let current = 0
  while (current < code.length) {
    let char = code[current]
​
    //whitespace
    const WHITESPACE = /\s/
    if (WHITESPACE.test(char)) {
      current++
      continue
    }
​
    //left paren
    if (char === '(') {
      tokens.push({
        type: TokenTypes.Paren,
        value: char,
      })
      current++
      continue
    }
​
    //right paren
    if (char === ')') {
      tokens.push({
        type: TokenTypes.Paren,
        value: char,
      })
      current++
      continue
    }
    
    //name
    const LETTERS = /[a-z]/i
    if (LETTERS.test(char)) {
      let value = ''
      while (LETTERS.test(char) && current < code.length) {
        value += char
        char = code[++current]
      }
      tokens.push({ type: TokenTypes.Name, value })
    }
    //number
    const NUMBERS = /[0-9]/
    if (NUMBERS.test(char)) {
      let value = ''
      while (NUMBERS.test(char) && current < code.length) {
        value += char
        char = code[++current]
      }
      tokens.push({ type: TokenTypes.NumberLiteral, value })
    }
  }
​
  return tokens
}
​

✅到这里,一个简单的词法分析就已经完成了,紧接着来实现我们的语法分析。

语法分析

这里我们还是从测试入手,在Parsing文件夹中创建parser.spec.ts和parser.ts

  • parser.spec.ts
import { parser } from './parser'
import { test, expect } from 'vitest'
import { TokenTypes, Token, NodeTypes } from '../AstType'
test('parser', () => {
  const tokens: Token[] = [
    { type: TokenTypes.Paren, value: '(' },
    { type: TokenTypes.Name, value: 'add' },
    { type: TokenTypes.NumberLiteral, value: '2' },
    { type: TokenTypes.Paren, value: '(' },
    { type: TokenTypes.Name, value: 'subtract' },
    { type: TokenTypes.NumberLiteral, value: '4' },
    { type: TokenTypes.NumberLiteral, value: '2' },
    { type: TokenTypes.Paren, value: ')' },
    { type: TokenTypes.Paren, value: ')' },
  ]
  const ast = {
    type: NodeTypes.Program,
    body: [
      {
        type: NodeTypes.CallExpression,
        name: 'add',
        params: [
          {
            type: NodeTypes.NumberLiteral,
            value: '2',
          },
          {
            type: NodeTypes.CallExpression,
            name: 'subtract',
            params: [
              {
                type: NodeTypes.NumberLiteral,
                value: '4',
              },
              {
                type: NodeTypes.NumberLiteral,
                value: '2',
              },
            ],
          },
        ],
      },
    ],
  }
  expect(parser(tokens)).toEqual(ast)
})
​

从测试用例来看,语法分析的主要任务是把词法分析返回的词法单元数组转换成能够描述语法成分的抽象语法树🌲

  • parser.ts
import { Token, TokenTypes } from '../AstType'import { NodeTypes, NumberLiteralNode, CallExpressionNode, RootNode } from '../AstType'function createRootNode(): RootNode {
  return {
    type: NodeTypes.Program,
    body: [],
  }
}
​
function createNumberNode(value: string): NumberLiteralNode {
  return {
    type: NodeTypes.NumberLiteral,
    value,
  }
}
​
function createCallExpressionNode(name: string): CallExpressionNode {
  return {
    type: NodeTypes.CallExpression,
    name,
    params: [],
  }
}
​
export function parser(tokens: Token[]) {
  let current = 0
​
  const rootNode = createRootNode()
​
  function walk() {
    let token = tokens[current]
​
    if (token.type === TokenTypes.NumberLiteral) {
      current++
      return createNumberNode(token.value)
    }
​
    if (token.type === TokenTypes.Paren && token.value === '(') {
      token = tokens[++current]
​
      const node = createCallExpressionNode(token.value)
      token = tokens[++current]
      while (!(token.type === TokenTypes.Paren && token.value === ')')) {
        node.params.push(walk())
        token = tokens[current]
      }
      current++
      return node
    }
    throw new Error(`Unknown token:${token}`)
  }
  while (current < tokens.length) {
    rootNode.body.push(walk())
  }
  return rootNode
}
​

从parser主函数我们可以得知,我们首先创建了一个根节点,然后定义了一个current指针。整个函数主要做了两个判断。

  • 分别是是否为数字节点和是否为括号。
  • 如果是数字的话就创建数字节点。
  • 如果为括号,就让指针++,获取括号后面的值(这里为表达式节点),然后创建对应的表达式节点,接着继续递归🐢做重复操作。
  • 最终将生成的节点push到数组中,这样就形成了AST抽象语法树。

Transformation

编译器的下一个阶段就是转换

  • 这里主要是将我们形成的AST转换成目标代码/语言所需要的AST树,这样我们就可以翻译我们的代码了
  • 在转换AST时,我们可以通过增删改我们AST树的节点来达到目的,为了遍历整棵树的节点这里我们采用深度遍历,并且运用到设计模式中的访问者模式。
  • 访问者模式这里主要通过遍历某个节点后,执行某些操作,例如增加节点或者删除修改节点,通过AST的类型来判断我们要做什么工作。
  • 这里先来模拟深度遍历节点
  • 在Transformation文件夹中分别创建traverser.spec.tstraverser.ts
  • traverser.spec.ts
import { test, expect } from 'vitest'
import { NodeTypes, RootNode, Visitor } from '../AstType'
import { traverser } from './traverser'
test('traverse', () => {
  const ast: RootNode = {
    type: NodeTypes.Program,
    body: [
      {
        type: NodeTypes.CallExpression,
        name: 'add',
        params: [
          {
            type: NodeTypes.NumberLiteral,
            value: '2',
          },
          {
            type: NodeTypes.CallExpression,
            name: 'subtract', 
            params: [
              {
                type: NodeTypes.NumberLiteral,
                value: '4',
              },
              {
                type: NodeTypes.NumberLiteral,
                value: '2',
              },
            ],
          },
        ],
      },
    ],
  }
  const callArr: any = []
  const visitor: Visitor = {
    Program: {
      enter(node) {
        callArr.push(['program-enter', node.type, ''])
      },
      exit(node) {
        callArr.push(['program-exit', node.type, ''])
      },
    },
    CallExpression: {
      enter(node, parent) {
        callArr.push(['call-enter', node.type, parent!.type])
      },
      exit(node, parent) {
        callArr.push(['call-exit', node.type, parent!.type])
      },
    },
    NumberLiteral: {
      enter(node, parent) {
        callArr.push(['number-enter', node.type, parent!.type])
      },
      exit(node, parent) {
        callArr.push(['number-exit', node.type, parent!.type])
      },
    },
  }
​
  traverser(ast, visitor)
  //引入观察者
  expect(callArr).toEqual([    ['program-enter', NodeTypes.Program, ''],
    ['call-enter', NodeTypes.CallExpression, NodeTypes.Program],
    ['number-enter', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['number-exit', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['call-enter', NodeTypes.CallExpression, NodeTypes.CallExpression],
    ['number-enter', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['number-exit', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['number-enter', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['number-exit', NodeTypes.NumberLiteral, NodeTypes.CallExpression],
    ['call-exit', NodeTypes.CallExpression, NodeTypes.CallExpression],
    ['call-exit', NodeTypes.CallExpression, NodeTypes.Program],
    ['program-exit', NodeTypes.Program, ''],
  ])
})
​

通过我们的测试用例,我们可以发现我们要实现深度遍历,并通过访问者模拟进入节点和退出节点的操作。那具体操作如何实现呢?我们来看代码

  • traverser.ts
import { NodeTypes, RootNode, ChildNode, Visitor, ParentNode } from '../AstType'

export function traverser(rootNode: RootNode, visitor: Visitor) {
  // 1. 实现深度优先
  function traverserArray(array: ChildNode[], parent: ParentNode) {
    array.forEach((node) => {
      traverserNode(node, parent)
    })
  }
  
  function traverserNode(node: RootNode | ChildNode, parent?: ParentNode) {
    
    const methods = visitor[node.type]
    if (methods) {
      //对当前结点和父结点进行处理
      methods.enter(node, parent)
    } 
    //对当前结点的子结点进行递🐢
    switch (node.type) {
      case NodeTypes.Program:
        traverserArray(node.body, node)
        break
      case NodeTypes.CallExpression:
        traverserArray(node.params, node)
        break
      case NodeTypes.NumberLiteral:
        break
    }
    if (methods && methods.exit) {
      methods.exit(node, parent)
    }
  }
  traverserNode(rootNode)
}
  • 这里我们可以很清楚的了解到
  • 当我们遍历一个节点时(需要传入当前节点和对应的父节点,这里是为了后面的操作更加方便)
  • 我们会先判断该节点的类型在访问者中是否有对应类型函数,如果有则执行
  • 接着继续递归节点
  • ✅最后通过单元测试

好了,既然深度遍历已经实现,我们接下来进入到我们的重头戏---转换

我们在Transformation文件夹中创建分别transformer.tstransformer.spec.ts,我们依旧从测试入手

  • transformer.spec.ts
import { NodeTypes, RootNode } from '../AstType';
import { transformer } from './transformer'
import { test, expect } from 'vitest'

test.skip('transformer', () => {
  const originalAST:RootNode = {
    type: NodeTypes.Program,
    body: [
      {
        type: NodeTypes.CallExpression,
        name: 'add',
        params: [
          {
            type: NodeTypes.NumberLiteral,
            value: '2',
          },
          {
            type: NodeTypes.CallExpression,
            name: 'subtract',
            params: [
              {
                type: NodeTypes.NumberLiteral,
                value: '4',
              },
              {
                type: NodeTypes.NumberLiteral,
                value: '2',
              },
            ],
          },
        ],
      },
    ],
  }
  //params 换成 arguments
  //name  callee包裹
  //body下面用statement包裹
  //父级不是表达式的话,用expressionstatement包裹
  const transformedAST = {
    type: NodeTypes.Program,
    body: [
      {
        type: NodeTypes.ExpressionStatement,
        expression: {
          type: NodeTypes.CallExpression,
          callee: {
            type: NodeTypes.Identifier,
            name: 'add',
          },
          arguments: [
            {
              type: NodeTypes.NumberLiteral,
              value: '2',
            },
            {
              type: NodeTypes.CallExpression,
              callee: {
                type: NodeTypes.Identifier,
                name: 'subtract',
              },
              arguments: [
                {
                  type: NodeTypes.NumberLiteral,
                  value: '4',
                },
                {
                  type: NodeTypes.NumberLiteral,
                  value: '2',
                },
              ],
            },
          ],
        },
      },
    ],
  }
  expect(transformer(originalAST)).toEqual(transformedAST)
})

通过单元测试我们可以知道,这里主要功能是将旧的AST语法树转换成我们想要的AST语法树,那如何实现呢?我们依旧看代码。

  • transformer.ts
import { RootNode, NodeTypes } from '../AstType'
import { traverser } from './traverser'
export function transformer(ast: RootNode) {
  const newAst = {
    type: NodeTypes.Program,
    body: [],
  }
  //老树和新树进行链接
  //ast.context === newAst.body
  //这样对旧树的操作就会反映到新树上
  ast.context = newAst.body

  traverser(ast, {
    CallExpression: {
      enter(node, parent) {
        //这里加if是为了类型收窄
        if (node.type === NodeTypes.CallExpression) {
          let expression: any = {
            type: NodeTypes.CallExpression,
            callee: {
              type: NodeTypes.Identifier,
              name: node.name,
            },
            arguments: [],
          }

          node.context = expression.arguments

          if (parent?.type !== NodeTypes.CallExpression) {
            expression = {
              type: NodeTypes.ExpressionStatement,
              expression,
            }
          }

          parent?.context?.push(expression)
        }
      },
    },

    NumberLiteral: {
      enter(node, parent) {
        if (node.type === NodeTypes.NumberLiteral) {
          const numberNode: any = {
            type: NodeTypes.NumberLiteral,
            value: node.value,
          }
          parent?.context?.push(numberNode)
        }
      },
    },
  })


  return newAst
}
  • 从代码中我们可以很清楚的了解到,首先创建新AST语法树的根节点
  • 然后我们在旧树的根节点下创建一个context数组,通过代码ast.context === newAst.body
  • 这样我们就可以实现通过对旧树的操作来同步到新树的根节点中的body中了
  • 接着我们调用我们之前写好的深度遍历,并将我们的访问者函数(对树的修改)传递进去
  • 这样我们就实现了对旧树做操作,然后返回一棵新的树
  • 到这里我们已经成功实现了我们目标代码所需要的AST树🌲了!

Code Generation

终于来到了最后一步---代码生成,这里我们将通过递🐢来将新的AST树转换成我们的目标代码

  • 我们在CodeGeneration文件夹中创建codegen.spec.tscodegen.ts
  • codegen.spec.ts
import { test, expect } from 'vitest'
import { codegen } from './codegen'

test('codegen', () => {
  const 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',
                },
              ],
            },
          ],
        },
      },
    ],
  }

  expect(codegen(ast)).toBe('add(2,subtract(4,2));')
})

通过测试用例,我们可以清楚了解到,我们要将AST语法树转换成目标代码

  • codegen.ts
export function codegen(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(codegen).join('')
    case 'ExpressionStatement':
      return codegen(node.expression) + ';'
    case 'CallExpression':
      return codegen(node.callee) + '(' + node.arguments.map(codegen).join(',') + ')'
    case 'Identifier':
      return node.name
    case 'NumberLiteral':
      return node.value + '' 
    default:
      throw new TypeError(node.type)
  }
}
  • 生成代码也非常的简单,通过判断语法树的type 来返回对应的符号和内容

编译

终于我们编译器的三个流程已经实现完毕,我们来看一下测试效果吧

  • 在根目录创建compiler.tscompiler.spec.ts
  • compiler.spec.ts
import { expect, test } from 'vitest'

import { compiler } from './compiler'

test('compiler', () => {
  const code = `(add 2 (subtract 4 2))`
  expect(compiler(code)).toBe('add(2,subtract(4,2));')
})
  • compiler.ts
import { tokenizer } from './Parsing/tokenizer'
import { parser } from './Parsing/parser'
import { transformer } from './Transformation/transformer'
import { codegen } from './CodeGeneration/codegen'
export function compiler(code: string) {
  // 1.词法分析器
  const tokens = tokenizer(code)
  // 2.语法分析器 将tokens转换为抽象语法树🌲
  const ast = parser(tokens)
  // 3.转换器 将抽象语法树🌲转换为新的抽象语法树🌲
  const newAst = transformer(ast)
  // 4.将抽象语法树🌲转换为可执行代码
  const output = codegen(newAst)
  return output
}

结果

image-20221230003354589.png

总结

本文通过编译器的概念和基本工作流程,包括词法分析器语法分析器遍历器转换器的基本实现,最后通过代码生成器,将各个阶段代码结合起来,实现了这个号称可能是有史以来最小的编译器。 对我个人而言,通过了这个项目使我了解了编译器是如何工作的,这也促使我下一阶段去学习webpack编译和babel以及其他框架源码的实现原理。