Introduction
In the last part, we learned what Streams are and how they simplify working with data in Java. But behind every powerful stream operation like filter(), map(), or forEach(), there’s something magical happening — Functional Interfaces.
Think of them as the heart and soul of Java 8’s functional programming.
Without them, Streams wouldn’t even exist.
Let’s understand these step-by-step — in a simple way.
What Is a Functional Interface?
A Functional Interface is an interface that has exactly one abstract method. This is what allows us to use lambda expressions.
@FunctionalInterface
interface MyPrinter {
void print(String message);
}
public class Example {
public static void main(String[] args) {
// Using lambda
MyPrinter printer = msg -> System.out.println(msg);
printer.print("Hello Functional Interfaces!");
}
}
Key rule: One abstract method = Functional Interface
Annotation: @FunctionalInterface (optional but recommended)
It is highly recommended that the annotation @FunctionalInterface is used to create functional interfaces as:
- If you try to add another abstract method it will lead to a compilation error.
- The whole reason lambdas work is because the compiler can map the lambda to that single abstract method.
- Once there’s ambiguity (more than one abstract method), Java can’t infer what the lambda should implement — so it disallows it.
Note: You can still add default and static methods to your functional interfaces, only condition is that it should have exactly 1 abstract method.
Why Are They So Important?
Functional interfaces make it possible to pass behavior as data.
That’s why you can do things like:
list.stream()
.filter(name -> name.startsWith("A")) // Predicate
.map(String::toUpperCase) // Function
.forEach(System.out::println); // Consumer
Each of those methods (filter, map, forEach) expects a functional interface.
There are some core functional interfaces that are used in streams: Predicate, Function, Consumer , Supplier, Bifunction, UnaryOperator and BinaryOperator.
We will try and understand each of them now.
Predicate
Represents a boolean condition on a value.
Method: boolean test(T t)
Here T is a generic type parameter. It represents the type of the input value that the predicate will test.
Example
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // true
System.out.println(isEven.test(7)); // false
Used in Streams:
filter() uses a Predicate to decide which elements to keep.
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.toList();
Function
Takes one value and returns another.
Method: R apply(T t)
Here T,R are generic type parameter. T represents the type of the input value that the Function will apply and R represents the output's return type.
Example
Function<String, Integer> lengthFinder = str -> str.length();
System.out.println(lengthFinder.apply("Java")); // 4
map() uses a Function to transform data.
List<Integer> lengths = words.stream()
.map(String::length)
.toList();
Consumer
Takes a value and performs an action, but returns nothing.
Method: void accept(T t)
Example
Consumer<String> printer = msg -> System.out.println("Message: " + msg);
printer.accept("Hello, World!");
forEach() uses a Consumer to process elements.
names.stream()
.forEach(name -> System.out.println("Hello, " + name));
Supplier
Supplies a value — takes nothing, just gives something back.
Method: T get()
Example:
Supplier<Double> randomValue = () -> Math.random();
System.out.println(randomValue.get());
Used when:
You want to provide values lazily (e.g., in factory methods or testing).
BiFunction
Takes two inputs, returns one output.
Method: R apply(T t, U u)
Here T,R,U are generic type parameter. T,U in BiFunction represents the type of the input value that the Function will apply and R represents the output's return type.
BiFunction<Integer, Integer, Integer> adder = (a, b) -> a + b;
System.out.println(adder.apply(5, 7)); // 12
UnaryOperator
A Function where input and output types are same.
UnaryOperator<Integer> square = n -> n * n;
System.out.println(square.apply(4)); // 16
BinaryOperator
A BiFunction where all types are same.
BinaryOperator<Integer> multiply = (a, b) -> a * b;
System.out.println(multiply.apply(3, 4)); // 12
Relationship in the functional interface hierarchy
| Interface | Extends | Method | Use Case |
|---|---|---|---|
Function<T, R> |
– | R apply(T t) |
Converts T → R
|
UnaryOperator<T> |
Function<T, T> |
T apply(T t) |
Transforms an element (T → T) |
BiFunction<T, U, R> |
– | R apply(T t, U u) |
Combines two different types (T, U → R) |
BinaryOperator<T> |
BiFunction<T, T, T> |
T apply(T t1, T t2) |
Combines two values of same type (T, T → T) |
Why functional interface are the backbone of java streams?
Every Stream operation depends on these interfaces:
| Stream Method | Functional Interface | Purpose |
|---|---|---|
filter() |
Predicate<T> |
Keep elements matching a condition |
map() |
Function<T, R> |
Transform elements |
forEach() |
Consumer<T> |
Act on each element |
reduce() |
BinaryOperator<T> |
Combine elements |
sorted() |
Comparator<T> |
Compare elements |
The table shows which stream method uses which functional interface to serve what purpose.
Conclusion
Functional Interfaces are like the grammar of Java 8 Streams.
Once you know their roles, you can easily read and write stream pipelines fluently — just like sentences in Java’s new functional language!
🔗 Next Up (Part 3): Creating Streams
Top comments (0)