Lambda, Stream API & Optional in Java

Series: Java Core for Beginners Lecture: 12 of 12 Topics: Lambda expressions · Method references · Functional interfaces · Stream API · Optional · Putting it all together


Functional Programming in Java

Java has always been an object-oriented language. But starting with Java 8, it embraced a second paradigm: functional programming — treating functions as first-class values that can be passed around, returned from methods, and composed together.

This is not a replacement for OOP — it is a complement. Java’s approach is pragmatic: add functional capabilities where they make code cleaner and more expressive, without abandoning the object model that underpins everything.

The three pillars of functional Java are:

  • Lambda expressions — anonymous functions written inline
  • The Stream API — a pipeline model for processing collections of data
  • Optional — a container that makes the possibility of absence explicit

Together, they transform how you write collection processing code. Compare these two approaches to finding the total salary of all engineers earning above $80,000:

// Imperative style (Lectures 1–9)
double total = 0;
for (Employee emp : employees) {
    if ("Engineering".equals(emp.getDepartment())) {
        if (emp.getSalary() > 80_000) {
            total += emp.getSalary();
        }
    }
}

// Functional style (this lecture)
double total = employees.stream()
    .filter(emp -> "Engineering".equals(emp.getDepartment()))
    .filter(emp -> emp.getSalary() > 80_000)
    .mapToDouble(Employee::getSalary)
    .sum();

The functional version reads like a description of what you want — filter by department, filter by salary, sum the salaries — rather than instructions for how to loop through and accumulate. Both are correct; choosing the right style depends on context.


Lambda Expressions

A lambda expression is an anonymous function — a block of code with parameters and a body, but no name. You saw them briefly in Lecture 6 (functional interfaces). Now we go deep.

Syntax

(parameters) -> expression
(parameters) -> { statements; }
// No parameters
Runnable r = () -> System.out.println("Hello!");

// One parameter — parentheses optional
Consumer<String> printer = s -> System.out.println(s);
Consumer<String> printer = (s) -> System.out.println(s);   // same

// Multiple parameters
Comparator<String> byLength = (a, b) -> a.length() - b.length();

// Block body — multiple statements, explicit return
Comparator<String> complex = (a, b) -> {
    int byLen = Integer.compare(a.length(), b.length());
    if (byLen != 0) return byLen;
    return a.compareTo(b);   // alphabetical for same length
};

Type inference

The compiler infers the type of lambda parameters from context — specifically from the functional interface the lambda is assigned to. You almost never need to write the type explicitly:

// With explicit types — verbose
Comparator<String> comp = (String a, String b) -> a.compareTo(b);

// With inferred types — clean
Comparator<String> comp = (a, b) -> a.compareTo(b);

Lambdas capture variables

A lambda can use variables from the enclosing scope, but those variables must be effectively final — their value never changes after the lambda is created:

String prefix = "Hello";           // effectively final — never reassigned
Consumer<String> greeter = name -> System.out.println(prefix + ", " + name + "!");

greeter.accept("Alice");   // Hello, Alice!
greeter.accept("Bob");     // Hello, Bob!

// prefix = "Hi";   // would make prefix NOT effectively final — lambda would not compile

This restriction exists because lambdas can outlive the method that created them. If the captured variable could change, the lambda’s behavior would become unpredictable.

Lambdas are not anonymous classes

A lambda is not syntactic sugar for an anonymous class — it is a more efficient construct. The JVM handles lambdas with invokedynamic, which typically results in less overhead than an anonymous class. The distinction matters when performance-tuning, but in everyday usage treat them as equivalent for readable code.


Method References

A method reference is a shorthand for a lambda that does nothing but call an existing method. They are more concise and often more readable than the equivalent lambda.

The syntax is: ClassName::methodName or instance::methodName.

Four kinds of method references

1. Static method reference

// Lambda
Function<String, Integer> parse = s -> Integer.parseInt(s);

// Method reference — equivalent
Function<String, Integer> parse = Integer::parseInt;

2. Instance method reference on a particular instance

String prefix = "Hello";

// Lambda
Predicate<String> startsWithHello = s -> prefix.startsWith(s);

// Method reference — calls startsWith on the specific prefix object
Predicate<String> startsWithHello = prefix::startsWith;

3. Instance method reference on an arbitrary instance of a type

// Lambda — s is the receiver, not an argument
Function<String, String> upper = s -> s.toUpperCase();

// Method reference — Java supplies the receiver from the argument
Function<String, String> upper = String::toUpperCase;

This is the most commonly used form. String::toUpperCase means “call toUpperCase() on whatever String is passed as the argument.”

