2.万花筒:实现解析器和

  • 第2章简介
  • 抽象语法树(AST)
  • 分析基础知识
  • 基本表达解析
  • 二进制表达式解析
  • 解析其余部分
  • 驱动程序
  • 结论
  • 完整的代码清单

我们将构建的解析器使用递归下降解析和 操作符优先解析的组合来解析Kaleidoscope语言(后者用于二进制表达式,前者用于其他所有语言)。在我们解析之前,让我们来谈谈解析器的输出:抽象语法树。

2.2 抽象语法树(AST)

程序的AST以这样的方式捕获其行为,即编译器的后续阶段(例如代码生成)很容易解释。我们基本上希望语言中每个构造都有一个对象,AST应该对语言进行密切建模。在Kaleidoscope中,我们有表达式,原型和函数对象。我们先从表达式开始:

上面的代码显示了基本ExprAST类的定义和我们用于数字文字的一个子类。关于此代码的重要注意事项是NumberExprAST类将文字的数值捕获为实例变量。这允许编译器的后续阶段知道存储的数值是什么。

现在我们只创建AST,因此它们没有有用的访问器方法。例如,添加虚拟方法来非常容易地打印代码是非常容易的。以下是我们将在Kaleidoscope语言的基本形式中使用的其他表达式AST节点定义:

  1. class VariableExprAST : public ExprAST {
  2. std::string Name;
  3. public:
  4. VariableExprAST(const std::string &Name) : Name(Name) {}
  5. };
  6. /// BinaryExprAST - Expression class for a binary operator.
  7. class BinaryExprAST : public ExprAST {
  8. char Op;
  9. std::unique_ptr<ExprAST> LHS, RHS;
  10. public:
  11. BinaryExprAST(char op, std::unique_ptr<ExprAST> LHS,
  12. std::unique_ptr<ExprAST> RHS)
  13. : Op(op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
  14. };
  15. /// CallExprAST - Expression class for function calls.
  16. class CallExprAST : public ExprAST {
  17. std::string Callee;
  18. std::vector<std::unique_ptr<ExprAST>> Args;
  19. public:
  20. CallExprAST(const std::string &Callee,
  21. std::vector<std::unique_ptr<ExprAST>> Args)
  22. : Callee(Callee), Args(std::move(Args)) {}
  23. };

这都是(有意)相当直接:变量捕获变量名称,二元运算符捕获它们的操作码(例如’+’),并且调用捕获函数名称以及任何参数表达式的列表。关于我们的AST的一个好处是它捕获语言功能而不谈论语言的语法。请注意,没有讨论二元运算符的优先级,词法结构等。

对于我们的基本语言,这些是我们将定义的所有表达式节点。因为它没有条件控制流程,所以它不是图灵完备的; 我们将在以后的文章中解决这个问题。接下来我们需要的两件事是讨论函数接口的方法,以及讨论函数本身的方法:

  1. /// PrototypeAST - This class represents the "prototype" for a function,
  2. /// which captures its name, and its argument names (thus implicitly the number
  3. /// of arguments the function takes).
  4. class PrototypeAST {
  5. std::string Name;
  6. std::vector<std::string> Args;
  7. public:
  8. PrototypeAST(const std::string &name, std::vector<std::string> Args)
  9. : Name(name), Args(std::move(Args)) {}
  10. const std::string &getName() const { return Name; }
  11. };
  12. /// FunctionAST - This class represents a function definition itself.
  13. class FunctionAST {
  14. std::unique_ptr<PrototypeAST> Proto;
  15. std::unique_ptr<ExprAST> Body;
  16. public:
  17. FunctionAST(std::unique_ptr<PrototypeAST> Proto,
  18. std::unique_ptr<ExprAST> Body)
  19. : Proto(std::move(Proto)), Body(std::move(Body)) {}
  20. };

在Kaleidoscope中,只使用参数计数来输入函数。由于所有值都是双精度浮点数,因此每个参数的类型不需要存储在任何位置。在更具侵略性和现实性的语言中,“ExprAST”类可能具有类型字段。

有了这个脚手架,我们现在可以讨论在Kaleidoscope中解析表达式和函数体。

2.3 解析基础

现在我们要构建一个AST,我们需要定义解析器代码来构建它。这里的想法是我们要解析类似“x + y”(由词法分析器返回三个标记)到AST中,这可以通过这样的调用生成:

  1. auto LHS = llvm::make_unique<VariableExprAST>("x");
  2. auto RHS = llvm::make_unique<VariableExprAST>("y");
  3. auto Result = std::make_unique<BinaryExprAST>('+', std::move(LHS),
  4. std::move(RHS));

为此,我们首先定义一些基本的帮助程序:

  1. /// CurTok/getNextToken - Provide a simple token buffer. CurTok is the current
  2. /// token the parser is looking at. getNextToken reads another token from the
  3. /// lexer and updates CurTok with its results.
  4. static int CurTok;
  5. static int getNextToken() {
  6. return CurTok = gettok();
  7. }

这在词法分析器周围实现了一个简单的标记缓冲区。这允许我们在词法分析器返回时提前查看一个标记。我们的解析器中的每个函数都假定CurTok是需要解析的当前标记。

  1. /// LogError* - These are little helper functions for error handling.
  2. std::unique_ptr<ExprAST> LogError(const char *Str) {
  3. fprintf(stderr, "LogError: %s\n", Str);
  4. return nullptr;
  5. }
  6. std::unique_ptr<PrototypeAST> LogErrorP(const char *Str) {
  7. LogError(Str);
  8. return nullptr;
  9. }

该LogError程序是我们的解析器将用来处理错误的简单的辅助程序。我们的解析器中的错误恢复不是最好的,并且不是特别用户友好的,但它对我们的教程来说已经足够了。这些例程使得在具有各种返回类型的例程中更容易处理错误:它们总是返回null。

有了这些基本的辅助函数,我们就可以实现我们语法的第一部分:数字文字。

  1. /// numberexpr ::= number
  2. auto Result = llvm::make_unique<NumberExprAST>(NumVal);
  3. getNextToken(); // consume the number
  4. return std::move(Result);
  5. }

