做一个科学计算器(二)

上一篇文章,《做一个科学计算器(一)》我们介绍了关于写一个数学计算器的相关理论知识,主要包含了前缀表达式、中缀表达式、后缀表达式和三种表达式的计算方法以及相互转化,同时还介绍了完整的调度场算法。这篇文章我们先来实现一个低配版的,仅支持简单的四则运算的计算器。

实现思路

  • 定义一个类,并在类的构造函数里定义一组运算符
  • 每一运算符需要有一个优先级和处理方法的属性(不需要参数个数,因为这个版本的仅考虑二元运算符)
  • 定义完运算符后需要计算出一个正则,用于匹配式子(判断合法性和读取式子时使用)
  • 解析式子,得到后缀表达式
  • 计算后缀表达式,得到式子

代码实现

1. Calculator类的代码结构

class Calculation {
  constructor () {
    //定义一组运算符
    //...
    //计算得到相应的正则
    this.calcReg()
  }

  //正则计算
  calcReg () {}

  /**
   * 运算符定义方法
   * @param symbol  运算符
   * @param handle 处理方法
   * @param precedence 优先级
  **/
  definedOperator(symbol, handle,precedence) {}

  //对传入的式子进行解析的方法
  parse(s) {}

  //对解析得到的后缀表达式求值
  evaluate(r){}

  //other methods 符号的处理方法也定义在这里
  //....

}
复制代码

以上就是第一个版本的完整的类的代码结构,通过实现各个定义的方法即可得到一个简单的计算器,下面就一步一步来实现里面的具体的方法。

2. 构造函数

针对简单的四则运算,我们需要在构造函数定义四个运算符+,-,*,/以及一对括号(,)。其中乘除的运算优先级比加减高。而对于括号,从前面的后缀表达式转换方法可以知道,其中左括号会直接进入运算符栈,用于匹配右括号时的定界,而右括号是不会进栈,同时左右括号均不参与运算,因此它们不需要指定优先级(可以通过置为0或者小于0的数来区分)。所以,这里我们把乘除的运算优先级定为2,加减定位1。以下为构造函数代码:

  //定义一个_symbols属性,用于存储定义的运算符,下文实现definedOperator会用到
  this._symbols = {}
  this.definedOperator("+",this.add,1)
  this.definedOperator("-",this.sub,1)
  this.definedOperator("*",this.multi,2)
  this.definedOperator("/",this.div,2)
  this.definedOperator("(")
  this.definedOperator(")")

  //计算正则
  this.calcReg()
复制代码

在定义运算符的时候我们同时需要定义几个符号处理函数,代码如下:

add(a,b) {
  return a + b
}

sub(a,b) {
  return a - b
}

multi(a,b) {
  return a * b
}

div(a,b) {
  return a / b
}
复制代码

3. 运算符定义方法definedOperator

这个方法需要做的事情有:将运算符保存下来,保存运算符的优先级以及处理方法。所以这个方法还是比较简单,代码实现如下:

definedOperator(symbol, handle,precedence) {
  this._symbols[symbol] =  {
    symbol,handle,precedence
  }
}
复制代码

4. 正则计算方法calcReg

在仅有四则运算并且不考虑运算表达式错误的情况下,正则的计算比较简单,主要的规则如下:

  • 式子中可以含有数字和已经定义的运算符
  • 数字可以小数,也可以是整数
  • 将特殊的运算符(正则元字符)转义
calcReg () {
  let regstr =  "\\d+(?:\\.\\d+)?|" +
  Object.values(this._symbols).map(
    val => val.symbol.replace(/[*+()]/g, '\\$&')
  ).join("|")
  this.pattern = new RegExp(regstr)
  console.log(this.pattern) //结果是:/\d+(?:\.\d+)?|\+|-|\*|/|\(|\)/g
}
复制代码

代码解释:

  • 首先是匹配数字\d+(?:\.\d+)?,可以是整数,也可以是小数,所以小数部分可以是0也可以是1,而?:表示该分组不捕获
  • 除去数字后,另外一部分应该是在对应的已定义的运算符里,所以仅需要遍历运算符,用|连接即可,但是+,*,(,)均为正则的元字符,属于特殊运算符,所以需要进行转义,$&表示对正则匹配的子串的引用
  • 注意:最后生成的正则需要将模式设置为g,即全局模式,因为后面会使用RegExp的exec方法,每一次匹配后都会从下一个位置开始匹配

5. 表达式解析与转换方法parse方法

该方法是实现计算器的核心方法,也就是我们前面讲解的将中缀表达式转换为后缀表达式的方法。该实现版本考虑四则运算,同时不考虑错误处理的情况,因此算法的实现也比较简单。其具体实现和代码的解释如下:

parse(s) {
  // 操作符栈,输出栈
  let operators = [],result = [],
  //正则匹配的结果,当前匹配到的符号
  match,token

  //去除空格的处理
  s = s.replace(/\s/g,'')
  //重置正则当前匹配位置
  this.pattern.lastIndex = 0
  do{
    match = this.pattern.exec(s)
    token = match ? match[0] : null
    //没有匹配到,结束匹配
    if(!token) break
    if(isNaN(+token)) { //如果不是一个数字
      if(token === "("){//如果是一个左括号,则直接入操作符栈
        operators.push(token)
      } else if(token === ')'){//当匹配到的是一个右括号
        //循环弹出操作符栈,并压入输出栈,
        //直到遇到左括号为止
        let o = operators.pop()
        while(o !== "(") {
          result.push(o)
          o = operators.pop()
        }
      } else if(!operators.length) {//操作符为空,直接入操作符栈
        operators.push(token)
      } else {//操作符栈不为空,需要比较优先级
        //获取前一个操作符
        let prev = operators[operators.length - 1]
        /**
          如果当前操作符的优先级不比栈顶元素操作符的优先级高,则将栈顶的操作符弹出并压入输出栈,
          循环与剩余的栈的栈顶元素做比较直到当前元素的优先级比栈顶元素高
        **/
        while(prev && this._symbols[token].precedence <= this._symbols[prev].precedence) {
          result.push(operators.pop())
          prev = operators[operators.length - 1]
        }
        //压入当前操作符压入操作符栈
        operators.push(token)
      }
    } else { //token是一个数字,直接进入输出栈
      result.push(+token)
    }
  } while(match)
  //将操作符栈剩余的操作符全部弹出并压入输出栈
  result.push(...operators.reverse())
  //得到输出栈,转为字符串即为后缀表达式
  return result
}

复制代码

以上为实现的第一版的parse算法,我们列举个例子,看看运行结果如何

var calc = new Calculator();
console.log(calc.parse("1+2-3"))
console.log(calc.parse("2 * (4-2)"))
console.log(calc.parse("2 * (4-2) / 3 - ((2 + 3) * 2)"))
复制代码

运行结果如下:

result1.png

通过比较前面的转换算法得到的结果,发现结果是一致的。因此,我们暂时认为该算法是符合要求的(实际上还有问题)

6. 结果计算evaluate方法

得到运算结果后我们就可以实现结果计算的方法了,其基本思路就是前面讲解的:从头到尾扫描后缀表达式,遇到数字压入输出栈,遇到符号取出两个数字(四则运算)与符号参与运算后得到的结果再压入输出栈,最后将输出栈弹出即为结果。实现的方法如下:

evaluate(r){
  //输出栈
  let result = [], o = [];
  //获取当前解析的结果
  r = this.parse(r)
  for(let i = 0, len = r.length; i < len; i++) {
    let c = r[i]
    if(isNaN(c)) {
      let token = this._symbols[c]
      result.push(token.handle(...result.splice(-2)))
    } else {
      result.push(c)
    }
  }
  return result.pop();
}
复制代码

执行以下测试代码:

console.log(calc.evaluate("1+2-3"))
console.log(calc.evaluate("2 * (4-2)"))
console.log(calc.evaluate("2 * (4-2) / 3 - ((2 + 3) * 2)"))
复制代码

得到结果如下,经验证结果是正确的。

result2.png

总结

到目前为止,我们已经实现了一个最基本的支持四则运算的计算器。如果给定的式子是正常的,那么到这一步基本是没什么问题了。但假如我们运行测试一下以下几个实例

console.log(calc.evaluate("1+2-3abc"))
console.log(calc.evaluate("2 * (4-2)+"))
console.log(calc.evaluate("(2 * (4-2) / 3 - ((2 + 3) * 2)"))
复制代码

将得到以下的结果:

result3.png

原因如下: 我们在生成正则时,使用了定义的操作符,除了数字和操作符,其他字符将会匹配失败,对于1+2-3abc来讲,当匹配到a的时候,执行了if(!token) break这个语句,退出了读取的过程,因此会执行1+2-3得到结果;对于第二个式子2 * (4-2)+,最后多出了一个+,当参与运算时输出栈已仅剩于一个数字,因此第二个数字读取是为undefined,相加得到结果NaN;最后一个式子是由于多了一个左括号,而左括号是没有定义函数的,因此会报错(理论上最后得到的栈不应该包含左括号)。

因此,我们目前实现的版本至少应该存在以下几个问题:

  • 没有对括号做严格的匹配
  • 没有针对除数字以及定义的操作符外的符号做处理
  • 没有对多余的运算符做处理

除了以上问题外,目前的版本还不支以下的功能:

  • 包含函数的表达式
  • 正负号等前缀一元运算符
  • 阶乘等后缀一元运算符
  • 其他运算,如指数,开方,对数等

那么下一篇文章我们就针对以上问题进行处理,同时丰富计算器的功能,使其支持更多的运算。

下一篇:做一个科学计算器(三)

Guess you like

Origin juejin.im/post/7034691550349099022