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
,这里不做深入
逆波兰式
可以参考下列文章
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
}
- 当操作符是
)
的时候,就持续弹出opStack
到resultStack
,直到遇到(
,(
不推入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。
-
该实现也存在一些问题:
- 并非完全用编译原理的思想去实现,而是自己摸解决方案,先实践,后了解问题。
- 并没有参考太多别人的实现,有点闭门造车的感觉。
-
思考:
- 对于
()
的处理或许可以使用递归的方式,进入()
之后重新开始一个新的表达式解析 - 思考不够全,单元测试覆盖不够,许多坑还不知道在哪儿
- 对于
总之:文章到此为止,有很多不够详细的地方还请见谅,多多交流,共同成长。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。