解析器基础
开始构造AST之前,先要准备好用于构造AST的语法解析器。说白了,就是要利用语法解析器把x+y
这样的输入(由词法分析器返回的三个语元)分解成由下列代码生成的AST:
auto LHS = llvm::make_unique<VariableExprAST>("x"); |
为此,我们先定义几个辅助函数:
/// CurTok/getNextToken - Provide a simple token buffer. CurTok is the current |
这在词法分析器周围实现了一个简单的标记缓冲区。
这允许我们在词法分析器返回时提前查看一个标记。
我们的解析器中的每个函数都假定CurTok是需要解析的当前标记。
/// LogError* - These are little helper functions for error handling. |
LogError
例程是我们的解析器将用于处理错误的简单帮助程序例程。
我们的解析器中的错误恢复不是最好的,并不是特别用户友好的,但它对我们的教程就足够了。
这些例程使得在具有各种返回类型的例程中更容易处理错误:它们总是返回null。
有了这些基本的辅助函数,我们就可以实现我们语法的第一部分:数字文字量。
解析基本表达式
之所以先从数值常量下手,是因为它最简单。Kaleidoscope语法中的每一条生成规则(production),都需要一个对应的解析函数。对于数值常量,就是:
/// numberexpr ::= number |
这个函数很简单:调用它的时候,当前待解析语元只能是tok_number
。该函数用刚解析出的数值构造出了一个NumberExprAST
节点,然后令词法分析器继续读取下一个语元,最后返回构造的AST节点。
这里有几处很有意思,其中最显著的便是该函数的行为,它不仅消化了所有与当前生成规则相关的所有语元,还把下一个待解析的语元放进了词法分析器的语元缓冲(该语元与当前的生成规则无关)。这是非常标准的递归下降解析器的行为。下面这个括号运算符的例子更能说明问题:
/// parenexpr ::= '(' expression ')' |
该函数体现了这个语法解析器的几个特点:
- 它展示了
Error
函数的用法。调用该函数时,待解析的语元只能是(
,然而解析完子表达式之后,紧跟着的语元却不一定是)
。比如,要是用户输入的是(4 x
而不是(4)
,语法解析器就应该报错。既然错误时有发生,语法解析器就必须提供一条报告错误的途径:就这个语法解析器而言,应对之道就是返回NULL
。 - 该函数的另一特点在于递归调用了
ParseExpression
(很快我们就会看到ParseExpression
还会反过来调用ParseParenExpr
)。这种手法简化了递归语法的处理,每一条生成规则的实现都得以变得非常简洁。需要注意的是,我们没有必要为括号构造AST节点。虽然这么做也没错,但括号的作用主要还是对表达式进行分组进而引导语法解析过程。当语法解析器构造完AST之后,括号就没用了。
下一条生成规则也很简单,它负责处理变量引用和函数调用:
/// identifierexpr |
该函数与其它函数的风格别无二致(调用该函数时当前语元必须是tok_identifier
)。前文提到的有关递归和错误处理的特点它统统具备。有意思的是这里采用了预读(lookahead)的手段来试探当前标识符的类型,判断它究竟是个独立的变量引用还是个函数调用。只要检查紧跟标识符之后的语元是不是(
,它就能知道到底应该构造VariableExprAST
节点还是CallExprAST
节点。
现在,解析各种表达式的代码都已经完成,不妨再添加一个辅助函数,为它们梳理一个统一的入口。我们将上述表达式称为主表达式(primary expression)。在解析各种主表达式时,我们首先要判定待解析表达式的类别:
/// primary |
看完这个函数的定义,你就能明白为什么先前的各个函数能够放心大胆地对CurTok
的取值作出假设了。这里预读了下一个语元,预先对待解析表达式的类型作出了判断,然后才调用相应的函数进行解析。
基本表达式全都搞定了,下面开始开始着手解析更为复杂的二元表达式。
解析二元表达式
二元表达式的解析难度要大得多,因为它们往往具有二义性。例如,给定字符串x+y*z
,语法解析器既可以将之解析为(x+y)*z
,也可以将之解析为x+(y*z)
。按照通常的数学定义,我们期望解析成后者,因为*
(乘法)的优先级要高于+
(加法)。
这个问题的解法很多,其中属运算符优先级解析最为优雅和高效。这是一种利用二元运算符的优先级来引导递归调用走向的解析技术。首先,我们需要制定一张优先级表:
/// BinopPrecedence - This holds the precedence for each binary operator that is |
最基本的Kaleidoscope语言仅支持4种二元运算符(对于我们英勇无畏的读者来说,再加几个运算符自然是小菜一碟)。函数GetTokPrecedence
用于查询当前语元的优先级,如果当前语元不是二元运算符则返回-1
。这里的map
简化了新运算符的添加,同时也可以证明我们的算法与具体的运算符无关。当然,要想去掉map
直接在GetTokPrecedence
中比较优先级也很简单。(甚至可以直接使用定长数组)。
有了上面的函数作为辅助,我们就可以开始解析二元表达式了。运算符优先级解析的基本思想就是通过拆解含有二元运算符的表达式来解决可能的二义性问题。以表达式a+b+(c+d)*e*f+g
为例,在进行运算符优先级解析时,它将被视作一串按二元运算符分隔的主表达式。按照这个思路,解析出来的第一个主表达式应该是a
,紧跟着是若干个有序对,即:[+, b]
、[+, (c+d)]
、[*, e]
、[*, f]
和[+,g]
。注意,括号表达式也是主表达式,所以在解析二元表达式时无须特殊照顾(c+d)
这样的嵌套表达式。
一开始,每个表达式都由一个主表达式打头阵,身后可能还跟着一串由有序对构成的列表,其中有序对的格式为[binop, primaryexpr]
:
/// expression |
函数ParseBinOpRHS
用于解析有序对列表(其中RHS
是Right Hand Side的缩写,表示右侧;与此相对应,LHS
表示左侧)。它的参数包括一个整数和一个指针,其中整数代表运算符优先级,指针则指向当前已解析出来的那部分表达式。注意,单独一个x
也是合法的表达式:也就是说binoprhs
有可能为空;碰到这种情况时,函数将直接返回作为参数传入的表达式。在上面的例子中,传入ParseBinOpRHS
的表达式是a
,当前语元是+
。
传入ParseBinOpRHS
的优先级表示的是该函数所能处理的最低运算符优先级。假设语元流中的下一对是[+, x]
,且传入ParseBinOpRHS
的优先级是40
,那么该函数将直接返回(因为+
的优先级是20
)。搞清楚这一点之后,我们再来看ParseBinOpRHS
的定义,函数的开头是这样的:
/// binoprhs |
这段代码首先检查当前语元的优先级,如果优先级过低就直接返回。由于无效语元(这里指不是二元运算符的语元——译者注)的优先级都被判作-1
,因此当语元流中的所有二元运算符都被处理完毕时,该检查自然不会通过。如果检查通过,则可知当前语元一定是二元运算符,应该被纳入当前表达式:
// Okay, we know this is a binop. |
就这样,二元运算符处理完毕(并保存妥当)之后,紧跟其后的主表达式也随之解析完毕。至此,本例中的第一对有序对[+, b]
就构造完了。
现在表达式的左侧和RHS
序列中第一对都已经解析完毕,该考虑表达式的结合次序了。路有两条,要么选择(a+b) binop unparsed
,要么选择a + (b binopunparsed)
。为了搞清楚到底该怎么走,我们先预读出binop
,查出它的优先级,再将之与BinOp
(本例中是“+
”)的优先级相比较:
// If BinOp binds less tightly with RHS than the operator after RHS, let |
binop
位于RHS
的右侧,如果binop
的优先级低于或等于当前运算符的优先级,则可知括号应该加在前面,即按(a+b) binop ...
处理。在本例中,当前运算符是+
,下一个运算符也是+
,二者的优先级相同。既然如此,理应按照“a+b
”来构造AST节点,然后我们继续解析:
... if body omitted ... |
接着上面的例子,“a+b+
”的前半段被解析成了“(a+b)
”,于是“+
”成为当前语元,进入下一轮迭代。上述代码进而将“(c+d)
”识别为主表达式,并构造出相应的有序对[+,(c+d)]
。现在,主表达式右侧的binop
是“*
”,由于“*
”的优先级高于“+
”,负责检查运算符优先级的if
判断通过,执行流程得以进入if
语句的内部。
现在关键问题来了:if
语句内的代码怎样才能完整解析出表达式的右半部分呢?尤其是,为了构造出正确的AST,变量RHS
必须完整表达“(c+d)*e*f
”。出人意料的是,写成代码之后,这个问题出奇地简单:
// If BinOp binds less tightly with RHS than the operator after RHS, let |
看一下主表达式右侧的二元运算符,我们发现它的优先级比当前正在解析的binop
的优先级要高。由此可知,如果自binop
以右的若干个连续有序对都含有优先级高于“+
”的运算符,那么就应该把它们全部解析出来,拼成“RHS
”后返回。为此,我们将最低优先级设为“TokPrec+1
”,递归调用函数“ParseBinOpRHS
”。该调用会完整解析出上述示例中的“(c+d)*e*f
”,并返回构造出的AST节点,这个节点就是“+
”表达式右侧的RHS
。
最后,while
循环的下一轮迭代将会解析出剩下的“+g
”并将之纳入AST。仅需区区14行代码,我们就完整而优雅地实现了通用的二元表达式解析算法。上述讲解比较简略,这段代码还是颇有些难度的。我建议你找些棘手的例子多跑跑看,好彻底搞明白这段代码背后的原理。
表达式的解析就此告一段落。现在,我们可以将任意语元流喂入语法解析器并逐步从中构造出表达式,直到解析至不属于表达式的语元为止。接下来,我们来处理函数定义等其他结构。
解析其余结构
下面来解析函数原型。在Kaleidoscope语言中,有两处会用到函数原型:一是extern
函数声明,二是函数定义。相关代码很简单,没太大意思(相对于解析表达式的代码而言):
/// prototype |
在此基础之上,函数定义就很简单了,说白了就是一个函数原型再加一个用作函数体的表达式:
/// definition ::= 'def' prototype expression |
除了用于用户自定义函数的前置声明,extern
语句还可以用来声明sin
、cos
等(C标准库)函数。这些extern
语句不过就是些不带函数体的函数原型罢了:
/// external ::= 'extern' prototype |
最后,我们还允许用户随时在顶层输入任意表达式并求值。这一特性是通过一个特殊的匿名零元函数(没有任何参数的函数)实现的,所有顶层表达式都定义在这个函数之内:
/// toplevelexpr ::= expression |
现在所有零部件都准备完毕了,只需再编写一小段引导代码就可以跑起来了!