Repository: hsiaosiyuan0/icj Branch: master Commit: 17505a5296d5 Files: 24 Total size: 55.7 KB Directory structure: gitextract_cf6h71uy/ ├── .gitignore ├── .vscode/ │ └── settings.json ├── README.md ├── SUMMARY.md ├── code/ │ └── hi/ │ ├── interpret-visitor.js │ ├── lexer.js │ ├── package.json │ ├── parser.js │ ├── source.js │ ├── test.js │ ├── visitor.js │ └── yaml-visitor.js ├── part1/ │ ├── 1-1-intro.md │ ├── 1-2-source.md │ ├── 1-3-hi.md │ ├── 1-4-lexer.md │ ├── 1-5-parser.md │ ├── 1-6-ast-interpreter.md │ ├── 1-7-arith-left-recursion.md │ ├── 1-8-arith-precedence-assoc.md │ ├── 1-9-ast-calculator.md │ ├── 1.10-liu-cheng-kong-zhi.md │ └── README.md └── part2.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store node_modules/ ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": [ "EBNF", "ofst", "sayhi" ] } ================================================ FILE: README.md ================================================ # 使用 JavaScript 来实现解释器和编译器系列教程 该系列「icj, Writing Interpreters and Compilers in JavaScript」中,我们将一起使用 JavaScript 徒手实现编程语言解释器和编译器。因此整个系列分为「解释器篇」和「编译器篇」,[在线阅读](https://hsiaosiyuan0.gitbook.io/icj/)。 * [解释器篇](part1/) 我们将逐步实现我们的第一个编程语言「hi 语言」。我们会一起从零开始构建它,然后以解释器的形式来实验它的功能。 * [编译器篇](part2.md) 我们将继续完善我们的「hi 语言」,使之可以直接编译为 JavaScript,并在浏览器和 node 环境中运行我们的编译结果。 本系列所有代码,都归纳在 [code](https://github.com/hsiaosiyuan0/icj/tree/master/code) 目录下。 200 个 stars 继续更新怎么样 **该系列文章属于原创,不可用于任何收费环节或者项目中。转载请注明出处。** ================================================ FILE: SUMMARY.md ================================================ # Table of contents * [使用 JavaScript 来实现解释器和编译器系列教程](README.md) * [解释器篇](part1/README.md) * [1.1 简述](part1/1-1-intro.md) * [1.2 预备工作 - Source](part1/1-2-source.md) * [1.3 我们的第一个语言 - hi](part1/1-3-hi.md) * [1.4 词法解析器](part1/1-4-lexer.md) * [1.5 语法解析器](part1/1-5-parser.md) * [1.6 使用 AST - 第一个解释器](part1/1-6-ast-interpreter.md) * [1.7 解析算术表达式 - 左递归和其消除法](part1/1-7-arith-left-recursion.md) * [1.8 解析算术表达式 - 优先级与结合性](part1/1-8-arith-precedence-assoc.md) * [1.9 使用 AST - 计算器](part1/1-9-ast-calculator.md) * [1.10 流程控制](part1/1.10-liu-cheng-kong-zhi.md) * [编译器篇](part2.md) ================================================ FILE: code/hi/interpret-visitor.js ================================================ const { Visitor } = require("./visitor"); class InterpretVisitor extends Visitor { visitProg(node) { node.body.forEach(stmt => this.visitStmt(stmt)); } visitSayHi(node) { console.log(`hi ${node.value}`); } visitBinaryExpr(node) { const left = this.visitExpr(node.left); const op = node.op.type; const right = this.visitExpr(node.right); switch (op) { case "+": return left + right; case "-": return left - right; case "*": return left * right; case "/": return left / right; case "**": return left ** right; } } visitPrintStmt(node) { console.log(this.visitExpr(node.value)); } visitNumLiteral(node) { return parseInt(node.value); } } module.exports = { InterpretVisitor }; ================================================ FILE: code/hi/lexer.js ================================================ const { EOF, EOL } = require("./source"); const assert = require("assert"); class SourceLoc { constructor(start, end) { this.start = start; this.end = end; } } class Token { constructor(type, value, loc) { this.type = type; this.value = value; this.loc = loc || new SourceLoc(); } } class TokenType {} TokenType.EOF = "eof"; TokenType.HI = "hi"; TokenType.STRING = "string"; TokenType.NUMBER = "number"; TokenType.MUL = "*"; TokenType.DIV = "/"; TokenType.ADD = "+"; TokenType.SUB = "-"; TokenType.EXPO = "**"; TokenType.PRINT = "print"; class Lexer { constructor(src) { this.src = src; } static isDigit(n) { return n >= "0" && n <= "9"; } static isOp(ch) { return ["*", "/", "+", "-", "*"].indexOf(ch) !== -1; } next() { this.skipWhitespace(); const ch = this.src.peek(); if (ch === '"') return this.readString(); if (ch === "h") return this.readHi(); if (ch === "p") return this.readPrint(); if (Lexer.isDigit(ch)) return this.readNumber(); if (Lexer.isOp(ch)) return this.readOp(); if (ch === EOF) return new Token(TokenType.EOF); throw new Error(this.makeErrMsg()); } makeErrMsg() { return `Unexpected char at line: ${this.src.line} column: ${this.src.col}`; } readPrint() { const tok = new Token(TokenType.PRINT); tok.loc.start = this.getPos(); const print = this.src.read(5); assert.ok(print === "print", this.makeErrMsg()); tok.loc.end = this.getPos(); tok.value = "print"; return tok; } readHi() { const tok = new Token(TokenType.HI); tok.loc.start = this.getPos(); const hi = this.src.read(2); assert.ok(hi === "hi", this.makeErrMsg()); tok.loc.end = this.getPos(); tok.value = "hi"; return tok; } readString() { const tok = new Token(TokenType.STRING); tok.loc.start = this.getPos(); this.src.read(); const v = []; while (true) { let ch = this.src.read(); if (ch === '"') break; else if (ch === EOF) throw new Error(this.makeErrMsg()); v.push(ch); } tok.loc.end = this.getPos(); tok.value = v.join(""); return tok; } readNumber() { const tok = new Token(TokenType.NUMBER); tok.loc.start = this.getPos(); const v = [this.src.read()]; while (true) { let ch = this.src.peek(); if (Lexer.isDigit(ch)) { v.push(this.src.read()); continue; } break; } tok.loc.end = this.getPos(); tok.value = v.join(""); return tok; } readOp() { const tok = new Token(); tok.loc.start = this.getPos(); tok.type = this.src.read(); if (tok.type === "*") { if (this.src.peek() === "*") { this.src.read(); tok.type = "**"; } } tok.loc.end = this.getPos(); return tok; } skipWhitespace() { while (true) { let ch = this.src.peek(); if (ch === " " || ch === "\t" || ch === EOL) { this.src.read(); continue; } break; } } peek() { this.src.pushPos(); const tok = this.next(); this.src.restorePos(); return tok; } getPos() { return this.src.getPos(); } } module.exports = { SourceLoc, Token, TokenType, Lexer }; ================================================ FILE: code/hi/package.json ================================================ { "name": "hi", "version": "1.0.0", "description": "", "main": "interpret-visitor.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "js-yaml": "^3.13.1" } } ================================================ FILE: code/hi/parser.js ================================================ const { SourceLoc, TokenType, Lexer } = require("./lexer"); const assert = require("assert"); class Node { constructor(type, loc) { this.type = type; this.loc = loc || new SourceLoc(); } } class Prog extends Node { constructor(loc, body = []) { super(NodeType.PROG, loc); this.body = body; } } class SayHi extends Node { constructor(loc, value) { super(NodeType.SAY_HI, loc); this.value = value; } } class PrintStmt extends Node { constructor(loc, value) { super(NodeType.PRINT_STMT, loc); this.value = value; } } class ExprStmt extends Node { constructor(loc, value) { super(NodeType.EXPR_STMT, loc); this.value = value; } } class BinaryExpr extends Node { constructor(loc, op, left, right) { super(NodeType.BINARY_EXPR, loc); this.op = op; this.left = left; this.right = right; } } class NumLiteral extends Node { constructor(loc, value) { super(NodeType.NUMBER, loc); this.value = value; } } class NodeType {} NodeType.PROG = "prog"; NodeType.SAY_HI = "sayhi"; NodeType.EXPR_STMT = "exprStmt"; NodeType.BINARY_EXPR = "binaryExpr"; NodeType.NUMBER = "number"; NodeType.PRINT_STMT = "printStmt"; class Parser { constructor(lexer) { this.lexer = lexer; } parseProg() { const node = new Prog(); node.loc.start = this.lexer.getPos(); while (true) { const tok = this.lexer.peek(); let stmt; if (tok.type === TokenType.EOF) break; if (tok.type === TokenType.HI) stmt = this.parseSayHi(); if (tok.type === TokenType.NUMBER) stmt = this.parseExprStmt(); if (tok.type === TokenType.PRINT) stmt = this.parsePrintStmt(); node.body.push(stmt); } node.loc.end = this.lexer.getPos(); return node; } parsePrintStmt() { const node = new PrintStmt(); let tok = this.lexer.next(); node.loc.start = tok.loc.start; node.value = this.parseExpr(); node.loc.end = this.lexer.getPos(); return node; } parseSayHi() { const node = new SayHi(); let tok = this.lexer.next(); assert.ok(tok.type === TokenType.HI, this.makeErrMsg(tok)); node.loc.start = tok.loc.start; tok = this.lexer.next(); assert.ok(tok.type === TokenType.STRING, this.makeErrMsg(tok)); node.value = tok.value; node.loc.end = tok.loc.end; return node; } parseExprStmt() { const node = new ExprStmt(); const expr = this.parseExpr(); node.loc = expr.loc; node.value = expr; return node; } parseExpr() { let left = this.parseTerm(); while (true) { const op = this.lexer.peek(); if (op.type !== "+" && op.type !== "-") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseTerm(); left = node; } return left; } parseTerm() { let left = this.parseExpo(); while (true) { const op = this.lexer.peek(); if (op.type !== "*" && op.type !== "/") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseExpo(); left = node; } return left; } parseExpo() { let left = this.parseFactor(); while (true) { const op = this.lexer.peek(); if (op.type !== "**") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseExpo(); left = node; } return left; } parseFactor() { return this.parseNum(); } parseNum() { const node = new NumLiteral(); let tok = this.lexer.next(); assert.ok(tok.type === TokenType.NUMBER, this.makeErrMsg(tok)); node.loc = tok.loc; node.value = tok.value; return node; } makeErrMsg(tok) { const loc = tok.loc; return `Unexpected token at line: ${loc.start.line} column: ${ loc.start.col }`; } } module.exports = { Node, NodeType, Prog, SayHi, Parser }; ================================================ FILE: code/hi/source.js ================================================ const NL = "\n"; const CR = "\r"; const EOL = "\n"; const EOF = "\x03"; class Position { constructor(ofst, line, col) { this.ofst = ofst; this.line = line; this.col = col; } } class Source { constructor(code = "", file = "stdin") { this.code = code; this.file = file; this.ch = ""; this.ofst = -1; this.line = 1; this.col = 0; this.isPeek = false; this.posStack = []; } read(cnt = 1) { const ret = []; let ofst = this.ofst; let c; while (cnt) { const next = ofst + 1; c = this.code[next]; if (c === undefined) { c = EOF; ret.push(c); break; } ofst = next; if (c === CR || c === NL) { if (c === CR && this.code[next + 1] === NL) ofst++; if (!this.isPeek) { this.line++; this.col = 0; } c = EOL; } else if (!this.isPeek) this.col++; ret.push(c); cnt--; } if (!this.isPeek) { this.ch = c; this.ofst = ofst; } return ret.join(""); } peek(cnt = 1) { this.isPeek = true; const ret = this.read(cnt); this.isPeek = false; return ret; } getPos() { return new Position(this.ofst, this.line, this.col); } pushPos() { this.posStack.push(this.getPos()); } restorePos() { const pos = this.posStack.pop(); if (pos === undefined) throw new LexerError("Unbalanced popping of position stack"); this.ofst = pos.ofst; this.line = pos.line; this.col = pos.col; } } module.exports = { NL, CR, EOL, EOF, Position, Source }; ================================================ FILE: code/hi/test.js ================================================ const { Source } = require("./source"); const { Lexer, TokenType } = require("./lexer"); const { Parser } = require("./parser"); const { InterpretVisitor } = require("./interpret-visitor"); const { YamlVisitor } = require("./yaml-visitor"); const util = require("util"); const code = `print 1 + 2 ** 3 * 5`; const src = new Source(code); const lexer = new Lexer(src); const parser = new Parser(lexer); const ast = parser.parseProg(); // const visitor = new YamlVisitor(); // console.log(visitor.visitProg(ast)); const visitor = new InterpretVisitor(); visitor.visitProg(ast); ================================================ FILE: code/hi/visitor.js ================================================ const { NodeType } = require("./parser"); class Visitor { visitProg(node) {} visitSayHi(node) {} visitExprStmt(node) {} visitPrintStmt(node) {} visitStmt(node) { switch (node.type) { case NodeType.EXPR_STMT: return this.visitExprStmt(node); case NodeType.SAY_HI: return this.visitSayHi(node); case NodeType.PRINT_STMT: return this.visitPrintStmt(node); } } visitStmtList(list) {} visitNumLiteral(node) {} visitBinaryExpr(node) {} visitExpr(node) { switch (node.type) { case NodeType.NUMBER: return this.visitNumLiteral(node); case NodeType.BINARY_EXPR: return this.visitBinaryExpr(node); } } } module.exports = { Visitor }; ================================================ FILE: code/hi/yaml-visitor.js ================================================ const { Visitor } = require("./visitor"); const yaml = require("js-yaml"); class YamlVisitor extends Visitor { visitProg(node) { return yaml.dump({ type: node.type, body: this.visitStmtList(node.body) }); } visitStmtList(list) { return list.map(stmt => this.visitStmt(stmt)); } visitExprStmt(node) { return { type: node.type, value: this.visitExpr(node.value) }; } visitPrintStmt(node) { return { type: node.type, value: this.visitExpr(node.value) }; } visitBinaryExpr(node) { return { type: node.type, op: node.op.type, left: this.visitExpr(node.left), right: this.visitExpr(node.right) }; } visitNumLiteral(node) { return node.value; } } module.exports = { YamlVisitor }; ================================================ FILE: part1/1-1-intro.md ================================================ # 1.1 简述 我们平时所书写的源码,是无法被计算机直接执行的,因此需要一个过程,来将源码转化成机器可以执行的代码,这个转化的过程,就是编译。 综合现在所有的编程语言实现来看,并不是只有编译为机器码的语言,比如 C语言,才能称为编译型的语言。这是因为原本看上去是解释执行的语言,在其内部也大都引入的编译的环节,比如 PHP 和 Lua,它们都会将源程序编译为中间代码,称之为 Opcode,然后交由引擎或者虚拟机来执行 Opcode。因此,编译主要是强调的源码转化的过程,笼统地将语言一分为二为编译型和解释型、在当下显得有些不严谨了。 编译的步骤可以被大致简单地划分为: 1. 词法分析 2. 语法分析 3. 语义分析 4. 生成中间代码 5. 生成目标代码 词法分析「Lexical Analysis」通过词法分析器「Lexer」来完成。词法分析器的作用就是扫描字符串,并输出作为语法单元的词素「Token」,因此它也被称为扫描器「Scanner」。 语法分析「Syntax Analysis」通过语法分析器「Parser」来完成。语法分析器的作用就是根据给定的语法「Grammar」来对词素进行分析,生成抽象语法树「AST, Abstract Syntax Tree」,抽象语法树用来描述程序的结构。 语义分析「Semantic Analysis」用来保证代码中的语义规则的完整性。比如类型检查「Type Checking」。语义分析过程中还会生成符号表「Symbol Table」并生成中间代码「Intermediate Code」。中间代码的作用就是进一步将词法分析的结果进行完善,方便接下来在生成目标代码时的工作。 之所以将编译的步骤进行这样的划分,是希望简化编译器的结构,加快编译的过程。并不是对所有语言而且,上述的环节都是必须的,比如语义分析,可以移到程序运行期间来完成。在 JavaScript 中,当我们引用一个未定义的变量时,会在运行阶段进行报错「ReferenceError」,这也是由于 JavaScript 的动态性,无法在编译环节发现这个错误。 我们在接下来的章节中,都会尽量地将一些特定中文词汇所对应的英文列出来,方便大家使用搜索引擎来进一步深入了解它们。 ================================================ FILE: part1/1-2-source.md ================================================ # 1.2 预备工作 - Source 按照前文的介绍,我们本应该探索如何实现词法解析了。在进行词法分析器的讲解之前,我们最好还是先介绍辅助类 Source。 在读取字符串的过程中,我们需要统一换行符以及支持先行预测「Lookahead」,需要这些功能的原因后面会介绍。 Source 类的结构如下: ![](../.gitbook/assets/source.svg) 为了简化源文件的处理过程,我们将需要处理的源文件一次性整个地读取到属性 `code` 中,其余属性的含义分别为: | 属性 | 作用 | | :--- | :--- | | file | 源文件名 | | ch | 当前偏移量指代的字符 | | ofst | 当前的偏移量 | | line | 当前所在行号 | | col | 当前所在列号 | | isPeek | 用来标识当前是否处于 peek 模式 | 在 JS 中,可以通过下标来检索字符串中的字符。所以我们可以通过属性 `ofst` 作为下标来检索 `code` 中的字符,即 `this.code[this.ofst]` 的方式。 `ch` 表示我们当前读取到的字符。 `ofst` 被设定为指向当前字符 `ch` 在 `code` 中的索引位置。 比如,对于字符串 `abcd` 而言,当 `ch` 为 `a` 时,`ofst` 的值为 `0`。为了读取下一个字符 `b`,我们需要先将 `ofst` 加 1,以加 1 后的值作为索引来检索字符,即: ```javascript this.ofst += 1; this.ch = this.code[this.ofst]; ``` 因此,`ofst` 的初始值被我们设定为 `-1`。 在不断的读入字符的过程中,我们还需要维护行列号,行列号主要用作生成调试信息。 Source 类目前只包含两个方法,分别是 `read` 和 `peak`。它们的作用都是读取给定数量的字符,组成字符串后返回给调用者。区别在于,`read` 方法会改变位置信息,包括 `ch`、`ofst`、`line`、`col`,而 `peek` 方法则不然。 我们使用 `peek` 方法来做先行预测,在出现可能分叉的地方进行使用。比如 Lua 中注释以 `--` 开头,我们可以直接向前 peek 两个字符,看看它们是否符合条件。 _为了使读者能够快速地运行文中的代码,所有代码都可以在_ `node v11.5.0` _下直接运行而不需要借助_ [_Babel_](https://babeljs.io/) _之类的编译器。当然读者还是需要在运行前注意一下代码中的_ `require` _路径是否和自己的环境相匹配。_ 下面给出 Source 的代码和注释: ```javascript // 不同的操作系统可能使用不同的字符作为换行字符,常见的情况有 // \n, \r, \r\n 三种。 // \n 叫做「LF,Linefeed」存在于「*nix,Unix-like」系统中 // \r 叫做「Carriage Return」存在于老式的 MacOS 中 // \r\n 存在于 Windows 中 // 这里我们使用「NL,newline」作为换成的变量名,因为下面会将三种情况都统一成它。 const NL = "\n"; const CR = "\r"; const EOL = "\n"; // 0x03 在 ascii 表中的含义即为 end of text const EOF = "\x03"; class Position { constructor(ofst, line, col) { this.ofst; this.line = line; this.col = col; } } class Source { // 我们的构造函数接收源文件代码,和源文件名 // 源文件名用于生产调试信息 constructor(code = "", file = "stdin") { this.code = code; this.file = file; this.ch = ""; this.ofst = -1; this.line = 1; this.col = 0; this.isPeek = false; } read(cnt = 1) { const ret = []; let ofst = this.ofst; let c; while (cnt) { const next = ofst + 1; c = this.code[next]; if (c === undefined) { c = EOF; ret.push(c); break; } ofst = next; // 这里我们将可能的三种换行都统一为 *nix 换行符 '\n' if (c === CR || c === NL) { if (c === CR && this.code[next + 1] === NL) ofst++; // 识别为换行之后,如果处于 read 模式,则需要更新行列号 // 将行号加 1,表示进入了下一行 // 将列号重置,表示此时处于新行的行首 if (!this.isPeek) { this.line++; this.col = 0; } c = EOL; } else if (!this.isPeek) this.col++; ret.push(c); cnt--; } if (!this.isPeek) { this.ch = c; this.ofst = ofst; } return ret.join(""); } // 由于 peek 逻辑除了不需要更新位置信息外、和 read 逻辑一致,因此我们通过 // 设定 `isPeek` 属性使得在 read 内部得以区分当前状态 peek(cnt = 1) { this.isPeek = true; const ret = this.read(cnt); this.isPeek = false; return ret; } getPos() { return new Position(this.ofst, this.line, this.col); } } module.exports = { NL, CR, EOL, EOF, Position, Source }; ``` 对于类「Position」和方法 `Source::getPos()`,很明显它们是分别用于存放和获取 Source 的位置信息的。 目前为止,我们 Source 类的功能有这几点: 1. 所有对源码的读取操作将通过 Source 类的实例来完成 2. 我们可以在读取的过程中维护当前的读取位置 3. 我们将不同的换行符都进行了统一,返回 EOL 4. 我们还提供了预读功能,就是在不改变当前位置信息的情况下,往前读取几个字符。 ================================================ FILE: part1/1-3-hi.md ================================================ # 1.3 我们的第一个语言 - hi 我们已经介绍了编译的基本概念,还实现了 Source 类。接下来我们将逐步实现自己的第一个编程语言。按照之前介绍的编译器结构,似乎我们应该开始介绍词法解析了,但是词法解析是需要根据语言的语法来进行的,所以还是让我们先了解一下,如何来对编程语言进行描述。 ## 扩展巴科斯范式「EBNF, Extended Backus–Naur Form」 我们通过语法「Grammar」来定义我们的编程语言。语法的是由一组规则「Production Rules」组成的,通过这些规则来构成语法。 这些规则由两部分所构成:1\). 规则名称 2\). 规则内容。比如我们将要实现的语言「称之为 hi」: ```text 「say_hi」需要满足: hi 字符串 「字符串」需要满足: " 除了「"」的任意字符 " ``` 如果上面的描述看不懂的话,我们来看一下使用我们的 hi 语言书写的程序: ```text hi "lexer" hi "parser" hi "compiling" ``` 看到了吧,我们的 hi 语言就只有一种类型的语句,通过它你可以 「say hi to everyone」。 在上面的语法中,我们定义了两个规则,分别是「字符串」和「say\_hi」。 对于规则「字符串」而言,它的规则内容为,以「"」开头,接收任意除了「"」的任意字符,直到接收到「"」后结束该规则,在该规则内接收的字符串,都被打包到了我们的「字符串」规则中。 对于规则「say\_hi\」而言,它的规则内容为,以关键字「hi」开头,接下来的内容将必须符合「字符串」规则。 为了更加严谨的描述我们的语法,我们需要使用各种标记语言,而扩展巴科斯范式「EBNF」就是这些标记语言中的其中一种。 通过 EBNF 可以将我们的语法表示为: ```text say_hi = "hi", string ; string = '"' , { all characters - '"' }, '"' ; ``` 可以看出,EBNF 描述规则的表达式为: ```text 规则名称 = 终结符和非终结符的组合 ``` 终结符「Terminal Symbol」就是词法中不能被分解的最小单元,反之为非终结符「Nonterminal Symbol」。比如规则「say\_hi」的右边,`"hi"` 为终结符;而 `string` 则为非终结符,因为它还有与之对应的规则。 EBNF 中使用了如下的符号: | 符号 | 含义 | | :--- | :--- | | = | 定义规则,左边为规则名称,右边为\(非\)终结符的组合 | | , | 表示连接位于其左右两边的\(非\)终结符 | | \| | 表示位于其左右两边的\(非\)终结符为二选一的关系 | | \[ ... \] | 表示方括号中的\(非\)终结符为可选项,在该位置出现次数为 0 或 1 | | { ...} | 表示大括号中的\(非\)终结符为重复出现的,在该位置出现次数 >= 0 | | \( ...\) | 表示小括号中的\(非\)终结符在该位置以组合的形式出现 | | " ... " | 表示终结符 | | ' ... ' | 表示终结符,主要用于需要表示 '"' 的情况 | | \( _..._\) | 表示注释 | | ?...? | 表示其中的内容具有特殊含义,对该含义的定义不在 EBNF 标准之内,有使用者来决定 | | - | 表示将右边的内容从左边进行排除 | 我们使用 EBNF 来总结一下 hi 的语法: ```text prog = { say_hi } ; say_hi = "hi", string ; string = '"' , { all characters - '"' }, '"' ; ``` 我们增加了一条新的规则「prog」作为我们未来进行解析的起始规则。我们的 hi 语言将只包含一条语句「Statement」类型,就是「say\_hi」。 ## W3C's EBNF 如果大家通过搜索引擎来检索关键字「EBNF」,那么会发现很多链接中都介绍了上一节的 EBNF 语法。它是由 ISO 制定的。ISO 是一个全球化的组织,制定了很多的标准,其中也就包括了 [ISO/IEC 14977](http://standards.iso.org/ittf/PubliclyAvailableStandards/s026153_ISO_IEC_14977_1996%28E%29.zip),也就是上一小节介绍的 EBNF 标记法。 ISO 制定 EBNF 标准的目的就是为了扩展和统一 [BNF](https://en.wikipedia.org/wiki/Backus–Naur_form) 的使用方法,因为在此之前大家在描述语法的时候,都或多或少的加入了自己的喜好,使之成了 BNF 的方言「Dialect」。 我们好像忘记介绍 BNF 了,没关系,一句话来概括它:就是没有扩展前的 EBNF、功能和 BNF 一样,都是用来描述语法的,缺点就是灵活性不如它的后辈 EBNF。 只是大家对 ISO 制定的 EBNF 标准并不满意,因为它使用起来还是很麻烦,甚至在 ISO 后来的标准制定中涉及语法描述的部分,都没有使用自家制定的「ISO/IEC 14977」。 所以这里我们要介绍由另一个组织 W3C 制定的 EBNF 标记法。W3C 是主要负责万维网标准制定的组织。它定义的 EBNF 标记法被自身和外界广泛使用。它的特点主要是参考了正则表达式「Regular Expression」的语法。接下来我们来一起看一下它的语法。 首先,语法中的每个规则定义一个符号「Symbol」,形式为: ```text symbol ::= expression ``` 如果 Symbol 定义词法元素「Token」的话,那么首字母大写;如果是定义语法元素,比如「Statement」或「Expression」的话,那么首字母小写。 我们怎么来确定语法中哪些元素应该视为词法元素还是语法元素呢?简单地说,不能再进行划分的,我可以视为词法元素,比如关键字,字符串,数值,它们在编程语言语法组成中的地位都好比我们自然语言中的单词。而编程语言中类比到人类语言中句型定义的部分,规则名称需要小写,比如定义疑问句的规则,定义陈述句的规则之类。 规则右边的内容,也就是 expression 用来匹配一个或者多个连续的字符。可以出现在右边的表达式的内容具有以下几种形式: **\#xN** 单个字符。N 是一个十六进制数,整个表达式用来表示一个字符,前导的 0 可以省略,也就是说 `\x03` 可以直接写成 `\x3`。具体的数值源于该字符在 Unicode 中的 [code-point](https://github.com/hsiaosiyuan0/icj/tree/8cd8591f0fb2468225c66c0c9c2b2af413f99ec8/part1/code-point.md)。比如,`a` 的 code-point 为 `0x61`,那么可以使用 `#x61` 来表示字符 `a` **\[a-zA-Z\], \[\#xN-\#xN\]** 字符区间。表示匹配位于该区间内的任意字符,包含区间的其实和结束字符在内。比如 `[a-z]` 表示匹配所有位于 `a` 之间的字符 `z`,包括 `a` 和 `z`。如果直接写成 `[\x0-\xff]` 的话,那么数值形式的区间很好理解;如果是字符形式的区间,则可以理解成先将起始和结束的字符转换成它们对应的 code-point,然后取 code-point 对应的数值区间。 **\[abc\], \[\#xN\#xN\#xN\]** 字符枚举。表示接下来出现的字符列举在方括号内,可以和「字符区间」写在同一个方括号内。 **\[^a-z\], \[^\#xN-\#xN\]** 字符区间排除。表示接下来的字符不处于区间之内。 **\[^abc\], \[^\#xN\#xN\#xN\]** 字符枚举排除。表示接下来的字符不处于枚举之列,也可以和「字符区间排除」写在同一个方括号内。 **"string"** 匹配双引号中的字符。 **'string'** 匹配单引号中的字符。 **\(expression\)** 括号中的表达式被划定为一个分组。 **A?** 表示 A 出现的次数为 0 或者 1。 **A B** 表示 A 后面紧接着 B。这个操作的优先级高于符号为 `|` 的交替操作,比如 `A B | C D` 等同于 `(A B) | (C D)` **A \| B** 表示 A 或者 B 交替出现于该位置,即该位置出现的内容需要满足非 A 即 B **A+** 表示一个或者多个连续出现的 A。该操作具有比符号为 `|` 的交替操作更高的优先级,比如 `A+ | B+` 等同于 `(A+) | (B+)` **A\*** 表示零个或者多个连续出现的 A。该操作具有比交替操作更高的优先级。 **/\*... \*/** 表示注释。 所以我们可以将 sayHi 的语法写成 W3C'S EBNF 的形式: ```text prog ::= say_hi* say_hi ::= HI STRING HI ::= "hi" STRING ::= '"' [^"]* '"' ``` 注意我们将词法元素的规则进行了大写区分。 我们可以将 W3C'S EBNF 转换成对应的语法图「Syntax Diagram 或 Railroad Diagram」: **prog:** ![](../.gitbook/assets/prog%20%281%29.png) **say\_hi:** ![](../.gitbook/assets/say_hi%20%281%29.png) **STRING:** ![](../.gitbook/assets/string%20%281%29.png) _我们可以使用_ [_Railroad Diagram Generator_](https://bottlecaps.de/rr/ui) _来生成这些图。_ 我们在看这些图的时候,使用从左往右的方向,以左边的箭头为起点,遇到分支就可以自由的选择不同的分路、除了不能回头,直到到达最右边。 比如第一个图,有两个分支,如果我们在第一个分支处,采取向右直行的分支,那么到达最右端之前,将不会遇到任何的位置匹配,这就表示了我们语法中的零条 `say_hi` 语句的情况。 在比如,如果在第一个分支处,我们选取了经过 `say_hi` 的分支,那么在到达第二个分支处时,表示我们已经匹配了一条 `say_hi` 语句,此时如果我们选择直接向右的路线,那么由于到达了终点,此条轨迹表示我们匹配一条语句的情况。而如果我们选择回到第一条分支的路线,那么则会继续匹配 `say_hi` 语句。 这几种情况的合集,就表示了我们的第一条规则:匹配零条或多条 `say_hi` 语句。 ================================================ FILE: part1/1-4-lexer.md ================================================ # 1.4 词法解析器 有了前面的介绍,我们编写词法解析的工作将变得很简单。接下来我们开始着手编写我们的词法解析器「Lexer」。 我们的 hi 语言只有两个词法元素,它们的类型分别是 `HI` 和 `STRING`。 词法分析器的作用就是,接收 Source 的输入,并将其转化为 Tokens 输出,根据这个需求,我们可以这样来写词法解析器: ```javascript class Lexer { constructor(src) { this.src = src; } next() { this.skipWhitespace(); const ch = this.src.peek(); switch (ch) { case '"': return this.readString(); case "h": return this.readHi(); case EOF: return new Token(TokenType.EOF); default: throw new Error(this.makeErrMsg()); } } makeErrMsg() {} readHi() {} readString() {} skipWhitespace() {} } ``` 可以看到 Lexer 的构造函数只有一个参数,就是 Source 的实例。未来在使用 Lexer 的时候,通过不断调用 `next` 方法,来读取 Tokens。 在 `next` 方法中,我们使用了先行预测「Lookahead」,即向前预读一个字符,来判断接下来是读取 HI 关键字,还是读取字符串。如果两者都不是,就判断是否已经读到源文件的结尾处。最后如果都匹配不到,我们就直接抛出一个异常,表示读入了无法识别的字符,也就是源文件中包含了不符合词法规则的字符。 因为词法元素在源文件中是被空白分割的,所以我们在 `next` 运行之初,调用了 `skipWhitespace` 方法,就和它的名字一样,这是用来跳过文件中的空白字符的。`skipWhitespace` 方法如下: ```javascript skipWhitespace() { while (true) { let ch = this.src.peek(); if (ch === " " || ch === "\t" || ch === EOL) { this.src.read(); continue; } break; } } ``` 我们这里简单地跳过空格、制表符、和换行。 为什么是「简单地」?因为我们没有考虑 Unicode 的情况,在 Unicode 中,还有其他的一些字符表示空白。 并且上面使用了 `EOL` ,因为各个平台不同的换行符已经被我们在 Source 中处理过了。 `makeErrMsg` 方法非常简单,就是制作报错信息,因为我们在词法解析的过程中,将会多次使用报错信息,所以我们将该功能做成一个方法: ```javascript makeErrMsg() { return `Unexpected char at line: ${this.src.line} column: ${this.src.col}`; } ``` 对于报错信息,我们也只是简单地打印行列号。 在开始读取 Token 之前,我们还需要另外几个辅助类,分别是「SourceLoc」,「Token」,「TokenType\]: ```javascript class SourceLoc { constructor(start, end) { this.start = start; this.end = end; } } class Token { constructor(type, value, loc) { this.type = type; this.value = value; this.loc = loc || new SourceLoc(); } } class TokenType {} TokenType.EOF = "eof"; TokenType.HI = "hi"; TokenType.STRING = "string"; ``` Token 表示的就是我们解析后的词法元素,包含了该词法元素的类型,内容,以及它在源文件中的位置信息。由于 Token 的内容是源文件的片段,所以位置需要包含 `start` 和 `end` 信息。 接着我们开始看一下 `readHi` 方法的实现: ```javascript readHi() { const tok = new Token(TokenType.HI); tok.loc.start = this.src.getPos(); const hi = this.src.read(2); assert.ok(hi === "hi", this.makeErrMsg()); tok.loc.end = this.src.getPos(); tok.value = "hi"; return tok; } ``` 首先,一旦进入了该方法,则表示接下来的字符一定是 `h`,因为我们是在 `next` 方法中通过预读一个字符、并匹配到 `h` 进来的。于是我们这里直接读取接下来的两个字符,判断它们是否是 `hi`。如果不是 `hi`,我们则直接报错。 注意这里 Token 的位置信息收集分为两部分,在读取之前,我们收集了 `start` 信息,在读取内容之后,我们收集了 `end` 的信息。 最后我们看一下 `readString` 方法: ```javascript readString() { const tok = new Token(TokenType.STRING); tok.loc.start = this.src.getPos(); this.src.read(); const v = []; while (true) { let ch = this.src.read(); if (ch === '"') break; else if(ch === EOF) throw new Error(this.makeErrMsg()); v.push(ch); } tok.loc.end = this.src.getPos(); tok.value = v.join(""); return tok; } ``` 注意第一个 `read` 操作,因为我们是通过预读满足了 `"` 才调用的该方法,所以我们这里直接跳过 `"`,因为我们只想收集双引号之间的字符串内容。然后在循环内部,我们就不断读取字符,如果遇到了作为关闭标签的 `"` 我们就跳出循环。 我们将读取的字符都先存入 `v` 数组中,在循环跳出之后,再将它们合并为字符串,存入 Token。 注意在写循环的时候,一定到注意跳出条件,所以如果在读到文件末尾仍未遇到关闭的 `"`,我们就抛出一个异常来终止程序。对于 `read` 方法的实现,我们也可以结合之前根据 EBNF 生成的铁路图来看。 现在我们来试一试我们的 Lexer 的工作效果: ```javascript const { Source } = require("./source"); const { Lexer, TokenType } = require("./lexer"); const code = `hi "lexer"`; const src = new Source(code); const lexer = new Lexer(src); while (true) { const tok = lexer.next(); if (tok.type === TokenType.EOF) break; console.log(tok); } ``` 运气好的话,我们应该会在控制台看到输出了两个 Tokens,它们的类型分别是 `HI` 和 `STRING`。 最后,我们再为 Lexer 添加一个方法 「Peek」,这样我们的词法分析器也有了预读的功能 ```javascript peek() { this.src.pushPos(); const tok = this.next(); this.src.restorePos(); return tok; } ``` 可以看到我们的预读功能主要是借助了 Source 类中的 `pushPos` 和 `restorePos` 方法。所以我们继续在 Source 类中补全这两个方法: ```javascript constructor(code = "", file = "stdin") { // ... this.posStack = []; } pushPos() { this.posStack.push(this.getPos()); } restorePos() { const pos = this.posStack.pop(); if (pos === undefined) throw new LexerError("Unbalanced popping of position stack"); this.ofst = pos.ofst; this.line = pos.line; this.col = pos.col; } ``` 首先我们给 Source 类增加一个属性 `posStack`,用来保存位置信息。随后,通过对这个数组进行 `push` 和 `pop` 来存入和取回位置信息。 因为 `push` 和 `pop` 在代码中肯定是成对出现的,所以我们在 `restorePos` 的时候,进行了一个简单的检查。 为了使我们未来在 Parser 中获取源文件位置时,不需要使用 `this.lexer.src.getPos()` 这么长的调用链,我们在 Lexer 中增加了 `getPos` 方法。 ```js getPos() { return this.src.getPos(); } ``` 这样我们在 Parser 中只需要通过 `this.lexer.getPos()` 就可以了,是短了一点吧看起来。 ================================================ FILE: part1/1-5-parser.md ================================================ # 1.5 语法解析器 通常的语法解析算法有「LL,Left-to-right, Leftmost Derivation」和「LR, Left-to-right, Rightmost derivation」两种,第一个 L 表示 Left-to-right,第二个 L 和 R 分别表示最左推导和最右推导。 「推导」,表示的是如何运用语法规则来匹配输入的字符串。和数学中的推导相似,我们通过不断地将非终结符替换为它的某个生产式来得出输入字符串是否匹配语法的结论。 比如我们的 hi 语言: ```text prog ::= say_hi* say_hi ::= HI STRING HI ::= "hi" STRING ::= '"' [^"]* '"' ``` 假设我们的源文件内容为 `hi "parser"`。为了解析这个字符串,我们从 `prog` 开始,遇到非终结符,就使用其规则内容代替,直到字符串结尾。 匹配的过程为: 1. 以 `prog` 为起始点 2. `prog` 右边为非终结符 `say_hi`,因此使用 `say_hi` 的规则内容 3. 发现非终结符 `HI`,于是使用其规则内容 4. 发现输入中的字符串 `hi` 匹配了该规则,于是开始尝试匹配 `say_hi` 规则中的 `STRING` 5. 发现 `STRING` 也是非终结符,于是转而使用其规则内容 6. 发现余下的输入字符匹配了 `STRING` 规则的内容,于是回到 `say_hi` 规则 7. 回到 `say_hi` 之后,发现它已经处理完毕,因此,回到了 `prog` 8. 回到 `prog` 之后,我们发现此时已经处理完全部的输入,符合了我们 `prog` 的规则,因此 `prog` 规则也匹配完成 最终,我们发现输入的字符串匹配了我们的语法。通过具体的步骤,我们可以体会到不断带入和推导的过程。 上述的推导过程,就是最左推导 - 我们总是从语法规则的最左边非终结符进行推导。与之类似,最右推导的定义为 - 总是选择最右边的非终结符进行推导。虽然最右推导的定义如此,但是我们并不能简单地将它套用进上述演示步骤。 最右推导可以描述的语法集合大于最左推导,但是缺点就是非常复杂且难于徒手完成它,因此出现了很多工具,用于生成 LR Parser,比如 [Yacc](http://dinosaur.compilertools.net/yacc/)。本系列的主要目的就是教大家如何手写编译器,因此余下的章节中,我们将只讨论最左推导。 最左推导还会有一个 N 的代数形式 - 「LL\(N\)」。N 表示的是在遇到分支的时候,最多向前预读 N 个 Token。比如有这样的语法: ```text stmt ::= ifStmt | whileStmt | blockStmt stmtList ::= stmt* ifStmt ::= "if" "(" expr ")" stmt "else" stmt whileStmt ::= "while" "(" expr ")" stmt blockStmt ::= "{" stmtList "}" ``` 我们在处理 `stmt` 规则的时候,至少需要预读 1 个 Token,根据该预读的 Token 是 `if` 还是 `while` 还是 `{` 来是选择接下来处理 `ifStmt` 还是 `whileStmt` 还是 `blockStmt` 。因此这样的语法又被称为 LL\(1\) 语法。 以 C 语言入门的同学,不知道是否记得当初学习 if 语句的口诀「if 只能跟单条语句,如果需要使用多条语句则需要将多条语句放在大括号中」。如果了解了上面语法的含义,我就会知道原来 if 语句实际上只能跟单条语句,只不过有一个 `blockStmt` 语句,在它内部可以包含多条语句。 如果我们仔细观察上面的演示推导过程,会发现两点: 1. 将对每个非终结符处理都设想成一个函数,那么会发现整个推导过程,实际就是函数的互相调用 2. 并且,是由父级的规则函数调用了子级的规则函数,比如 `Prog` 调用了 `say_hi`;整个过程是由根「Root」规则开始,以深度优先的原则逐步进入到叶子规则的处理 因此基于最左推导完成的解析器通常又被称为自上而下「Top-Down」的解析器。 现在可以看看作为我们 hi 语言的语法解析器的框架代码: ```javascript class Parser { constructor(lexer) { this.lexer = lexer; } parseProg() { while (true) { const tok = this.lexer.peek(); if (tok.type === TokenType.EOF) break; this.parseSayHi(); } } parseSayHi() {} } ``` `parseProg` 中的内容,就对应了 `prog` 规则的内容。我们先预读一个 Token,看其是否已经到达输入的结尾,如果是就停止处理,否则就调用 `parseSayHi` 进行解析。 注意这里的框架代码不要完全的对照上面的演示推导过程,否则你会发现没有 `parseHi` 和 `parseString`。前文已经提到过,为了将整个编译过程结构化,词法元素的解析已经被我们移到了 Lexer 中。 在开始完善解析器之前,还需要介绍另外两个类:「Node」和「NodeType」 ```javascript class Node { constructor(type, loc) { this.type = type; this.loc = loc || new SourceLoc(); } } class Prog extends Node { constructor(loc, body = []) { super(NodeType.Prog, loc); this.body = body; } } class SayHi extends Node { constructor(loc, value) { super(NodeType.SAY_HI, loc); this.value = value; } } class NodeType {} NodeType.Prog = "prog"; NodeType.SAY_HI = "sayhi"; ``` 我们通过类「Node」和其派生类来存放我们解析的结果,通过「NodeType」来区分 Node 类型。 现在我们可以开始看看补全后的解析方法: ```javascript parseProg() { const node = new Prog(); node.loc.start = this.lexer.getPos(); while (true) { const tok = this.lexer.peek(); if (tok.type === TokenType.EOF) break; node.body.push(this.parseSayHi()); } node.loc.end = this.lexer.getPos(); return node; } parseSayHi() { const node = new SayHi(); let tok = this.lexer.next(); assert.ok(tok.type === TokenType.HI, this.makeErrMsg(tok)); node.loc.start = tok.loc.start; tok = this.lexer.next(); assert.ok(tok.type === TokenType.STRING, this.makeErrMsg(tok)); node.value = tok.value; node.loc.end = tok.loc.end; return node; } makeErrMsg(tok) { const loc = tok.loc; return `Unexpected token at line: ${loc.start.line} column: ${ loc.start.col }`; } ``` 我们在开始处理节点数据前,首先保存它的其实位置信息,在节点处理完成后,保存它的结束位置信息。并且我们使用 `assert.ok` 来在发生读入 Token 和语法不匹配时,直接报错并结束程序。 虽然通常情况下,一个好的错误恢复「Error Recovery」机制是一个好的解析器的必要条件,因为这样可以在一次解析过程中收集并报告尽量多的错误信息。然而要真正地完成一个良好的错误恢复子程序,其难度并不亚于编写一个解析器。况且在现有硬件条件、配合大部分情况下源码体积都很小时,解析一次也耗费不了多少时间,所以我们的一次仅报告一个错误的实现看起来似乎也是可行的。 最后我们来看一看,如何来将 Parser 和 Lexer 放到一起,来解析程序,并打印结果: ```javascript const { Source } = require("./source"); const { Lexer, TokenType } = require("./lexer"); const { Parser } = require("./parser"); const util = require("util"); const code = `hi "lexer"`; const src = new Source(code); const lexer = new Lexer(src); const parser = new Parser(lexer); const ast = parser.parseProg(); console.log(util.inspect(ast, true, null)); ``` 运气好的情况下,我们大概会得到以下的输出: ```text Prog { type: 'prog', loc: SourceLoc { start: Position { ofst: -1, line: 1, col: 0 }, end: Position { ofst: 9, line: 1, col: 10 } }, body: [ SayHi { type: 'sayhi', loc: SourceLoc { start: Position { ofst: -1, line: 1, col: 0 }, end: Position { ofst: 9, line: 1, col: 10 } }, value: 'lexer' }, [length]: 1 ] } ``` 这个输出的内容,就是我们常说的抽象语法树「AST,Abstract Syntax Tree」了。这个树形结构,就是用来描述源码中的词法元素根据语法规则组成的层程序结构。有了这个结构之后,我们可以做更多的事情,比如我们接下来将进一步实现一个解释器,来解释运行我们的代码。 ================================================ FILE: part1/1-6-ast-interpreter.md ================================================ # 1.6 使用 AST - 第一个解释器 但我们得到了程序的 AST 结构之后,我们可以围绕它做很多事情,接下来我们将通过编写一个解释器,来了解如何使用 AST。 因为 AST 是一个树形结构,那么很明显,我们需要通过遍历这个树形结构来使用它。 由于 AST 中有很多不同类型的节点\(尽管目前我们的 hi 语言只有寥寥无几的几个类型\),而针对这些节点,我们大概率也会采取不同的操作,因此我们将对这些节点的操作都抽离出来,放到一个名为 Visitor 的类中。这也是利用了[设计模式](https://zh.wikipedia.org/wiki/设计模式_%28计算机%29)中的访问者模式「Visitor Pattern」。 下面我们来看一下 Visitor 的结构: ```javascript class Visitor { visitProg(node) {} visitSayHi(node) {} } ``` 由于我们的 hi 语言太简单了,它只有两个节点类型,一个是 `PROG` 和 `SAY_HI`,所以我们的 Visitor 中的操作也只有两个。 现在我们开始实现我们的解释器,我们的解释器需要继承于 Visitor 类,我们给它取名「InterpretVisitor」: ```javascript class InterpretVisitor extends Visitor { visitProg(node) { node.body.forEach(stmt => this.visitSayHi(stmt)); } visitSayHi(node) { console.log(`hi ${node.value}`); } } ``` 因为 InterpretVisitor 的实现也非常的简单,我们就直接给出实现了。 可以看到,我们在 `visitProg` 内部,就是迭代节点的 `body` 属性,使用其中的元素为参数调用 `visitSayHi` 方法。回顾我们的 `Prog` 节点的定义: ```javascript class Prog extends Node { constructor(loc, body = []) { super(NodeType.Prog, loc); this.body = body; } } ``` 我们将它其中的语句都存入了 `body` 属性数组中。 而对于 `visitSayHi` 方法的实现,我们则是简单地拼接一个 `hi ${something}` 字符串,然后打印该字符串。 我们将所有这些组合到一起,来运行一下我们的解释器: ```javascript const { Source } = require("./source"); const { Lexer, TokenType } = require("./lexer"); const { Parser } = require("./parser"); const { InterpretVisitor } = require("./interpret-visitor"); const util = require("util"); const code = `hi "lexer" hi "parser" `; const src = new Source(code); const lexer = new Lexer(src); const parser = new Parser(lexer); const ast = parser.parseProg(); const visitor = new InterpretVisitor(); visitor.visitProg(ast); ``` 幸运的话,我们会在控制台看到如下的输出: ```text hi lexer hi parser ``` 到此为止,我们已经完成了一个解释型的语言。千万不要感到惊讶,尽管它目前非常的简单,但是它真的是一个编程语言。 ================================================ FILE: part1/1-7-arith-left-recursion.md ================================================ # 1.7 解析算术表达式 - 左递归和其消除法 ## 算术表达式语法 到目前为止,我们的 hi 语言还是显得有些单薄了。接下来我们将给它添加计算数学表达式的功能。 未来我们在像 hi 语言中增加新的语法功能的时候,都将按照这样的步骤: 1. 增加相应的语法规则,也就是在我们的 EBNF 中增加需要新增的语法规则 2. 根据新增的语法规则,向词法解析器中增加适应新规则的内容 3. 根据新增的语法规则,向语法解析器中增加适应新规则的内容 按照上面的步骤来看,首先我们需要在向语法中增加数学表达式的新规则。我们来看看数学表达式的语法来如何书写: ```text expr ::= expr "*" expr | expr "/" expr | expr "+" expr | expr "-" expr | num ``` ## 左递归「Left Recursion」 我们先来回顾下,我们对已有的 hi 语言规则是如何进行解析的: ```text prog ::= say_hi* say_hi ::= HI STRING HI ::= "hi" STRING ::= '"' [^"]* '"' ``` 我们分别在 Parser 中写了 `parseProg` 和 `parseSayHi` 两个方法,它们分别对应语法中的 `prog` 和 `say_hi` 这两个规则。然后我们根据 `prog` 的语法规则,在 `parseProg` 内部调用了 `parseSayHi`,方法之间的协作方式,完全的参照了语法规则的定义。 再来看一看我们目前得到的数学表达式语法规则,我们立刻按部就班地往 Parser 中添加一个新的 `parseExpr` 方法。 但是问题很快就出现了,我们先看第一个分支: ```text expr ::= expr "*" expr ``` 按照规则的定义,我们在 `parseExpr` 内部,需要首先调用 `parseExpr` 方法。很显然,由于这个方法直接调用了自身的同时、内部并没有对需要处理的字符串进行任何步进操作,因此会导致程序陷入无限循环。 对于一个规则而言,如果它右边的规则内容部分、最左边出现的又是自身的规则,我们就称该规则是左递归「Left Recursion」的;并且该规则所属的语法,又被称为是左递归的语法。相似的,如果它右边的规则内容部分、最右边出现的又是自身的规则,我们就称之为右递归的规则。 ## 消除左递归「Elimination of Left Recursion」 我们目前手写的语法解析器,是属于自上而下「Top-Down」的类型,我们还发现,自上而下的语法解析器,是无法处理左递归语法的。 所以我们要考虑,如何消除左递归「Elimination of Left Recursion」。 为了将问题简化,我们来看一个简单的左递归的语法规则: ```text A ::= Aα | β ``` 这个语法规则中包含这样几个内容: 1. 非终结符 A 2. 不以 A 开头的\(非\)终结符 α 3. 不以 A 开头的\(非\)终结符 β 有一点需要注意的是,上面的语法规则属于直接左递归,因为它的右边的规则内容立刻出现了自身。为了做为对比,我们看一下间接左递归: ```text A ::= Sα | β S ::= Aβ ``` 我们来看一下这个规则的匹配过程: 1. 选取 `A ::= Sα` 这个分支 2. 因为 `S ::= Aβ`,所以将其右边带入到上一步、替换其中的 `S`,得到:`A ::= Aβα` 这又符合了我们之前对左递归的定义。我们的自上而下的解析器同样无法处理间接左递归的语法。 我们先看一下对于直接左递归 `A ::= Aα | β` 而言,它如何匹配输入的字符串: 1. `A ::= Aα` 2. `A ::= Aαα` 3. `A ::= Aααα` 4. 以此类推... 5. 直到某一时刻,输入的内容匹配到了 `β`,最终我们匹配的结果为 6. `A ::= βααα...` 当然,我们也可能在第一步的时候就直接匹配到了 `β`。因此,上面的语法匹配的字符为:以 `β` 开头的,后面紧接着任意数量的 `α`。 现在我们知道对于上面的规则来说,形如 `βααα...` 的输入,将会符合规则。并且上面的语法规则实际上是从右往左来匹配输入的。 为了使得我们的自上而下的解析器得以工作,我们重写后的规则需要满足: 1. 规则 A 需要从左往右来匹配输入 2. 规则 A 最左边的第一个\(非\)终结符不能为 A 自身 根据这两个原则,我们首先将 A 写成: ```text A ::= βA' ``` 这样的目的就是先匹配 `βααα...` 中的第一个 `β`,这就满足了我们上面的两点重写需求。但是还没结束,我们接着 `A'` 要做的就是能够匹配重复出现的 `α`,因此我们可以将 `A'` 写成: ```text A' ::= αA' | ε ``` `ε` 表示匹配输入的结尾,作为匹配的终止条件。 我们将它们放到一起: ```text A ::= βA' A' ::= αA' | ε ``` 我们来试着使用重写后的规则,看看它将如何匹配输入: 1. `A ::= βA'` 匹配开头第一个 `βA'` 2. 因为 `A' ::= αA'`,将其右边带入上一步的右边、替换 `A'`,可以匹配到 `βαA'` 3. 继续上一步,可以继续匹配到 `βααA'` 4. 继续上一步,可以继续匹配到 `βαααA'` 5. 以此类推... 6. 直到匹配到输入的结尾部分,满足 `A' ::= ε` 7. 最终得以匹配的结果为 `βααα...αααε` 现在我们得出结论,对于直接左递归: ```text A ::= Aα | β ``` 我们可以通过左递归消除,将其变换为: ```text A ::= βA' A' ::= αA' | ε ``` 现在我们着手看一下我们的算术表达式语法,针对它如何进行左递归消除: ```text expr ::= expr "*" expr | num ``` 我们直接套用公式,令: * `A = expr`,因为 `A` 的定义为需要进行消除的项 * `α = "*" expr`,因为 `α` 的定义为,以不是待消除项开头的\(非\)终结符 * `β = num`,因为 `β` 的定义为,以不是待消除项开头的\(非\)终结符 经过变换得到: ```text expr ::= num expr' expr' ::= "*" expr expr' | ε ``` 我们已经可以将乘法表达式进行左递归消除了,接下来我们看看如何处理整个算术表达式: ```text expr ::= expr "*" expr | expr "/" expr | expr "+" expr | expr "-" expr | num ``` 面对一下变得这么复杂的表达式,大家估计会感到无从下手。我们可以利用已经掌握的知识,将每个分支先拆开来进行消除: ```text expr ::= num expr' expr' ::= "*" expr expr' | ε expr ::= num expr' expr' ::= "/" expr expr' | ε expr ::= num expr' expr' ::= "+" expr expr' | ε expr ::= num expr' expr' ::= "-" expr expr' | ε ``` 我们将上面的结果两行一组、相互对照起来观察,不难发现,第一行都是相同的,第二行也只是操作符不同,将所有第二行综合起来看,它们其实都是表示 `expr'` 的不同分支。我们可以将上面的结果进行合并: ```text expr ::= num expr' expr' ::= "*" expr expr' | "/" expr expr' | "+" expr expr' | "-" expr expr' | ε ``` 于是我们发现一个新的结论,对于形如: ```text A ::= Aα | Aβ | γ ``` 的规则,我们可以将其转换成: ```text A ::= γA' A' ::= αA' | βA' | ε /* A' 根据 EBNF 语法功能,上面的 A' 又可进一步简化为 */ A' ::= α* | β* | ε /* 将 A' 进一步简化 */ A' ::= ( α | β )* | ε ``` 来消除左递归。 我们最终处理完成后的表达式语法为: ```text expr ::= num expr' /* 对照上面简化 A' 中间形式 */ expr' ::= ( "*" expr )* | ( "/" expr )* | ( "+" expr )* | ( "-" expr )* | ε /* 对照 A’ 的最终形式 */ expr' ::= ( "*" expr )* | ( "/" expr )* | ( "+" expr )* | ( "-" expr )* | ε /* 根据我们的表达式内容进一步简化 */ expr' ::= ( ( "*" | "/" | "+" | "-" ) expr )* | ε /* 最终我们得到 */ expr ::= num expr' expr' ::= ( ( "*" | "/" | "+" | "-" ) expr )* | ε ``` ## 完善解析器 现在我们来补全我们的解析器,以解析算术表达式。 ### 完善词法解析器 我们先来完善词法解析器,首先我们先增加几个 Token 类型: ```javascript TokenType.NUMBER = "number"; TokenType.MUL = "*"; TokenType.DIV = "/"; TokenType.ADD = "+"; TokenType.SUB = "-"; ``` 这些新增的类型即为我们接下来将要解析的 Token 类型,我们来修改一下 `Lexer::next` 方法: ```javascript next() { this.skipWhitespace(); const ch = this.src.peek(); if (ch === '"') return this.readString(); if (ch === "h") return this.readHi(); // 解析数字 if (Lexer.isDigit(ch)) return this.readNumber(); // 解析运算符 if (Lexer.isOp(ch)) return this.readOp(); if (ch === EOF) return new Token(TokenType.EOF); throw new Error(this.makeErrMsg()); } ``` 没有什么特别新的东西,只是增加了两个预测分支,分别预测接下来选择解析数字还是解析运算符。接着我们看一下 `readNumber` 和 `readOp` 的实现: ```javascript readNumber() { const tok = new Token(TokenType.NUMBER); tok.loc.start = this.getPos(); const v = [this.src.read()]; while (true) { let ch = this.src.peek(); if (Lexer.isDigit(ch)) { v.push(this.src.read()); continue; } break; } tok.loc.end = this.getPos(); tok.value = v.join(""); return tok; } readOp() { const tok = new Token(); tok.loc.start = this.getPos(); tok.type = this.src.read(); tok.loc.end = this.getPos(); return tok; } ``` 如果输入的是数字的话,我们就不断的尝试读取接下来的数字,直到接下来的字符不是数字为止,这里我们只处理了整型数,并且没有特别考虑前导零和正负整数的情况。所以符合我们条件的数字面量的类型为:以可选前导零开头的任意整数。 处理操作符\(运算符\)的过程就很简单了,由于我们目前的操作符都是单个字符的,直接读取它们就行了。 ### 完善语法解析器 我们继续看一下如何完善语法解析器。 首先,我们添加几个新的节点类型的定义: ```javascript class ExprStmt extends Node { constructor(loc, value) { super(NodeType.EXPR_STMT, loc); this.value = value; } } class BinaryExpr extends Node { constructor(loc, op, left, right) { super(NodeType.BINARY_EXPR, loc); this.op = op; this.left = left; this.right = right; } } class NumLiteral extends Node { constructor(loc, value) { super(NodeType.NUMBER, loc); this.value = value; } } ``` 这里我们开始区分表达式「Expression」和语句「Statement」这两种不同的节点类型了。 运算符 `* / + -` 被称为双目运算符,所谓「目」就是操作数的意思。因为这些运算符都需要两个操作数,所以称之为双目运算符,它们所表示的运算就称为双目运算。 我们通过类「BinaryExpr」来表示这样的程序结构。 对于数值字面量而言,我们使用类「NumLiteral」来表示它。字面量也是表达式,表达式「Expression」是语句「Statement」的组成部分。 为了表示程序中出现的表达式语句,我们还定义了类「ExprStmt」,该类的属性 `value` 表示的就是作为其唯一子节点的表达式节点。 为了对应新增的节点定义,我们也需要添加几个新的节点类型: ```javascript NodeType.EXPR_STMT = "exprStmt"; NodeType.BINARY_EXPR = "binaryExpr"; NodeType.NUMBER = "number"; ``` 和完善词法解析器的步骤相似,我们也从语法解析的入口方法开始完善: ```javascript parseProg() { const node = new Prog(); node.loc.start = this.lexer.getPos(); while (true) { const tok = this.lexer.peek(); let stmt; if (tok.type === TokenType.EOF) break; if (tok.type === TokenType.HI) stmt = this.parseSayHi(); // 预测解下来是否需要进行表达式的解析 if (tok.type === TokenType.NUMBER) stmt = this.parseExprStmt(); node.body.push(stmt); } node.loc.end = this.lexer.getPos(); return node; } ``` 上一节我们已经将算术表达式的左递归进行了消除,通过消除后的表达式我们发现,算术表达式总是以数值字面量作为开头的,因此我们可以使用上面代码中的预测条件。 由于 ExprStmt 只有唯一的表达式节点,所以对它的解析也很接单: ```javascript parseExprStmt() { const node = new ExprStmt(); const expr = this.parseExpr(); node.loc = expr.loc; node.value = expr; return node; } ``` `parseExprStmt` 只是简单地调用 `parseExpr` 方法,那么 `parseExpr` 实现为: ```javascript parseExpr() { const num = this.parseNum(); return this.parseExpr1(num); } parseNum() { const node = new NumLiteral(); let tok = this.lexer.next(); assert.ok(tok.type === TokenType.NUMBER, this.makeErrMsg(tok)); node.loc = tok.loc; node.value = tok.value; return node; } parseExpr1(left) { const node = new BinaryExpr(); node.left = left; node.op = this.lexer.peek(); if (!Lexer.isOp(node.op.type)) return left; this.lexer.next(); assert.ok(Lexer.isOp(node.op.type), this.makeErrMsg(node.op)); node.right = this.parseExpr(); return node; } ``` 上面的代码对照消除左递归后的表达式语法: ```text expr ::= num expr' expr' ::= ( ( "*" | "/" | "+" | "-" ) expr )* | ε ``` 我们只是使用方法来替代语法规则中的展开操作。`parseNum` 就是解析数值字面量,就不过多解释了。 `parseExpr1` 方法就对应了消除后的 `expr'` 的内容。`parseExpr1` 中我们先预读接下来的 Token 是否为操作符,如果不是,就表示处理已经完成了,直接返回传入的 `left`。如果接下来为操作符,我们就先将已经处理的操作符跳过,然后接续调用 `parseExpr`。我们没有陷入无限循环的原因就是,我们在方法内部总是可以消化掉一部分输入。 对于表达式 `1 + 2 * 3` 来说,调用的过程如下: ![](../.gitbook/assets/expr123.svg) 我们可以以一个 U 型的方式来看这个图,从左边开始往下,到了最底部时,转到右边往上。左边和中间左半边部分,表示我们的递归调用链、期间发生的参数传递、以及表达式字符串被不断读取的消减过程。右边和中间的右半部分表示了递归调用结束,不断返回并构造节点的过程。 上图中我们不仅分析了整个表达式解析的过程;还根据这个过程,演示了我们消除左递归后的右递归的执行过程。 在我们着手试一试完善的成果之前,我们先添加一个新的 Visitor - 「YamlVisitor」,它可以将我们的 AST 输出为 [YAML](https://yaml.org/) 的格式,这个格式的好处就是既能体现我们树形结构的层级关系,格式上也显得更加清晰。 我们先往类「Visitor」中增加一些内容: ```javascript visitExprStmt(node) {} visitStmt(node) { switch (node.type) { case NodeType.EXPR_STMT: return this.visitExprStmt(node); case NodeType.SAY_HI: return this.visitSayHi(node); } } visitStmtList(list) {} visitNumLiteral(node) {} visitBinaryExpr(node) {} visitExpr(node) { switch (node.type) { case NodeType.NUMBER: return this.visitNumLiteral(node); case NodeType.BINARY_EXPR: return this.visitBinaryExpr(node); } } ``` 空着的方法留给子类去实现。`visitStmt` 和 `visitExpr` 做一个简单的任务派发,根据不同节点的类型,将它们派发到各自的处理方法中。 下面是「YamlVisitor」的实现: ```javascript const { Visitor } = require("./visitor"); const yaml = require("js-yaml"); class YamlVisitor extends Visitor { visitProg(node) { return yaml.dump({ type: node.type, body: this.visitStmtList(node.body) }); } visitStmtList(list) { return list.map(stmt => this.visitStmt(stmt)); } visitExprStmt(node) { return { type: node.type, value: this.visitExpr(node.value) }; } visitBinaryExpr(node) { return { type: node.type, op: node.op.type, left: this.visitExpr(node.left), right: this.visitExpr(node.right) }; } visitNumLiteral(node) { return node.value; } } ``` 在对不同节点的处理中,我仅输出扼要的内容,比如跳过了行列号。在起始点的处理方法 `visitProg` 中,我们将返回根据 YAML 语法序列化后的字符串。 我们尚未考虑如何消除间接左递归,因为消除直接左递归已经可以应对大部分情况了,我们不妨等遇到间接左递归的时候再考虑如何消除它。 最后通过一小段程序来检验这次的完成结果: ```javascript const { Source } = require("./source"); const { Lexer, TokenType } = require("./lexer"); const { Parser } = require("./parser"); const { InterpretVisitor } = require("./interpret-visitor"); const { YamlVisitor } = require("./yaml-visitor"); const util = require("util"); const code = `1 + 2 + 3 4 + 5 * 6 `; const src = new Source(code); const lexer = new Lexer(src); const parser = new Parser(lexer); const ast = parser.parseProg(); const visitor = new YamlVisitor(); console.log(visitor.visitProg(ast)); ``` 幸运的话,将看到类似下面的输出: ```yaml type: prog body: - type: exprStmt value: type: binaryExpr op: + left: '1' right: type: binaryExpr op: '+' left: '2' right: '3' - type: exprStmt value: type: binaryExpr op: + left: '4' right: type: binaryExpr op: '*' left: '5' right: '6' ``` ================================================ FILE: part1/1-8-arith-precedence-assoc.md ================================================ # 1.8 解析算术表达式 - 优先级与结合性 ## 优先级「Precedence」 上一节我们已经得到了消除左递归后的算术表达式语法,并根据语法完善了我们的解析程序。现在让我们来试着用我们上一节完成的内容,来解析算术表达式 `2 * 3 + 4`,我们会得到类似下面的输出: ```text type: prog body: - type: exprStmt value: type: binaryExpr op: '*' left: '2' right: type: binaryExpr op: + left: '3' right: '4' ``` 我们解析的结果,从现实的数学表达式的角度来看,是存在问题的,因为我们将表达式解析成了: ```text node / | \ 2 * node / | \ 3 + 4 ``` 上面的结构表示的是 `2 * (3 + 4)`,而表达式 `2 * 3 + 4` 的结构应为: ```text node / | \ node + 4 / | \ 2 * 3 ``` 为了解决这个问题,我们引入和数学上解决该问题类似的概念 - 优先级「Precedence」。优先级表示的是,在一个表达式中,如果同时出现多个运算符,那么具有较高优先级的运算符将先进行预算。 我们通过观察上面 `2 * 3 + 4` 对应的结构图发现,具有较高优先级的表达式\(`*`\),将作为较低优先级的表达式\(`+`\)的操作数,即子节点。而是什么造就了图中的层次结构呢?就是我们的语法,语法规则和其待展开项,经过我们的 Top-Down 解析器的解析,就会体现为父子节点的关系。 依照这个思路,我们可以将具有较高优先级的表达式独立出来、作为新的语法规则,然后将其作为具有较低优先级的语法规则的待展开项。对于四则运算而言,其中的操作符优先级由高到低为:`num > "*/" > "+-"`,因此我们可以得到: ```text /* rule1 */ expr ::= expr "+" term | expr "-" term | term /* rule2 */ term ::= term "*" factor | term "/" factor | factor /* rule3 */ factor ::= num ``` 我们通过三个规则来区分运算符的优先级。rule1 和 rule2 为我们之前所提到的左递归规则。我们拿 rule1 来再次实践如何消除左递归。 回顾我们的左递归消除公式: ```text A ::= Aα | β // 消除左递归后 A ::= βA' A' ::= αA' | ε // 将消除后的内容利用 EBNF 语法进一步合并为 A ::= βα* ``` 根据上面的式子,我们令: * `A = expr` * `α = "+" term` * `β = term` 将得到消除后的结果: ```text expr ::= term ( ("+" | "-") term )* ``` rule2 的消除就留给大家来尝试了。上面的算术表达式语法最终为: ```text expr ::= term ( ( "+" | "-" ) term )* term ::= factor ( ( "*" | "/" ) factor )* factor ::= num ``` 根据最终的语法,我们来完善我们的解析器。得益于我们之前的完善工作,现在我们这里只需要向语法解析器中增加3个方法,来对应上面的规则: ```javascript parseExpr() { let left = this.parseTerm(); while (true) { const op = this.lexer.peek(); if (op.type !== "+" && op.type !== "-") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseTerm(); left = node; } return left; } parseTerm() { let left = this.parseFactor(); while (true) { const op = this.lexer.peek(); if (op.type !== "*" && op.type !== "/") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseFactor(); left = node; } return left; } parseFactor() { return this.parseNum(); } ``` `parseExpr` 和 `parseTerm` 的内容差不多,所以我们理解一下 `parseExpr` 的实现内容: 1. 首先 `let left = this.parseTerm()` 对应 `expr` 规则的最左边第一个非终结符的解析 2. 随后我们进入了 while 循环 3. 在循环中,我们预读下一个 Token 是否为 `+` 或 `-` 4. 如果不是,我们就跳出循环,返回 left 5. 如果是 `+` 或 `-`,我们就调用 `parseTerm` 来解析运算符右边的子节点 6. 这样我们就已经得到了一个 BinaryExpr 节点,此时我们将它替换之前的 left 值 7. 跳到第3步继续执行 在循环中,我们将中间每一步得到的节点,都作为下一个即将处理的 BinaryExpr 节点的左边子节点。因此对于表达式 `1 + 2 - 3`,解析后的结构为: ```text node / | \ node - 3 / | \ 1 + 2 ``` 也就是说,对于优先级相同的情况,我们采用了和数学中类似的从左往右处理的方式。 我们可以试再着解析问题表达式 `2 * 3 + 4`,我们会得到下面的输出: ```text type: prog body: - type: exprStmt value: type: binaryExpr op: + left: type: binaryExpr op: '*' left: '2' right: '3' right: '4' ``` 上面的结果对应下图: ```text node / | \ node + 4 / | \ 2 * 3 ``` 可见我们的工作已经达到目的了。 ## 结合性「Associativity」 接下来我们来解析一个新的运算符 `**`,这个运算符就是 JS 中的指数运算符。该运算符含有两个字符,而我们目前的运算符还只是单个字符,除了这点不同之外,它还具有比 `*/` 运算符更高的优先级。 我们可以打开这个页面 [Operator precedence ](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence),这个页面中列出了 JS 中所有的运算符,以及它们的优先级。 在这上述页面中,我们可以发现 `**` 运算符的优先级为 `15`,而 `*/` 的优先级为 `14`。因此为了在语法中包含这个运算规则,我们需要将现有的规则修改成如下的形式: ```text expr ::= term ( ( "+" | "-" ) term )* term ::= expo ( ( "*" | "/" ) expo )* expo ::= factor ( "**" factor )* factor ::= num ``` 只要将现有的实现,修改下面几处,就可以实现对该运算规则的解析。 首先在 Lexer 中修改: ```javascript // 增加 Token 类型 TokenType.EXPO = "**"; // 修改 Lexer::isOp 方法,增加 `*` static isOp(ch) { return ["*", "/", "+", "-", "*"].indexOf(ch) !== -1; } // 修改 Lexer::readOp 方法,使之可以读取 `**` readOp() { const tok = new Token(); tok.loc.start = this.getPos(); tok.type = this.src.read(); if (tok.type === "*") { // 如果当前的字符为 `*`,我们就预读下一个,如果也是 `*` 就读取它 if (this.src.peek() === "*") { this.src.read(); tok.type = "**"; } } tok.loc.end = this.getPos(); return tok; } ``` 接着修改 Parser: ```javascript parseTerm() { let left = this.parseExpo(); while (true) { const op = this.lexer.peek(); if (op.type !== "*" && op.type !== "/") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseExpo(); left = node; } return left; } parseExpo() { let left = this.parseFactor(); while (true) { const op = this.lexer.peek(); if (op.type !== "**") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseFactor(); left = node; } return left; } ``` 我们增加了 `parseExpo` 方法,并将 `parseTerm` 中原本调用 `parseFactor` 的地方替换为 `parseExpo`,而在 `parseExpo` 中,我们调用 `parseFactor`。这些修改都是一一对应了我们的语法规则的变动。 修改完成后,我们试着解析表达式 `2 ** 3 ** 4`,我们会得到下面的输出: ```text type: prog body: - type: exprStmt value: type: binaryExpr op: '**' left: type: binaryExpr op: '**' left: '2' right: '3' right: '4' ``` 该输出的结构即为: ```text node / | \ node ** 4 / | \ 2 ** 3 ``` 该结构表示的计算等于表达式 `(2 ** 3) ** 4`。但是我们知道,在 JS 中,表达式 `2 ** 3 ** 4` 实际上等于 `2 ** (3 ** 4)`,大家动手试一试。 造成这个结果的原因,就是我们没有正确处理运算符的结合性。当表达式中的运算符具有相同的优先级时,就需要通过结合性来指定计算的先后顺序。 更为具体地来说,对于表达式 `a OP b OP c` 而言: * 因为两个 `OP` 相同,即具有相同的优先级,此时我们需要考虑 `OP` 的结合性 * 如果 `OP` 为左结合的,那么 `b` 将先和左边的 `a` 一起,执行 `OP` 运算,运算的结果再和 `c` 一起,做右边的 `OP` 运算。 * 如果 `OP` 为右结合的,那么 `b` 将先和右边的 `c` 一起,执行 `OP` 运算,运算的结果再和 `a` 一起,做左边的 `OP` 运算。 比如 `a ** b ** c ** d`,对应的结构应为: ```text node / | \ a ** node / | \ b ** node / | \ c ** d ``` 在了解了结合性之后,我们来看如何将 `parseExpo` 进行修改,以解析具有右结合性的操作符 `**`。 我们来回顾一下在 [1.7 解析算术表达式 - 左递归和其消除法](1-7-arith-left-recursion.md) 末尾的测试用例,对于表达式 `1 + 2 + 3`,我们输出的结果为 ```yaml type: prog body: - type: exprStmt value: type: binaryExpr op: + left: '1' right: type: binaryExpr op: '+' left: '2' right: '3' ``` 对应的结构就是: ```text node / | \ 1 + node / | \ 2 + 3 ``` 这个结果其实可以看成是将 `+` 当做右结合性来解析的结果。为什么能有这样的效果,我们来看当时的语法: ```text expr ::= num expr' expr' ::= ( ( "*" | "/" | "+" | "-" ) expr )* | ε ``` 为了解析这样的语法,我们的代码为: ```javascript parseExpr() { const num = this.parseNum(); return this.parseExpr1(num); } parseNum() { const node = new NumLiteral(); let tok = this.lexer.next(); assert.ok(tok.type === TokenType.NUMBER, this.makeErrMsg(tok)); node.loc = tok.loc; node.value = tok.value; return node; } parseExpr1(left) { const node = new BinaryExpr(); node.left = left; node.op = this.lexer.peek(); if (!Lexer.isOp(node.op.type)) return left; this.lexer.next(); assert.ok(Lexer.isOp(node.op.type), this.makeErrMsg(node.op)); node.right = this.parseExpr(); return node; } ``` 我们还一起看了这段代码执行的流程示意图,大家可以结合起来看。这段代码将操作符都处理成右结合性的原因就在于,`parseExpr1` 方法中,对于右边节点的处理,总是调用 `parseExpr`: ```javascript parseExpr1(left) { // ... node.right = this.parseExpr(); return node; } ``` 而在 `parseExpr` 内部,它先读取一个操作数,然后将其作为参数继续调用 `parseExpr1`,在未来某一时刻 `parseExpr1` 返回时,此前被传入的操作数,成为了返回的节点的左边子节点;这样的间接递归形式,使得所有的操作数都和它右边的操作符结合到了一起,刚好符合了我们需要对右结合性的处理需求。 所以我们可以将我们的 `**` 处理方法修改成: ```javascript parseExpo() { let left = this.parseFactor(); while (true) { const op = this.lexer.peek(); if (op.type !== "**") break; this.lexer.next(); const node = new BinaryExpr(); node.left = left; node.op = op; node.right = this.parseExpo(); left = node; } return left; } ``` 非常简单的修改,只有一行 `node.right = this.parseExpo();`,我们将原本 `parseFactor` 换成了 `parseExpo`。虽然我们这里使用的是直接递归,但是不必担心,我们在 `parseExpo` 中总是会先读取 factor,从而消耗掉一部分输入,因此不会陷入无限循环。 我们通过一个测试来试一试我们修改后的内容,解析表达式 `2 ** 3 ** 4 ** 5`。我们可以得到下面的输出: ```yaml type: prog body: - type: exprStmt value: type: binaryExpr op: '**' left: '2' right: type: binaryExpr op: '**' left: '3' right: type: binaryExpr op: '**' left: '4' right: '5' ``` 再来一个综合型的测试,解析表达式 `1 + 2 ** 3 * 5`。我们可以得到下面的输出: ```yaml type: prog body: - type: exprStmt value: type: binaryExpr op: + left: '1' right: type: binaryExpr op: '*' left: type: binaryExpr op: '**' left: '2' right: '3' right: '5' ``` ================================================ FILE: part1/1-9-ast-calculator.md ================================================ # 1.9 使用 AST - 计算器 我们已经可以解析算术表达式了,基于此我们可以进一步来实现一个计算器。 为了可以将打印我们的表达式的执行结果,在实现函数调用的语法之前,我们先实现一个支持打印执行结果的语法: ```text print ::= "print" expr ``` 这样对于语句 `print 1 + 2 + 3` 来说,它会先执行 `1 + 2 + 3` 然后打印执行的结果。 语法添加完毕后,第一步还是先完善词法解析器: ```javascript TokenType.PRINT = "print"; class Lexer { next() { this.skipWhitespace(); const ch = this.src.peek(); // ... if (ch === "h") return this.readHi(); if (ch === "p") return this.readPrint(); // ... throw new Error(this.makeErrMsg()); } readPrint() { const tok = new Token(TokenType.HI); tok.loc.start = this.getPos(); const print = this.src.read(5); assert.ok(print === "print", this.makeErrMsg()); tok.loc.end = this.getPos(); tok.value = "print"; return tok; } } ``` 添加一个新的 Token 类型 `TokenType.PRINT`,在 `Lexer::next` 中增加对该关键字的预测分支,以及解析该关键字的 `Lexer::readPrint` 方法。 接着我们来完善语法解析器: ```javascript class PrintStmt extends Node { constructor(loc, value) { super(NodeType.PRINT_STMT, loc); this.value = value; } } NodeType.PRINT_STMT = "printStmt"; class Parser { parseProg() { // ... while (true) { // ... if (tok.type === TokenType.NUMBER) stmt = this.parseExprStmt(); if (tok.type === TokenType.PRINT) stmt = this.parsePrintStmt(); node.body.push(stmt); } // ... } parsePrintStmt() { const node = new PrintStmt(); let tok = this.lexer.next(); node.loc.start = tok.loc.start; node.value = this.parseExpr(); node.loc.end = this.lexer.getPos(); return node; } } ``` 我们新增了一个节点类型 `NodeType.PRINT_STMT` 已经它的定义 `PrintStmt`。在 `Parser::parseProg` 中,我们增加了预测 `print` 语句的分支,`Parser::parsePrintStmt` 负责解析 `print` 语句。 接着完善 Visitor: ```javascript class Visitor { visitPrintStmt(node) {} visitStmt(node) { switch (node.type) { case NodeType.EXPR_STMT: return this.visitExprStmt(node); case NodeType.SAY_HI: return this.visitSayHi(node); case NodeType.PRINT_STMT: return this.visitPrintStmt(node); } } } ``` 我们增加了 `visitPrintStmt` 方法,留给子类去实现。在 `visitStmt` 中,增加了对 `NodeType.PRINT_STMT` 类型的识别和处理。 最后,我们来完善 InterpretVisitor: ```javascript class InterpretVisitor extends Visitor { visitProg(node) { node.body.forEach(stmt => this.visitStmt(stmt)); } visitBinaryExpr(node) { const left = this.visitExpr(node.left); const op = node.op.type; const right = this.visitExpr(node.right); switch (op) { case "+": return left + right; case "-": return left - right; case "*": return left * right; case "/": return left / right; case "**": return left ** right; } } visitPrintStmt(node) { console.log(this.visitExpr(node.value)); } visitNumLiteral(node) { return parseInt(node.value); } } ``` 在 `visitNumLiteral` 中,我们将字符串通过 `parseInt` 转换成了整型,注意我们在定义数字的语法规则时,具有可选的前导零,当时做这样的选择,也是顺带利用了 JS 中 `parseInt` 方法的功能。`visitBinaryExpr` 中,我们对于两个操作数,我们分别调用 `visitExpr` 来取它们的值,然后根据不同的操作符执行了相应的运算,并返回运行结果,这样使得操作数取值的 `visitExpr` 调用总是可以拿到值:来自 parseInt 或者子节点的运算结果。`visitPrintStmt` 的操作就是打印运算的结果,对于求值则是委托给了 `visitExpr` 来完成。 我们可以来试一试运行语句 `print 1 + 2 ** 3 * 5`: ```javascript const code = `print 1 + 2 ** 3 * 5`; const src = new Source(code); const lexer = new Lexer(src); const parser = new Parser(lexer); const ast = parser.parseProg(); const visitor = new InterpretVisitor(); visitor.visitProg(ast); ``` 我们将会得到输出 `41`。因为我们的 hi 语言使用了和 JS 相同的运算符优先级和结合性,所以大家也可以将表达式 `1 + 2 ** 3 * 5` 直接粘贴到浏览器的控制台,来验证执行的结果是否和 hi 语言相同。 ================================================ FILE: part1/1.10-liu-cheng-kong-zhi.md ================================================ # 1.10 流程控制 ================================================ FILE: part1/README.md ================================================ # 解释器篇 在这一整篇章节中,我们将从零开始,不断完善我们的第一个编程语言「hi 语言」。在本篇完成时,我们将得到一个可以解释执行的「hi 语言」版本。 * [1.1 简述](1-1-intro.md) * [1.2 预备工作 - Source](1-2-source.md) * [1.3 我们的第一个语言 - hi](1-3-hi.md) * [1.4 词法解析器](1-4-lexer.md) * [1.5 语法解析器](1-5-parser.md) * [1.6 使用 AST - 第一个解释器](1-6-ast-interpreter.md) * [1.7 解析算术表达式 - 左递归和其消除法](1-7-arith-left-recursion.md) * [1.8 解析算术表达式 - 优先级与结合性](1-8-arith-precedence-assoc.md) * [1.9 使用 AST - 计算器](1-9-ast-calculator.md) * 1.10 流程控制 ================================================ FILE: part2.md ================================================ # 编译器篇 TBD