TL;DR
What is the/a best practice approach to avoid if-else/switch statements when dealing with a calculator functionality that may expand at a later time (e.g. currently does addition/subtraction, room to add the modulo function without violating open/closed SOLID principle).
Background
I am working on a calculator program for a Udemy course assignment. The assignment asks us to modify an existing Calculator
class to reduce the amount of if-else/switch statements. The original project was in Typescript, but I've been converting it to Java to see if I can recreate the functionality. Omitting some of the surrounding classes for brevity, the below is the App
class that executes the calculator functions and prints the results.
public class App {
public static void main(String[] args) throws Exception {
Calculator calculator = new Calculator(0);
calculator
.execute(OperationType.add, 10)
.execute(OperationType.add, 20)
.execute(OperationType.subtract, 15)
.execute(OperationType.multiply, 3)
.execute(OperationType.divide, 2)
.execute(OperationType.multiply, 1000);
PrintHandler printer = new PrintHandler(calculator);
printer.printResults();
}
}
Output:
0.0 added to 10.0
10.0 added to 20.0
30.0 subtracted by 15.0
15.0 multiplied by 3.0
45.0 divided by 2.0
22.5 multiplied by 1000.0
-----------
Total: 22500.0
Question
While the code does work, the Calculator's execute
method still has a large switch statement (code below) that routes to different class instantiation. I was attempting to simplify the execute
method by abstracting out actual calculations (addition/subtraction) into separate classes, but I've basically just moved that logic around rather than bypassing the need for a switch statement. Since this model violates the open-closed solid principle, I'm wondering how I can implement a solution that follows solid principles and doesn't require the code to be re-opened in the event of adding a new operation type (e.g. Modulo).
public Calculator execute(OperationType operationType, float newValue) throws Exception {
Operation newOperation;
// Operation is the interface. SpecifiedOperations is the superclass holding the
// nested classes for each operation class, which house the logic for the "calculate"
// method. I could also see putting each operation class in its own class
switch (operationType) {
case add:
newOperation = new SpecifiedOperations.AdditionOperation(currentValue, newValue);
break;
case subtract:
newOperation = new SpecifiedOperations.SubtractionOperation(currentValue, newValue);
break;
case multiply:
newOperation = new SpecifiedOperations.MultiplicationOperation(currentValue, newValue);
break;
case divide:
newOperation = new SpecifiedOperations.DivisionOperation(currentValue, newValue);
break;
default:
throw new Exception("Invalid operation type");
}
currentValue = newOperation.calculate();
operations.add(newOperation);
return this;
}
What I've Tried
In typescript I was able to somewhat achieve my goal via the code below. By mapping the operation type to the type of class, rather than an instance of it, I'm at least removing the need to open any methods and can just modify the map if new operation types come into the equation.
const operationGenerationMapper = new Map([
[OperationType.addition, AdditionOperation],
[OperationType.subtraction, SubtractionOperation],
[OperationType.multiplication, MultiplicationOperation],
[OperationType.division, DivisionOperation],
]);
export function generateOperation(
operationType: OperationType,
currentValue: number,
newValue: number
): any {
let newOperation: Operation;
for (let [key, value] of operationGenerationMapper) {
if (operationType == key) {
return new value(currentValue, newValue);
}
}
return null;
}
I found an interesting post titled Java way to create an object based on enum type that seems to be attempting to get at the same concept, but I'm not experienced enough with Java to modify the code they discussed/provided to my situation. I also wonder if using generics like this is considered "best practice" or just a way to answer the question provided.
I've also tried looking into factory patterns (https://www.youtube.com/watch?v=EdFq_JIThqM, https://www.youtube.com/watch?v=QNpwWkdFvgQ), but I haven't quite understood how (or if) those solutions would apply to my scenario.