Skip to content
academia.sh

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 String that can return null, the other an Optional<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.

  • FJ28findColorOptional gets called with the same two inputs ("pear", "apple"), and the result gets uppercased with map and bound to a default string with orElse; 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 orElse and orElseGet; 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, because Optional is 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.

  • FJ31Optional.of(null) and Optional.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. Optional does 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.
  • Optional supports chaining: map never runs on an empty container, no separate null check needs writing at intermediate steps. But orElse always computes its argument even if the container is full; orElseGet only 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 Optional gets 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 with NullPointerException at the moment of construction; Optional.ofNullable(null) does not fall, it returns an empty container and the check gets deferred to the get() 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.

Start typing to search.

↑↓ Esc navigate · open · close