两个基本功:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须要掌握递归下降算法。
两种算法思路:一种是自顶向下的语法分析,另一种则是自底向上的语法分析。
上下文无关文法(Context-Free Grammar)
start:blockStmts ; //起始
block : '{' blockStmts '}' ; //语句块
blockStmts : stmt* ; //语句块中的语句
stmt = varDecl | expStmt | returnStmt | block; //语句
varDecl : type Id varInitializer? ';' ; //变量声明
type : Int | Long ; //类型
varInitializer : '=' exp ; //变量初始化
expStmt : exp ';' ; //表达式语句
returnStmt : Return exp ';' ; //return语句
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
在语法规则里,我们把冒号左边的叫做非终结符(Non-terminal),又叫变元(Variable)
非终结符可以按照右边的正则表达式来逐步展开,直到最后都变成标识符、字面量、运算符这些不可再展开的符号,也就是终结符(Terminal)
终结符其实也是词法分析过程中形成的 Token。
像这样左边是非终结符,右边是正则表达式的书写语法规则的方式,就叫做扩展巴科斯范式(EBNF)。ANTLR 这样的语法分析器生成工具中,经常会看到这种格式的语法规则。
另一种写法,就是产生式(Production Rule),又叫做替换规则(Substitution Rule)。产生式的左边是非终结符(变元),它可以用右边的部分替代,中间通常会用箭头连接。
总结起来,语法规则是由 4 个部分组成的:一个有穷的非终结符(或变元)的集合;一个有穷的终结符的集合;一个有穷的产生式集合;一个起始非终结符(变元)。那么符合这四个特点的文法规则,就叫做上下文无关文法(Context-Free Grammar,CFG)。
正则文法是上下文无关文法的一个子集。其实,正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为“[0-9]+”)可以写成:
IntLiteral -> Digit IntLiteral1IntLiteral1 -> Digit IntLiteral1 IntLiteral1 -> εDigit -> [0-9]
在上下文无关文法里,产生式的右边可以放置任意的终结符和非终结符,而正则文法只是其中的一个子集,叫做线性文法(Linear Grammar)。它的特点是产生式的右边部分最多只有一个非终结符,比如 X->aYb,其中 a 和 b 是终结符。
在高级语言里,本地变量必须先声明,才能在后面使用。这种制约关系就是上下文相关的。不过,在语法分析阶段,我们一般不管上下文之间的依赖关系,这样能使得语法分析的任务更简单。而对于上下文相关的情况,则放到语义分析阶段再去处理。
递归下降算法(Recursive Descent Parsing)
总结起来,递归下降算法的特点是:对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式。如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做回溯(Backtracking)。
递归下降算法,能非常有效地处理很多语法规则,但是它也有两个缺点。
第一个缺点,就是著名的左递归(Left Recursion)问题。比如,在匹配算术表达式时,产生式的第一项就是一个非终结符 add,那么按照算法,要下降一层,继续匹配 add。这个过程会一直持续下去,无限递归下去。
把产生式改成右递归不就可以了吗?也就是 add 这个递归项在右边:
add -> mul + add
这样确实可以避免左递归问题,但它同时也会导致结合性的问题。
2+3+4这个表达式
它会先计算“3+4”,而不是先计算“2+3”。这破坏了加法的结合性规则,加法运算本来应该是左结合的。
标准的方法,能避免左递归问题,可以改写原来的语法规则,也就是引入add',把左递归变成右递归。
add -> mul add'
add' -> + mul add' | ε
这种改写方法虽然能够避免左递归问题,但由于add'的规则是右递归的,采用标准的递归下降算法,仍然会出现运算符结合性的错误。
把递归调用转化成循环,解决左递归问题。
add : mul ('+' mul)* ;
对于('+'mul)*这部分,我们其实可以写成一个循环。而在循环里,我们可以根据结合性的要求,手工生成正确的 AST。
递归下降算法的第二个缺点,就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费。
有个针对性的解决办法,就是预读后续的一个 Token,判断该选择哪个产生式。
自动计算出选择不同产生式的依据的算法,LL 算法家族。
LL 算法:计算 First 和 Follow 集合
LL(1) 中的第一个 L,是 Left-to-right 的缩写,代表从左向右处理 Token 串。第二个 L,是 Leftmost 的缩写,意思是最左推导。最左推导是什么呢?就是它总是先把产生式中最左侧的非终结符展开完毕以后,再去展开下一个。这也就相当于对 AST 从左子节点开始的深度优先遍历。LL(1) 中的 1,指的是预读一个 Token。
递归下降和 LL 算法,都是自顶向下的算法。还有一类算法,是自底向上的,其中的代表就是 LR 算法。
LR 算法:移进和规约
而 LR 算法的原理呢,则是从底下先拼凑出 AST 的一些局部拼图,并逐步组装成一棵完整的 AST。所以,其中的关键之处在于如何“拼凑”。
LR 算法。L 还是代表从左到右读入 Token,而 R 是最右推导(Rightmost)的意思。
LR(k),那它的意思就是会预读 k 个 Token。