写给前端的编译原理科普

昊昊是一个前端工程师,最近涉及到工程化领域,想了解一些编译的知识。刚好我比他研究的早一些,因此把我了解的东西给他介绍了一遍,因而就有了下面的对话。javascript

什么是编译啊?

昊昊: 最近想了解一些编译的东西,光哥,编译究竟是什么啊?css

: 编译啊就是一种转换技术,从一门编程语言到另外一门编程语言,从高级语言转换成低级语言,或者从高级语言到高级语言,这样的转换技术。html

昊昊: 什么是高级语言,什么是低级语言啊?前端

低级语言是与机器有关的,涉及到寄存器、cpu指令等,特别“低”,描述具体在机器上的执行过程,好比机器语言、汇编语言、字节码等。高级语言则没有这些具体执行的东西,主要用来表达逻辑,并且提供了条件、循环、函数、面向对象等特性来组织逻辑,而后经过编译来把这些描述好的高级语言逻辑自动转换为低级语言的指令,这样既可以方便的表达逻辑,又不影响具体执行。说不影响执行也不太对,由于若是直接写汇编,能写出效率最高的代码,可是若是是高级语言经过编译来自动转换为低级语言,那么就难以保证生成代码的执行效率了,须要各类编译优化,这是编译领域的难点。vue

其实想一想,咱们把脑中的想法,把制订好的方案转换为高级语言代码,这个过程是否是也是转换,可不能够自动化呢,这就涉及到ai了。如今有理解需求文档生成代码的智能化技术的研究方向。java

image.png

昊昊: 那具体是怎么转换的呢?python

: 要转换首先得了解转换的双方,要转换的是什么,转换到什么。好比高级语言到高级语言,要转换的是字符串,按照必定的格式组织的,这些格式分别叫作词法、语法,总体叫作文法,那要转换的目标呢,目标若是也是高级语言那么要了解目标语言的格式,若是目标是低级语言,好比汇编,那要了解每条指令时干啥的。而后就要进行语义等价的转换,注意这个“语义等价”,经过一门语言解释另外一门语言,不能丢失或者添加一些语义,必定要先后一致才能够。react

知道了转换的双方都是什么,就能够进行转换了,首先得让计算机理解要转换的东西,什么叫“计算机理解“呢?就是把咱们规定的那些词法、语法格式告诉计算机,怎么告诉呢?就是数据结构,要按照必定的数据结构把源码字符串解析后的结果组织起来,计算机就能处理了。 这个过程叫作 parse,要先分词,再构形成语法树。linux

其实不仅是编译领域须要“理解”,颇有不少别的领域也要“理解”:webpack

全文搜索引擎也要先把搜索的字符串经过分词器分词,而后根据这些词去用一样分词器分词并作好索引的数据库中去查,对词的匹配结果进行打分排序,这样就是全文搜索。

人工智能领域要处理的是天然语言,他也要按照词法、语法、句法等等去“理解”,变成必定的数据结构以后,计算机才懂才能处理,而后就是各类处理算法的介入了。

分词是按照状态机来分的(有限状态机 DFA),这个是干啥的,为啥分词须要它,我知道你确定有疑问。 由于词法描述的是最小的单词的格式,好比标识符不能以数字开头,而后后面加字母数字下划线等,这种,还有关键字 if、while、continue 等,这些不能再细分了,再细分没意义啊。分词就是把字符串变成一个个的最小单元的不能再拆的单词,也叫 token,由于不一样的单词格式不一样,总不能写if else来处理不一样的格式吧。其实还真能够,wenyan 就是if else,吐槽一下。可是当有100中单词的格式要处理,所有写成if else,个人天,那代码还能看么。因此要把每一个单词的处理过程当成一种状态,处理到不一样的单词格式就跳到不一样的状态,跳转的方式天然是根据当前处理的字符来的,处理一个字符串从开始状态流转到不一样的状态来处理,这样就是状态自动机,每一个token识别完了就能够抛出来,最终产出的就是一个token数组。

其实状态也不仅一级的,你想一想好比一个 html 标签的开始标签,能够做为一个状态来处理,但这个状态内部又要处理属性、开始标签等,这就是二级状态,属性又能够再细分几个状态来处理,这是三级状态,这是分治的思想,一层层的处理。