4. Constructor reference

// Lambda
Supplier<ArrayList<String>> listFactory = () -> new ArrayList<>();

// Constructor reference
Supplier<ArrayList<String>> listFactory = ArrayList::new;

// With a parameter — picks the matching constructor
Function<Integer, ArrayList<String>> sizedList = ArrayList::new;   // new ArrayList<>(capacity)

Method references in practice

List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");

// Lambdas
names.stream().map(s -> s.toLowerCase()).forEach(s -> System.out.println(s));

// Method references — cleaner
names.stream().map(String::toLowerCase).forEach(System.out::println);

// Sorting with method reference
names.stream()
     .sorted(String::compareToIgnoreCase)
     .forEach(System.out::println);

// Filtering with an instance method reference
List<String> nonBlank = List.of("Alice", "", "  ", "Bob");
nonBlank.stream()
        .filter(Predicate.not(String::isBlank))   // Predicate.not wraps a method reference
        .forEach(System.out::println);

Built-in Functional Interfaces

java.util.function provides a comprehensive set of functional interfaces. Knowing these by heart lets you use the Stream API and write higher-order methods fluently.

Core four

Interface Method Description Example
Predicate boolean test(T t) Tests a condition s -> s.length() > 5
Function R apply(T t) Transforms a value s -> s.length()
Consumer void accept(T t) Consumes a value, returns nothing s -> System.out.println(s)
Supplier T get() Produces a value, takes nothing () -> new ArrayList<>()

Composition methods

Every core interface provides default methods for composing multiple lambdas:

// Predicate composition
Predicate<String> longWord  = s -> s.length() > 5;
Predicate<String> startsWithA = s -> s.startsWith("A");

Predicate<String> longAndA = longWord.and(startsWithA);     // both must be true
Predicate<String> longOrA  = longWord.or(startsWithA);      // at least one true
Predicate<String> notLong  = longWord.negate();             // inverse

// Function composition
Function<String, String> trim  = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, Integer> len  = String::length;

Function<String, String>  trimThenLower = trim.andThen(lower);   // trim first, then lower
Function<String, Integer> lowerThenLen  = lower.andThen(len);    // lower first, then length
Function<String, String>  lowerThenTrim = trim.compose(lower);   // lower first, then trim
List<String> words = List.of("  APPLE  ", "  banana  ", "  Fig  ", "  elderberry  ");

words.stream()
     .map(trim.andThen(lower))
     .filter(longWord)
     .forEach(System.out::println);
// banana
// elderberry

Specialised variants

Interface Description
BiFunction Like Function but takes two inputs
BiPredicate Like Predicate but takes two inputs
BiConsumer Like Consumer but takes two inputs
UnaryOperator Function — input and output same type
BinaryOperator BiFunction — two inputs, same type output
IntFunction Function specialized for int input
ToIntFunction Function specialized for int output
IntSupplier Supplier for int — avoids boxing
IntConsumer Consumer for int — avoids boxing
IntUnaryOperator UnaryOperator for int — avoids boxing

The primitive-specialized variants (IntPredicate, LongFunction, DoubleConsumer, etc.) avoid autoboxing overhead — use them when performance matters.


The Stream API

A stream is a sequence of elements supporting sequential and parallel aggregate operations. Streams are not data structures — they do not store elements. They carry elements from a source through a pipeline of operations to a terminal result.

The stream pipeline model

Source → [Intermediate operations]* → Terminal operation
  • Source — where elements come from: a collection, an array, a file, a range
  • Intermediate operations — transform the stream lazily: filter, map, sorted, distinct
  • Terminal operation — triggers execution and produces a result: forEach, collect, count, reduce

Streams are lazy. No intermediate operation executes until a terminal operation is called. The JVM can then optimize the entire pipeline — for example, a filter followed by findFirst stops processing as soon as the first matching element is found, even if the source has millions of elements.

Streams are consumed once. You cannot reuse a stream. Once a terminal operation has been called, the stream is exhausted. Create a new stream for each pipeline.

Creating streams

// From a Collection
List<String> names = List.of("Alice", "Bob", "Charlie");
Stream<String> fromList = names.stream();

// Parallel stream — processed by multiple threads (use carefully)
Stream<String> parallel = names.parallelStream();

// From an array
int[]          arr   = {1, 2, 3, 4, 5};
IntStream      intS  = Arrays.stream(arr);
Stream<String> arrS  = Arrays.stream(new String[]{"a", "b", "c"});

// From values directly
Stream<String> ofValues = Stream.of("x", "y", "z");