这个例程非常简单:它希望在当前令牌是tok_number令牌时被调用。它获取当前数字值,创建NumberExprAST节点,将词法分析器前进到下一个标记,最后返回。

这有一些有趣的方面。最重要的一点是,这个例程会占用与生产相对应的所有令牌,并返回带有下一个令牌(它不是语法生成的一部分)的词法分析器缓冲区。这是递归下降解析器的一种相当标准的方法。有关更好的示例,括号运算符的定义如下:

  1. /// parenexpr ::= '(' expression ')'
  2. static std::unique_ptr<ExprAST> ParseParenExpr() {
  3. getNextToken(); // eat (.
  4. auto V = ParseExpression();
  5. if (!V)
  6. return nullptr;
  7. return LogError("expected ')'");
  8. getNextToken(); // eat ).
  9. return V;
  10. }

1)它显示了我们如何使用LogError例程。调用时,此函数需要当前标记为’(’标记,但在解析子表达式后,可能没有’)’等待。例如,如果用户输入“(4 x”而不是“(4)”,解析器应该发出错误。因为错误可能发生,解析器需要一种方式来指示它们发生了:在我们的解析器中,我们返回null错误。

2)这个函数的另一个有趣的方面是它通过调用使用递归ParseExpression(我们很快就会看到它 ParseExpression可以调用ParseParenExpr)。这很强大,因为它允许我们处理递归语法,并使每个生产变得非常简单。请注意,括号不会导致AST节点本身的构造。虽然我们可以这样做,但括号中最重要的作用是引导解析器并提供分组。解析器构造AST后,不需要括号。

下一个简单的生产是用于处理变量引用和函数调用:

此例程遵循与其他例程相同的样式。(如果当前令牌是tok_identifier令牌,则期望被调用)。它还有递归和错误处理。这方面的一个令人感兴趣的方面是,它使用先行,以确定是否在当前标识符是一个独立变量的参考,或者如果它是一个函数调用的表达。它通过检查标识符后面的标记是否是’(’标记,根据需要构造一个VariableExprAST或 一个CallExprAST节点来处理这个问题。

现在我们已经拥有了所有简单的表达式解析逻辑,我们可以定义一个辅助函数将它们组合成一个入口点。我们称这类表达式为“主要”表达式,原因将在本教程后面更加清晰。为了解析任意的主表达式,我们需要确定它是什么类型的表达式:

  1. /// primary
  2. /// ::= identifierexpr
  3. /// ::= numberexpr
  4. /// ::= parenexpr
  5. static std::unique_ptr<ExprAST> ParsePrimary() {
  6. switch (CurTok) {
  7. default:
  8. return LogError("unknown token when expecting an expression");
  9. case tok_identifier:
  10. return ParseIdentifierExpr();
  11. case tok_number:
  12. return ParseNumberExpr();
  13. case '(':
  14. return ParseParenExpr();
  15. }
  16. }

现在你看到了这个函数的定义,为什么我们可以在各种函数中假设CurTok的状态更为明显。这使用预测来确定正在检查哪种表达式,然后使用函数调用对其进行解析。

现在处理了基本表达式,我们需要处理二进制表达式。它们有点复杂。

2.5 二进制表达式解析

二进制表达式很难解析,因为它们通常是模糊的。例如,当给定字符串“x + y z”时,解析器可以选择将其解析为“(x + y) z”或“x +(y z)”。对于数学中的常见定义,我们期望后面的解析,因为“”(乘法)具有比“+”(加法)更高的优先级。

有很多方法可以解决这个问题,但优雅而有效的方法是使用Operator-Precedence Parsing。此解析技术使用二元运算符的优先级来指导递归。首先,我们需要一个优先级表:

  1. /// BinopPrecedence - This holds the precedence for each binary operator that is
  2. /// defined.
  3. static std::map<char, int> BinopPrecedence;
  4. /// GetTokPrecedence - Get the precedence of the pending binary operator token.
  5. static int GetTokPrecedence() {
  6. if (!isascii(CurTok))
  7. return -1;
  8. // Make sure it's a declared binop.
  9. int TokPrec = BinopPrecedence[CurTok];
  10. if (TokPrec <= 0) return -1;
  11. return TokPrec;
  12. }
  13. int main() {
  14. // Install standard binary operators.
  15. // 1 is lowest precedence.
  16. BinopPrecedence['<'] = 10;
  17. BinopPrecedence['+'] = 20;
  18. BinopPrecedence['-'] = 20;
  19. BinopPrecedence['*'] = 40; // highest.
  20. ...
  21. }

对于万花筒的基本形式,我们只支持4个二元运算符(这显然可以由你,我们的勇敢和无畏的读者扩展)。该GetTokPrecedence函数返回当前标记的优先级,如果标记不是二元运算符,则返回-1。使用地图可以轻松添加新运算符,并清楚地表明算法不依赖于所涉及的特定运算符,但是很容易消除映射并在GetTokPrecedence函数中进行比较 。(或者只使用固定大小的数组)。

通过上面定义的帮助程序,我们现在可以开始解析二进制表达式。运算符优先级解析的基本思想是将具有可能不明确的二元运算符的表达式分解为多个部分。例如,考虑表达式“a + b +(c + d) e f + g”。运算符优先级解析将此视为由二元运算符分隔的主表达式流。因此,它将首先解析主要的主要表达式“a”,然后它将看到对[+,b] [+,(c + d)] [,e] [,f]和[+,g] ]。请注意,因为括号是主表达式,所以二进制表达式解析器根本不需要担心嵌套的子表达式,如(c + d)。

首先,表达式是一个主表达式,可能后跟一系列[binop,primaryexpr]对:

  1. /// expression
  2. /// ::= primary binoprhs
  3. ///
  4. static std::unique_ptr<ExprAST> ParseExpression() {
  5. auto LHS = ParsePrimary();
  6. if (!LHS)
  7. return nullptr;
  8. return ParseBinOpRHS(0, std::move(LHS));
  9. }

ParseBinOpRHS是为我们解析对序列的函数。它需要一个优先级和一个指向目前已解析的部分的表达式的指针。请注意,“x”是一个完全有效的表达式:因此,“binoprhs”被允许为空,在这种情况下,它返回传递给它的表达式。在上面的示例中,代码将“a”的表达式传递给,ParseBinOpRHS并且当前标记为“+”。

