1. Overview

Java’s support for functional-style programming begins with two essential features: functional interfaces and lambda expressions. These constructs let us express behavior as values and pass it around with minimal boilerplate – a pattern that’s particularly powerful when working with the Streams API.

In this lesson, we’ll explore functional interfaces in the java.util.function package. We’ll implement them using anonymous inner classes. Then, we’ll simplify things using lambda expressions, take a look at method references, and connect these concepts to real-world usage with streams.

The relevant module we need to import when starting this lesson is: functional-interfaces-and-lambdas-in-java-start.

If we want to reference the fully implemented lesson, we can import: functional-interfaces-and-lambdas-in-java-end.

2. Introduction to Functional Interfaces

A functional interface is a non-sealed interface with a single abstract method contract. Methods matching public instance methods of Object do not count toward this contract, and inherited abstract methods can represent the same contract. These interfaces represent single units of behavior. They provide the target type for lambda expressions and method references.

Java has had functional interfaces since the early days, such as the Runnable and Comparator interfaces. With Java 8, a full set of standardized functional interfaces was introduced in the java.util.function package.

3. Built-in Functional Interfaces in java.util.function

Let’s start by implementing some of the core functional interfaces using anonymous inner classes. This will help us appreciate the difference lambdas make later on.

We can open the JavaStreamsUnitTest class and define a new test method to check this:

@Test
void givenFunctionalInterfacesAsAnonymousClasses_whenExecutingThem_thenAllReturnCorrectResult() {

}

3.1. Function

The Function interface represents a transformation from one value to another, potentially of a different type. In our test method, let’s define a new function:

Function<String, Integer> strLength = new Function<>() {

    @Override
    public Integer apply(String s) {
        return s.length();
    }
};
assertEquals(8, strLength.apply("Baeldung"));

Here, we create a function that takes a String and returns its length as an Integer.

Although Function is just an interface, we can provide an inline implementation with an anonymous class. This way, we specify how its single abstract method should behave, without declaring a separate class. In other words, we’re treating behavior as an object that can be passed around.

3.2. BiFunction

The BiFunction interface works similarly, but takes two input values:

BiFunction<Integer, Integer, Double> divideToDouble = new BiFunction<>() {

    @Override
    public Double apply(Integer i1, Integer i2) {
        return i1.doubleValue() / i2.doubleValue();
    }
};
assertEquals(2.0, divideToDouble.apply(4, 2));

We define a bi-function that divides two integers and returns a double.

3.3. Consumer

The Consumer interface operates on a single input and returns nothing:

Consumer<String> printString = new Consumer<>() {

    @Override
    public void accept(String s) {
        System.out.println(s);
    }
};
printString.accept("Hello Baeldung");

Here, we use a consumer to print a string as a side effect.

3.4. Supplier

The Supplier interface produces a value without taking any input:

Supplier<String> idGenerator = new Supplier<>() {

    @Override
    public String get() {
        return UUID.randomUUID().toString();
    }
};
assertTrue(idGenerator.get().length() > 0);

We can use a supplier to generate a random UUID as a String.

3.5. Predicate

The Predicate interface evaluates a condition and returns a boolean:

Predicate<Integer> isEven = new Predicate<>() {

    @Override
    public boolean test(Integer i) {
        return i % 2 == 0;
    }
};
assertTrue(isEven.test(4));

Here, we define a predicate to check whether an integer is even.

3.6. Beyond the Core Built-ins

In addition to the core functional interfaces, Java provides several more specialized functional interfaces such as UnaryOperator, BinaryOperator, IntPredicate, and others.

If none of the built-in types fit our use case, we can also define our own functional interface – as long as it satisfies the single abstract method contract described above.

When creating a custom functional interface, we can annotate it with @FunctionalInterface to state its purpose. The compiler can then catch mistakes, such as adding a second independent abstract method.

4. Lambda Expression Syntax

Using anonymous inner classes works fine, but the syntax is verbose and not ideal for chaining multiple operations, especially in streams. Lambda expressions solve this problem by offering a compact way to provide the implementation of a functional interface.

At their core, lambda expressions are shorthand for creating instances of functional interfaces without having to write an anonymous class. They let us represent behavior directly in the code, making stream pipelines much more readable.

The basic syntax for a lambda expression is as follows: (parameters) -> expression.

The syntax has three parts:

  • parameters is a comma-separated list of inputs
  • The arrow token (->) separates the input from the function body
  • expression is the body of our function

5. Built-in Functional Interfaces as Lambda Expressions

As we mentioned, any functional interface can be represented as a lambda function, including the built-in ones. Let’s define a new test method in the JavaStreamsUnitTest class to check this:

@Test
void givenFunctionalInterfacesAsLambdas_whenExecutingThem_thenAllReturnCorrectResult() {
    Supplier<String> idGenerator = () -> UUID.randomUUID()
      .toString();

    Function<String, Integer> strLength = s -> s.length();

    BiFunction<Integer, Integer, Double> divideToDouble = (s1, s2) -> s1.doubleValue() / s2.doubleValue();

    Consumer<String> printString = s -> System.out.println(s);

    Predicate<Integer> isEven = s -> s % 2 == 0;

    // Supplier
    String newId = idGenerator.get();
    assertTrue(!newId.isEmpty());

    // Function
    Integer lengthOfString = strLength.apply("test");
    assertEquals(4, lengthOfString);

    // BiFunction
    Double result = divideToDouble.apply(5, 2);
    assertEquals(2.5, result);

    // Consumer
    printString.accept("Showing from consumer");

    // Predicate
    boolean is10Even = isEven.test(10);
    boolean is11Even = isEven.test(11);
    assertTrue(is10Even);
    assertFalse(is11Even);
}