// From a range (IntStream, LongStream)
IntStream range      = IntStream.range(1, 6);      // 1, 2, 3, 4, 5 (exclusive end)
IntStream rangeClosed = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5 (inclusive end)

// Infinite stream (must be limited)
Stream<Integer> naturals = Stream.iterate(1, n -> n + 1);   // 1, 2, 3, ...
Stream<Double>  randoms  = Stream.generate(Math::random);   // infinite random doubles

// From a file
Stream<String> fileLines = Files.lines(Path.of("data.txt")); // close after use!

// Empty stream
Stream<String> empty = Stream.empty();

Intermediate Operations

Intermediate operations return a new Stream. They are lazy — they are not executed until a terminal operation demands elements.

filter — selecting elements

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

numbers.stream()
       .filter(n -> n % 2 == 0)   // keep only even numbers
       .forEach(System.out::println);   // 2 4 6 8 10

map — transforming elements

List<String> names = List.of("Alice", "Bob", "Charlie");

// Transform each String to its length
List<Integer> lengths = names.stream()
    .map(String::length)
    .collect(Collectors.toList());   // [5, 3, 7]

// Transform each String to uppercase
names.stream()
     .map(String::toUpperCase)
     .forEach(System.out::println);   // ALICE, BOB, CHARLIE

mapToInt, mapToDouble, mapToLong — primitive streams

When the mapped type is a primitive, use the specialized mapTo* variants to avoid boxing:

List<String> words = List.of("apple", "banana", "cherry");

int totalLength = words.stream()
    .mapToInt(String::length)   // IntStream — no boxing
    .sum();                     // 5 + 6 + 6 = 17

double avgLength = words.stream()
    .mapToInt(String::length)
    .average()
    .orElse(0.0);   // 5.666...

flatMap — flattening nested structures

map produces one output element per input. flatMap produces zero or more output elements per input, flattening the results into a single stream:

List<List<Integer>> nested = List.of(
    List.of(1, 2, 3),
    List.of(4, 5),
    List.of(6, 7, 8, 9)
);

List<Integer> flat = nested.stream()
    .flatMap(List::stream)   // each inner list becomes a stream; all are merged
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

Real-world use: splitting each sentence in a list into words:

List<String> sentences = List.of("hello world", "java is great", "streams are powerful");

List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .distinct()
    .sorted()
    .collect(Collectors.toList());
// [are, great, hello, is, java, powerful, streams, world]

distinct — removing duplicates

List<Integer> nums = List.of(1, 2, 2, 3, 3, 3, 4);
nums.stream().distinct().forEach(System.out::print);   // 1 2 3 4

Uses equals() and hashCode() for comparison — works correctly for String, Integer, and any class with properly overridden methods.

sorted — ordering elements

List<String> names = List.of("Charlie", "Alice", "Bob", "Dave");

// Natural order
names.stream()
     .sorted()
     .forEach(System.out::println);   // Alice, Bob, Charlie, Dave

// Custom comparator
names.stream()
     .sorted(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()))
     .forEach(System.out::println);   // Bob, Dave, Alice, Charlie

// Reverse order
names.stream()
     .sorted(Comparator.reverseOrder())
     .forEach(System.out::println);   // Dave, Charlie, Bob, Alice

limit and skip — slicing streams

// First N elements
Stream.iterate(1, n -> n + 1)
      .limit(5)
      .forEach(System.out::print);   // 1 2 3 4 5

// Skip first N elements
List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream()
    .skip(4)
    .forEach(System.out::print);     // 5 6 7 8 9 10

// Pagination — skip page * size elements, take size
int page = 2, size = 3;
List.of(1,2,3,4,5,6,7,8,9,10).stream()
    .skip((long) page * size)
    .limit(size)
    .forEach(System.out::print);     // 7 8 9

peek — inspecting elements without consuming

peek passes each element through unchanged but lets you observe them — useful for debugging a pipeline:

List<String> result = List.of("  Alice  ", "  bob  ", "  CHARLIE  ").stream()
    .peek(s -> System.out.println("Before: " + s))
    .map(String::trim)
    .peek(s -> System.out.println("After trim: " + s))
    .map(String::toLowerCase)
    .collect(Collectors.toList());

Do not use peek for side effects in production code — it is for debugging only. Its execution is not guaranteed in all pipeline configurations.

takeWhile and dropWhile (Java 9+)

// takeWhile — take elements while condition is true, stop at first false
List.of(2, 4, 6, 7, 8, 10).stream()
    .takeWhile(n -> n % 2 == 0)
    .forEach(System.out::print);   // 2 4 6  (stops at 7)

