完整cmm解释器构造实践(三):语法分析
语法树节点
我的语法分析器不仅会判断cmm代码的语法是否正确, 同时会存储分析过程中得到的信息, 完成语法树的构建.
为什么要有语法树呢, 其实还是为了计算机方便做进一步的处理才用的, 语法树里面存储了从代码里面提取的信息, 我们生成语法树之后再通过遍历语法树来得到中间代码. 当然直接遍历语法树并解释执行也是可以的, 我们老师非得让我们有中间代码, 所以我的语法分析只为了生成中间代码而服务的, 虽然我的代码生成器本质上是解释器改造而成的.
那么既然语法树是存储代码信息的, 代码语法树中最小的单元树的节点就很关键了.
怎么定义树的节点, 每个人有不同的看法, 书上也没有仔细讲这部分内容, 我全凭自己的理解来定义的.
下面是我的TreeNode.java中的片段, 这里是TreeNode可能的类型以及成员变量, 注意TreeNode的类型不同, 成员变量存储的信息的意义也不相同, 所以一定要仔细看, 当然你也可以只看一个大概, 然后自己去定义, 因为TreeNode信息在生成代码的阶段还需要使用, 如果不了解其中每个变量在各种情况下的意义, 是没法生成中间代码的. 我这里就不具体解释了, 因为实在太过复杂了. 注释里有一些我当时注明的信息.
/**
* 语句块使用链表存储,使用NULL类型的TreeNode作为头部,注意不要使用NULL的节点存储信息,仅仅使用next指向下一个TreeNode
*/
public static final int NULL = 0;
/**
* if语句
* left存放exp表达式
* middle存放if条件正确时的TreeNode
* right存放else的TreeNode,如果有的话
*/
public static final int IF_STMT = 1;
/**
* left存储EXP
* middle存储循环体
*/
public static final int WHILE_STMT = 2;
/**
* left存储一个VAR
*/
public static final int READ_STMT = 3;
/**
* left存储一个EXP
*/
public static final int WRITE_STMT = 4;
/**
* 声明语句
* left中存放VAR节点
* 如果有赋值EXP,则存放中middle中
*/
public static final int DECLARE_STMT = 5;
/**
* 赋值语句
* left存放var节点
* middle存放exp节点
*/
public static final int ASSIGN_STMT = 6;
/**
* 复合表达式
* 复合表达式则形如left middle right
* 此时datatype为可能为 LOGIC_EXP ADDTIVE_EXP TERM_EXP
* value==null
*/
public static final int EXP = 7;
/**
* 变量
* datatype存放变量类型Token.INT 和 REAL
* value存放变量名
* left:
* 在声明语句中变量的left值代表变量长度exp,在其他的调用中变量的left代表变量索引值exp,若为null,则说明是单个的变量,不是数组
* 不存储值
*/
public static final int VAR = 8;
/**
* 运算符
* 在datatype中存储操作符类型
*/
public static final int OP = 9;
/**
* 因子
* 有符号datatype存储TOKEN.PLUS/MINUS,默认为Token.PLUS
* 只会在left中存放一个TreeNode
* 如果那个TreeNode是var,代表一个变量/数组元素
* 如果这个TreeNode是exp,则是一个表达式因子
* 如果是LITREAL,该LITREAL的value中存放字面值的字符形式
* EXP为因子时,mDataType存储符号PLUS/MINUS
*/
public static final int FACTOR = 10;
/**
* 字面值
* value中存放字面值,无符号
* datatype存放类型,在TOKEN中
*/
public static final int LITREAL = 11;
private int type;
private TreeNode mLeft;
private TreeNode mMiddle;
private TreeNode mRight;
/**
* {@link TreeNode#getType()}为{@link TreeNode#VAR}时存储变量类型,具体定义在{@link Token}中INT / REAL?????
* {@link TreeNode#getType()}为{@link TreeNode#OP}时存储操作符类型,具体定义在{@link Token}中 LT GT ......
* {@link TreeNode#getType()}为{@link TreeNode#EXP}时表示复合表达式
* {@link TreeNode#getType()}为{@link TreeNode#FACTOR}表示因子,mDataType处存储表达式的前置符号,具体定义在{@link Token}
* 中PLUS/MINUS, 默认为PLUS
* {@link TreeNode#getType()}为{@link TreeNode#LITREAL}表示字面值,存储类型
*/
private int mDataType;
/**
* {@link TreeNode#getType()}为{@link TreeNode#FACTOR}时存储表达式的字符串形式的值
* {@link TreeNode#getType()}为{@link TreeNode#VAR}时存储变量名
*/
private String value;
/**
* 如果是代码块中的代码,则mNext指向其后面的一条语句
* 普通的顶级代码都是存在linkedlist中,不需要使用这个参数
*/
private TreeNode mNext;
注意有些成员变量, 比如mDataType
可能存储的值其实是在Token.java中定义的常量.
cmm文法
program -> stmt-sequence
stmt-sequence -> statement ; stmt-sequence | statement | ε
statement -> if-stmt | while-stmt | assign-stmt | read-stmt | write-stmt | declare-stmt
stmt-block -> statement | { stmt-sequence }
if-stmt -> if ( exp ) then stmt-block | if ( exp ) then stmt-block else stmt-block
while-stmt -> while ( exp ) stmt-block
assign-stmt -> variable = exp ;
read-stmt -> read variable ;
write-stmt -> write exp ;
declare-stmt -> (int | real) ( (identifier [= exp ]) | (identifier [ exp ]) ) ;
variable -> identifier [ [ exp ] ]
exp -> addtive-exp logical-op addtive-exp | addtive-exp
addtive-exp -> term add-op additive-exp | term
term -> factor mul-op term | factor
factor -> ( exp ) | number | variable | Add-op exp
logical-op -> > | < | >= | <= | <> | ==
add-op -> + | -
mul-op -> * | /
上面是我的语法分析器所使用的cmm文法, 是由很多产生式构成的, 符号->
称为推导, 加粗的是终结符, 没有加粗的是非终结符或文法描述用的符号.
上面的文法其实是一个LL(1)文法, 所谓的LL(1)文法, 就是非二义性, 且不含左递归. 说白了就是按照这个文法来检查语句是否合法, 不会出现一句代码出现两个意思, 同时程序在分析的过程中不会陷入无限递归当中. 再说明白一点就是, 我们大家很喜欢这个文法, 因为写程序很方便.
LL(1)文法使用的是自顶向下的分析方法, 第一个L代表从左向右扫描输入串, 第二个L代表使用最左推导. 数字1代表只需要提前向右看一个符号就可以知道如何推导.
递归下降子程序法
自左向右扫描输入串和提前向右看一个符号非常容易实现, 问题在于我们怎么将这种文法的规则转换为代码让程序来自动检查呢.
要知道文法之所以能描述一种语言的所有语法, 就是因为其具有递归的定义方法.
这里就需要介绍递归下降子程序法, 这种方法的核心思想就是利用递归和子程序来完成语法分析.
具体的做法就是让每一个产生式对应一个函数, 产生式中的终结符由函数自身来处理, 而产生式中的非终结符则通过调用相应的函数来处理. 比如对于statement函数, 则只需要根据情况调用if-stmt, while-stmt等函数就可以了, 很形象的说法就是任务层层下发, 最终每个终结符都被处理了.
那么statement函数怎么知道要调用哪个语句对应的函数呢, 这个时候就是提前向右看一个符号来发挥作用了.
比如下面的函数片段, 就是提前取下一个Token, 通过类型来判断应该调用哪个语句的处理函数.
private static TreeNode parseStmt() throws ParserException {
switch (getNextTokenType()) {
case Token.IF: return parseIfStmt();
case Token.WHILE: return parseWhileStmt();
case Token.READ: return parseReadStmt();
case Token.WRITE: return parseWriteStmt();
case Token.INT: // same as REAL
case Token.REAL: return parseDeclareStmt();
case Token.LBRACE: return parseStmtBlock();
case Token.ID: return parseAssignStmt();
default:
throw new ParserException("line " + getNextTokenLineNo() + " : expected token");
}
}
这个规则看似简单, 但是是正确的, if语句的开头一定是if
, while语句的开头一定是while
, 如果下一个符号是一个标识符, 那么一定是一个赋值语句, 如果下一个符号是int
或者real
, 那么一定是声明语句. 不信可以自己尝试.
在实现”提前向右看一个符号”时, 很多人的代码都混乱不堪, 因为有时候这个符号是当前正在处理的语句的一部分, 有时是下一句代码的开头, 暂时不能处理. 其实在java里面有一种叫做ListIterator
的迭代器, 不仅可以往后迭代, 还可以往前退一步, 比如下面检查下一个Token类型的代码:
/**
* 获取下一个token的type,如果没有下一个token,则返回{@link Token#NULL}
* @return
*/
private static int getNextTokenType() {
if (iterator.hasNext()) {
int type = iterator.next().getType();
iterator.previous();
return type;
}
return Token.NULL;
}
下面针对if语句的子程序举个例子, 其他的类似, 可以在文章最后的下载地址里面下载完整代码来查看.
/**
* if语句
* @throws ParserException
*/
private static TreeNode parseIfStmt() throws ParserException {
TreeNode node = new TreeNode(TreeNode.IF_STMT);
consumeNextToken(Token.IF);
consumeNextToken(Token.LPARENT);
node.setLeft(parseExp());
consumeNextToken(Token.RPARENT);
node.setMiddle(parseStmt());
if (getNextTokenType() == Token.ELSE) {
consumeNextToken(Token.ELSE);
node.setRight(parseStmt());
}
return node;
}
这个函数开头就是创建一个IF_STMT类型的TreeNode, 之所以这么明确, 是因为如果它被调用, 那么一定是要处理if语句了, 如果发现者不是if语句, 直接报错就行.
下面两个函数都是用来消耗Token的, 这俩函数在消耗Token之前会检查一下类型是否匹配, 如果不匹配就会抛出异常, 这个异常会一直向上抛, 抛到语法分析器的外面, 由调用语法分析器的人来处理异常.
消耗了一个if
和一个左括号后(
, 我们解析一个表达式, 而且解析表达式的函数会返回存储表达式的TreeNode给我们, 这个TreeNode实际上是表达式语法树的根, 注意parseExp()
这类函数也是会抛异常的. 再消耗一个右括号)
, 这时我们会解析语句, 注意这个语句是广义上的语句, 也就是说可能是单条语句, 也可能是一个语句块. 随后会判断下一个Token是不是else
, 如果是就会对else条件进行相应的处理, 最后将TreeNode返回.