Lambdas, Streams and Collectors

Article · 14:00

A lambda is a function written where a value is expected, and since Java has no function type of its own it is an instance of a functional interface. Then lazy pipelines, the collectors worth knowing, and why mutating from inside a lambda is a bug waiting.

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.
  • mapToInt avoids boxing and gives you summaryStatistics.
  • groupingBy, partitioningBy and joining do the work a manual loop would.
  • Never write to a shared collection from a lambda; let collect/toList build it.