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

0x000 概述

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

0x001 效果

  • 源码地址:github

  • 实现功能:

    • 任意顺序的四则+-*/正整数运算
    • 支持()
    • 前端后端通用
    • 提供直接计算函数
    • 提供四则运算表达式转逆波兰AST函数
    • 提供语法分析函数(暂时只支持上下两个字符判定)
  • 效果演示:

0x002 实现

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

  1. 如何实现优先级计算,比如*/()的优先级大于+-
  2. 如何分割字符串,比如如何识别数字、符号和错误字符,也就是词素化。
  3. 如何实现语法检测,也就是让表达式的规则满足要求,比如+后面比如跟随数字或者((这里将-当作操作,而不是符号)。

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)
复制代码

转化之后计算步骤如下:

  1. 初始化一个栈
        let resultStack = []
    复制代码
  2. 每次从表达式中取出一位
    let token = input.pop()
    复制代码
  3. 如果是数字,则推入栈中
    if (/[0-9]/.test(token)) {resultStack.push(token)continue
    }
    复制代码
  4. 如果是操作符,则从栈中取出两个数,做相应的运算,再将结果推入栈中
    if (/[+\-*/]/.test(token)) {let num1 = +resultStack.pop()let num2 = +resultStack.pop()resultStack.push(calMap[token](num1, num2))continue
    }
    复制代码
  5. 如果表达式不为空,进入步骤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 = nullbreak} 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*+')
复制代码
  1. 当操作符为*/的时候,取出栈顶元素,判断栈中的元素的优先级低是否低于*/,如果是就直接将操作符推入opStack,然后退出,否则一直将栈中取出的元素推入resultStack
if (/[+\-]/.test(preOp)) {opStack.push(preOp)// 这里用了栈来做判断,所以判断完还得还回去...opStack.push(token)token = nullbreak
}else {resultStack.push(preOp)continue
}
复制代码
  1. 还要注意栈空掉的情况,需要将操作符直接入栈。
    token && opStack.push(token)continue
复制代码
  1. 当操作符为+-的时候,因为已经是最低的优先级了,所以直接将所有的操作符出栈就行了
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
}
复制代码
  1. 当操作符是+-的时候,不再无脑弹出,如果是(就不弹出了
while (opStack.length) {let op=opStack.pop()if (/\(/.test(op)){opStack.push(op)break}resultStack.push(op)}opStack.push(token)
复制代码
  1. 当操作符是(的时候,就推入opStack
if (/\(/.test(token)) {opStack.push(token)continue
}
复制代码
  1. 当操作符是)的时候,就持续弹出opStackresultStack,直到遇到((不推入resultStack
if (/\)/.test(token)) {let preOp = opStack.pop()while (preOp !== '('&&opStack.length) {resultStack.push(preOp)preOp = opStack.pop()}continue
}
复制代码

动画示例:

如此,就完成了中缀转后缀了,那么整个问题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]/)) breaktoken += nextnext = 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],//...
}
复制代码

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

 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 总结

  • 该文章存在一些问题:
    1. 我推导不出为啥要用逆波兰式,只是知道有这么一个解决方案,拿过来用而已,而不是由问题推导出解决方案。
    2. 文字功底不够,讲的不够 cool。
  • 该实现也存在一些问题:
    1. 并非完全用编译原理的思想去实现,而是自己摸解决方案,先实践,后了解问题。
    2. 并没有参考太多别人的实现,有点闭门造车的感觉。
  • 思考:
    1. 对于()的处理或许可以使用递归的方式,进入()之后重新开始一个新的表达式解析
    2. 思考不够全,单元测试覆盖不够,许多坑还不知道在哪儿

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

0x007 资源

  • 编译原理课程
  • 源码
  • 动画制作软件Principle

转载于:https://juejin.im/post/5c2c7ca56fb9a04a0d56f60b

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/537226.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

程序员笔试面试后上机_hcie面试有哪些要注意的事项?

