TLDR? Github repository is here.
People say you can build a calculator in any programming language. Today I'm going to do it in Vanilla Javascript. I've spent a lot of time using frameworks these days (don't we all) so I think using Vanilla is good way of sharpening my core js.
Prerequisites and Tech: Javascript Knowledge, VS Code, Node.js, Jest
We're going to go very basic. One HTML file, one Javascript file for the calculator class and one file to create an instance and call a method. Each file js file has a test file to match. The calculator will run using alerts in the browser. User inputs a string, gets a result for their calculation if it's valid. Simple.
1) Prep: Make a project folder with a HTML file and a js file.
js_calculator.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calculator</title>
</head>
<body>
Calculator
</body>
<script type="module" src="run_calculator.js"></script>
</html>
Here are some handy VS Code shortcuts. Run them in the file to speed up HTML.
html:5 - generate template html file
src:module - generate script line
You also need:
An empty run_calculator.js file
An empty calculator.js file
2) Prep: Install Node.js and Jest.
So building a calculator properly is a complicated process, so I highly recommended you do some TDD for your more intense functions.
Install Node.js if you haven't, and then run this in the terminal of your project:
npm init -y (or npm init then answer the questions)
npm install jest
With Jest, you'll be able to code more efficiently. When you write tests for your code, you're forced to make code that passes the tests. It speeds up your thinking process, so in TDD we work by building the tests along with pseudo code before the functions.
3) Prep: Restructure the folders for testing.
If you're using testing, I find this folder structure useful. However different companies have different rules, so be prepared to be flexible.
src ->
component/class folder -> component or class
testing folder -> test files
Convention says each test file should be name with in the style of component.test.js so:
calculator.test.js
Tip: I like to make a txt file for pseudo code.
4) Let's make a class!
So there are four main functions your calculator class needs:
An input collection function that converts strings to arrays.
An infix to postfix function that converts the 'human ordered' calculation (infix) to a 'computer readable' calculation (postfix). (more on this later)
A postfix reading function that returns a single integer from the computer readable calculation.
A main function to run all the other functions.
// module.exports = class Calculator {
export default class Calculator {
constructor () {
this.regex = /\d+|[\(\)\+\-\*\//]+/g;
this.operators = /[\(\)\+\-\*\//]+/g;
}
getInputArray = (userInput) => {
}
convertInfixToPostfix = (calcArray) => {
}
runPostfixOperations(postfixArray) {
}
calculate = (userCalculation) => {
};
}
Let's look at each function one by one.
5) Input Collection Function
For this function, I used a regex that detects all numbers and the operators within a string and returns them in an array. (My calculator handles parentheses but not exponents -> expect a change in version 2.)
In TDD we make the tests first,, so I made this test.
test('getInputArray -> (1+1)(1+1) becomes [ ( 1 + 1 ) ( 1 + 1 ) ]', () => {
expect(testCalc.getInputArray('(1+1)(1+1)')).toStrictEqual(['(','1','+','1',')''('1','+','1',')'])
})
My code looked like this
//input collection
getInputArray = (userInput) => {
return userInput.match(this.regex)
//main calculate function:
calculate = () => {
let userCalculation = prompt("Please enter your calculation");
let calcArray = this.getInputArray(userCalculation)
I expect an array like this with my test.
[ '(','1','+','1',')','(','1','+','1',')']
But instead I received:
[ '(','1','+','1',')(','1','+','1',')']
The brackets were joined by the regex. The regex should return combined numbers (e.g. 1000 not 1,0,0,0) so I had to use it, but for the string operators I need to target them once they were in the array. So I made this test:
test('getInputArray -> )( becomes [ ) ( ]', () => {
expect(testCalc.getInputArray(')(')).toStrictEqual([')','('])
})
And came to the conclusion that a flatMap would be the best approach. Flat map allows a call back to be run on each iteration, allowing me to split conjoined brackets.
//input collection
getInputArray = (userInput) => {
//create array from regex + if string has two characters, split it
return userInput.match(this.regex).flatMap((char) => (char.length > 0 && char.match(this.operators) ? char.split('') : char));
}
//main calculate function:
calculate = () => {
let userCalculation = prompt("Please enter your calculation");
let calcArray = this.getInputArray(userCalculation)
6) Convert Infix to Postfix
This function was the real work, spawning several other helper functions. Before embarking on this part of calculator I highly recommend this video:
Education4U - Infix to Postfix
It's not a coding video, rather a math teacher explaining the concept. As I said earlier, infix to postfix is about converting human readable calculations into computer readable code. This is because a computer will run human readable calculations in the wrong order if not given a post fix expression.
It works like this:
- Build an operator stack and an output stack
- Follow a set of rules for every number and operator to make sure the output stack is in the right order.
Rules:
A number should be put straight in the output stack.
For operators, priorities must be followed.
High - ^
Mid - * /
Low - + -Operators of same priority cannot stay together. In case of meeting, pop the top element and add to postfix.
If the top element of stack is higher than current element, pop the top element and add to postfix. If the top element is lower, the higher element can be added.
If there is a left bracket wait for right to appear.
If there is a right bracket, pop everything after the left bracket.
When any rule is broken, pop all numbers that are broken.
E.g. current num is -. stack contains * +. * and + must be popped at this point.
My functions related to infix->postfix are below. It checks each item in the array, pushes numbers and runs conditions on the operators to decide whether the operator can go straight in the stack or triggers any pops.
precedenceSameOrHigher = (stack, newItem) => {
//compare top operators in stack with new item (current iteration)
let topItem = stack[stack.length -1]
const operationRanks = {
"^": 3,
"*" : 2,
"/": 2,
"+": 1,
"-": 1,
"": 0,
}
return operationRanks[topItem] >= operationRanks[newItem]
}
isOperator = (item) => {
return item.match(this.operators)
}
pushBracketOperators = (stack, postfixResult) => {
//slice our all relevant operators and delete left bracket, append postfixResult
let bracketOperators = stack.splice(stack.indexOf("("), stack.length - 1);
bracketOperators.shift()
return [...postfixResult, ...bracketOperators]
}
convertInfixToPostfix = (calcArray) => {
//opStack acts as a stack, the end is the top, and the start is the bottom (lifo)
let postfixResult = []
const opStack = []
let bracketOpen = false;
for (let current in calcArray) {
let c = calcArray[current];
let temp = parseInt(c);
//test if number
if (Number.isInteger(temp)) {
postfixResult.push(temp)
continue
}
//test for parentheses
if(c === "(") {
opStack.push(c)
bracketOpen = true;
continue
}
if (bracketOpen && c === ")") {
postfixResult = this.pushBracketOperators(opStack, postfixResult)
bracketOpen = false
continue
}
if(this.isOperator(c) && bracketOpen && !this.precedenceSameOrHigher(opStack, c)) {
opStack.push(c)
continue
}
if(this.isOperator(c) && this.precedenceSameOrHigher(opStack, c)) {
postfixResult.push(opStack.pop())
//keep comparing and empty stack of rule breaking items
while(this.precedenceSameOrHigher(opStack, c)) {
postfixResult.push(opStack.pop())
}
opStack.push(c)
continue
}
if(this.isOperator(c) && !this.precedenceSameOrHigher(opStack, c)) {
opStack.push(c)
continue
}
}
if(opStack.length > 0 ) {
postfixResult = [...postfixResult, ...opStack.reverse()]
}
return postfixResult
}
For my stacks, I used javascript arrays, treating the final element as the top of the stack. I could then use pop() and push() to return an array of elements in the correct order.
For a problem this complicated, it's important to test each expected behavior as well as testing final output, i.e. divide the problem.
test('convertInfixToPostfix -> postfix collects all numbers and opstack collects all operators on loop', () => {
expect(testCalc.convertInfixToPostfix(['1', '+', '1', '*', '1', '/','1','-', '1'])).toStrictEqual(
{0: [1,1,1,1,1],
1: ['+','*','/','-']})
})
The test above wouldn't work on the final function, but it helped me confirm that the numbers were being placed directly in the postfix result while the operators were being gathered in the stack.
The task was too big, so I divided the problem into smaller goals:
- put the numbers and operators into the correct stacks
- decide when to push or pop the operators
Once the first goal was accomplished, I could test the second goal like this with some simple output.
test('convertInfixToPostfix -> 1 * 2 + 3 -> 1 2 * 3 +', () => {
expect(testCalc.convertInfixToPostfix(['1', '*', '2', '+', '3'])).toStrictEqual([1, 2, '*', 3, '+'])
})
I could then use the calculation in the video above as a input as well. Handling parens needed its own separate function, pushBracketOperators( ), probably the most complicated part of testing. The key thing about this calculation is not the parens, but the fact that you need to pop every element that clashes with your current number.
test('convertInfixToPostfix -> Infix 1+2/3 * (4+5) - 6 -> 1 2 3 / 4 5 + * + 6 - ', () => {
expect(testCalc.convertInfixToPostfix(['1','+', '2' ,'/','3','*','(','4','+','5',')','-','6'])).toStrictEqual([1, 2, 3, '/', 4, 5,'+', '*', '+', 6, '-'])
})
7) Run the calculations in postfix order and return the result.
Making the postfix list needed a stack, so it shouldn't be too surprising that actually running the calculations involves a stack as well. The main function runPostfixOperations() loops over the array generated by convertInfixToPostfix() and builds a stack, running operations after each 2 numbers is added to the stack. It keeps going until the stack contains one number, the result.
runOperation = (operator, base, newnum) => {
switch (operator) {
case "+":
return parseFloat(base) + parseFloat(newnum);
case "-":
return parseFloat(base) - parseFloat(newnum);
case "*":
return parseFloat(base) * parseFloat(newnum);
case "/":
return parseFloat(base) / parseFloat(newnum);
default:
return 0;
}
};
runPostfixOperations(postfixArray) {
let stack = []
for (let current in postfixArray) {
let c = postfixArray[current]
if(Number.isInteger(c)) {
stack.push(c)
continue
}
if(this.isOperator(c)) {
let num1 = stack.pop();
let num2 = stack.pop();
stack.push(this.runOperation(c, num2, num1))
continue
}
}
return stack[0]
}
test('calculate -> 1+2/3 * (4+5) - 6', () => {
expect(testCalc.calculate('1 + 2 / 3 * ( 4 + 5 ) - 6')).toStrictEqual(1)
})
So, that's how you build a calculator. Use a regex for the input, use infix to postfix to get it ready, and use a loop to return the result. Thanks for reading.
Top comments (0)