编译技术在前端的实践(一)—— 编译原理基础

web前端开发

序言

随着现代浏览器和前端领域的蓬勃发展,特别是 MVVM 框架的百花齐放,编译器在前端的应用越来越广泛。就日常工作而言,包括但不限于:

  • v8 引擎、tsc 工具(Typescript 编译器)
  • webpack(底层的编译器是 acorn)及各类工具(如 babel)
  • angular、vue 的模板编译器
  • vscode 等 IDE 或代码编辑组件(例如飞书的代码块)
  • antlr 生态

作为前端研发,没有必要对这些编译器或底层的编译原理有深入的理解。但如果能对这一朝夕相处的领域,有基本概念层的认知,并能熟练使用相关工具服务于生产生活,仍大有裨益。

因此,本系列分享,旨在传播面向前端研发的入门编译原理和技术,并介绍在包括 acorn 和 antlr 等在内的生态工具的基本原理和应用实战。需要指出的是,本人也只是一名对编译原理涉猎不深的前端研发,只能带领大家对这一领域进行浅尝辄止的了解。因此本系列分享可能会有错误的表达,还忘海涵。如果对这一领域有更多的兴趣,推荐购买专业教材进行系统性学习。

本次分享是此系列的第一篇,会介绍编译原理中最基本的概念,并通过编写一个表达式计算器来小试牛刀。

概述

我们先来从宏观角度看下编译这一行为。通常情况下,当你运行 tsc 命令,或者 gcc 命令时,都会触发下图所示的一个通用的流程。即,编译器将输入的源代码(字符串数据),结合当前编译器所约定的语言规则,进行分析和处理,并最终输出结果。

image.png

这里提到的“约定的语言规则”,教科书上称为“上下文无关文法(context free grammar,简称 CFG)”。简单理解就是各种编程语言的语法规则。比如对 tsc 而言就是 typescript 语法,对 babel 而言就是指定的 es 的某个版本(如 es6)语法,对 gcc 而言就是 c/c++ 语法。

经过“编译处理”后,不同的编译器会产出不同的“编译结果”。比如 tsc 会产出 js 代码,babel 会产出指定目标(如 es5)的 js 代码,而 gcc 则会产出指定平台(如 x86)上的二进制可执行文件。

整个“编译处理”过程,一般意义上通常分为两个大的阶段,“编译前端”和“编译后端”。这两个大的阶段,同时也是整个编译原理的两大理论研究方向。

image.png

从上图可以看到,编译前端主要就是编译器如何阅读和理解输入的过程。通常情况下,编译前端会产出一种用于给编译后端消费的中间产物,比如最常见的是一棵树型的数据结构,称为抽象语法树(Abstract Syntax Tree,简称 AST)。而编译后端,则是在前端解析的结果的基础上,进一步进行优化和转换并最终生成目标结果。

上图中,编译后端只画出了最简单的概念。在实际的编译原理中,编译后端是编译原理中更复杂和核心的模块,会涉及到包括但不限于堆栈管理、内存管理、垃圾回收、代码优化、指令流水线优化等诸多编程领域的核心理论。但因为此次分享只限于适用于前端的入门基础,因此不会对这一复杂的编译后端领域进行探讨。

接下来,我们相对深入地来看一下编译前端。

上下文无关文法

前面提到,编译器会根据“上下文无关文法(CFG)”来识别和理解输入。CFG 用于在理论上的形式化定义一门语言的语法,或者说,用于系统地描述程序设计语言的构造(比如表达式和语句)。我们假设一个极其简单的语言,这个语言只能像 js 那样声明整数型常量,以及声明不接受任何参数且只能直接返回常量加法的箭头函数。

const a = 10
const b = 20
const c = () => a + b
复制代码

这个语言的文法表达如下:

program :: statement+
statement :: declare | func
declare :: KEYWORD_CONST VARIABLE OPERATOR_EQUAL INTEGER
func :: KEYWORD_CONST OPERATOR_LEFT_BRACKET OPERATOR_RIGHT_BRACKET OPERATOR_EQUAL OPERATOR_RIGHT_ARROW expression
expression :: VARIABLE + VARIABLE


