Module 15 - Functional Programming: Optional & Method References
Goal: Replace null checks and anonymous class boilerplate with clean, composable functional code using Optional<T>, method references, and the java.util.function toolkit.
Table of Contents
- TOC
Why Functional Programming in Java?
Java has been functional-capable since Java 8. The core idea: treat behaviour as data. Pass functions as arguments, return them as results, compose them into pipelines. This eliminates whole categories of bugs:
- Null checks →
Optional<T> - Anonymous inner classes → lambdas and method references
- Repeated strategy classes →
Function/Predicatevalues - Mutable accumulator loops → stream pipelines
Optional<T>
Optional<T> is a container that holds either a value or nothing. It forces you to handle the absent case at the type level.
Creation
Optional<String> present = Optional.of("hello"); // non-null only
Optional<String> maybe = Optional.ofNullable(value); // null becomes empty()
Optional<String> empty = Optional.empty();
Never use Optional.get() without isPresent(). Use the transformation methods instead.
map / flatMap / filter
// map: transform the value if present
Optional<Integer> len = Optional.of("hello").map(String::length); // Optional[5]
// flatMap: when the mapping function itself returns Optional
Optional<Integer> parsed = Optional.of("42").flatMap(OptionalDemo::parseIntSafe);
// filter: keep value only if predicate passes
Optional<Integer> even = Optional.of(4).filter(n -> n % 2 == 0); // present
Optional<Integer> odd = Optional.of(3).filter(n -> n % 2 == 0); // empty
Terminal operations
| Method | Behaviour |
|---|---|
orElse(T) | Return value or default (default always evaluated) |
orElseGet(Supplier<T>) | Return value or call supplier (lazy - only when empty) |
orElseThrow(Supplier<X>) | Return value or throw exception |
ifPresent(Consumer<T>) | Run consumer if present, nothing if empty |
ifPresentOrElse(Consumer, Runnable) | One branch per case (Java 9+) |
Prefer orElseGet over orElse for expensive defaults:
// BAD: new ArrayList() always constructed, even when opt is present
return opt.orElse(new ArrayList<>());
// GOOD: ArrayList only constructed when opt is empty
return opt.orElseGet(ArrayList::new);
or / stream (Java 9+)
// or: fallback Optional - result stays wrapped
Optional<String> result = primary.or(() -> fallback);
// stream: flatMap empties away in stream pipelines
List<Integer> numbers = inputs.stream()
.map(OptionalDemo::parseIntSafe) // Stream<Optional<Integer>>
.flatMap(Optional::stream) // Stream<Integer>
.collect(Collectors.toList());
Chained pipeline pattern
return Optional.ofNullable(userId)
.filter(id -> !id.isBlank())
.map(profiles::get)
.map(String::trim)
.filter(name -> !name.isEmpty())
.map(String::toLowerCase)
.orElse("anonymous");
No null checks. No early returns. No NullPointerException.
Method References
A method reference is a compact lambda that delegates to an existing method. Four kinds:
Kind Syntax Equivalent lambda
──────────────────────────────────────────────────────────────────
Static ClassName::staticMethod x -> ClassName.method(x)
Bound instance instance::method x -> instance.method(x)
Unbound instance ClassName::method (obj,x) -> obj.method(x)
Constructor ClassName::new args -> new ClassName(args)
Static
.map(Integer::parseInt) // s -> Integer.parseInt(s)
Comparator<Integer> cmp = Integer::compare;
Bound instance
String prefix = "Hello";
Predicate<String> startsWith = prefix::startsWith; // receiver captured at creation
Consumer<String> print = System.out::println;
Unbound instance
Function<String, String> upper = String::toUpperCase; // receiver = first arg at call time
Comparator<String> byLen = Comparator.comparingInt(String::length);
BiFunction<String, String, Boolean> contains = String::contains;
Constructor
Supplier<StringBuilder> sbNew = StringBuilder::new;
Function<String, StringBuilder> sbFrom = StringBuilder::new;
IntFunction<int[]> arr = int[]::new;
java.util.function - Core Interfaces
| Interface | Signature | Use case |
|---|---|---|
Function<T,R> | T -> R | transform |
Predicate<T> | T -> boolean | test/filter |
Consumer<T> | T -> void | side-effect |
Supplier<T> | () -> T | produce/defer |
BiFunction<T,U,R> | T,U -> R | two-input transform |
UnaryOperator<T> | T -> T | in-place transform |
BinaryOperator<T> | T,T -> T | fold/combine |
Primitive specialisations (IntFunction, LongPredicate, ToIntFunction, …) avoid boxing overhead.
Composition
// Function.andThen
Function<String, String> trimUpper =
((Function<String,String>) String::trim).andThen(String::toUpperCase);
// Predicate.and / or / negate
Predicate<String> valid = Predicate.not(String::isBlank)
.and(s -> s.length() >= 8);
// Consumer.andThen
Consumer<String> logAndStore = logger::log.andThen(store::add);
Custom @FunctionalInterface
@FunctionalInterface
interface Transformer<A, B> {
B transform(A input);
default <C> Transformer<A, C> andThen(Transformer<B, C> after) {
return input -> after.transform(this.transform(input));
}
}
Transformer<String, Integer> wordCount = s -> s.trim().split("\\s+").length;
Transformer<String, String> report = wordCount.andThen(n -> "words: " + n);
Functional Patterns
Currying and partial application
Function<Integer, Function<Integer, Integer>> add = curry(Integer::sum);
Function<Integer, Integer> add5 = add.apply(5); // first arg fixed
Memoization
Function<Integer, Integer> memoized = memoize(n -> expensiveCompute(n));
memoized.apply(10); // computed
memoized.apply(10); // from cache - fn not called again
Strategy via functions
// No interface, no classes - strategies are just Function values
Function<Order, Double> strategy = tier.equals("GOLD") ? twentyPct : tenPct;
double price = strategy.apply(order);
Validation combinator
Validator<String> minLen = s -> s.length() >= 8 ? emptyList() : List.of("too short");
Validator<String> noSpace = s -> !s.contains(" ") ? emptyList() : List.of("no spaces");
List<String> errors = minLen.and(noSpace).validate(input); // collects ALL failures
Lazy evaluation
Lazy<Config> config = Lazy.of(() -> Config.loadFromDisk()); // not loaded yet
Config c = config.get(); // loaded once here, cached for all subsequent calls
Source Files
| File | What it covers |
|---|---|
OptionalDemo.java | Creation, map, flatMap, filter, orElse family, or, stream, pipelines |
MethodReferences.java | All four kinds, Comparator composition, processing pipelines |
FunctionalInterfaces.java | Function/Predicate/Consumer/Supplier composition, primitives, custom interface |
FunctionalPatterns.java | Higher-order fns, currying, memoization, Strategy, Decorator, Validator, Lazy |
Common Mistakes
orElseeagerly evaluates its argument.opt.orElse(expensiveCall())callsexpensiveCall()even whenoptis present. UseorElseGet(() -> expensiveCall())instead.
Don’t wrap
Optionalin collections or fields.Optionalis designed for return values only. StoringOptionalin aListor as a class field is an anti-pattern.
Unbound vs bound:
String::toUpperCaseasFunction<String,String>is unbound - the string is the receiver.myString::toUpperCaseasSupplier<String>is bound -myStringis captured. Same syntax, different semantics depending on the target functional interface.