资讯专栏INFORMATION COLUMN

[前端漫谈] 做一个四则计算器

马永翠 / 1329人阅读

摘要:概述近期重新开始学习计算机基础方面的东西,比如计算机组成原理网络原理编译原理之类的东西,目前正好在学习编译原理,开始对这一块的东西感兴趣,但是理论的学习有点枯燥无味,决定换种方式,那就是先实践遇到问题尝试解决,用实践推动理论。

0x000 概述

近期重新开始学习计算机基础方面的东西,比如计算机组成原理网络原理编译原理之类的东西,目前正好在学习编译原理,开始对这一块的东西感兴趣,但是理论的学习有点枯燥无味,决定换种方式,那就是先实践、遇到问题尝试解决,用实践推动理论。原本打算写个中文JS解析的,但是好像有点难,需要慢慢实现,于是就找个简单的来做,那就是解析一下四则运算,就有了这个项目,声明:这是一个很简单的项目,这是一个很简单的项目,这是一个很简单的项目。其中用到的词法分析、语法分析、自动机都是用简单的方式实现,毕竟比较菜。

0x001 效果

源码地址:github

实现功能:

任意顺序的四则+-*/正整数运算

支持()

前端后端通用

提供直接计算函数

提供四则运算表达式转逆波兰AST函数

提供语法分析函数(暂时只支持上下两个字符判定)

效果演示:

0x002 实现

既然说很简单,那不管用到的理论和实现的方式都一定要都很简单,实现这个效果一共需要克服三个问题:

如何实现优先级计算,比如*/()的优先级大于+-

如何分割字符串,比如如何识别数字、符号和错误字符,也就是词素化。

