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 (
Optionalas a field — usename nullor require non-null instead) - A parameter type (
void process(Optional— just use nullable parameter or overloading)name) - 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:
recordfor concise immutable data classesIntStream.range()withmapToObjto generate datagroupingBy+summingDoublefor revenue aggregation- Chained stream pipeline to sort a Map by value
Optionalreturned bybestSellerInCategoryand consumed withifPresentOrElsesummarizingIntfor multi-stat aggregation in one pass- Set-based membership test for “unsold products” logic
TreeMap::newsupplier to control output map orderingCollectors.toMapwith 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
StringAPI. - Controlled program flow with
if/else,switchexpressions,for,while,do-while,break, andcontinue.
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, andfinal. - 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
Arraysutility class, and the fullStringAPI includingStringBuilderandString.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
Collectorsfor grouping, partitioning, joining, and mapping. Handled absent values elegantly withOptional.
Where to go next
This series covers Java Core — the language and its standard library. The natural next steps:
- Build something. Apply everything by building a complete project — a command-line tool, a REST API with Spring Boot, a game, a data pipeline.
- Java Advanced topics — multithreading and concurrency (
ExecutorService,CompletableFuture), reflection, annotations, modules (Java 9+). - Spring Boot — the dominant Java framework for web applications. Everything in this series is a prerequisite.
- Databases with JDBC and JPA — connecting Java to relational databases.
- Testing with JUnit 5 and Mockito — writing automated tests for the code you build.
- 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 usingmap - 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 Company → Department → Employee → ContactInfo → PhoneNumber 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 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.mdusing aPrintWriterwrapped inFiles.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!
