做一个科学计算器(四)

853 阅读11分钟

前面几篇文章我们已经从理论到实践,一步一步的实现了一个支持四则运算、函数、正负号、开方、指数运算等数学运算的计算器。如果你还没有阅读前面几篇文章,你可以先点击下方链接进行阅读。

本文为系列最后一篇文章,主要是对代码进行优化和封装整理,包含以下几个内容:

  • 将表达式的计算合并到解析的过程中去,优化现有的代码
  • 支持表达式的错误校验并给出相应的提示
  • 对括号进行严格的匹配
  • 测试用例
  • 封装代码并开源

废话不多说,直接切入主题。

解析和计算过程合并

在已经实现了的计算器中,式子的解析和求值是分开的。大概的步骤如下:

  1. 解析式子,得到输出队列,输出队列里包含了一系列的数字和运算符

  2. 从输出队列中不断的读取元素,当遇到运算符时就做一次计算,直到所有的运算符和数字计算完毕

仔细观察这个过程不难发现,在最后遍历输出队列来计算的时候,操作符的读取顺序和在解析时,操作符进入队列的顺序是一致的。换句话说,哪一个操作符优先进入输出队列,哪一个操作符在计算时就优先参与计算。既然如此,何不换种思路:在解析式子的时候,不把操作符压入输出队列,而是当操作符要进入输出队列时,直接从输出队列中取出若干个运算参数和操作符做一次计算并将计算结果压入输出队列。这样子,输出队列将只会包含读取到的数字或者每次运算后的结果,不会包含操作符。举个栗子:

如式子1 + 2 * 3 + 4

按照前面的实现方式,首先是解析出后缀表达式123*+4+,紧接着再遍历解析结果做三次运算2*3=61+6=77+4=11

换成本文的思路是:

(1)读取到1,进入输出队列,得到S:[],Q: [1]

(2)读取到+,进入操作符栈,得到S:[+],Q: [1]

(3)读取到2,进入输出队列,得到S:[+],Q:[1,2]

(4)读取到*,进入操作符栈,得到S:[+,*],Q: [1,2]

(5)读取到3,进入输出队列,得到S:[+,*],Q:[1,2,3]

(6)读取到+,将*取出不入Q,而是直接参与运算,执行2*3=6,并将6压入输出队列,得到S:[+], Q:[1,6];紧接着再取出S中的+,执行1+6=7,得到S:[], Q: [7];最后将当前+如S栈,得到S:[+], Q:[7]

(7)读取到4,进入输出队列,得到: S: [+], Q: [7,4]

(8)读取完毕,执行7+4=11,得到S:[], Q: [11],最后结果即为11。

有了以上的思路后,就可以将parseevaluate方法合并为一个了(合并后方法名为evaluate),实现的代码如下:

evaluate(s) {
  let calc = symbol => {
    result.push(symbol.handle(...[].concat(...result.splice(-symbol.argCount))))
    return symbol.precedence
  }
  // 操作符栈,输出栈
  let operators = [this._symbols["("].prefix],result = [],
  //正则匹配的结果,当前匹配到的符号,上次匹配到的是不是数字
  match,token, lastIsNumber = false

  //去除空格的处理
  s = s.replace(/\s/g,'')
  //重置正则当前匹配位置
  this.pattern.lastIndex = 0
  do{
    match = this.pattern.exec(s)
    token = match ? match[0] : ")"
    //没有匹配到,结束匹配
    if(!token) break
    const curr = this._symbols[token]
    // console.log(token)
    if(lastIsNumber) {//上次匹配到的数数字
      //上次匹配到的是数字,当前只能是中缀或者后缀运算符
      const currSymbol = curr.postfix || curr.infix
      do {
        let prev =  operators[operators.length - 1] // 获取上一个运算符
        if(!prev || prev.precedence < currSymbol.precedence) break
      } while(calc(operators.pop()))

      if(currSymbol.symbol != ")") {
        if(currSymbol.type === "postfix") {//此时为后置运算符,后置运算符应该直接计算出其结果并参与下一步的运算
          calc(currSymbol)
          //后置运算符运算后为一个数字,所以将lastIsNumber重置为true
          lastIsNumber = true;
        } else {//是一个其他运算符号则将其存入符号栈里
          operators.push(currSymbol)
          lastIsNumber = false;
        }
      }
    } else if(isNaN(+token)) {//上次匹配到的不是数字,当前也不是数字,说明只能是前缀运算符或者函数
      operators.push(curr.prefix || curr.func)
      if(curr.func) {//说明这是一个函数运算符,需要后面紧跟一个括号,所以再执行一次匹配
        match = this.pattern.exec(s)
      }
    } else {//说明当前是个数字
      lastIsNumber = true //标记
      result.push(+token)
    }
  } while(match && operators.length)
  //得到输出栈,转为字符串即为后缀表达式
  console.log('解析结果:',result)
  return result.pop()
}