分词以后咱们拿到了一个个的单词,以后要把这些单词进行组装,生成 ast,为啥必定要ast呢?我知道你确定想问。其实高级语言的代码都是嵌套的,你看低级语言好比汇编,就是一条条指令,线性的结构,可是高级语言呢,有函数、if、else、while等各类块,块之间又能够嵌套。因此天然要组织成一棵树形数据结构让计算机理解,就是Abtract Syntaxt Tree,语法树、并且是抽象的,也就是忽略了一些没有含义的分隔符,好比html的<、>、</等字符,js的{ }() [] ;就是细节,不须要关心,注释也会忽略掉,注释只是分词会分出来,可是不放到ast里面。

怎么组装呢,仍是嵌套的组装,那是否是要递归组装,是的,你想的没错须要递归,不仅是这里的ast组装须要递归,后面的处理也不少递归,除非到了线性的代码的阶段,就像汇编那样,你递归啥,没嵌套的结构能够递归了。

词法咱们刚才分析了,就是一个个的字符串格式,语法呢,是组装格式,是单词之间的组合方式。这也是为啥咱们刚刚要先分词了,要是直接从字符串来组装ast,那么处理的是字符串级别,而从token开始是单词级别, 这就像让你用积木造个城堡,可是积木也要你本身用泥巴造,那你怎么造呢,能够先把一个个积木造好,而后再去组装成城堡,也能够边造积木边组装。不太小汽车的话你能够边制做积木,边组装,城堡级别的边作积木边组装你能理清要造啥积木么,就很难,因此仍是要看状况。用这两种方式来作parser的都有,简单的能够边词法分析,分析出热乎乎的单词而后立刻组装到ast中, 好比html、css这种,可是像js、c++这种,若是不先分词,直接从字符串开始造ast,我只能说太生猛了。

说了半天积木和组装,那么怎么组装呢,从左到右的处理token,遇到一个token怎么知道他是啥语法呢,这就像怎么知道一块积木是属于那个部件的。也有两种思路,一种是你先肯定这个积木是属于那个部件,而后找到那个部件的图纸,按照图纸来组装,另外一种是你先组装,组装完了再看看这个是啥部件。这就是两种方式,先根据一两个积木肯定是哪一个部件,再按照图纸组装这个部件,这种是 ll 的方式,先组装,组装完了看看是啥部件,这种是 lr 的方式。ll的方式要肯定组装的是啥ast节点要往下看几个,根据要看几个来肯定组装的是什么就分别是LL(1),LL(2)等算法。ll也就是递归降低,这是最简单的组装方式,固然有人以为lr的方式也挺简单。ll有个问题还必须得用lr解决,那就是递归降低遇到了左边一直往下递归不到头的状况,要消除左递归,也就是你按照图纸来组装搞不定的时候,就先组装再看看组装出来的是啥吧。 这其实和人生挺像的,一种方式是往下看两步而后决定当前怎么走,另外一种方式是先走,走到哪步再说。其实我就属于第二种,没啥计划性。

通过词法、语法分析以后就产生了ast。用一棵树形的数据结构来描述源代码,从这里开始就是计算机能够理解的了,后续能够解释执行、能够编译转换。无论是解释仍是编译都须要先parse,也就是要先让计算机理解他是什么,而后再决定怎么处理。

后面把树形的ast转换为另外一个ast,而后再打印成目标代码的字符串,这是转译器,把ast解释执行或者专成线性的中间代码再解释执行,这是解释器,把ast转成线性中间代码,而后生成汇编代码,以后作汇编和连接,生成机器码,这是编译器。

编译器是咋处理AST的?

昊昊: 光哥,那编译器是怎么处理ast的啊?

: 有了ast以后,计算机就能理解高级语言代码了,可是编译器要产生低级语言,好比汇编代码,直接从ast开始距离比较远。由于一个是嵌套的、树形的,一个是线性的、顺序的,因此啊,须要先转成一种线性的代码,再生成低级代码。我以为ast也能够算一种树形IR,IR是immediate representation中间表示的意思。要先把AST转成线性IR,而后再生成汇编、字节码等。

image.png

咋翻译,树形的结构咋变成线性的呢? 明显要递归啊,按照语法结构递归ast,进行每一个节点的翻译,这叫作语法制导翻译,用线性IR中的指令来翻译AST节点的属性。每一个节点的翻译方式,if咋翻译、while咋翻译等能够去看下相关资料,搜中间代码生成就行了。

可是ast不能上来就转中间代码。

