Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 15 of 41

Lesson 15 · Functional Java

Lambdas, Functional Interfaces and Method References

How Java lets you pass behaviour as a value: lambda expressions, functional interfaces and the standard Predicate, Function, Consumer and Supplier, the four kinds of method reference, composing functions, why captured variables must be effectively final, and how lambdas replace the older anonymous classes.

On this page

Key takeaways

  • A lambda expression is a short anonymous function that you can pass as a value. It can be used wherever a functional interface, an interface with exactly one abstract method, is expected.
  • Learn the four core shapes in java.util.function: Predicate (T to boolean), Function (T to R), Consumer (T to nothing) and Supplier (nothing to T), plus their two-argument and primitive versions.
  • A method reference is a shorthand for a lambda that only calls an existing method. There are four kinds: static, bound instance, unbound instance and constructor references.
  • A lambda can use local variables of the enclosing method only if they are final or effectively final. Keep lambdas short, stateless and free of side effects where you can.
  • Lambdas replaced most anonymous classes, but you will still meet the older style in existing code and interviews.

Suppose you write a method that filters a list of job applications. Today the rule is "status is APPLIED". Tomorrow someone wants "applied more than 14 days ago". The day after, "company name contains 'Tech'". If the rule is hard-coded, you write a new method each time, with the same loop around a different if.

What you want is to pass the rule itself as an argument. Java supports that through lambda expressions, which arrived in Java 8 and changed how everyday Java is written. They are the foundation of streams (next article), Optional, many Spring APIs and modern test code.

What you will learn

#
  • What a lambda expression is, and how it relates to the interfaces you know.
  • What a functional interface is.
  • The standard functional interfaces you will use constantly.
  • The four kinds of method reference.
  • How to combine small functions.
  • The rules about captured variables and exceptions.

The problem: passing behaviour

#

Before Java 8, the way to pass behaviour was an anonymous class: an unnamed class defined and instantiated in one expression. Here is the older style for sorting by length and running a task:

Java
// File: Main.javaimport java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.List;public class Main {    public static void main(String[] args) {        List<String> words = new ArrayList<>(List.of("kiwi", "fig", "banana", "apple"));        // Older style: an anonymous class, five lines for one idea        Collections.sort(words, new Comparator<String>() {            @Override            public int compare(String a, String b) {                return Integer.compare(a.length(), b.length());            }        });        System.out.println(words);        // Modern: a lambda        words.sort((a, b) -> Integer.compare(b.length(), a.length()));        System.out.println(words);        Runnable task = new Runnable() {            @Override            public void run() {                System.out.println("anonymous class runs");            }        };        Runnable shorter = () -> System.out.println("lambda runs");        task.run();        shorter.run();    }}
Code
[fig, kiwi, apple, banana][banana, apple, kiwi, fig]anonymous class runslambda runs

The anonymous class Comparator<String> needs a lot of ceremony around a single line of logic. Oracle's tutorial describes exactly that: when an anonymous class is very simple, such as an interface containing only one method, its syntax is unwieldy and unclear. A lambda expression keeps only the essential part: the parameters and the body.

Lambda syntax

#
Code
(parameters) -> expression(parameters) -> { statements; return value; }

Form

Example

No parameters

() -> System.out.println("hi")

One parameter (parentheses optional)

name -> name.length()

Several parameters

(a, b) -> a + b

Explicit types

(String a, String b) -> a.compareTo(b)

Block body

(a, b) -> { int sum = a + b; return sum * 2; }

You can think of a lambda as an anonymous method: parameters, an arrow, and a body. The compiler works out the parameter types from the context.

Where can you use a lambda? Functional interfaces

#

A lambda can be used wherever a functional interface is expected. A functional interface is an interface with exactly one abstract method (it may also have default and static methods, as in article 6). Comparator and Runnable are functional interfaces. The compiler matches the lambda to that single method, using the target type (the type expected by the context): in words.sort(...), the parameter is a Comparator<String>, so the lambda must be a compare method for strings. That is why a lambda is not a new kind of object but a compact way to write an implementation of an interface you already understand.

You can declare your own and mark it with @FunctionalInterface, which makes the compiler verify that it really has a single abstract method:

Java
@FunctionalInterfaceinterface Discount {    long apply(long paise);}

The standard functional interfaces

#

