前端相关的计算机基础(四):编译原理

2,238 阅读8分钟

以下计算机系统层次结构的图,我们在前面已经见过多次了(在计算机组成原理中讨论了M0,M1,M3,在操作系统中讨论了M2),而我们平时编程所在的就在M4层,本文会讨论我们平时使用的高级语言是怎么被转换成机器语言被执行(可能会先编译成汇编语言),以及怎么不同高级语言之间怎么转换(比如ts转js、高版本js转低版本js),这个过程我们统称为编译(compile)

主要内容包括

  1. 编译理论基础:参考龙书引论部分
  2. 编译实践:参考The Super Tiny Compiler
  3. 对js编译器babel编译部分的详细介绍,并写一个babel插件

1 理论基础

程序设计语言是向人以及计算机描述计算过程的符号,在计算机运行编程设计语言之前需要将其翻译成能够被机器执行的机器语言,这个过程就是编译,完成编译的软件就叫做编译器(compiler)。

1.1 语言处理器

一个编译器输入一种语言(被称为源语言)编写的程序,并输出一个等价的、用另一种语言(目标语言)编写的程序。如果输出的是机器语言程序,就可以直接在计算机执行。
另一种语言处理器是解释器(interpreter),并不包含翻译过程,而是将源语言的程序边解释便执行。、

1.2 一个编译器的结构

一个完整的编译过程分为两个部分,analysis和synthesis部分,又被称为前端和后端。

其中前端包括词法分析、语法分析、语义分析、中间代码生成属于前端,代码生成和优化属于后端。
词法分析将源程序分解处理生成词法单元(token),语法分析在这些要素之上加上语法结构形成语法树(ast),然后通过语义分析的错误检查和中间代码生成,获得一个保存程序信息的符号表和中间代码(比如)交给后端处理。后端使用所得的信息生成目标机器能运行的机器语言并做相应优化。

当中间表示和符号表与高级语言无关时,就可以实现不同前后端模块的组合,比如gcc的实现,将各种高级语言解析成语法树,进一步转化成中间语言RTL,并进一步生成汇编语言(assembler code)乃至各种机器语言。

下面以赋值语句position=initial+rate*60编译成汇编语言为例,熟悉整个编译过程。

1.2.1 词法分析

词法分析(lexical analysis),又称扫描(scanning),读入源代码的字符流,组织成有意义的词素(lexeme)序列。在这个例子中词素指的是

  • position
  • =
  • initial
  • +
  • rate
  • *
  • 60 词法分析器用词素生成<token-name,attribute-value>格式的词法单元(token)其中token-name指的是语法分析时使用的抽象符号(比如identifier,简称id),attribute-value是对应的信息,比如名字和类型。此例中的token结果是<id:1><=><id,2><+><id,3><*><60>,对于等号=等其token-name为本身,第二项忽略。

1.2.2 语法分析

语法分析(syntax analysis),又称解析(parsing),语法分析器使用各个token中的第一个分量创建树形的中间表示,其中一种表示方式就是语法树,其中树的每个内部节点表示一个运算,该节点的子节点表示该运算的分量,比如本例中

1.2.3 语义分析

语义分析(semantic analyzer)使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致,同时收集类型信息,并把这些信息存放在语法树或符号表中,以便在随后生成中间代码生成过程中使用。
语义分析另一个部分是类型检查,以及可能的类型转换,在本例中词素60被转化为浮点数

1.2.4 中间代码生成

前面的语法树就可以看成是中间代码的一种,在经过语法分析和语义分析后会生成一个易被翻译成机器语言的中间代码表示,比如本例中的三地址代码(three address code),这种中间表示由一组类似于汇编语言的指令组成

1.2.5 代码生成

输出最终的目标语言,在输出之前进行必要的优化,比如直接用浮点值60.0表示,并去掉不必要的变量t3,本例中优化成

输出最终的汇编语言

1.3 程序设计语言的发展历程

第一代电子计算机出现在20世纪40年代,使用0、1序列编程,这个序列明确告诉计算机以什么顺序执行哪些运算,即将数据从一个位置移动到另一个位置,将不同寄存器的值进行运算等,这就是第一代编程语言,机器语言。
为了更友好的编程,20世纪50年代推出了第二代编程语言汇编语言,一开始汇编语言的指令只是机器语言的助记表示,后来加入宏指令,简化了其中的编程过程。

再后来c语言等的高级语言使程序员更容易地开发程序,这是第三代编程语言

第四代编程语言是为特定应用设计的语言,比如用于数据库查询的sql

第五代是基于逻辑和约束的语言,比如prolog

程序设计语言的发展同时也对编译器的设计者提出了新的要求。

2 编译实践

在了解了编译的相关理论基础以后,我们使用js实现一个简单的编译器,在这个编译器中我们将编译简化为三个步骤

  • Parsing 解析,将源程序代码解析为抽象语法树,在这里抽象语法树作为中间表示形式
  • Transformation 转换,将抽象语法树转换以达到当前编译器想要实现的目的所需的表示
  • Code Generation 将转换后的代码生成目的代码

在这个过程中抽象语法树就是一个包含代码各种信息的嵌套对象,考虑到原文已经介绍的很详细了,请自取。

