IDE 中的魔法 - 编译原理通识

867 阅读10分钟

本文收录于专栏文章「IDE中的魔法」,希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,帮助大家在了解 IDE 的同时,也能更深入的了解语言本身。

前言

如果说程序员是现实世界中的魔法师的话,那么 IDE 可以算的上魔法师手中的法杖,代码高亮让代码变得更加易读,自动补全让我们不用去背诵类的属性方法,语法错误能够自动检测,lint 帮助不同的成员统一代码风格,还有调试,重构甚至于集成在 IDE 中的各种插件,我们已经很难想像使用文本编辑器来写代码的样子,IDE 就如同一叶沉默的小帆,始终在默默助力程序员去追求成为更好的自己。那么,对于这个与我们朝夕相处的伙伴,我们又有了解多少?

我将尝试通过系列文章,以 Typescript 为例,深入浅出介绍 IDE 涉及的相关技术,包括编译原理的介绍、AST 生成、错误容忍与恢复、作用域解析、类型推导及断点调试等等;希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,避免思维被语言所局限,帮助大家在更深入的了解 IDE 的同时,也能更深入的了解语言本身。

正如大学上课时,老师已经告诉我们这门课的前置课程有哪些前置课程,但是大部分课程仍然会抽出几个课时来帮助我们温习一下课程所需的前置知识一样。本文作为系列文章的第一篇,自然也是和所有读者对齐思想,统一战线,对一些编译器中的基础性概念进行科普,只浅不深。

How it works

如图,编译器的工作流程总体划分为图上的几个阶段,每个阶段有着各自的任务;通常的,从词法分析到中间代码生成的过程,被称作编译器前端,而从中间代码到机器码,则被称作编译器后端。前后端的划分在工程上有着重要的意义,因为编译器前端可以对接不同的语言,你只需要把它编译成约定的中间代码,而编译器后端则对接不同的运行环境,不同的 CPU 架构,后端的目标就是将约定的中间代码编译为指定环境下运行效率最优的机器码。而 IDE 涉及的静态分析都处于前端部分,毕竟我们分析的是用户所写的代码。下面我们就逐一介绍一下前端所做工作。

  • 词法分析

词法分析的任务是将程序的字符流转化为 token 序列,也就是编译器可以识别的词法单元,他会将相关的词法符号分类,例如 NUMBER 对应着数字,STRING 对应字符串这样,当语法分析中不关心的单个符号,只关心符号类型时,这样的归类是很有必要的。例如

let x: string = "test"
// tokenize 后,一种简单的可能的表达如下
LET IDENTITY : TYPE = STRING

对于词法的归类所带来的影响,可以看看之前前端很火的 redux 的模式,redux 在最初支持 typescript 时,遇到了很大的困难,我们可以尝试从编译器的视角来看一下这个问题。

function reducer(action: string, payload: A | B) {
    switch (action) {
        case 'a':
            return funA(payload as A)
        case 'b':
            return funB(payload as B)
    }
}

redux 典型的模式如上,传入一个字符串 action 和参数,然后调用不同的函数。action 不同,payload 类型不同,同时,函数的返回值也可能不同。如果我们传入 action "a",我们期望,ts 能够自动推导出 payload 的类型和 reducer 的返回值。但在实践中,这样写是不会成功的。究其原因,就在于从编译器的视角看,action 就是一个 string,而 'a'、'b' 这两字符串常量经过词法分析后可能就变成了一个叫做 STRING 的 token,在后续的分析中,编译器只会关心 case 后是一个 string,并不关心其他的。

虽然从各种角度看,似乎上述代码提供了足够的信息,可以让编译器去做出符合我们预期的推断,但是这种根据 action 运行时的值来处理多态,实际上属于我们自己的程序的一种约定(action 是 'a',payload 就是 A),这不是语法上的约束,编译器并不知道这种约定,也不会根据我们特定的约定去做额外的推断。而且,像这样通过运行时的一个变量的值的不同来处理另一个变量或函数返回值的多态,虽然在 js 这样的弱类型语言中看着没问题,在强类型的语言里面通常是一种 bad practice,因为它本质上是一种运行时的约定。

  • 语法分析

语法分析是在词法分析的基础上进行的。他根据语法规则,将 token 序列转化为抽象语法树(AST)。语法分析通常可以分为自顶向下和自底向上的语法分析,我们后续将会介绍的 ANTLR 采用的是递归向下(LL 语法)的分析方式。语法分析的过程不是这个系列文章的重点,这些都可以在编译原理的书上获取,不过后续我们仍会有所涉及。

