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 understand what functional interfaces are, explore the most common built-in functional interfaces in the java.util.function package, and learn how to 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.
These are foundational tools we’ll build on as we move toward working with the Java Streams API.
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 any interface that defines a single abstract method. These interfaces are designed to represent single units of behavior and are the target type for lambda expressions and method references, which we’ll introduce later in this lesson.
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, and those are what we’ll focus on next.
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() {
}
Now, we’ll learn each functional interface one by one and implement them through anonymous classes.
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 defined 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 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 like 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 declares exactly one abstract method.
When creating a custom functional interface, it’s recommended to annotate it with @FunctionalInterface, which signals to readers that the interface is intended to be functional and also allows the compiler to catch mistakes, such as accidentally adding a second 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.
Where:
- 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;
}
Let’s have a look. In this example, we used lambdas to implement several of Java’s core functional interfaces. This gives us a practical preview of how concise and expressive lambda-based code can be. Rather than writing verbose anonymous classes, we simply pass behavior using short inline functions – a key technique we’ll rely on when working with Stream operations later in the course.
After seeing how we can implement functional interfaces using lambdas, it’s helpful to understand the underlying mechanism behind this concept. Even though lambdas look very different from anonymous inner classes, they aren’t fundamentally new: lambda expressions are compiled into instances of functional interfaces, just like anonymous inner classes, but with a more efficient and lightweight representation under the hood.
This understanding reinforces that lambdas integrate seamlessly with Java’s object-oriented model while providing a more concise syntax, and it also gives us insight into runtime behavior, performance characteristics, and the limitations of lambdas.
6. Lambda Expressions Parameters
Sometimes, we may want to use variables from the surrounding scope inside a lambda; for example, to 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. This allows us to maintain a structured codebase while still benefiting from the concise syntax of lambdas.
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. So everything we’ve learned about target types, type inference, and captured variables still applies.
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 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 = tasks.stream()
.map(Task::getCode)
.filter(code -> code.startsWith("T"))
.collect(Collectors.joining(", "));
assertEquals("T1, T2, T3", combinedCodes);
}
When we type tasks.stream(). in an IDE like IntelliJ or Eclipse, auto-completion suggests many Stream operations that expect functional interfaces as arguments:
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 value, a technique we’ll rely on extensively when working with streams throughout this course.