You rarely need to write your own, because the java.util.function package provides ready-made ones. The package documentation states the principle: each functional interface has a single abstract method (the functional method), and a lambda's parameter and return types are matched to it. There are four core shapes to memorise.

Interface

Method

Shape

Example

Predicate<T>

boolean test(T t)

T to boolean

s -> s.isBlank()

Function<T, R>

R apply(T t)

T to R

s -> s.length()

Consumer<T>

void accept(T t)

T to nothing

s -> System.out.println(s)

Supplier<T>

T get()

nothing to T

() -> new ArrayList<String>()

Variants build on these: BiFunction<T, U, R>, BiPredicate, BiConsumer take two arguments. UnaryOperator<T> is a Function<T, T>, and BinaryOperator<T> combines two Ts into one. There are also primitive specialisations (IntPredicate, ToIntFunction<T>, IntBinaryOperator, ...) that avoid boxing int values into Integer objects, which matters in large loops.

Java
// File: Main.javaimport java.util.List;import java.util.function.BiFunction;import java.util.function.BinaryOperator;import java.util.function.Consumer;import java.util.function.Function;import java.util.function.IntPredicate;import java.util.function.Predicate;import java.util.function.Supplier;import java.util.function.UnaryOperator;public class Main {    public static void main(String[] args) {        Predicate<String> isEmpty = s -> s.isEmpty();        Function<String, Integer> length = s -> s.length();        Consumer<String> shout = s -> System.out.println(s.toUpperCase() + "!");        Supplier<List<String>> newList = () -> new java.util.ArrayList<>();        UnaryOperator<String> trim = String::strip;        BinaryOperator<Integer> add = (a, b) -> a + b;        BiFunction<String, Integer, String> repeat = (text, times) -> text.repeat(times);        IntPredicate even = n -> n % 2 == 0;        System.out.println(isEmpty.test("") + " " + isEmpty.test("x"));        System.out.println(length.apply("lambda"));        shout.accept("hello");        System.out.println(newList.get().size());        System.out.println("[" + trim.apply("  padded  ") + "]");        System.out.println(add.apply(2, 3) + " " + repeat.apply("ab", 3) + " " + even.test(7));    }}
Code
true false6HELLO!0[padded]5 ababab false

When you design your own methods, accept the most general standard interface that fits: a method that filters should take a Predicate<T>, one that transforms should take a Function<T, R>, and one that needs a lazily created value should take a Supplier<T>. Callers can then pass a lambda, a method reference or an existing object.

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;import java.util.function.Function;import java.util.function.Predicate;public class Main {    record Application(String company, String status, int daysOld) {}    static <T> List<T> filter(List<T> items, Predicate<T> rule) {        List<T> result = new ArrayList<>();        for (T item : items) {            if (rule.test(item)) {                result.add(item);            }        }        return result;    }    static <T, R> List<R> mapAll(List<T> items, Function<T, R> transform) {        List<R> result = new ArrayList<>();        for (T item : items) {            result.add(transform.apply(item));        }        return result;    }    public static void main(String[] args) {        List<Application> apps = List.of(                new Application("Acme", "APPLIED", 20),                new Application("Globex", "INTERVIEW", 5),                new Application("Initech", "APPLIED", 3));        System.out.println(filter(apps, a -> a.status().equals("APPLIED")).size());        System.out.println(filter(apps, a -> a.daysOld() > 14));        System.out.println(mapAll(apps, Application::company));    }}
Code
2[Application[company=Acme, status=APPLIED, daysOld=20]][Acme, Globex, Initech]

One filter method now serves every rule anyone can think of. This is the strategy pattern from object-oriented design, with the ceremony removed: the strategy is a lambda instead of a class.

Method references

#

When a lambda does nothing except call an existing method, you can write a method reference with :: instead. Oracle's tutorial lists four kinds:

Kind

Syntax

Equivalent lambda

Static method

Integer::parseInt

s -> Integer.parseInt(s)

Method of a particular object (bound)

System.out::println

x -> System.out.println(x)

Instance method of an arbitrary object of a type (unbound)

String::length

s -> s.length()

Constructor

ArrayList::new

() -> new ArrayList<>()

