Lambda Expressions and Functional Interfaces

9 min read

A lambda expression is a one-line anonymous function. Java added them in Java 8 as a way to pass behaviour — not just data — to methods. The same idea exists in JavaScript (arrow functions: x => x.toUpperCase()), Python (lambda x: x.upper()), and Kotlin. Java's version is statically typed and works with any functional interface — an interface with a single abstract method. Streams, Collection.forEach, Comparator, JUnit's assertThrows, and almost every modern Java API designed since 2014 takes lambdas. This lesson covers the syntax, the four functional interfaces you'll meet daily, and why they make test code shorter without making it harder to read.

The syntax — three flavours

Java's lambda is (parameters) -> body:

// 1) zero parameters
Runnable r = () -> System.out.println("done");
 
// 2) one parameter, one expression — body is the result
Function<String, Integer> length = s -> s.length();
 
// 3) two parameters, multi-statement body, explicit return
Comparator<String> byLength = (a, b) -> {
    if (a.length() != b.length()) return Integer.compare(a.length(), b.length());
    return a.compareTo(b);
};

Three rules:

  • Parentheses around parameters are optional for exactly one parameter. s -> s.length() and (s) -> s.length() are the same.
  • Curly braces and return are optional for a single-expression body. The expression's value is the return.
  • Braces and return are required when the body has multiple statements.

That's the whole syntax. Everything else is what type the lambda has.

Before lambdas — anonymous classes

To appreciate why lambdas exist, look at the equivalent code without them. Sorting a list with a custom comparator pre-Java 8:

import java.util.*;
 
public class OldSchool {
    public static void main(String[] args) {
        List<String> tests = new ArrayList<>(List.of("CheckoutTest", "Login", "ProductSearchTest"));
 
        tests.sort(new Comparator<String>() {
            @Override
            public int compare(String a, String b) {
                return Integer.compare(a.length(), b.length());
            }
        });
 
        System.out.println(tests);
    }
}

Six lines of ceremony for one comparison. The same code with a lambda:

tests.sort((a, b) -> Integer.compare(a.length(), b.length()));

One line. Same behaviour. The compiler still creates the Comparator instance under the hood — it just doesn't make you type the boilerplate. That five-line saving multiplies across every callback in your test framework.

Functional interfaces — what types lambdas can be

Lambdas implement interfaces with a single abstract method (SAM). The compiler matches the lambda's shape to the interface's one method. JUnit's Executable, Java's Comparator, JavaScript-style callbacks: every one is a single-method interface.

Java 8 ships four "general-purpose" functional interfaces in java.util.function that cover most cases:

import java.util.function.*;
 
Predicate<String> isEmpty   = s -> s.isEmpty();              // T -> boolean
Function<String, Integer> len = s -> s.length();             // T -> R
Consumer<String> printer    = s -> System.out.println(s);    // T -> void
Supplier<String> uuid       = () -> java.util.UUID.randomUUID().toString();  // () -> T

What each is for:

  • Predicate<T> — boolean test. Used by filter, removeIf, anyMatch. "Is this user active?"
  • Function<T, R> — transform a T into an R. Used by map. "Get the user's name."
  • Consumer<T> — do something with a T, return nothing. Used by forEach. "Print this user."
  • Supplier<T> — produce a T from nothing. Used by lazy defaults, factories. "Generate a UUID."

There are bi-prefixed versions (BiFunction<T, U, R>, BiPredicate<T, U>) for two-argument variants, plus int/long/double-specialised versions for primitives (IntPredicate, ToIntFunction) when boxing matters.

A real test-data example

Filter, sort, and print a list of users — pre-streams-style — using each functional interface:

import java.util.*;
import java.util.function.*;
 
public class FilterUsers {
 
    record User(String name, String role, boolean active) {}
 
    public static void main(String[] args) {
        List<User> users = List.of(
            new User("Alice", "admin",  true),
            new User("Bob",   "member", false),
            new User("Carol", "admin",  true),
            new User("Dave",  "guest",  true)
        );
 
        Predicate<User> isActiveAdmin = u -> u.active() && u.role().equals("admin");
        Function<User, String> toName = User::name;          // method reference
        Consumer<String>      logIt   = System.out::println;
 
        // imperative loop with a lambda predicate
        List<String> activeAdminNames = new ArrayList<>();
        for (User u : users) {
            if (isActiveAdmin.test(u)) {
                activeAdminNames.add(toName.apply(u));
            }
        }
        activeAdminNames.forEach(logIt);
    }
}

Output:

Alice
Carol

Lesson 4 will collapse the loop into a Stream pipeline. The point of this example is the types: Predicate for the filter test, Function for the projection, Consumer for the print step. Any modern Java collection method that takes "a thing to do with each element" expects one of these.

Method references — the shorter shorthand

When a lambda just calls a method, Java offers an even tighter syntax — the method reference:

Function<User, String> toName = User::name;             // u -> u.name()
Consumer<String> printer    = System.out::println;      // s -> System.out.println(s)
Predicate<String> notEmpty  = s -> !s.isEmpty();        // (no method-reference equivalent)

