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")) { ... }
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)) { ... }
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); }
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.
/* 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;
/* 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"); }
- 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);
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'));
In second part, we will see another approach, where no enumeration is possible, because comparison logic is not so trivial.