「译」JavaScript 是如何计算 1+1 的 - Part 4 将表达式解析成 AST

527 阅读13分钟

来源 medium.com/compilers/c…

我是一个编译器爱好者,一直在学习 V8 JavaScript 引擎的工作原理。当然,学习东西最好的方式就是写出来,所以这也是我在这里分享经验的原因。我希望这也能让其他人感兴趣。

image.png

这是深入探讨 V8 JavaScript 引擎如何计算表达式 1 + 1 系列文章的第四部分。这看起来似乎是一个简单的任务,但它涉及了 JavaScript 运行时环境的很大一部分。在之前的博文中,我们看到:

  • 第 1 部分 - 1 + 1 字符串是如何存储在 JavaScript 堆中的。
  • 第 2 部分 - 如何缓存字节码以避免不必要的编译
  • 第 3 部分 - 如何将字符串 1 + 1 扫描成对应的 tokens

现在,在第 4 部分中,我们将学习如何解析 1 + 1,以根据官方的 JavaScript 语法进行验证,目标是创建一个内存中的抽象语法树(AST)

image.png

系列文章:

  1. 「译」JavaScript 是如何计算 1+1 的 - Part 1 创建源码字符串
  2. 「译」JavaScript 是如何计算 1+1 的 - Part 2 缓存字节码
  3. 「译」JavaScript 是如何计算 1+1 的 - Part 3 将字符扫描成 Token

ECMAScript 语法

你可能知道,JavaScript 语言是由 ECMAScript 标准正式定义的,也就是 ECMA-262。然而,除非你学习过该标准,否则你可能不知道第 12-15 章花了大约 200 页(860页中)的篇幅,用详细的上下文无关文法定义了该语言的语法

  • 第 12 章 - 提供了 JavaScript 语言中所有可能的表达式的语法,包括一个广泛的数学和逻辑运算符列表
  • 第 13 章 - 提供了语句(如 ifthenwhile)和声明(如 letconst)的语法
  • 第 14 章 - 提供了函数和类定义的语法
  • 第 15 章 - 提供了顶层脚本和模块的语法

如果你学习这些章节,你会看到该语言语法的全部定义,描述了 JavaScript 程序中 tokens 的有效组合。然而,在这些章节中没有任何内容来描述一个有效程序的实际意义。相反,语言的语义在规范的后面几章有描述(本博文也没有讨论)

1 + 1 的语法

让我们来快速浏览一下 JavaScript 的语法,看看 1 + 1 将如何被解析,至少在理论层面上是这样(我们稍后将看到 V8 中是如何做的)。语法从 Script 符号开始,启动了一长串语法规则,用来推导一个有效的 JavaScript 程序

为了便于说明,我们只展示解析 1 + 1 时相关的规则。与任何上下文无关文法一样,右侧的符号要么是终结符,代表我们语言中的标记(如 1+ ),要么是非终结,可以使用其他规则递归替换

译者注: ::= 是「相当于」的意思

对于一个简单的表达式来说,这是一个很长的规则链,但对于现实的程序来说,它甚至更长。我们只看到了我们 1 + 1 例子的相关规则。强烈建议你点击超链接(上面),你会看到更大的规则集,包括 &&&|||*+ 等运算符

image.png

抽象语法树 (AST)

解析输入 tokens 的目标之一是创建一个抽象语法树。这为编译器提供了程序在内存中的表示形式,比一维的 tokens 序列更容易操作。AST 经过优化设计,使树形遍历算法能够验证输入程序,优化程序结构,并最终生成字节码

AST 是一棵 n-ary 树,每个节点代表输入程序的某一部分。节点具有各种属性,更详细地描述程序(例如变量名或整数值),通常有子节点来反映程序的嵌套结构

译者注:n-ary 树就是可以有任意子节点的树

在 V8 中,所有的 AST 节点都在 src/ast/ast.h 中被定义为 C++ 类,AstNode 是所有其他节点类型的父类。每个类都包含用于装饰节点的内部字段(用变量名,或字面量值),以及指向子 AST 节点的指针

下图显示了 AST 节点的部分类层次结构。请注意,StatementAstNode 的一个子类,它的每个子类都代表了不同类型的 JavaScript 语句。正如我们后面会看到的,我们将在 1 + 1 的例子中使用 ExpressionStatement