大家都知道&#xff0c;华为认证hcie考试分为三个部分&#xff0c;分别是笔试、lab实验和面试。其中&#xff0c;考生讨论得最多的就是面试部分&#xff0c;因为面试不同于笔试和lab实验&#xff0c;自己埋头答题和操作就行&#xff0c;面试要面对考官&#xff0c;考核的东西非…

【Infragistics教程】在javascript构造函数中创建基本继承

2019独角兽企业重金招聘Python工程师标准>>> 【下载Infragistics Ultimate最新版本】 用javascript创建对象有四种方法。具体如下&#xff1a; 对象作为文本构造函数调用模式创建&#xff08;&#xff09;方法在ES6之后使用类继承的实现因对象创建方法而异。本文将解…

python爬虫ssl错误_Python爬虫:Requests的SSLError:certificate verify failed问题解决方案6条...

问题&#xff1a;脚本是用Python写的&#xff0c;用到开源库play-scraper&#xff0c;调用其collectionAPI来获取Google Play的Top App列表。该库使用了requests作为客户端来对Google Play进行操作。当脚本执行时&#xff0c;会报如下错误&#xff1a;certificate verify faile…

2019年1月3日

数组 字面量创建数组 1. var arr[]; []里边可以放数字&#xff0c;字符串&#xff0c;true&#xff0c;false&#xff0c;null&#xff0c;undefined&#xff0c;数组&#xff08;[1,2,3]&#xff09;&#xff0c;对象{x&#xff1a;1&#xff0c;y&#xff1a;2} var arr[1,2…

vertex 3.0 与SpringBoot混合开发之初探

SpringBoot是最近几年比较流行的web应用开发框架&#xff0c;它是微服务的一个开发框架。它的Web服务器内核为Tomcat或Jetty&#xff0c;它们作为Servlet容量来对客户端的http/https请求进行解析。最近&#xff0c;spring.io又出推出一套新的服务器内核框架&#xff0c;它就是W…

switch芯片和phy芯片的区别_感应式芯片卡CPU卡的FM1208-9和FM1208-10有什么区别,你知道吗?...

感应式CPU卡是目前芯片卡中安全系统较高的芯片&#xff0c;使用范围也较为广泛&#xff0c;但是这款CPU分为FM1208-9和FM1208-10&#xff0c;那你们知道分别代表什么意思呢&#xff1f;他们之间有什么不同呢&#xff1f;CPU白卡FM是什么&#xff1f;首先&#xff0c;我们来说下…

每次登陆都要滑动验证_湖人队冠军成员卡鲁索很吃香:每次谈判都有N支球队点名要他...

10月24日NBA直播台讯&#xff1a;洛杉矶湖人队助理教练迈克-彭伯西在接受媒体采访时透露&#xff0c;湖人队替补控球后卫卡鲁索目前在联盟中很吃香。湖人队每次进行交易谈判时&#xff0c;对方球队都点名想要卡鲁索。彭伯西表示&#xff1a;“每一次我们在休赛期或者交易截止日…

[HAOI2015]按位或

朴素的 f[S]表示S到(1<<n)的期望次数 发现1的个数只增加不减少 所以可以类似拓扑序的图&#xff0c;然后枚举子集O(3^n)转移 没有优化的余地 另辟蹊径&#xff1a; 拆开每一位来看 t[i]表示第i位变成1的次数 ansE(max(t[i])) 根据min-max容斥 得到&#xff1a;ans∑E(t[i…

MySQL在DOS指令里面的使用以及增删改查的使用

本人的第一条博客&#xff0c;选中我的电脑单机右键&#xff0c;点开管理&#xff0c;选中服务找到MySQL57.启动该服务。回退至桌面&#xff0c;按住winR 输入cmd打开DOS指令的窗口。 在窗口输入: mysql -h localhost -u root -p 显示password输入提示&#xff1a;表示已经…

node+socket.io 实现一个聊天室

我们只做简单的实现&#xff0c;不接入数据库&#xff0c;nodejs也不使用express和koa等框架 因此依赖只有两个&#xff1a; 1、socket.io 2、mime&#xff08;用于获取静态资源时获取文件的mime类型&#xff09; 安装命令&#xff1a; npm install socket.io mime --save 其他…

