可配置语法分析器开发纪事(四)——构造一个真正能用的状态机(上)
本来说这一篇文章要把构造确定性状态机和look ahead讲完的,当我真正要写的时候发现东西太多,只好分成两篇了。上一篇文章说道一个基本的状态机是如何构造出来的,但是根据第一篇文章的说法,这一次设计的文法是为了直接构造出语法树服务的,所以必然在执行状态机的时候就要获得构造语法树的一切信息。如果自己开发过类似的东西就会知道,类似LALR这种东西,你可以很容易的把整个字符串分析完判断他是不是属于这个LALR状态机描述的这个集合,但是你却不能拿到语法分析所走的路径,也就是说你很难直接拿到那颗分析树。没有分析树肯定是做不出语法树的。因此我们得把一些信息插入到状态机里面,才能最终把分析树(并不一定真的要表达成树,像上一篇文章的“分析路径”(其实就是分析树的一种可能的表达形式)所确定的语法树构造出来。
就像《构造正则表达式引擎》一般给状态机添加信息的方法,就是把一些附加的数据加到状态与状态之间的跳转箭头里面去。为了形象的表达这个事情,我就拿第一篇文章的四则运算式子来举例。在这里我为了大家方便,重复一下这个文法的内容(除去了语树书声明):
token NAME = "[a-zA-Z_]/w*"; token NUMBER = "/d+(./d+)"; token ADD = "/+"; token SUB = "-"; token MUL = "/*"; token DIV = "//"; token LEFT = "/("; token RIGHT = "/)"; token COMMA = ","; rule NumberExpression Number = NUMBER : value; rule FunctionExpression Call = NAME : functionName "(" [ Exp : arguments { "," Exp : arguments } ] ")"; rule Expression Factor = !Number | !Call; rule Expression Term = !Factor; = Term : firstOperand "*" Factor : secondOperand as BinaryExpression with { binaryOperator = "Mul" }; = Term : firstOperand "/" Factor : secondOperand as BinaryExpression with { binaryOperator = "Div" }; rule Expression Exp = !Term; = Exp : firstOperand "+" Term : secondOperand as BinaryExpression with { binaryOperator = "Add" }; = Exp : firstOperand "-" Term : secondOperand as BinaryExpression with { binaryOperator = "Sub" };
那么我们把这个文发转成状态机之后,要给跳转加上什么呢?从直觉上来说,跳转的时候我们会有六种要干的事情:
1、Create:这个文法创建的语法树节点是某个类型的(区别于在这一刻给这个问法创建一个返回什么类型的语法树节点)
2、Set:给创建的语法树节点的某个成员变量设置一个指定的值
3、Assign:给创建的语法树节点的某个成员变量设置这一次跳转的符号产生的语法树节点(譬如说Exp = Exp: firstOperand “+” Term: secondOperand,走Term的时候,一个语法树节点就会被assign给那个叫做secondOperand的成员变量)
4、Using:使用这一次跳转的符号产生的语法树节点来做这次文法的返回值(譬如说Factor = !Number | !Caller这一条)
5、Shift:略
6、Reduce:略
在这里我们并没有标记整个文法从哪一个非终结符开始,因为在实际过程中,其实分析师可以从任何一个文法开始的。譬如说写IDE的时候,我们可能在某些情况下仅仅只需要分析一个表达式。所以考虑到每一个非终结符都有可能被用到,因此我们的“Token流开始”和“Token流结束”就会在每一个非终结符的状态机中都出现。因此在第一步创建Epsilon PDA(下推自动机)的时候,就可以先直接生成。在这里我们拿Exp做例子:
双虚线代表的是Token流和Token流结束,这并不是我们现在关心的事情。在剩下的转换中,实现是具有输入的转换,而虚线则是没有输入的转换(一般称为epsilon边)。
在这里我们要明确一个概念——分析路径。分析路径代表的是token在“流”过状态机的时候,状态是如何跳转的。因此对于实际的分析过程,分析路径其实就是分析树的一种表达形式。而在状态机里面,分析路径则代表一条从开始到结尾的可能的路径。譬如说在这里,分析路径可以有三条:
$e –> e1 –> e2 –> e$
$e –> e3 –> e8 –> e7 –> e6 –> e5 –> e4 –> e$
$e –> e9 –> e14 –> e13 –> e12 –> e11 –> e10 –> e$
因此我们可以清楚,一条路径上是不能出现多个create的,否则你就不知道应该创建的是什么了。当然create和using都不能同时出现,using也不能有多个。而且由于create和set都是在描述这个非终结符(在这里是Exp)所创建的语法树节点的类型和属性,跟执行他们的时机无关,所以其实在同一条分析路径里面,create和set放在哪里都没关系。就譬如说在上面的第二条分析路径里面,create是在e6->e5里面标记出来的。就算他移动到了e3->e8,做的事情也一样。反正只要一条路径上标记了create,那么他在这条路径被确定之后,就一定会create所指定的具体类型的语法树节点。这是相当重要的,因为在后面的分析中,我们很可能需要移动create和set的具体位置。
跟上一篇文章说的一样,接下来的一步就是去除epsilon边了。结果如下:
面对这种状态机,去除epsilon边就不能跟处理正则表达式一样简单的去除了。首先,所有的终结状态——也就是所有经过或者不经过epsilon边之后,通过“Token流结束”符号连接到最后一个状态的状态,在这里分别是e2、e6和e12——都是不能删掉的。而且所有的“Token流开始”和“Token流结束”——也就是图里面的$转换——是不能带有信息的。所以我们就会看到e6后面的信息全部被移动到了e7->e6这条边上面。由于create和set的流动性,我们这么做对于状态机的定义完全没有影响。
到了这里还没完,因为这个状态机还是有很多冗余的状态的。譬如说e8和e14、e7和e13、e2和e6和e12实际上是可以合并的。合并的策略其实十分简单:
1、如果我们有跳转e0->e1和e0->e2,并且两个跳转所携带的token输入和信息完全一致的话,那么e1和e2就可以合并。
2、如果我们有跳转e1->e0和e2->e0,并且两个跳转所携带的token输入和信息完全一致的话,那么e1和e2就可以合并。
所以对于e8和e14我们是完全可以合并的。那么e7和e13怎么办呢?根据create和set的流动性,我们只要把这两个东西挪到他的前面一个或者若干个跳转去,那这两个状态就可以合并了。为了让算法更加的简单,我们遇到两个跳转类似的时候,总是先挪动create和set,然后再看看是不是真的可以合并。所以这一步处理完之后就会变成下面这个样子:
我们在不改变状态机语义的情况下,把Exp的三个状态机最终压缩成了这个样子。看过上一篇文章的同学们都知道,下一步就是要把所有的状态机统统都连接起来了。关于在连接的时候如何具体操作转换附带的信息、以及做出一个确定性的下推状态机的所有事情将在下一篇文章详细解释。大家敬请期待。