// dropWhile — skip elements while condition is true, take the rest
List.of(2, 4, 6, 7, 8, 10).stream()
    .dropWhile(n -> n % 2 == 0)
    .forEach(System.out::print);   // 7 8 10  (drops 2 4 6)

Terminal Operations

Terminal operations trigger the pipeline and produce a result. After a terminal operation runs, the stream is exhausted.

forEach — consuming each element

List<String> names = List.of("Alice", "Bob", "Charlie");
names.stream().forEach(System.out::println);

// forEachOrdered — guaranteed order even on parallel streams
names.parallelStream().forEachOrdered(System.out::println);

count — counting elements

long count = List.of(1, 2, 3, 4, 5).stream()
                 .filter(n -> n > 3)
                 .count();
System.out.println(count);   // 2

collect — accumulating into a container

collect is the most powerful terminal operation — covered in full in section 8.

List<String> filtered = names.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());

reduce — combining elements into one value

reduce applies a binary operation repeatedly, accumulating a single result:

// Sum using reduce
int sum = List.of(1, 2, 3, 4, 5).stream()
              .reduce(0, Integer::sum);   // 0 + 1 + 2 + 3 + 4 + 5 = 15

// Product
int product = List.of(1, 2, 3, 4, 5).stream()
                  .reduce(1, (a, b) -> a * b);   // 120

// Max without identity (returns Optional)
Optional<Integer> max = List.of(3, 1, 4, 1, 5, 9).stream()
                             .reduce(Integer::max);
max.ifPresent(m -> System.out.println("Max: " + m));   // Max: 9

min and max

Optional<String> shortest = List.of("banana", "fig", "apple", "elderberry").stream()
    .min(Comparator.comparingInt(String::length));
shortest.ifPresent(System.out::println);   // fig

Optional<Integer> max = Stream.of(3, 1, 4, 1, 5, 9, 2, 6).max(Integer::compareTo);
max.ifPresent(System.out::println);        // 9

findFirst and findAny

// findFirst — first element matching filter (deterministic)
Optional<String> first = List.of("Alice", "Bob", "Alexander", "Anne").stream()
    .filter(s -> s.startsWith("A"))
    .findFirst();
System.out.println(first.orElse("none"));   // Alice

// findAny — any matching element (faster on parallel streams, non-deterministic)
Optional<String> any = List.of("Alice", "Bob", "Alexander").parallelStream()
    .filter(s -> s.startsWith("A"))
    .findAny();

anyMatch, allMatch, noneMatch

Short-circuit terminal operations — stop as soon as the result is determined:

List<Integer> numbers = List.of(2, 4, 6, 7, 8);

boolean anyOdd  = numbers.stream().anyMatch(n -> n % 2 != 0);   // true (7 is odd)
boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0);   // false
boolean noneNeg = numbers.stream().noneMatch(n -> n < 0);       // true

toArray

String[] array = List.of("Alice", "Bob", "Charlie").stream()
                     .toArray(String[]::new);

Numeric reduction — sum, average, min, max, summaryStatistics

IntStream, LongStream, and DoubleStream have additional numeric terminal operations:

int[] values = {10, 20, 30, 40, 50};
IntStream s = Arrays.stream(values);

// These only exist on primitive streams
s.sum();       // 150
s.average();   // OptionalDouble[30.0]
s.min();       // OptionalInt[10]
s.max();       // OptionalInt[50]

// Summary statistics — all in one pass
IntSummaryStatistics stats = Arrays.stream(values).summaryStatistics();
System.out.println("Count:   " + stats.getCount());    // 5
System.out.println("Sum:     " + stats.getSum());      // 150
System.out.println("Min:     " + stats.getMin());      // 10
System.out.println("Max:     " + stats.getMax());      // 50
System.out.println("Average: " + stats.getAverage());  // 30.0

Collectors

Collectors is a utility class providing ready-made Collector implementations for the most common collection operations.

Collecting to collections

import java.util.stream.Collectors;

List<String> names = List.of("Alice", "Bob", "Charlie", "Alice", "Bob");

// To list (preserves order, allows duplicates)
List<String> list = names.stream().collect(Collectors.toList());

// To unmodifiable list (Java 10+)
List<String> unmodifiable = names.stream().collect(Collectors.toUnmodifiableList());

// To set (removes duplicates, no guaranteed order)
Set<String> set = names.stream().collect(Collectors.toSet());

// To a specific collection type
LinkedList<String> linked = names.stream()
    .collect(Collectors.toCollection(LinkedList::new));

TreeSet<String> sorted = names.stream()
    .collect(Collectors.toCollection(TreeSet::new));

