做一个科学计算器(三)

1,040 阅读12分钟

在上一篇文章《做一个科学计算器(二)》我们实现了一个低配版的,仅支持四则运算的计算器,但目前还存在一些问题。本文就着手来解决这些问题,并继续完善计算器,来实现一个更中配版的。本文解决问题后,至少应该要达到以下几个目标:

  • 支持除四则运算外的更多的数学运算,如阶乘、指数、开方、对数等
  • 支持正负号
  • 支持函数调用
  • 支持表达式的错误校验并给出相应的提示
  • 对括号对进行严格的匹配

要实现以上的功能,我们需要先进行一些分析:

  • 在上一个实现版本中,我们仅支持了四则运算,由于都是二元运算符,在计算结果传递参数给处理方法时,都是默认传递了两个参数,但当前要实现的版本参数是不定的,因此,这个传递参数个数必须是可变的;我们知道
  • 支持正负号,意味着对于同一个加号或者减号,它应该是具有多重的处理方法
  • 要校验表达式的正确性意味着前面定义的正则需要完善,同时应该能捕获到对应的错误

下面,开始一步步来完善这个计算器。

实现方法

1. 构造函数

(1)分析:前面支持了四则运算符,这里要支持更多的运算,因此需要增加其他运算符的定义。同时,基于前面的分析,我们知道,定义运算符时应该还需定义运算符参与运算时需要的参数个数。我们暂时先不考虑正负号的实现,先定义一组运算符,下文在分析其他功能的过程中,再回头来完善构造函数。

(2)代码:

constructor() {
  this._symbols = {};
  this.definedOperator("+",this.add,1,2)
  this.definedOperator("-",this.sub,1,2)
  this.definedOperator("*",this.multi,2,2)
  this.definedOperator("/",this.div,2,2)
  this.definedOperator("(")
  this.definedOperator(")")
  this.definedOperator("%",this.mod,2,2) //求余
  this.definedOperator("!",this.fac,3,1) // 阶乘
  this.definedOperator("^",Math.pow,2,2) // 指数

  this.calcReg()
}

我们暂时先添加了求余、阶乘和指数运算三种运算符,同时新增了第四个参数,用来指定运算符运算时需要传入多少个参数。目前看阶乘的优先级别是最高的,指数暂时定为与乘除一致(实际指数运算为右结合运算符),并使用原生的Math.pow作为处理方法。我们对阶乘和求余两个函数顺带也给实现了,代码如下:

mod(a,b) {
  return a % b
}

fac(a) {
  if(a % 1 || !(+a >=0)) return NaN
  if(a > 170) return Infinity
  let b = 1
  while( a > 1) b *= a--
  return b
}

说明:我试了下,在Chrome浏览器下,当阶乘的基数为170时,浏览器就已经返回了无穷大,因此这里对于超过170的数,直接返回无穷大,这有助于提高运算的性能;另外,按照阶乘的定义,基数必须大于1的正整数,所以,不符合条件的直接返回NaN。

2. 运算符定义方法definedOperator

新的definedOperator仅增加了一个参数,代码实现如下:

definedOperator(symbol, handle,precedence, argCount) {
  precedence = precedence || 0 //将括号的优先级设为0
  this._symbols[symbol] =  {
    symbol, handle, precedence, argCount
  }
}

到这里为止,如果只是新增以上三个运算符的话,其实parse方法是不需要改动的,但是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)) {
      // 从result中截取出来argCount个数字,展开后传入运算符的处理方法
      let token = this._symbols[c]
    result.push(token.handle(...result.splice(-token.argCount)))
    } else {
      result.push(c)
    }
  }
  return result.pop();
}

此外,由于增加的^运算符也是正则的元字符,所以需要对其进行转义,正则的结果为

let regstr =  "\\d+(?:\\.\\d+)?|" +
  Object.values(this._symbols).map(
    val => val.symbol.replace(/[*+^()]/g, '\\$&')
  ).join("|")
this.pattern = new RegExp(regstr,"g")

改完之后我们运行如下代码:

console.log(calc.evaluate("2 * (4-2) / 3 - ((2 + 3) * 2)"))
console.log(calc.evaluate("2 * (4-2) / 3 - ((4 % 2) * 2) + 3! + 4^2"))

得到如下结果:

经验证结果为正确。

3. 函数支持的实现

result4.png (1)问题分析

考虑我们有以下式子

1+2+min(2,3)+max(2,4,5,(3 + 5))

