2

TL;DR

In apex, is it possible to get around the use of if-else/switch statements to supply unique code via an enum or other parameter? Something like storing functions or class instances in maps or enums?

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, and I was able to convert it to Java via support with this stack overflow question. I'm now attempting to do convert the functionality to Apex.

In Typescript, my solution was something like this:

Calculator Class's execute method:

  execute(operation: OperationType, newValue: number) {
    let newOperation = generateOperation(operation, this._currentValue, newValue);
    this._currentValue = newOperation.calculate();
    this._operations.push(newOperation);
    return this;
  }

Source for generateOperation method:

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;
}

In Java, my solution was like this:

Calculator execute class:

public Calculator execute(OperationType operationType, float newValue) {

    Operation newOperation = new Operation(this.currentValue, operationType, newValue);
    this.currentValue = newOperation.calculate();
    operations.add(newOperation);

    return this;
}

OperationType enum:

public enum OperationType {
    add("added to") {
        public float calculate(float value1, float value2){
            return value1 + value2;
        }
    },
    subtract("subtracted from") {
        public float calculate(float value1, float value2){
            return value1 - value2;
        }
    },
    multiply("multiplied by") {
        public float calculate(float value1, float value2){
            return value1 * value2;
        }
    },
    divide("divided by") {
        public float calculate(float value1, float value2){
            return value1 / value2;
        }
    };

    public final String joinerText;

    OperationType(String joinerText){
      this.joinerText = joinerText;
    }

    public abstract float calculate(float value1, float value2);

}

Question

How can I create a structure in Apex that allows for a variable parameter to come in (such as a string or enum) and unique functionality being returned (e.g. addition, subtraction), without relying on if-else/switch statements. My reason for wanting to avoid these statements is to see if apex supports a solution that adheres to the Open/Closed SOLID principle, and just more scalable code in general.

An example of what the "problem" code could look like:

public Calculator execute(OperationType operationType, float newValue) throws Exception {

    switch (operationType) {
        case add:
            return this.currentValue + newValue;
            break;
        case subtract:
            return this.currentValue - newValue;
            break;
        case multiply:
            return this.currentValue * newValue;
            break;
        case divide:
            return this.currentValue / newValue
            break;
        default:
            throw new Exception("Invalid operation type");
    }
}

Thoughts/Ideas

The only apex-based solution I can think of is to just make a new method for each new calculation type (add(), subtract(), etc.), but that doesn't really answer my original question. One could also offload the switch statement to another class or function, but that just moves the switch, it doesn't eliminate it.

3 Answers 3

5

You can't add functions to enums, like Java, but we can nevertheless use them. We can do this by either interfaces or abstract or virtual classes, much as you would in Java.

Here's an implementation with interfaces:

public class Calulator {
  public interface Operation {
    Decimal calculate(Decimal a, Decimal b);
  }
  public class Add implements Operation {
    public Decimal calculate(Decimal a, Decimal b) {
      return a + b;
    }
  }
  public class Subtract implements Operation {
    public Decimal calculate(Decimal a, Decimal b) {
      return a - b;
    }
  }
  public class Divide implements Operation {
    public Decimal calculate(Decimal a, Decimal b) {
      return a / b;
    }
  }
  public class Multiply implements Operation {
    public Decimal calculate(Decimal a, Decimal b) {
      return a * b;
    }
  }
  public enum MathOperation {
    ADD,
    SUBTRACT,
    MULTIPLY,
    DIVIDE
  }
  public static Map<MathOperation, Operation> operations = new Map<MathOperation, Operation>{
    MathOperation.ADD => new Add(),
    MathOperation.SUBTRACT => new Subtract(),
    MathOperation.MULTIPLY => new Multiply(),
    MathOperation.DIVIDE => new Divide()
  };
  public static Map<MathOperation, String> operationJoiners = new Map<MathOperation, String>{
    MathOperation.ADD => 'added to'
    MathOperation.SUBTRACT => 'subtracted by',
    MathOperation.MULTIPLY => 'multiplied by',
    MathOperation.DIVIDE => 'divided by'
  };
  Decimal currentValue = 0;
  public Decimal performOperation(MathOperation operation, Decimal value) {
    Decimal result = operation.calculate(currentValue, value);
    System.debug(currentValue + ' ' + operationJoiners.get(operation) + value + ' equals ' + result);
    currentValue = result;
    return result;
  }
}