Joining strings

List<String> words = List.of("Java", "is", "awesome");

String joined = words.stream().collect(Collectors.joining());
// "Javaisawesome"

String withDelimiter = words.stream().collect(Collectors.joining(", "));
// "Java, is, awesome"

String withAll = words.stream().collect(Collectors.joining(", ", "[", "]"));
// "[Java, is, awesome]"

Counting and summarizing

long count = names.stream().collect(Collectors.counting());

// Average
OptionalDouble avg = names.stream()
    .collect(Collectors.averagingInt(String::length));

// Summarizing
IntSummaryStatistics stats = names.stream()
    .collect(Collectors.summarizingInt(String::length));

groupingBy — partitioning into a Map

groupingBy is one of the most powerful collectors. It groups elements by a classifier function:

List<String> names = List.of("Alice", "Bob", "Charlie", "Anna", "Barbara", "Carl");

// Group by first letter
Map<Character, List<String>> byFirstLetter = names.stream()
    .collect(Collectors.groupingBy(s -> s.charAt(0)));
// {A=[Alice, Anna], B=[Bob, Barbara], C=[Charlie, Carl]}

// Group and count
Map<Character, Long> countByFirstLetter = names.stream()
    .collect(Collectors.groupingBy(s -> s.charAt(0), Collectors.counting()));
// {A=2, B=2, C=2}

// Group and join
Map<Character, String> joinedByLetter = names.stream()
    .collect(Collectors.groupingBy(
        s -> s.charAt(0),
        Collectors.joining(", ")));
// {A=Alice, Anna, B=Bob, Barbara, C=Charlie, Carl}

partitioningBy — splitting into two groups

partitioningBy is like groupingBy with a boolean classifier — it always produces a map with exactly two keys, true and false:

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Map<Boolean, List<Integer>> evenOdd = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]}

List<Integer> evens = evenOdd.get(true);
List<Integer> odds  = evenOdd.get(false);

toMap

List<String> words = List.of("apple", "banana", "cherry");

// word → its length
Map<String, Integer> wordLengths = words.stream()
    .collect(Collectors.toMap(
        s -> s,           // key: the word itself
        String::length    // value: its length
    ));
// {apple=5, banana=6, cherry=6}

// Handle duplicate keys with a merge function
Map<Integer, String> lengthToWord = words.stream()
    .collect(Collectors.toMap(
        String::length,          // key: length
        s -> s,                  // value: the word
        (existing, replacement) -> existing + ", " + replacement  // merge on duplicate keys
    ));
// {5=apple, 6=banana, cherry}  → actually {5=apple, 6=banana, cherry}

// Maintain insertion order with LinkedHashMap
Map<String, Integer> ordered = words.stream()
    .collect(Collectors.toMap(
        s -> s,
        String::length,
        (a, b) -> a,
        LinkedHashMap::new   // map supplier — controls the Map implementation
    ));

Optional

Optional is a container that either holds a non-null value of type T or is empty. It makes the possibility of absence explicit in the type — a method returning Optional clearly signals that the user might not exist.

Creating Optional

Optional<String> present = Optional.of("Alice");           // must be non-null
Optional<String> empty   = Optional.empty();               // explicitly empty
Optional<String> maybe   = Optional.ofNullable(getUser()); // null becomes empty

Never call Optional.of(null) — it throws NullPointerException. Use Optional.ofNullable() when the value might be null.

Checking and extracting the value

Optional<String> opt = Optional.of("Alice");

opt.isPresent()   // true
opt.isEmpty()     // false (Java 11+)
opt.get()         // "Alice" — throws NoSuchElementException if empty — avoid!

Avoid opt.get() without a prior isPresent() check. It defeats the purpose of Optional — you still get a runtime exception. Use the safer methods below instead.

Consuming the value safely

Optional<String> name = findUser("alice");

// ifPresent — run action only if present
name.ifPresent(n -> System.out.println("Found: " + n));

// ifPresentOrElse (Java 9+) — run one of two actions
name.ifPresentOrElse(
    n -> System.out.println("Found: " + n),
    () -> System.out.println("Not found.")
);

Providing defaults

Optional<String> name = Optional.empty();

// orElse — return default value (always evaluated, even if value is present)
String result1 = name.orElse("Anonymous");                  // "Anonymous"

// orElseGet — return result of supplier (only called if empty — lazy)
String result2 = name.orElseGet(() -> "User-" + randomId()); // lazy evaluation

// orElseThrow — throw exception if empty
String result3 = name.orElseThrow(() ->
    new UserNotFoundException("No default user available.")); // throws if empty

