Skip to content
elephantoo

Lambdas, functional interfaces & method references

Lesson 30 of 43 17 min read

Lambda syntax, Function, Predicate, Consumer, Supplier, composing functions and the four kinds of method reference.


Java 8 brought the biggest change in the language's history: lambda expressions. A lambda is a short, anonymous function you can store in a variable or pass to a method. Lambdas power the Streams API, modern collection methods, CompletableFuture, Spring configuration and much more. This lesson covers lambda syntax, the standard functional interfaces, composing functions, and method references.

From anonymous class to lambda#

Suppose we want to sort strings by length. Before Java 8, you wrote an anonymous class:

Java
names.sort(new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});

With a lambda, all the ceremony disappears:

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

The compiler knows sort expects a Comparator<String>, which has one abstract method compare(String, String). So the lambda is that method's implementation.

Lambda syntax#

Output
(parameters) -> expression
(parameters) -> { statements; return value; }
Syntax.java
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;

public class Syntax {
    public static void main(String[] args) {
        Runnable hello = () -> System.out.println("Hello");          // no parameters
        Function<Integer, Integer> square = x -> x * x;               // one parameter: parentheses optional
        BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;  // two parameters
        BiFunction<Double, Double, Double> hyp = (Double a, Double b) -> Math.sqrt(a * a + b * b); // explicit types
        Supplier<String> greeting = () -> {                           // block body needs return
            String time = "morning";
            return "Good " + time;
        };

        hello.run();
        System.out.println(square.apply(7));
        System.out.println(add.apply(2, 3));
        System.out.println(hyp.apply(3.0, 4.0));
        System.out.println(greeting.get());
    }
}
Output
Hello
49
5
5.0
Good morning

Functional interfaces#

