算符优先算法

黑色的影子

通常,操作符的优先级越高,位置就越低。例如在3+45这个表达式中,*的优先级高于+,那么就是+节点的子节点。但如果3+4添加了(),即:(3+4)*5,两个符号的优先级就颠倒回来。
图来自于编程语言实现模式

如果我们用nearyley(一种使用类BNF语法表达的parser生成器)表达,就有如下代码,可以看到每一种不同优先级的运算符,对应了一条语法规则。 当不同优先级的运算符增加,我们要写的语法规则也会相应增加:

expression -> additive {% d => d[0] %}
additive -> 
          additive "+" multiple {% d => d[0] + d[2] %}
        | additive "-" multiple {% d => d[0] - d[2] %}
        | multiple {% d => d[0] %}
multiple -> 
          multiple "*" factor {% d => d[0] * d[2] %}
        | multiple "/" factor {% d => d[0] / d[2] %}
        | factor {% d => d[0] %}

factor -> "(" additive ")" {% d => d[1] %}
        | %number {% d => Number(d[0]) %}

这样新增一个运算符,就会给我们的parser实现带来一定的成本,那么有没有一种通用的方式处理我们的运算符优先级呢?算符优先算法就是为了这个目的而生的。

下面是算符优先算法的代码,为了方便学习,这里我们lexer和parser,写的尽可能的简单,所以我们的parser不允许符号之间有空格,且只支持解析个位数数(即支持3+4,不支持33+4):

class BinaryExpr{
  constructor(left, op, right){
    this.left = left
    this.op = op
    this.right = right
  }
}

class OpPrecedenceParser{
  constructor(lexer, precedenceMap){
    this.lexer = lexer
    this.precedenceMap = precedenceMap
  }
  expression(){
    let right = this.factor()
    let next;
    while(next = this.nextOperator()){
      right = this.doShift(right)
    }
    return right
  }
  
  doShift(left){
    const op = this.lexer.read()
    let right = this.factor()
    let next;
    while((next = this.nextOperator()) && this.rightIsExpr(op, next)){
      right = this.doShift(right)
    }
    return new BinaryExpr(left, op, right)
  }
  nextOperator(){
    const op = this.lexer.nextToken()
    if(this.precedenceMap.get(op)) return op
    return null
  }
  factor(){
    if(this.lexer.nextToken() === "("){
      this.token()
      const exp = this.expression()
      this.token()
      return exp
    } else {
      return this.lexer.read()
    }
  }
  token(){
    return this.lexer.read()
  }
  rightIsExpr(op1, op2){
    return this.precedenceMap.get(op1) < this.precedenceMap.get(op2)
  }
}

class Lexer{
  constructor(content){
    this.currentIndex = 0
    this.content = content || ""
    this.tokens = []
  }
  peek(index){
    return this.content[this.currentIndex + index]
  }
  nextToken(){
    if(this.content[this.currentIndex])
    return this.peek(0)
  }
  read(){
    return this.content[this.currentIndex++]
  }
}

function parse(expr){
  const precedenceMap = new Map()
  precedenceMap.set("+", 2)
  precedenceMap.set("-", 2)
  precedenceMap.set("*", 3)
  precedenceMap.set("/", 3)  
  const lexer = new Lexer(expr)
  const parser = new OpPrecedenceParser(lexer, precedenceMap)
  const tree = parser.expression()
  return tree
  console.log(ast)
}
const ast = parse("3+4*5")
console.log(ast)

上述代码的打印结果是:

BinaryExpr {
  left: '3',
  op: '+',
  right: BinaryExpr { left: '4', op: '*', right: '5' }
}

我们改变一下入参为:(3+4)*5,打印结果为:

BinaryExpr {
  left: BinaryExpr { left: '3', op: '+', right: '4' },
  op: '*',
  right: '5'
}

可以看到结果是符合我们预期的,优先级低的操作符更靠近根节点。那如果我们需要增加新的符号呢?只需这样做:

precedenceMap.set("<", 1)
precedenceMap.set(">", 1)

当我们执行parse(3+4>5)时,得到的结果为:

BinaryExpr {
  left: BinaryExpr { left: '3', op: '+', right: '4' },
  op: '>',
  right: '5'
}

现在我们领略了算符优先算法的强大,增加符号只需要往规则map里面增加规则及优先级即可。让我们分析一下算符优先算法。OpPrecedenceParser中expression和factor方法比较容易理解,只是根据我们的文法做自顶向下的匹配。核心在于doShift函数:

  doShift(left){
    const op = this.lexer.read()
    let right = this.factor()
    let next;
    while((next = this.nextOperator()) && this.rightIsExpr(op, next)){
      right = this.doShift(right)
    }
    return new BinaryExpr(left, op, right)
  }

假设有NUMBER1+NUMBER2*NUMBER3这样的表达式,doshift函数向后读取,拿到符号,然后将+和进行比较,如果前面的符号优先级更高,则构造出Expr,并返回。如果后面的符号优先级更高,则对右侧的表达式做doShift。

可以看出,算符优先算法对于parse表达式,真的是一件利器,当我们使用LL算法做parser时,不妨在表达式解析上,使用算符优先算法。

参考:

  • 《编程语言实现模式》
  • 《两周自制脚本语言》
阅读 224

我的前端学习
一个前端学习者的分享
890 声望
69 粉丝
0 条评论
890 声望
69 粉丝
宣传栏