Java
// File: Main.javaimport java.util.List;import java.util.function.BiFunction;import java.util.function.Function;import java.util.function.Supplier;public class Main {    record Person(String name) {        static Person parse(String text) {            return new Person(text.strip());        }    }    public static void main(String[] args) {        Function<String, Integer> staticRef = Integer::parseInt;        System.out.println(staticRef.apply("42") + 1);        List<String> names = List.of("Asha", "Ravi");        names.forEach(System.out::println);                 // bound: System.out is a particular object        Function<String, Integer> unbound = String::length; // the first argument is the receiver        BiFunction<String, String, Boolean> startsWith = String::startsWith;        System.out.println(unbound.apply("method") + " " + startsWith.apply("reference", "ref"));        Supplier<StringBuilder> make = StringBuilder::new;  // constructor        Function<String, Person> parse = Person::parse;        Function<String, Person> create = Person::new;        System.out.println(make.get().append("built").toString() + " " + parse.apply(" Meena ") + " " + create.apply("Dev"));    }}
Code
43AshaRavi6 truebuilt Person[name=Meena] Person[name=Dev]

The unbound form is the one that confuses people: String::length as a Function<String, Integer> means "take the argument and call length() on it". Use a method reference when it is clearer than the lambda, which is usually when the lambda would just repeat the method's name. If you need to do something extra, write the lambda.

Composing small functions

#

Because functions are values, you can build bigger ones from smaller ones. The functional interfaces provide methods for this:

Java
// File: Main.javaimport java.util.function.Function;import java.util.function.Predicate;public class Main {    public static void main(String[] args) {        Predicate<String> notBlank = s -> !s.isBlank();        Predicate<String> shortEnough = s -> s.length() <= 8;        Predicate<String> validName = notBlank.and(shortEnough);        Predicate<String> invalid = validName.negate();        Predicate<String> special = s -> s.startsWith("#");        System.out.println(validName.test("Asha") + " " + validName.test("  ") + " " + validName.test("Bartholomew"));        System.out.println(invalid.test("Bartholomew") + " " + validName.or(special).test("#hashtag-too-long"));        Function<String, String> trim = String::strip;        Function<String, String> upper = String::toUpperCase;        Function<String, String> exclaim = s -> s + "!";        System.out.println(trim.andThen(upper).andThen(exclaim).apply("  hello "));        System.out.println(upper.compose(trim).apply("  same result "));    }}
Code
true false falsetrue trueHELLO!SAME RESULT

and, or and negate combine predicates, and andThen and compose chain functions (f.andThen(g) runs f first, f.compose(g) runs g first). The Comparator methods you saw earlier (thenComparing, reversed) work the same way. This is a different style from loops with ifs inside: you describe the rule as data and combine rules.

Captured variables must be effectively final

#

A lambda can use variables from the method around it, which is called capturing. There is a rule: Oracle's tutorial says a lambda can access only local variables and parameters of the enclosing block that are final or effectively final. A variable is effectively final if its value never changes after it is initialised.

Java
// File: Main.javaimport java.util.List;public class Main {    public static void main(String[] args) {        List<Integer> numbers = List.of(1, 2, 3);        int total = 0;        numbers.forEach(n -> total += n);        System.out.println(total);    }}
Code
Main.java:8: error: local variables referenced from a lambda expression must be final or effectively final        numbers.forEach(n -> total += n);                             ^1 error

The compiler rejects the assignment. The reason is that a lambda may run later or on another thread, after the method has returned and its local variables are gone, so it works with a copy of the captured value. Allowing the lambda to modify a variable that the method also sees would create two diverging copies, or race conditions. Java forbids it. Note that this concerns local variables: a lambda can freely call methods on a captured object, including methods that change it (such as list.add), and can read and write fields.

How to fix the example: do not accumulate through side effects at all. Compute the total with reduce or a stream's sum, which the next article shows. (Using an AtomicInteger or a one-element array to get around the rule compiles, but it is usually a sign that you are using the wrong tool.)

Also, inside a lambda, this refers to the enclosing object, unlike in an anonymous class where this is the anonymous object itself. A lambda introduces no new scope: its parameter names must not clash with local variables of the enclosing method.

Lambdas and checked exceptions

#

The standard functional interfaces do not declare checked exceptions. A lambda body that calls a method throwing a checked exception, such as Files.readString, does not compile unless the lambda handles it:

Java
paths.stream().map(p -> Files.readString(p));          // compile error: unreported IOException

Practical options: catch the exception inside the lambda and wrap it in an unchecked one (catch (IOException e) { throw new UncheckedIOException(e); }), move the risky code into a helper method that does that, or avoid the lambda and use a plain loop. This is one reason that many modern libraries use unchecked exceptions, as discussed in article 10.