// orElseThrow() with no argument (Java 10+) — throws NoSuchElementException
String result4 = name.orElseThrow();

Prefer orElseGet() over orElse() when the default is expensive to compute. orElse() always evaluates its argument — even when the Optional is present.

Transforming the value

Optional has map, flatMap, and filter — analogous to Stream operations:

Optional<String> name = Optional.of("  alice  ");

// map — transform if present, empty stays empty
Optional<String> upper = name
    .map(String::trim)
    .map(String::toUpperCase);
System.out.println(upper);   // Optional[ALICE]

// filter — keep if predicate passes, discard otherwise
Optional<String> longName = Optional.of("Alexander")
    .filter(s -> s.length() > 5);
System.out.println(longName);           // Optional[Alexander]

Optional<String> shortName = Optional.of("Bob")
    .filter(s -> s.length() > 5);
System.out.println(shortName);          // Optional.empty

// flatMap — for when the mapper itself returns an Optional
Optional<String> email = findUser("alice")
    .flatMap(user -> findEmail(user.getId()));   // avoids Optional<Optional<String>>

Chaining Optionals — a realistic example

// Without Optional — null checks everywhere
User user = userRepo.findById(userId);
if (user != null) {
    Address addr = user.getAddress();
    if (addr != null) {
        City city = addr.getCity();
        if (city != null) {
            return city.getName();
        }
    }
}
return "Unknown";

// With Optional — linear, readable chain
return userRepo.findById(userId)          // Optional<User>
    .map(User::getAddress)                // Optional<Address>
    .map(Address::getCity)               // Optional<City>
    .map(City::getName)                  // Optional<String>
    .orElse("Unknown");

What Optional is NOT for

Optional is intended as a return type for methods that might not return a value. It is NOT intended to be:

  • A field type in a class (Optional name as a field — use null or require non-null instead)
  • A parameter type (void process(Optional name) — just use nullable parameter or overloading)
  • Used in collections (List> — strange and usually unnecessary)

Putting It Together — A Complete Example

Let us build a sales analytics system that uses every concept from this lecture:

import java.util.*;
import java.util.stream.*;
import java.util.function.*;

// Domain model
public record Product(String id, String name, String category, double price) {}

public record SaleRecord(String saleId, Product product, int quantity,
                         String region, String salesPerson) {
    public double total() { return product.price() * quantity; }
}
public class SalesAnalytics {

    // ── Data loading ──────────────────────────────────────────────
    public static List<SaleRecord> generateSampleData() {
        List<Product> products = List.of(
            new Product("P001", "Java Book",      "Books",       49.99),
            new Product("P002", "Python Book",    "Books",       44.99),
            new Product("P003", "Laptop Stand",   "Electronics", 89.99),
            new Product("P004", "USB Hub",        "Electronics", 29.99),
            new Product("P005", "Desk Lamp",      "Furniture",   59.99),
            new Product("P006", "Office Chair",   "Furniture",  299.99)
        );
        String[] regions  = {"North", "South", "East", "West"};
        String[] persons  = {"Alice", "Bob", "Charlie", "Diana", "Eve"};
        Random rng = new Random(42);

        return IntStream.range(0, 200)
            .mapToObj(i -> new SaleRecord(
                "S" + String.format("%04d", i + 1),
                products.get(rng.nextInt(products.size())),
                rng.nextInt(5) + 1,
                regions[rng.nextInt(regions.length)],
                persons[rng.nextInt(persons.length)]
            ))
            .collect(Collectors.toList());
    }

    // ── Analysis methods ──────────────────────────────────────────

    // Total revenue across all sales
    public static double totalRevenue(List<SaleRecord> sales) {
        return sales.stream()
            .mapToDouble(SaleRecord::total)
            .sum();
    }

