尝试再造python编译器:龙书重制版

一段时间前,我们用go编写了python的词法解析器。由于近一段时间事情繁多,同时囊中羞涩,因此更多的精力投入到了和“变现”相关的工作,对编译原理,数据库这些极为基础且底层的技术有所忽略,毕竟他们不像reactjs, javascript,后台开发等这些工程性技术那样容易挣钱,因此属于用爱发电的范畴。但是工程性工作做多了我也发现一个问题,那就像人吃多精细食品而没有适当摄入粗粮,这会使得人有气无力,气虚多汗,让人感觉到体内虚空,没有底层理论和技术支持,一切上层构建都搭承在脆弱的地基上,随时有坍塌的危险。

为此我再次回归到底层技术,这次希望能沉淀下来,把编译原理,数据库系统等底层技术的设计和算法思想说清楚,这些都是当前繁花乱眼的上层技术的根基,掌握好他们,我们就能在风云变幻的信息技术世界站稳了根基,无论各种流行的技术如何变迁,我们都能以万变不离其宗的姿态淡定面对。

我们上一次完成python语言的词法分析时匆匆而过,忽略了一个很重要的数据结构和算法概念,其实词法解析并不仅仅是对字符串的简单处理,它基于一个根本概念叫有限状态自动机,大家如果在云课堂上看过我的“自己动手用java写编译器”课程就会发现,那里我用了大量的篇幅和代码来说明这个东西。为了改变上次浮皮潦草的态度,这次我打算认认真真基于编译原理“圣经”,也就是在龙书的基础上,将编译原理的算法和理论慢慢展现出来,同时将它们以Python编译器的形式逐步实现,这样我们才能够“知行合一”,不仅仅将认知停留在似懂非懂的理论上,同时也能破除编译原理给人晦涩难懂的感觉。

进入正题。我们先了解编译器的基本结构。所谓编译实际上就是将一种语言所表述的内容用另一种语言说出来。因此编译器的基本功能在于“分析”与“合成”。”分析“是把源语言分解成多个基本单元的组成,然后检测这些基本单元的结合形态是否满足特定语法结构。如果满足那么在此基础上将其转换成一种中间形态,也叫中间代码,例如java编译器将java代码编译成的字节码就属于这个东西,这个步骤也叫编译前端。如果这个过程发现源代码的基本单元在组合过程中出现语法或句法错误,那么编译器就会报告出来。分析过程主要是把源代码对应的基本单元组合情况进行了解,并把相应信息存储到一种叫做“符号表”的结构,然后将其与中间代码传递给合成过程。

合成主要任务是将分析过程传入的数据转换为目标语言。这个过程也叫编译器的“后端”。编译器在运行过程中会分为若干阶段:
请添加图片描述

在上图中,中间代码生成之前的部分叫前端,之后的部分叫后端。整个编译流程的第一部分叫词法解析或者是源码扫描,它把组成源代码的字符一一读入,然后检测这些字符是否能组合成满足条件的单元,如果可以则为这些单元用特定标记表示,标记也叫token,token的表现格式为<name, value>,然后它会传入后续步骤,也就是语法解析。所谓的value其实是一个数值,用来代表字符串所属的集合,例如类似于"1234",“1.23”,"1e23"这类字符串统一归属为NUMBER范围,因此可以给他们统一赋予一个数值1,“variable”, "my_str"这类字符串属于变量,因此统一赋予一个数值2表示,“for”, “if”, "else"等属于关键字,他们分别赋予不同的数值用来标明,value是当前所识别对象在符号表中的入口。

例如给定代码语句 position = initial + rate * 60,那么词法分析会做出如下输出:
“position”-><ID, 1>," ="-><ASSIGN, >, “initial”-><ID, 2>, “+”-><PLUS, > , “rate”-><ID, 3>,
“*” -> <MUL, >, “60”-><NUM, 4

看过上一节词法分析实现的同学会知道,这里的ID, PLUS等只不过就是一个常量数字,同时有限符号不会在符号表中有存储,例如操作符+,*这些。接下来语法分析会把上面的输出构造成一种二叉树结构,也叫语法解析树,二叉树的父节点都是操作符:

请添加图片描述

我们注意到数字60被转换成了<NUM,4>,其中60这个数值信息就好存在符号表入口为4的地方,后面我们会看到相关实现。由于语法解析树后,编译器需要查看其组成是否满足特定编程语言的语法,这个过程也叫语义分析,同时还要收集各个变量的类型信息,这个过程还要进行类型检测,例如PLUS操作不能跟着一个NUM节点和STR(字符串常量)节点.这个阶段还会进行类型转换,例如PLUS跟着一个整形变量节点和浮点型变量节点,那么它有可能会隐性将整形节点转换为浮点型(上图中的inttofloat),对应golang编译器而言,这种转换就不会发生,同时报告错误,除法代码自己进行了显示转换。

完成了语义分析,编译器会根据语法树生成中间代码。一种常用的中间代码叫”三地址码“,也就是每条语句最多包含三个操作数,每个操作数都能放在寄存器中,上面语法树就可以构建如下中间代码:

t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3

三地址码有个特性,操作符会位于等号的右边,其中t1,t2,t3都是临时变量,用于存储中间过程的计算结果,三地址码语句中最多有三个变量,同时允许变量少于3个。接下来阶段叫代码优化,其实就是尽可能减少三地址码的数量,例如上面指令中最后一条其实没有必要,同时可以直接用浮点的60.0替换掉整形60,于是中间代码优化器就会把它去除形成如下结果:

t1 = id3 * t1
id1 = id2 + t1

代码优化是个复杂过程,因为面对不同cpu或指令集会有不同的优化方式。在编译过程中,有很大部分的时间就消耗在优化阶段。最后是代码生成,编译器会将中间代码转换为对应平台的指令集,于是中间代码的操作会转换为目标语言的指令,变量会转换为寄存器或内存地址,例如上面代码可以转换为如下指令集:

LDF R2, id3
MULF R2, R2, #60.0
LDF R1, id2
ADDF R1, R1, R2
STF id1, R1

LDF, MULF等式操作指令,跟着的第一个单元为操作结果存放地址,其中的F表示进行浮点操作。LDF表示加载操作,例如LDF R2, id3表示将id3的内容存放到寄存器R2,然后将R2里面的内容与浮点数60.0进行乘法操作并将结果存放到寄存器R2,后面指令以此类推。

这些过程其实还有很多工程性问题需要解决。例如变量的地址分配,这个时候就需要符号表的帮助,因为符号表记录了变量的类型,于是编译器知道所需地址有多大,对于函数对象,符号表还会记录输入参数的数量,类型等,同时还能知道如何传递参数以及函数的返回值类型等,符号表是一个需要深入了解的数据结构,后面我们会详细分析。

所有这些内容都来自于编译原理的经典书:龙书。如果你看过我对"自己动手用java写编译器“,那么就能比较容易理解其内容,要不然你读起来会云里雾里,不知所云。我们后面会将龙书中的算法进行实践,特别是用来做一个”简易“版python编译器,只有通过动手实践,我们才有可能掌握复杂的编译原理算法。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值