合并后的代码比之前分成两个方法的代码要精简很多,但精简并不意味着简单。合并后的这段代码包含的内容和细节还是十分丰富的,以下对这段代码做一个详细的解析(以下分析均建立在数学表达式是正确的前提下)。

首先是下面这几个语句:

let operators = [this._symbols["("].prefix], result = []
// ...
 do{
  // ...
  match = this.pattern.exec(s)
  token = match ? match[0] : ")"
  //...
}
// ...

你也许会有这样的疑问:为什么操作符栈的初始值会是包含一个左括号?为什么匹配不到符号或者数字时,token的值置为)而不是null

这个问题依赖于后面的实现,因此我们留到后面再来解决这个疑问。在回答这个问题之前,我们先再看另外的一些代码实现:

if(lastIsNumber) {
  const currSymbol = curr.postfix || curr.infix
  do {
    let prev =  operators[operators.length - 1] // 获取上一个运算符
    if(!prev || prev.precedence < currSymbol.precedence) break
  } while(calc(operators.pop()))

  if(currSymbol.symbol != ")") {
    if(currSymbol.type === "postfix") {//此时为后置运算符,后置运算符应该直接计算出其结果并参与下一步的运算
      calc(currSymbol)
      //后置运算符运算后为一个数字,所以将lastIsNumber重置为true
      lastIsNumber = true
    } else {//是一个其他运算符号则将其存入符号栈里
      operators.push(currSymbol)
      lastIsNumber = false
    }
  }
}

当上次匹配到一个数字时,本次匹配到的为一个操作符。按照前面的逻辑,我们会对操作符进行各种判断,比如是否为一个左括号,是否为函数,与上一个操作符的计算优先级做比较等。再按照实际情况决定是否将操作符压入操作符栈以及是否从操作符栈取出操作符压入输出队列。而我们本次代码改造的其中一个目标是,对于要压入输出队列的操作符不做压入操作,而是直接参与运算得到结果再压入输出队列。因此,改造后以上的这个分支,一上来就先循环比较当前操作符与上一个操作符的优先级,并将优先级高的操作符直接取出来参与运算(调用calc函数,后面讲解),之后再对当前操作符做处理。来看下循环的条件

if(!prev || prev.precedence < currSymbol.precedence) break

while(calc(operators.pop()))

第一个条件很好理解,只要当前的操作符的运算优先级比前一个操作符高或者操作符栈已经为空了即退出循环。第二个条件则是依据calc函数的返回值来判断。calc函数代码如下:

let calc = symbol => {
  result.push(symbol.handle(...[].concat(...result.splice(-symbol.argCount))))
  return symbol.precedence
}

该函数引用了result队列,进行对应的计算后改变result,最后返回当前操作符的优先级的值。换句话说,只要当前操作符的优先级为0,循环即结束。那什么操作符的优先级为0呢?从我们前面定义的操作符中知道,最终压入操作符栈,优先级为0的符号有函数运算符和左括号两种。右括号也为0,但是其不会压入运算符栈。那为什么是0的时候就退出循环了呢?来理一下几种情况:

  • 假如当前匹配到的是一个非括号非函数,那么按照上面条件,在没遇到优先级为0的操作符时就退出了循环,不会走到这一步
  • 假如当前匹配到的是个右括号(左括号不会走lastIsNumber这个条件分支),按照之前的逻辑,遇到右括号,需要不断弹出操作符直到遇到左括号为止。结合后面另一个条件分支
else if(isNaN(+token)) {//上次匹配到的不是数字,当前也不是数字,说明只能是前缀运算符或者函数
  operators.push(curr.prefix || curr.func)
  if(curr.func) {//说明这是一个函数运算符,需要后面紧跟一个括号,所以再执行一次匹配
    match = this.pattern.exec(s)
  }
}

