Home
Java from First Principles / Chapter 16 — Lambdas and Functional Interfaces

Lambdas and Functional Interfaces

The Java 8 feature that changed everything. Method references, the standard functional interfaces, and the patterns that replaced 80% of inner-class boilerplate.


What changed in Java 8

Java 8 (2014) added lambdas. It was the biggest language change since generics. The point wasn't just shorter syntax — it was enabling a whole style of programming that was painful before: passing behaviour as data.

Before Java 8, to sort a list with custom logic, you wrote:

Java
Collections.sort(users, new Comparator<User>() {
    @Override
    public int compare(User a, User b) {
        return a.getName().compareTo(b.getName());
    }
});

After Java 8:

Java
users.sort((a, b) -> a.getName().compareTo(b.getName()));

Or even shorter:

Java
users.sort(Comparator.comparing(User::getName));

The lambda (a, b) -> a.getName().compareTo(b.getName()) is an inline function. It implements the Comparator<User> interface's single method. The compiler figures out the types from context.

This unlocked the stream API (next chapter), modern collection methods, callback-heavy frameworks, and a more functional style of Java overall.


Lambda syntax

The basic form is (parameters) -> expression or (parameters) -> { statements; }.

Java
// No parameters
Runnable r = () -> System.out.println("hello");

// One parameter — parens optional
Function<Integer, Integer> doubler = n -> n * 2;
Function<Integer, Integer> doubler2 = (n) -> n * 2;   // same

// Multiple parameters
BinaryOperator<Integer> add = (a, b) -> a + b;

// Multi-line body
Function<String, String> greet = name -> {
    String prefix = "Hello, ";
    return prefix + name;
};

// Type annotations on parameters (rare — usually inferred)
Function<Integer, Integer> doubler3 = (Integer n) -> n * 2;

Lambdas are typed implicitly. The compiler picks the type based on context — the variable's declared type, the expected parameter type of a method you're calling, or the return type of a method.

Java
Runnable r = () -> System.out.println("hi");     // r is Runnable
Comparator<String> c = (a, b) -> a.compareTo(b);  // c is Comparator<String>

If the compiler can't figure out the type — for example, if you assign a lambda to a var or Object — you get a compile error. Lambdas don't have a type of their own; they're always coerced into a specific functional interface.


Functional interfaces

A **functional interface** is an interface with exactly one abstract method. Lambdas can only be assigned to functional interface types.

The optional @FunctionalInterface annotation makes this explicit and lets the compiler check:

Java
@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

If anyone adds a second abstract method, the compiler refuses to compile the interface. The annotation isn't required for an interface to be functional — having exactly one abstract method is enough — but adding it is good practice for clarity.

**The standard library has dozens of functional interfaces in java.util.function.** The most-used ones:

| Interface | Method | Use |
|---|---|---|
| Predicate<T> | boolean test(T t) | filter, test |
| Function<T, R> | R apply(T t) | transform |
| Consumer<T> | void accept(T t) | side-effect, forEach |
| Supplier<T> | T get() | factory, lazy default |
| BiFunction<T, U, R> | R apply(T t, U u) | transform two inputs |
| BinaryOperator<T> | T apply(T t, T t) | combine same-typed values |
| UnaryOperator<T> | T apply(T t) | transform in place |
| Runnable | void run() | execute, no input/output |
| Callable<V> | V call() | execute with return value, can throw |

Use these instead of defining your own where you can. Standard interfaces compose well with the rest of the JDK.

**Primitive specialisations.** Generic functional interfaces involve autoboxing for primitives, which is slow in tight loops. The JDK provides specialised versions: IntPredicate, LongFunction<R>, DoubleConsumer, IntBinaryOperator, etc. Use them when working with raw int, long, or double streams.


Method references

A method reference is a shorthand for a lambda that just calls an existing method. Four forms:

**1. Reference to a static method.** ClassName::methodName

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

**2. Reference to an instance method on a specific instance.** instance::methodName

Java
PrintStream out = System.out;
Consumer<String> printer = out::println;
// equivalent to: s -> out.println(s)

**3. Reference to an instance method on an arbitrary instance of a type.** ClassName::methodName

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

Comparator<User> byName = Comparator.comparing(User::getName);
// equivalent to: Comparator.comparing(u -> u.getName())

**4. Reference to a constructor.** ClassName::new

Java
Supplier<ArrayList<String>> factory = ArrayList::new;
// equivalent to: () -> new ArrayList<String>()

Method references are usually clearer than the equivalent lambda. Compare:

Java
users.stream().map(u -> u.getName()).forEach(n -> System.out.println(n));
users.stream().map(User::getName).forEach(System.out::println);