安卓应用用户数据_用户指标数据应用

一、如何理解数据用户数据&#xff1a;gender:性别、 birthday:出生日期行为数据&#xff1a;user_id:用户id、auction_id:购买行为编号、buy_mount:购买数量、day:购买时间商品数据&#xff1a;cat_id:商品种类ID、cat1:商品类别、property:商品属性二、用户数据指标1.用户数据…

三大数据库数据库端口号及连接jdbc驱动下载

Jdbc连接三大数据库&#xff08;mysql sqlserver oracle&#xff09; Mysql:端口号为&#xff1a;3306&#xff08;默认&#xff09; 用java连接mysql数据库 Try{Class.forName(“com.mysql.jdbc.Driver”); //DatabaseName:需要连接的数据库名称 String url”jdbc:mysql://12…

webgis从基础到开发实践_开源WebGIS教程系列——11.1 GISLite 的开发背景与设计

地理信息门户可以帮助人们更容易地发现、访问和使用地理空间信息&#xff0c; 是地理信息发布、服务和共享的重要环节。许多国家都很重视地理信息门户的 建设&#xff0c;把它作为国家空间数据基础设施(spatial data infrastructure&#xff0c;SDI)的重要组成部分。GISLite 是…

Oracle数据库及在DOS命令下面的简单操作

在Oracle数据库注释用--表明为注释&#xff0c;但以下用//或--代表解释;数据库不怎么区分大小写&#xff1b; 先说说一些简单Oracle数据库操作的语句&#xff1a; 使用语句创建普通用户&#xff1a; Create user username identified by password; //创建普通用户 Grant reso…

CSS属性(display)

1.display属性 <!DOCTYPE html> <html lang"zh-CN"> <head><meta charset"UTF-8"><title>08display属性</title><style>.c1 {background-color: red;/*display: none; !* 让其在页面上不显示 *!*//*display: i…

产品发布系统_【产品发布】第3期|阀门遥控系统

更多精彩&#xff0c;请点击上方蓝字关注我们&#xff01;常熟瑞特电气股份有限公司的阀门遥控系统是一款经典的产品线&#xff0c;包括了全系列的液压执行器&#xff0c;电液执行器&#xff0c;微型动力单元&#xff0c;液压动力泵站&#xff0c;液压电磁阀箱等产品。阀门遥控…

大数据就业前景,分析的太到位了

大数据广泛应用于电网运行、经营管理及优质服务等各大领域&#xff0c;并正在改变着各行各业&#xff0c;也引领了大数据人才的变革。大数据就业前景怎么样&#xff1f;这对于在就业迷途中的我们是一个很重要的信息。 随着大数据时代的到来【这次国家教育部也改革动真格了】&am…

常用集合(List,Set,Map)的基本定义和操作

集合类存放于java.util包中。 集合类存放的都是对象的引用&#xff0c;而非对象本身&#xff0c;出于表达上的便利&#xff0c;我们称集合中的对象就是指集合中对象的引用&#xff08;reference)。 常用的集合类型主要有3种&#xff1a;set(集&#xff09;、list(列表&#x…

多麦克风做拾音的波束_麦克风丨人声应该用动圈话筒还是电容话筒?

无论是在您最喜欢的乐队的纪录片中&#xff0c;还是在电影中那些有关录音棚里的场景中&#xff0c;似乎都存在着一个共同的主题&#xff0c;那就是&#xff1a;歌手们都在使用大振膜的电容麦克风进行录音。我知道人们应该从别人的经验中汲取精华&#xff0c;事半功倍。但是我并…

MYSQL安装与库的基本操作

mysql数据库 什么是数据库 # 用来存储数据的仓库 # 数据库可以在硬盘及内存中存储数据 数据库与文件存储数据区别 数据库本质也是通过文件来存储数据, 数据库的概念就是系统的管理存储数据的文件 数据库介绍 数据库服务器端: 存放数据的主机集群数据库端: 可以连接数据库的任意…