
本文介绍如何使用JavaScript来编写能够解析四则运算表达式的脚本语言
本文源码:用JS编写简单的脚本语言(四则运算)
1. 规则需求
-
支持数字运算,也就是
+ - * /
这几个符号,至于是用*
还是x
大家可以根据喜好自定义。 -
允许出现空格,但数字之间不能有空格,允许
12 + 3.1
但不允许1 2 + 3.1
或12 + 3. 1
-
支持小数点,允许例如:
1-2.222
但不允许1-2.2.2
-
支持括号,且括号必须能够对应闭合,允许例如:
1+(2-3)
但不允许(1+2()-3)
或(1+2(-3)
-
支持负数,且符号后面只能跟着数字,例如:
1--2
1-(-2)
( -12 )
但不允许-(2)
-
除了负号意外的运算符的左右必须是括号或是数字,例如:
1+1
、1+(-2)
但不允许+1-3
-
不允许出现连续的符号,例如:
1++1
或1---1
-
除了以上的运算符、括号、数字、空格,不能再出现其他的字符,例如:
1&-2
2. 表达式分析
我们来看一段表达式的代码:
-20.5 * 2/(6+(3+2*2+12)-9)--1
3. 首先来分析字符
如果将上面的字符分类的话,可以分为:空格、数字字符、符号组成。而符号包括了小数点、运算符、定界符(也就是括号)。
我们将有效内容(排除空格)提取出来,结果大概是像这样的:
[ "-20.5", "*", "2", "/", "(", "6", "+", "(", "3", "+", "2", "*", "2", "+", "12", ")", "-", "9", ")", "-", "-1"]
4. 再来分析运算逻辑
上面代码的运算结果为-1.5625
,而我们又是如何去计算的呢?
小学的时候我们都学过四则运算法则,也就是先乘除后加减,括号先计算。
根据四则运算法则,我们可能会很自然的先看到括号算括号内人内容,但这里我们需要模拟计算机的遍历行为,从左往右一点点计算。
首先计算的是-20.5 * 2
结果是-41
。
接着遇到括号,也就是(6+(3+2*2+12)-9)
这段内容,由于计算(6+(...
的时候又遇到了括号,所以就先得计算(3+2*2+12)
这段内容,计算得到19
。
所以可以吧(6+(3+2*2+12)-9)
看做(6+19-9)
计算结果是12
。
那么前面的计算结果相当于计算-41/12
结果是-2.5625
。
最后计算-2.5625--1
结果就是-1.5625
。
5. 解释器
编写脚本语言,就不得不说到解释器。
解释器功能简单理解,就是将我们写的表达式,能够转成js函数与参数进行执行与计算。
解释器执行流程大概是:
表达式/代码---->分词---->语义分析---->返回结果
在分词与语义分析部分会对我们的写的代码规范进行校验,如果错误会返回异常提示信息。错误一般是两种,一种是语法异常;一种是执行异常。
而在语义解析部分或许会涉及到“抽象语法树(AST)”的相关知识。
那么完整流程大概是这样:
表达式/代码---->分词---->构建语法树---->分析语法树---->执行---->返回结果
如果是编译器的话就是把上面的执行部分变成了字符的替换与生成编译后的文件。
根据这个原理我们可以尝试将-20.5 * 2/(6+(3+2*2+12)-9)--1
构建出一个语法树

可以打开https://astexplorer.net/查看语法树数据结构

6. 分词器的代码实现
我们根据原理先来实现一个分词器:
// 错误显示
function printErrStr(str, i) {
let ss = "";
for (let ii = 0; ii < str.length; ii++)
ss = ss + (i == ii ? '^' : ' ');
return str + '\n' + ss
}
// 格式过滤与语法检测
function tokenCode(str) {
// 保存字符串解析结果值与描述信息
let tokens = [];
let token, currentChar;
// 上一个记录的token值
let prev = {};
// 括号索引统计
let bract = [];
for (let i = 0; i < str.length; i++) {
currentChar = str[i];
if (currentChar == ' ') continue;
if (/[0-9]/.test(currentChar)) {
token = {
type: 'number',
value: currentChar,
};
// 是否存在小数点
let pot = false;
// 是否存在空格
let space = false;
for (i++; i < str.length; i++) {
currentChar = str.charAt(i);
if (/[0-9]/.test(currentChar)) {
if (space) throw new Error(`数字格式不正确 \n索引 ${i},字符 ${currentChar} \n${printErrStr(str, i)}`);
token.value += currentChar;
} else if (currentChar === '.') {
if (space) throw new Error(`数字格式不正确 \n索引 ${i},字符 ${currentChar} \n${printErrStr(str, i)}`);
if (pot) throw new Error(`小数点格式不正确 \n索引 ${i},字符 ${currentChar} \n${printErrStr(str, i)}`);
token.value += currentChar;
pot = true;
} else if (currentChar === ' ') {
space = true;
} else {
i--;
break;
}
}
if (prev.prefix) {
token.value = prev.value + token.value;
tokens.pop();
}
} else if ('+-*/'.includes(currentChar)) {
token = {
type: 'operator',
value: currentChar,
};
//
if (currentChar == '-' && (((tokens.length == 0) || (i > 0 && ((prev.type == 'operator') || (prev.value == '('))))) && !prev.prefix) {
token.prefix = true;
} else if ((i > 0 && prev.type != 'operator' && prev.value != '(') && !prev.prefix) {
} else {
throw new Error(`符号格式不正确 \n索引 ${i},字符 ${currentChar} \n ${printErrStr(str, i)}`);
}
} else if ((currentChar === '(' && prev.value != ')' && !prev.prefix)) {
token = {
type: 'Punctuator',
value: currentChar,
}
bract.push(i);
} else if ((currentChar === ')' && prev.value != '(' && !prev.prefix)) {
token = {
type: 'Punctuator',
value: currentChar
}
if (bract.pop() == undefined) throw new Error(`括号不匹配 \n索引 ${i},字符 ${currentChar} \n ${printErrStr(str, i)}`);
} else {
throw new Error(`无法识别的字符 \n索引 ${i},字符 ${currentChar} \n ${printErrStr(str, i)}`);
}
tokens.push(token);
prev = token;
}
if (bract.length > 0) throw new Error(`索引 ${bract[0]},字符 '${str[bract[0]]}', 括号不匹配 `);
return tokens;
}
原理就是遍历传入的字符串,取出字符去校验匹配类型,用if语句去判断语法规则,将解析成功的信息一一存入数组中。
如果遇到数字则往下遍历拼接,记录空格与小数点数量,如果上一个字符是负数前缀也就是'-'的时候,拼接字符。
如果是运算符的时候,需要注意的是'-'的时候,一下代码
if (currentChar == '-' && (((tokens.length == 0) || (i > 0 && ((prev.type == 'operator') || (prev.value == '('))))) && !prev.prefix)
判断当前符号是否是前缀符:
- '-'号是不是负数前缀
- !prev.prefix 不存在前缀的前提下
- tokens.length==0 如果是首字符
- prev.type == 'operator' 如果前一个是操作符
- prev.value == '(' 如果前一个是开始括号
括号的部分统计直接判断与保存,在闭合括号部分判断括号是否完全闭合,否则视为异常。
测试用例:
for (let i of [
"1+-1",
"1 1+-1",
"11+-1-",
"11+-1}",
"(11+(1)-1)",
"1+(-1)",
"1+(-1.1.1)",
"1+(-1.1 .1)",
"1+(-1 .1 .1)",
"1+(-1 .1.1)",
"1--(-1)",
"1--1",
"--1",
"---1",
"-(1)",
"-(-1)",
" - 1 / 2",
" - 1 / (2+3/4)",
" - 1 * (2+3/4)",
" - 1 * (2+3/-4)",
" - 1 * (2+3/ - 4)",
" -20/(3+( (2-5.9384)+1))*(3-1)+6-8 ",
" 20 /(3+( (2 -5.9384)+1))*(3-1)+6-8 ",
" 20.5*9/(2+(2.99)+0.22)-2.9+(-0.001)*5",
" 20.5*9/(2+(-2.666)+1)-9+(-1)*5",
" -20.5 * 2/(6+(3+2*2+12)-9)--1",
'1.4-1.1',
'2.2+2.1',
'2.2*2.2',
'2.1/0.3',
' (873*477-198)/(476*874+199)',
'2000*1999-1999*1998+1998*1997-1997*1996+2*1',
'297+293+289+209'
]) {
console.info('===============');
try {
console.log(i,tokenCode(i));
} catch (error) {
console.log(i);
console.error(error);
}
console.info('===============');
}

7. 表达式字符串解析
解析部分我们可以不需要使用AST语法树进行,通过两个数组或者叫它栈更加准确吧,用过它记录符号与数值通,然后定义符号优先级判断是否计算。
符号优先级参考:
-1: '('
0: ')'
1: '+'
1: '-'
2: '*'
2: '/'
遍历分好的词组,从左到右把它的字符与值加入到栈,从第二个操作符开始,对比前面的操作符的优先级,条件允许则优先计算。
就拿1+2*(3-4)
作为例子
- 计算的时候是先算
(3-4)
- 然后计算
2*..
- 最后是计算
1+..
对应的栈值类似这样:
符号=["+","*","(","-",")"];
数值=[1,2,3,4];
由于)
优先级小于-
,所以取出值4
、3
,先进行计算得到-1
,同时删除对应符号
此时栈值变成了,
符号=["+","*"];
数值=[1,2,-1];
同理计算*
栈值变成了,
符号=["+"];
数值=[1,-2];
最后得到值是 1+-2
=-1
假如我们把表达式改成1+2*3-4
,其实在遍历到-
的时候可以判断出*
优先级大于+
栈值
符号=["+","-"];
数值=[1,6,4];
所以结果一样从后往前喜欢计算得到值3
所以代码差不多是这样:
function parse(tokens, operator) {
// 记录操作符
let operatorStack = [];
// 记录值
let numStack = [];
// 操作符与括号的优先级映射
let precedence = {
'(': -1,
')': 0,
'+': 1,
'-': 1,
'*': 2,
'/': 2,
};
// 操作符
let opts = Object.keys(precedence);
// 操作符绑定计算函数
operator = operator || {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => a / b,
};
// 判断是否允许执行计算
function allow() {
// 存在两个值栈 与 一个字符栈
return numStack.length >= 2 && operatorStack.length >= 1;
}
// 计算
function count() {
// 取出两个数值
let e2 = numStack.pop();
let e1 = numStack.pop();
// 取出
let op = operatorStack.pop();
// 计算并保存结果值
let fn = operator[op];
if (fn) numStack.push(fn(e1, e2));
}
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token.value == '(') {
operatorStack.push(token.value);
} else if (token.type == "number") {
numStack.push(Number(token.value));
} else if (token.type == "operator" || token.type == "Punctuator") {
// 允许计算 且 上一个优先级大于当前字符优先级 则先计算
while (allow() && precedence[token.value] <= precedence[operatorStack.slice(-1)])
count();
// 追加
// 如果当前符号是反括号 则删除上一个反括号
if (token.value == ')')
operatorStack.pop();
else
operatorStack.push(token.value);
} else {
throw new Error(`未知错误!!`);
}
}
while (allow())
count();
return numStack.pop();
}
测试代码
for (let i of [
"1+-1",
"1 1+-1",
"11+-1-",
"11+-1}",
"(11+(1)-1)",
"1+(-1)",
"1+(-1.1.1)",
"1+(-1.1 .1)",
"1+(-1 .1 .1)",
"1+(-1 .1.1)",
"1--(-1)",
"1--1",
"--1",
"---1",
"-(1)",
"-(-1)",
" - 1 / 2",
" - 1 / (2+3/4)",
" - 1 * (2+3/4)",
" - 1 * (2+3/-4)",
" - 1 * (2+3/ - 4)",
" -20/(3+( (2-5.9384)+1))*(3-1)+6-8 ",
" 20 /(3+( (2 -5.9384)+1))*(3-1)+6-8 ",
" 20.5*9/(2+(2.99)+0.22)-2.9+(-0.001)*5",
" 20.5*9/(2+(-2.666)+1)-9+(-1)*5",
" -20.5 * 2/(6+(3+2*2+12)-9)--1",
'1.4-1.1',
'2.2+2.1',
'2.2*2.2',
'2.1/0.3',
' (873*477-198)/(476*874+199)',
'2000*1999-1999*1998+1998*1997-1997*1996+2*1',
'297+293+289+209'
]) {
console.info('===============');
let ev = '';
try {
ev = eval(i);
} catch (error) {
ev = error;
}
try {
console.log(i, parse(tokenCode(i)), ev);
} catch (error) {
console.error(i);
console.error(error);
}
console.info('===============');
}
效果演示

8. 自定义运算函数
在上面的解析函数parse
中有两个参数第一个tokens
是词组信息,第二个operator
是干嘛的呢?
这里的operator
是用来自定义执行实际的运算函数的。
我们应该知道JS在进行浮点运算的时候回遇到一些bug。
比如:

我这使用了https://gitee.com/baojuhua/lutils/blob/master/src/num/index.js这个函数库来计算数据
最后效果:

本文到此就结束,代码部分大家不一定完全了解,对于编写真正意义上脚本语言其实还有很多需要学习的地方,希望这篇文章能够给大家一些启发。
嘻嘻 ~ d(・∀・*)♪゚
网友评论