昊昊: 为啥,ast不就能表示源码信息了么,为啥不能直接翻译成线性ir?

: 由于还没作语义检查啊,结构对不必定意思对,就像“昊昊是只猪”,这个符合语法吧,可是语义明显不对啊,这不是骂人么,因此要先作语义检查。还有就是要推导出一些信息来,才能作后续的翻译。

语义分析要检查出语义的错误,好比类型是否匹配、引用的变量是否存在、break是否在while中等,主要要作做用域分析引用消解类型推导和检查正确性检查等。

做用域分析就是分析函数、块等,这些做用域内的变量都有啥,做用域之间的联系是怎样的,其实做用域是一棵树,从顶层做用域到子做用域能够生成一个树形数据结构。我记得有个作scope分析的webpack插件,他是把模块也给连接起来了,造成了一个大的 scope graph,而后作分析。

做用域中有各类声明,要把它们的类型、初始值、访问修饰符等信息记录下来,保存这个信息的结构叫符号表,这至关因而一个缓存,以后处理这个符号的时候直接去查符号表就行,不用再次从ast来找。

引用消解呢就是对每一个符号检查下是否都能查找到定义,若是查找不到就报错。类型方面你比较熟,js的源码中确定不可能都写类型,不少地方能够直接推导出来,根据ast能够得出类型的声明,记录到符号表中,以后遍历ast,对各类节点取出声明时的类型来进行检查,不一致就报错。还有其余一些琐碎的检查,好比continue、break只能出如今while中等等一些检查。

昊昊: 语义分析我懂了,就是检查错误和记录一些分析出的信息到符号表,那语义分析以后呢?

语义分析以后就表明着程序已经没有语法和语义的错误了,能够放心进行各类后续转换,不会再有开发者的错误。以后先翻译成线性IR,而后对线性IR进行优化,须要优化就是由于自动生成的代码不免有不少冗余,须要把各类不必的处理去掉。可是要保证语义不变。好比死代码删除、公共子表达式删除、常量传播等等。

线性IR的分析要创建流图,就是控制流图,控制流就是根据if、while、函数调用等致使的程序跳转,把顺序执行的代码和跳转到的代码之间链接起来就是一个图,顺序执行的代码当作一个总体,叫作基本快。以后根据这个流图作数据流分析,也就是分析一个变量流经了那些代码,而后基于这些作各类优化。

这个部分叫作程序分析,或者静态分析,是一个专门的方向,能够用于代码漏洞的静态检查,能够用于编译优化,这个是比较难的。研究这个的博士都比较少。国内只有北大和南大开设程序分析课程。

优化以后的线性IR就能够生成汇编代码了,而后经过汇编器转成机器码,再连接一些标准库,好比v8目录下能够看到builtins目录,这里就是各类编译好的机器码文件,能够静态连接成一个可执行文件。

昊昊: 哦,感受汇编和连接这两步前端接触不到啊。

: 对的,由于js是解释型语言,直接从源码解释执行,不要说js了,java的字节码也不须要静态连接。像c、c++这些生成可执行文件的才须要经过汇编器把代码专成机器码而后连接成一个文件。并且若是目标平台有这些库,那么不须要静态连接到一块儿,能够动态连接。你可能听过.dll和.so这就分别是windows和linux的用于运行时动态加载的保存机器码的文件。

你说的没错,前端领域基本不须要汇编和连接,就算是wasm,也是生成wasm 字节码,以后解释执行。前端主要仍是转译器。

转译器是咋处理AST的?

昊昊: 那转译器在ast以后又作了哪些处理呢?

: 转译器的目标代码也是高级语言,也是嵌套的结构,因此从高级语言到高级语言是从树形结构到树形结构,不像翻译成低级的指令方式组织的语言,还得先翻译成线性IR,高级到高级语言的转换,只须要ast,对ast作各类转换以后,就能够作代码生成了。

昊昊: 我说呢,我就没据说babel中有线性IR的概念。

: 对的,无论是跨语言的转换,好比 ts 转 rust,仍是同语言的转换js转js都不须要线性结构,两棵树的转换要啥线性中间代码啊。 因此通常转译器都是 parsetransformgenerate 这3个阶段。

image.png

parse 广义上来讲包含词法、语法和语义的分析,狭义的parse单指语法分析。这个没必要纠结。

