Expressive Conditionals in Java - Reclaiming the 'if' expression
I want to show that java code can be much easier to reason with, when having an if expression rather than an if statement. Something that returns a value.
The common if statement does not represent a value, which means that unless it is used to purely decide on side effects, you either have to duplicate code or use mutating variables. Java has the ternary operator (?:) that is an expression that represents a value, but may be found difficult to read. With lambdas, it’s easy to create an if-expression that returns a value.
Here is an ifelse expression:
<T> T ifelse(boolean test, Supplier<T> then, Supplier<T> otherwise) {
return test ? then.get() : otherwise.get();
}
It’s just a wrapper around the ternary operator that puts the intention, the name, that it is an if-else expression, first. It makes it easier to read. The expression represents a value. Here it represents “yes”
return ifelse(true, () -> "yes", () -> "no");
Duplication or variable
When the if statement is a statement, rather than an expression we need to either introduce a variable, result:
String result = null;
if(true) result = "yes";
else result = "no";
return result;
… or duplicate code, the return statement:
if (true)
return "yes";
return "no";
This doesnt look like much. But it a lot in not so condenced code, when other things a happening simultaneousy, when there is a lot to reason about.
Swiss army knife
The if statement is like a Swiss army knife, used for many types of conditionals. It does not reveal the type. Are we producing a value, are we doing a side effect, is there an else case, or are there many cases? ifelse tells us up front that there is an else case, so we do not have to search to find out.
doWhen
The doWhen is a variation of the ifelse variation. Our first variation, that tells us do: that it is a side effect without a value and when: that there is no else condition.
doWhen(tooWarm, Thermostat::decreaseTemperature);
As it is so traditional, it is very simple. The benefit is that it tells us that we dont have to search for an else clause and, it forces us to use an expression. It’s like an if without curly brackets, but were do not forget to not have more than one line.
void doWhen (boolean test, Runnable then){
if(test)
then.run();
}
unless - exceptional if
Then there are the exceptional if statements. There is a normal flow, but under some circumstances something has to be done, when something exceptional may have occurred. Checking the exceptional condition is not the important part of the program flow, and under such circumstances it’s easier to read the important program flow first:
// make the string lower unless it's null, when it should be Nothing.
String lowerOrNothing(String a){
return unless(() -> a.toLowerCase(), // the important path first
a==null, () -> "Nothing") // and the exceptional
}
It is very simple, just a small ternary operator.
<T> T unless(Supplier<T> then, boolean test, Supplier<T> otherwise) {
return test ? otherwise.get() : then.get();
}
The ternary operator does not express intention first, the reasoning, that it is an exceptional condition.
either - if with a flow
Sometimes you want the test value flow through the expression, without needing to have to mention it, point free.
Here we us the string in every part. We make a string upper case if it happen to begin with an upper case , and otherwise lower case.
String upperOrLowerOnFirst(String str){
return either(
str,
s -> s.matches("\\p{Upper}.*"),
String::toUpperCase,
String::toLowerCase);
}
This does not look much in such small context, but it may simplify code a lot in a bigger context.
IT is simply:
public static <T,V> V either(
T t,
Predicate<? super T> p,
Function<? super T,? extends V> then,
Function<? super T,? extends V> otherwise) {
return ifelse(
p.test(t),
() -> then.apply(t),
() -> otherwise.apply(t));
}
If uses a predicate and two functions for the cases. The reason is that each case has to be lazily evaluated, otherwise both expression would always apply, and that the test and the conditions can be passed in as values. It could be implemented as
Predicate<String, Boolean> isCapital = s -> s.matches("\\p{Upper}.*");
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> toLower = String::toLowerCase;
// and somewhere else
String upperOrLowerOnFirst(String str){
return either(str, isCapital, toUpper, toLower);
}
cond
Then there is the situation when there are many cases to consider, that the switch-case statement is meant for. However there are mostly many things to consider so we kind of resort to complex usage if the if statement, checking all sorts of things while making a decision, making the decision logic harder to follow. Probably with nested if statements with many temporary variables.
cond in Lisp
The Lisp programming language used the cond expression long before the C-like syntax was invented. It is more powerful, as it represents a value, can have any number of conditions, and can check anything. It’s like a cleaner version of nested if statements. And in Clojure, the lisp of today, a cond expression like:
(cond
(= 2 (count "Three")) (count "Four")
(< 3 4) (+ 1 2)
:else 5) ;; :else is a keyword. Keywords are neither false or nil, meaning they are truth
… which is 3, is a macro that expands to nested if expressions:
(if (= 2 (count "Three"))
(count "Four")
(if (< 3 4)
(+ 1 2)
(if :else
5
nil)))
The cond expression has pairs of expressions, a predicate and a corresponding expression. The cond expression returns the value of the first successful test. This is powerful because you can test multiple conditions in order, and still return a single value — much like the ternary operator, but more flexible.
cond in Java
A similar cond expression in Java could look like:
return cond(() -> 2 == "Three".length(), () -> "Four".length(),
() -> 3 < 4, ()-> 1 + 2,
() -> 5);
which would translate to:
int value = -1;
if (2 == "Three".length())
value = "Four".length();
else if (3 < 4)
value = 1 + 2;
else value = 5;
return value;
cond java implementation
This cond can easily be implemented with pairs of predicate and value Suppliers, like:
/**
* Evaluates each test t in order until true, in which case value of corresponding v is returned.
* When all values are false, or null, returns the value of last v.
* Evaluations short-circuits on truth. There are multiple overloads supporting different arities.
* <p>Example:
* <pre>{@code
* cond(() -> false, () -> "No",
* () -> true, () -> "Yes",
* () -> "catch all") => "Already caught"
* }</pre>
*/
public static <T> T cond(
Supplier<Boolean> t0, Supplier<? extends T> v0,
Supplier<Boolean> t1, Supplier<? extends T> v1,
Supplier<? extends T> r){
requireAllNonNull(t0, v0, t1, v1, r);
return ifelse(truth(t0.get()),
v0::get,
()->cond(t1, v1, r));
}
// Where cond with next arity is called up on first test failure
public static <T> T cond(
Supplier<Boolean> t0, Supplier<? extends T> v0,
Supplier<? extends T> r){
requireAllNonNull(t0,v0,r);
return ifelse(truth(t0.get()), v0::get, r::get);
}
boolean truth(Boolean b){
return Objects.nonNull(b) && b;
}
This gives us a conditional expression just as powerful as Lisp’s cond, but in plain Java. The Suppliers are used to prevent evaluation before the choice has been made, and to short circuit the expression.
condPred
It might be a bit easier to read when each case is written using predicates and values directly, rather than explicit lambdas for everything.
int result = Choices.condPred(
this::is3InLength, "Three", this::return4,
x -> isLessThan(4, x), 3, ()-> 3,
() -> 5
);
…which is 3.
The condPred can be implemented with similar code, where the predicate is a Predicate and the value is supplied as is.
public static <T, T0, T1> T condPred(
Predicate<T0> t0, T0 v0, Supplier<? extends T> s0,
Predicate<T1> t1, T1 v1, Supplier<? extends T> s1,
Supplier<? extends T> r) {
requireAllNonNull(t0, s0, t1, s1, r);
return ifelse(truth(t0.test(v0)),
castSupplier(s0),
() -> condPred( t1, v1, s1, r));
}
//Where condPred with next arity is called up on first test failure
public static <T, U> T condPred(
Predicate<U> t0, U u, Supplier<? extends T> v0,
Supplier<? extends T> v1) {
requireAllNonNull(t0, v0, v1);
return ifelse(truth(t0.test(u)), v0::get, v1::get);
}
@SuppressWarnings("unchecked")
private static <T> Supplier<T> castSupplier(Supplier<? extends T> s) {
return (Supplier<T>) s;
}
condFlow
It’s also possible to have a cond variant where the tested value flows through to the result expression — similar to either
public static <T, U, A> T condFlow(
Predicate<? super U> test0,
Supplier<? extends U> value0,
Function<? super U, ? extends T> result0,
Predicate<? super A> test1,
Supplier<? extends A> value1,
Function<? super A, ? extends T> result1,
Supplier<? extends T> otherwise) {
requireAllNonNull(test0, value0, result0,
test1, value1, result1,otherwise);
var value = value0.get();
return ifelse(truth(test0.test(value)),
() -> result0.apply(value),
() -> condFlow(test1, value1, result1, otherwise));
}
// Which calls
public static <T, U, A> T condFlow(
Predicate<? super U> test0,
Supplier<? extends U> value0,
Function<? super U, ? extends T> result0,
Supplier<? extends T> otherwise) {
requireAllNonNull(test0, value0, result0, otherwise);
var value = value0.get();
return ifelse(truth(test0.test(value)),
() -> result0.apply(value),
otherwise::get);
}
Where the supplied value is passed to a function, here toString and the Hello lambda when test succeeds
String result = condFlow(
alwaysFalse(), constInt(1), Integer::toString(),
x -> true, () -> 2), x -> "Hello " + x,
() -> "fallback");
Supplier<Integer> constInt(int val) {
return () -> val;
}
static <T> Predicate<T> alwaysFalse() {
return x -> false;
}
Note how we defer value evaluation using suppliers, so it only happens if no earlier test succeeds. There are a lot of variations of how a if statement can be replaced with conditional constructs that tells the story up front. These constructs make Java more expressive — not by changing the language, but by embracing what it already provides. They strip away boilerplate, clarify intent, and tell a story — just like all good code should.