The :: separates the receiver from the method name. There are four shapes:

  • ClassName::staticMethodInteger::parseInt
  • instance::methodSystem.out::println
  • ClassName::instanceMethodString::trim (the receiver becomes the first argument)
  • ClassName::new — constructor reference: ArrayList::new

You'll see method references all over Stream pipelines (lesson 4) — users.stream().map(User::name) is the canonical shape. They're not new functionality, just a more readable way to write a lambda whose body is exactly one method call.

Lambdas in real frameworks

You'll meet lambdas everywhere in modern Java test code:

  • Streamslist.stream().filter(...).map(...).collect(...). (Next lesson.)
  • Collection.forEach, Map.forEachusers.forEach(System.out::println). (Lesson 6.4.)
  • JUnit 5 assertionsassertThrows(IllegalArgumentException.class, () -> cfg.setRetries(-1)); runs the lambda and asserts it throws.
  • Selenium WebDriverWaitwait.until(d -> d.findElement(By.id("ok")).isDisplayed()); blocks until the lambda returns a truthy value.
  • Comparatorsusers.sort(Comparator.comparing(User::name)); chained, fluent, type-safe.

The common shape: a method that takes "a snippet of code." Before Java 8 you'd have to write an anonymous class; now you write a lambda. The verbosity of pre-Java-8 code is one of the main reasons Java got its reputation; that reputation is mostly out of date.

Anonymous class vs lambda

Anonymous class vs lambda — same behaviour, less ceremony

Anonymous class (pre-Java 8)

  • tests.sort(new Comparator<String>() {

  • @Override

  • public int compare(String a, String b) {

  • return Integer.compare(a.length(), b.length());

  • }

  • });

  • 6 lines of ceremony to express one comparison

Lambda (Java 8+)

  • tests.sort((a, b) -> Integer.compare(a.length(), b.length()));

  • 1 line. Same behaviour.

  • Compiler still creates a Comparator under the hood — you skip the typing

  • Even tighter with a method reference: Comparator.comparingInt(String::length)

The compiler treats both forms the same. The lambda is just sugar — but it's sugar that turns six-line callbacks into one-line ones. For test code where callbacks are everywhere (waits, predicates, comparators, listeners), that's a real readability win.

Effectively final — the variable-capture rule

A lambda can read variables from its surrounding scope, but only if those variables are final or effectively final (never reassigned after their initial value):

String env = "staging";
Predicate<User> envMatch = u -> u.role().equals(env);    // ✅ env is effectively final
 
env = "production";        // ❌ now lambda captures a non-effectively-final var → compile error

The rule exists because the lambda might run later (on another thread, after the local variable's stack frame is gone). Capturing a mutable reference would be unsafe. Practically: don't reassign variables you've used in a lambda. If you really need mutable state, capture a final List<> or final int[] counter = {0} and mutate the contents.

⚠️ Common mistakes

  • Confusing lambda parameters with their declared types. (a, b) -> ... works because the compiler infers the types from the target functional interface. If you write (int a, int b) -> ... and the interface expects Strings, you get a confusing error. Let the compiler infer; only specify types when you genuinely need to disambiguate overloaded methods.
  • Capturing a mutable variable. Re-assigning a local variable that a lambda closed over is a compile error (variable used in lambda expression should be final or effectively final). The fix is either to not reassign, or to wrap state in a tiny container (an int[] of length 1) — but usually the right move is to restructure to avoid the mutation.
  • Treating forEach as a way to transform. users.forEach(u -> u.role.equals("admin")) does nothing usefulforEach returns void, and the lambda's return value is discarded. To transform, use a Stream's .map(...) (lesson 4); forEach is for side effects.

🎯 Practice task

Convert anonymous-class callbacks into lambdas. 25-30 minutes.

  1. Create Callbacks.java. Build List<String> tests = new ArrayList<>(List.of("LoginTest", "CheckoutTest", "Search", "Logout"));.

  2. Anonymous class first. Use tests.sort(new Comparator<String>() { ... }) to sort by length, ascending. Print the list.

  3. Lambda. Re-sort using tests.sort((a, b) -> Integer.compare(a.length(), b.length()));. Confirm the result is identical.

  4. Method reference. Replace with tests.sort(Comparator.comparingInt(String::length));. Same result, fewer characters.

  5. Define each of the four general-purpose functional interfaces:

    • Predicate<String> startsWithLogin = s -> s.startsWith("Login");
    • Function<String, Integer> nameLength = String::length;
    • Consumer<String> printer = System.out::println;
    • Supplier<String> uuid = () -> java.util.UUID.randomUUID().toString();

    Call each one (predicate.test(...), function.apply(...), consumer.accept(...), supplier.get()) and print the result.

  6. Use tests.removeIf(t -> t.length() > 8); to drop long names. Confirm only short names survive.

  7. Stretch: define record User(String name, String role) with a small list. Use users.sort(Comparator.comparing(User::role).thenComparing(User::name)) to sort by role first, then by name. Comparator chaining like that is the kind of thing that would be 30 lines of pre-Java-8 boilerplate; it's two lines now.

You can now express behaviour as a value. Lesson 4 brings it home — the Streams API uses these same lambdas to chain filter / map / collect into the test-data processing pipelines you'll write every day.

// tip to track lessons you complete and pick up where you left off across devices.