image.png

下面是另一部分类的层次结构,重点介绍 Expression 类,它也是 AstNode 的一个子类。每个子类都代表了不同类型的 JavaScript 表达式。在我们的例子中,我们将使用 Literal

image.png

1 + 1 的 AST

对于我们这个特殊的 1 +1 的例子,AST 非常简单,只涉及三个节点。FunctionLiteral, ExpressionStatement, 和 Literal

image.png 在 AST 的顶端是 FunctionLiteral 节点。我们的表达式看起来并不像一个函数,但 V8 这样包装表达式是为了让字节码编译更加可行。在我们的例子中,函数的形式参数为零,预期的 JavaScript 对象属性为零。

第二层是一个 ExpressionStatement 节点,它是 FunctionLiteralbody 字段引用的 Statement 节点列表中的单项。如果这是一个更典型的函数,我们希望在这个列表中看到多个 Statement 节点。

最后,ExpressionStatementexpression_ 字段指的是唯一的表达式节点。在我们的例子中,1 + 1 表达式将被「折叠」成单一的小整数值 2(我们将很快看到这一点)

为了帮助理解这些 AST 节点中的每一个节点是如何在内存中表示的,这里是 Literal C++ 类的一个高度简略(和注释)的版本,它能够存储任何类型的字面量值(数字、字符串、布尔运算等)

class Literal final : public Expression {
    
public:
    
  // 所有可能的字面量类型
  enum Type {
    kSmi,
    kHeapNumber,
    kBigInt,
    kString,
    kBoolean,
    kUndefined,
    kNull,
    kTheHole,
  };
    
  // 返回此 Literal 的类型
  Type type() const { ... }
    
  // 测试 Literal 类型的方法
  bool IsNumber() const { ... }
  bool IsString() const { ... }
    
  // 以各种方式获取 Literal 值的方法。
  AstRawString* AsRawPropertyName() { ... }
  Smi AsSmiLiteral() { ... }
  double AsNumber() { ... }
  AstBigInt AsBigInt() { ... }
  AstRawString* AsRawString() { ... }
  bool ToBooleanIsTrue();
  bool ToBooleanIsFalse() { ... }
  bool ToUint32(uint32_t* value) const;
    
private:
    
  // C++ union 用于存储值
  union {
    const AstRawString* string_;
    int smi_;
    double number_;
    AstBigInt bigint_;
    bool boolean_;
  };
};

Literal 的基本结构包括一个类型标记来区分各种可能的值,然后是一系列的访问函数来获取值本身。最后,该类的私有部分允许存储每种类型的值。完整的源代码请参见 src/ast/ast.h

区域(Zone)内存分配

我们在讨论 AST 节点时忽略了一个有趣的事实,那就是 AST 节点到底存储在内存中的什么地方。我们已经知道了 JavaScript 堆(来自本系列的第 1 部分),但那并不是 AST 节点存储的地方。相反,Zone 的概念开始发挥作用。

Zone 类提供了非常快速的小块内存的分配,比如 AST 节点。然而,不是使用垃圾收集算法,或者显式 delete 调用来重新分配内存,而是一次性丢弃整个区域(zone)。这在以增量方式创建 AST、在内存中保存一段时间、然后在不再需要 AST 时丢弃是有道理的。因为并没有部分释放一个 AST 的概念

为了从 Zone 中分配内存块,AstNodeFactory 类(使用 「工厂」模式)提供了创建不同类型 AST 节点的方便方法。例如,这里是 ExpressionFromLiteral() 方法的一部分,它将下一个扫描器 token 识别为Token::SMI,从该 token 中获取整数值,然后用该整数值创建一个新的 Literal 节点

...
case Token::SMI: {
  uint32_t value = scanner()->smi_value();
  return factory()->NewSmiLiteral(value, pos);
}
...

现在我们已经看到了 ECMAScript 语法,也了解 了AST 是如何构造的,我们有足够的知识来完成 1 + 1 的解析过程。正如我们在第三部分所学到的,这个输入将以 Token::SMI(值 1)、Token::ADDToken::SMI(值 1)的序列从扫描器流向解析器