针对函数会强制再执行一次正则匹配(去掉了函数后面的左括号),那么这种情况是符合当前讨论的逻辑的

  • 假如当前匹配到的是一个函数,由于函数也不会走lastIsNumber这个条件分支,因此也不符合当前讨论的情况

综上所述,当且仅当当前匹配到的符号为右括号时,才会遇到calc函数执行返回为0的情况(也就是遇到了左括号或者函数),因此,在改造后的代码中,我们并没有单独针对匹配到右括号做另外一个条件分支的判断,因为在这一步循环的时候已经处理过了。

仔细观察,发现整个匹配的循环条件也由原本的while((match)改成了(match && operators.length),至于为什么要这么做,还得结合前面遗留下来的疑问一起来探讨一下。先整理一下改造后的evaluate方法做了哪些处理:

  • 去除了原本一些不必要的条件分支,因为有些分支在保证数学表达式正确的情况下,是永远不会发生的。由于本文将会加入式子正确性校验的逻辑,故可以将这部分的条件分支去除掉。(比如,在前一次匹配为数字的情况下,当前次就不会匹配到左括号和函数)
  • 匹配到函数时,强制再执行一次正则匹配,用于去除函数后面的左括号
  • 增加了calc函数专门用来计算,并返回当前运算符的优先级

好了,接下来我们以一个例子1 + 2 * 3 - 4来分析一下为什么operators的初始值需要有个(元素,match匹配结束不为null而是)以及读取符号时循环的条件为何需要增加 operators.length

首先我们假设operators的初始值还是为空数组,当match匹配不到时还是为null。整个过程如下:

符号操作符栈S输出队列Q说明
11数字,进输出队列
++1操作符,且不为右括号,进S
2+1, 2数字,进输出队列
*+ *1, 2操作符,优先级比+高,入栈S
3+ *1, 2, 3数字,进输出队列
--7操作符,循环弹出*和+参与运算得到结果7,如Q
4-7, 4数字,进输出队列
null-7, 4读取结束,得到结果

可以看到,在这种条件下,最后得到的结果将会是Q.pop(),也就是4。属于错误的结果。在上一个实现版本中,parse方法最后会有以下的处理

    //将操作符栈剩余的操作符全部弹出并压入输出栈
    result.push(...operators.reverse())

式子解析结束后,其实operators栈里可能还有剩余的操作符。而我们改版之后的evaluate方法没有做对应的处理,因此得到了错误的结果。我们可以用parse一样的处理方法来解决这个问题。但我们的目标是,解析和计算合并在一起,这种处理方法的话最后还是得做多一步遍历的计算,我们应该尽可能的做到最后不需要再额外的做一次遍历。那怎么做呢?有个处理技巧就是再执行一次下面的循环的逻辑

do {
  let prev =  operators[operators.length - 1]
  if(!prev || prev.precedence < currSymbol.precedence) break
} while(calc(operators.pop()))

这个循环会不断的取出operators里的操作符参与运算,而其取出的前提条件是当前的运算符计算的优先级比operators里的运算符优先级低。前面我们对这段代码进行了分析:当且仅当遇到右括号时,不断的弹出operators的操作符参与运算,直到遇到左括号或者函数,循环可以终止。因此,我们自然想到了,当数学表达式读取完毕时,matchnull,得到token为空,为了使其能够继续进行多一次上面的循环,可以在此时将 token置为)。循环后,operators剩余的运算符都将被弹出并参与运算。那为什么又要让operators初始化时有一个左括号的元素呢?是因为后面增加了个右括号所以前面需要成对增加一个左括号吗?emmm...如果要这么理解也不是不可以,但本质的原因不是这样的。我们考虑有以下式子:

2 * (3 + 4)) + 5

这是一个错误的数学表达式,错在式子多了一个右括号。于是,在读取式子每一个符号的时候,当读取到第二个右括号时,由于一直没有遇到一个左括号,所以会导致操作符栈opertators被清空情况。按正常逻辑此时式子已经不应该继续读取下去了,因为已经出错了,应该抛出错误告知式子错误,然后终止程序的运行了。那什么时候才要抛出这个错误呢?由于遇到多余的右括号就会清空操作符栈,所以我们可以为外层循环增加一个条件operators.length,当这个为0的时候,说明了程序已经不需要继续循环读取式子的符号了,于是外层循环的条件就变成了下面这个样子:

do{

} while(match && operators.length)