3 编译器babel

3.1 基本介绍

babel是一个js编译器,按照前一章介绍的三个步骤进行编译,可以用来进行以下操作

  • 将最新版的js语法编译成当前浏览器支持的语法
  • 完成react中必要的编译工作,比如编译jsx语法
  • 去除ts或flow等的类型注释和相关语法转换
  • 利用插件进行的其他自定义编译

babel本身存在的意义是让我们更方便的使用下一代js语法,而除了编译语法以外,还提供了其他功能,比如polyfill。关于babel的具体使用和其他功能请参考我的这篇文章,本文只介绍编译相关部分。

babel本身是一个工具链,具体完成编译由对应插件完成。一个不带有插件的babel执行的操作类似于const babel =code=>code,即什么也不做,直接输出原来的代码。

我们可以利用插件完成两部分工作,一个是指导特定语法的解析,这种插件被称为Syntax Plugins,比如jsx,另一种插件用于解析后的中间表示代码的转换,这类插件被称为Transform plugins,比如将箭头函数的语法转换成es5的语法。

为了实现以上功能,babel提供了一系列packages,其中包括核心包4个

  • @babel/core 依赖后面三个,对外提供封装好的transform接口方便我们直接调用
  • @babel/parser 用来解析原码,babel使用改进后的ESTree规则,即ast-spec,来将js源码解析为ast,如果需要解析另外的语法,比如jsx,需要对应的语法插件。
  • @babel/traverse 用来遍历ast进行必要的操作,比如对ast节点进行增删改
  • @babel/generator 按照转换后的ast生成目标代码

还有辅助的package

  • @babel/types 用来验证、创建和修改ast节点

3.2 写插件前的预备知识

这部分及下部分参考Babel Plugin Handbook,这里只介绍入门的插件知识,更为复杂的操作自行阅读链接内容。

3.2.1 ASTs

ast即抽象语法树,就是个树类型的数据结构,每个节点都是标准中定义的一种节点类型,每个数据类型都实现了接口

interface Node {
  type: string;
  loc: SourceLocation | null;
}

本文涉及到的节点类型包括

  • Identifier 标识符,继承了Expression(表达式), Pattern(解构模式)
interface Identifier <: Expression, Pattern {
  type: "Identifier";
  name: string;
}
  • FunctionDeclaration 函数声明,继承了Function(函数), Declaration(声明)
interface FunctionDeclaration <: Function, Declaration {
  type: "FunctionDeclaration";
  id: Identifier;
}
  • BlockStatement 块语句,就是用花括号包起来的语句
interface BlockStatement <: Statement {
  type: "BlockStatement";
  body: [ Statement ];
  directives: [ Directive ];
}
  • ReturnStatement return语句
interface ReturnStatement <: Statement {
  type: "ReturnStatement";
  argument: Expression | null;
}
  • BinaryExpression 二元表达式
interface BinaryExpression <: Expression {
  type: "BinaryExpression";
  operator: BinaryOperator;
  left: Expression;
  right: Expression;
}

当我们想知道一段js代码的ast时可以直接通过AST Explorer查看(也可以看非js的ast)。

3.2.2 babel的transform

babel三个步骤解析、转换和生成中最复杂的是转换步骤,这也是插件主要起作用的的地方,即通过遍历ast,一边走一边增加、移除或更新节点。
当我们处理一个节点时,我们称在访问它们,执行访问的对象叫visitor(可以参考设计模式中的访问者模式).

  1. visitor

visitor是一个对象,其中包含各种节点类型为key,对该类型节点的操作为value的键值对,这个value可以是一个函数,也可以是一个带有enter、exit两个字段的对象。比如

const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};
//或
const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
};

处理函数带有一个参数path,是一个表示两个节点连接的响应式对象,其中包含了对应节点的相关信息和对节点操作的方法,包括移除、增加、移动和更新等,具体api参考Transformation Operations
下面我们会用的是

  • path.findParent 返回父path
  1. types 对于节点的细节操作,path需要借助type来实现,包括类型验证、创建和修改节点,比如
  • t.identifier() 创建一个标识符
  • t.isIdentifier() 对应节点是否是个标识符
  • t.isCallExpression() 是不是callExpression

3.3 写插件

在了解了babel的基本实现逻辑后,我们便可以利用当前工具实现一些自定义的插件完成我们想要的功能,即写一个Transform plugins,这部分还参考了这篇文章

一个插件是一个函数,对节点的访问主要在visitor部分,格式为

export default function(babel) {
  return {
    // 必需,配合traverse使用的visitor对象
    visitor: {},
    // 可选,继承其它插件,比如识别JSX、async function等语法
    inherits: OtherPlugin,
    // 可选,插件执行前,初始化状态,如cache
    pre(state) {},
    // 可选,插件执行后,收尾清理工作
    post(state) {}
  }
}

比如我们要实现一个删除console相关方法的插件


export default function(babel) {
  return {
    visitor: {
      Identifier(path){
      if (path.node.name === 'console') {
        path.findParent(p => p.isCallExpression()).remove();;
 	   }
      },
    }
  }
}

验证时可以在AST Explorer直接运行, 也可以作为插件在项目中配置


完结撒花