这个式子中包含函数的调用,主要有以下特点:

  • 函数名可能是多个字符组成的,不一定是单一的字符
  • 函数调用时需要有括号,括号内部由一系列的参数组成
  • 参数之间以英文逗号作为分割符
  • 参数也可以是一个表达式
  • 同一个函数,其参数个数可能是不定的,例如求最大值最小值可以传入的参数个数不确定

这看起来似乎还是个挺棘手的问题:怎么处理函数的括号和其他运算的括号?怎么做到函数的参数不固定?函数的运算优先级该怎么定?

我们先回到前面讲解的调度场算法中,对于函数的处理方式:

如果读到的字符表示一个函数,那么将其压入操作符栈当中;如果表示一个函数参数的分隔符(例如一个半角逗号 , ):从栈当中不断地弹出操作符并且放入输出队列中去,直到栈顶部的元素为一个左括号为止。如果一直没有遇到左括号,那么要么是分隔符放错了位置,要么是括号不匹配。

可以知道,遇到函数操作符可以不用比较优先级直接将函数名直接压入到操作符栈中去。主要的难点在于参数分割符,换个式子来理解一下上面的处理方法:

1+min(3,4,max(2,2+3,5))

整个解析的过程如下(S是操作符栈,Q是输出队列):

S: [], Q: [1] ->

S:[+],Q:[1] ->

S:[+,min] ,Q:[1] ->

