「译」JavaScript 是如何计算 1+1 的 - Part 3 将字符扫描成 Token

来源 medium.com/compilers/c…

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

image.png 这是多部分系列的第三部分,介绍 V8 JavaScript 引擎如何计算 1 + 1 。如果你还没有读过这个系列的前几篇文章,你可能会喜欢从第 1 部分(将源代码字符串存储在 JavaScript 堆中)和第 2 部分(检查字节码是否已经被缓存)开始。不过,由于这篇博文与前两篇相对独立,你也可以单独理解它

在我们讲述 V8 如何计算 1 + 1 的这一部分中,我们将学习如何将输入字符扫描成 tokens,然后将其作为JavaScript 解释器的输入。这个概念对于任何读过编译器入门教科书的人来说都会很熟悉

译者注:token 是一个字符序列,代表源程序中的一个信息单位

系列文章:

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

image.png

扫描过程

以我们的 1 + 1 为例,我们希望从 scanner 中看到的输出,是以下 tokens 序列:

Token::SMI (value 1)
Token::ADD
Token::SMI (value 1)
复制代码

其中 Token::SMIToken::NUMBER 的特殊变体,代表小整数值,而 Token::ADD 毫无意外地代表加法。还请注意,1+1 之间的空白字符被忽略了,因为它们不提供更进一步的意义

下面是 V8 扫描和解析输入流时的整体流程:

image.png

第一步是 v8::internal::Utf16CharacterStream 类从 JavaScript 堆中读取单个字符(如第 1 部分所示,字符串被存储为 SeqOneByteString 对象)。接下来,v8::internal:Scanner 类将字符序列转换为 tokens(类型为 v8::internal::Token)。最后,v8::internal::Parser 类(我们将在后文研究)使用这些 tokens 来验证输入流,并在内存中构建抽象语法树(AST)

需要理解的是,所有这些活动都是以流的形式来发生的。当解析器 Parser 请求下一个标记时,所有的一切都开始了,这将导致Scanner(扫描器)从 Utf16CharacterStream 中请求下一个(或复数个)字符,进而让 Utf16CharacterStream 从 JavaScript 堆中读取输入字符串。与任何基于流的解决方案一样,能保持最小的临时存储空间,因为只有在下游实际需要 tokens 时才对其进行扫描

让我们更详细地看看这个过程

image.png

v8::internal::Utf16CharacterStream 类

Utf16CharacterStream 类负责从输入流中读取 Unicode 字符,然后将它们一一提供给 Scanner 类。这似乎是一个微不足道的工作,但正如我们所将看到的,有一些有趣的边缘情况需要考虑。首先,扫描器在决定当前 token 应该是什么之前,可能需要提前查看输入流。第二,扫描器不知道(或不关心)字符来自哪里,或者它们是如何存储在内存中的

Utf16CharacterStream 方法

让我们来看看 Utf16CharacterStream 的一些方法,来了解这个类是如何使用的

  • stream->Advance() - 这个方法返回输入流里的下一个 Unicode 字符,并且从输入流中完全删除该字符。这是读取序列中字符时的标准行为

  • stream->Peek() - 这个方法返回输入流中的下一个 Unicode 字符,但不实际消耗该字符。因此,当下一次调用 Peek()Advanced() 时,该字符仍然可以被使用。这对于预读内容很有用,字符都不会被消耗,直到你确认这是当前 token 的一部分之前

  • stream->AdvanceUntil(func) - 持续读取(也就是「用完」)字符,直到 func 函数返回 true。这对于消耗字符,直到达到某个点(例如行结束)非常有用

  • stream->Back() - 基本上与 Advance() 相反。将字符返回到输入流中,以便使用在未来的 Peek()Advance() 调用中。当扫描程序尝试预读但又确定下一个字符实际上不是当前 token 的一部分时,此功能很有用

还有一些方法可以使用,但这些是最重要的。当我们研究 Scanner 类中的一些方法时,我们将看到,预读回推字符的能力对于正确扫描输入 tokens 至关重要。

Utf16CharacterStream 是抽象的

