Lesson 10 / 15
`Optional`
Expressing absence as a type does not move the caller's obligation to compile time: all four unchecked calls — both in the null-returning signature and the Optional-returning signature — compile unchecked, and one falls at runtime. The gain is visibility in the signature; this gain disappears in a field and a parameter, and of() falls immediately on null while ofNullable() does not.
Contents
The previous lesson measured which promises the container
accumulating at the end of a stream chain gets built with. Collecting
always produces a value — but once a single item gets sought, what
happens if that item does not exist in the source at all? Some methods
of the standard library return null in this case, some return a
container called Optional<T> that expresses absence as a type.
Optional itself is not an interface, it is a single class; this
course’s “who gives the guarantee” question gets asked here in a third
form: does carrying absence into the type genuinely eliminate the
caller’s “check first, then read” obligation, or does it only make it
visible? Once absence moves into the type, does the caller’s
obligation genuinely move to compile time, or does the check still
stay at runtime?
Same Work, Two Signatures: The Check Still Stays at Runtime
- FJ27 — The same lookup work gets written with two signatures:
one a
Stringthat can return null, the other anOptional<String>. Both get called with an existing and a missing input, with both the unchecked and the safe idiom.
// Same.java - the same work, a null-returning signature versus an Optional-returning one: the check left to the caller
import java.util.*;
public class Same {
static final Map<String, String> COLORS = Map.of("pear", "green");
static String findColor(String fruit) { return COLORS.get(fruit); }
static Optional<String> findColorOptional(String fruit) { return Optional.ofNullable(COLORS.get(fruit)); }
public static void main(String[] args) {
int checkedCalls = 0, uncheckedCalls = 0;
try {
int len1 = findColor("pear").length();
uncheckedCalls++;
System.out.println("null-returning signature, existing input : " + len1 + " (compiled unchecked)");
} catch (NullPointerException e) { System.out.println("unexpected exception"); }
try {
int len2 = findColor("apple").length();
System.out.println("null-returning signature, missing input : " + len2);
} catch (NullPointerException e) {
uncheckedCalls++;
System.out.println("null-returning signature, missing input -> exception: "
+ e.getClass().getSimpleName() + " (compiled unchecked, fell at runtime)");
}
try {
int len3 = findColorOptional("pear").get().length();
uncheckedCalls++;
System.out.println("Optional-returning signature, existing input : " + len3 + " (compiled unchecked)");
} catch (NoSuchElementException e) { System.out.println("unexpected exception"); }
try {
int len4 = findColorOptional("apple").get().length();
System.out.println("Optional-returning signature, missing input : " + len4);
} catch (NoSuchElementException e) {
uncheckedCalls++;
System.out.println("Optional-returning signature, missing input -> exception: "
+ e.getClass().getSimpleName() + " (compiled unchecked, fell at runtime)");
}
System.out.println("safe idiom, null-returning signature : "
+ (findColor("apple") != null ? findColor("apple") : "unknown"));
checkedCalls++;
System.out.println("safe idiom, Optional-returning signature: " + findColorOptional("apple").orElse("unknown"));
checkedCalls++;
System.out.println();
System.out.println("calls that compiled unchecked (all four of four): " + uncheckedCalls);
System.out.println("checked calls (written with the safe idiom) : " + checkedCalls);
}
}
null-returning signature, existing input : 5 (compiled unchecked) null-returning signature, missing input -> exception: NullPointerException (compiled unchecked, fell at runtime) Optional-returning signature, existing input : 5 (compiled unchecked) Optional-returning signature, missing input -> exception: NoSuchElementException (compiled unchecked, fell at runtime) safe idiom, null-returning signature : unknown safe idiom, Optional-returning signature: unknown calls that compiled unchecked (all four of four): 4 checked calls (written with the safe idiom) : 2
All four unchecked calls compile — whether the signature is String
or Optional<String>, the compiler raises no objection to any of
them. Called with a missing input, both fail at runtime, only the
exception’s name changes: NoSuchElementException instead of
NullPointerException. The measured number is plain here: the count
of calls that can pass unchecked, left to the caller by two signatures
doing the same work, is equal. The Optional return type does not
drop this count to zero; when the check does not get written, it
still falls at runtime.
The gain is somewhere else: in the signature itself. Someone
reading the signature String findColor(String) cannot know the
returned value might be null — they only learn this from the
documentation or by reading the source. The signature
Optional<String> findColorOptional(String), though, states the
possibility of absence directly; the caller has to look at
neither the documentation nor the source to see this. The check
itself is still optional, but whether a check is needed is now
written in the type.
This distinction can be compared to the checked exception built in the
Object-Oriented Java course: there, the exception a method could throw
was written in the signature, and the caller had to handle it —
otherwise compilation stopped; not catching it was an option too, but
the option was at least visible. The Optional return type provides
similar visibility, but has no mandatory side: while code that handles
a checked exception nowhere at all does not compile, code that calls
get() on an Optional with no check at all compiles without a
hitch, as seen in the fourth line. Both mechanisms carry absence or
the error into the signature; only one makes it mandatory, the other
only makes it visible.
Chaining Does Not Eliminate the Check, It Defers It
Optional is not just a container; it offers a chaining similar to a
stream chain, with methods like map, orElse, orElseGet. This
chaining does not turn a null value into a check that never gets
written, but it at least eliminates an if (x != null) block repeated
at every step.
- FJ28 —
findColorOptionalgets called with the same two inputs ("pear","apple"), and the result gets uppercased withmapand bound to a default string withorElse; no explicit check gets written at any intermediate step.
// Chain.java - how Optional chaining removes the intermediate check
import java.util.*;
public class Chain {
static final Map<String, String> COLORS = Map.of("pear", "green");
static Optional<String> findColorOptional(String fruit) { return Optional.ofNullable(COLORS.get(fruit)); }
public static void main(String[] args) {
String upperExisting = findColorOptional("pear").map(s -> s.toUpperCase(Locale.ROOT)).orElse("NONE");
String upperMissing = findColorOptional("apple").map(s -> s.toUpperCase(Locale.ROOT)).orElse("NONE");
System.out.println("existing input, chain result : " + upperExisting);
System.out.println("missing input, chain result : " + upperMissing);
}
}
existing input, chain result : GREEN missing input, chain result : NONE
The map(s -> s.toUpperCase(Locale.ROOT)) call never runs if the
container is empty — the “interface guarantee” measured in the
Predicate and Function lessons holds here too: map’s own promise is
never calling the function on an empty container. This is why, once
findColorOptional("apple") returns an empty container,
s.toUpperCase(...) never runs and no exception falls; the chain
drops straight to orElse("NONE"). The gain here is real: had we
written the same work with null, a separate if would be needed at
every step.
But this chaining has a trap of its own. orElse and orElseGet look
like they do the same work — both say “give this if it is missing” —
but when they compute their argument differs.
- FJ29 — The same expensive default-value producer gets called on
both a full and an empty container, with both
orElseandorElseGet; how many times the producer runs gets counted.
// OrElse.java - orElse always computes the expensive default, orElseGet only when needed
import java.util.*;
public class OrElse {
static int callCount = 0;
static String expensiveDefault() {
callCount++;
return "default";
}
public static void main(String[] args) {
Optional<String> full = Optional.of("pear");
callCount = 0;
full.orElse(expensiveDefault());
System.out.println("orElse() on a full container, default called how many times : " + callCount);
callCount = 0;
full.orElseGet(OrElse::expensiveDefault);
System.out.println("orElseGet() on a full container, default called how many times: " + callCount);
Optional<String> empty = Optional.empty();
callCount = 0;
empty.orElse(expensiveDefault());
System.out.println("orElse() on an empty container, default called how many times : " + callCount);
callCount = 0;
empty.orElseGet(OrElse::expensiveDefault);
System.out.println("orElseGet() on an empty container, default called how many times: " + callCount);
}
}
orElse() on a full container, default called how many times : 1 orElseGet() on a full container, default called how many times: 0 orElse() on an empty container, default called how many times : 1 orElseGet() on an empty container, default called how many times: 1
orElse gets called once in both of the two rows where its
container is full — even though the container is full. The reason is
in the syntax: when orElse(expensiveDefault()) gets written, the
expensiveDefault() call, as the argument of a Java method call, gets
evaluated before orElse itself runs — the producer runs even
when the container is full, orElse just does not use the result.
orElseGet(OrElse::expensiveDefault), though, takes a producer
reference and calls it only when the container is empty; on a
full container, the producer never runs. This repeats, here, the
laziness measurement from the previous topic: when an operation
runs gets understood not by looking at the name of the method
wrapping it, but by running it.
The Gain Disappears in a Field and a Parameter
The visibility Optional earns only works once it gets used as a
return type. Once it gets used as a field or parameter type, the
same gain disappears.
- FJ30 — A class carrying a field of type
Optional<String>gets built, and that field gets assigned null directly — the compiler allows this, becauseOptionalis an ordinary reference-typed class too.
// Field.java - does the gain disappear when Optional gets used in a field
import java.util.*;
public class Field {
static class Entry {
Optional<String> label;
}
public static void main(String[] args) {
Entry e = new Entry();
e.label = null;
System.out.println("can the field itself be null : " + (e.label == null));
try {
boolean present = e.label.isPresent();
System.out.println("isPresent() on the field : " + present);
} catch (NullPointerException ex) {
System.out.println("isPresent() on the field -> exception: " + ex.getClass().getSimpleName());
}
}
}
can the field itself be null : true isPresent() on the field -> exception: NullPointerException
Assigning null directly to the Optional<String> label field
compiles. Optional is an ordinary class carrying a type
parameter; it is itself a reference, and like every reference, it can
be null. Calling isPresent() while the field is null this time
raises NullPointerException — exactly the same exception Optional
exists to prevent, just one layer outside.
This is the lesson’s boundary measurement: Optional‘s gain only
holds in the narrow context of getting used as a method’s return
type. Used in a field or a parameter, a doubled possibility of
absence gets born — the field itself can be null, and the value the
field carries can be null — and the visibility earned turns into a
complexity that takes back more than it earned. The standard
library’s own documentation counts this use as not recommended; what
gets measured here is the reasoning behind that recommendation.
The reason becomes clear once it joins with the first section’s
measurement: Optional‘s only protection is a reminder sitting in
the signature, not a rule enforced at runtime. Used as a return type,
this reminder shows up again at every call site — everyone calling
the method sees the returned value is Optional and decides. Used as
a field, though, the reminder shows up only once, in the class’s
definition; code using the class does not re-read that reminder
every time it accesses the field, because the access looks like an
ordinary field read, and Optional itself becomes invisible.
Visibility’s gain ends exactly where visibility disappears.
of Falls Immediately, ofNullable Does Not
Optional has two main ways to build a container: Optional.of(value)
and Optional.ofNullable(value). Both return a container of the same
type, but they come apart once called with a null value.
- FJ31 —
Optional.of(null)andOptional.ofNullable(null)get tried separately;get()gets called on the second one’s result to measure when the check falls.
// OfOfNullable.java - of falls immediately, ofNullable does not
import java.util.*;
public class OfOfNullable {
public static void main(String[] args) {
try {
Optional<String> direct = Optional.of(null);
System.out.println("building of(null): fell through");
} catch (NullPointerException e) {
System.out.println("building of(null) -> exception: " + e.getClass().getSimpleName());
}
Optional<String> nullable = Optional.ofNullable(null);
System.out.println("building ofNullable(null): did not fall, result=" + nullable);
try {
nullable.get();
System.out.println("ofNullable(null).get(): succeeded");
} catch (NoSuchElementException e) {
System.out.println("ofNullable(null).get() -> exception: " + e.getClass().getSimpleName());
}
}
}
building of(null) -> exception: NullPointerException building ofNullable(null): did not fall, result=Optional.empty ofNullable(null).get() -> exception: NoSuchElementException
Optional.of(null) falls at the moment of construction: an of
call means “I know this value is not null,” and once called with a
null value, this claim gets falsified immediately.
Optional.ofNullable(null), though, does not fall — it already
accepts the value might be null, and returns an empty container
(Optional.empty()). The break does not happen there, it happens once
the container’s content gets read: the get() call falls with
NoSuchElementException. Both methods can end up at the same point
eventually — calling get() on an empty container — but of catches
this immediately, at the construction line, while ofNullable carries
this risk along with the container itself, and defers the check to the
line where the container gets used.
This deferral is not a defect, it is exactly Optional‘s job: at the
point ofNullable gets called, no one may yet know how the container
will get used — maybe it will go straight into a map chain, maybe
it will get assigned to a field, maybe it will never get read at all.
of assumes the opposite: it claims, right here, at exactly this
line, that the value is full, and does not wait to confirm this claim.
The difference between the two gives a small answer to the
caller’s-obligation question measured at the start of the lesson: the
obligation never disappears, only when it falls changes with the
method the caller chooses. Which one to choose is the caller’s
decision: if certain the input cannot be null, of builds an
early-warning system; if already knowing the input might be null,
ofNullable represents this not as an exception, but as an
ordinary case.
Summary
- A null-returning signature and an
Optional-returning signature are equal in the count of calls left unchecked: all four unchecked calls compile, one falls at runtime.Optionaldoes not reduce this count. - What gets gained is the signature’s visibility: the
Optional<T>return type shows the possibility of absence directly in the signature, without looking at the documentation. Optionalsupports chaining:mapnever runs on an empty container, no separate null check needs writing at intermediate steps. ButorElsealways computes its argument even if the container is full;orElseGetonly runs when the container is empty — though the two look like they do the same work, their costs differ.- Boundary measurement: the gain disappears once
Optionalgets used as a field or a parameter — the field itself can be null, and this carries the exception meant to be prevented one layer outward. Optional.of(null)falls withNullPointerExceptionat the moment of construction;Optional.ofNullable(null)does not fall, it returns an empty container and the check gets deferred to theget()call. The choice is the caller’s.
Next Step
Across this topic, what a single type — the functional interface, the
lambda, the stream chain, the collector, Optional — promises the
library got measured, and every time, the promise was exactly as
narrow as what the compiler checks: single abstract method gets held,
local capture gets frozen, order mostly comes from the source, the
container’s type is mostly ambiguous. Optional is the last example
of that same narrow promise — absence got stated as a type, the
obligation got carried into the signature, but the check still stayed
at runtime. The next topic asks the same question no longer over the
language’s own types, but at the boundary built with the outside
world: it measures what a byte, a file, a clock, and text promise the
standard library.
To keep your progress and take notes, Log in
My notes
Log in to take notes.