March 25, 2022

ReThinking If/Else - Use the Force (part 1)

A modern OOP approach to if/else conditional




Everyone knows IF/ELSE IF/ELSE IF/..../ELSE . And also everyone knows the evil inside a pletora of if/else_if/.... or not?
really not?
So, let these images shows the evil:






Are u convinced now? So, let's start to discover new ways to handle if/else_if/.../else logic.


Assumption 1:

often we can not avoid a conditional logic, because simply we need.

We have to compare some strings, some values against number/date/whatever.. sometimes there are multiple combination of those, using AND or OR, and so on.

So, what are we talking about? Of course not of "avoid comparison", but "improve the ways we are using to do the comparison".

Assumption 2:

scenarios covered by this tutorial cover have a common property: all if/else_if/../else act on (almost) same set of outputs, or provide 1 output of same type, such as:

  
  /* SAMPLE 1 */
  
  if (logic1) { 
    return 1; 
  } else if (logic2) { 
    return 2; 
  } else if (logic3) { 
    return 3; 
  }
  

OR
  
  /* SAMPLE 2 */
  
  if (logic1) {
    x = ..;
    y = ..;
  }
  else if (logic2) {
    x = ..;
    y = ..;
  } else if (logic3) {
    x = ..;
    y = ..;
  } else if (logic 4) {
    x = ..;
    // not assign a value for y, here...
  }
  



PART 1: comparison on ENUMERABLE


What is Enumerable ? They are values for which we could have a group/collection containing those values; Enumerables are a finite number of elements, well known a-priori, that is: when we are writing the comparison code, we already know values against we are comparing our variable.

So, we could collect these values using any of Collection types:
  • Set, which admits not null values and not multiple same values: that is, no multiples "A", "A", "A", nor 'null'
  • Map, which associates a value to a key, and the keys set is just a Set Comparison on Enumerables could be used when we could apply some simply logic on strings (simple scenario), or on some set of objects for which we could have a kind of comparison.

Use Case A: simple comparison on strings/objects

We could write something as:
  
  /* SAMPLE 3: */

  String s = ...;
  if (s.equals("A") 
      || s.equals("B")) 
      || s.equals("C")) 
      || ... 
      || s.equals("Z")) { 
    ... 
  }
  
So, really we would write a code as below? No, we would not, but often I saw a code as example above, really: once time there were 37 if/if/if/..., eventually to grow with new cases.

Of course we could use if /else if/else if/else.. but the fact is: we are doing N comparison until we will find the exactly match (if any), so our code cost O(n) and it is very difficult to read, test, maintain, enrich... because for each new case we have to modify the code.

Solution A: use Set

Solution A1: on strings
  
  /* SAMPLE 4 */

  Set<String> set = Set.of("A", "B", "C", "D", ..., "Z");
  // or, better: the set could be populated from 
  // db/configuration/any_external_source_easy_to_maintain

  // and then:
  if (set.contains(myString)) { 
    ... 
  }
  
et voilĂ : cost is O(1), because lookup into Set costs 1 operation, the code is really readable and maintanable, and if u want add further case, it's enough to add another string on configuration/db_table/whatever

Use A2: on standard Java object

If our variable is a String/Date/Integer/..., we could still use Set to collect our enumerable types, such as:
  
  /* SAMPLE 5 */

  Set<Date> dates = ...;
  // and
  if (dates.contains(ourDates)) {
     ...;
  }
  
Solution A3: simple comparison on custom object
  
  /* SAMPLE 6 */

  // let we have a custom object
  class MyObject {
    String id;
    String name;
  }

  // we could still use Set to collect our enumerable Object, such as:
  Set<MyObject> myObjectSet = Sets.of(..., ..., ...);

  // and, so:
  if (myObjectSet.contains(ourObject)) {
   ...;
  }

  // BUT, careful: our MyObject must implement Comparable, 
  // or Set will not work (AT RUNTIME!);
  // so, let our object implements Comparable:

  class MyObject implements Comparable {
    String id;
    String name;
    @Override
    public int compareTo(final MyObject o) {
      return o.id.compareTo(id);
    }
  }

  // now MyObject implements Comparable#compareTo method, 
  // and automatic comparison from Set will work using contains
  