KEYWORD_CONST  :: "const"
OPERATOR_EQUAL :: "="
OPERATOR_LEFT_BRACKET   :: "("
OPERATOR_RIGHT_BRACKET  :: ")"
OPERATOR_RIGHT_ARROW    :: ">"
INTEGER :: \d+
VARIABLE :: \w[\w\d$_]*
复制代码

可以看出,整个文法的表达,涵盖了很多正则表达式的概念。该表达是一种自顶向下的规范,首先入口约束了程序(program)是由一条(及以上)的表达式(statement)构成,而表达式又可以由声明语句(declare)或函数语句(func)构成。声明语句依次由 const 关键字符号 =整数 从左到右排列构成。整数的定义则直接使用正则表达式来约束。函数语句也是类似。

大家可以观察到,上述的文法分成了上下两个大的部分。上半部分定义了语句以及由语句递归构造的表达,通常称为语法规则(grammar rules);下半部分定义了可通过排列构成语句的基本词汇,通常称为词法规则(lexer rules)。

大家可以试试,在满足这个文法约定的条件下,是否写出的代码一定是满足“声明整数型常量,以及不接受任何参数且只能直接返回常量加法的箭头函数”这一宏观的语法描述的。或者说,如果随手写出一些不满足这一宏观描述的代码,是否在往这个文法上靠齐时一定会出现无法匹配文法。通过这样的尝试,可以更深刻地理解上下文无关文法是如何形式化地表达一门语言的语法的。

在实践中,词法规则往往没有单独罗列,而是直接写入到语法规则中。比如上述文法可简化为:

program :: statement+
statement :: declare | func
declare :: "const" variable "=" integer
func :: "const" "(" ")" "=" ">" expression
expression :: variable + variable
variable :: \w[\w\d$_]*
integer  :: \d+
复制代码

编译器在编译时会自动感知到相同的字符串常量(比如 "const")并将其合并成同一个词法规则。
接下来,我们简单地窥视下 javascript 语言的文法真容。ECMAScript 2020 Language Specification (rbuckton.github.io)ECMAScript® 2022 Language Specification (tc39.es)

词法分析和 Token 流

有了上下文无关文法作为编译器的语法规则,接下来我们看看编译器是如何在文法的约束下,阅读和理解输入源码。在上文的编译前端的图中,我们看到编译器会首先将源码转换成 token 流。一般而言,token 是一个有类型(type)和值(value)的数据结构,而 token 流简单理解可以是 token 数组。比如前文给出的极简语言的那段代码,根据其文法中的词法规则,可以解析成如下的 token 流:

[
{ type: "KEYWORD_CONST", value: "const" }, { type: "VARIABLE", value: "a" },
{ type: "OPERATOR_EQUAL", value: "=" }, { type: "INTEGER", value: "10" }
...

]
复制代码

将源码在词法规则的约束下产生对应的 token 流的方法是一个相对复杂的话题。一般而言有两个方向:一种是编译器自身的代码实现逻辑中硬编码了这些词法规则;一种是利用工具将上文中的文法表达作为输入自动生成可词法规则对应的代码逻辑。在本文后续章节,会具体的介绍第一种方法,而第二种方法,会在本系列的后续分享中介绍。

语法分析和抽象语法树

词法分析产生的 token 流,经过语法分析的处理后,通常会输出为如下示例的抽象语法树结构:

{
  type: 'program',
  statements: [{
    type: 'declare',
    variable: 'a',
    value: 10,
  }, {
    type: 'declare',
    variable: 'b',
    value: 10
  }, {
    type: 'func',
    name: 'c',
    expression: {
      operator: '+',
      left: {
        type: 'variable',
        value: 'a'
      },
      right: {
        type: 'variable',
        type: 'b'
      }
    }
  }]
}
复制代码

有了上述的 AST 之后,可以通过包括深度或广度遍历等在内的方法,去进一步处理。比如执行代码、压缩代码、转成 es5 等。我们以执行代码为例:

image.png