As an abstract implementation:

public class Calculator {
  Decimal currentValue = 0;
  public abstract class Operation {
    abstract String joiner();
    abstract Decimal doOperation(Decimal a, Decimal b);
  }
  public class AddOperation extends Operation {
    public override String joiner() {
      return ' added to ';
    }
    public override Decimal doOperation(Decimal a, Decimal b) {
      return a + b;
    }
  }
  public class SubOperation extends Operation {
    public override String joiner() {
      return ' subtracted by ';
    }
    public override Decimal doOperation(Decimal a, Decimal b) {
      return a - b;
    }
  }
  public class MultOperation extends Operation {
    public override String joiner() {
      return ' multiplied by ';
    }
    public override Decimal doOperation(Decimal a, Decimal b) {
      return a * b;
    }
  }
  public class DivideOperation extends Operation {
    public override String joiner() {
      return ' divided by ';
    }
    public override Decimal doOperation(Decimal a, Decimal b) {
      return a / b;
    }
  }
  public enum OperationType {
    ADD,
    SUB,
    MULT,
    DIVIDE
  }
  static Map<OperationType, Operation> operations = new Map<OperationType, Operation> {
    OperationType.ADD => new AddOperation(),
    OperationType.SUB => new SubOperation(),
    OperationType.MULT => new MultOperation(),
    OperationType.DIVIDE => new DivideOperation()
  };
  public Decimal doOperation(OperationType operator, Decimal value) {
    Operation desiredOperation = operations.get(operator);
    Decimal result = desiredOperation.doOperation(currentValue, value);
    System.debug(currentValue + desiredOperation.joiner()+ value + ' equals ' + result);
    currentValue = result;
    return result;
  }
}

As I said, this could also be a virtual class, but that'd have the same effect.

5
  • sfdcfox sweeping in again with the expert-tier knowledge! Thank you! Out of curiosity, do you know if the class initialization in the operations map you created could ever be configured to include passing class constructor parameters?
    – Jwok
    Jul 27 at 23:06
  • 1
    @Jwok Those are already constructed, but you could choose to, say, use Type to map the various types of operators, then use Callable or another interface to pass in parameters, potentially dynamically. There's a few different options available.
    – sfdcfox
    Jul 27 at 23:32
  • A much nicer way to "pass in parameters", as part of construction, without the need to use Callable or any other interface is to use JSON.deserialize and to have the parameters stored as attributes of the class instance. You get clean rype-checking and using strict even ensures every property in the JSON is valid for the given target class.
    – Phil W
    Jul 28 at 6:10
  • @sfdcfox typo: operation.operate should be operation.calculate
    – javanoob
    Jul 28 at 16:21
  • 1
    @javanoob Thanks, I was a bit on autopilot writing that code.
    – sfdcfox
    Jul 28 at 20:34
7

I think it's good to keep in mind that SOLID is a list of principles, and not gospel.

That said, if you follow the Open/Close principle through to its conclusion (where adding a new operation should not modify any existing class), you'll end up with a solution that takes advantage of the Type class.

The Type class is the closest thing we have to reflection in Apex, and with a little planning/convention, it's not that bad to write.

public class Calculator {
    public interface Operation {
        Decimal calculate(Decimal a, Decimal b);
    }