Use Case B: comparison on strings and do something

  /* SAMPLE 7 */

  String s = ...;
  if (s.equals("A")) { doingForA }
  if (s.equals("B")) { doingForB }
  if (s.equals("C")) { doingForC }
  ...
  if (s.equals("Z")) { doingForZ }
  

Similar to A cases, but here we would do something different according to each IF.

For this scenario, we could take advantage of Map+Lambda (if we are in Java; while in Javascript the literal object could supply; C# has dictionary+delegate, etc).

First, let's define a Action interface, generics aware:
  /* SAMPLE 8.a */

  interface Action<I,R> {
    R execute(I input);
  }
  
We have an 'execute' method, accepting an input of generic I, and producing a result R; we could specify some real type, such as <String,String> or also <Void,Void> to have null input and null output, or other combination.

Then, we use a Map to associate a String (the nth string we would compare against our string) to an Action to perform if we match on that string; this pattern is called Map of Function(s), or Functor from old C++ terms:
  /* SAMPLE 8.b */

  Map<String,Action<String,String>> map = new HashMap<>();

  // then populate
  map.put("A", new Action<String,String>() {
    @Override
    public String execute(String input) {
      return input.toUpperCase() + "_A";
    }
  });
  map.put("B", new Action<String,String>() {
    @Override
    public String execute(String input) {
      return input.toUpperCase()+ "_B";
    }
  });
  map.put("C", new Action<String,String>() {
    @Override
    public String execute(String input) {
      return input.toUpperCase() + "_C";
    }
  });
  // other strings to handle...
  
  /* SAMPLE 8.c */

  // but we could take advantage of compact Lambda form and write the population as:
  map.put("A", input -> input.toUpperCase() + "_A");
  map.put("B", input -> input.toUpperCase() + "_B");
  map.put("C", input -> input.toUpperCase() + "_C");
  ...
  // nice, really? yes, very nice.
  
and then the usage:
  /* SAMPLE 8.d */

  String theInput = ...

  Action a = map.get(theInput);
  String result;
  // because we could not have any match, 
  // we have to check a 'null' value Action from Map,
  // eventually causing NullPointerException
  if (a != null) {
    result = a.execute(myInputToUse);
  }
  
  /* SAMPLE 8.e */

  // but we could (should) use Optional from Java8:

  String result = Optional
  		    .ofNullable(map.get(myStringToCheck).execute(myInputToUse))
  		      .orElse(-1);

  // where '-1' is a our default value to use as "else" case;
  
Another approach could be using (enhanced) Enum from Java5:
  /* SAMPLE 8.f */

  public class MyClass;

  private final MyService myService = new MyService(); 
  // or use @Inject/@Autowired/whatever

  // declaring
  public enum ActionEnum {
    A {
        @Override
        public String execute(String input) {
          myService.doSomething(); // but this will not work - see below
          return input.toUpperCase() + "_A";
        }
    };
    // other for B, C, ...

    public abstract String execute(String input);
  }

  // usage:
  public void myMethod() {
    ActionEnum.A.execute("asd");
  }
  
Here the function code is implicit into 'execute' method which each Enum instance implements: this could be a short approach for certain scenarios, or not.. it depends; Enum has its pro/cons: it is compiled time and statically resolved so:
  • the pro: the ActionEnum.XXX <- will not compile if you try to search an enum instance never declared; instead, using Map you will not discover on right values until you will use them, at Runtime...
  • the cons: if you declare the enum within another class (a nested enum) you could NOT pass instance fields from external class into execute method implementations, such a Closure, because Enum is static resolved by JVM; the line "myService.doSomething" will not work

Considerations:
Too much code to write to populate Map in Sample 8.bXXX? perhaps yes, surely not in Sample8c .
Too difficult to read? again perhaps if you don't know Java8 Lambda (so, it's time you study it..).
Too much costly to execute? definitely not, because lookup into Map and Enum costs O(1), so our code will not execute all if/else_if searching for matching case, but it will point directly to matching string, using lookup on map/enum, and so finally execute the internal code from Action/enum method (which, in boiler plate if/else_if, it is the code within each 'then' block).