在 IDE 所涉及的技术中,还有一个重要的议题就是错误处理与恢复。我们可以简单回忆,当我们在编写代码,并且敲下了 obj. 的时候,IDE 就会提示我们 obj 有哪些属性方法,但是,obj. 不是符合我们语法规则的输入,很明显我们去编译这段代码是过不了的,他会提示我们 unexpected token 之类的错误。那么在这种输入是语法不完整的情况下,我们是如何做到能够正常地给出提示的?

错误容忍与恢复(Error recovery)就可以解决上述问题,通常而言,在遇到无法完整匹配的 token 序列时,我们要求 parser 能够正常生成语法树,如果 AST 不能生成,后续的一切分析都不可能继续。parser 有各种策略去做错误容忍,包括删除当前不符合预期的 token,或者假装某些 token 是存在的,或者简单无视无法匹配的 token 序列,直到 parser 找到可以断定当前规约的语句结束的标识,parser 开始匹配下一条语句。

此外,Error recovery 要求我们处理不符合我们语法规则的输入,但这里就有一个问题,不符合我们语法规则的输入到底有多少?实际上,如果我们让一只猴子来敲键盘,他敲出来的大概率就是不符合我们的语法规则的输入。如果我们将任意输入(猴子敲键盘)当做可接受输入的集合的全集,那符合我们语法的输入只是其中一个很小很小的子集,剩余的绝大部分都是不符合的输入。

上述问题,在我个人看来,可以告诉我们,Error recovery 是没有银弹的,不能指望它能帮我们处理任意的输入错误,我们也只能在错误能够匹配某些规则和形式时,才能做出相应的处理。同时,对于任意的没有规则的输入,不仅是 parser,用户也不知道这样的输入可以分析出什么来,我的后续的 blog 中将会介绍的 ANTLR 能够很好地自动帮助完成大部分工作,同时也允许我们扩展它的错误处理策略。我还会额外抽一小部分篇幅,介绍一下 LR 语法的 parser generator(YACC/Bison)等如何做 Error recovery。

  • 语义分析

语义分析是对程序(AST)进行语义检查,确保结构上的正确并且没有语义错误,语义分析会是我们核心的部分,IDE 中的各种技术均属于语义分析。AST 是语义分析所需要的核心数据,不仅如此,中间代码的生成也会基于 AST。通常情况下,我们的自然语言是具有二义性的,为了消除二义性,文法定义要求必须是上下文无关的,否则,同样的代码,我们可以产生不同的理解,这会让整个晋西北全乱了。那么这里就会有个问题,既然上下文无关,我们能直接把 token 序列转化为中间代码吗?因为上下文无关,我们解析这段序列的时候可以不依赖其他的 token,那理论上我们可以不需要这一步呢?

为什么我们一定需要 AST,究其原因,虽然文法上的设计是上下文无关的,但是在语义上,大部分语言在一些情况下的语义不是上下文无关的,是需要 context 的,AST 就维护着语法信息和他的 context。最简单上下文有关的语义的例子就是 + 在数字和字符串拼接之间的不同语义:

"hello" + "world"
1 + 2

更复杂的例子诸如函数的重载,duck typing 等等,这些都是需要知道上下文才能完成的事情。而在 IDE 所涉及的静态分析中,则无时无刻都在与上下文打交道,变量定义在哪?函数是否可以接受这个参数?等等等等,不一而足,最后,再分享一个在 JS 中比较坑的上下文有关的语义(图一乐):

function test() {
    var x = 1;
    y = 2;
    ... 一大堆代码
    var y;
}

在 JS 中,通过关键字声明的变量会定义在当前的作用域下,但是,对一个未声明的变量直接赋值,这个变量会被定义到 global 下,但是(第二个但是),JS 存在一种将声明前置的语义,在当前作用域下,我们可以先赋值,再声明,这种情况下,变量被定义到当前作用域。上述代码中,有最后一行声明,y 被定义在 test 的函数作用域下,没有,y 被定义在全局。这里就应该能明显感受到这部分语义是多么上下文有关。

  • 中间代码生成

中间代码生成把 AST 转化为约定的中间代码,中间代码就是程序语法和语义的简单表达,它与运行环境,CPU 架构无关,将中间代码转化为机器码是后端的事情。中间代码的存在,就是为了解耦语言和运行环境,否则排列组合下来,不同的语言加上不同的运行环境,你需要做非常多类似的实现。

不过,中间代码不在我们后续讨论的范围内,毕竟我们主要是去分析用户代码,而不是执行用户代码,而且这部分的内容也看具体代码标准,我们只在这里简单介绍一下。

写在最后

IDE 中涉及的技术非常的多且庞杂,在准备要发这篇文章时,感觉到会有一个巨大的坑要填,不过如果能够顺利写完所有的内容的话,相信我自己也能从中取得长足的进步。如果我的文章存在什么错误,也希望大家能够帮助我修改完善。

参考链接