image.png

1 + 1 的递归下降解析法

为了构建 AST,V8 JavaScript 引擎使用了递归下降解析技术。这是最容易理解的解析算法之一,因为它只是简单地使用递归方法调用来模拟语法规则链。也就是说,我们语法中的每个非终结符都被映射到一个 C++ 方法上。 当调用该方法时,该方法会提前查看下一个扫码到的 token(或 tokens)以决定如何继续,并依据语法递归调用其他 C ++ 方法。

让我们看看在我们的 1 + 1 例子中是如何做到这一点的

递归向下解析

我们将从 CompileTopLevel() 方法内部开始我们的旅程,该方法调用 ParseProgram() 将 tokens 序列转换为 AST。为了实现这个目标,该方法初始化扫描器,然后递归调用 DoParseProgram()(我们将在这一节中看到很多递归调用!)

...
scanner_.Initialize();
FunctionLiteral* result = DoParseProgram(isolate, info);
...

如果我们回想一下前面看到的 ECMAScript 语法,DoParseProgram() 方法相当于 ScriptBody 规则。特别是,它包含了解析 StatementList 的代码,StatementList 是一个由 0 个或多个 StatementListItem 项组成的序列

...
while (peek() != end_token) {
  StatementT stat = ParseStatementListItem();
  if (impl()->IsNull(stat)) return;
  if (stat->IsEmptyStatement()) continue;
  body->Add(stat);
}
...

译者注:->箭头操作符。使用一个类对象的指针来调用该指针所指对象的成员 类比 js 的话, impl()->IsNull(stat) 可以先理解成是 (impl())[IsNull(stat)] (实际上不是这样)

DoParseProgram() 方法的最后,有一段用于创建一个新的 FunctionLiteral AST 节点的代码,它包含了我们的 Statement 节点列表。这个新节点应该看起来很熟悉,因为它是我们期望在最终 AST 中看到的三个节点中的第一个

result = factory()->NewScriptOrEvalFunctionLiteral(... body ...);

为了继续递归,调用了 ParseStatementListItem() 方法。这个方法,就像我们即将看到的许多其他方法一样,使用了一个一致的模式,即检查下一个 token,然后决定递归调用哪个附加方法。例如,如果 Token::CLASS 是输入流中的下一个 token,我们就消耗这个 token,然后递归调用ParseClassDeclaration()

...
switch (peek()) {
  case Token::FUNCTION:
    return ParseHoistableDeclaration(...);
        
  case Token::CLASS:
    Consume(Token::CLASS);
    return ParseClassDeclaration(...);\
        
  case Token::VAR:
  case Token::CONST:
    return ParseVariableStatement(...);
        
  case Token::LET:
    return ...
        
  case Token::ASYNC:
    return ...
        
  default:
    break;
}

/* none of the tokens match, continue along rule chain */
return ParseStatement(...)

在我们简单的 1 + 1 例子中,这些预读 tokens 都不相关,所以我们继续调用 ParseStatement() 的默认情况。事实上,这种模式会持续一段时间,进行更多的递归调用

这个列表看起来应该和我们之前看到的 ECMAScript 语法非常相似。也许唯一的差异是一些非常相似的规则(比如 _ExpressionStatement _和 LabelledStatement)它们会被合并到一个单一的 ParseExpressionOrLabelledStatement() 方法中,它能够同时处理这两种规则

一旦我们到了 ParseBinaryExpression(),模式就会稍有变化,因为许多不同的语法规则,从 LogicalOrExpressionExponentiationExpression,都被合并到一个单一的 C++ 方法中。正如我们稍后将讨论的那样,我们通过传递一个先例参数来处理这个问题,并使用先例规则将子表达式适当地分组

最后,我们通过调用更多的 C++ 方法来继续深入语法规则链

字面量解析

一旦我们到达 ParsePrimaryExpression() 方法,递归就结束了。我们不再有更多的规则要应用,现在我们已经准备好将 1 识别为一个字面量值。下面是相关代码

...
if (Token::IsLiteral(token)) {
  return impl()->ExpressionFromLiteral(Next(), beg_pos);
}
...

