OpenCalc — React Native — Deep Dive (Part 2)

Branko Blagojevic
ml-everything
Published in
14 min readJul 23, 2018

--

This is part 2 of a 2 part series on OpenCalc, an open-source mobile calculator built with react-native, javascript and flow. The first part dealt with the design and UI components and a previous post addressed writing the app the app market in general. The second part will deal with the calculation and validation. OpenCalc is available on iOS and Android.

OpenCalc in action

The main controller has a property called brain, which is an instance of CalculatorBrain. The controller calls the following brain functions:

brain.clear()        // clears the brains queue
brain.setItem(button) // adds an item to the queue
brain.deleteLast() // deletes last item in the queue
brain.getDisplay() // returns text display of queue
brain.getResult() // returns the result from evaluating the queue
That’s why testing is important

The brain’s main purpose is to deal with a queue of Operations. Operations are defined in the Operations file.

Operations

Operations.js serves many functions related to defining, storing and using Operations. One of the main purposes of the Operations file is to define the Operation class.

The Operation class

The Operation constructor is below:

constructor(
stringVal: string,
operationType: number,
operationSubType: number,
val: any,
priority: ?number,
operationArgs: ?Set<number>
)

The stringVal is just the string representation in the display. The operationType and operationSubType just hold a value to help the Validator determine if an operation is legal and how to handle each. The types and sub-types are just enum values.

The val is used to store a function related to the operator. The priority is the priority of operations (e.g. PEMDAS). For instance, the priority for + and * is 2 and 4, respectively. So when the calculator evaluates 1 + 2 * 3, it knows to do * first as it is the higher priority. Finally, the operationArgs is just a set of enums that store special cases. For instance, pi is a number, but it should be printed as a string rather than the numeric representation.

Storing all the valuations available in the calculator

Operations is a dictionary of various operations available to the calculator. It’s easy to add a new operation or change how one works from this screen. Example of an operation :

'+': Operation(
stringVal = '+',
operationType = OperationType.Operation,
operationSubType = OperationSubType.BinaryOp,
val = function(x, y) { return x + y; },
priority = 2.0
)

“+” is an binary operation (has two inputs) who’s value is equal to x + y. It has a priority of 2.0. The key to the dictionary is “+” which is also the text of the button on the calculator view. So the controller passes through the “+” to the brain, who then knows the associated operation to add to its queue.

The highest and lowest priority items are parenthesis:

'(': new Operation('(', OperationType.Parenthesis, OperationSubType.ParenthesisOpen, null, 7.0),  ')': new Operation(')', OperationType.Parenthesis, OperationSubType.ParenthesisClose, null, -7.0)

Parenthesis are treated separately by the brain. The queue is scanned from left to right and the parenthesis priorities are applied to all values following the parenthesis. So an open/close parenthesis cancel out the priority change.

For instance, remember that the priority for + and * are 2 and 4, respectively:

queue:                   1 + 2 * 3
original priorities: 2 4
original evaluation: 2 * 3 => + 1 => 7
queue: (1 + 2) * 3
original priorities: 2 4
parenthesis priorities: 7 -7
revised priorities: 9 4
revised evaluation: 1 + 2 => * 3 => 9

The function evaluateParenthesis in the CalculatorBrain performs this operation are returns a queue with parenthesis removed and operator priorities revised.

Generate new Operation objects

The function newOperation is used by the brain to generate new objects to add to its queue. It takes three inputs and returns an Operation:

function newOperation(
opString: string,
queue: ?Array<Operation>,
operationArgs: ?Array<number>
): Operation { ... }

It gets passed along a queue, which is just an Array of Operation type. This is required because of overloaded operations. The first thing the newOperation function does is check for overloaded operation. An overloaded operation is an operation which can have multiple meanings depending on the prior operation in the queue. For instance, “-” can mean minus or negative. Most calculators have a separate button for minus and negation, but I didn’t want to give up the real estate.

