Functional Interfaces, Lambdas, and Method References

Lambdas landed in Java 8 (2014) and changed how the language reads. A decade later, most Java 21 codebases use them fluently but few engineers can explain what a lambda actually is: it is a compact expression that gets converted, at compile time, into an instance of a functional interface — an interface with exactly one abstract method (SAM). This file makes that concrete, then walks the standard functional interfaces, method references (all four kinds), and the capture rules that trip people in studies.

The Standard Functional Interfaces (java.util.function)

Interface

Signature

Meaning

Function<T, R>

R apply(T t)

Transform T to R

BiFunction<T, U, R>

R apply(T t, U u)

Two inputs, one output

Predicate<T>

boolean test(T t)

T → boolean

BiPredicate<T, U>

boolean test(T t, U u)

Two inputs → boolean

Consumer<T>

void accept(T t)

Side effect on T

BiConsumer<T, U>

void accept(T t, U u)

Side effect on two inputs

Supplier<T>

T get()

No input, produces T

UnaryOperator<T>

T apply(T t)

Function<T, T>

BinaryOperator<T>

T apply(T a, T b)

BiFunction<T, T, T>

These five families cover almost everything. Learn them so well you can name them from memory.

Primitive specializations

Autoboxing is expensive in hot loops. The primitive-specialized variants exist to avoid it:

Boxed

Primitive

Function<Integer, Integer>

IntUnaryOperator

Function<Integer, R>

IntFunction<R>

Function<T, Integer>

ToIntFunction<T>

Predicate<Integer>

IntPredicate

Consumer<Integer>

IntConsumer

Supplier<Integer>

IntSupplier

Same families exist for Long and Double. When you see IntStream.mapToObj(...) you are moving from primitive-specialized to reference stream.

Lambda Syntax — Every Form

() -> 42                              // no params, returns 42 — Supplier<Integer>
x -> x * 2                            // one param, single expression — UnaryOperator<Integer> etc.
(x) -> x * 2                          // parens optional for one param
(int x) -> x * 2                      // explicit type — rarely useful
(x, y) -> x + y                       // BinaryOperator
(x, y) -> {                           // block body needs return
    int sum = x + y;
    return sum;
}
() -> { throw new IllegalStateException(); }   // void or throwing body

The compiler infers the target type from context (the parameter or variable being assigned). If context is ambiguous, cast: (Predicate<String>) s -> !s.isBlank().

Method References — The Four Kinds

A method reference is a lambda whose body is a single existing method call. There are four flavors, and study partners love asking you to name them:

1. Static method reference — ClassName::staticMethod

Function<String, Integer> parser = Integer::parseInt;
// equivalent to: s -> Integer.parseInt(s)

2. Bound instance method — instance::instanceMethod

String prefix = "user-";
Function<String, String> withPrefix = prefix::concat;
// equivalent to: s -> prefix.concat(s)

The instance is captured (see capture rules below).

3. Unbound instance method — ClassName::instanceMethod

Function<String, Integer> length = String::length;
// equivalent to: s -> s.length()

The first parameter of the lambda becomes the receiver. This is the one people confuse with the static form. Read String::length as “take a String, call .length() on it.”

4. Constructor reference — ClassName::new

Supplier<ArrayList<String>> make = ArrayList::new;
Function<Integer, ArrayList<String>> makeWithCap = ArrayList::new;   // picks the (int) constructor

Compiler picks the constructor overload by the target functional interface’s parameter count and types.

Cheat table

Lambda

Method reference

Kind

s -> Integer.parseInt(s)

Integer::parseInt

Static

s -> System.out.println(s)

System.out::println

Bound instance

s -> s.toUpperCase()

String::toUpperCase

Unbound instance

() -> new HashMap<>()

HashMap::new

Constructor

Lambda Capture — The “Effectively Final” Rule

A lambda can reference variables from the enclosing scope, but they must be effectively final — either declared final or never reassigned after initialization.

String greeting = "Hello";                       // effectively final
Function<String, String> greet = name -> greeting + ", " + name;
// greeting = "Hi";                              // if uncommented, lambda fails to compile

int count = 0;
Runnable r = () -> count++;                       // compile error: count not effectively final

Why? Lambdas may execute on another thread at a later time. Capturing a mutable local would create sneaky data-race bugs. The compiler forces a design that makes this impossible.

Workarounds when you truly need mutable state:

// Use an array of length 1 — the reference is final, contents are mutable
int[] counter = {0};
Runnable r = () -> counter[0]++;

// Or AtomicInteger — thread-safe and idiomatic
AtomicInteger count = new AtomicInteger();
Runnable r2 = count::incrementAndGet;

AtomicInteger is almost always the better choice — it documents your intent and is safe under concurrency.

Instance and static fields are NOT subject to the effectively-final rule

class Counter {
    private int count = 0;
    Runnable makeIncrementer() {
        return () -> count++;   // fine — count is a field, not a local
    }
}

The lambda captures this, then reads/writes the field through it. This is why you should be careful with capturing this from long-lived lambdas (memory leak potential).

Functional Composition — andThen, compose

Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, Integer> plus3  = x -> x + 3;

// andThen: run left first, then right
Function<Integer, Integer> f = times2.andThen(plus3);   // (x*2)+3
f.apply(5);   // 13

// compose: run right first, then left
Function<Integer, Integer> g = times2.compose(plus3);   // (x+3)*2
g.apply(5);   // 16

Predicate composes with and, or, negate:

Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> short_   = s -> s.length() < 10;
Predicate<String> ok = notBlank.and(short_);

Consumer composes with andThen for sequential side effects:

Consumer<String> log   = System.out::println;
Consumer<String> audit = s -> auditLog.record(s);
Consumer<String> both  = log.andThen(audit);

When Lambdas Hurt Readability

Lambdas are a scalpel, not a hammer. They hurt when:

1. The body is too long

// Painful
users.stream()
    .map(u -> {
        var enriched = enricher.enrich(u);
        var scored   = scorer.score(enriched);
        var validated = validator.validate(scored);
        if (!validated.isValid()) return null;
        return converter.toDto(validated);
    })
    .filter(Objects::nonNull)
    .toList();

// Extract:
users.stream()
    .map(this::enrichScoreValidateConvert)
    .filter(Objects::nonNull)
    .toList();

Rule of thumb: lambda body > 5 lines → extract a private method.

2. Checked exceptions

Function, Predicate, Consumer, Supplier do not throw checked exceptions. So this fails:

files.stream()
     .map(Files::readString)   // IOException is checked
     .toList();                // compile error

Fix options:

// Wrap in a helper that converts to unchecked
files.stream()
     .map(p -> {
         try { return Files.readString(p); }
         catch (IOException e) { throw new UncheckedIOException(e); }
     })
     .toList();

// Or a utility method reference
files.stream()
     .map(FileUtils::readStringUnchecked)
     .toList();

Libraries like Vavr and Guava offer “throwing” functional interfaces if you use them a lot. Most codebases just live with a small helper.

3. When a for loop is clearer

// Stream feels forced
String[] result = new String[users.size()];
IntStream.range(0, users.size()).forEach(i -> result[i] = users.get(i).name());

// For loop is clearer
for (int i = 0; i < users.size(); i++) result[i] = users.get(i).name();

Streams are for pipelines: read-only sources, pure transformations, terminal aggregation. If you are indexing, mutating shared state, or breaking out early on a condition, a loop is honest.

⚠️ What Most People Get Wrong

They write side-effectful lambdas inside stream operations:

List<String> errors = new ArrayList<>();
users.stream()
     .filter(u -> {
         if (!u.isValid()) errors.add(u.name());   // side effect in a predicate — no
         return u.isValid();
     })
     .toList();

Side effects in filter, map, or sorted violate the stream contract, prevent safe parallelization, and are hard to reason about. If you need two outputs (valid + invalid), use Collectors.partitioningBy or two separate streams. Lambdas are meant to be pure functions of their inputs.

They also confuse Consumer (returns void, side-effect intent) with Function (returns a value, transformation intent). Use Consumer for forEach and ifPresent. Use Function for map.

What to Practice This Week

  1. Convert 10 anonymous Comparator implementations in your codebase to Comparator.comparing(...) with method references. Note the line delta.

  2. Write one example of each method reference kind. Force yourself to name them.

  3. Take a for loop that mutates a shared list. Rewrite as a stream. Then honestly decide which reads better and keep that one.

  4. Read Bloch Effective Java Items 42–44 (“Prefer lambdas to anonymous classes,” “Prefer method references to lambdas,” “Favor the use of standard functional interfaces”).


Return to README.md · Previous: 01_generics_deep.md · Next: 03_streams_the_honest_guide.md