意思是操作符栈清空了,或者式子读取结束了,那么循环就退出了。然后后面我们就可以再最后通过判断假如match不为空来确定是否有多余的右括号(因为match不为空,说明式子没有读取完毕,但是循环已经退出了,说明遇到了右括号将操作符栈清空的情况,也就是有多余的右括号)

这里我们已经解决前面为什么需要两个条件来决定是否退出循环的疑惑。那这样子就可以了吗?不是的。因为增加了这个条件后就等于式子没法正常读取完毕了,什么意思呢?比如1+2+3,由于第一个读到的符号是数字,此时operators还是空栈,那么式子就不会再进行读取下去了。为了解决这个问题,我们默认在operators压入一个左括号,以保证式子能够正常读取。这就是为什么操作符栈初始化默认需要压入一个左括号的原因。同时,这样做正好能够为后续式子读取完毕后压入多一个右括号构成了完整的一对括号。

到这里可能就有人要问了,是这么怎么想到这样一种处理思路的?

unthought.png

只能说,太巧了,我也没想到。其实再实现到这个版本之前,我是踩了很多个坑,遇到很多问题,迭代了好多个版本才迭代成了这个版本。只有一点点的实现,一点点的测试,通过测试后慢慢的完善代码才有了最后的解决方案。好了,到这里基本讲解完了改造后的evaluate方法。此外还有一些额外的细节,比如:

  • if(!token) break这个语句可以去除,因为token永远不可能为空(为空时会被置为右括号)
  • 循环比较优先级时,退出条件!prev || prev.precedence < currSymbol.precedence可以改为prev.precedence < currSymbol.precedence,因为默认压入了左括号后,第一种情况不会发生了。
  • 前面实现的版本中,左括号是不参与运算的,现在的版本等于左括号也作为了运算符参与了运算,因此定义左括号操作符的时候需要传入处理函数,那么怎么处理呢?其实跟正号的处理方法一样就好了,也就是直接返回原值(this.last方法)
  • 还有一个很隐蔽的细节,它会导致式子计算错误,那到底是什么呢?看下面式子
3^4^2

这个式子正常的结果应该是3的16次方,也就是43046721。但如果按照我们现在的处理方式,该式子最后计算结果会是81的平方,也就是6561。问题出在哪里呢?我们知道,指数运算其实是右结合运算,其计算的顺序是从右往左的,而像加减乘除这类运算属于左结合运算,其运算顺序是从左往右的,问题就出在这里。那按照这样讲,操作符还具有另外一个属性,就是结合性。那怎么处理呢?其实这里是因为我们实现的算法有问题,也就是说我们前面实现的方法是由bug的。调度场算法本身是对该问题进行了对应的处理的。我们回到调度场算法看下以下这句话:

如果c是一个操作符,继续做如下比较:

  • 如果S为空,则将c压入S
  • 否则,将S中的栈顶元素记为o,如果c是左结合性的并且优先级小于等于o,或者c是右结合性的并且优先级小于o,那么将o压入Q,循环这个步骤,直到条件不成立。最后将c压入S。

对于左结合性和右结合性的操作符来讲,其区别在于比较优先级时,左结合是**“小于等于”**,而右结合是“小于”。有了这个思路我们就可以继续完善我们的代码了,需要做如下处理:

  • 定义操作符时增加多一个属性,用于标记操作符的结合性,这里增加为第5个参数right2left,默认为false,代表左结合,如果是右结合的话该值置为true
  • 循环比较运算符优先级并取元素参与运算时,需要增加一个循环退出的条件,即属于右结合性的时候退出循环,修改后如下:
do {
  let prev =  operators[operators.length - 1] // 获取上一个运算符
  if(prev.precedence < currSymbol.precedence || prev.right2left && currSymbol.precedence == prev.precedence) break
} while(calc(operators.pop()))

这个条件改为了:

  • 当前运算符的优先级小于前一个运算符
  • 否则的话要保证当前运算符和前一个运算符优先级相等且是右结合性的

或者可以改为如下的条件,结果是一样的:

if(((currSymbol.precedence - prev.precedence) || prev.right2left) > 0) break

这样子可以保证优先级相等的时候不走第二个条件分支,但前提要保证当前运算符优先级要大于前一个运算符优先级,所以需要条件的结果大于0。

解决了这个问题后,关于evaluate方法的改造就完成了,下一步来完善关于式子校验以及一些错误处理的问题。

