Another approach is to turn your string into a an array that can be evaluated as a stack, then do a simple evaluation of that stack. For instance, we might make "10 - 20 + 30 * 2 / 10"
into [10, 20, "-", 30, "+", 2, "*", 10, "/"]
and then evaluate it by sequentially replacing the top two elements of the stack with the value of the current operation applied to them.
This technique is only for the left-to-right operations. It ignores operator precedence, and cannot handle parentheses, or non-binary operations. But it might be enough for your needs.
Here's an implementation:
const evalExpr = (ops) => (expr) => expr
.replace (/([-+*\/])(\s)*(\d+)/g, (_, a, b, c) => c + b + a)
.split (/\s+/)
.map (n => Number(n) || n)
.reduce (
(stack, symbol, _, __, op = ops[symbol]) => op
? [... stack.slice(0, -2), op(...stack.slice(-2))]
: [... stack, symbol]
, []
) [0];
const ops = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => a / b,
};
const evalNumericExpr = evalExpr (ops);
// Test
[
"1 + 1",
"1 - 1",
"1 * 1",
"1 / 1",
"2 + 4 + 7",
"5 - 7",
"5 * 2 + 10",
"10 - 20 + 30 * 2 / 10",
"1 + 5 * 2 + 12 * 2 * 2",
"10 + 13 - 5 * 3 + 12 / 3 + 3"
]
.forEach (expr => console .log (`${expr} ==> ${evalNumericExpr (expr)}`))
The replace
, split
, and map
steps together turn this string into a stack ready for processing. The reduce
step actually processes that array, adding and removing elements on a stack. With "10 - 20 + 30 * 2 / 10"
becoming [10, 20, "-", 30, "+", 2, "*", 10, "/"]
, the reduction would proceed like this:
stack: [], next: 10 // Push 10 onto the stack
stack: [10], next: 20 // Push 20 onto the stack
stack: [10, 20], next: '-' // Pop 10 and 20 from the stack. Push (10 - 20) to it
stack: [-10], next: 30 // Push 30 to the stack
stack: [-10, 30], next: '+' // Pop -10 and 30 from the stack. Push (-10 + 30) to it
stack: [20], next: 2 // Push 2 to the stack
stack: [20, 2], next: '*' // Pop 20 and 2 from the stack. Push (20 * 2) to it
stack: [40], next: 10 // Push 10 to the stack
stack: [40, 10], next: '/' // Pop 40 and 10 from the stack. Push (40 / 10) to it
stack: [4] // For a well-formed expression, the stack now has just
// one element on it, and that's your result.
There are plenty of ways you could extend this. Obviously it's trivial to add new binary operations. We could also add other arity operations to the reduction (although it would be trickier to convert the string to the stack format) by replacing the -2
's in the reduction with -op.length
. If we wanted to handle decimal numbers, we could just change the regex to something like /([-+*\/])(\s)*(\-?\d+(:?\.\d+)?)/g
.
And congratulations to us. We've just written the beginning of a Forth interpreter!
Update
The question specifically asked about how to do this recursively, and I wrote a recursive version which was overall simpler than the above. But then I realized how it could easily be extended to handle parentheses and to respect operator precedence. It's no longer necessarily simpler, but it's an interesting approach, one which we could easily extend with other operators and difference precedence levels:
// Does not test for well-formedness. Will likely return NaN for
// ill-formed expression strings
const evalNumericExpr = (
expr,
[regex, fn, ops] = [
// parentheses
[/\(([^())]*)\)/, (ops, expr, regex) => evalNumericExpr (expr.replace(regex, (_, e) => evalNumericExpr(e))), {}],
// multiplication and division
[/\s*(\-?\d+)\s*([/*])\s*(\-?\d+)/, (ops, expr, regex) => evalNumericExpr (expr .replace (
regex,
(_, a, op, b) => String(ops[op](Number(a), Number(b)))
)), {'*': (a, b) => a * b, '/': (a, b) => a / b}],
// addition and subtraction
[/\s*(\-?\d+)\s*([+-])\s*(\-?\d+)/, (ops, expr, regex) => evalNumericExpr (expr .replace (
regex,
(_, a, op, b) => String(ops[op](Number(a), Number(b)))
)), {'+': (a, b) => a + b, '-': (a, b) => a - b}],
// everything else
[/.?/, (ops, expr, regex) => Number(expr.trim()), {}]
].find(([regex, fn, ops]) => regex.test(expr))
) => fn(ops, expr, regex)
// Test
; [
"1 + 5",
"7 - 2",
"3 * 5",
"21 / 3",
"2 + 4 + 7",
"5 - 7",
"5 * 2 + 10",
"5 * 2 + (3 * 5)",
"10 - 20 + 30 * 2 / 10",
"10 - ((4 * 5) - (5 * 6)) * 2 / 10",
"10 - ((4 * (2 + 3)) - (5 * 6)) * 2 / 10",
"1 + 5 * 2 + 12 * 2 * 2",
"10 + 13 - 5 * 3 + 12 / 3 + 3"
].forEach (expr => console .log (`${expr} ==> ${evalNumericExpr (expr)}`))
The big advantage to this approach is that it would make it easy to extend to any mathematical operators we choose. And there is room for further simplification.
Update 2
I wasn't really happy with the code from the previous update. This version seems cleaner to me and also adds exponentiation. Here's a first pass at it:
const evalNumericExpr = (() => {
const ops = [
[ // grouping
/\(([^()]+)\)/,
(evaluator, subexpr) => evaluator(subexpr)
], [ //exponentiation
/([-+]?\d+)\s*[\^]\s*([-+]?\d+)([^\^]*)$/,
(_, base, exp, rest) => `${base ** exp}${rest}`
], [ // multiplication, divide, remainder
/([-+]?\d+)\s*([*%\/])\s*([-+]?\d+)/,
((ops) => ((_, a, op, b) => ops [op] (Number (a), Number (b))))(
{'*': (a, b) => a * b, '/': (a, b) => a / b, '%': (a, b) => a % b}
)
], [ // addition, subtraction
/([-+]?\d+)\s*([+-])\s*([-+]?\d+)/,
((ops) => ((_, a, op, b) => ops [op] (Number (a), Number (b))))(
{'+': (a, b) => a + b, '-': (a, b) => a - b}
)
]
]
const evaluator = (expr) => Number(ops .reduce(
(expr, [regex, fn]) => regex .test (expr)
? evaluator(expr .replace (regex, (...args) => fn (evaluator, ...args .slice (1))))
: expr,
expr
))
return evaluator
})()
// Test
; [
"1 + 3",
"7 - 2",
"2 * 6",
"12 / 4",
"2 + 4 + 7",
"5 * 2 + 10",
"10 - 20 + 30 * 2 / 10",
"1 + 5 * 2 + 12 * 2 * 2",
"10 + 13 - 5 * 3 + 12 / 3 + 3",
"10 + (13 - 5) * 3 + 12 / 3 + 3",
"5 * (4 + (2 * (1 + 1 + 1)))",
"5 ^ 2",
"5 ^ 2 * 2",
"2 ^ 3 ^ 2", // Note: should parse as `2 ^ (3 ^ 2)`, not `(2 ^ 3) ^ 2`
"2 ^ 3 ^ 2 + 3 ^ 3 * 2",
]
.forEach (expr => console .log (`${expr} ==> ${evalNumericExpr (expr)}`))
We work by associating a regular expression with a function that will be used as a replace
callback alongside that regex. Each such pair is run repeatedly until there are no more matches in the input.
It first handles grouping with parentheses (from the inside out), then exponentiation, then multiplication, division, and remainders, and finally addition and subtraction. This ordering is based on the standard JS operator precedence chart. The parentheses' groupings recurs internally before continuing, and all function recur on the remaining expression. Note that exponentiation, which associates right, has a little extra work to do, including the remainder of the string as a capture group, testing that it doesn't include any exponentiation operators; this might be better written with a negative look-ahead, but I'm not much of a regex expert. Note also that I use the caret (^
) for an exponentiation operator; it should be easy enough to change to a double asterisk (**
) if that's preferred.
For a complex expression, this is how it might proceed:
2 ^ 3 ^ (4 ^ 2 - 5 * 3 + 1) - (((2 + 2) * (2 * 5) ^ 2) + (2 * 5 * 7))
4 ^ 2 - 5 * 3 + 1 // grouping
16 - 5 * 3 + 1 // exponentiation
16 - 15 + 1 // multiplication
1 + 1 // subtraction
2 // addition
2 ^ 3 ^ 2 - (((2 + 2) * (2 * 5) ^ 2) + (2 * 5 * 7))
2 + 2 // grouping
4 // addition
2 ^ 3 ^ 2 - (( 4 * (2 * 5) ^ 2) + (2 * 5 * 7))
2 * 5 // grouping
10 // multiplication
2 ^ 3 ^ 2 - (( 4 * 10 ^ 2) + (2 * 5 * 7))
4 * 10 ^ 2 // grouping
4 * 100 // exponentiation
400 // multiplication
2 ^ 3 ^ 2 - ( 400 + (2 * 5 * 7))
2 * 5 * 7 // grouping
10 * 7 // multiplication
70 // multiplication
2 ^ 3 ^ 2 - ( 400 + 70)
400 + 70 // grouping
470 // addition
2 ^ 3 ^ 2 - 470
2 ^ 9 - 470 // expoentiation
512 - 470 // exponentiation
42 // subtraction