The second reads as "map each user to its name; print each name." No "noise" parameters.

**When method references don't fit:** if the lambda does anything beyond a single method call, write it as a lambda. Don't twist code to make a method reference work.


Variable capture

Lambdas can use variables from their enclosing scope. The rules:

**Local variables: must be final or effectively final.**

Java
int multiplier = 3;
Function<Integer, Integer> triple = n -> n * multiplier;   // OK

multiplier = 5;   // makes multiplier no longer effectively final
Function<Integer, Integer> tripleAgain = n -> n * multiplier;   // compile error

"Effectively final" means: not declared final but never reassigned. The compiler figures this out automatically.

Why this rule? Lambdas can outlive the method that created them — passed to event handlers, stored in collections, run on other threads. If the captured variable could change after the lambda was created, the lambda would see surprising state. Java forbids it to keep behaviour predictable.

**Instance fields: no restriction.** A lambda inside a method can freely read and modify the enclosing class's fields:

Java
public class Counter {
    private int count = 0;

    public Runnable incrementer() {
        return () -> count++;   // fine — count is an instance field
    }
}

The reason this works: the lambda captures this, and count is accessed via this.count. The reference to this is final (you can't reassign this), so the rule is satisfied.

**Workaround for the "I need a mutable counter in a lambda" case.** Use an array or an atomic wrapper:

Java
int[] count = {0};
list.forEach(item -> count[0]++);

// or, cleaner for concurrent code:
AtomicInteger count = new AtomicInteger();
list.forEach(item -> count.incrementAndGet());

This is a sign you should usually be using .count() or Collectors.counting() from streams. But the workaround exists when you need it.


Practical patterns

**Replacing callback interfaces.**

Java
// Before
button.addActionListener(new ActionListener() {
    @Override public void actionPerformed(ActionEvent e) {
        save();
    }
});

// After
button.addActionListener(e -> save());

**Comparator chaining.**

Java
users.sort(
    Comparator.comparing(User::getLastName)
              .thenComparing(User::getFirstName)
              .thenComparingInt(User::getAge)
);

This is dramatically clearer than the equivalent pre-Java-8 comparator. Each thenComparing adds a tiebreaker.

**Map operations with default values.**

Java
Map<String, Integer> counts = new HashMap<>();
words.forEach(w -> counts.merge(w, 1, Integer::sum));
// counts each word, using Integer::sum to combine existing + new value

**Conditional logic as data.**

Java
public Map<String, Predicate<User>> filters = Map.of(
    "active", u -> u.isActive(),
    "adult",  u -> u.getAge() >= 18,
    "admin",  u -> u.hasRole("ADMIN")
);

// Apply a named filter:
users.stream().filter(filters.get("active")).toList();

**Lazy initialisation.**

Java
private static final Supplier<ExpensiveResource> RESOURCE =
    () -> new ExpensiveResource();   // not created yet

Combined with Memoizer patterns or Lazy wrappers, this enables true on-demand initialisation.


Pitfalls

**1. Verbose lambdas hide their type.**

Java
processor.handle((req, ctx, callback) -> {
    // 30 lines of logic
});

When the lambda gets long, the reader can't easily tell what types req, ctx, callback are. Extract to a named method or named class:

Java
processor.handle(this::handleRequest);

private Response handleRequest(Request req, Context ctx, Callback callback) {
    // same 30 lines, but with explicit signature
}

**2. Exception handling.** Lambdas can't throw checked exceptions unless the functional interface declares them. The standard interfaces (Function, Predicate, etc.) don't. So:

Java
files.stream().map(Files::readString);   // compile error — readString throws IOException

Workarounds:
- Wrap and rethrow as unchecked:

Java
  files.stream().map(f -> {
      try { return Files.readString(f); }
      catch (IOException e) { throw new UncheckedIOException(e); }
  });
  

**3. Performance: capture allocation.** Each call site that captures different local variables creates a new lambda instance. In hot loops, this can produce garbage:

Java
for (User u : users) {
    handler.process(() -> u.notify());   // new lambda per iteration
}

Usually fine. If profiling shows it's a problem, hoist the lambda or use a non-capturing version.

**4. Excessive var with lambdas.**

Java
var f = (String s) -> s.length();    // compile error — var can't infer lambda type

Lambdas need an explicit target type. Either declare the variable's type or use a method that takes a functional parameter.

**5. Stack traces from lambdas can be confusing.** Errors thrown from inside a lambda show line numbers from the synthetic method the compiler generated. Modern stack traces are better than they used to be, but it's still less obvious than a named method.


⁂ Back to all modules