按编译原理的思路设计的一个计算器

  • Post author:
  • Post category:其他



(周游[


http://blog.csdn.net/naturemickey]


版权所有,未经许可请勿转载)




首先看一下这个计算器的功能:


点击(

此处

)折叠或打开

CALC> set a = 1; b = 2


CALC> set c = 3


CALC> calc (10 + pow(b, c)) * sqrt(4) – 1


35.0


CALC> exit


如上所示,这个计算器的功能非常简单:





  1. 用set命令设置上下文中的变量。

  2. 用calc命令计算一个表达式的值。

  3. 用exit命令退出计算器。
我们把编译的重点放在calc命令后面的计算表达式的解析,其它的部分我们可以简单处理(如set命令可以这样简单处理:先按分号分隔得到多个赋值处理,再按等号分隔就可以在上下文中设置变量了,并不需要复杂的编译过程)。


如上的演示例子中,我们使用编译技术处理的部分是(10 + pow(b, c)) * sqrt(4) – 1,其它部分我们只使用简单的文本处理。




麻雀虽小,但五脏俱全,这个计算器包含编译技术中最必要的部分。虽然这次我们只是实现了一个计算器,但所使用的技术足以实现一个简单的脚本语言的解释器了。


这个计算器分为如下几个部分:


  1. 词法分析:把表达式的文本,解析成词法元素列表(tokenList)。
  2. 语法分析:把tokenList解析成语法树(syntaxTree)。
  3. 语义分析:把语法树转成汇编语言的代码(asm)
  4. 汇编器:把汇编代码翻译为机器码(字节码)。
  5. 虚拟机:执行字节码。
一般的编译步聚中不包含“汇编器”和“虚拟机”,而这里之所以包含这两个部分是因为:




  1. 通常编译器会直接由中间代码生成机器码,而不是生成汇编代码,而这里我之所以要生成汇编代码的原因是“调试的时候汇编的可读性很好”,如果直接生成目标代码,则会非常难用肉眼来阅读。

  2. 自己实现虚拟机的原因是:现有的机器(包括物理机和虚拟机以及模拟器)的指令虽然也很丰富,但似乎都没有直接计算“乘方”或“开方”的指令,自已实现虚拟机可以任意设计计算指令,这样可以降低整个程序的复杂度。
因汇编器与虚拟机并不是编译原理的部分,所以下文中并不会描述其实现细节,但

因为计算器代码编译后的目标代码就是汇编代码,所以

需要把汇编指令做一下说明

(以下把这个汇编语言简称为ASM














ASM

指令介绍



指令助记符 操作数 指命说明
store

number

把number放入栈顶

add


从栈顶取出两个数相加,并把结果压回栈中

sub


从栈顶取出一个数做为被减数,再取一个做为减数,相减之后的结果入栈

mul


从栈顶取出两个数相乘,并把结果入栈

div


从栈顶取出一个数做为除法的分子,再取出一个做为除法的分母,相除的结果入栈

pow


从栈顶取出一个数做为底,再取出一个做为幂,计算结果入栈

sqrt


从栈顶取出一个数,把这个数开平方后的结果入栈

print


在控制台打印栈顶的数字

这个虚拟机是基于栈来设计的,所有的计算指令的操作数都从栈中取,store命令向栈顶添加数据。


print指令用于打印当前栈顶的数据,在我们编译的汇编代码要做到:正确计算出结果,且计算完成之后的结果要刚好在栈顶,这样最后调用一个print指令即可以控制台看到计算结果。





ASM

举例:


例1,如果我们要计算1-2*3,则我们写出的汇编代码如下(行号是为下文解释代码方便而放上去的,不是代码的一部分):

点击(

此处

)折叠或打开


  1. store 3


  2. store 2


  3. mul


  4. store 1


  5. sub
  6. print

对这段代码的说明如下:




  1. 前两行向栈顶添加两个数字,先压入3再压入2,这样栈顶的数字是2,第二个数字是3。

  2. 第三行mul会从栈顶弹出两个数字(2和3)计算乘法,并把结果(6)再压入栈中,此时栈中只有一个数字6。

  3. 第四行向栈顶压入一个数字1,此时栈顶为1,第二个数字是6。

  4. 第五行sub指令从栈顶取出两个数字,第一个数字1做为被减数,第二个数字6做为减数,即计算1-6,并把结果压入栈中,此时栈中只有一个数字-5。
  5. 最后一行print指令不对栈做写操作,只读取栈顶的数字,并打印出来。
在这里,我们用到两个运算,mul和sub,这两个运算都是二元运算,因我在设计指令的时候,先取出来的数字是第一个操作数,所以先压入的应该是第二个操作数,这也是为什么代码中先压入的是3,之后是2,最后才是1。



例2,

如果我们要

计算(10 + pow(2, 3)) * sqrt(4) – 1,

则我们写出的汇编

代码如下

(行号是为下文解释代码方便而放上去的,不是代码的一部分






点击(

此处

)折叠或打开


  1. store 1


  2. store 4

  3. sqrt

  4. store 3

  5. store 2

  6. pow

  7. store 10


  8. add



  9. mul



  10. sub


  11. print

对这段代码的说明如下:


  1. 这段代码稍有点复杂,但有前一段代码的经验,我们可以看到,所有的操作数的先后顺序是从右向左store的,所以store指令的顺序是固定下来的,剩下的关键是操作指令应该放在哪里。

  2. 操作指令也是有一个规律的,即:当前栈顶的数据刚刚好满足某运算时,则操作指令就放在哪里,如:




    • store 1的时候没有任何操作的操作数都在栈中。

    • store 4的时候,刚刚好sqrt的操作数都在栈中,则此时加入sqrt指令。

    • store 3 store 2时,刚刚好可以计算pow。

    • store 10之后,就可以计算加法了,所以此时加入add指令。

    • add计算完成之后,再加上之前已计算完的sqrt指令,则此时乘法的所有操作数都在栈中了,则此时加入mul指令。

    • 最后减操作也可以计算了,则加上sub指令。

    • 所有计算完成之后打印出结果。
在这个例子中,我所说的“规律”其实就是“后缀表达式”。


我们平常写的算术表达式是“中缀”的,即符号在操作数中间,如1 + 2,的 + 在1和2中间,转为后缀形式即为1 2 +


这里因为我对于参数顺序的设计是与“正常”顺序相反的,所以1 + 2对于这个汇编器来说,其后缀形式就应该为2 1 +


大家是可以按照这个规律,相对简单的实现这个计算器——只要做好词法分析就可以按照后缀表达式的规律直接由tokenList生成汇编代码了——但我们的目的是用这个计算器的例子来讲编译,所以还是按步就班来讲。






词法分析




词法分析的目的是把一段文本分解成词法元素列表,例如(10 + pow(2, 3)) * sqrt(4) – 1,做词法分析之后会分解成如下几个词法元素:



10

+

pow

(

2

,

3

)

)

*

sqrt

(

4

)



1







这里只是做了一次文本处理——在处理之前,我们手里有的东西就是一串字符组成的字符串,在处理之后,我们按照一定的“规则”分解为多个单词。


算法是多种多样的,有创造力的程序员会想出各种办法来处理这个单词分解的问题。


在编译原理中,普遍的方式是用如下一个状态转换图来实现的





在图中,“椭圆形”的是状态,状态与状态之间有一条有方向的线,这个线代表从一个状态到另一个状态的路径,在这条线上面有一个花括号代表从前一个状态到达后一个状态的输入(为方便表示,0-9表示从0到9十个数字,a-z表示从a到z二十六个字母等),如从START状态开始,输入一个数字1,就会到达INT状态。


图中蓝色的状态是终结状态,如果从START状态经过若干输入后到达终结状态,则这些输入的字符可合并为词法的合法单词——这里需要额外说明一点:在对输入进行匹配状态时采用贪婪方式,即:尽量输入更多的字符。


在识别到一个合法的词法单元之后,状态回到START继续识别下一个元素,直到没有新的元素为止。




这个状态转换图在编译原理中有一个专有名词称呼它“确定有限状态自动机”,英文简称DFA。这里“确定”的意思是每一个状态经过一个输入字符可以确定只有一条路径到达另一个状态,“有限”的意思是,状态的个数是有限的。对于一个复杂语言的状态数量是这个状态机的几个数量级的大小。但我们现在的计算器只需要这几个状态就够了。


通常的DFA会由工具读取正则方法描述而生成的,而不是直接手工构造,但对我们现在设计的计算器来说,其DFA非常小,手工构造是很方便的,所以就不用工具了。另外,如果使用工具的话,我这篇文章也不会使用现有的工具,而是自己实现一个工具。




下面举个例子:我们有一个表达式12.3 + abc,下面我来描述一下DFA的运行过程:

  1. 定义一个变量s,来表示当前状态机所在的状态(初始时s = START)。
  2. 输入第一个字符1,此时START状态可接受这个输入1,到达INT状态,则变量s赋值为INT状态,此时可看到INT状态的颜色是蓝色表示当前可识别为一个合法的词法元素,但因为我们的规则是贪婪匹配,所以我们还要看看是否还能够匹配更多的字符。
  3. 输入第二个字符2,此时INT状态也可以接受这人输入,并到达INT状态(转了一个圈又回到原来的状态),此时变量s赋值为INT状态。
  4. 再输入第三个字符 . ,此时INT状态可接受 . 到达NUMBER状态,此时变量s赋值为NUMBER状态。
  5. 此时我们再向前看一个字符 + ,此时的状态NUMBER无法接受这个字符了,同时我们可在看到NUMBER状态的颜色是蓝色的,表示当前状态可识别一个合法的词法元素,即:从START开始到当前我们一共经历的输入为12.3,则这个单词做为第一个合法的词法元素。
  6. 第一个词法元素识别成功之后,变量s要回到START状态,并继续输入 + ,此时从START状态可到达ADD状态,且ADD状态并不允许接受任何输入,同时ADD状态的颜色也是蓝色的,则我们又识别出第二个词法元素 + 。
  7. 在识别到第二个词法元素之后,变更s要回到START状态,并继续输入a,此时START状态可由a指向的路径到达ID状态,此时s赋值为新的状态ID。
  8. 现在的情况与识别NUMBER的情况类似,当前的状态也是终结状态,但按贪婪匹配的原则还要继续看看是否可以匹配到新的输入。
  9. 后面的b和c都在ID一个状态里转圈,我就不在赘述了,这样我们就识别到了最后一个词法元素abc了。

用如上过程的方法可以识别任何正确的算术表达式,但还有几点需要特别说明:

  1. 如何识别错误:目前我见过的语言规范都在描述如何正确的编译一个语言,但没有一个规范有描述如何报错的,所以我们目前能做的是只要按正常的走法走不通了,那就是错了,就要报错,并尽可能提供详细的错误信息。
  2. 对空白的识别:我在DFA中并没有画识别空白的部分,原因是对于计算器程序来说,空白完全没有用处,所以我只是在代码中直接忽略这个输入,以免状态机无法识别空白,同时在识别到词法元素之后,去掉单词前后的空白。对于某些语言来说,空白是有意义的,需要做为词法元素识别出来,不能忽略掉。
  3. 对于词法元素的表示:通常我们会用一个类型Token来表示一个词法元素,Token中有两个属性,一个用于表示这个Token的类型,另一个用于表示内容,只有数字与标识符才需要使用Token类型的内容属性,其它的类型因为同一类型只有一种表示的可能,所以就不需要再把内容保存下来了(如ADD的内容一定是+)。
  4. 关于标识符:DFA中的ID状态用于识别标识符,这里的标识符包括自定义的变量,也包括函数名。在DFA的设计过程中,我们可以选择把普通标识符与保留字做为不同的状态,也可以用使用同一个状态。我们现在的设计就使用了一个ID状态表示所有的标识符,而在识别到一个ID之后,我们在看是否是一个保留字,这样在返回Token对象时设置不同的类型。
  5. 对于INT和NUMBER:这个计算器的所有计算都使用的是double类型的数字,所在虽然我们的词法可以识别到INT,但我们定义Token的类型时,就只定义一个NUMBER类型,INT或NUMBER状态确定的单词都返回NUMBER这个类型的Token对象。

前面的逻辑中有一个贪婪批配的原则,这个原则在某些语言的词法中会有一些特例不适用,例如在C

++

和JAVA中都有一个运算符“>>”,这个运算符代表右移,但在C

++

11标准中可以写出这样的代码std::vector<std::vector<int>>,在JDK5及以上版本也可以这样写List<List<Integer>>,这里如果按贪婪批配就错了。所以必须在词法分析中加入上下文的判断才能决定是按两个>来识别还是按一个>>来识别——上下文的判断是语法分析的部分了,但对于复杂的词法结构在没有一个统一的词法解析算法可以处理的情况下就得借助更高级的方法了。


现在剩下的就是写代码了。

如果我把代码贴在这里就太长了,不太合适。所以下面我只描述一下描述DFA的思路:

  1. 思路一:直接静态代码来描述,类似这样的方式把状态机的每个路径都描述出来:IF S = START AND c = ‘1’ THEN S = INT … ELSIF …,这样就可以输入运行了。
  2. 思路二:表格驱动式的,例如列出下面的表格即可知道哪个状态经过哪个输入之后到达哪个新的状态了——下表的左标题是当前的状态,上标题是在当前状态上的输入,表格中的内容是经过路径到达的状态。
  3. 思路三:结合前两个思路——先写一个代码生成工具,工具读取“思路二”中表格中的内容,并生成“思路一”中的静态代码。



[0-9] . [_a-zA-Z] + * / ( ) ,
START INT POINT ID ADD SUB MUL DIV LBT RBT COMMA
INT INT NUMBER







POINT NUMBER








NUMBER NUMBER








ID ID
ID






ADD









SUB









MUL









DIV









LBT









RBT









COMMA











下面举一下DFA返回的Token对象的类型:NUM,FUN,VAR,ADD,SUB,MUL,DIV,LBT,RBT,COMMA


这里前三个与DFA的状态名不同:




  • NUM代表INT状态和NUMBER状态两个状态识别出的词法元素。

  • FUN和VAR都是ID状态识别出的元素,如果这个ID是一个函数名,则Token为FUN类型,否则即为VAR类型。
其它类型与DFA的状态是一一对应的。



最后说一下DFA的接口:




  1. 假设DFA有一个方法叫做parse,则这个方法的参数只有一个字符串用于传入表达式即可。如果我们写的DFA是用于解析长文本的(而不仅仅是解析这个简短的算术表达式


    ),则可以考虑参数为输入流。

  2. parse方法的返回可以考虑返回一个Token的游标,游标供语法分析程序调用;也可以考虑解析完所有的词法元素返回一个Token的列表。因为目前比较通用的语法分析算法只需要向前看一个Token对象,所以游标就足够了。

  3. 因为语法分析程序有可能会把已读出来的Token放回去,下次再用,所以游标可考虑加一个putBack方法——也可以考虑不实现这个方法,而由语法分析程序自己缓存当前用不到的词法元素——如果DFA返回的是list就简单一些,语法分析器可以前后前后移动offset即可。
  4. DFA返回list的方式实现虽然简单一点,但对于要解析的数据非常大的情况就不适用了——尤其数据是从网络上以流的方式传过来的,这样我们根本不知道什么时候这个流会结束,所以还是需要一个元素就返回一个元素的做法比较安全——对于我们现在做的计算器的程序来说,随便怎么做都可以了。






语法分析




语法分析是把词法分析过程返回的tokenList转换为语法树的过程。


词法分析的结果是为语法分析服务的,语法分析自然也是为下一步的语义分析做准备的,在这一节中,我们只讲一下语法树是怎么构造的,下一节语义分析的部分再讲如何使用语法树。




语法树是一个树形的数据结构,树的每个根节点表示一个语法结构,子节点表示构成根这个大语法结构的小语法结构。这样不断划分更小的语法结构直到无法再分的子节点,其实就是一个整体与部分的关系。


因树形结构是要画图的,但我们通常的工具是文本工具,所以我们通常用类似以下文本形式来表示树形结构:


NODE1     -> NODE2 NODE3


NODE2     -> NODE4


这个表示的意思是:NODE1为根节点,NODE1有两个子节点NODE2和NODE3,NODE2又有一个子节点NODE4。


这样即可用文本的形式表示树形结构了。




这种表示形式叫“巴科斯范式”,英文简称为BNF——下文中有需要表示这个名称的地方,我就直接用BNF三个字母来代替了。




下面我们看一下这个计算器的BNF(

对于复杂语言的BNF描述要写几十页,但我们的计算器就只有这么几行

):

点击(

此处

)折叠或打开

  1. exp     -> term
  2. exp     -> term + exp
  3. exp     -> term – exp
  4. term    -> factor
  5. term    -> factor * term
  6. term    -> factor / term
  7. factor  -> varName
  8. factor  -> number
  9. factor  -> – number
  10. factor  -> funCall
  11. factor  -> ( exp )
  12. funCall -> funName ( params )
  13. params  -> exp
  14. params  -> exp , params

下面对这个语法结构描述一下:




  1. exp为算术表达式,即为我们要分析的表达式整体。

  2. term是可做加法或减法的项。

  3. 我们可看到exp有三行表示基结构,这三行是或的关系,即:exp可能是一个term,也可能是一个term加上一个exp,也可能是一个term减掉一个exp。也就是说,exp这个根节点的子节点可能只有一个节点,也可能有三个节点。
  4. factor是可做乘法或除法的项。
  5. term继续分解的过程与exp是完全一样的,这里就不再赘述。
  6. 这里把加减法与乘除法分开为两种语法结构的原因是,乘法与除法的优先级高于加法与减法,按照我们现在的表示,在计算加法或减法之前必须先计算term,这样因term是先计算的,所以优先级就表示出来了。
  7. varName是变量名,numbe

    r是数字,


    fun


    Ca



    ll是


    函数调用


  8. 对于factor的组成部分就可理解为:可以为一个变量,或一个数字,或一个减号连着一个数字(负数),或一次函数调用,或用小括号括起来的表达式。

  9. funName是函数名,params是函数调用的参数。

  10. funCall的结构为:函数名后跟着一个括号,再跟着一个或多个参数,再跟着一个括回。

  11. params由两个产生式,因每个参数都可以是单独的算术表达式,所以一个exp节点即可表示一个参数,如果有多个参数,则exp后成要跟着一个逗号,之后再跟着其它的参数。


这种表示方法相对于树形的表示来说,有一个优点,即:相对比较容易表示“或”,比如factor这个节点可能由变量名来表示,也可能由数字来表示,如可能是……,这样我们如果画图的话,如何表示多种可能中只选其中一种的情况呢?




上面的表示是对语法结构的抽象表示,抽象的东西还是具体化为我们的代码中的数据结构的。


我们的目的是把我们的算术表达式变成符合语法树描述的样子,举个例子来说:(1 + 2) * 3转成语法语法树就是下图所示的样子:






图中蓝色的部分为语法树的叶子节点,也就是不能再分解的节点。这些叶子节点正是词法分析部分的词法元素。


下面我们看一下来看一下构造这个树的步骤:




  1. 上面的图中只有三种非叶子节点的类型(exp、term、factor)以及几个叶子节点的类型[number、*、+、(、)],所以下面我只以这几个为例做一下描述,其它的节点类型的解析步骤是类似的——因+*()这几个符号在描述中会不太好看,所以下文中我改用add、mul、lbt、rbt来表示。

  2. 节点的类型:


    • 我们可以为每种节点类型单独创建一个类,如:exp类型的节点即为Exp类型的对象,term类型的节点即为Term类型的对象……
    • 也可以考虑只用一个类型TreeNode,而用Node中的一个属性type来表示不同的节点类型。
    • 我选择用第二种方式来表示节点的类型,这样对于子节点的表示也相对简单的直接用一个list即可。
  3. 对于number类型的节点,还需要一个属性来表示数字的内容,所以TreeNode类中除了type字段之外,还要有一个content字段——varName或funName类型也是需要content字段的,用于表示变量名或函数名。
  4. 为每个非叶子节点写一个函数来解析这个语法结构,如:parseExp、parseTerm、parseFactor,因为我们每个语法结构(或子语法结构)







    T


    reeNode类型的对象为根的树,所以这几个函数的返回值类型为TreeNode。
  5. 每个函数内的结构化代码与BNF中的描述可完全对应得上,比如:

    • exp有三个产生式:”exp -> term”和”exp -> term + exp”和”exp -> term – exp”,则parseExp的代码可以按下面的步骤来写:

      • 创建一个类TreeNode的对象expNode,并指定其类型为exp。
      • 调用parseTerm函数解析出expNode的第一个子元素termNode。
      • 向前看下一个词法元素(如果还有下一个的话):

        1. 如果为add或sub类型的Token,则创建第二个子元素opNode(这个元素的类型为add或sub),之后再递归调用parseExp解析第三个子元素。
        2. 如果不为add或sub类型的Token,则一定是出错了。
      • 反回expNode。
    • term有三个产生式:”term -> factor”和”term -> factor * term”和”term -> factor / term”,这三个产生式与exp的三个产生式是同一个格式的,所以代码也几乎是一样的。
    • factor有五个产生式,但我们现在这个例子用不到变更和函数,所以只看其中的三个:”factor -> number”和”factor -> – number”和”factor -> ( exp )”,则parseFactor的代码可以这样写:

      • 创建一个类TreeNode的对象factorNode,并指定其类型为factor。
      • 向前看一个词法元素:

        1. 如果是num类型的Token,则创建一个number类型的TreeNode对象做为factorNode的子节点(这个节点的content值为当前这个Token的值)。
        2. 如果是sub类型的Token,则再从tokenList中读出一个num,创建一个number类型的TreeNode做为factorNode的子节点(这个节点的content值为当前这个Token的值把符号位反过来)。
        3. 如果是lbt类型的,则先创建一个lbt类型的TreeNode做为factorNode的第一个子节点,再调用parseExp函数来获得第二个子节点,再向前看一个Token,如果是rbt类型的,则创建一个rbt类型的Token做为第三个子节点(如果看到的不是rbt类型的就要报语法错误了)。
      • 返回factorNode。
按上面描述的逻辑实现代码是要不了多少行代码就可以实现这个计算器的语法解析了。


还有两个funCall和params,这两个类型的解析与exp或term或factor差不多,就不再描述了。




下面还要说一点额外的注意事项:


  1. 我们现在写代码这种方式叫做LL(1)型,也叫递归向下型的语法分析,在这种语法分析方式中,我们总是先创建根节点,再创建子节点,在创建子节点时,我们总是由最左边的节点开始一个一个去创建(如parseExp中,我们先创建出来的就是expNode,然后再创建的是termNode,之后如果还有元素的话就会创建opNode和下一个expNode),这种语法解析方式是最容易用代码实现的,所以我才使用这种方式来做,但这种解析方式有一个局限性:语法的BNF描述中不能有左递归。何为左递归呢?举个例子来说:exp的产生式中的两个




    exp -> term + exp


    “和”exp -> term – exp”,如果改为




    exp ->

    e


    x


    p +

    term


    “和”exp ->

    e


    x


    p –

    term”


    ,也是对的,但按照这样的产生式来写代码时,第一个要解析的就不是term,则还是一个exp,要解析第一个TreeNode就要递归调用parseExp函数了,这样就会形成无限的递归了。所以我们在设计LL(1)型的文法描述时要避免左递归。

  2. LL(1)的文法设计在解析复杂的语言时会有语法描述上的局限性,如:C语言的一条语句开头如果为ABC,则这个ABC可能一个变量名,也可能是行号,此时必须再向前看一个Token才能知道应该使用哪个产生式,这就变成LL(2)型了——LL后面括号中的数字代表我们在识别语法产生式时,要向前看几个Token——这样的语法解析的代码就复杂很多了。那么,有没有实现简单,且语法描述能力又强的解析方法呢?答案为:是有的。但本文只需要实现计算器的语法,就没有必要喧宾夺主来花费大量的篇幅来写其它的语法解析方式。以后如果有时间可专门再写一个博文来讲语法解析。

  3. 语法分析有时也是要判断上下文的,比如:假设在C

    ++

    代码中有这样的一句:f<A, B>(c);这句代码在没有上下文的情况下是不能判断是什么,它可能是一个函数调用(f是函数名,A和B是范形参数,c是函数的参数),也可能是一个逗号表达式(f,A,B,c都是变量或值,这一名就表示为f小于A,B大于c)——其实这是C

    ++语法上的一个考虑不周之处,这给编译器的设计者带来了很大的麻烦,JAVA对于范型方法的调用就避免了C++的尴尬:JAVA中范型参数位置不在方法名与参数之间,而在方法名之前,如:<A, B> f(c),这样就不会与其它的JAVA语法冲突了

    。从这也可看出,语法的设计对语法解析程序的编写影响是非常直接的。


最后再讲一点稍微偏门一点的东西:


上面讲的解析过程是正统的语法解析方式,用这样的方式来解析计算器的算术表达式有点杀鸡用牛刀的感觉。


对于算术表达式的语法树可以用以更简单的结构,例如:表达式

(10 + pow(2, 3)) * sqrt(4) – 1的语法树可以表示成下面的图形:








这个图显然比正统的语法解析方式的结果树要小很多——因为这个树中只有终结符号,exp、term、factor等非终结符号是不存在的。


对于这样的语法树的语义分析也更简单,后面讲语义分析时再讲一下如何解析这个袖珍版本的语法树。


这个树的构造即:所有的叶子节点都是操作数,所有的根节点都是操作符——同时大家也可以注意到括号不见了,实际上括号在语法树中也是不必要的。


至于这个语法树如何构造就留个悬念,不在这里讲了。











语义分析




语义分析是把语法树转成中间代码,再由中间代码转成目标代码。但对于简单的分析来说,我们省略中间代码的步骤,直接读语法树生成目标代码(目标代码即为前面讲过的

ASM

代码)。


虽然对于复杂的语言来说语义分析这个部分是非常复杂,但对于语法与设计都很简单的语言来说语义分析这个部分简直简单到可以合并到语法分析的过程中去做了,只是我们现在的目的是用计算器的例子来讲编译过程,所以这个部分还是简单讲一讲。


我之所以说这个计算器的语义分析可以合并到语法分析中是因为计算器的结构中没有上下文的判断,所以语法分析不报错的话,语义分析就一定没有问题了。



我们还是以在语法分析中的 (1 + 2) * 3 的例子来讲语义分析,这个表达式的语法树还是这个:





语法分析是构造语法树,语义分析是读语法树,所以语义分析的代码与语法分析的代码是相通的,读这个语法树我们也是需要parseExp、parseTerm、parseFactor三个函数的,只是这三个函数的参数不再是tokenList,而是TreeNode,返回值不再是TreeNode,而要返回ASM代码(直接返回字符串文本即可——不过直接返回一个字符串列表可能更好一些,这样海汇编器直接收到的就是一个个的汇编表示了)。下面分别描述一下

parseExp、parseTerm、parseFactor三个函数的逻辑




  1. parseExp:


    检查一下exp节点的子节点有几个,如果只有一个,则这一个一定是一个term结构,则把这个节点传给parseTerm得到结果并返回即可;如果有三个,则对第三个参数调用parseExp得到一个asmCode,

    把第一个参数传给parseTerm得到一串汇编指令,

    并加到asmCode后面做为新的asmCode




    最后




    判断第二个参数是add还是sub,直接向asmCode后面加上一个add指令或sub指令作为新的asmCode,最后返回asmCode即可。




    • 这里要说明一个在解析三个子节点的情况下为什么要从第三个开始,然后第一个,最后第二个呢?是因为:

      • 我们现在的汇编器的设计中,指令要从本中取操作数,所以要先把所有的操作数搞定才能搞写指令,所以第二个操作数的处理要放到最后。



      • 我们现在的汇编器的设计中,操作数出栈的顺序是按我们正常表达式的从左到右的顺序,所以入线的顺序就要从右到左,所以第三个操作数先处理,然后才是第一个。
  2. parseTerm:与parseExp也几乎是一样的,也不赘述了。
  3. parseFactor:在语法解析中我们只将了这三个产生式:

    “factor -> number”和”factor -> – number”和”factor -> ( exp )”,所以在这里我们还是继续以此为例。




    • 如果factor的第一个子节点是一个number类型,则直接返回”store ” + number指令即可。

    • 如果factor的第一个子节点是一个sub类型,则直接返回”store -” + number指令即可。

    • 如果factor的第一个子节点是lbt类型,则对第二个参数调用parseExp函数得到asmCode,并返回asmCode即可——因此时第三个操作数一定是rbt,所以此时不管它也可以,其实在语法分析时本就可以直接不构造lbt和rbt这两个节点的。
就这样,我们就可以得到ASM代码了——为了在控制台看到计算结构,我们还要在ASM代码的结尾加一个print指令。


最后我们要做的事情是调用已经存在的汇编器和虚拟机来运行了。




到现在所涉及到的技术对于处理复杂语言是远远不够,但也足可以用这些知识来设计语法简单,但功能强劲的语言解释器,对于一般的领域特定语言的语法处理都是足够的了。



以后有机会可能会再写其它的关于编译技术的文章。

ps:还有一点没说的:

简化版语法树的语义分析:




这个非常简单:对语法树进行后根遍历(即先右节点,再左节点,再根节点),碰到叶子节点就用store指令,碰到根节点就用对应的计算指令——非常简单吧?



版权声明:本文为naturemickey原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。