Lambdas, Streams and Collectors
A lambda is a function written where a value is expected. Java has no function type of its own, so a lambda is an instance of a functional interface - an interface with exactly one abstract method.
import java.util.function.*;
public class Lambdas {
public static void main(String[] args) {
Function<Integer, Integer> twice = n -> n * 2;
Predicate<String> isLong = s -> s.length() > 4;
Supplier<String> now = () -> "a value, built on demand";
Consumer<String> print = System.out::println;
BiFunction<Integer, Integer, Integer> add = Integer::sum;
System.out.println(twice.apply(21));
System.out.println(isLong.test("hello"));
print.accept(now.get());
System.out.println(add.apply(2, 3));
// Composition: andThen runs the second on the first's result.
System.out.println(twice.andThen(n -> n + 1).apply(10));
System.out.println(isLong.negate().test("hi"));
// A method reference is a lambda whose body is one call.
Function<String, Integer> byLambda = s -> s.length();
Function<String, Integer> byReference = String::length;
System.out.println(byLambda.apply("abc") + " " + byReference.apply("abc"));
}
}
A stream describes work; it does not do it
filter, map and the rest are intermediate - they build a pipeline and
return immediately. Nothing runs until a terminal operation asks for a
result: collect, forEach, count, reduce, findFirst.
import java.util.*;
import java.util.stream.*;
public class Lazy {
public static void main(String[] args) {
var numbers = List.of(1, 2, 3, 4, 5, 6);
var pipeline = numbers.stream()
.peek(n -> System.out.println(" looking at " + n))
.filter(n -> n % 2 == 0)
.map(n -> n * n);
System.out.println("nothing has run yet");
System.out.println(pipeline.toList());
// Each element goes through every step before the next one starts,
// which is what makes findFirst stop early rather than map everything.
var first = numbers.stream()
.peek(n -> System.out.println(" examining " + n))
.filter(n -> n > 3)
.findFirst();
System.out.println(first.orElseThrow());
// A stream is single-use. Asking twice is an error, not a re-run.
var used = numbers.stream();
System.out.println(used.count());
try {
used.count();
} catch (IllegalStateException e) {
System.out.println("a stream cannot be reused: " + e.getMessage());
}
}
}
The operations worth knowing by name
import java.util.*;
import java.util.stream.*;
public class Operations {
record Learner(String name, String country, int score) { }
static final List<Learner> LEARNERS = List.of(
new Learner("Ada", "UK", 95),
new Learner("Grace", "US", 88),
new Learner("Alan", "UK", 72),
new Learner("Katherine", "US", 95));
public static void main(String[] args) {
System.out.println(LEARNERS.stream().map(Learner::name).toList());
System.out.println(LEARNERS.stream().filter(l -> l.score() > 80).count());
System.out.println(LEARNERS.stream().anyMatch(l -> l.score() == 100));
System.out.println(LEARNERS.stream().allMatch(l -> l.score() > 50));
// mapToInt escapes the boxing, and gives you the numeric summaries.
var stats = LEARNERS.stream().mapToInt(Learner::score).summaryStatistics();
System.out.println(stats.getMin() + ".." + stats.getMax() + " avg " + stats.getAverage());
// sorted takes the same Comparator as List.sort.
LEARNERS.stream()
.sorted(Comparator.comparingInt(Learner::score).reversed()
.thenComparing(Learner::name))
.limit(2)
.forEach(l -> System.out.println(l.name() + " " + l.score()));
// flatMap flattens one level: many streams into one.
var words = List.of("hello world", "goodbye moon");
System.out.println(words.stream()
.flatMap(line -> Arrays.stream(line.split(" ")))
.toList());
// reduce, when the fold is not one of the ready-made ones.
System.out.println(LEARNERS.stream()
.map(Learner::name)
.reduce("", (a, b) -> a.isEmpty() ? b : a + " & " + b));
}
}
Collectors are where a stream turns back into data
import java.util.*;
import java.util.stream.*;
public class Collectors1 {
record Learner(String name, String country, int score) { }
static final List<Learner> LEARNERS = List.of(
new Learner("Ada", "UK", 95),
new Learner("Grace", "US", 88),
new Learner("Alan", "UK", 72),
new Learner("Katherine", "US", 95));
public static void main(String[] args) {
System.out.println(LEARNERS.stream().map(Learner::name)
.collect(Collectors.joining(", ", "[", "]")));
Map<String, List<String>> byCountry = LEARNERS.stream()
.collect(Collectors.groupingBy(Learner::country,
Collectors.mapping(Learner::name, Collectors.toList())));
System.out.println(byCountry);
Map<String, Double> averageByCountry = LEARNERS.stream()
.collect(Collectors.groupingBy(Learner::country,
Collectors.averagingInt(Learner::score)));
System.out.println(averageByCountry);
Map<Boolean, List<String>> passed = LEARNERS.stream()
.collect(Collectors.partitioningBy(l -> l.score() >= 80,
Collectors.mapping(Learner::name, Collectors.toList())));
System.out.println(passed);
// toMap throws on a duplicate key unless you say how to merge - which
// is a good default, because silently dropping one is worse.
Map<String, Integer> best = LEARNERS.stream()
.collect(Collectors.toMap(Learner::country, Learner::score, Math::max));
System.out.println(best);
}
}
Do not modify anything from inside a stream
A lambda that writes to a variable outside itself is the classic misuse: it turns a description of work into an order-dependent side effect, and it is exactly what breaks if the stream ever goes parallel.
import java.util.*;
import java.util.stream.*;
public class SideEffects {
public static void main(String[] args) {
var source = IntStream.rangeClosed(1, 1000).boxed().toList();
// Wrong: a shared, unsynchronised collection written from a stream.
List<Integer> collected = new ArrayList<>();
source.parallelStream().filter(n -> n % 2 == 0).forEach(collected::add);
System.out.println("unsafe size: " + collected.size() + " (expected 500, and not reliably)");
// Right: let the stream build the result.
List<Integer> safe = source.parallelStream().filter(n -> n % 2 == 0).toList();
System.out.println("safe size: " + safe.size());
}
}
Parallel streams are worth reaching for far less often than people expect: they
use a shared pool, they cost coordination, and they only pay on large, CPU-bound,
side-effect-free work. Measure before and after, or leave the parallel() off.
What to take away
- A lambda implements a functional interface; a method reference is the short form.
- Intermediate operations are lazy - nothing runs until a terminal one asks.
- A stream is single-use, and elements flow one at a time through the whole pipeline.
mapToIntavoids boxing and gives yousummaryStatistics.groupingBy,partitioningByandjoiningdo the work a manual loop would.- Never write to a shared collection from a lambda; let
collect/toListbuild it.