在前端实践中,对 javascript 语言而言,不仅在文法层面有统一的 ecma 语言文法规范,抽象语法树也有一套约定的规范:GitHub - estree/estree: The ESTree Spec,社区称为 estree。借助这个约定的 AST 规范,整个前端社区,生产类工具统一产出该格式的数据结构而无需关心下游,消费类工具统一使用该格式进行处理而无需关心上游。

以我们最熟悉的 webpack 为例,它的底层是 acorn 工具。acorn 会把 js 源码转换成上述的标准的 estree,webpack 作为下游,消费该 estree,比如遍历,提取和分析 require/import 依赖,转换代码并输出。另一方面,自定义 webpack loader 除了可以生成 js 代码(像 css-loader 等本质就是把 css 代码转成 js 代码)外,更高级的用法是,作为上游,直接输出 estree 给 webpack。这样 webpack 内核就不再使用 arco 去二次 parse,从而提升性能。

我们可以在这个在线工具 AST explorer 中,贴入 js 代码,查看 estree 的数据结构。这个工具非常有用,当我们在写一些对 estree 进行处理的工具时,可以随时利用这个工具查看具体代码的 estree 构造,从而有针对性的编写遍历 estree 的代码。这里引申一点的是,对 typescript 而言也同样有在线查看 AST 的工具 TypeScript AST Viewer (ts-ast-viewer.com)

介绍完 AST 之后,自然会产生一个问题是,编译器是如何将 token 流,在文法规则的约束下,转换成 AST 的?

和前面提到的将源码转成 token 流一样,生成 AST 也主要是两大方向:一是将文法规则的约束硬编码到编译器的代码逻辑中,二是使用自动生成工具将文法规则直接转换成语法 parse 代码。前者是特定语言的编译器使用的常见方案,这种方案往往是人工编写 parse 代码,对输入源码的各种错误和异常可以更细致地报告和处理。比如前面提到的 arco,以及 tsc,babel,以及熟悉的 vue,angular 的模板编译器等,都主要是这种方法。而后者,更常用于非特定的编程语言,比如一些业务中自定义的简单但易变的语法,或仅仅只是字符串文本的复杂处理规则。

接下来会简单介绍第一种方法。而第二种方法会在本系列的后续分享中介绍。

递归下降技术

递归下降的编译技术,是业界最常使用的手写编译器实现编译前端的技术之一。限于时间关系,该技术底层的理论原理不深入介绍,感兴趣的同学推荐购买专业教材。此处,仅以一个简单的实例来带着大家感性地认知该技术。

我们知道,任何语言都需要支持常量的加减乘除表达式。此处,我们尝试实现一个整数的加减乘除(可带括号)表达式计算器。

先尝试使用上下文无关文法来定义该表达式的规则:

expr :: term "+" expr | term "-" expr | term
term :: factor "*" term | factor "/" term | factor
factor :: "(" expr ")" | int | "-" int
int :: \d+
复制代码

可以直观的感受到,这个文法的定义是有递归的概念的。比如 expr 可以由 term 构成,term 可以由 factor 构成,factor 可以又由 expr 构成,于是形成一种递归的环路。在实际的编程语言中,除了计算表达式外,也随处可见这种递归的概念。比如 js 中,函数 的 body 中,又可以由 函数 构成,层层嵌套。

采用这种递归的定义,严谨且简洁,可以用极少的表达去精确地描述一门语言的语法。这一类的文法表达,学术上称之为 BNF 范式。而递归下降技术,则是用和 BNF 范式一致的递归方式去从上到下地解析源码。

在实际中,编写和 BNF 范式一致的递归向下代码可能会遇到“歧义”和“左递归”的问题。歧义很好理解,解析到某个位置时,下一个字符同时满足两条可能的规则,编译器无法自动决策该走哪条路,是谓歧义。这种情况下,编译器就需要再向前看(look ahead)一个字符,总共使用接下来的两个字符来判断该解析哪条规则。但有可能向前看一个还不够,还得继续向前看。以此类推,需要向前看 k 个字符,才能消除歧义,则称为 LL(k) 文法。可以通过调整文法的规则来尽量减小 k,或者说尽量消除歧义。但对于某些语法,无法消除所有歧义(完全消除歧义则无法表达完整的语法规则)。通常的目标是做到,只要向前看一次,就能避免歧义,也就是做到 LL(1) 。

