Using Optionals #
In order to discuss how to replace null
checks with idiomatic use of optional combinators,
we introduce types representing arithmetic expressions
and implement different methods to evaluate them.
Arithmetic expressions #
Arithmetic expressions come in different variants.
We restrict ourselves to representing constants and
applications of binary operators for basic arithmetic operations.
The different kinds of expressions are represented as different classes
Num
and Bin
implementing the interface Exp
.
public interface Exp {
<R> R transform(ExpTransform<R> toResult);
}
As we want to implement different methods for evaluating expressions we use
double dispatch
and implement different evaluators as instances of the interface ExpTransform<R>
.
This interface has overloaded methods to transform each possible type of expressions.
public interface ExpTransform<R> {
default R fromExp(Exp exp) {
return exp.transform(this);
}
R fromExp(Num num);
R fromExp(Bin bin);
}
The
default implementation
for the Exp
type
calls the transform
method of the given expression
which needs to be implemented in concrete classes
by dispatching to one of the other overloaded fromExp
methods.
The class Num
represents integer constants.
public class Num implements Exp {
private final int value;
public Num(int value) {
this.value = value;
}
@Override
public <R> R transform(ExpTransform<R> toResult) {
return toResult.fromExp(this);
}
public int getValue() {
return value;
}
}
It wraps an internal value providing read-access via getValue
.
More complex expressions can be constructed using the class Bin
representing applications of binary operators.
public class Bin implements Exp {
private final BinOp op;
private final Exp leftArg;
private final Exp rightArg;
public Bin(char op, Exp leftArg, Exp rightArg) {
this.op = new BinOp(op);
this.leftArg = Objects.requireNonNull(leftArg);
this.rightArg = Objects.requireNonNull(rightArg);
}
@Override
public <R> R transform(ExpTransform<R> toResult) {
return toResult.fromExp(this);
}
// getters for private members omitted
}
It wraps values representing the operator as well as its arguments
providing read-access via corresponding getter methods.
The operator is represented by the class BinOp
.
public class BinOp {
private final char chr;
public BinOp(char chr) {
this.chr = chr;
}
public char getChar() {
return chr;
}
public int apply(int left, int right) {
// implementation omitted
}
}
The implementation of apply
uses corresponding Java operators
for basic arithmetic operations.
With these definitions, the arithmetic expression 1 + 2 / 3
can be represented
using the following Java expression.
new Bin('+', new Num(1), new Bin('/', new Num(2), new Num(3)))
Here is a first version of an evaluator for arithmetic expressions1.
public class Evaluator implements ExpTransform<Integer> {
@Override
public Integer fromExp(Num num) {
return num.getValue();
}
@Override
public Integer fromExp(Bin bin) {
final Integer left = fromExp(bin.getLeftArg());
final Integer right = fromExp(bin.getRightArg());
return bin.getOp().apply(left, right);
}
}
The version of fromExp
for constants simply returns the wrapped value.
For applications of binary operators,
fromExp
is called recursively to evaluate both arguments
before applying the operator to compute the result.
Explicit null
checks
#
The shown evaluator will throw an arithmetic exception
when division by zero occurs during the evaluation.
Our next evaluator avoids runtime exceptions and returns null
instead.
public class NullEvaluator extends Evaluator {
public Integer fromExp(Bin bin) {
final Integer left = fromExp(bin.getLeftArg());
final Integer right = fromExp(bin.getRightArg());
return applyOp(bin.getOp(), left, right);
}
private Integer applyOp(BinOp op, Integer left, Integer right) {
if (left == null || right == null //
|| op.getChar() == '/' && right == 0) {
return null;
} else {
return op.apply(left, right);
}
}
}
The method for Num
instances is inherited from the Evaluator
class.
The version for Bin
uses applyOp
to check for division by zero
instead of applying the operator directly.
As recursive calls might return null
,
it also implements explicit null
checks for their results.
Unfortunately, returning null
when evaluating expressions
is just as likely to cause a runtime exception down the road
if callers are not aware of possibly missing results.
Naïve use of optionals #
Our next evaluator replaces nullable integers with optional ones.
Initially, the implementation mimics the previous version,
replacing null
checks with corresponding checks on optionals.
public class OptEvaluator implements ExpTransform<Optional<Integer>> {
@Override
public Optional<Integer> fromExp(Num num) {
return Optional.of(num.getValue());
}
@Override
public Optional<Integer> fromExp(Bin bin) {
final Optional<Integer> left = fromExp(bin.getLeftArg());
final Optional<Integer> right = fromExp(bin.getRightArg());
return applyOp(bin, left, right);
}
private Optional<Integer>
applyOp(Bin bin, Optional<Integer> left, Optional<Integer> right) {
final BinOp op = bin.getOp();
if (left.isEmpty() || right.isEmpty()
|| op.getChar() == '/' && right.get() == 0) {
return Optional.empty();
} else {
return Optional.of(op.apply(left.get(), right.get()));
}
}
}
As mentioned previously, optionals are primarily intended to be used
in the result type of methods.
However, the new version of applyOp
uses Optional
not only in the result type
but also in the type of two arguments.
Moreover, first checking if an optional value is present
and then using get
to access it
is a common anti-pattern when programming with optionals.
Idiomatic use of optionals #
We will now transform this implementation
making more idiomatic use of the optional combinators in every step.
As it turns out, we do not need the method applyOp
to implement error handling
but can use map
, filter
, and flatMap
instead.
flatMap
specializes sequences
#
Here is an alternative implementation of fromExp
for Bin
arguments
that replaces the sequence of recursive calls with nested calls to flatMap
for their results.
public Optional<Integer> fromExp(Bin bin) {
final BinOp op = bin.getOp();
return
fromExp(bin.getLeftArg()).flatMap(left ->
fromExp(bin.getRightArg()).flatMap(right -> {
if (op.getChar() == '/' && right == 0) {
return Optional.empty();
} else {
return Optional.of(op.apply(left, right));
}
}));
}
Here, left
and right
are Integer
values guaranteed to be not null
,
so we do not need to check them any more or use get
to access their values.
filter
specializes conditionals
#
Next, we can use filter
to sort out values that would lead to division by zero
before calling flatMap
for the right
argument.
public Optional<Integer> fromExp(Bin bin) {
final BinOp op = bin.getOp();
return
fromExp(bin.getLeftArg()).flatMap(left ->
fromExp(bin.getRightArg())
.filter(right -> op.getChar() != '/' || right != 0)
.flatMap(right -> Optional.of(op.apply(left, right))));
}
We negate the condition used before
to describe those elements that will not lead to division by zero
and use it in the argument passed to filter
.
map
specializes flatMap
#
Finally, we can employ a useful property for reasoning about programs
involving optional combinators.
Calling flatMap
and immediately creating an optional value in the passed function
is equivalent to calling map
with a corresponding function
that does not create an optional value.
public Optional<Integer> fromExp(Bin bin) {
final BinOp op = bin.getOp();
return
fromExp(bin.getLeftArg()).flatMap(left ->
fromExp(bin.getRightArg())
.filter(right -> op.getChar() != '/' || right != 0)
.map(right -> op.apply(left, right)));
}
Here, the second call to flatMap
has been replaced with a corresponding call to map
.
Guided by useful properties for reasoning about map
, filter
, and flatMap
,
we have transformed a naïve version of the OptEvaluator
into another version that makes idiomatic use of optional combinators
and avoids common anti-patterns.
It is not only safer than the NullEvaluator
based on explicit null
checks
but also shorter.
Task: Refactor anti-patterns #
The file ExplainCommands.java
implements an interactive command line application
that can be used to print explanations for common Unix commands.
-
Study the implementation of this tool and read the documentation of used methods involving streams and/or optionals that your are unaware of.
-
Refactor the
explain
method to avoid common anti-patterns when programming with optional values by using the combinatorsmap
,filter
, and/orflatMap
.
The method loadExplanation
is an example for an API that might return “no result”.
You can treat it as a black box
and don’t need to change it or study its implementation in detail.
-
We use the boxed type
Integer
instead of the primitive typeint
as result type because parameters of generic types likeExpTransform<R>
cannot be instantiated with primitive types. If we would experience the performance penalty of boxing we could specialize used generic types with versions usingint
instead of a type parameter. ↩︎