ExpressionFromLiteral() 方法中,有获取 token 的 SMI 值(1)的代码,然后使用 AST 工厂创建一个新的 Literal AST 节点,就像我们前面看到的那样

...
case Token::SMI: {
  uint32_t value = scanner()->smi_value();
  return factory()->NewSmiLiteral(value, pos);
}
...

这段代码的返回值类型为 Expression *,现在它将被传回 C++ 调用栈

有先例的二进制表达式的解析

由于我们现在已经到达了调用栈的底部,并且要从一长串的递归方法中返回,这不仅仅是一个立即返回的简单问题。每个方法必须决定是否应该消耗更多的输入 tokens,或者它的工作是否已经完成,返回给调用者是正确的方法

为了了解为什么这很重要,请考虑表达式:1 + 2 * 3。如果我们已经看到了 1 + 2,那么要问的问题是 1 + 2 是否是一个完整的表达式,或者是否还有更多的内容需要评估。很明显,它应该先评估 2 * 3,然后再加上 1,所以我们需要消耗剩余的 tokens 来形成二进制表达式 2 * 3,然后再返回生成二进制表达式 1 + (2 * 3)

换一种说法,考虑一下我们前面看到的 ECMAScript 规则

AdditiveExpression ::= AdditiveExpression + MultiplicativeExpression

鉴于我们的例子,为了评估_AdditiveExpression,_我们需要在「返回」到上一个方法之前先评估 _MultiplicativeExpression _

在 V8 内部,这一切都通过 ParseBinaryExpression() 方法来完成,该方法期望将运算符的优先级作为一个参数。当表达式的解析完成后,会检查下一个 toksn 的优先级,看它是否更高。如果是,则在语法中的同一层次继续解析,在这种情况下,调用 ParseBinaryContinuation()

下面是相关代码

    ...
    x = ParseUnaryExpression();
  }
  int prec1 = Token::Precedence(peek(), accept_IN_);
  if (prec1 >= prec) {
    return ParseBinaryContinuation(x, prec, prec1);
  }
  return x;
}

在我们的 1 + 1 例子中,调用 ParseBinaryExpression()prec 等于 6,由于 + 的优先级是 12,由于 prec1>prec,表达式应该在语法中的同一层次继续。因此,我们调用 ParseBinaryContinuation() 而不是向上返回堆栈

折叠常量

ParseBinaryContinuation() 做的另一件事是尝试「折叠」常量,在编译时简化表达式。我们已经有了第一个 1Literal、运算符 + 和第二个 1 的第二个 Literal,然后我们用所有这些值调用ShortcutNumericLiteralBinaryExpression(),看看它们是否能被折叠成一个 Literal

下面是相关代码

...
if ((*x)->IsNumberLiteral() && y->IsNumberLiteral()) {
  double x_val = (*x)->AsLiteral()->AsNumber();
  double y_val = y->AsLiteral()->AsNumber();
  switch (op) {
    case Token::ADD:
      *x = factory()->NewNumberLiteral(x_val + y_val, pos);
      return true;
    ...

综上所述,如果两个表达式都是字面量值,那么我们将它们相加,然后用一个包含总和的 Literal 代替

收尾工作

我们现在已经看到了整个表达式,除了最后的 Token::EOS 标志着输入流的结束外,没有其他的输入标记。当每个递归 C++ 方法返回时,它检查下一个标记,但只看到 Token::EOS,导致它立即返回。

虽然现在这已经是相当常规的做法,但有一个有趣的情况值得一提。在 ParseExpressionOrLabelledStatement() 方法中,值为 2Literal AST 节点被包裹在ExpressionStatement AST节点中:

return factory()->NewExpressionStatement(expr, pos);

最后,正如我们前面所看到的,创建了一个 FunctionalLiteral AST 节点,封装了 ExpressionStatement。这使我们回到了我们一直期待看到的 AST 图: image.png

下一节……

我们现在已经看到了解析 1 + 1 所涉及的大部分过程,包括 JavaScript 堆上数据的分配、字节码的缓存、token 的扫描,以及现在对 ECMAScript 语法的解析。在下一篇博文中,将继续研究 1 + 1(实际上是现在的 2)是如何在字节码中转换的