Of course, we could (should) use an intelligent IDE (Eclipse, IntellijIdea, whatever) to have completion/aiding/etc during code writing; in example: Eclipse transforms boiler plate code using anonymous classes (8.b) to Lambda version (8.c).


Finally, we could avoid to create our custom Action interface, and, for this scenario, use directly java.util.Function, which, basically, acts as our Action:
  /* SAMPLE 9 */

  Map<String,Function<String,Integer>> map = new HashMap<>();

  // then populate
  map.put("A", new Function<String,Integer>() {
    @Override
    public Integer apply(String input) {
      return 1;
    }
  });
  ....

  // java Function provides "apply" method, instead of our custom execute, 
  // but the lambda version is the same as 8b:

  // the population
  map.put("A", input -> 1);

  // and also the same usage from 9a retrieving/executing the Action 
  // (here Function, with 'apply'), 
  // using explicit check on null or better the Optional
  Integer result = Optional.ofNullable(map.get(myString).apply(theInput)).orElse(-1);
  
java.util.Function provides other useful interface to implement a Functional Paradigm: BiFunction (2 input, 1 output), Consumer(1 input, no output), BiConsumer(2 input, no output), Supplier(no input, 1 output). If we need more *Function (or *Consumer), we could implement our own, such as a TriFunction:
  @FunctionalInterface
  public interface TriFunction<T, U, V, R> {
    R apply(T t, U u, V v);
  }
  
and use it with 3 inputs - and, of course: QuadriFunction, PentaFunction, and so on...

If we want return acts on multiple variables in our 'then' blocks, using Functor is always possible; we have just to return a Holder object:
  /* SAMPLE 10 */

  // declare our results holder, using @RequiredArgsConstructor annotation from Lombok
  // which generate a constructor for final fields
  // (check other Lombok annotations, they are very powerful: https://projectlombok.org )
  @RequiredArgsConstructor
  class MyResult {
      final String from;
      final String value;
  }

  // declare the Functor
  Map<String, Function<String, MyResult>> map = new HashMap<>();

  // populate the Functor:
  map.put("A", t -> {
    MyResult mr = new MyResult("A",t);
    return mr;
  }); 
  // or better using the best Java8 Lambda compact syntax
  map.put("A", t -> new MyResult("A",t));
  map.put("B", t -> new MyResult("B",t));
  map.put("C", t -> new MyResult("C",t));
  ...


  // finally use the Functor, returning an empty object if no matches on Map keys
  // returning an empty object is also known as "NullObject pattern"
  // check it on same design pattern tutorial...
  MyResult result = Optional.ofNullable(map.get("A").apply("someValue"))
    .orElse(new MyResult());

  // instead here we want it throws a Java standard 'NoSuchElement' 
  // exception if no matches on Map keys
  MyResult result = Optional.ofNullable(map.get("a").apply("someValue"))
    .orElseThrow();

  // while here we want it throws a custom exception if no matches on Map keys
  MyResult result = Optional.ofNullable(map.get("b").apply("someValue"))
    .orElseThrow(() -> new MyResultEmptyException("no MyResult for 'b'));
  
Well, we arrived at the end of PART_1, and we saw some technique to avoid boilerplate/annoying/unmaintanaible/etc 'If/else_if' code, when we could use a kind of comparison on Enumerable values.

In second part, we will see another approach, where no enumeration is possible, because comparison logic is not so trivial.