本篇是算术是表达式解析系列文章之一,上篇:算术表达式解析系列之逆波兰表示法
建议先阅读上篇,本篇不重复介绍一些基本的概念。
再这篇文章里面,将介绍一种完全不同的解决方案,Precedence Climbing Method,下称优先级攀爬算法。 这种算法,在手写一些表达式解析器的时候,经常会使用。
下面简单概括下实现:
- 解析录入的原始字符串,输出 token 流
- 逐个读取 token,根据算符不同,执行不同的操作
下面开始实现,代码使用 ts 做演示。
目标跟上一篇的相似,提供一样的运算符,不过再几个方面做了强化:
- 数值支持更多的形式,例如:
10
,-10
,0.5e2
等等等等 - 提供完整的 Tokenize 实现,直接录入原始表达式字符串,即可运算出结果
- 提供更友好的错误提示,包括指示出错误出现的行号列号
实现 tokenize
tokenize 是将原始字符串录入,转换成一个个 token 的过程。
转换成 token 的目的,是为了简化后续的处理步骤。
实现 tokenize 有很多方法,这里使用手工实现,遍历读取字符进行分析。
首先实现一个简单的流抽象类,用于后续读取原始输入和 tokens。
流
abstract class Stream<T> {
// 当前流过的位置
private _count = 0
get count() {
return this._count
}
set count(n: number) {
this._count = n
}
/**
* 获取缓冲数据抽象方法
*/
abstract buffer(): any
/**
* 是否流干
*/
drained(): boolean {
return this.peek() == null
}
/**
* 流到下一个元素
*/
junk(): void {
this.count += 1
}
/**
* 流到下一个元素,返回当前元素
*/
next(): T {
const e = this.peek()
if (e != null) this.junk()
return e
}
/**
* 当前元素
*/
peek(): T {
return this.buffer()[this.count]
}
/**
* 读 N 个元素(不移动当前位置)
* @param n
*/
npeek(n: number): T[] {
const count = this.count
const list = []
while (n--) {
list.push(this.next())
}
this.count = count
return list
}
}
实现一个字符流,接受表达式源码,返回一个流对象,方便管理对字符的读取。
class CharStream extends Stream<string> {
// 字符缓冲
private _buffer: string
// 当前行号
private _row = 0
// 当前列号
private _col = 0
constructor(buffer: string) {
super()
this._buffer = buffer || ''
}
get row() {
return this._row
}
get col() {
return this._col
}
/**
* @override
*/
buffer(): string {
return this._buffer
}
/**
* @override
*/
npeek(n: number): string[] {
const { row, col } = this
const chars = super.npeek(n)
this._row = row
this._col = col
return chars
}
/**
* @override
*/
junk(): void {
super.junk()
if (this.peek() === '\n') {
this._row += 1
this._col = 0
}
else {
this._col += 1
}
}
/**
* 在当前位置开始的子串中,执行正则匹配
*/
execRegExp(regexp: RegExp, n?: number) {
return regexp.exec(this._buffer.substr(this.count, n))
}
}
接着,根据我们的需要,定义一批 Token 的类型。 我们目前只需要三类 Token,分别为数字、操作符、分界符。
const enum TokenType {
Number = 'Number',
Operator = 'Operator',
Punctuator = 'Punctuator',
}
确定类型之后,就可以实现一个 Token 类用来存储解析过程遇到的各种类型的单词信息。
我们计划在 Token 中存记录以下信息:
- 类型,即
TokenType
- Token 位于原始表达式输入的起始位置,用于出错时,友好地提示。
- token 的值是什么。
根据这些要求,我们写出以下代码:
// 原始输入的表达式的位置
interface Position {
row: number
col: number
}
// Token 的实例化参数
interface TokenOptions {
kind: TokenType
start: Position
end: Position
value?: string
}
// 类实现
class Token {
kind: TokenType
start: Position
end: Position
value?: string
constructor({ kind, value, start, end }: TokenOptions) {
Object.assign(this, { kind, value, start, end })
}
}
有了以上准备后,我们就可以着手开始 Token 流的实现了,使用统一的抽象方式,简化后续的处理。
// 工具函数,检查输入的字符是否数字
const isDigit(char) => /^[0-9]/.test(char)
// 类实现
class TokenStream extends Stream<Token> {
private _buffer: any = []
private _srcStream: CharStream
constructor(source: string) {
super()
this._srcStream = new CharStream(source.trim())
}
/**
* @override
*/
buffer(): Token[] {
return this._buffer
}
/**
* @override
*/
peek() {
while (this.buffer().length <= this.count && !this._srcStream.drained()) {
this.force()
}
return super.peek()
}
// 获取当前读取到的字符所处的位置
pos() {
return {
row: this._srcStream.row,
col: this._srcStream.col
}
}
// 抛出词法错误
raiseLexicalError(message?: string) {
const { row, col } = this.pos()
throw new Error(`Lexical error(${row}, ${col})${message ? ': ' + message : ''}`)
}
// 原始输入中,往前推进 n 个字符
srcJunk(n = 1) {
while (n--) {
this._srcStream.junk()
}
}
// 从原始输入流中解析出下一个 token
force() {
if (this._srcStream.drained()) return null
while (!this._srcStream.drained()) {
const start = this.pos()
const ch = this._srcStream.peek()
// 读取到空格或换行,此处直接移除
if (ch === '\n' || ch === ' ') {
this.srcJunk()
continue
}
// 读取到数字或小数点,说明这是一个数字 token 的开始
if (isDigit(ch) || ch === '.') {
const number = this.slurpNumber()
if (!number) this.raiseLexicalError('invalid number')
const end = this.pos()
const token = new Token({ kind: TokenType.Number, value: number, start, end })
this._buffer.push(token)
return token
}
// 分界符,“(”,“)” 用于分组
if (['(', ')'].includes(ch)) {
this.srcJunk()
const end = this.pos()
const token = new Token({ kind: TokenType.Punctuator, value: ch, start, end })
this._buffer.push(token)
return token
}
// 二元运算符
if (['+', '-', '*', '/', '%', '^'].includes(ch)) {
this.srcJunk()
const end = this.pos()
const token = new Token({ kind: TokenType.Operator, value: ch, start, end })
this._buffer.push(token)
return token
}
this.raiseLexicalError('无效的符号')
}
}
// 尝试提取数字
maybeNumber() {
const reg = /^\d+\.\d+(?:e[\+\-]?\d+)?|^\d+\.?(?:e[\+\-]?\d+)?|^\.\d+(?:e[\+\-]?\d+)?/i
const matched = this._srcStream.execRegExp(reg)
const number = matched ? matched[0] : ''
return number
}
// 从原始输入流中,提取数字
slurpNumber() {
const number = this.maybeNumber()
if (!number) this.raiseLexicalError()
this.srcJunk(number.length)
return number
}
}
通过上面的实现代码,我们做到了每一次调用 next()
方法,就能自动读取出下一个 token。
如此一来,我们就可以将 Tokenize 的动作,整合进语法分析的过程之中,而无需做一遍词法分析的独立过程。
语法分析
在这之前,需要先定义好运算符的优先级和结合性。
const Precedence = Object.freeze({ L1: 1, L2: 2, L3: 3 })
const enum Associativity { Left, Right }
我们此处的实现,只有三个优先级。而结合性,只存在左结合和右结合之分。
然后开始定义我们希望支持的运算符,指定它们的优先级和结合性。
// 二元运算符优先级以及结合性
const BINARY_OP_INFO = Object.freeze({
// 加减
'+': [Precedence.L1, Associativity.Left],
'-': [Precedence.L1, Associativity.Left],
// 乘除
'*': [Precedence.L2, Associativity.Left],
'/': [Precedence.L2, Associativity.Left],
'%': [Precedence.L2, Associativity.Left],
// 指数运算
'^': [Precedence.L3, Associativity.Left],
})
定制好运算符后,就可以准备实现语法分析类。 语法分析类,接受一个原始表达式字符串作为输入,生成对应的语法分析器对象。执行对象上的分析逻辑,即可输出该表达式的 AST。
所以,我们还得准备下,先定义好必要的 AST 结点。
首先是结点的类型定义,我们使用一个枚举来定义。
const enum AstType {
Number = 'Number',
BinaryExpression = 'BinaryExpr',
UnaryExpression = 'UnaryExpr',
}
可能存在三种结点,分别是
- 数字
- 二元表达式
- 一元表达式(负数,例如 -1)
然后实现 AST 节点类。
class Ast {
kind: AstType
[key: string]: any
static createNode(kind, options) {
return new Ast(kind, options)
}
constructor(kind, options) {
this.kind = kind
Object.assign(this, options)
}
}
现在,万事俱备,不欠东风,可以开始分析语法了。
下面使用递归下降的方式手写一个简单的语法分析器。
关于二元运算符的的优先级和结合性的处理,重点关注 parseBinaryExpr()
方法中的注释。
class Parser {
tokenSteam: TokenStream
constructor(src: string) {
this.tokenSteam = new TokenStream(src)
}
// 抛出语法错误
raiseSyntaxError(start: Position, end: Position, message?: string) {
throw new Error(`Syntax error(${start.row},${start.col}-${end.row},${end.col})${message ? ': ' + message : ''}`)
}
// 抛出语义错误
raiseSemanticError(start: Position, end: Position, message?: string) {
throw new Error(`Semantic error(${start.row},${start.col}-${end.row},${end.col})${message ? ': ' + message : ''}`)
}
// 读取当前的 Token
peek() {
return this.tokenSteam.peek()
}
// 读取下一个 Token
next() {
return this.tokenSteam.next()
}
// 消耗一个 Token
consume() {
this.tokenSteam.junk()
}
// 检查目标 Token 是否指定的运算符
isOperator(token?: Token, value?: string) {
if (!token || !value) return false
return token.kind === TokenType.Operator && token.value === value
}
// 检查指定 Token 是否指定的分解符
isPunctuator(token?: Token, value?: string) {
if (!token || !value) return false
return token.kind === TokenType.Punctuator && token.value === value
}
// 解析表达式的入口
parseExpr(): Ast {
const expression = this.parseBinaryExpr(Precedence.L1)
return expression
}
// 解析二元表达式
parseBinaryExpr(minPrec): Ast {
let lhs = this.parseUnaryExpr()
// 此处使用优先级攀爬(Precedence climbing)算法处理二元表达式
while (true) {
// 读取当前 Token,假设是运算符
let op = this.peek()
// 若读取到的 Token 为空,或者运算符定义中找不到,或新运算符的优先级达不到要求的最低优先级
// 则不再与后续的运算符结合,结束本次循环,直接返回当前已经组装的 ast 子树
if (!op || !BINARY_OP_INFO[op.value] || BINARY_OP_INFO[op.value][0] < minPrec) {
return lhs
}
// 获取运算符的优先级和结合性,算出递归调用时传入的最小优先级数值
// 此处是关键点,通过指定最小优先级,解决不同优先级运算符的结合情况
// 而右结合的情况,也通过 `+1` 巧妙地转换成优先级一样的处理逻辑了
const [prec, assoc] = BINARY_OP_INFO[op.value]
const nextMinPrec = assoc === Associativity.Left ? prec + 1 : prec
// 消耗掉当前的 token,并准备下一个 token,用于下轮递归
this.consume()
const rhs = this.parseBinaryExpr(nextMinPrec, isArgComma)
// 使用新值更新 lhs
lhs = Ast.createNode(AstType.BinaryExpression, {
op: {
kind: TokenType.Operator,
start: op.start,
end: op.end,
value: op.value,
},
left: lhs,
right: rhs
})
}
}
// 处理一元表达式,即处理一元取负运算符
parseUnaryExpr(): Ast {
const op = this.peek()
if (this.isOperator(op, '-')) {
this.consume()
const argument = this.parsePrimaryExpr()
return Ast.createNode(AstType.UnaryExpression, {
prefix: true,
op: {
kind: TokenType.Operator,
start: op.start,
end: op.end,
value: op.value,
},
argument
})
}
return this.parsePrimaryExpr()
}
// 处理主表达式,包括数字这种不可再细分的语法成分。而括号分组也在此处视作一个整体对待。
parsePrimaryExpr(): Ast {
const token = this.peek()
// 括号表达式
if (this.isPunctuator(token, '(')) {
this.consume()
// 括号里面可能还是一个表达式
const expression = this.parseExpr()
// 必须存在关闭的括号,否则就是括号不匹配的语法错误
const closingParen = this.peek()
if (!this.isPunctuator(closingParen, ')')) {
this.raiseSyntaxError(closingParen.start, closingParen.end, `缺失符号 ')'`)
}
this.consume()
return expression
}
// 数值字面量表达式
if (token.kind === TokenType.Number) {
this.consume()
return Ast.createNode(AstType.Number, {
value: token.value,
start: token.start,
end: token.end
})
}
// 全部不匹配,报告语法错误
this.raiseSyntaxError(token.start, token.end, `无效的符号 ${token.value}`)
}
}
上面 100 多行的代码,即可实现我们这个简单的解析器的工作。 使用方式如下:
const parser = new Parser('1+1')
const ast = parser.parseExpr()
// ast 即为目标抽象语法树,数据结构大概如下:
//
// {
// "kind": "BinaryExpr",
// "op": {
// "kind": "Operator",
// "start": {
// "row": 0,
// "col": 1
// },
// "end": {
// "row": 0,
// "col": 2
// },
// "value": "+"
// },
// "left": {
// "kind": "Number",
// "value": "1",
// "start": {
// "row": 0,
// "col": 0
// },
// "end": {
// "row": 0,
// "col": 1
// }
// },
// "right": {
// "kind": "Number",
// "value": "1",
// "start": {
// "row": 0,
// "col": 2
// },
// "end": {
// "row": 0,
// "col": 3
// }
// }
// }
//
实现了 AST 输出之后,即可实现计算逻辑,这部分就非常简单了,就是树遍历的过程。
注,在我们的例子中 AST 的生成其实不是必须的,在分析的过程中,已经可以执行相应的计算语义了。
下面给出一个简单的计算方法实现。
先定义一个运行时库,包含各种运算符的计算逻辑
const runtime = {
operators: {
'+': (a: number, b: number) => a + b,
'-': (a: number, b: number) => a - b,
'*': (a: number, b: number) => a * b,
'/': (a: number, b: number) => a / b,
'%': (a: number, b: number) => a % b,
'^': (a: number, b: number) => Math.pow(a, b),
'neg': (a: number) => -a,
}
}
接着实现树遍历计算即可。
function evaluateAst(ast: Ast) {
switch (ast.kind) {
// 二元表达式
case AstType.BinaryExpression: {
// 提取运行时中的操作方法
const calc = runtime.operators[ast.op.value]
// 递归计算出左操作数
const lhs = evaluateAst(ast.left)
// 递归计算出右操作数
const rhs = evaluateAst(ast.right)
// 返回计算结果
return calc(lhs, rhs)
}
// 一元表达式
case AstType.UnaryExpression: {
const arg = evaluateAst(ast.argument)
return runtime.operators.neg(arg)
}
// 数字字面量,直接字符串转数字即可
case AstType.Number: {
return JSON.parse(ast.value)
}
}
}
综合上面所有的实现,最终可以封装一个计算 API,提供给外部使用。
function evaluate(src: string) {
return evaluateAst(new Parser(src).parseExpr())
}
调用
evaluate('(10 + 15 - 20) * 30 / 40 ^ 2')
// 输出:0.09375
跟上一篇的方法,完全一样的输出。
下篇预告
算术表达式解析系列之文法规则实现优先级