3.5 静态类型检查工具的实现原理

代码检查是一种静态分析的方法,用于寻找有问题的模式或代码。代码检查工具极大地提高了开发者代码评审的效率,并有效减少了工作量,在享受它带来的便利性的同时,我们也应该思考它是如何实现检查功能的。如果你使用过Node.js,就不难想到通过fs模块也可以将整个文件以字符串的形式读取到我们的程序中,那么拿到字符串文本之后,又该如何分析呢?事实上,这个问题会引出前端领域一个非常重要,对于初级开发者而言却非常陌生的概念——编译,它不仅仅是ESLint的基础,包括大名鼎鼎的Babel、webpack以及你每天都在用的Vue、Angular、React框架都离不开这个重要的知识点。

3.5.1 编译语言和解释语言

编程语言可分为编译语言和解释语言。无论哪种语言,在最终执行前都会被翻译成机器能够识别的机器码,但编译语言和解释语言被翻译的时机是不同的。举例来说,编译语言就像是先做好一桌菜再开吃,而解释语言则更像是吃火锅,边煮边吃,所以也就不难理解为什么解释语言在运行时效率更低了。

编译语言在编写完成后并不能直接使用,而是需要先将其编译为计算机可以识别的机器码,这样计算机才能够运行高级语言所实现的功能。由于其提前完成了翻译工作,所以执行的时候速度更快,但缺点也是显而易见的,因为不同的平台能够识别的机器码并不相同,所需要的编译器也不一样。所以,高级语言会使用一种被称为“字节码”的技术将高级语言所编写的程序编译为虚拟机能够识别的中间状态的二进制编码,而将跨平台的兼容性放在虚拟机中来实现,从而兼顾编程语言的跨平台特性和运行效率。而解释语言则会在执行虚拟机中一边翻译一边执行,也就是我们常说的即时编译(Just-In-Time Compilation),不难理解其优缺点与编译语言正好是对立的。JavaScript就是一种解释型语言。

3.5.2 编译流程

传统编译型语言的编译过程大致需要经过如下几个典型阶段。

1. 分词分析(Lexical Analysis)阶段

编译器将字符串序列分割成若干个具有一定意义的字符串单元,也称为词法单元(token),分词所依赖的策略会依据不同语言的特点来制定,其结果一般会以数组的形式标记出每个词法单元的类型和原始字符串,比如,某个编译器可能会使用identifier(标识符)、number(数值)、operator(操作符)、punctuation(标点符号)等来标识词法单元的类型。

2. 语法分析(Syntactic Analysis)阶段

语法分析是在词法分析的基础上进行的,它会尝试将词法单元组合成为符合一定语法规范的语句,如果词法单元的序列无法拼接成合法的语法,就说明源程序出现了语法错误。比如在JavaScript语法中,一个标识符加上一个等号,再加上一个数值或者另一个标识符,就可以组成一个赋值语句。语法分析转换后的形式一般称为“抽象语法树”(Abstract Syntax Tree,AST)。

3. 遍历分析(Traversal Analysis)阶段

遍历分析是在抽象语法树的基础上进行的,其依据一个自定义的策略集合(可能是语法转换策略,也可能是针对抽象语法树中某些特定类型节点的检查或优化策略)对相应的部分进行操作,我们可以对抽象语法树中的节点进行增删改查操作(如果你已经掌握了一些基本的数据结构和算法知识,就不难意识到,抽象语法树的本质就是树,所有对于树型结构的抽象理论和运算都可以用于抽象语法树)。

4. 代码生成(Code Generation)阶段

在这个阶段中,编译器会将抽象语法树转换为可执行代码,或者一组机器指令,这个过程与使用的平台密切相关,当你在编译参数中指定不同的适用系统时,最终生成的结果通常也不相同。这就好像是我们熟悉的React,在架构设计中就引入了独立的渲染层,引入不同的渲染器模块可以将同样的应用层代码渲染到不同的平台中。同理,当我们使用不同的代码生成器时,理论上也可以将同一个抽象语法树结构转换为其他语言的代码。

3.5.3 编译简单的JavaScript程序

本节将通过一段简单的示例代码来展示编译过程,它能够帮助我们更直观地理解编译器的工作:

/*源代码*/
var a = 1;
/**
* 1. 词法分析:
* type-词法类型,
* value-原始字符串,
* start-词法单元开始位置,
* end-词法单元结束位置
*/
[
    {type:'Keyword', value:'var', start:0, end:3},
    {type:'Identifier', value:'a', start:4, end:5},
    {type:'Punctuator', value:'=', start:6, end:7},
    {type:'Numeric', value:'1', start:8, end:9},
    {type:'Punctuator', value:';', start:9, end:10}
]
/**
* 2. 语法分析:
* type:'Program'程序段,
* type:'VariableDeclaration'-变量声明语句,
* type:'VariableDeclarator'-变量声明表达式,
* type:'Identifier'-标识符,
* type:'Literal'-字面量
*/
{
    "type": "Program",
    "start": 0,
    "end": 10,
    "body": [
        {
            "type": "VariableDeclaration",
            "start": 0,
            "end": 10,
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "start": 4,
                    "end": 9,
                    "id": {
                        "type": "Identifier",
                        "start": 4,
                        "end": 5,
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "start": 8,
                        "end": 9,
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "module"
}

看到抽象语法树的“庐山真面目”后,代码静态类型检查的实现思路就变得很清晰了,下面试着用伪代码实现一些常见的静态类型检查项目:

//1. 声明变量时必须为其赋初始值
if (node.type === 'VariableDeclarator' && node.init === null){
    console.log('变量必须初始化');
}
//2. 每一个变量声明语句只能声明一个变量
if (node.type === 'VariableDeclaration' && node.declarations.length > 1){
    console.log('存在声明语句声明了多个变量的情况');
}
//3. 标识符指向对象类型时,变量需要用关键字const进行声明
if (node.type === 'VariableDeclaration'){
    node.declarations.map(declarator=>{
        if (declarator.init
            && declarator.init.type === 'ObjectExpression'
            && node.kind !== 'const'){
            console.log('标识符指向对象时需要使用const进行声明');
        }
    });
}

可能有读者已经察觉到,对于编译过程来说,代码本身就是数据。上面的过程只演示了抽象语法树分析工作的冰山一角,我们还需要学习一些树结构遍历的基本算法,才能对抽象语法树的遍历分析有更好的理解。至此,编译工作还差最后一步,那就是生产代码。这个环节比较神奇,抽象语法树实际上是一个关键信息的聚合结构,换句话说,它与语言并不是强耦合的。对于抽象语法树里的每一种语法类型,编译器都会有对应的代码字符串生成策略(当然,有的策略是依赖于配置参数的),假如我们的编译目标是“中文”或“机器码”,那么最终产生的代码可能会是下面这个样子:

//假设从'VariableDeclarator'节点开始分析
/**
* 编译成中文
* 生成策略:`变量声明 ${node.id.name} 初始化赋值为 ${node.init.value} ;`
* 输出结果: 变量声明 a 初始化赋值 为 1 ;
*/
/**
* 编译成机器码
* 生成策略:(略)
* 输出结果: 01001001 00101010......
*/

生成了代码之后,只需要将它写到编译结果目录下的指定文件中即可,这个步骤通常称为“emit”。至此,我们完成了一段简易的JavaScript代码的编译工作。你可以在astexplorer.net网站上方便地将JavaScript代码转换为抽象语法树,从而了解其他语法的转换结果。

拓展知识

掌握编译原理是初级前端开发者进阶非常重要的知识储备,前端领域的所有重要技术几乎都与它有关,但其学习难度也非常大。如果你对相关的内容感兴趣,可以从下面几个项目着手学习。

(1)The-Super-Tiny-Compiler[1]

这个项目在GitHub上有2.1万颗星星,它实现了一个极简却“五脏俱全”的编译器,代码中包含非常丰富的注释和知识讲解,建议反复观摩源代码来学习,直到能够自行实现或默写。

(2)Espree

它是著名的JavaScript代码编译工具,也是ESLint使用的解析器,可以调用它提供的方法来查看一些典型的代码分词结果和抽象语法树。

(3)Acorn

大名鼎鼎的Babel在版本升级中基于Acorn解析器定制了自己的@babel/parser,如果你对抽象语法树的解析效率感兴趣,可以自行研究、对比不同的解析器。

(4)ASTexplorer

在线AST转换工具,可以实时地将JavaScript代码转换成为抽象语法树,当开发者编写ESLint插件或是Babel插件等任何依赖于AST转换的工具时,经常会使用它来查看目标节点的类型。

(5)“编译原理”公开课

如果时间和精力都允许,可以尝试观看斯坦福大学的“编译原理”公开课,以便系统地学习编译相关知识,当然大多数前端工程师可能并没有机会使用到这么深入的知识,斯坦福大学的官方线上学习平台[2]或B站[3]上都可以找到相关视频(B站有中文字幕)。


[1]源代码仓库地址:https://github.com/jamiebuilds/the-super-tiny-compiler

[2]online.stanford.edu/lagunita-learning-platform斯坦福大学官方在线学习平台,其中包含了大量计算机相关课程。

[3]bilibili.com,知名视频网站。