从零开始实现一门编程语言(二):实现一个解析器和抽象语法树

175 阅读16分钟

欢迎来到用LLVM实现一门语言教程的第二章。本章向您展示如何使用第1章中构建的词法分析器,为万花筒语言构建完整的解析器。有了解析器之后,我们将定义并构建一个抽象语法树(AST)。我们将构建的解析器使用递归下降解析和操作符优先解析的组合来解析Kaleidoscope语言(后者用于二进制表达式,前者用于其他所有内容)。在开始解析之前,让我们先讨论一下解析器的输出:抽象语法树。

2.1 抽象语法树 (AST)

AST节点定义

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

// 表达式基类
class ExprAST {
public:
  virtual ~ExprAST() = default;
};

// 数字字面量节点
class NumberExprAST : public ExprAST {
  double Val;
public:
  NumberExprAST(double Val) : Val(Val) {}
};

// 变量引用节点
class VariableExprAST : public ExprAST {
  std::string Name;
public:
  VariableExprAST(const std::string &Name) : Name(Name) {}
};

// 二元表达式节点
class BinaryExprAST : public ExprAST {
  char Op;
  std::unique_ptr<ExprAST> LHS, RHS;
public:
  BinaryExprAST(char Op, std::unique_ptr<ExprAST> LHS,
                std::unique_ptr<ExprAST> RHS)
    : Op(Op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
};

上面的代码显示了ExprAST基类和一个用于数字字面值的子类的定义。关于这段代码需要注意的重要一点是,NumberExprAST类捕获文字的数值作为实例变量。这允许编译器的后面阶段知道存储的数值是什么。
现在我们只创建AST,因此在AST上没有有用的访问器方法。例如,添加一个虚拟方法来漂亮地打印代码是非常容易的。下面是我们将在Kaleidoscope的基本形式中使用的其他表达式AST节点定义

//表示表达式变量 例如 变量 'a'
class VariableExprAST : public ExprAST {
  std::string Name;

public:
  VariableExprAST(const std::string &Name) : Name(Name) {}
};

//表示二元操作
class BinaryExprAST : public ExprAST {
  char Op;
  std::unique_ptr<ExprAST> LHS, RHS;

public:
  BinaryExprAST(char Op, std::unique_ptr<ExprAST> LHS,
                std::unique_ptr<ExprAST> RHS)
    : Op(Op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
};

// 表示函数调用表达式
class CallExprAST : public ExprAST {
  std::string Callee;
  std::vector<std::unique_ptr<ExprAST>> Args;

public:
  CallExprAST(const std::string &Callee,
              std::vector<std::unique_ptr<ExprAST>> Args)
    : Callee(Callee), Args(std::move(Args)) {}
};

这一切都相当直接:变量捕获变量名,二进制操作符捕获它们的操作码(例如‘ + ’),调用捕获函数名以及任何参数表达式的列表。我们的AST的一个优点是,它捕获了语言特性,而不讨论语言的语法。注意,这里没有讨论二元操作符的优先级、词法结构等。
对于我们的基本语言,这些是我们将定义的所有表达式节点。因为它没有条件控制流,所以它不是图灵完备的;我们将在以后的文章中修复这个问题。我们接下来需要的两件事是一种谈论函数接口的方式,以及一种谈论函数本身的方式:

//这个类代表函数的原型 他捕捉了函数名称、以及函数参数名称(以及隐含捕捉了函数的个数).
class PrototypeAST {
  std::string Name;
  std::vector<std::string> Args;

public:
  PrototypeAST(const std::string &Name, std::vector<std::string> Args)
    : Name(Name), Args(std::move(Args)) {}

  const std::string &getName() const { return Name; }
};

//这个类表示函数定义
class FunctionAST {
  std::unique_ptr<PrototypeAST> Proto;
  std::unique_ptr<ExprAST> Body;

public:
  FunctionAST(std::unique_ptr<PrototypeAST> Proto,
              std::unique_ptr<ExprAST> Body)
    : Proto(std::move(Proto)), Body(std::move(Body)) {}
};

在Kaleidoscope中,函数的类型仅包含其参数的计数。由于所有值都是双精度浮点数,因此每个参数的类型不需要存储在任何地方。在更激进和现实的语言中,ExprAST类可能会有一个类型字段。有了这个脚手架,我们现在可以讨论在Kaleidoscope中解析表达式和函数体。

2.2 解析器基础

现在我们有了要构建的AST,我们需要定义解析器代码来构建它。这里的想法是,我们想要解析像“x+y”这样的东西(词法分析器将其作为三个token返回)到一个AST中,该AST可以通过以下调用生成:

auto LHS = std::make_unique<VariableExprAST>("x");
auto RHS = std::make_unique<VariableExprAST>("y");
auto Result = std::make_unique<BinaryExprAST>('+', std::move(LHS),
                                              std::move(RHS));

为了做到这一点,我们将从定义一些基本的助手例程开始:

// CurTok/getNextToken -提供一个简单的令牌缓冲区。CurTok是当前  
// 解析器正在查找的token。getNextToken读取来自词法分析器中另外一个token
   并更新CurTok的结果
static int CurTok;
static int getNextToken() {
  return CurTok = gettok();
}

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

//这是帮助错误处理的类
std::unique_ptr<ExprAST> LogError(const char *Str) {
  fprintf(stderr, "Error: %s\n", Str);
  return nullptr;
}
std::unique_ptr<PrototypeAST> LogErrorP(const char *Str) {
  LogError(Str);
  return nullptr;
}

LogError例程是简单的助手例程,解析器将使用它来处理错误。我们的解析器中的错误恢复不是最好的,也不是特别用户友好,但对于我们的教程来说已经足够了。这些例程可以更容易地处理具有各种返回类型的例程中的错误:它们总是返回null。有了这些基本的辅助函数,我们就可以实现语法的第一部分:数字字面量。

2.3 基本表达式解析

我们从数字文字开始,因为它们是最容易处理的。对于语法中的每个结果,我们将定义一个解析该结果的函数。对于数字字面值,我们有:

/// 例如表达式 numberexpr ::= number
static std::unique_ptr<ExprAST> ParseNumberExpr() {
  auto Result = std::make_unique<NumberExprAST>(NumVal);
  getNextToken(); // consume the number
  return std::move(Result);
}

这个例程非常简单:当当前token是tok_number时,它将被调用。它接受当前数值,创建一个NumberExprAST节点,将词法分析器推进到下一个toekn,最后返回。这其中有一些有趣的方面。最重要的一点是,这个例程吃掉与结果对应的所有标记,并返回词法分析器缓冲区,并准备好使用下一个标记(它不是语法结果的一部分)。对于递归下降解析器来说,这是一种相当标准的方法。为了获得更好的示例,定义了括号操作符

// parenexpr ::= '(' expression ')'
static std::unique_ptr<ExprAST> ParseParenExpr() {
  getNextToken(); // eat (.
  auto V = ParseExpression();
  if (!V)
    return nullptr;

  if (CurTok != ')')
    return LogError("expected ')'");
  getNextToken(); // eat ).
  return V;
}

这个函数说明了关于解析器的许多有趣的事情:\

  1. 它展示了我们如何使用LogError例程。当调用时,此函数期望当前标记是‘(’标记,但在解析子表达式后,可能没有‘)’等待。例如,如果用户输入“(4 x)”而不是“(4)”,解析器应该会发出一个错误。由于错误可能发生,解析器需要一种方法来指示它们发生了:在我们的解析器中,我们在出现错误时返回null。\
  2. 这个函数的另一个有趣的方面是,它通过调用ParseExpression使用递归(我们很快就会看到ParseExpression可以调用ParseParenExpr)。这很强大,因为它允许我们处理递归语法,并使每个结果非常简单。注意,括号不会导致AST节点本身的构造。虽然我们可以这样做,但括号最重要的作用是指导解析器并提供分组。一旦解析器构造AST,就不需要括号了。
    下一个简单的结果是处理变量引用和函数调用
///   变量引用
///   ::= identifier
///   ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> ParseIdentifierExpr() {
  std::string IdName = IdentifierStr;

  getNextToken();   // eat identifier.

  if (CurTok != '(') // Simple variable ref.
    return std::make_unique<VariableExprAST>(IdName);

  // 函数
  getNextToken();  // eat (
  std::vector<std::unique_ptr<ExprAST>> Args;
  if (CurTok != ')') {
    while (true) {
      if (auto Arg = ParseExpression())
        Args.push_back(std::move(Arg));
      else
        return nullptr;

      if (CurTok == ')')
        break;

      if (CurTok != ',')
        return LogError("Expected ')' or ',' in argument list");
      getNextToken();
    }
  }
  // Eat the ')'.
  getNextToken();

  return std::make_unique<CallExprAST>(IdName, std::move(Args));
}

这个例程遵循与其他例程相同的样式。(如果当前token是tok_identifier,则期望调用它)。它还具有递归和错误处理。其中一个有趣的方面是,它使用提前查找来确定当前标识符是独立变量引用还是函数调用表达式。它通过检查标识符后面的令牌是否为'('令牌来处理此问题,并根据需要构造一个合适的VariableExprAST或CallExprAST节点。\

现在我们已经准备好了所有简单的表达式解析逻辑,我们可以定义一个辅助函数来将它们打包到一个入口点中。我们称这类表达式为“主”表达式,原因将在本教程后面变得更清楚。为了解析任意主表达式,我们需要确定它是什么类型的表达式:

///   解析主要表达式
///   ::= identifierexpr
///   ::= numberexpr
///   ::= parenexpr
static std::unique_ptr<ExprAST> ParsePrimary() {
  switch (CurTok) {
  default:
    return LogError("unknown token when expecting an expression");
  case tok_identifier:
    return ParseIdentifierExpr();
  case tok_number:
    return ParseNumberExpr();
  case '(':
    return ParseParenExpr();
  }
}

既然您看到了这个函数的定义,那么为什么我们可以在各个函数中假设CurTok的状态就更明显了。它使用前瞻性来确定要检查的表达式类型,然后使用函数调用对其进行解析。处理了基本表达式之后,我们需要处理二元表达式。它们更复杂一些。

2.4 二元表达式解析

二进制表达式很难解析,因为它们通常是不明确的。例如,当给定字符串“x+yz”时,解析器可以选择将其解析为“(x+y)z”或“x+(yz)”。对于来自数学的通用定义,我们期望后面的解析,因为“”(乘法)比“+”(加法)具有更高的优先级。有许多方法可以处理这个问题,但一种优雅而有效的方法是使用操作符优先解析。这种解析技术使用二进制操作符的优先级来指导递归。首先,我们需要一个优先顺序表:

/// BinopPrecedence 保存二元操作符的优先级
static std::map<char, int> BinopPrecedence;
//GetTokPrecedence - 获取挂起的二进制运算符令牌的优先级
static int GetTokPrecedence() {
  if (!isascii(CurTok))
    return -1;

  // Make sure it's a declared binop.
  int TokPrec = BinopPrecedence[CurTok];
  if (TokPrec <= 0) return -1;
  return TokPrec;
}

int main() {
  // Install standard binary operators.
  // 1 is lowest precedence.
  BinopPrecedence['<'] = 10;
  BinopPrecedence['+'] = 20;
  BinopPrecedence['-'] = 20;
  BinopPrecedence['*'] = 40;  // highest.
  ...
}

对于Kaleidoscope的基本形式,我们将只支持4个二进制运算符(这显然可以由您扩展)。GetTokPrecedence函数返回当前令牌的优先级,如果令牌不是二进制运算符,则返回-1。使用映射可以很容易地添加新的操作符,并清楚地表明算法不依赖于所涉及的特定操作符,但是消除映射并在GetTokPrecedence函数中进行比较也很容易。(或者只使用固定大小的数组)

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

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

/// expression
///   ::= primary binoprhs
///
static std::unique_ptr<ExprAST> ParseExpression() {
  auto LHS = ParsePrimary();
  if (!LHS)
    return nullptr;

  return ParseBinOpRHS(0, std::move(LHS));
}

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

传递给ParseBinOpRHS的优先级值表示该函数允许使用的最小操作符优先级。例如,如果当前的对流是[+,x],并且ParseBinOpRHS以40的优先级传递,它将不会消耗任何令牌(因为‘ + ’的优先级只有20)。考虑到这一点,ParseBinOpRHS开始:

/// binoprhs
///   ::= ('+' primary)*
static std::unique_ptr<ExprAST> ParseBinOpRHS(int ExprPrec,
 std::unique_ptr<ExprAST> LHS) {
 // If this is a binop, find its precedence.
  while (true) {
    int TokPrec = GetTokPrecedence();

// If this is a binop that binds at least as tightly as the current binop,
// consume it, otherwise we are done.
    if (TokPrec < ExprPrec)
      return LHS;

这段代码获取当前令牌的优先级,并检查它是否太低。由于我们将无效令牌定义为优先级为-1,因此该检查隐式地知道,当令牌流的二进制操作符用完时,pair-stream结束。如果检查成功,我们知道该令牌是一个二进制操作符,并且它将包含在这个表达式中

// Okay, we know this is a binop.
int BinOp = CurTok;
getNextToken();  // eat binop

// Parse the primary expression after the binary operator.
auto RHS = ParsePrimary();
if (!RHS)
  return nullptr;

因此,这段代码吃掉(并记住)二元运算符,然后解析后面的主表达式。这将构建整个对,其中第一个是[+,b],用于运行示例。现在我们已经解析了表达式的左侧和一对RHS序列,我们必须确定表达式的关联方式。特别是,我们可以有“(a+b) binop unparsed”或“a + (b binop unparsed)”。为了确定这一点,我们提前查看“ binop ”以确定其优先级,并将其与binop的优先级进行比较(在本例中为‘ + ’):

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

如果“RHS”右边的二进制操作符的优先级低于或等于当前操作符的优先级,则我们知道括号关联为“(a+b) binop…”。在我们的示例中,当前操作符是“+”,下一个操作符是“+”,我们知道它们具有相同的优先级。在本例中,我们将为“ a+b ”创建AST节点,然后继续解析:

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

在上面的示例中,这将把“a+b+”转换为“(a+b)”并执行循环的下一次迭代,其中“+”作为当前标记。上面的代码将读取、记忆和解析“(c+d)”作为主要表达式,这使得当前对等于[+,(c+d)]。然后,它将以“”作为主值右边的双子星来计算上面的“if”条件。在这种情况下,“”的优先级高于“+”的优先级,因此进入if条件。

这里的关键问题是“if条件如何完整解析右侧”?特别是,要为我们的示例正确构建AST,它需要将“(c+d)ef”全部作为RHS表达式变量。这样做的代码非常简单(上面两个块的代码重复用于上下文):

   // If BinOp binds less tightly with RHS than the operator after RHS, let
   // the pending operator take RHS as its LHS.
    int NextPrec = GetTokPrecedence();
    if (TokPrec < NextPrec) {
      RHS = ParseBinOpRHS(TokPrec+1, std::move(RHS));
      if (!RHS)
        return nullptr;
    }
    // Merge LHS/RHS.
    LHS = std::make_unique<BinaryExprAST>(BinOp, std::move(LHS),
                                           std::move(RHS));
  }  // loop around to the top of the while loop.
}

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

最后,在while循环的下一次迭代中,“+g”段被解析并添加到AST中。通过这一小段代码(14行不平凡的代码),我们以一种非常优雅的方式正确地处理了完全通用的二进制表达式解析。这是对这段代码的旋风式浏览,有些微妙。我建议通过一些比较难的例子来了解它是如何工作的

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

2.5 解析剩余部分

下一个缺失的是函数原型的处理。在Kaleidoscope中,它们既用于‘ extern ’函数声明,也用于函数体定义。这样做的代码是直接的:

/// prototype
///   ::= id '(' id* ')'
static std::unique_ptr<PrototypeAST> ParsePrototype() {
  if (CurTok != tok_identifier)
    return LogErrorP("Expected function name in prototype");

  std::string FnName = IdentifierStr;
  getNextToken();

  if (CurTok != '(')
    return LogErrorP("Expected '(' in prototype");

  // Read the list of argument names.
  std::vector<std::string> ArgNames;
  while (getNextToken() == tok_identifier)
    ArgNames.push_back(IdentifierStr);
  if (CurTok != ')')
    return LogErrorP("Expected ')' in prototype");

  // success.
  getNextToken();  // eat ')'.

  return std::make_unique<PrototypeAST>(FnName, std::move(ArgNames));
}

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

/// definition ::= 'def' prototype expression
static std::unique_ptr<FunctionAST> ParseDefinition() {
  getNextToken();  // eat def.
  auto Proto = ParsePrototype();
  if (!Proto) return nullptr;

  if (auto E = ParseExpression())
    return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  return nullptr;
}

此外,我们支持‘ extern ’来声明像‘ sin ’和‘ cos ’这样的函数,以及支持用户函数的前向声明。这些“外部”只是没有主体的原型:

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

最后,我们还将允许用户键入任意顶级表达式,并动态地对其求值。我们将通过为它们定义匿名空值(零参数)函数来处理这个问题:

/// toplevelexpr ::= expression
static std::unique_ptr<FunctionAST> ParseTopLevelExpr() {
  if (auto E = ParseExpression()) {
    // Make an anonymous proto.
    auto Proto = std::make_unique<PrototypeAST>("", std::vector<std::string>());
    return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
  }
  return nullptr;
}

现在我们有了所有的部分,让我们构建一个小驱动程序,它将让我们实际执行我们构建的代码

2.6 驱动器

此驱动程序只需调用所有解析块,并使用顶级调度循环。

/// top ::= definition | external | expression | ';'
static void MainLoop() {
  while (true) {
    fprintf(stderr, "ready> ");
    switch (CurTok) {
    case tok_eof:
      return;
    case ';': // ignore top-level semicolons.
      getNextToken();
      break;
    case tok_def:
      HandleDefinition();
      break;
    case tok_extern:
      HandleExtern();
      break;
    default:
      HandleTopLevelExpression();
      break;
    }
  }
}

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

2.7 总结

使用不到400行注释代码(240行非注释、非空白代码),我们完全定义了我们的最小语言,包括词法分析器、解析器和AST构建器。这样,可执行程序将验证Kaleidoscope代码,并告诉我们它是否在语法上无效。例如,下面是一个交互示例:

$ ./a.out
ready> def foo(x y) x+foo(y, 4.0);
Parsed a function definition.
ready> def foo(x y) x+y y;
Parsed a function definition.
Parsed a top-level expr
ready> def foo(x y) x+y );
Parsed a function definition.
Error: unknown token when expecting an expression
ready> extern sin(a);
ready> Parsed an extern
ready> ^D
$

这里有很大的扩展空间。您可以定义新的AST节点,以多种方式扩展该语言,在下一部分,我们将描述如何从AST生成LLVM中间表示(IR)。