    // Revenue by category — sorted descending
    public static Map<String, Double> revenueByCategory(List<SaleRecord> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                s -> s.product().category(),
                Collectors.summingDouble(SaleRecord::total)
            ))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                Map.Entry::getValue,
                (a, b) -> a,
                LinkedHashMap::new
            ));
    }

    // Top N salespeople by revenue
    public static List<Map.Entry<String, Double>> topSalespeople(
            List<SaleRecord> sales, int n) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                SaleRecord::salesPerson,
                Collectors.summingDouble(SaleRecord::total)
            ))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(n)
            .collect(Collectors.toList());
    }

    // Best-selling product in a given category
    public static Optional<String> bestSellerInCategory(
            List<SaleRecord> sales, String category) {
        return sales.stream()
            .filter(s -> s.product().category().equalsIgnoreCase(category))
            .collect(Collectors.groupingBy(
                s -> s.product().name(),
                Collectors.summingInt(SaleRecord::quantity)
            ))
            .entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .map(Map.Entry::getKey);
    }

    // Sales summary per region
    public static Map<String, IntSummaryStatistics> quantityStatsByRegion(
            List<SaleRecord> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                SaleRecord::region,
                Collectors.summarizingInt(SaleRecord::quantity)
            ));
    }

    // Products with no sales in a given region
    public static List<String> unsoldProductsInRegion(
            List<SaleRecord> allSales, List<Product> allProducts, String region) {
        Set<String> soldIds = allSales.stream()
            .filter(s -> s.region().equalsIgnoreCase(region))
            .map(s -> s.product().id())
            .collect(Collectors.toSet());

        return allProducts.stream()
            .filter(p -> !soldIds.contains(p.id()))
            .map(Product::name)
            .sorted()
            .collect(Collectors.toList());
    }

    // Average order value per salesperson (formatted as string map)
    public static Map<String, String> avgOrderValuePerPerson(List<SaleRecord> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                SaleRecord::salesPerson,
                Collectors.averagingDouble(SaleRecord::total)
            ))
            .entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> String.format("$%,.2f", e.getValue()),
                (a, b) -> a,
                TreeMap::new   // alphabetical by person name
            ));
    }

    // ── Main ──────────────────────────────────────────────────────
    public static void main(String[] args) {
        List<SaleRecord>  sales    = generateSampleData();
        List<Product>     products = sales.stream()
            .map(SaleRecord::product)
            .distinct()
            .collect(Collectors.toList());

        // 1. Total revenue
        System.out.printf("Total revenue: $%,.2f%n%n", totalRevenue(sales));

        // 2. Revenue by category
        System.out.println("Revenue by category:");
        revenueByCategory(sales).forEach((cat, rev) ->
            System.out.printf("  %-15s $%,.2f%n", cat, rev));

        // 3. Top 3 salespeople
        System.out.println("\nTop 3 salespeople:");
        topSalespeople(sales, 3).forEach(e ->
            System.out.printf("  %-10s $%,.2f%n", e.getKey(), e.getValue()));

        // 4. Best seller in Electronics
        bestSellerInCategory(sales, "Electronics")
            .ifPresentOrElse(
                name -> System.out.println("\nBest Electronics seller: " + name),
                ()   -> System.out.println("\nNo Electronics sales found.")
            );

        // 5. Quantity stats by region
        System.out.println("\nQuantity stats by region:");
        quantityStatsByRegion(sales).entrySet().stream()
            .sorted(Map.Entry.comparingByKey())
            .forEach(e -> {
                IntSummaryStatistics s = e.getValue();
                System.out.printf("  %-6s count=%d  min=%d  max=%d  avg=%.1f%n",
                    e.getKey(), s.getCount(), s.getMin(), s.getMax(), s.getAverage());
            });

        // 6. Unsold products in South
        List<String> unsold = unsoldProductsInRegion(sales, products, "South");
        System.out.println("\nProducts not sold in South: " +
            (unsold.isEmpty() ? "none" : String.join(", ", unsold)));

        // 7. Average order value per person
        System.out.println("\nAverage order value per salesperson:");
        avgOrderValuePerPerson(sales).forEach((person, avg) ->
            System.out.printf("  %-10s %s%n", person, avg));
    }
}

This example shows:

  • record for concise immutable data classes
  • IntStream.range() with mapToObj to generate data
  • groupingBy + summingDouble for revenue aggregation
  • Chained stream pipeline to sort a Map by value
  • Optional returned by bestSellerInCategory and consumed with ifPresentOrElse
  • summarizingInt for multi-stat aggregation in one pass
  • Set-based membership test for “unsold products” logic
  • TreeMap::new supplier to control output map ordering
  • Collectors.toMap with merge function to build a sorted result map

Series Summary

You have reached the end of the Java Core series. Here is what you have mastered across all twelve lectures:

Phase 1 — Getting Started

  • Set up the JDK and IntelliJ IDEA, understood the JVM/JRE/JDK distinction, wrote and ran your first Java program.
  • Mastered all eight primitive types, reference types, variables, casting, operators, and the String API.
  • Controlled program flow with if/else, switch expressions, for, while, do-while, break, and continue.

Phase 2 — Object-Oriented Programming

  • Designed classes with fields, constructors, methods, and access modifiers. Created objects, understood the heap and garbage collection.
  • Built inheritance hierarchies with extends, overriding, super, polymorphism, abstract classes, and final.
  • Used interfaces for multiple capability inheritance, default and static methods, functional interfaces, and deep encapsulation with defensive copying and immutability.