错误的处理

错误处理主要包含以下几个:

  • 式子上出现了未定义的符号(空格除外)
  • 语法错误,主要是式子上符号的位置错误
  • 括号不成对匹配

这几个错误的处理都不难,现在就一个一个来实现一下。

**1. 非法字符的处理 **

怎样的字符才是非法字符?这里我们认为只要不是数字且不是我们的定义的操作符(空格除外)都可以认为是非法字符。定义字符后我们会生成一个匹配的正则,用于解析整个表达式,只要能匹配到即做相应的解析处理。换句话说,如果定义的操作符正则中没有匹配到字符,而此时字符又不是空格,那么就意味着匹配到了其他的字符,即非法字符。有了这个思路,只要稍微修改一下正则表达式即可。

原本的正则如下:

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")

修改后正则如下:

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

做了两处改动:增加了一个正则元字符的替换,主要是考虑到后续自定义运算符包含这些字符;新增了\S的匹配并进行捕获。这样的话,当在解析表达式的时候,如果能捕获到分组数据,则说明了匹配到了非法的字符,就可以给出相应的错误提醒。此时evaluate方法解析时部分改动如下:

evaluate()  {
  //...
  do{
    match = this.pattern.exec(s)
    token = match ? match[0] : ")"
    if(match && match[1]) {//说明此时捕获到了非法的字符
      return {
        code: 1001,
        message: "式子中含有未定义的字符"
      }
    }
    //...
  }
  //...
}

pattern.exec(s)时,如果有匹配到非法字符将会被捕获,match[1]不为空,通过判断其是否为空即可。考虑到可能需要对具体错误做具体的处理,因此返回一个包含错误码的对象的形式而不是直接跑出异常。

2. 语法错误处理

语法错误主要是操作符出现的位置不对引起的错误,比如式子* 2 + 3由于*在这里只定义了一个中缀运算,没有定义前缀运算(可自定义),而这里又将其作为前缀运算符来使用,会导致最终的计算结果错误或者程序运行报错。因此,需要找出此类错误并做处理。处理思路为:当上一次读取的符号不是数字或者第一次读取时,假如当前读取到的符号为中缀运算符,那么就给出语法错误的提醒。代码修改后如下:

  match = this.pattern.exec(s)
  token = match ? match[0] : ")"
  const curr = this._symbols[token]
  if(match && match[1]) {//说明此时捕获到了非法的字符
    return {
      code: 1001,
      message: "式子中含有未定义的字符"
    }
  }
  //前后两次不是数字且当前不是函数运算符或者前缀运算符
  if(!lastIsNumber && isNaN(token) && (!curr  || curr && !curr.prefix && !curr.func)) {
    return {
      code: 1002,
      message: "语法错误,请检查你式子是否正确"
    }
  }

3. 括号成对匹配的处理

括号成对匹配的处理稍微麻烦一点,同时,左括号和右括号多余的处理方式是不太一样的。处理之前先总结一下前面关于括号的处理:

  • 操作符栈初始化时会先压入一个左括号,式子读取结束时会给token赋值右括号
  • 遇到函数时,需要执行多一次匹配,得到的左括号不入栈
  • 每次读取符号时,会取出栈元素做优先级比较并运算,直到遇到左括号或者函数为止

通过以上处理后,应该有以下的结果:

  • 由于最后token会被赋值为右括号,其权重为0,那么正常情况下,操作符栈的符号都会被弹出并参与运算,直到遇到正常的初始化时压入的左括号,操作符栈就会成为一个空栈。而如果操作符栈最后不为空,那么就是最后压入的右括号提前遇到了左括号(如(2*(1+3)))或者函数(如min(2,3),说明了式子中的左括号比右括号多(有多余的左括号或者缺少右括号)
  • 当读取到函数再执行一次匹配时,匹配到的不是左括号,说明缺少函数左括号,如min2,3)
  • 正常情况下,式子解析完后match应该为null,假如不为null,说明了循环终止是因为operators已经是空了,也就是说还没有在最后给token赋值为)时 ,operators已经是个空栈了(初始化压入的左括号也弹出了),只能是此时读取到了一个多余的右括号,从而执行遍历将栈清空,这时就是有右括号的数量比左括号多

有了以上的思路,就可以实现代码了,如下:

//...
else if(isNaN(+token)) {//上次匹配到的不是数字,当前也不是数字,说明只能是前缀运算符或者函数
  operators.push(curr.prefix || curr.func)
  if(curr.func) {//说明这是一个函数运算符,需要后面紧跟一个括号,所以再执行一次匹配
    match = this.pattern.exec(s)
    if(!match || match[0] !== '(')
      return {
        code: 1003,
        message:"函数后面缺少一个左括号"
      }
  }
}
//...

return operators.length ? {
  code: 1004,
  message: "左括号的数量比右括号多"
} : match ? {
      code: 1005,
      message: "右括号的数量比左括号多"
    } : result.pop()

以上已经把几种错误都做了相应的处理。由于考虑到在使用的时候,可能需要对特定的错误做出特定的自定义处理,所以所有错误均采用返回含有错误代码的形式。因此是可以将错误封装为一个独立的函数。同时,希望能告知式子解析到哪个位置出现问题,这个通过在控制台打印一条信息即可。最后的函数如下:

let error = (code, msg) => {
    let pos  = match ? match.index : s.length,
    str = `${msg} (第${pos + 1}个字符):\n${s}\n${' '.repeat(pos)}^`
    console.warn(`error[code:${code}]:${str}`)
}

然后将上面的返回错误的方式改为调用函数就行了。最终结果大概如下:

error.png

好了,到此基本上功能、错误处理都实现了。接下来就是定义相关API,加上测试用例以及一些打包这类的配置就完成了一个完整的项目。以下开始定义几个API。

  • 增加符号定义的API
  • 预定义多几个常见的数学计算
  • 解析和求值再独立开来,返回包含valueresult属性的对象,这样可以按需获取值还是获取逆波兰表达式

预定义符号如下

  this._definedOperator("//",this.mod,"infix",4) //求余,原有的%被重写为百分数
  this._definedOperator("%",this.rate,"postfix",5) //百分比
  this._definedOperator("cos",Math.cos) // 余弦
  this._definedOperator("sin",Math.sin) //正弦
  this._definedOperator("tan",Math.tan) //正切
  this._definedOperator("abs",Math.abs) //绝对值
  this._definedOperator("log",this.log) //对数
  this._definedOperator("√",this.sqrt,"infix", 4) // 开方

  log(a,b) {
    if(a <= 0 || a == 1) {
      throw new Error("The base number of logarithmic operations must be greater than 0 and not equal to 1")
    }
    return Math.log(b) / Math.log(a)
  }
  rate(a) {
    return a / 100;
  }
  sqrt(a,b) {
    return Math.pow(b, 1/a)
  }

原有的definedOperator改为了内部方法_definedOperator,因为后面需要暴露一个自定义符号API,名字为definedOperator。对数计算使用了换底公式来实现;开方使用指数的倒数来实现。

原有的%被重写为百分数,后缀表达式和中缀表达式不能定义个同一个符号,因此原有的求余定义为//

** 自定义符号API **

definedOperators(operators) {
  operators = Array.isArray(operators) ? operators : [operators]
  operators.map(v => {
    ["(", ")", ","].indexOf(v.token) === -1 && this._definedOperator(v.token,v.func,v.type,v.weight, v.rtol)
  })
  this.calcReg()
}

支持传入对象数组和对象,分别定义多个符号和一个符号。同时,括号和逗号这三个符号不允许重新定义其他的行为。对象格式如下:

{
  token: "|", //符号,
  func: function() {}, //处理的方法
  type: 'infix', // 符号的类型
  weight: 4, // 权重
  rtol: false, //是否是右结合性符号
}

至于实现存储valueresult的方法实现不是很难,这里不贴详细的代码了,可以点击这里查看

最后跑几个例子看下结果:

let arr = [
  "1+2+3",
  "5-6+3",
  "--3++6--2",
  "5-6*(2+3)",
  "10/2/(1+4)-4",
  "10% + 10//3 * 2 - 1",
  "10- 3! * (2 *( 1+ 3)) + 3^2^3",
  "4 * max(10 / (min(2,min(3,1)) * 2))",
  "2 + log(2,8)",
  " 2* 3√27",
  "cos("+Math.PI+")"
]
for(let i = 0; i < arr.length; i++) {
  console.log(
    "%c" +arr[i] + " = " + calc.parse(arr[i]).value,
    "font-size: 16px;line-height: 2em;color:" + (i % 2 ? "#ff8e01" : " #249ff1")
  )
}

运行后结果如下:

result7.png