我是一个编译器爱好者,一直在学习 V8 JavaScript 引擎的工作原理。当然,学习东西最好的方式就是写出来,所以这也是我在这里分享经验的原因。我希望这也能让其他人感兴趣。
这是多部分系列的第三部分,介绍 V8 JavaScript 引擎如何计算 1 + 1
。如果你还没有读过这个系列的前几篇文章,你可能会喜欢从第 1 部分(将源代码字符串存储在 JavaScript 堆中)和第 2 部分(检查字节码是否已经被缓存)开始。不过,由于这篇博文与前两篇相对独立,你也可以单独理解它
在我们讲述 V8 如何计算 1 + 1
的这一部分中,我们将学习如何将输入字符扫描成 tokens,然后将其作为JavaScript 解释器的输入。这个概念对于任何读过编译器入门教科书的人来说都会很熟悉
译者注:token 是一个字符序列,代表源程序中的一个信息单位
系列文章:
扫描过程
以我们的 1 + 1
为例,我们希望从 scanner 中看到的输出,是以下 tokens 序列:
Token::SMI (value 1)
Token::ADD
Token::SMI (value 1)
复制代码
其中 Token::SMI
是 Token::NUMBER
的特殊变体,代表小整数值,而 Token::ADD
毫无意外地代表加法。还请注意,1
、+
、1
之间的空白字符被忽略了,因为它们不提供更进一步的意义
下面是 V8 扫描和解析输入流时的整体流程:
第一步是 v8::internal::Utf16CharacterStream
类从 JavaScript
堆中读取单个字符(如第 1 部分所示,字符串被存储为 SeqOneByteString
对象)。接下来,v8::internal:Scanner
类将字符序列转换为 tokens(类型为 v8::internal::Token
)。最后,v8::internal::Parser
类(我们将在后文研究)使用这些 tokens 来验证输入流,并在内存中构建抽象语法树(AST)
需要理解的是,所有这些活动都是以流的形式来发生的。当解析器 Parser
请求下一个标记时,所有的一切都开始了,这将导致Scanner
(扫描器)从 Utf16CharacterStream
中请求下一个(或复数个)字符,进而让 Utf16CharacterStream
从 JavaScript 堆中读取输入字符串。与任何基于流的解决方案一样,能保持最小的临时存储空间,因为只有在下游实际需要 tokens 时才对其进行扫描
让我们更详细地看看这个过程
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
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
}
复制代码
正如我们稍后所见, Scanner
和 Parser
类中使用 Token::SMI
或 Token::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
,如果是,我们检查下面的字符是否是x
、o
或b
,分别委托给 ScanHexDigits()、ScanOctalDigits() 或 ScanBinaryDigits()。但是,如果下一个字符实际上是一个八进制数字(从0
到7
),那么就会调用 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 和指数的复杂性,整个过程相当复杂
下一节……
在下一篇博文中,我们将继续我们计算 1 + 1
的故事。现在我们已经有了一个 tokens 序列(Token::SMI
、Token::ADD
和 TOKEN:SMI
),我们可以看到 Parser
类如何根据 JavaScript
语言定义验证输入。最后,我们将看到如何创建一个 AST(抽象语法树)作为我们程序的内存表示