transform 就是对ast的增删改,以后generator再把ast打印成字符串,咱们解析ast的时候把[]{} () 等分隔符去掉了,generate的时候再把细节加回来。

其实前端领域主要仍是转译器,由于主流js引擎执行的是源代码,可是这个源代码和咱们写的源代码还不太同样,因此前端不少源码到源码的转译器来作这种转换,好比babel、typescript、terser、eslint、postcss、prettier等。

babel 是把高版本es代码转成低版本的,而且注入polyfill。typescript是类型检查和转成js代码。eslint是根据规范检查,但--fix也能够生成修复后的代码。prettier也是用于格式化代码的,比eslint处理的更多,不仅限于js。postcss主要是处理css的,posthtml用于处理html。相信你也用过不少了。taro这种小程序转译器就是基于babel封装的。

解释器是咋处理AST的?

昊昊: 哦,光哥,我大概知道编译器和转译器都对ast作了啥处理了,这俩都是生成代码的,那解释器呢?

: 对,首先转译器也是编译器的一种,只不过比较特殊,叫作 transpiler,通常的编译器叫作compiler。 解释器和编译器的区别确实是是否生成代码,提早编译成机器代码的叫作 AOT 编译器,运行时编译成机器代码的叫作 JIT 编译器,

解释器并不生成机器代码,那它是怎么执行的呢?知道你确定有疑问。

其实解释器是用一门高级语言来解释另外一门高级语言,好比c++,通常都用c++来写解释器,由于能够作内存管理。用c++来写js解释器,像v八、spidermonkey等都是。咱们在有了ast而且作完语义分析以后就能够遍历ast,而后用c++来执行不一样的节点了,这种叫作tree walker解释器,直接解释执行ast,v8引擎在17年以前都是这么干的。可是在17年以后引入了字节码,由于字节码能够缓存啊,这样下次再直接执行字节码就不须要parse了。字节码是种线性结构,也要作ast到线性ir的转换,以后在vm上执行字节码。

通常解释线性代码的好比汇编代码、字节码等这种的程序才叫作虚拟机,由于机器代码就是线性的,其实从ast开始就能够解释了,可是却不叫vm,我以为就是由于这个,和机器码比较像的线性代码的解释器才叫 vm。

无论是解释 ast 也好,仍是转成字节码再解释也好,效率都不会特别高,由于是用别的高级语言来执行当前语言的代码,因此要提升效率仍是得编译成机器代码,这种运行时编译就是JIT编译器,编译是耗时的,因此也不是啥代码都JIT,要作热度的统计,到达了阈值才会作JIT。而后把机器码缓存下来,固然也多是缓存的汇编代码,用到的时候再用汇编器转成机器码,由于机器代码占的空间比较大。

能够对比v8来理解,v8 有parser、ignation解释器、turbofan编译器,还有gc。

ignation解释器就是把parse出的ast转成字节码,而后解释执行字节码,热度到达阈值以后会交给turbofan编译为汇编代码以后生成机器代码,来加速。gc是独立的作内存管理的。

turbofan是涡轮增压器,这个名字就能体现出 JIT 的意义。但JIT提高了执行速度,也有缺点,好比会使得js引擎体积更大,占用内存更大,因此轻量级的js引擎不包含jit,这就是运行速度和包大小、内存空间之间的权衡。架构设计也常常要作这种两边均可以,可是要作选择的trade off,咱们叫作方案勾兑。

说到权衡,我想起rn的js引擎hermes就改为支持直接执行字节码了,在编译期间把js代码编译成字节码,而后直接执行字节码,这就是在跨端领域的js引擎的trade off。

前端领域都有哪些地方用到编译知识?

昊昊:哦,光哥,我明白解释器、编译器、转译器都干啥的了,那前端领域都有那些地方用到编译原理的知识呢?

:其实你也确定有个大概的了解了,可是不够明确,我列一下我知道的。

工程化领域各类转译器: babel、typescript、eslint、terser、prettier、postcss、posthtml、taro、vue template compiler等

js引擎: v八、javascriptcore、quickjs、hermes等

wasm: llvm能够生成wasm字节码,因此c++、rust等能够转为llvm ir的语言均可以作wasm开发

ide 的 lsp: 编程语言的语法高亮、智能提示、错误检查等经过language service protocol协议来通讯,而lsp服务端主要是基于parser对正在编辑的文本作分析

本身如何实现一门语言呢?