The only overloaded operation I have is the “-”, which can either be a binaryOp (e.g. 1–2) or a unaryOp (e.g. 1 * -2):

const OperationsOverloaded = Object.freeze({
'−': Object.freeze({
UnaryOp: 'neg',
BinaryOp: '−'
})
});
function getOverloadedOperation(
opString: string,
queue: ?Array<Operation>
) {
const opOverload = OperationsOverloaded[opString];
if (opOverload && queue) {
const lastOp = queue[queue.length - 1];
if (lastOp && isInArray(
lastOp.operationSubType,
Array(
OperationSubType.Constant,
OperationSubType.ParenthesisClose,
OperationSubType.BackwardUnaryOp)
)
) { return opOverload.BinaryOp; }
else {
return opOverload.UnaryOp;
}
}
return opString;
};

The above getOverloadedOperation function checks the operationSubType of the last operation in the queue. If its a constant, closed parenthesis or backward-unary-op, then it knows from the context that the “-” is a binary-operation (minus). Otherwise its a unary operation (negation).

Example below:

What type of operation is "-"?inputs: 1 {-} 
queue: [constant]
result: binaryOp
inputs: 1 * {-}
queue: [constant, binaryOp]
result: unaryOp

Round to zero

The roundToZero function is a wrapper around geometric operations (e.g. sin, cos, tan). So for instance, the function for the Operation cos maps to:

function(x) {return roundZero(Math.cos, x)}

This is required since the apps is using javascript Math module and floats for our geometric functions. For instance, Math.sin(Math.PI) === 1.22464e-16. The roundZero function addresses this, although not perfectly. General rounding is handled elsewhere in the app but it only rounds the final result. Besides, pi is represented as an approximation anyway and the value of sin(pi) is actually 0.

The roundZero function is below:

function roundZero(func, x) {
const out = func(x);
return (Math.abs(out) < Number.EPSILON) ? 0 : out;
}

Calculator Brain

The brain is where most of the heavy lifting takes place. It’s also the largest file, coming in at ~250 lines. The full implementation can be found here.

Despite its length and complexity, the brain only does two things:

  1. Manages the queue of operations
  2. Generates displays for the controller based on the queue

Queue management

The queue is just an Array of type Operation. The functions that alter the queue are below:

brain.clear()            // clears the brains queue
brain.setItem(button) // adds an item to the queue
brain.deleteLast() // deletes last item in the queue

Clear simply clears the queue. The setItem function appends an Operation to the queue and deleteLast alters the queue by removing the last effective operation from the queue.

Setting items

An item is passed to the brain as a string, based on the text value of the button used to trigger the event. Then the setItem function creates a new Operation value from the function in the Operations file discussed in the last section. But depending on the operationType of the Operation, the brain passes the operation to different functions.

There are four operationTypes that can be passed along: equals, constant, operation and parenthesis. The setItem function checks the operationType and goes out to a separate function that checks the validity of the operation and returns the actual operation to be added to the queue. It then adds this Operation to the queue.

setItem(item: string) {
let op: Operation = newOperation(item, this.queue);
let opToAdd: ?Operation;
let cleared = false;
if (op.operationType == OperationType.Equals) {
opToAdd = this.setEquals(op);
cleared = true;
} else if (op.operationType === OperationType.Constant) {
opToAdd = this.setOperand(op);
} else if (op.operationType === OperationType.Operation) {
opToAdd = this.setOperator(op);
} else if (op.operationType === OperationType.Parenthesis) {
opToAdd = this.setParenthesis(op);
}
if (opToAdd) {
this.queue.push(opToAdd);
if (opToAdd.operationArgs.has(OperationArgs.AddParenthesis)) {
this.queue.push(newOperation('('));
}
this.cleared = cleared;
}
}

Setting parenthesis

