07 - Functional Java¶
What this session is¶
About ninety minutes. Since Java 8, functions are values you can pass around, and the Streams API lets you express data transformations declaratively. This session covers lambdas, method references, the functional interfaces behind them, streams used well (and when not to use them), and Optional done right. By the end you'll write the clean, pipeline-style Java that fills modern codebases - and know where it helps versus where a plain loop is better.
Lambdas: functions as values¶
A lambda is an anonymous function you can store in a variable or pass to a method:
// Old way: anonymous class, six lines of ceremony for one line of logic.
Runnable oldWay = new Runnable() {
public void run() { System.out.println("hi"); }
};
// Lambda: the same thing.
Runnable lambda = () -> System.out.println("hi");
lambda.run(); // prints "hi"
The syntax is (parameters) -> body:
() -> 42 // no args, returns 42
x -> x * 2 // one arg (parens optional), returns x*2
(x, y) -> x + y // two args
(String s) -> s.length() // explicit type (usually inferred, so omit)
x -> { // block body with explicit return
int doubled = x * 2;
return doubled + 1;
}
A lambda is just a compact way to implement a functional interface - an interface with exactly one abstract method. That's the key insight that makes lambdas not magic.
Functional interfaces¶
A functional interface has one abstract method. A lambda is an instance of one - the lambda is that method's implementation.
@FunctionalInterface // optional annotation; compiler enforces "one abstract method"
interface Transformer {
String transform(String input);
}
Transformer upper = s -> s.toUpperCase(); // lambda implements transform()
System.out.println(upper.transform("hi")); // HI
You rarely define your own, because java.util.function provides the ones you need. The five that cover almost everything:
// Function<T, R> - takes a T, returns an R
Function<String, Integer> length = s -> s.length();
length.apply("hello"); // 5
// Predicate<T> - takes a T, returns boolean (a test)
Predicate<Integer> isEven = n -> n % 2 == 0;
isEven.test(4); // true
// Consumer<T> - takes a T, returns nothing (a side effect)
Consumer<String> print = s -> System.out.println(s);
print.accept("hi"); // prints hi
// Supplier<T> - takes nothing, returns a T (a factory/lazy value)
Supplier<Double> random = () -> Math.random();
random.get(); // a random double
// BiFunction<T, U, R> - takes two args, returns a result
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
add.apply(2, 3); // 5
Plus specializations to avoid autoboxing (chapter 12): IntFunction, ToIntFunction, IntPredicate, etc. - same ideas, primitive-typed. And UnaryOperator<T> (a Function<T,T>) and BinaryOperator<T> (a BiFunction<T,T,T>) for same-type cases.
Knowing these names lets you read any modern Java API. When Stream.map asks for a Function<? super T, ? extends R>, you now know exactly what it wants (and you can read the wildcards from chapter 04).
Method references: lambdas, shorter¶
When a lambda just calls one existing method, a method reference says the same thing more cleanly. :: is the method-reference operator.
// These pairs are equivalent:
s -> s.toUpperCase() === String::toUpperCase // instance method of the arg
s -> System.out.println(s) === System.out::println // method on a specific object
s -> Integer.parseInt(s) === Integer::parseInt // static method
() -> new ArrayList<>() === ArrayList::new // constructor
The four kinds:
- Static method:
Integer::parseIntfors -> Integer.parseInt(s). - Instance method of a particular object:
System.out::printlnfors -> System.out.println(s). - Instance method of the parameter:
String::toUpperCasefors -> s.toUpperCase()(the parameter becomes the receiver). - Constructor:
ArrayList::newfor() -> new ArrayList<>().
Use a method reference when the lambda body is exactly one method call - it's more readable. Use a lambda when there's any additional logic. Don't contort code to force a method reference; clarity wins.
Streams: declarative data processing¶
A Stream is a pipeline for processing a sequence of elements. You describe what you want (filter these, transform those, collect the rest) instead of how to loop. Compare:
// Imperative: how to do it, step by step.
List<String> result = new ArrayList<>();
for (Person p : people) {
if (p.age() >= 18) {
result.add(p.name().toUpperCase());
}
}
result.sort(Comparator.naturalOrder());
// Declarative: what you want.
List<String> result = people.stream()
.filter(p -> p.age() >= 18) // keep adults
.map(p -> p.name().toUpperCase()) // transform to uppercase names
.sorted() // sort them
.toList(); // collect to a list
A stream pipeline has three parts:
- A source -
collection.stream(),Stream.of(...),Arrays.stream(arr),IntStream.range(0, n). - Intermediate operations - lazy transformations that return a new stream:
filter,map,sorted,distinct,limit,skip,peek,flatMap. These do nothing until a terminal operation runs. - A terminal operation - triggers the pipeline and produces a result:
toList,collect,forEach,count,reduce,findFirst,anyMatch,min/max.
Laziness matters: intermediate ops don't run until a terminal op pulls elements through. This lets streams short-circuit:
Optional<Integer> firstBig = numbers.stream()
.filter(n -> n > 1000) // lazy
.findFirst(); // terminal - stops at the FIRST match, doesn't scan the rest
The operations you'll use constantly¶
// filter - keep elements matching a predicate
nums.stream().filter(n -> n % 2 == 0)
// map - transform each element
names.stream().map(String::length)
// flatMap - flatten nested structure (a stream of lists into a stream of elements)
listOfLists.stream().flatMap(List::stream)
// reduce - fold into a single value
nums.stream().reduce(0, Integer::sum) // sum
// collect - the powerful terminal; build any result
people.stream().collect(Collectors.toList())
people.stream().collect(Collectors.groupingBy(Person::department))
people.stream().collect(Collectors.toMap(Person::id, Person::name))
people.stream().collect(Collectors.joining(", ")) // for streams of strings
// counting, summing, averaging
people.stream().collect(Collectors.groupingBy(Person::dept, Collectors.counting()))
Collectors.groupingBy is the workhorse - it does in one line what nested maps-of-lists did in chapter 05:
// Group people by department:
Map<String, List<Person>> byDept = people.stream()
.collect(Collectors.groupingBy(Person::department));
// Count per department:
Map<String, Long> countByDept = people.stream()
.collect(Collectors.groupingBy(Person::department, Collectors.counting()));
// Average age per department:
Map<String, Double> avgAge = people.stream()
.collect(Collectors.groupingBy(Person::department,
Collectors.averagingInt(Person::age)));
Primitive streams¶
IntStream, LongStream, DoubleStream avoid autoboxing (chapter 12) and add numeric methods:
int sum = IntStream.rangeClosed(1, 100).sum(); // 5050
double avg = people.stream().mapToInt(Person::age).average().orElse(0);
IntStream.range(0, 5).forEach(System.out::println); // 0 1 2 3 4
Use mapToInt/mapToObj to move between object and primitive streams.
When NOT to use streams¶
Streams are not always better. Reach for a plain loop when:
- The logic is simple iteration with side effects.
for (var x : list) process(x);is clearer thanlist.forEach(this::process)for plain iteration - and far clearer thanlist.stream().forEach(...)(never add.stream()just to callforEach). - You need the index. Streams hide indices; a classic
for (int i = ...)is cleaner when you needi. - You're mutating external state in the pipeline. Streams should be functional - transform inputs to outputs. Mutating a list or counter from inside
map/forEachis a smell and breaks under parallelism. - Debugging step-by-step matters. A loop is trivial to breakpoint; a long stream chain is harder (though
.peek()helps). - Performance is critical in a hot loop. Streams have small per-element overhead. For most code it's irrelevant; in a tight numeric loop, a plain
forcan be faster (measure - chapter 13).
The guidance: use streams for declarative transformations (filter/map/collect pipelines); use loops for simple iteration, index-dependent logic, and mutation. A stream that's three operations and reads like a sentence is great. A stream with a giant lambda block, side effects, and peek debugging is worse than the loop it replaced.
Optional: representing "maybe a value"¶
Before Optional, "this might return nothing" meant returning null - and every caller forgetting a null check was a NullPointerException waiting to happen. Optional<T> makes absence explicit in the type.
// A method that might not find a result returns Optional, not null.
Optional<User> findUser(long id) {
User u = lookup(id);
return Optional.ofNullable(u); // wraps null as Optional.empty()
}
The caller is now forced by the type to handle absence:
Optional<User> result = findUser(42);
// Good patterns:
String name = result.map(User::name).orElse("unknown"); // transform + default
result.ifPresent(u -> sendEmail(u)); // do something if present
User u = result.orElseThrow(() -> new UserNotFoundException(42)); // or throw
// Chaining through possibly-absent steps:
String city = findUser(42)
.map(User::address) // Optional<Address>
.map(Address::city) // Optional<String>
.orElse("no city"); // safe even if user or address is absent
Using Optional well - and badly¶
Do: - Return Optional from methods that may legitimately find nothing (findById, firstMatching). It tells the caller, in the type, to handle absence. - Chain with map/filter/flatMap to transform safely through possibly-missing values. - End with orElse, orElseGet, orElseThrow, or ifPresent.
Don't: - Don't use Optional for fields - it adds overhead and isn't serializable; use a nullable field with clear documentation, or restructure. - Don't use Optional for method parameters - overload or accept nullable instead; forcing callers to wrap arguments in Optional is clumsy. - Don't call .get() without checking - optional.get() on an empty Optional throws NoSuchElementException, recreating the very NPE problem Optional was meant to solve. Use orElse/orElseThrow instead. - Don't do if (opt.isPresent()) opt.get() - that's just a null check with extra steps. Use ifPresent, map, or orElse.
// BAD - Optional used like a null check
if (result.isPresent()) {
return result.get().name();
}
return "unknown";
// GOOD - functional, no .get()
return result.map(User::name).orElse("unknown");
Optional is for return values that may be absent. That's its job. Used there, it eliminates a category of NPEs; used as a field or parameter, it just adds noise.
A worked example: a small report pipeline¶
Everything together - parse, filter, group, summarize:
record Sale(String region, String product, double amount) {}
List<Sale> sales = loadSales();
// Total sales per region, only counting sales over $100, sorted high to low.
Map<String, Double> totalByRegion = sales.stream()
.filter(s -> s.amount() > 100) // keep significant sales
.collect(Collectors.groupingBy(
Sale::region, // group by region
Collectors.summingDouble(Sale::amount))); // sum amounts per group
// Top product overall by revenue:
Optional<String> topProduct = sales.stream()
.collect(Collectors.groupingBy(
Sale::product,
Collectors.summingDouble(Sale::amount))) // Map<product, total>
.entrySet().stream()
.max(Map.Entry.comparingByValue()) // highest total
.map(Map.Entry::getKey); // just the product name
topProduct.ifPresent(p -> System.out.println("Top product: " + p));
In imperative code this is 30+ lines of nested loops and maps. As a stream pipeline it reads like the requirement. This is where streams shine - declarative aggregation over collections.
Try it¶
-
Lambda to method reference. Write
names.stream().map(s -> s.toUpperCase()).forEach(s -> System.out.println(s)). Convert both lambdas to method references. Confirm identical output and note the readability gain. -
The five functional interfaces. Declare a
Function,Predicate,Consumer,Supplier, andBiFunctioneach as a variable, and call its method. This cements what every stream operation is asking for. -
groupingBy three ways. Given a list of words, build (a)
Map<Integer, List<String>>grouping by length, (b)Map<Integer, Long>counting per length, (c)Map<Character, List<String>>grouping by first letter. Onecollectcall each. -
Stream vs loop judgment. Write "sum of squares of even numbers in a list" as both a stream and a loop. Then write "print each element with its index" as both. Notice the stream wins the first (declarative transform) and the loop wins the second (needs the index). Internalize the boundary.
-
Optional chains. Write
findUserreturningOptional<User>. Chain.map(User::manager).map(User::name).orElse("no manager"). Test with a user who has a manager, one who doesn't, and a missing user. One chain, three cases, no NPE. -
Refactor the anti-pattern. Take
if (opt.isPresent()) { return opt.get().field(); } else { return "default"; }and rewrite as a one-linemap().orElse(). Then find a real null-returning method in your own code and convert it to returnOptional.
What you might wonder¶
"Are streams slower than loops?" Slightly, per element, due to the pipeline machinery and (for object streams) boxing. For the vast majority of code the difference is irrelevant and readability wins. In a measured hot path with millions of iterations, a plain for (especially over primitives) can be meaningfully faster. Rule: write the clear version first, optimize only what profiling (chapter 13) flags.
"What about parallel streams (.parallelStream())?" They split the work across threads automatically. Tempting, but a trap for beginners: they only help for large datasets with CPU-heavy, independent, stateless operations, and they can be slower (or wrong) otherwise. They also use a shared common thread pool. Don't reach for parallelStream until you've measured a real bottleneck and understand the constraints - it's a chapter 11 (concurrency) topic in disguise.
"toList() vs collect(Collectors.toList())?" stream.toList() (Java 16+) is the modern, concise form and returns an unmodifiable list. collect(Collectors.toList()) returns a list with no guarantee about mutability. Prefer .toList() unless you specifically need a mutable result (then collect(Collectors.toCollection(ArrayList::new))).
"When flatMap vs map?" map transforms each element to exactly one element. flatMap transforms each element to a stream of elements and flattens them all into one stream - use it when each input produces zero-or-many outputs (a list of orders, each with many items, into a stream of all items). It's also how you chain Optionals that themselves return Optional.
"Is Optional overhead a problem?" Each Optional is a small object allocation. For return values it's negligible and the safety is worth it. That's why the advice is "return values yes, fields/parameters/hot-loops no" - in a tight loop creating millions of Optionals, the allocation adds up (chapter 12). Right tool, right place.
"Can lambdas access local variables?" Yes, but only effectively final ones - variables you don't reassign after the lambda captures them. The lambda captures the value, not a live reference. This is the same closure-capture rule that causes bugs in concurrency (chapter 09) - worth remembering now.
Done¶
- You can write lambdas and know they implement single-method functional interfaces.
- You know the five core functional interfaces (
Function,Predicate,Consumer,Supplier,BiFunction) - the vocabulary of every modern Java API. - You can convert lambdas to method references when it reads better.
- You can build stream pipelines: source -> intermediate ops -> terminal, with
filter/map/collect/groupingBy/reduce. - You know when not to use a stream (simple iteration, indices, mutation, hot loops).
- You use
Optionalfor absent return values, withmap/orElse, and avoid the.get()and field/parameter anti-patterns.
Next: memory for application developers - heap, stack, references, GC awareness, and how Java programs leak.
Next: Memory for app developers →