- Understand what functional interfaces are and how to create them
- Master built-in interfaces: Function, Predicate, Consumer, Supplier
- Learn to use method references as lambda shorthand
Functional Interfaces in Java
Before Java 8, passing behavior to a method meant creating an anonymous class. Verbose. Hard to read. Java 8 introduced functional interfaces and lambdas to fix this, bringing functional programming to Java.
A functional interface is an interface with exactly one abstract method. That single method represents the behavior you're passing around. Because there's only one, Java can infer which method you're implementing when you use a lambda.
Understanding Functional Interfaces
What Makes an Interface Functional?
Exactly one abstract method. But it can also have:
- Multiple default methods (with implementation)
- Multiple static methods
- Methods inherited from
Object(toString(),equals())
The rule: one and only one abstract method.
The @FunctionalInterface Annotation
While not required, adding the @FunctionalInterface annotation is a best practice. It serves two purposes:
- Documentation: It tells other developers that this interface is meant to be used with lambdas
- Compile-time checking: The compiler will produce an error if the interface has more than one abstract method
@FunctionalInterface
public interface Calculator {
int calculate(int a, int b); // The single abstract method
// These are allowed:
default void printResult(int result) {
System.out.println("Result: " + result);
}
static Calculator getAdder() {
return (a, b) -> a + b;
}
}
From Anonymous Classes to Lambdas
Before lambdas, implementing a functional interface required verbose anonymous classes:
// Old way: Anonymous class (verbose)
Calculator adder = new Calculator() {
@Override
public int calculate(int a, int b) {
return a + b;
}
};
// New way: Lambda expression (concise)
Calculator adder = (a, b) -> a + b;
Both create the same behavior, but the lambda is much more readable. The compiler knows that (a, b) -> a + b implements the calculate method because it's the only abstract method in the interface.
The Four Core Functional Interfaces
Java provides a set of built-in functional interfaces in the java.util.function package. Understanding four core interfaces will cover most use cases:
1. Function<T, R> - Transforming Data
Purpose: Takes an input of type T and produces an output of type R.
Think of Function as a converter or transformer. You give it something, and it gives you back something else (possibly of a different type).
Real-world analogy: A currency converter takes dollars and returns euros.
// Transform a String to its length
Function<String, Integer> getLength = text -> text.length();
int length = getLength.apply("Hello"); // Returns 5
// Transform a number to a formatted string
Function<Double, String> formatPrice = price -> String.format("$%.2f", price);
String formatted = formatPrice.apply(19.99); // Returns "$19.99"
Chaining Functions: You can combine functions using andThen() and compose():
Function<String, String> toUpperCase = String::toUpperCase;
Function<String, String> addExclamation = s -> s + "!";
// andThen: first toUpperCase, then addExclamation
Function<String, String> shout = toUpperCase.andThen(addExclamation);
shout.apply("hello"); // Returns "HELLO!"
2. Predicate - Testing Conditions
Purpose: Takes an input and returns true or false.
Think of Predicate as a yes/no question about the input. It's commonly used for filtering data.
Real-world analogy: A security checkpoint checks if a person has a valid ticket (yes/no).
// Check if a number is positive
Predicate<Integer> isPositive = number -> number > 0;
isPositive.test(5); // Returns true
isPositive.test(-3); // Returns false
// Check if a string is not empty
Predicate<String> isNotEmpty = text -> text != null && !text.isEmpty();
Combining Predicates: Predicates can be combined with logical operators:
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven = n -> n % 2 == 0;
// Combine with AND
Predicate<Integer> isPositiveAndEven = isPositive.and(isEven);
isPositiveAndEven.test(4); // true (positive AND even)
isPositiveAndEven.test(3); // false (positive but NOT even)
// Combine with OR
Predicate<Integer> isPositiveOrEven = isPositive.or(isEven);
isPositiveOrEven.test(-2); // true (even, though not positive)
// Negate
Predicate<Integer> isNegative = isPositive.negate();
isNegative.test(-5); // true
3. Consumer - Performing Actions
Purpose: Takes an input and performs an action without returning anything.
Think of Consumer as an endpoint that "consumes" data. It does something with the input (like printing, saving, or sending) but doesn't produce a new value.
Real-world analogy: A printer consumes documents and prints them (no return value).
// Print a message
Consumer<String> printer = message -> System.out.println(message);
printer.accept("Hello, World!"); // Prints: Hello, World!
// Save to a database (conceptually)
Consumer<User> saveUser = user -> database.save(user);
// Log with timestamp
Consumer<String> logger = msg ->
System.out.println(LocalDateTime.now() + ": " + msg);
Chaining Consumers: Use andThen() to perform multiple actions:
Consumer<String> log = msg -> System.out.println("LOG: " + msg);
Consumer<String> archive = msg -> archiveSystem.store(msg);
// First log, then archive
Consumer<String> logAndArchive = log.andThen(archive);
logAndArchive.accept("Important event occurred");
4. Supplier - Providing Values
Purpose: Takes no input and produces a value.
Think of Supplier as a factory or generator. When you need a value, you call get() and it provides one.
Real-world analogy: A vending machine provides snacks when you press a button.
// Generate random numbers
Supplier<Double> randomNumber = () -> Math.random();
double value = randomNumber.get(); // Gets a random number
// Get current time
Supplier<LocalDateTime> now = () -> LocalDateTime.now();
// Create new instances (factory pattern)
Supplier<List<String>> listFactory = () -> new ArrayList<>();
List<String> newList = listFactory.get(); // Creates a new empty list
Common use case: Lazy evaluation with Optional:
Optional<String> optional = Optional.empty();
// The supplier is only called if the Optional is empty
String value = optional.orElseGet(() -> computeExpensiveDefault());
Extended Functional Interfaces
BiFunction<T, U, R>
When you need a function with two inputs:
BiFunction<String, String, String> concat = (a, b) -> a + b;
concat.apply("Hello, ", "World!"); // Returns "Hello, World!"
BiFunction<Integer, Integer, Double> divide = (a, b) -> (double) a / b;
divide.apply(10, 4); // Returns 2.5
UnaryOperator and BinaryOperator
When input and output types are the same:
// UnaryOperator: one input, same type output
UnaryOperator<Integer> square = n -> n * n;
square.apply(5); // Returns 25
// BinaryOperator: two inputs of same type, same type output
BinaryOperator<Integer> max = (a, b) -> a > b ? a : b;
max.apply(10, 7); // Returns 10
Primitive Specializations
To avoid boxing/unboxing overhead, Java provides primitive-specific versions:
| Generic | Int Version | Long Version | Double Version |
|---|---|---|---|
Predicate<T> |
IntPredicate |
LongPredicate |
DoublePredicate |
Function<T,R> |
IntFunction<R> |
LongFunction<R> |
DoubleFunction<R> |
Consumer<T> |
IntConsumer |
LongConsumer |
DoubleConsumer |
Supplier<T> |
IntSupplier |
LongSupplier |
DoubleSupplier |
// More efficient than Predicate<Integer>
IntPredicate isEven = n -> n % 2 == 0;
isEven.test(4); // No boxing overhead
Method References
Method references are a shorthand syntax for lambdas that simply call an existing method. There are four types:
1. Static Method Reference
// Lambda
Function<String, Integer> parser = s -> Integer.parseInt(s);
// Method reference (cleaner)
Function<String, Integer> parser = Integer::parseInt;
2. Instance Method on a Specific Object
String prefix = "Hello, ";
// Lambda
Function<String, String> greeter = name -> prefix.concat(name);
// Method reference
Function<String, String> greeter = prefix::concat;
3. Instance Method on the Parameter
// Lambda
Function<String, String> upper = s -> s.toUpperCase();
// Method reference (the parameter becomes the receiver)
Function<String, String> upper = String::toUpperCase;
4. Constructor Reference
// Lambda
Supplier<ArrayList<String>> factory = () -> new ArrayList<>();
// Constructor reference
Supplier<ArrayList<String>> factory = ArrayList::new;
Practical Usage with Streams
Functional interfaces truly shine when used with the Stream API:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Diana");
List<String> result = names.stream()
.filter(name -> name.length() > 3) // Predicate
.map(String::toUpperCase) // Function
.sorted() // Uses Comparator
.collect(Collectors.toList());
// Result: [ALICE, CHARLIE, DIANA]
Best Practices
- Keep lambdas short: If your lambda is more than 2-3 lines, extract it to a method
- Use method references when possible: They're more readable than lambdas for simple cases
- Prefer standard functional interfaces: Use
java.util.functioninterfaces before creating custom ones - Add @FunctionalInterface: Always annotate your custom functional interfaces
- Choose the right interface: Match the interface to your use case (transform → Function, test → Predicate, etc.)
Functional interfaces bridge object-oriented and functional Java. Four core interfaces cover most scenarios: Function<T,R> transforms data, Predicate<T> tests conditions, Consumer<T> performs actions, Supplier<T> provides values. Essential for streams, Optional, and modern Java code.