The setParenthesis function is the simplest of the four. It just goes out to the Validator that determines the validity of the parenthesis and, if valid, it returns the Operation passed in. Parenthesis are not always valid (e.g. a closed parenthesis without an earlier open parenthesis), so determining validity is the Validators job. I’ll speak more about the Validator later.

setParenthesis(op: Operation) {
if (Validator.validParenthesis(op, this.queue)) {
return op;
}
return null;
}

Setting Operator

For setting an operator, we just need to reach out to the Validator and provide the operator and context and determine if the last operator needs to be replaced and if its a valid operator. The last operator may need to be replaced. For instance, if a user types in “1 + *” we would want the final output to be “1 *” as the * was meant to replace the prior + entered.

setOperator(op: Operation): ?Operation {
if (Validator.replaceOperator(op, this.queue)) {
this.queue.pop();
}
if (Validator.validOperator(op, this.queue)) {
return op;
}
}

Setting equals

All the functions go out to the Validator to determine validity of operation and setEquals is no exception. The only time an equals operation is invalid is if the queue length is less than 2. For instance, if you type in “1 =”, the result would be nothing since there are no operations to perform. So allowing an equals would just clear the queue, which is not what we want. Note that this isn’t perfect as you can input “sin(cos(“ and then equals, which would just clear the input.

Normally though, the equals just evaluates the queue, and places the result in the display field. The way queues are evaluated is through the brain getResult function. The problem with that function is that it returns a string. So our setEquals needs to evaluate the queue, replace the commas and return the result as an operator to be added to the queue. It must also clear the queue.

I don’t like the fact that I have no function to return a numeric representation of the result, and if I were to do it again, I would add that and let the conversion to string happen elsewhere.

setEquals(op: Operation): ?Operation {
if (Validator.validEquals(op, this.queue)) {
const strResult: string = this.getResult();
const result: string = strResult.replace(/,/g, '');
const opArgs: Array<number> = new Array(OperationArgs.Cleared);
const opToAdd = newOperation(result, this.queue, opArgs);
this.clear();
return opToAdd;
}
}

Setting Operand

An operand is a value that gets operated on (e.g. a constant). Like the other set functions, the setOperand function goes out to the Validator to validate the operator based on its context. Since I consider a decimal point an operand, there could be times where you want to ignore an operation because it’s not valid (e.g. “1.1.”).

If the operation is valid, then we need to consider the last operation. If the last operation was a constant, we need to append our value to that constant. For instance, if the queue already has a “1” and another “1” gets added, we would want a single Operation in the queue for “11”.

One exception to this rule is if the queue was just cleared. In the case, we don’t want to append our new constant to the queue constant, but instead replace the original constant and use this one.

The other exception is if the constant is not parseable. For instance if someone types in “π” and then “1” we wouldn’t want to see “π1” but just have “1” replace the π.

Below is the full implementation of setOperand:

Evaluating the Queue

The calculator also evaluates the queue. This means it takes the queue of operations and calculates the result. This is done through the getResult which returns a string representing the text value of the evaluated operations. getResult is called every time a button is pressed on the calculator. I didn’t bother performing any cacheing or other optimizations because it was unnecessary and I didn’t want to introduce the complexity.

The first thing getResult does is separate the queue into numbers and operations. Next it evaluates the parenthesis and revises the operations to reflect the surrounding parenthesis. It then calls evaluate, passing along the numbers, operations and any accumulated value.

Evaluate

Evaluate is a recursive function that keeps an accumulator.

Consider the following input: 1 + 2 * log(3) / 4. The following arrays would be passed along as numbers: [1, 2, 3, 4], ops: [+, *, log, /], acc: None]. evaluate would perform the following steps:

  1. Zip up the operations with their indexes twice. The first value represents the operation index and the second value represents the number associated with the operation. So in our example, our zipped queue would look like: (0,0,+), (1,1,*), (2, 2, log), (3,3, /). The tuple (0, 0, +) means that the + operation is the 0th operation and operates on the 0th number as its first input.
  2. The next step is to go through all the ops, look for unary ops and adjust the number indexes of any tuples that follow the unary ops by one. So after doing this, our zipped index looks like (0,0,+), (1,1,*), (2, 2, log), (3,2, /). You can see that the last operation now acts on the 2nd number index.
  3. Find the max priority operation. If its a unary op and there are two operations with equal priorities, give preference to the right most priority (e.g. sin cos 0 === sin (cos 0)). Otherwise, if priorities are the same for two operations, perform operations from left to right (e.g. 1 * 2 / 3 === (1 * 2) / 3). The highest priority in our example is log.
  4. Unpack the zipped operations into their components: the operation index, number index and operation. Pop the number and operation out of their respective queues, and try and reference the relevant number(s) based on the the number index. If it’s a unary op, just use the first number index, or if its binary, use both. In our example: log(3) => 0.477
  5. Update the accumulator and pass along the accumulated value, remaining numbers/operation arrays back into the evaluate function: numbers: [1, 2, 0.477, 4], operations: [+, -, /], acc: 0.477

Get display

The getDisplay function just maps over the queue and returns the string representation. It also formats the numbers to show commas or scientific notation.

The brain makes use of the Validator object and a few functions from the Utils file.

Utils

The Utils file has a number of somewhat general functions. Below is the entire list.

zip                    // e.g. f((a,b), (c,d)) => ((a,c), (b,d))
zipWithIndexTwice // e.g. f(a,b) => ((0,0,a), (1,1,b))
lastOrNull // e.g. f([]) => null
isNumeric // e.g. f("123.a") => False
isInArray // e.g. f(1, [2,3,1]) => True
decimalPlaces // e.g. f("123.45") => 2
multiply // e.g. f(1.2, 3.45) => 4.14
useExponential // e.g. f(123456789) => True
numberWithCommas // e.g. f(123456789) => 123,456,789

These are mostly straight forward but multiply and numberWithCommas are fairly interesting if not hacky and worth going into.

Multiply

As you may have guessed, the multiply function just takes two number inputs and multiplies them together returning a number.

export function multiply(a: number, b: number): number {
const aDecimals = decimalPlaces(a.toString());
const bDecimals = decimalPlaces(b.toString());
const maxDecimals = Math.max(aDecimals, bDecimals);
const multiplier = Math.pow(10, maxDecimals);
const divisor = Math.pow(10, (maxDecimals * 2));
const result = (a * multiplier * b * multiplier) / divisor;
return result;
}

The multiply function is required to deal with floating point representation by javascript. For instance:

js> .1*.2
0.020000000000000004

There are probably some types to represent decimals, but since this just applies to multiplication I just wrote my own simply multiply function.

The multiply function can be best described with an example

multiply(0.1, 0.23)
a = 0.1; b = 0.23
aDecimals = 1; bDecimals = 2
maxDecimal = max(1,2) = 2
multiplier = Math.pow(2) = 100
divisor = Math.pow(10, 2 * 2) = 10000
result = ((0.1 * 100) * (0.23 * 100)) / 10000
= (10 * 23) / 10000
= 230 / 10000
= 0.023

The code is below:

export function multiply(a: number, b: number): number {
const aDecimals = decimalPlaces(a.toString());
const bDecimals = decimalPlaces(b.toString());
const maxDecimals = Math.max(aDecimals, bDecimals);
const multiplier = Math.pow(10, maxDecimals);
const divisor = Math.pow(10, (maxDecimals * 2));
const result = (a * multiplier * b * multiplier) / divisor;
return result;
}

Numbers with commas

The numberWithCommas function takes a string input and a round boolean that defaults to true. The function then converts the string back into a number. There is a native javascript toLocaleString but it does not work on Android.

If the round boolean is set to true, the number is first rounded based on the MaxPrecision in the Configs file.

It then checks whether the number should be displayed as an exponential through the useExponential function. If so, it’ll just return the exponential representation.

Otherwise, the function splits the string representation by the decimal places. The idea is that we want to add commas only to the number to the left of the decimal place (e.g. 1234.5678 => 1,234.5678). It then uses a regex to find where commas should go and inserts them accordingly.

Finally it joins the head (left of decimal) and tail (right of decimal) and returns the string representation.

The full implementation is below.

export function numberWithCommas(x: string, round: ?boolean = true) {
const xNumber = Number(x);
const xString = (
round &&
(decimalPlaces(x) > Configs.MaxPrecision)) ?
xNumber.toFixed(Configs.MaxPrecision) : x;

const parseRegex: RegExp = /\B(?=(\d{3})+(?!\d))/g
if (useExponential(xNumber)) {
return xNumber.toExponential(Configs.ExponentialDecimalPlaces);
} else {
// toLocaleString does not work in Android
const parts = xString.split(DECIMAL);
const head = parts[0].replace(parseRegex, COMMA) || '0';
const tail = (parts.length > 1) ? parts[1].toString() : '';
const decimalJoin = (xString.indexOf(DECIMAL) < 0) ? '' : DECIMAL;
return head + decimalJoin + tail;
}
}

The only major part of the calculator left is the Validator which validates if an input is valid or not based on the queue.

Validator

The Validator object validates the input given the queue. For instance:

Validator.validOperator(op: "+", queue: [1, +, 1]) => True
Validator.validOperator(op: "*", queue: [sin]) => False
Validator.validDigit(digit: 1, queue: [2]) => True
Validator.validDigit(digit: 1, queue: [2, %]) => False
Validator.validEquals(op: "=", queue: [1, +, 1]) => True
Validator.validEquals(op: "=", queue: [1, +]) => False
Validator.validParenthesis(op: ")", queue: [(, 1, +, 1]) => True
Validator.validParenthesis(op: ")", queue: [1, +, 1]) => False

If the validator says that an operation is not valid, the brain just ignores the input.

On one hand, the validator nicely encapsulates the function of validating whether an input is okay. But on the other hand, it’s just a series of if statements based on every conceivable scenario I can imagine. I didn’t sit down and think of all the scenarios, but updated it over time as I found weird inputs that worked or inputs that were proper that didn’t work. It’s tough to understand without examples so I found it useful to write tests.

The other function is replaceOperator which just returns if the last operator in a queue should be replaced based on the operator provided.

For instance:

Validator.replaceOperator(op: "*", queue: [1, +]) => True
Validator.replaceOperator(op: "*", queue: [1]) => False

The first example, the * should replace the +. In the second example, it should not. This allows someone to replace the operator they just wrote in without the need to use backspace.

Full implementation is below.

Final Thoughts

Writing an natural calculator that mimics Google’s calculator is not trivial. The actual evaluation of the queue can be represented rather elegantly through two stacks. Now begin to include orders of operations and it gets a bit more complicated. And when you begin to include a wider range of operations like backward unary operations (e.g. %, !) and overloads (e.g. “-” meaning negative and minus), then it gets more complicated still. And then there are validating entries are valid, floating point representation and appropriate rounding, use of exponentials and string representations of numbers.

The difficulty explains why many calculators don’t bother with these functions. A few top calculators on the iPad app store don’t even bother validating input so they let you do whacky calculations like “1 + * 4” and just don’t display any result if it doesn’t make sense.

Overall this experience has led me to greater appreciate the Google calculator. and the developers that designed it. There are great depths to their design that I didn’t even attempt to mimic. For instance, 10,000! works on the Google calculator (the answer is 2.84E+35659). So they are obviously working with a numeric class much larger than anything practical. And the calculator doesn’t really round. For instance, 2/3 results if 0.66666… and you can scroll the result to the right so it becomes …666666666666E-11 and so on, up to an arbitrary level of precision.

But the most rewarding part would be designing and developing an app from scratch.

--

--