S: [+,min,(], Q: [1] ->

S: [+,min,(], Q: [1,3] ->

S: [+,min,(], Q: [1,3] ->

S: [+,min,(], Q: [1,3,4] ->

S: [+,min,(], Q: [1,3,4] ->

S: [+,min,(,max], Q: [1,3,4] ->

S: [+,min,(,max,(], Q: [1,3,4] ->

S: [+,min,(,max,(], Q: [1,3,4,2] ->

S: [+,min,(,max,(], Q: [1,3,4,2] ->

S: [+,min,(,max,(], Q: [1,3,4,2,2] ->

S: [+,min,(,max,(,+], Q: [1,3,4,2,2] ->

S: [+,min,(,max,(,+], Q: [1,3,4,2,2,3] ->

S: [+,min,(,max,(], Q: [1,3,4,2,2,3,+](遇到了逗号,将左括号之前的操作符压入输出队列)->

S: [+,min,(,max,(], Q: [1,3,4,2,2,3,+,5] ->

S: [+,min,(,max], Q: [1,3,4,2,2,3,+,5] ->

S: [+,min], Q: [1,3,4,2,2,3,+,5,max] ->

S: [+,min], Q: [1,3,4,2,2,3,+,5,max,min,+]

最后得到的结果就是1 3 4 2 2 3 + 5 max min +,我们尝试对其进行求值,这时候会遇到一个问题,maxmin在调用时依旧无法知道应该传入多少个参数。但以上结果都是基于个人对调度场算法的理解,因为按照调度场算法的描述的话,逗号最终并没有作为操作符压入操作符栈。我不太清楚我的理解是否有误,如果有知道的正确的解读方式的话,你可以跟我分享一下,我来完善这部分的内容。现在,我们不妨将调度场算法的这句话**“如果表示一个函数参数的分隔符(例如一个半角逗号 , ):从栈当中不断地弹出操作符并且放入输出队列中去,直到栈顶部的元素为一个左括号为止”解读为如果表示一个函数参数的分隔符...直到栈顶部的元素为一个左括号为止,之后将函数参数分隔符压入操作符队列,再来解析这个式子得到的结果是1 3 4 2 2 3 + 5 , , max , , min +。得到这个式子后我们再尝试对其求值,这时候可以发现,当我们每遇上一个参数分割符时就意味着后面的函数需要增加一个参数,这个过程可以通过将函数的传递参数个数看为1,然后传递的参数通过展开数组来实现,参数分割符的作用就是就是将多个参数转为一个数组(如使用Array.of来实现)。通过这样一种方式,我们就可以解决以上关于解析函数时遇到的问题。

理清了以上的思路,我们就可以对表达式中包含函数的功能进行实现,主要需要做以下几个处理:

  • 函数定义为接收一个参数
  • 函数分隔符将前后两个参数拼接为数组(使用Array.of处理)压入输出队列
  • 函数与左括号一样,直接压入操作符栈无需比较运算优先级,但由于其是一个参与运算的操作符,需要与普通操作符区分开了,故针对函数定义时新增一个type参数用于表示其类型为函数

(1)构造函数增加支持的函数的定义

  this.definedOperator("min",Math.min,0,1,'func')
  this.definedOperator("max",Math.max,0,1,'func')
  this.definedOperator(",",Array.of,0,2)

(2)操作符定义函数

新增一个类型参数

  /**
   * 运算符定义方法
   * @param symbol  运算符
   * @param handle 处理方法
   * @param precedence 优先级
   * @param precedence 参数个数
   * @param type 操作符类型
  **/
  definedOperator(symbol, handle,precedence, argCount,type) {
    precedence = precedence || 0 //将括号的优先级设为0
    this._symbols[symbol] =  {
      symbol, handle, precedence, argCount,type
    }
  }

(3)正则的完善

函数名应该的正则应该怎么写呢?假如一个式子里某个函数名包含了另外一个函数名怎么处理?(如min(1,2,3) + m(4,5),在这个式子中,有两个函数mmin。其中min包含了m,如果不做适当的处理,那么解析时遇到min可能会解析出m而不是完整的min)。其实,无论是普通的数学运算符还是函数,它们都应该作为一个符号整体来看待。正则默认是贪婪匹配的,同时管道符的匹配是从左往右按顺序匹配。如下例子:

regtest.png

因此,对于这种情况,我们只需要在生成正则时,将min置于m之前即可(根据长度做一次排序),也就是长度长的位于长度短的后面。所以,最终,正则的生成规则如下:

  let regstr =  "\\d+(?:\\.\\d+)?|" +
  Object.values(this._symbols)
  .sort((a,b) => b.symbol.length - a.symbol.length)
  .map(
    val => val.symbol.replace(/[*+^()]/g, '\\$&')
  ).join("|")
  this.pattern = new RegExp(regstr,"g")
  console.log(this.pattern)

(4)解析方法parse

通过上面分析,解析方法需要做以下的完善:

  • 解析到函数,同解析到左括号一样,直接压入操作符栈
  • 遇到参数分隔符(,)`,在不断弹出操作符并压入输出队列,直到遇到左括号

完善的部分如下:

  if(isNaN(+token)) { //如果不是一个数字
    if(token === "(" || this._symbols[token].type== 'func'){//如果是一个左括号或者是一个函数,则直接入操作符栈
      operators.push(token)
    } else if(token === ')'){//当匹配到的是一个右括号
      //...
    } else if(!operators.length) {//操作符为空,直接入操作符栈
      operators.push(token)
    } else if(token === ',') {//如果是参数分隔符,不断弹出操作符压入输出队列
      let prev = operators[operators.length - 1]
      while(prev !== '(') {
         result.push(operators.pop())
         prev = operators[operators.length - 1]
      }
      // 将参数分隔符入栈
      operators.push(token)
    } else {//操作符栈不为空,需要比较优先级
      // ...
    }
  }

(5)计算方法evaluate

修改完parse方法后尝试解析min(1,2,3)得到结果1 2 , 3 , min,但计算后得到结果是NaN。这是因为,evaluate方法在调用操作符的处理方法传参时,执行的是

result.push(token.handle(...result.splice(-token.argCount)))

使用splice截取若干个参数,并展开传递给执行方法,当遇到,时,调用的是Array.of方法,经过第一个,处理后,得到[1,2],第二次调用得到[[1,2],3],因此,最终相当于执行了Math.min([1,2],3),得到结果NaN。解决这个问题,就需要每次调用时,将参数转为一维数组,如[1,2,3]。那怎么将[[1,2],3]转为[1,2,3]呢?很自然,可以想到使用concat方法是可以处理的,即[].concat([1,2],3)。所以,计算方法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)) {
      // 从result中截取出来argCount个数字,展开后传入运算符的处理方法
      let token = this._symbols[c]
      result.push(token.handle(...[].concat(...result.splice(-token.argCount))))
    } else {
      result.push(c)
    }
  }
  return result.pop();
}

修改后在测试得到正确的结果1。再测试以下几个例子,也能得到正确的结果。

 console.log(calc.evaluate("3! + 4^2 +min(2,3,3-3)"))
 console.log("------------------------------------------------------")
 console.log(calc.evaluate("max(2!,max(3,3-3),min(max(2^2,3)))"))

结果和跟踪过程如下:

result5.png

4. 正负号的支持

(1)操作符定义方法defineOperator

正负号是一个前缀运算符,要支持正负号,就意味着对于同一个操作符+-有着双重的角色。其中作为正负号其是一个一元运算符,而作为加减法是二元运算符。正负号后,对于一个数学表达式的符号支持类型应该是基本满足了。我们再来重新审视一下运算符具有的一些属性。

  • 运算符分前缀运算符,中缀运算符、后缀运算符和函数
  • 执行运算时,除了中缀运算符需要两个参数外,其它均为一个(函数的参数是由函数分隔符将若干个参数组合成的一个数组参数)
  • 运算符具有计算优先级属性
  • 同一个运算符符号可以是多种类型运算符(如正负和加减)

然后看下我们目前定义的操作符最终的数据结构:

symbol.png

可以看到,一个操作符对应一个含有各个参数的对象,如果需要改为一个符号可以支持多种类型的话,则需要在外层多包裹隐层用于区分不同的类型。如:


+: {
  type1: {
    argCount: 2
    handle: function(a,b) {}
    precedence: 1
    symbol: "+"
    type: undefined
  },
  type2: {
    argCount: 2
    handle: function(a) {}
    precedence: 1
    symbol: "+"
    type: undefined
  }
}

因此,假如要支持正负号的运算,首先要完善的是操作符的定义方法defineOperator,完善后代码如下:

/**
 * 运算符定义方法
 * @param symbol  运算符
 * @param handle 处理方法
 * @param type 操作符类型[prefix,infix,postfix,func]
 * @param precedence 优先级
**/
definedOperator(symbol, handle, type = 'func', precedence = 0) {
  //函数类型强制将优先级置为0
  if(type === 'func') precedence = 0
  let argCount = type === 'infix' ? 2 : 1
  this._symbols[symbol] =  Object.assign({}, this._symbols[symbol], {
    [type]:{symbol,handle,precedence,argCount,type},
    symbol
  })
}

代码解读:

  • 仅二元运算符(中缀运算符)需要传两个参数,因此原本的argCount这个参数可以去掉,通过运算符的类型来决定
  • 操作符类型增加prefix,infix,postfix三种类型,合并上前面定义的func类型共四种类型,不传默认为func
  • 符号的定义多个类型采用嵌套的方式,以type作为键名来区分

(2)构造函数

defineOperator修改后,构造函数关于符号定义也要修改,另外需要增加正负号两个符号,代码如下:

this.definedOperator("+", this.last, "prefix", 3)
this.definedOperator("-", this.negation, "prefix", 3)
this.definedOperator("+",this.add, 'infix', 2)
this.definedOperator("-",this.sub, 'infix', 2)
this.definedOperator("*",this.multi, 'infix',4)
this.definedOperator("/",this.div, 'infix', 4)
this.definedOperator("(")
this.definedOperator(")")
this.definedOperator("%", this.mod, 'infix', 4)
this.definedOperator("!", this.fac, 'postfix', 5)
this.definedOperator("^", Math.pow, 'infix', 4)
this.definedOperator("min", Math.min)
this.definedOperator("max", Math.max)
this.definedOperator(",", Array.of,'infix', 1)

对于正负号,需要有额外的处理方法,如下:

 last(a){
  return a
 }
negation(a){
  return -a
}

(3)解析方法parse

增加正负号后,需要解决的一个问题就是:当匹配到加号或者负号时,怎么来区分处理。通过观察一个式子如3*-3++6---4,可以发现,对于一个正确的式子来讲,当上一次匹配到的为一个操作符时,当次匹配到的+,-都应该属于前缀运算符;相反,当上次匹配到的是数字时,当前的+,-就属于中缀运算符。因此,要解决这个问题,仅需要在读取时,增加一个标记来标记上一次匹配到的是否是一个数字即可。最终,parse方法改造完善如下:

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

  //去除空格的处理
  s = s.replace(/\s/g,'')
  //重置正则当前匹配位置
  this.pattern.lastIndex = 0
  do{
    match = this.pattern.exec(s)
    token = match ? match[0] : null
    //没有匹配到,结束匹配
    //console.log(token,lastIsNumber)
    if(!token) break
    const curr = this._symbols[token]
    if(lastIsNumber) {//上次匹配到的数数字
      //上次匹配到的是数字,当前只能是中缀或者后缀运算符
      lastIsNumber = false;
      const currSymbol = curr.postfix || curr.infix
      if(token === "(" || this._symbols[token].type== 'func'){//如果是一个左括号或者是一个函数,则直接入操作符栈
        operators.push(curr.func || curr.prefix)
      } else if(token === ')'){//当匹配到的是一个右括号
        //循环弹出操作符栈,并压入输出栈,
        //直到遇到左括号为止
        let o = operators.pop()
        while(o.symbol !== "(") {
          result.push(o)
          o = operators.pop()
        }
        //右括号不入栈,重置lastIsNumber为true
        lastIsNumber = true;
      } else if(!operators.length && currSymbol.type !== 'postfix') {//操作符为空,直接入操作符栈
        operators.push(this._symbols[token].infix)
      } else {//操作符栈不为空,需要比较优先级
        //获取前一个操作符
        let prev = operators[operators.length - 1]
        /**
          如果当前操作符的优先级不比栈顶元素操作符的优先级高,则将栈顶的操作符弹出并压入输出栈,
          循环与剩余的栈的栈顶元素做比较直到当前元素的优先级比栈顶元素高
        **/
        while(prev && prev.symbol !== "(" && currSymbol.symbol === ',' || prev && currSymbol.precedence <= prev.precedence) {
          result.push(operators.pop())
          prev = operators[operators.length - 1]
        }
        if(currSymbol.type === 'postfix') {//后缀运算符需要先压入输出队列,并将lastIsNumber置为true,如3!+4
          result.push(currSymbol)
          lastIsNumber = true;
        } else {
          //压入当前操作符压入操作符栈
          operators.push(currSymbol)
        }
      }
    } else if(isNaN(+token)) {//上次匹配到的不是数字,当前也不是数字,说明只能是前缀运算符或者函数
      operators.push(curr.prefix || curr.func)
    } else {//说明当前是个数字
      lastIsNumber = true //标记
      result.push(+token)
    }
  } while(match)
  //将操作符栈剩余的操作符全部弹出并压入输出栈
  result.push(...operators.reverse())
  //得到输出栈,转为字符串即为后缀表达式
  console.log('解析结果:',result)
  return result
}

代码解读:

  • 新增了一个标记lastIsNumber用来标记上一次匹配的是不是数字
  • 压入操作符栈的不再仅仅是操作符的符号,而是一整个操作符对象
  • 去除了匹配参数分割符条件分支的逻辑。因为我们知道,参数分割符在defineOperator改造时已经当做了一个二元运算符来处理,按照调度场算法,遇到参数分隔符时需要将前面左号之后的操作符压入输出队列,那么可以将参数分隔符当做是运算优先级最低的运算符,因此定义时其优先级为1。
  • 对于后缀表达式,做了调整:当匹配到时优先将其压入输出队列,并将lastIsNumber置为了true。为什么要这样做呢?前面我们分析处理正负号时讲到,当上一次遇到操作符,本次遇到的加减号就当做正负号处理,这其实有点问题。考虑式子3! + 4,这个式子是正确的,允许连续两个操作符出现。但按照上面逻辑,当解析到+时,由于上次匹配到的是!,那么此时+就会被当做正号来处理,从而得到不正确的结果。一个解决办法就是对于后缀运算符,直接先计算出来值,然后压入输出队列,当做数字来处理。也就是遇到时优先将后缀运算符压入输出队列,让其优先参与运算。同时将lastIsNumber设为true就解决了这个问题

至此,关于支持正负号的功能暂时已经完成了。测试一下以下几个式子

console.log(calc.evaluate("-max(2!,max(3,3-3),min(max(2^2,3)))"))
console.log("------------------------------------------------------")
console.log(calc.evaluate("---3 +++ 2"))
console.log("------------------------------------------------------")
console.log(calc.evaluate("3^2 + 2 * min(max(-2,-3++9))"))

得到如下结果:

result6.png

总结

针对上一篇文章留下的问题,本文做了详细的详解。到本文为止,已经基本上实现了一个完整的支持函数,四则运算,正负号,指数运算等计算的数学计算器。目前还遗留两个问题:

  • 支持表达式的错误校验并给出相应的提示
  • 对括号对进行严格的匹配

由于本文的内容已经足够多,故将这两个问题放到下一篇文章去处理和完善。除此之外,下一篇文章的还应达到以下几个目标:

  • 将表达式的计算合并到解析的过程中去,优化现有的代码
  • 对整个计算器进行封装,并为外部暴露可配置api
  • 完整的测试
  • 使其支持多种规范(AMD,CMD,UMD)
  • 开源该项目

下一篇:《做一个科学计算器(四)》