第二个有趣的讨论是如何从内存中检索字符。事实证明 Utf16CharacterStream 是一个抽象类,有一系列不同的实现可供选择,每个实现都专注于源字符串的特定存储布局。在我们的例子中,1 + 1 字符串被存储在JavaScript 的堆上,使用 1 个字节来存储每个字符。其他选项包括从 2 个字节的字符串读取或者从存储在 JavaScript 堆外以外的字符串中读取

选择合适的 Utf16CharacterStream 子类是在 ParseProgram 方法中进行的(见 src/parsing/parsing.cc)。ParseProgram 做了许多不同的事情,但就扫描而言,最相关的代码行是:

std::unique_ptr<Utf16CharacterStream> stream(
    ScannerStream::For(isolate, source));
复制代码

使用我们的 1 + 1 例子,theScannerStream::For 方法检查了源字符串,并确定它的类型是SeqOneByteString(1 字节字符串,存储在 JavaScript 堆中),然后代码返回 BufferedCharacterStream 类的实例,该类是能够读取 SeqOneByteString 对象的Utf16CharacterStream 的特定子类

BufferedCharacterStream 子类中最有趣的部分是 ReadBlock() 方法。这个方法被更高层次的方法如Peek()Advance() 所调用,从输入流中获取下一个字符块,无论它是如何存储的

现在让我们在扫描器的流程中继续前行,了解 V8 如何表示 tokens

image.png

v8::internal::Token 类

与其他编译器类似,v8::Internal::Token 类提供了一个供扫描器识别的所有 token 值的枚举。这些 tokens 的定义(见 src/parsing/token.h)由一个精巧的 C++ 宏(TOKEN_LIST)管理,它包含了所有令牌的列表,并结合了第二个宏(T)来提取名称部分

译者注:宏(Macro)本质上就是代码片段,通过别名来使用。在编译前的预处理中,宏会被替换为真实所指代的代码片段

以下是使用 C++ 宏对 token 枚举的定义(警告:不容易读懂):

#define T(name, string, precedence) name,  
   enum Value : uint8_t { TOKEN_LIST(T, T) NUM_TOKENS };
#undef T
复制代码

这里是 TOKEN_LIST 的定义,src/parsing/token.h 中的代码片段

#define TOKEN_LIST(T, K) \
    T(TEMPLATE_SPAN, nullptr, 0) \
    T(TEMPLATE_TAIL, nullptr, 0) \
    T(PERIOD, ".", 0) \
    T(LBRACK, "[", 0) \
    T(QUESTION_PERIOD, "?.", 0) \
    T(LPAREN, "(", 0) \
    T(RPAREN, ")", 0) \
    T(RBRACK, "]", 0) \
    ...
复制代码

当宏展开时,就变成了一个可读性更好的枚举

enum Value : uint8_t {
   TEMPLATE_SPAN,
   TEMPLATE_TAIL,
   PERIOD,
   LBRACK,
   QUESTION_PERIOD,
   LPAREN,
   RPAREN,
   ...
   ADD,
   ...
   SMI,
   ...
   WHITESPACE,
   UNINITIALIZED,
   REGEXP_LITERAL,
   NUM_TOKENS
}
复制代码

正如我们稍后所见, ScannerParser 类中使用 Token::SMIToken::ADD 的句法来引用 token 值

v8::internal::Scanner 类

现在让我们深入了解一下 Scanner 类的内部结构,它负责从输入中读取字符,并为输出生成 tokens。我们将看到扫描运算符(如 + ),以及扫描数字(如 1)的例子

扫描器方法

首先,这里有 Scanner 类的一些有趣的方法。它们有点类似于 Utf16CharacterStream 类的方法,但操作的是整个 token,而不是单个字符

  • scanner->Next() - 从输入流中返回下一个 token,并将输入指针推进。当然,这将调用 stream->Advance() 方法从 Utf16CharacterStream 中获取多个字符,但将只返回一个 token 值。在我们的 1 + 1 例 子中,每个 token 只有一个字符长,但通常不是这样

  • scanner->peek() - 在不推进输入的情况下,提前窥视下一个 token 是什么(超出 Next() 返回的范围)。解析器使用它来检查即将出现的 tokens,以确定当前解析规则是否与输入匹配

  • scanner->PeekAhead() - 更进一步地向前窥视,对于解析一些 JavaScript 的复杂语法是必要的

  • scanner->location() - 返回当前 token 的位置。这提供了源字符串中的开始和结束字符位置

  • scanner->smi_value() - 返回当前 token 的 Smi(小整数)值(如果有)。在我们的例子中,这将返回两个 Token::SMI 标记的整数 1