In this example, we implement the same functional interfaces with lambdas. Each lambda supplies the behavior of the interface’s abstract method without the anonymous-class declaration.

After seeing how we can implement functional interfaces using lambdas, we can distinguish creating the implementation from invoking it. Evaluating a lambda expression produces a reference to an object that implements its target functional interface. The lambda body runs when we invoke the corresponding interface method.

6. Lambda Expression Parameters

Sometimes, we may want to use variables from the surrounding scope inside a lambda. For example, we can include a common prefix or suffix in the output. In Java, this is allowed only if those variables are effectively final, that is, assigned once and never reassigned. If we try to change the value later, either outside or inside the lambda, the compiler will raise an error.

Let’s write a new test in JavaStreamsUnitTest using a lambda to build a summary string for a Task:

@Test
void givenTask_whenBuildingDueYearSummary_thenSummaryConstructed() {
    String summaryConnector = "is due on";

    // Uncommenting the next line would break the lambda below:
    // summaryConnector = "changed connector"; // Compile-time error: not
    // effectively final

    Task t1 = new Task("T1", "Task Name", "Task Description", LocalDate.of(2050, 1, 1));

    Function<Task, String> dueYearSummary = task -> {
        final String name = task.getName();
        final int dueYear = task.getDueDate().getYear();
        return "%s %s %s".formatted(name, summaryConnector, dueYear);
    };

    String summaryValue = dueYearSummary.apply(t1);

    assertEquals("Task Name is due on 2050", summaryValue);
}

As we can see, when the body of a lambda requires more than one statement, we wrap it in braces and use an explicit return if it produces a result.

Also, we can notice how Java supports type inference within lambda expressions. In the example above:

  • We didn’t need to declare the type of Task – the compiler infers it from the Function<Task, String> declaration.
  • We also didn’t specify the return type explicitly – the compiler expects a String return value based on the functional interface and ensures the body of the lambda matches that.

7. Method References

A method reference is a shorthand for a lambda that simply calls an existing method. It uses the double-colon operator and can be used for different scenarios:

  • Static method – ClassName::staticMethod
  • Instance method of a particular object – instance::method
  • Instance method of an arbitrary object of a particular type – ClassName::instanceMethod
  • Constructor reference – ClassName::new

Method references improve readability when the lambda body does nothing more than delegate to another method.

Let’s write a new test to outline the difference between a simple lambda reference and a method reference:

@Test
void givenAList_whenUsingMethodReference_thenGetResult() {
    Function<Task, LocalDate> getDueDate = task -> task.getDueDate();
    Function<Task, LocalDate> getDueDateRef = Task::getDueDate;

    Task t1 = new Task("T2", "Task 2 Name", "Task 2 Description", LocalDate.of(2050, 1, 1));
    LocalDate dueDate = getDueDate.apply(t1);
    LocalDate dueDateRef = getDueDateRef.apply(t1);

    assertEquals(dueDate, dueDateRef);
}

Just like a lambda, a method reference is used to create an instance of a functional interface. The functional interface still supplies its target type.

8. Functional Interfaces, Lambdas, and the Streams API

Without going into details yet, let’s bring everything together in a preview of how streams and lambdas work hand in hand.

For this, we’ll define a small list of tasks and create a simple test:

private final List<Task> tasks = List.of(
  new Task("T1", "Task 1", "Task 1", LocalDate.now()),
  new Task("T2", "Task 2", "Task 2", LocalDate.now()),
  new Task("T3", "Task 3", "Task 3", LocalDate.now()),
  new Task("S1", "Task 4", "Task 4", LocalDate.now()));

@Test
void givenListOfTasks_whenJoiningCodesUsingStream_thenCorrectStringIsReturned() {
    String combinedCodes = joinCodesWithStream(tasks);

    assertEquals("T1, T2, T3", combinedCodes);
}

The test delegates to this helper, matching the end project:

String joinCodesWithStream(List<Task> tasks) {
    return tasks.stream()
      .map(Task::getCode)
      .filter(code -> code.startsWith("T"))
      .collect(Collectors.joining(", "));
}

When we type tasks.stream(). in an IDE like IntelliJ or Eclipse, auto-completion suggests many Stream operations that expect functional interfaces as arguments:

IDE auto-completion for Stream operations

In this example, we can already see how the behavior of each step in the pipeline is expressed concisely using lambda expressions and method references:

  • first, we extract all the task codes with a Function, represented by the Task::getCode method reference
  • then, we keep only the codes that start with “T” using a Predicate defined as a lambda expression

9. Conclusion

Together, functional interfaces, anonymous inner classes, lambda expressions, and method references bring functional-style programming to Java. They give us a clean, expressive way to pass behavior as a value, a technique we’ll rely on extensively when working with streams throughout this course.