传入的优先级值ParseBinOpRHS表示允许该函数吃的 最小运算符优先级。例如,如果当前对流是[+,x]并且ParseBinOpRHS以40的优先级传递,则它不会消耗任何标记(因为’+’的优先级仅为20)。考虑到这一点,ParseBinOpRHS 从以下开始:

  1. /// binoprhs
  2. /// ::= ('+' primary)*
  3. static std::unique_ptr<ExprAST> ParseBinOpRHS(int ExprPrec,
  4. std::unique_ptr<ExprAST> LHS) {
  5. // If this is a binop, find its precedence.
  6. while (1) {
  7. int TokPrec = GetTokPrecedence();
  8. // If this is a binop that binds at least as tightly as the current binop,
  9. // consume it, otherwise we are done.
  10. if (TokPrec < ExprPrec)
  11. return LHS;

此代码获取当前令牌的优先级,并检查是否过低。因为我们将无效标记定义为优先级为-1,所以此检查隐式地知道当标记流用完二元运算符时,对流结束。如果此检查成功,我们知道该令牌是二元运算符,并且它将包含在此表达式中:

  1. // Okay, we know this is a binop.
  2. int BinOp = CurTok;
  3. getNextToken(); // eat binop
  4. // Parse the primary expression after the binary operator.
  5. auto RHS = ParsePrimary();
  6. if (!RHS)
  7. return nullptr;

因此,此代码吃掉(并记住)二元运算符,然后解析后面的主表达式。这构建了整个对,第一个是运行示例的[+,b]。

  1. // If BinOp binds less tightly with RHS than the operator after RHS, let
  2. // the pending operator take RHS as its LHS.
  3. int NextPrec = GetTokPrecedence();
  4. if (TokPrec < NextPrec) {

如果binop在“RHS”右边​​的优先级低于或等于我们当前运算符的优先级,那么我们知道括号关联为“(a + b)binop …”。在我们的示例中,当前运算符为“+”,下一个运算符为“+”,我们知道它们具有相同的优先级。在这种情况下,我们将为“a + b”创建AST节点,然后继续解析:

  1. ... if body omitted ...
  2. }
  3. // Merge LHS/RHS.
  4. LHS = llvm::make_unique<BinaryExprAST>(BinOp, std::move(LHS),
  5. std::move(RHS));
  6. } // loop around to the top of the while loop.

此时,我们知道我们的主要RHS的二元运算符优先于我们当前正在解析的binop。因此,我们知道任何运算符都优先于“+”的对的序列应该被一起解析并返回为“RHS”。为此,我们以递归方式调用ParseBinOpRHS指定“TokPrec + 1” 的函数作为其继续所需的最小优先级。在上面的例子中,这将导致它将“(c + d) e f”的AST节点作为RHS返回,然后将其设置为“+”表达式的RHS。

最后,在while循环的下一次迭代中,解析“+ g”片段并将其添加到AST。使用这一小段代码(14个非平凡的行),我们以非常优雅的方式正确处理完全通用的二进制表达式解析。这是对这段代码的旋风之旅,它有点微妙。我建议通过几个棘手的例子来看看它是如何工作的。

这包含了表达式的处理。此时,我们可以将解析器指向任意标记流并从中构建表达式,停止在不属于表达式的第一个标记处。接下来我们需要处理函数定义等。

缺少的是缺少功能原型的处理。在Kaleidoscope中,这些用于’extern’函数声明以及函数体定义。执行此操作的代码是直截了当的,并且不是很有趣(一旦表达式幸存下来):

  1. /// prototype
  2. /// ::= id '(' id* ')'
  3. static std::unique_ptr<PrototypeAST> ParsePrototype() {
  4. if (CurTok != tok_identifier)
  5. return LogErrorP("Expected function name in prototype");
  6. std::string FnName = IdentifierStr;
  7. getNextToken();
  8. if (CurTok != '(')
  9. return LogErrorP("Expected '(' in prototype");
  10. // Read the list of argument names.
  11. std::vector<std::string> ArgNames;
  12. while (getNextToken() == tok_identifier)
  13. ArgNames.push_back(IdentifierStr);
  14. if (CurTok != ')')
  15. return LogErrorP("Expected ')' in prototype");
  16. // success.
  17. getNextToken(); // eat ')'.
  18. return llvm::make_unique<PrototypeAST>(FnName, std::move(ArgNames));
  19. }

鉴于此,函数定义非常简单,只是一个原型加上一个表达式来实现正文:

  1. /// definition ::= 'def' prototype expression
  2. static std::unique_ptr<FunctionAST> ParseDefinition() {
  3. getNextToken(); // eat def.
  4. auto Proto = ParsePrototype();
  5. if (!Proto) return nullptr;
  6. if (auto E = ParseExpression())
  7. return llvm::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  8. return nullptr;
  9. }

另外,我们支持’extern’来声明’sin’和’cos’之类的函数,以及支持用户函数的前向声明。这些’extern’只是没有身体的原型:

  1. /// external ::= 'extern' prototype
  2. static std::unique_ptr<PrototypeAST> ParseExtern() {
  3. getNextToken(); // eat extern.
  4. return ParsePrototype();
  5. }

最后,我们还让用户输入任意顶级表达式并动态评估它们。我们将通过为它们定义匿名的nullary(零参数)函数来处理这个问题:

  1. /// toplevelexpr ::= expression
  2. static std::unique_ptr<FunctionAST> ParseTopLevelExpr() {
  3. if (auto E = ParseExpression()) {
  4. // Make an anonymous proto.
  5. auto Proto = llvm::make_unique<PrototypeAST>("", std::vector<std::string>());
  6. return llvm::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  7. }
  8. return nullptr;
  9. }

现在我们已经完成了所有部分,让我们构建一个小驱动程序,让我们实际执行我们构建的代码!

2.7。驱动程序

这个驱动程序只是通过顶级调度循环调用所有解析部分。这里没什么有趣的,所以我只包括顶级循环。请参阅下面的“顶级解析”部分中的完整代码。

  1. /// top ::= definition | external | expression | ';'
  2. static void MainLoop() {
  3. while (1) {
  4. fprintf(stderr, "ready> ");
  5. switch (CurTok) {
  6. case tok_eof:
  7. return;
  8. case ';': // ignore top-level semicolons.
  9. getNextToken();
  10. break;
  11. case tok_def:
  12. HandleDefinition();
  13. break;
  14. case tok_extern:
  15. HandleExtern();
  16. break;
  17. default:
  18. HandleTopLevelExpression();
  19. break;
  20. }
  21. }
  22. }

最有趣的部分是我们忽略顶级分号。你问,这是为什么?基本原因是,如果在命令行中键入“4 + 5”,则解析器不知道这是否是您要键入的结尾。例如,在下一行,您可以键入“def foo …”,在这种情况下,4 + 5是顶级表达式的结尾。或者,您可以键入“* 6”,这将继续表达式。使用顶级分号允许您键入“4 + 5;”,解析器将知道您已完成。

只有不到400行的注释代码(240行非注释,非空代码),我们完全定义了我们的最小语言,包括词法分析器,解析器和AST构建器。完成此操作后,可执行文件将验证Kaleidoscope代码并告诉我们它是否在语法上无效。例如,这是一个示例交互:

  1. $ ./a.out
  2. ready> def foo(x y) x+foo(y, 4.0);
  3. Parsed a function definition.
  4. ready> def foo(x y) x+y y;
  5. Parsed a function definition.
  6. Parsed a top-level expr
  7. ready> def foo(x y) x+y );
  8. Parsed a function definition.
  9. Error: unknown token when expecting an expression
  10. ready> extern sin(a);
  11. ready> Parsed an extern
  12. ready> ^D
  13. $

以下是我们正在运行的示例的完整代码清单。因为它使用LLVM库,我们需要将它们链接起来。为此,我们使用 llvm-config工具通知makefile /命令行有关使用哪些选项:

  1. # Compile
  2. clang++ -g -O3 toy.cpp `llvm-config --cxxflags`
  3. ./a.out

下一步:实现代码生成到LLVM IR