    public static decimal doSome(Type operationType, Decimal arg1, Decimal arg2){
        // The Type class is the secret sauce here
        // Given a "Type", we can make a new instance of it
        // newInstance() always returns an "Object" though, so we need to cast

        // We can omit the outer class name here because we're in the same class
        //   that the inner class (well, interface) is defined in
        return ((Operation) operationType.newInstance()).calculate(arg1, arg2);
    }
}
// Each operation is its own class, so we're always extending and
//   never modifying
public class Addition implements Calculator.Operation {
    public override Decimal calculate(Decimal a, Decimal b) {
        return a + b
    }

and in usage

// The <apex class name>.class property (inherited from the Object class,
//   I believe) gets us a "Type" instance that we can instantiate
System.debug(Calculator.doSome(Addition.class, 2, 2));

Using Addition.class gets you a compile-time type check so that you're guaranteed to never use an operation that hasn't been defined. If you want/need a little more flexibility, or to be a little closer to the Javascript/Typescript, then you can just as easily use Type.forName().

// Using a string instead of a type
public static decimal doSome(String operationType, Decimal arg1, Decimal arg2){
    // Using the Safe Navigation Operator to guard against an invalid
    //   type name
    Operation targetOp = Type.forName(operationType)?.newInstance();

    return targetOp == null ? null : targetOp.calculate(arg1, arg2);
}
2
  • 1
    Come to think of it, this is quite similar to the Strategy Pattern. Construct an operation instance in your client and pass that in instead of passing a Type or String (and updating the doSome() signature appropriately), e.g. Calculator.doSome(new Addition(), 2, 2);, and it looks even more like the Strategy Pattern.
    – Derek F
    Jul 28 at 11:33
  • +1 on Strategy pattern. I looked it up after seeing your comment and this is pretty much what I was going for. For anyone interested this youtube video by Geekify is a pretty good summary of the pattern.
    – Jwok
    Jul 28 at 20:50
0

Thanks so much to everyone for your help. I've marked the accepted answer, and for anyone curious, I posted the final code below. Using the Type class was the key in implementing this solution. To add new operations (multiply, divide, modulo) you'd just add the new sub-class extending the Operation abstract class, and adding it's type to the OPERATION_MAP map.

Edit: Apparently this is called the "Strategy Pattern". This youtube video by Geekify is a helpful tutorial of how it works.

Calculator

public with sharing class Calc_Calculator {

    List<Calc_Operation.Operation> operationList = new List<Calc_Operation.Operation>();
    Decimal startingValue;
    Decimal currentValue;

    public Calc_Calculator(Decimal startingValue) {
        this.startingValue = startingValue;
        this.currentValue = startingValue;
    }

    public Calc_Calculator execute(Calc_Operation.OperationType operationType, Decimal newValue){
        Type type = Calc_Operation.OPERATION_MAP.get(operationType);
        Calc_Operation.Operation opp = (Calc_Operation.Operation)type.newInstance();
        this.currentValue = opp.calculate(this.currentValue, newValue);
        this.operationList.add(opp);
        return this;
    }

    public void printOperations(){
        for(Calc_Operation.Operation opp : operationList){
            String logText = opp.getOperationAsString();
            System.debug(logText);
        }
        System.debug('------------');
        System.debug('Total: '+this.currentValue);
    }
}

Operations

public class Calc_Operation {
 
    public enum OperationType {
        ADD,
        SUBTRACT
    }

    public abstract class Operation {
        protected Decimal value1;
        protected Decimal value2;
        public abstract Decimal calculate(Decimal value1, Decimal value2);
        public abstract String getOperationAsString();
    }


    public static final Map<OperationType, Type> OPERATION_MAP = new Map<OperationType, Type>{
        OperationType.ADD => Add.class,
        OperationType.SUBTRACT => Subtract.class
    };

    public class Add extends Operation {

        public override Decimal calculate(Decimal value1, Decimal value2){
            this.value1 = value1;
            this.value2 = value2;
            return value1 + value2;
        }

        public override String getOperationAsString(){
            return (this.value1 + ' plus ' + this.value2);
        }
    }

    public class Subtract extends Operation {

        public override Decimal calculate(Decimal value1, Decimal value2){
            this.value1 = value1;
            this.value2 = value2;
            return value1 - value2;
        }

        public override String getOperationAsString(){
            return (this.value1 + ' minus ' + this.value2);
        }
    }
}

I would have liked to use an interface rather than an abstract class to get away from inheritance, but since I needed each operation instance to persist their pre-calculated values via fields and this seemed like the best way to "enforce" that if the code was ever picked up by another dev down the line (for hypothetical practice that is - this is just a HW assignment).

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .