04 · Lambdas & Streams API¶
Lambda expressions let you pass a block of behavior around like a value. Combined with the Streams API, they let you describe what transformation you want on a collection instead of writing manual loops for how to do it.
Functional interfaces¶
A functional interface is any interface with exactly one abstract method
— that single method is what a lambda expression implements. Runnable (one
method, run()) is a classic built-in example.
You can define your own with @FunctionalInterface (the annotation isn't
required, but it makes intent clear and the compiler will error if you
accidentally add a second abstract method):
@FunctionalInterface
interface Calculator {
int operate(int a, int b);
}
Calculator add = (a, b) -> a + b;
Calculator multiply = (a, b) -> a * b;
System.out.println(add.operate(3, 4)); // 7
System.out.println(multiply.operate(3, 4)); // 12
Lambda syntax¶
// Full form
Calculator subtract = (int a, int b) -> { return a - b; };
// Type inference + implicit return (no braces needed for a single expression)
Calculator divide = (a, b) -> a / b;
// No parameters
Runnable greet = () -> System.out.println("Hi!");
// Exactly one parameter -- parentheses optional
java.util.function.Function<String, Integer> length = s -> s.length();
Built-in functional interfaces (java.util.function)¶
Java ships a standard library of general-purpose functional interfaces so you rarely need to declare your own:
import java.util.function.*;
Function<String, Integer> parseLength = String::length; // T -> R
Predicate<Integer> isEven = n -> n % 2 == 0; // T -> boolean
Consumer<String> print = System.out::println; // T -> void
Supplier<Double> random = Math::random; // () -> R
BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b; // (T, U) -> R
System.out.println(parseLength.apply("hello")); // 5
System.out.println(isEven.test(7)); // false
print.accept("printed via Consumer");
System.out.println(random.get()); // some double between 0 and 1
System.out.println(sum.apply(3, 4)); // 7
| Interface | Method | Signature | Typical use |
|---|---|---|---|
Function<T,R> |
apply(T) |
T -> R |
Transform a value |
Predicate<T> |
test(T) |
T -> boolean |
A yes/no check, e.g. for filter |
Consumer<T> |
accept(T) |
T -> void |
Do something with a value, no result |
Supplier<T> |
get() |
() -> T |
Produce/lazily generate a value |
BiFunction<T,U,R> |
apply(T,U) |
(T,U) -> R |
Combine two values into a result |
Method references¶
When a lambda does nothing but call an existing method, a method reference is a shorter, often clearer alternative.
List<String> names = new ArrayList<>(List.of("charlie", "alice", "bob"));
names.forEach(System.out::println); // instance method on an implied argument
names.sort(String::compareToIgnoreCase); // instance method, two args
names.replaceAll(String::toUpperCase); // instance method on the element
names.forEach(name -> print.accept(name)); // equivalent lambda, for comparison
| Kind | Example | Equivalent lambda |
|---|---|---|
| Static method | Integer::parseInt |
s -> Integer.parseInt(s) |
| Instance method (bound) | str::toUpperCase |
() -> str.toUpperCase() |
| Instance method (unbound) | String::toUpperCase |
s -> s.toUpperCase() |
| Constructor | ArrayList::new |
() -> new ArrayList<>() |
The Streams API pipeline¶
A stream is a one-time-use pipeline over a data source: zero or more
intermediate operations (like filter, map, sorted — each returns a
new stream) followed by exactly one terminal operation (like collect,
forEach, count — which triggers the actual processing).
import java.util.List;
import java.util.stream.Collectors;
List<String> words = List.of("banana", "kiwi", "apple", "fig", "cherry");
List<String> result = words.stream()
.filter(w -> w.length() > 4) // keep words longer than 4 chars
.map(String::toUpperCase) // transform each to uppercase
.sorted() // sort alphabetically
.collect(Collectors.toList()); // terminal op -- materialize into a List
System.out.println(result); // [APPLE, BANANA, CHERRY]
Nothing runs until collect is called — intermediate operations just build
up the pipeline description ("lazy evaluation").
Common terminal operations¶
List<Integer> numbers = List.of(4, 9, 2, 7, 5, 1);
long count = numbers.stream().filter(n -> n > 3).count();
System.out.println(count); // 4
int total = numbers.stream().reduce(0, Integer::sum);
System.out.println(total); // 28
int max = numbers.stream().reduce(Integer.MIN_VALUE, Integer::max);
System.out.println(max); // 9
// IntStream avoids boxing overhead for primitive sums
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
System.out.println(sum); // 28
Set<Integer> asSet = numbers.stream().collect(Collectors.toSet());
Map<Boolean, List<Integer>> byParity = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(byParity); // {false=[9, 7, 5, 1], true=[4, 2]}
A realistic example: filter + map + collect over objects¶
public record Employee(String name, String department, double salary) {}
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Sales", 62000),
new Employee("Cara", "Engineering", 88000),
new Employee("Dave", "Sales", 71000)
);
List<String> highEarnersInEngineering = employees.stream()
.filter(e -> e.department().equals("Engineering"))
.filter(e -> e.salary() > 90000)
.map(Employee::name)
.collect(Collectors.toList());
System.out.println(highEarnersInEngineering); // [Alice]
double totalSalary = employees.stream()
.mapToDouble(Employee::salary)
.sum();
System.out.println(totalSalary); // 316000.0
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println(namesByDept);
// {Engineering=[Alice, Cara], Sales=[Bob, Dave]}
Records (used for Employee above) are covered fully in
Module 9.
How It Actually Works¶
A lambda expression does not compile to an anonymous inner class.
javac emits an invokedynamic instruction whose bootstrap method
(LambdaMetafactory.metafactory) is invoked once, lazily, the first
time that lambda expression is reached — it generates a small hidden
class implementing the target functional interface, wires it up via a
MethodHandle to your lambda body (compiled as a private synthetic
method), and caches the generated CallSite so subsequent hits are just
a direct call. This is why lambdas have near-zero per-call overhead
after the first invocation but a one-time class-generation cost, and why
they show up as synthetic $$Lambda classes rather than
OuterClass$1-style anonymous classes in a stack trace.
Stream pipelines are lazy and single-pass: intermediate operations
(map, filter) just build up a chain of Sink objects; nothing
executes until a terminal operation (collect, forEach, reduce)
pulls elements through the whole chain one at a time. This lets
stream().filter(...).map(...).findFirst() short-circuit after
processing a single element instead of materializing an intermediate
collection at every stage — a real fusion optimization, not just
readable syntax.
parallelStream() splits work using the common ForkJoinPool
(work-stealing deques across CPU cores) via a Spliterator's
trySplit(), which is why parallel streams help on CPU-bound,
splittable, large workloads and actively hurt on small or I/O-bound ones
— the fork/join overhead and shared-pool contention outweigh the gain.
Exercise¶
Given a List<String> of product names with mixed casing and some
duplicates, use a stream pipeline to: remove duplicates, filter out any name
shorter than 4 characters, convert the rest to title case (first letter
uppercase, String::toLowerCase on the remainder is fine for a simple
version), sort alphabetically, and collect into a List<String>. Print the
result. Then write a Predicate<String> variable that checks whether a
string starts with a given letter, and use it inside a second filter call.