正如你所预料的那样,还有许多其他的 Scanner 方法,主要集中在错误处理上,但也用于获取 token 的相关值。下面是使用这些方法的 Parser 代码的一小部分:

...
if (peek() == Token::PERIOD && PeekAhead() == Token::PRIVATE_NAME) {
    Consume(Token::PERIOD);
    Consume(Token::PRIVATE_NAME);
    ...
}
...
复制代码

我们将在下一篇博文中学习更多关于 Parser 类的知识,但这段代码可以让你了解 Parser 如何调用 Scanner 来返回即将到来的 token 值

TokenDesc 结构

虽然我们早已讨论过 token 的枚举值,允许我们写 Token::SMI(数字 1)或 Token::ADD+ 符号),但这只是扫描器维护 token 所需的一部分。此外,扫描器还关心 token 的位置、文本字符、任何可能的错误情况,当然还有 token 的实际数值

为了存储所有的这些额外的信息,扫描器使用 TokenDesc 结构

struct TokenDesc {
  Location location = {0, 0};
  LiteralBuffer literal_chars;
  LiteralBuffer raw_literal_chars;
  Token::Value token = Token::UNINITIALIZED;
  MessageTemplate invalid_template_escape_message = 
      MessageTemplate::kNone;
  Location invalid_template_escape_location;
  uint32_t smi_value_ = 0;
  bool after_line_terminator = false;
}
复制代码

这些字段是:

  • location - 字符串的起始和结束位置。例如,我们的第一个 Token::SMI 在 0 号位置,而我们的Token::ADD 在 2 号位置

  • literal_chars - 构成 token 的实际字符,无论是数字、字符串、标识符,还是其他。这一点很重要,因为知道 total_cost 是一个 Token::IDENTIFIER 只是故事的一部分。此外,我们还需要标识符的名字 (total_cost)来区分它和其他 Token::IDENTIFIER 的值

  • raw_literal_chars - 类似于 literal_chars,但用于模板字符串。在这种情况下,我们不希望转义序列(例如:\064)被相应的字符(例如:4)取代,而是要求将原始的文本字符传递给模板函

  • token - token 的枚举值,和之前一样

  • invalid_template_escape_message / invalid_template_escape_location - 如果在扫描token 时发现错误,这些字段会储存错误代码和位置

  • smi_value_ - 在 Token::SMI 的情况下,这个字段存储了数字的实际整数值。这是由 scanner->smi_value() 返回的

  • after_line_terminator - 表示 token 是否作为新行的第一个 token 出现。这对自动插入分号很有用

现在,我们了解了所有的构造块,让我们继续沿扫描程序进行操作,以了解字符是如何被转换为 token 的:

示例:扫描运算符 Tokens

此时,我们已经准备好跟踪扫描 1 + 1 的操作了。由于扫描运算符比扫描数字更容易,我们先看看当即将到来的输入字符是 + 号时,scaner->Next() 是怎么做的

扫描机制的大部分内容在私有的 ScanSingleToken() 方法中。扫描从一个非常简单的 one_char_tokens[128] 数组开始,它包含了前 128 个 Unicode 字符(也就是 ASCII 字符)到 「猜测」token 值是什么的直接映射。这里是 GetOneCharToken()方法,用来填充one_char_tokens[128] 数组:

constexpr Token::Value GetOneCharToken(char c) {
   return
      c == '(' ? Token::LPAREN :
      c == ')' ? Token::RPAREN :
      c == '{' ? Token::LBRACE :
      c == '}' ? Token::RBRACE :
      c == '[' ? Token::LBRACK :
      c == ']' ? Token::RBRACK :
      c == '?' ? Token::CONDITIONAL :
      c == ':' ? Token::COLON :
      ...
      c == '+' ? Token::ADD :
      ...
复制代码

在我们的例子中,字符 + 被映射到 Token::ADD。然而,这只是一个猜测。如果实际输入的是 +++= 呢?为了处理这个问题,代码会提前查看下面的字符是否也是 +(在这种情况下,Token::INC 会被返回),或者可能是一个 =(返回 Token::ASSIGN_ADD)。如果这两种情况都不为真,则返回原来的 Token::ADD

以下是相关代码

case Token::ADD:
  // + ++ +=
  Advance();
  if (c0_ == '+') return Select(Token::INC);
  if (c0_ == '=') return Select(Token::ASSIGN_ADD);
  return Token::ADD;
复制代码

请注意,Advance() 方法将下一个字符读到本地变量 c0_ 中,而 Select 是消费该字符并返回指定 token 的简写

现在让我们看看更复杂的扫描数字的情况

示例:扫描数字 Tokens

当扫描字符串 1 时,过程的开始方式和之前一样。我们在 one_char_tokens[128] 数组中查找字符,数组提供 Token::NUMBER 作为初始猜测。然后,代码立即调用 ScanNumber 方法,更深入地查看输入流中出现的字符

case Token::NUMBER:
    return ScanNumber(false);
复制代码

下面是 ScanNumber 的工作原理:

  • 第 752 行 - 决定这个数字是否以小数点(. 字符)开始。如果这是真的,数字只包含小数部分(如 .123),所以进一步的扫描将委托给 ScanDecimalDigits() 方法。在我们的 1 + 1 的例子中,我们不采取这个代码路径

  • 第 762 行 - 下一步,我们检查数字中的第一个字符是否是 0,如果是,我们检查下面的字符是否是 xob,分别委托给 ScanHexDigits()ScanOctalDigits()ScanBinaryDigits()。但是,如果下一个字符实际上是一个八进制数字(从 07),那么就会调用 ScanImplicitOctalDigits() 来处理像 077 这样的情况(而不是更明确的 0o77)。最后,像 088 这样的数字(超出八进制的范围)会被当作常规的十进制 88 来处理。

  • 第 797 行 - 由于我们的输入字符串(1 + 1)不是以 0 开头,我们认为这个数字是十进制的,可能包含下划线(例如 1_000

  • 第 803 行 - 考虑到大多数的数字都比较小,我们冒险将数字扫描为 Smi(小整数),将工作委托给ScanDecimalAsSmi(),返回一个 C++ uint64_t 类型的值

  • 第 807 行 - 一个 Smi 必须足够小,以适应 31 位。如果可以的话,我们将 token 的 smi_value_ 字段设置为整数的值,然后返回 Token::SMI。这就是我们在 1 + 1 例子中的路径

  • 第 819 行 - 如果数字不是 Smi,继续解析十进制数字,调用 ScanDecimalDigits() 来解析。此外,我们还要检查尾随的小数点(. 字符),后面是否还有小数部分。请注意,这段代码只是简单地验证数字是否格式良好,而不是提取实际值本身(就像我们在 Smi 案例中做的那样)

  • 第 833 行 - 这是我们处理 BigInt 情况的地方,在这种情况下,数字有一个尾部的 n,例如,12345678n

  • 第 848 行 - 最后,我们处理指数情况,即数字的指数后面有一个尾部的 e。例如:123e5。这与我们 1 + 1 的情况无关,因为 Smi 不能有指数。

然后结束 JavaScript 中运算符和数字的扫描过程,由于多字符运算符、不同的基数(十六进制、八进制、二进制)、用作数字分隔符的下划线、分数部分、BigInts 和指数的复杂性,整个过程相当复杂

image.png

下一节……

在下一篇博文中,我们将继续我们计算 1 + 1 的故事。现在我们已经有了一个 tokens 序列(Token::SMIToken::ADDTOKEN:SMI),我们可以看到 Parser 类如何根据 JavaScript 语言定义验证输入。最后,我们将看到如何创建一个 AST(抽象语法树)作为我们程序的内存表示

猜你喜欢

转载自juejin.im/post/7054824596043268109