“左递归”的概念更复杂些,此处就不再讨论,简单的理解就是某些递归形式可能导致死循环,需要消除这种形式。对于 BNF 范式,有形式化的数学手段来消除左递归,此处也不介绍,直接给出消除左递归后,可用于指导编码的文法规则:

expr :: term expr_
expr_ :: "+" term | "-" term | eps
term :: factor term_
term_ :: "*" factor term_ | "/" factor term_ | eps
factor :: "(" expr ")" | int | "-" int
int :: \d+
复制代码

在上述 BNF 范式的指导下,我们很容易编写出与之一致的递归下降的解析代码:

/** 判断给定字符是否是数字 '0' - '9' */
function isDigit(chr: string) {
  return /\d/.test(chr);
}

class Calc {
  /** 表达式源码 */
  private readonly exprstr: string;
  /** 当前解析正在处理的源码位置 */
  private curIdx: number = -1;
  /** 对外暴露的静态函数 */
  static calculate(exprstr: string) {
    return new Calc(exprstr).expr();
  }
  private constructor(exprstr: string) {
    this.exprstr = exprstr;
  } 
  // 词法解析的相关函数
  /** 返回当前字符给编译器做分支决策 */
  private peekChr() {
    // 跳过空白符
    while(this.exprstr[this.curIdx + 1] === ' ') {
      this.curIdx++;
    }
    return this.exprstr[this.curIdx + 1];
  }
  /** 消费掉当前字符,并递增索引 */

  private eatChr() {
    this.curIdx++;
    return this.exprstr[this.curIdx];
  } 

  // 语法分析的相关函数,通常可以和文法规则一一对应
  private expr() {
    let value = this.term(); // expr:: term expr_
    while(true) {
      // 次处的 if else 就是向前看一个字符来推定有歧义的分支,所谓 ll(1)
      if (this.peekChr() === '+') { // expr_ :: "+" term | "-" term
        this.eatChr();
        const termReturn = this.term()
        value += termReturn;
      } else if (this.peekChr() === '-') {
        this.eatChr();
        value -= this.term();
      } else {
        break;
      }
    }
    return value;
  }

  private term() {
    let value = this.factor(); // term :: factor term_
    while(true) {
      if (this.peekChr() === '*') { // term_ :: "*" factor | "/" factor
        this.eatChr();
        value *= this.factor();
      } else if (this.peekChr() === '/') {
        this.eatChr();
        value /= this.factor(); // 注意,此处没有处理除数为 0
      } else {
        break;
      }
    }
    return value;
  }

  private factor() {
    let sign = 1;
    if (this.peekChr() == '-') {
      this.eatChr();
      sign = -1;
    }

    let value: number;
    if (isDigit(this.peekChr())) {
      value = sign * this.int();
    } else if (this.peekChr() == '(') { // factor :: 
      this.eatChr(); // 吃掉前括号,进入下一个字符
      value = sign * this.expr(); // 递归解析括号里的表达式
      this.eatChr(); // 吃掉后括号
    }
    return value;
  }

  private int() {
    let value = 0;
    // 如果当前解析到的字符是数字,则持续解析下一个字符直到数字结束
    while(isDigit(this.peekChr())) {
      const chr = this.eatChr();
      value = value * 10 + Number(chr);
    }
    return value;
  }
}

console.log(
  Calc.calculate("34 + 4 * (3 - 6)")
);
复制代码

总结&预告

本次分享从侧重感性认知的层面介绍了编译原理的基本概念。实践中,对前端而言,最有用的点其实很容易掌握,那就是理解 AST 的基本概念并能够使用相关工具服务于生产生活。如果大家有兴趣,本系列分享会进一步从实战的角度去介绍和前端相关的编译技术和生态工具。

文章分类
前端