A lambda can be used wherever a functional interface is expected: an interface with exactly one abstract method (default and static methods don't count). The lambda's parameters and return value must match that method.

java.util.function provides the common shapes, so you rarely need to define your own:

InterfaceMethodShapeExample
Function<T, R>R apply(T t)T → Rs -> s.length()
BiFunction<T, U, R>R apply(T t, U u)(T, U) → R(a, b) -> a * b
Predicate<T>boolean test(T t)T → booleann -> n > 0
Consumer<T>void accept(T t)T → nothings -> System.out.println(s)
Supplier<T>T get()nothing → T() -> new ArrayList<>()
UnaryOperator<T>T apply(T t)T → Ts -> s.trim()
BinaryOperator<T>T apply(T a, T b)(T, T) → T(a, b) -> a + b

Primitive specialisations avoid boxing: IntPredicate, IntFunction<R>, ToIntFunction<T>, IntUnaryOperator, IntBinaryOperator, DoubleSupplier and more. Older interfaces like Runnable, Callable<V> and Comparator<T> are functional too.

StandardInterfaces.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;

public class StandardInterfaces {
    public static void main(String[] args) {
        Function<String, Integer> length = s -> s.length();
        Predicate<Integer> isEven = n -> n % 2 == 0;
        Consumer<String> shout = s -> System.out.println(s.toUpperCase() + "!");
        Supplier<List<String>> newList = () -> new ArrayList<>();
        UnaryOperator<String> trim = s -> s.strip();
        BinaryOperator<Integer> max = (a, b) -> a >= b ? a : b;

        System.out.println(length.apply("Elephantoo"));
        System.out.println(isEven.test(10) + " " + isEven.test(7));
        shout.accept("lambdas rock");
        List<String> list = newList.get();
        list.add(trim.apply("   padded   "));
        System.out.println(list);
        System.out.println(max.apply(12, 30));
    }
}
Output
10
true false
LAMBDAS ROCK!
[padded]
30

Passing behaviour into methods#

The real power comes from writing methods that take functions as parameters:

Behaviour.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public class Behaviour {
    static <T> List<T> filter(List<T> items, Predicate<T> keep) {
        List<T> out = new ArrayList<>();
        for (T item : items) if (keep.test(item)) out.add(item);
        return out;
    }

    static <T, R> List<R> map(List<T> items, Function<T, R> fn) {
        List<R> out = new ArrayList<>();
        for (T item : items) out.add(fn.apply(item));
        return out;
    }

    static void retry(int times, Runnable action) {
        for (int i = 1; i <= times; i++) {
            System.out.print("attempt " + i + ": ");
            action.run();
        }
    }

    public static void main(String[] args) {
        List<Integer> nums = List.of(5, 12, 7, 20, 3);
        System.out.println(filter(nums, n -> n > 6));
        System.out.println(map(nums, n -> n * 10));
        System.out.println(map(List.of("ant", "bee"), s -> s + "s"));
        retry(2, () -> System.out.println("calling server"));
    }
}
Output
[12, 7, 20]
[50, 120, 70, 200, 30]
[ants, bees]
attempt 1: calling server
attempt 2: calling server

filter and map don't know what to keep or how to transform; the caller decides. That is behaviour parameterisation, and it's exactly what the Streams API offers out of the box.

Composing functions#

Functional interfaces have default methods for combining them:

Composing.java
import java.util.function.Function;
import java.util.function.Predicate;

public class Composing {
    public static void main(String[] args) {
        Predicate<String> notBlank = s -> !s.isBlank();
        Predicate<String> shortWord = s -> s.length() <= 5;
        Predicate<String> valid = notBlank.and(shortWord);
        System.out.println(valid.test("java") + " " + valid.test("  ") + " " + valid.test("javascript"));
        System.out.println(shortWord.negate().test("javascript"));
        System.out.println(notBlank.or(s -> s.isEmpty()).test(""));

        Function<Integer, Integer> doubleIt = x -> x * 2;
        Function<Integer, Integer> plusThree = x -> x + 3;
        System.out.println(doubleIt.andThen(plusThree).apply(5));   // (5*2)+3
        System.out.println(doubleIt.compose(plusThree).apply(5));   // (5+3)*2
        System.out.println(Function.<Integer>identity().apply(42));
    }
}
Output
true false false
true
true
13
16
42

Capturing variables: effectively final#

A lambda can use variables from the surrounding method, but only if they are effectively final (assigned once, never changed):

Java
String prefix = "Hi ";                          // never reassigned: effectively final
names.forEach(n -> System.out.println(prefix + n));   // OK

int count = 0;
names.forEach(n -> count++);                    // compile error: local variables referenced
                                                // from a lambda expression must be final or effectively final

This rule prevents confusing behaviour (and data races) when a lambda runs later or on another thread. Fields and array contents are not restricted, but mutating shared state from lambdas is usually a design smell. Use a stream's count() or a plain loop instead.

Inside a lambda, this refers to the enclosing object, not to the lambda itself (unlike in anonymous classes).

Method references#

When a lambda just calls an existing method, a method reference says it more directly with :::

KindSyntaxEquivalent lambda
Static methodInteger::parseInts -> Integer.parseInt(s)
Instance method of a particular objectSystem.out::printlnx -> System.out.println(x)
Instance method of an arbitrary object of a typeString::toUpperCases -> s.toUpperCase()
ConstructorArrayList::new() -> new ArrayList<>()
MethodRefs.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;

public class MethodRefs {
    record Person(String name, int age) { }

    static boolean isAdult(Person p) { return p.age() >= 18; }

    public static void main(String[] args) {
        Function<String, Integer> parse = Integer::parseInt;               // static
        System.out.println(parse.apply("42") + 1);

        List<String> words = new ArrayList<>(List.of("pear", "Fig", "apple"));
        words.forEach(System.out::println);                                // particular object

        words.replaceAll(String::toUpperCase);                             // arbitrary object
        System.out.println(words);

        BiFunction<String, String, Boolean> startsWith = String::startsWith;  // (s, prefix) -> s.startsWith(prefix)
        System.out.println(startsWith.apply("Elephantoo", "Ele"));

        Supplier<List<String>> maker = ArrayList::new;                     // constructor
        BiFunction<String, Integer, Person> newPerson = Person::new;
        Person p = newPerson.apply("Asha", 30);
        System.out.println(p + " adult? " + isAdult(p));

        List<Person> people = new ArrayList<>(List.of(p, new Person("Ben", 15)));
        people.removeIf(((java.util.function.Predicate<Person>) MethodRefs::isAdult).negate());
        people.sort(Comparator.comparing(Person::name));
        System.out.println(people + " " + maker.get().isEmpty());
    }
}
Output
43
pear
Fig
apple
[PEAR, FIG, APPLE]
true
Person[name=Asha, age=30] adult? true
[Person[name=Asha, age=30]] true

For the negate() example, Java 11 added a neater helper: Predicate.not(MethodRefs::isAdult).

Use a method reference when it is clearer than the lambda. Person::name beats p -> p.name(), but a lambda is better when you need extra logic or the reference would be obscure.

Defining your own functional interface#

When no standard interface communicates intent, or you need a checked exception, define one:

CustomFunctional.java
public class CustomFunctional {
    @FunctionalInterface
    interface Validator<T> {
        boolean validate(T value);

        default Validator<T> and(Validator<T> other) {
            return v -> validate(v) && other.validate(v);
        }
    }

    public static void main(String[] args) {
        Validator<String> hasAt = s -> s.contains("@");
        Validator<String> hasDot = s -> s.contains(".");
        Validator<String> email = hasAt.and(hasDot);
        System.out.println(email.validate("a@b.com") + " " + email.validate("nope"));
    }
}
Output
true false

Common mistakes#

  • Mutating local variables from a lambda (effectively-final error).
  • Writing huge multi-line lambdas. Extract a named method and use a method reference.
  • Using Function<Integer, Integer> in hot loops when IntUnaryOperator avoids boxing.
  • Swallowing checked exceptions inside lambdas. Standard interfaces can't throw them, so wrap them deliberately (e.g. in UncheckedIOException).
  • Overusing lambdas where a simple loop is clearer.

What's next#

Lambdas truly shine with the Streams API, which lets you filter, transform, group and summarise data in expressive pipelines. That's next.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which functional interface represents a function that takes a T and returns a boolean?

  2. 2.Why does this fail to compile? int count = 0; list.forEach(x -> count++);

  3. 3.Which method reference is equivalent to s -> s.toUpperCase()?

Finished reading?

Mark this lesson complete to track your progress.