数字/字符串
在上一篇文章中,我们已经实现了一个仅能够解析数字的解析器。虽然简单,但的确是一个非常好的开始。
数字和字符串都是字面量的范畴,这篇文章我们继续增加对字符串的处理。同时我们引入词法分析器,即分词器。
分词器的核心功能是从字符串中提取 token。我们看到代码清单 1 中分词器的实现,在 getNextToken 函数中,它识别提取数字 token 和字符串 token。
具体的提取方式也很简易:当检测到第一个字符是数字时,就一直往后读取数字字符,当作数字 token;当检测到第一个字符是双引号时,则一直读取直到下一个双引号,当作字符串 token。
- class Tokenizer {
- init(string) {
- this._string = string;
- this._cursor = 0;
- }
- isEOF() {
- return this._cursor === this._string.length;
- }
- hasMoreTokens() {
- return this._cursor < this._string.length;
- }
- getNextToken() {
- if (!this.hasMoreTokens()) {
- return null;
- }
- const string = this._string.slice(this._cursor);
- // Numbers:
- if (!Number.isNaN(Number(string[0]))) {
- let number = '';
- while (!Number.isNaN(Number(string[this._cursor]))) {
- number += string[this._cursor++];
- }
- return {
- type: 'NUMBER',
- value: number,
- };
- }
- // String
- if (string[0] === '"') {
- let s = '';
- do {
- s += string[this._cursor++];
- } while (string[this._cursor] !== '"' && !this.isEOF());
- s += this._cursor++;
- return {
- type: 'STRING',
- value: s,
- };
- }
- return null;
- }
- }
- module.exports = {
- Tokenizer
- };
顺带学 JavaScript:
JavaScript 中的 === 与 C 中的 == 在概念上是类似的。
JavaScript 中的 == 与 C++ 中隐式转换有点类似,但是转换规则着实令人费解。查了一下可能是历史包袱的设计原因,无需花时间多纠结。
接着我们改造之前的解析器逻辑,让其利用上分词器的功能。如代码清单 2 所示,我们在解析器的构造函数中引入分词器实例。
同时注意这边引入了一个 _lookahead 变量。lookahead 是典型的递归下降解析结构,使用 LL(1) 策略。这边“LL”表示“从左到右扫描(Left-to-right)”,以及“最左推导(Leftmost derivation)”;“1”表示向前看一个 token。即我们每次会向前看一个 token,进而进行决策制定。
- class Parser {
- constructor() {
- this._string = '';
- this._tokenizer = new Tokenizer();
- }
- /**
- * Parse a string into AST
- */
- parse(string) {
- this._string = string;
- this._tokenizer.init(string);
- this._lookahead = this._tokenizer.getNextToken();
- return this.Program();
- }
- }
我们再看到 _lookahead 的维护,如代码清单 3 所示,我们看到增加的 _eat 函数。它的作用是“吃掉”、消费掉一个 token,确保它符合预期的类型,并更新 _lookahead 到下一个 token。
- class Parser {
- _eat(tokenType) {
- const token = this._lookahead;
- if (token == null) {
- throw new SyntaxError(
- `Unexpected end of input, expected: "${tokenType}"`,
- );
- }
- if (token.type !== tokenType) {
- throw new SyntaxError(
- `Unexpected token: "${token.value}", expected: "${tokenType}"`,
- );
- }
- this._lookahead = this._tokenizer.getNextToken();
- return token;
- }
- }
我觉得这个 _eat 更有意义的一点是,它能确保解析过程严格遵循语法规则。它不仅控制 token 进展,还检查 token 的正确性。
最后我们感受 LL(1) 决策的体现。看到代码清单 4 中的 Literal() 函数,它根据 token 的类型决定如何处理字面量。
同时注意语法规范在设计上的改动,新增了 Program,作为更加抽象的顶层。新增了 Literal,包含 NumericLiteral 和 StringLiteral。
- class Parser {
- /**
- * Main entry point
- *
- * Program
- * : Literal
- * ;
- */
- Program() {
- return {
- type: 'Program',
- body: this.Literal(),
- };
- }
- /**
- * Literal
- * : NumericLiteral
- * | StringLiteral
- * ;
- */
- Literal() {
- switch (this._lookahead.type) {
- case 'NUMBER':
- return this.NumericLiteral();
- case 'STRING':
- return this.StringLiteral();
- }
- throw new SyntaxError(`Literal: unexpected literal production`);
- }
- /**
- * StringLiteral
- * : STRING
- * ;
- */
- StringLiteral() {
- const token = this._eat("STRING");
- return {
- type: 'StringLiteral',
- value: token.value.slice(1, -1),
- };
- }
- /**
- * NumericLiteral
- * : NUMBER
- * ;
- */
- NumericLiteral() {
- const token = this._eat("NUMBER");
- return {
- type: 'NumericLiteral',
- value: Number(token.value),
- };
- }
- }