如何实现语法检测,也就是让表达式的规则满足要求,比如+后面比如跟随数字或者((这里将-当作操作,而不是符号)。

0x003 解决问题1: 如何实现优先级运算 1. 暂时忽略优先级

如果没有优先级问题,那实现一个计算十分的简单,比如下面的代码可以实现一个简单的加减或者乘除计算(10以内,超过一位数会遇到问题2,这里先简单一点,避过问题2):

        let calc = (input) => {
            let calMap = {
                "+": (num1, num2) => num1 + num2,
                "-": (num1, num2) => num1 - num2,
                "*": (num1, num2) => num1 * num2,
                "/": (num1, num2) => num1 / num2,
            }
            input = [...input].reverse()
            while (input.length >= 2) {
                let num1 = +input.pop()
                let op = input.pop()
                let num2 = +input.pop()
                input.push(calMap[op](num1, num2))
            }
            return input[0]
        }

        expect(calc("1+2+3+4+5-1")).toEqual(14)
        expect(calc("1*2*3/3")).toEqual(2)

算法步骤:

将输入打散成一个栈,因为是10以内的,所以每个数只有一位:

input = [...input].reverse()

每次取出三位,如果是正确的输入,则取出的三位,第一位是数字,第二位是操作符,第三位是数字:

let num1 = +input.pop()
let op = input.pop()
let num2 = +input.pop()

根据操作符做运算后将结果推回栈中,又形成了这么一个流程,一直到最后栈中只剩下一个数,或者说每次都要取出3个数,所以如果栈深度<=2,那就是最后的结果了:

while (input.length >= 2) {
    // ......
    input.push(calMap[op](num1, num2))
}

动画演示:

2. 考虑优先级

但是现在需要考虑优先级,比如*/的优先级大于+-()的运算符最高,那如何解决呢,其实都已经有解决方案了,我用的是后缀表达式,也叫逆波兰式

后缀表达式:
所谓的后缀表达式,就是将操作符放在表达式的最后边,比如1+1表示成11+

中缀表达式:
所谓的中缀表达式,其实就是我们平常使用的写法了,这里不做深入。

前缀表达式
所谓的后缀表达式,就是将操作符放在表达式的最前边,比如1+1表示成+11,这里不做深入

逆波兰式可以参考下列文章

Wiki-逆波兰表示法

知乎-什么是逆波兰表达式

3. 逆波兰式解决优先级问题

在逆波兰式子中,1+1*2可以转化为112*+
代码演示:

 let calc = (input) => {
    let calMap = {
        "+": (num1, num2) => num1 + num2,
        "-": (num1, num2) => num1 - num2,
        "*": (num1, num2) => num1 * num2,
        "/": (num1, num2) => num1 / num2,
    }
    input = [...input].reverse()
    let resultStack = []
    while (input.length) {
        let token = input.pop()
        if (/[0-9]/.test(token)) {
            resultStack.push(token)
            continue
        }
        if (/[+-*/]/.test(token)) {
            let num1 = +resultStack.pop()
            let num2 = +resultStack.pop()
            resultStack.push(calMap[token](num1, num2))
            continue
        }
    }
    return resultStack[0]
}
expect(calc("123*+")).toEqual(7)

转化之后计算步骤如下:

初始化一个栈

    let resultStack = []

每次从表达式中取出一位

let token = input.pop()

如果是数字,则推入栈中

if (/[0-9]/.test(token)) {
    resultStack.push(token)
    continue
}

如果是操作符,则从栈中取出两个数,做相应的运算,再将结果推入栈中

if (/[+-*/]/.test(token)) {
    let num1 = +resultStack.pop()
    let num2 = +resultStack.pop()
    resultStack.push(calMap[token](num1, num2))
    continue
}

如果表达式不为空,进入步骤2,如果表达式空了,栈中的数就是最后的结果,计算完成

while (input.length) {
    // ...
}
return resultStack[0]

动画演示:



转化成逆波兰式之后有两个优点:

不关心运算符优先级

去除括号,比如(1+2)*(3+4),可以转化为12+34+*,按照逆波兰式运算方法即可完成运算

4. 中缀转后缀

这是问题1的最后一个小问题了,这个问题的实现过程如下:

let parse = (input) => {
            input = [...input].reverse()
            let resultStack = [], opStack = []
            while (input.length) {
                let token = input.pop()
                if (/[0-9]/.test(token)) {
                    resultStack.push(token)
                    continue
                }
                if (/[+-*/]/.test(token)) {
                    opStack.push(token)
                    continue
                }
            }
            return [...resultStack, ...opStack.reverse()].join("")
        }

        expect(parse(`1+2-3+4-5`)).toEqual("12+3-4+5-")

准备两个栈,一个栈存放结果,一个栈存放操作符,最后将两个栈拼接起来上面的实现可以将1+2-3+4-5转化为12+3-4+5-,但是如果涉及到优先级,就无能为力了,例如

        expect(parse(`1+2*3`)).toEqual("123*+")

1+2*3的转化结果应该是123*+,但其实转化的结果却是123+**/的优先级高于+,所以,应该做如下修改

 let parse = (input) => {
            input = [...input].reverse()
            let resultStack = [], opStack = []
            while (input.length) {
                let token = input.pop()
                if (/[0-9]/.test(token)) {
                    resultStack.push(token)
                    continue
                }
//                if (/[+-*/]/.test(token)) {
//                    opStack.push(token)
//                    continue
//                }
                if (/[*/]/.test(token)) {
                    while (opStack.length) {
                        let preOp = opStack.pop()
                        if (/[+-]/.test(preOp)) {
                            opStack.push(preOp)
                            opStack.push(token)
                            token = null
                            break
                        } else {
                            resultStack.push(preOp)
                            continue
                        }
                    }
                    token && opStack.push(token)
                    continue
                }
                if (/[+-]/.test(token)) {
                    while (opStack.length) {
                        resultStack.push(opStack.pop())
                    }
                    opStack.push(token)
                    continue
                }
            }
            return [...resultStack, ...opStack.reverse()].join("")
        }

        expect(parse(`1+2`)).toEqual("12+")
        expect(parse(`1+2*3`)).toEqual("123*+")

当操作符为*/的时候,取出栈顶元素,判断栈中的元素的优先级低是否低于*/,如果是就直接将操作符推入opStack,然后退出,否则一直将栈中取出的元素推入resultStack

if (/[+-]/.test(preOp)) {
     opStack.push(preOp)// 这里用了栈来做判断,所以判断完还得还回去...
     opStack.push(token)
     token = null
     break
}else {
    resultStack.push(preOp)
    continue
}

还要注意栈空掉的情况,需要将操作符直接入栈。

    token && opStack.push(token)
    continue

当操作符为+-的时候,因为已经是最低的优先级了,所以直接将所有的操作符出栈就行了

if (/[+-]/.test(token)) {
    while (opStack.length) {
        resultStack.push(opStack.pop())
    }
    opStack.push(token)
    continue
}

到这里已经解决了+-*/的优先级问题,只剩下()的优先级问题了,他的优先级是最高的,所以这里做如下修改即可:

if (/[+-]/.test(token)) {
    while (opStack.length) {
        let op=opStack.pop()
        if (/(/.test(op)){
            opStack.push(op)
            break
        }
        resultStack.push(op)
    }
    opStack.push(token)
    continue
}
if (/(/.test(token)) {
    opStack.push(token)
    continue
}
if (/)/.test(token)) {
    let preOp = opStack.pop()
    while (preOp !== "("&&opStack.length) {
        resultStack.push(preOp)
        preOp = opStack.pop()
    }
    continue
}

当操作符是+-的时候,不再无脑弹出,如果是(就不弹出了

while (opStack.length) {
        let op=opStack.pop()
        if (/(/.test(op)){
            opStack.push(op)
            break
        }
        resultStack.push(op)
    }
    opStack.push(token)

当操作符是(的时候,就推入opStack

if (/(/.test(token)) {
    opStack.push(token)
    continue
}

当操作符是)的时候,就持续弹出opStackresultStack,直到遇到((不推入resultStack

if (/)/.test(token)) {
    let preOp = opStack.pop()
    while (preOp !== "("&&opStack.length) {
        resultStack.push(preOp)
        preOp = opStack.pop()
    }
    continue
}

完整代码:

let parse = (input) => {
   input = [...input].reverse()
   let resultStack = [], opStack = []
   while (input.length) {
       let token = input.pop()
       if (/[0-9]/.test(token)) {
           resultStack.push(token)
           continue
       }
       if (/[*/]/.test(token)) {
           while (opStack.length) {
               let preOp = opStack.pop()
               if (/[+-]/.test(preOp)) {
                   opStack.push(preOp)
                   opStack.push(token)
                   token = null
                   break
               } else {
                   resultStack.push(preOp)
                   continue
               }
           }
           token && opStack.push(token)
           continue
       }
       if (/[+-]/.test(token)) {
           while (opStack.length) {
               let op = opStack.pop()
               if (/(/.test(op)) {
                   opStack.push(op)
                   break
               }
               resultStack.push(op)
           }
           opStack.push(token)
           continue
       }
       if (/(/.test(token)) {
           opStack.push(token)
           continue
       }
       if (/)/.test(token)) {
           let preOp = opStack.pop()
           while (preOp !== "(" && opStack.length) {
               resultStack.push(preOp)
               preOp = opStack.pop()
           }
           continue
       }
   }
   return [...resultStack, ...opStack.reverse()].join("")
    

动画示例:

如此,就完成了中缀转后缀了,那么整个问题1就已经被解决了,通过calc(parse(input))就能完成中缀=>后缀=>计算的整个流程了。

0x004 解决问题2:分割字符串

虽然上面已经解决了中缀=>后缀=>计算的大问题,但是最基础的问题还没解决,那就是输入问题,在上面问题1的解决过程中,输入不过是简单的切割,而且还局限在10以内。而接下来,要解决的就是这个输入的问题,如何分割输入,达到要求?

解决方式1:正则,虽然正则可以做到如下,做个简单的demo还是可以的,但是对于之后的语法检测之类的东西不太有利,所以不太好,我放弃了这种方法

(1+22)*(333+4444)`.match(/([0-9]+)|([+-*/])|(()|())/g)
// 输出
// (11) ["(", "1", "+", "22", ")", "*", "(", "333", "+", "4444", ")"]

解决方法2:逐个字符分析,其大概的流程是

while(input.length){
    let token = input.pop()
    if(/[0-9]/.test(token)) // 进入数字分析
    if(/[+-*/()]/.test(token))// 进入符号分析
}

接下来试用解决方案2来解决这个问题:

1 定义节点结构

当我们分割的时候,并不单纯保存值,而是将每个节点保存成一个相似的结构,这个结构可以使用对象表示:

{
    type:"",
    value:""
}

其中,type是节点类型,可以将四则运算中所有可能出现的类型归纳出来,我的归纳如下:

    TYPE_NUMBER: "TYPE_NUMBER", // 数字
    TYPE_LEFT_BRACKET: "TYPE_LEFT_BRACKET", // (
    TYPE_RIGHT_BRACKET: "TYPE_RIGHT_BRACKET", // )
    TYPE_OPERATION_ADD: "TYPE_OPERATION_ADD", // +
    TYPE_OPERATION_SUB: "TYPE_OPERATION_SUB", // -
    TYPE_OPERATION_MUL: "TYPE_OPERATION_MUL", // *
    TYPE_OPERATION_DIV: "TYPE_OPERATION_DIV", // /

value则是对应的真实值,比如123+-*/

2 数字处理

如果是数字,则继续往下读,直到不是数字为止,将这过程中所有的读取结果放到value中,最后入队。

if (token.match(/[0-9]/)) {
    let next = tokens.pop()
    while (next !== undefined) {
        if (!next.match(/[0-9]/)) break
        token += next
        next = tokens.pop()
    }
    result.push({
        type: type.TYPE_NUMBER,
        value: +token
    })
    token = next
}
3 符号处理

先定义一个符号和类型对照表,如果不在表中,说明是异常输入,抛出异常,如果取到了,说明是正常输入,入队即可。

const opMap = {
    "(": type.TYPE_LEFT_BRACKET,
    ")": type.TYPE_RIGHT_BRACKET,
    "+": type.TYPE_OPERATION_ADD,
    "-": type.TYPE_OPERATION_SUB,
    "*": type.TYPE_OPERATION_MUL,
    "/": type.TYPE_OPERATION_DIV
}
let type = opMap[token]
if (!type) throw `error input: ${token}`
result.push({
    type,
    value: token,
})
4 总结

这样就完成了输入的处理,这时候,其他的函数也需要处理一下,应为输入已经从字符串变成了tokenize之后的序列了,修改完成之后就是可以calc(parse(tokenize()))完成一整套骚操作了。

0x005 解决问题3:语法检测

语法检测要解决的问题其实就是判断输入的正确性,是否满足四则运算的规则,这里用了类似状机的思想,不过简单到爆炸,并且只能做单步判定~~
定义一个语法表,该表定义了一个节点后面可以出现的节点类型,比如,+后面只能出现数字或者(之类。

let syntax = {
    [type.TYPE_NUMBER]: [
        type.TYPE_OPERATION_ADD,
        type.TYPE_OPERATION_SUB,
        type.TYPE_OPERATION_MUL,
        type.TYPE_OPERATION_DIV,
        type.TYPE_RIGHT_BRACKET
    ],
    [type.TYPE_OPERATION_ADD]: [
        type.TYPE_NUMBER,
        type.TYPE_LEFT_BRACKET
    ],
    [type.TYPE_OPERATION_SUB]: [
        type.TYPE_NUMBER,
        type.TYPE_LEFT_BRACKET
    ],
    [type.TYPE_OPERATION_MUL]: [
        type.TYPE_NUMBER,
        type.TYPE_LEFT_BRACKET
    ],
    [type.TYPE_OPERATION_DIV]: [
        type.TYPE_NUMBER,
        type.TYPE_LEFT_BRACKET
    ],
    [type.TYPE_LEFT_BRACKET]: [
        type.TYPE_NUMBER,
        type.TYPE_LEFT_BRACKET
    ],
    [type.TYPE_RIGHT_BRACKET]: [
        type.TYPE_OPERATION_ADD,
        type.TYPE_OPERATION_SUB,
        type.TYPE_OPERATION_MUL,
        type.TYPE_OPERATION_DIV,
        type.TYPE_RIGHT_BRACKET
    ]
}

这样我们就可以简单的使用下面的语法判定方法了:

 while (tokens.length) {
    // ...
    let next = tokens.pop()
    if (!syntax[token.type].includes(next.type)) throw `syntax error: ${token.value} -> ${next.value}`
    // ...
 }

对于(),这里使用的是引用计数,如果是(,则计数+1,如果是),则计数-1,检测到最后的时候判定一下计数就好了:

    // ...
    if (token.type === type.TYPE_LEFT_BRACKET) {
        bracketCount++
    }
    // ...
    if (next.type === type.TYPE_RIGHT_BRACKET) {
        bracketCount--
    }
    // ...
    if (bracketCount < 0) {
        throw `syntax error: toooooo much ) -> )`
    }
    // ...
0x006 总结

该文章存在一些问题:

我推导不出为啥要用逆波兰式,只是知道有这么一个解决方案,拿过来用而已,而不是由问题推导出解决方案。

文字功底不够,讲的不够 cool。

该实现也存在一些问题:

并非完全用编译原理的思想去实现,而是自己摸解决方案,先实践,后了解问题。

并没有参考太多别人的实现,有点闭门造车的感觉。

思考:

对于()的处理或许可以使用递归的方式,进入()之后重新开始一个新的表达式解析

思考不够全,单元测试覆盖不够,许多坑还不知道在哪儿

总之:文章到此为止,有很多不够详细的地方还请见谅,多多交流,共同成长。

0x007 资源

编译原理课程

源码

动画制作软件Principle

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/100634.html

相关文章

  • 编译原理实战入门:用 JavaScript 写一个简单的四则运算编译器(四)结语

    摘要:四则运算编译器,虽然说功能很简单,只能编译四则运算表达式。再复杂的编译器再简单的编译器,功能上是差不多的,只是复杂的编译器实现上会更困难。每一章都是理论与实践结合的经典,从计算机硬件知识到软件体系,再到编译原理和操作系统。 四则运算编译器,虽然说功能很简单,只能编译四则运算表达式。但是编译原理前端部分几乎都有涉及,词法分析,语法分析,还有代码生成。 再复杂的编译器、再简单的编译器,功能...

    chemzqm 评论0 收藏0
  • [前端漫谈_3] 从 filter 聊到 Promise

    摘要:前言在学习前端的时候,我总是能听到很多高级词汇,比如今天会聊到的函数式编程高阶函数。接下来我们看看,高阶函数有可能会遇到的问题,又如何去解决。 前言 在学习前端的时候,我总是能听到很多高级词汇,比如今天会聊到的 函数式编程(Functional Programming) & 高阶函数 (Higher-order function) 。但是当你真正的理解什么是 函数式编程 & 高阶函数 ...

    crossoverJie 评论0 收藏0
  • 漫谈前端性能 突破 React 应用瓶颈

    摘要:表示调用栈在下一将要执行的任务。两方性能解药我们一般有两种方案突破上文提到的瓶颈将耗时高成本高易阻塞的长任务切片,分成子任务,并异步执行这样一来,这些子任务会在不同的周期执行,进而主线程就可以在子任务间隙当中执行更新操作。 showImg(https://segmentfault.com/img/remote/1460000016008111); 性能一直以来是前端开发中非常重要的话题...

    whlong 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<