Laziness with Supplier

#

A Supplier also lets you defer work until it is needed. Compare Optional.orElse(expensive()), which computes the fallback even when it is not needed, with orElseGet(() -> expensive()), which computes it only when required (article 17). Logging libraries use the same trick: the message supplier runs only if that log level is enabled.

Writing good lambdas

#
  • Keep them short. If a lambda needs more than a few lines, give it a name: write a method and use a method reference.
  • Prefer pure functions: no side effects, results depend only on the arguments. They are easier to understand, test and run in parallel.
  • Do not mutate captured state.
  • Name your intent. isOverdue(app) as a method reference reads better than a long inline condition.
  • Do not over-compose. A chain of five andThen calls is harder to read than a method with a few lines.
  • Use the primitive specialisations (IntPredicate, ToIntFunction) in tight loops over numbers.

Common mistakes

#
  • Trying to modify a captured local variable.
  • Writing a multi-line lambda where a named method would be clearer.
  • Confusing the unbound method reference (String::length) with a bound one (someString::length).
  • Ignoring checked exceptions until the compiler complains, then swallowing them.
  • Expecting this to mean the lambda itself.
  • Creating your own functional interface when a standard one exists.
  • Using a lambda where an object with state is needed, for example something that must keep a counter between calls.

Exercises

#

Exercise 1

Convert

#

Rewrite the following anonymous class as a lambda, and then as a method reference if possible: new Function<String, Integer>() { public Integer apply(String s) { return Integer.valueOf(s); } }.

Exercise 2: Choose the interface

#

Which standard functional interface fits each: (a) "decide whether an order is overdue", (b) "turn an order into its total", (c) "print an order", (d) "create a new empty order", (e) "add two money values to get a third of the same type"?

Exercise 3: Predict

#

What does this print? Function<Integer, Integer> doubleIt = x -> x * 2; Function<Integer, Integer> addTen = x -> x + 10; System.out.println(doubleIt.andThen(addTen).apply(5) + " " + doubleIt.compose(addTen).apply(5));

Exercise 4: Fix the compile error

#

int count = 0; list.forEach(item -> { if (item.isBlank()) count++; }); does not compile. Explain why and rewrite it without mutation.

Exercise 5: Design a method

#

Write a method static <T> void forEachMatching(List<T> items, Predicate<T> test, Consumer<T> action) that runs action on every item for which test is true. Call it to print all blank-free names from a list.

Show solutionHide solution

As a lambda: s -> Integer.valueOf(s). As a method reference: Integer::valueOf. The method reference is clearer, because the lambda just forwards its argument to an existing static method. The interface is Function<String, Integer>, so both forms are interchangeable wherever it is expected.

Exercise 2

Show solutionHide solution

(a) Predicate<Order>. (b) Function<Order, Money> (or ToLongFunction<Order> for a primitive total). (c) Consumer<Order>. (d) Supplier<Order>, for example Order::new. (e) BinaryOperator<Money>, which is BiFunction<Money, Money, Money>. The pattern is to ask "what goes in and what comes out?": in-and-boolean, in-and-out, in-and-nothing, nothing-and-out.

Exercise 3

Show solutionHide solution

It prints 20 30. doubleIt.andThen(addTen).apply(5) doubles first (10) and then adds ten, giving 20. doubleIt.compose(addTen).apply(5) runs addTen first (15) and then doubles, giving 30. The key is the order: f.andThen(g) means "f, then g", while f.compose(g) means "g, then f".

Exercise 4

Show solutionHide solution

count is modified inside the lambda, but a captured local variable must be effectively final, so the compiler rejects it. Rewrite without mutation: long count = list.stream().filter(String::isBlank).count(); (streams are covered in article 16), or use a simple loop: for (String item : list) { if (item.isBlank()) count++; }, which has no lambda and so no restriction.

Exercise 5

Show solutionHide solution
Java
static <T> void forEachMatching(List<T> items, Predicate<T> test, Consumer<T> action) {    for (T item : items) {        if (test.test(item)) {            action.accept(item);        }    }}forEachMatching(names, name -> !name.isBlank(), System.out::println);

The method combines the two shapes you learned: a Predicate to decide, and a Consumer to act. The call passes a lambda for the test and a bound method reference for the action.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. docs.oracle.com
  3. docs.oracle.com
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java