昊昊: 我学了编译原理能够实现一门语言么?

: 其实编程语言主要仍是设计,实现的话首先实现 parser 和语义分析,后面分为两条路,一种是解释执行的解释器配合JIT编译器的路,一种是编译成汇编代码码,而后生成机器码再连接成可执行文件的编译器的路。

parser部分比较繁琐,能够用 antlr 这种parser生成器来生成,语义分析要本身写,这个不太难,主要是对ast的各类处理。以后若是想作成编译器,能够用 llvm 这种通用的优化器和代码生成器,clang、rust、swift都是基于它,因此很靠谱,能够直接用。 若是作解释器能够写 tree walker解释器,或者再进一步生成线性字节码,而后写个vm来解释字节码。JIT编译器也能够用llvm来作。要把ast转成llvm ir,也是树形结构转线性结构,这个仍是编译领域很常见的操做。

其实编译原理只是告诉你怎么去实现,语言设计不关心实现,一门语言能够实现为编译型也能够实现为解释型,也能够作成 java 那种先编译后解释,你看 hermes(react native 实现的 js 引擎) 不就是先把 js 编译为字节码而后解释执行字节码么。语言不分编译解释,这个概念要有,c也有解释器,js也有编译器,咱们说一门语言是编译型仍是解释型主要是主流的方式是编译仍是解释来决定的。

编程语言能够分为 GPLDSL 两种。

GPL是通用编程语言,它是图灵完备的,也就是可以描述任何可计算问题,像c++、java、python、go、rust等这些语言都是图灵完备的,因此一门语言能实现的另外一门语言都能实现,只不过实现难度不一样。好比go语言内置协程实现,那么写高并发程序就简单,java没有语言级别的协程,那么就要上层来实现。你可能听到过设计模式是对语言缺陷的补充就是这个意思,不一样语言设计思路不一样,内置的东西也不一样,有的时候须要运行时来弥补。、

编程语言有不一样的设计思路,大的方向是编程范式,好比命令式、声明式、函数式、逻辑式等,这些大的思路会致使语言的语法,内置的实现都不一样,表达能力也不一样。 这基本肯定了语言基调,后续再补也很难,就像js里面实现函数式,你又不能限制人家不能用命令式,就很难写出纯粹的函数式代码。

DSL 不是图灵完备的,却换取了某领域的更强的表达能力,好比html、css、正则表达式,jq的选择器语法等等,比较像一种伪代码,特定领域的表达能力很强,可是却不是图灵完备的不能描述全部可计算问题。

编译原理是实现编程语言的步骤要学习的,更上层的语言设计还要学不少东西,最好能熟悉多门编程语言的特性。

我该怎么学习编译原理呢?

昊昊: 光哥,那我该怎么学习编译原理呢?

: 首先你要理解编译都学什么,看我上面对编译、转译、解释的科普大概能有个印象,而后查下相关资料。知道均可以干啥了以后先写parser,由于无论啥都要先parse成ast才能被“理解”和后续处理,学下有限状态机来分词和递归降低构造ast。推荐看下vue template compiler 的 parser,这种xml的parser比较简单,适合入门。语言级别的parser细节不少,仍是得找一个来debug看。不过我以为没太大必要,通常也就写个html parser,要是语言的,能够用antlr生成。转译器确定要了解babel,这个是前端领域很不错的转译器。

js引擎能够尝试用babel作parser,本身作语义分析,解释执行ast试试,以后进一步生成字节码或其余线性ir,而后写个vm来解释字节码。

还能够学习wasm相关技术,那个是涉及到其余语言编译到wasm 字节码的过程的。

我在写一个《babel 插件通关秘籍》的小册,里面会实现 js 解释器、转译器、type cheker、linter 等等,涉及到编译原理的不少知识,或许能帮你入门编译原理。

当你学完了编译原理,就大概知道怎么实现一门编程语言了,以后想深刻语言设计能够多学一些其余编程范式的语言,了解下各类语言特性,怎么设计一门表达性强的gpl或者dsl。

也能够进一步学习一下操做系统和体系结构,由于编译之后的代码仍是要在操做系统上以进程的形式运行的,那么运行时该怎么设计就要了解操做系统了。而后cpu指令集是怎么用电路实现的,这个想深刻能够去看下计算机体系结构。

不过,前端工程师不须要达到那种深度,可是眼界开阔点没啥坏处。

相关文章
相关标签/搜索