Phase 3 — Core APIs

  • Worked with arrays (1D and 2D), the Arrays utility class, and the full String API including StringBuilder and String.format.
  • Navigated the Collections Framework: ArrayList, LinkedList, HashSet, TreeSet, HashMap, TreeMap, and factory methods.
  • Wrote type-safe generic classes, methods, and interfaces. Applied bounded type parameters, wildcards (PECS), and understood type erasure.

Phase 4 — Error Handling & I/O

  • Handled exceptions with try-catch-finally, try-with-resources, multi-catch, and exception chaining. Created meaningful custom exception hierarchies.
  • Read and wrote files using NIO.2 (Path, Files), BufferedReader/BufferedWriter, byte and character streams, and walked directory trees.

Phase 5 — Modern Java

  • Wrote lambda expressions and method references. Composed functional interfaces (Predicate, Function, Consumer, Supplier).
  • Built Stream pipelines with filter, map, flatMap, sorted, distinct, limit, skip, and terminal operations (collect, reduce, findFirst, anyMatch).
  • Used Collectors for grouping, partitioning, joining, and mapping. Handled absent values elegantly with Optional.

Where to go next

This series covers Java Core — the language and its standard library. The natural next steps:

  1. Build something. Apply everything by building a complete project — a command-line tool, a REST API with Spring Boot, a game, a data pipeline.
  2. Java Advanced topics — multithreading and concurrency (ExecutorService, CompletableFuture), reflection, annotations, modules (Java 9+).
  3. Spring Boot — the dominant Java framework for web applications. Everything in this series is a prerequisite.
  4. Databases with JDBC and JPA — connecting Java to relational databases.
  5. Testing with JUnit 5 and Mockito — writing automated tests for the code you build.
  6. Design patterns — reusable solutions to common object-oriented design problems.

Exercises

Exercise 1 — Lambda and Comparator Given a List of full names like ["Charlie Brown", "Alice Smith", "Bob Jones"]:

  • Sort by last name, then first name for ties — using a lambda Comparator
  • Sort by name length descending, then alphabetically for ties — using Comparator.comparingInt().reversed().thenComparing()
  • Remove all names where either part is fewer than 4 characters
  • Convert to "LAST, First" format using map
  • Print each result using forEach

Exercise 2 — Stream pipeline chain Using a single stream pipeline (no intermediate variables), take the sentence "the quick brown fox jumps over the lazy dog", split it into words, filter out words shorter than 4 characters, capitalize the first letter of each remaining word, sort them alphabetically, remove duplicates, collect into a List, and print the result as a comma-separated string using Collectors.joining.

Exercise 3 — groupingBy analytics Create a List of at least 20 Transaction records (each with date as LocalDate, category as String, amount as double, and type as "INCOME" or "EXPENSE"). Use the Stream API and Collectors.groupingBy to:

  • Total income and expense by category
  • Find the month with the highest total spending (group by YearMonth)
  • List all categories where total expenses exceed total income
  • Find the top 3 expense categories

Exercise 4 — Optional chain Model a CompanyDepartmentEmployeeContactInfoPhoneNumber hierarchy where any link in the chain might be absent. Write a method getPhoneNumber(Company company, String deptName, String empName) that returns Optional. Each lookup method returns an Optional. Write the entire method body as a single flatMap/map chain with no if statements and no .get() calls.

Exercise 5 — Custom Collector Implement a custom Collector>> named groupByLength that groups strings by their length. Use it as stream.collect(groupByLength()). Then write a second custom collector toSortedMap(Function keyMapper, Function valueMapper) that collects to a TreeMap. Hint: look at how Collector.of() works.

Exercise 6 — Full pipeline: file to analytics Reuse the CSV file from Lecture 11 Exercise 2 (grades.csv). Read it using Files.lines(), skip the header, parse each line into a Student record (name, math, english, science), compute average per student. Then using streams:

  • Find the student with the highest overall average
  • Compute subject averages across all students
  • Identify students below the class average in at least two subjects
  • Write a markdown summary report to analytics.md using a PrintWriter wrapped in Files.newBufferedWriter

This exercise combines File I/O (Lecture 11), Generics (Lecture 9), Exception Handling (Lecture 10), Collections (Lecture 8), and the full Stream API — a capstone for the entire series.


Congratulations on completing the Java Core series. You now have a solid foundation in every core aspect of the Java language. Go build something!

Leave a Reply

Your email address will not be published. Required fields are marked *