Lambdas and Functional Interfaces
The motivating problem
Before Java 8, passing behaviour as a parameter required heavyweight constructs. Suppose you want to sort a list of strings by length. You had two options, both verbose:
Option 1: Named class (full Strategy pattern)
class LengthComparator implements Comparator<String> {
public int compare(String a, String b) {
return a.length() - b.length();
}
}
Collections.sort(list, new LengthComparator());
Option 2: Anonymous class
Collections.sort(list, new Comparator<String>() {
public int compare(String a, String b) {
return a.length() - b.length();
}
});
Both work, but for a one-off, short piece of logic — a single expression — the ceremony of declaring a class or anonymous class is excessive. The intent (“sort by length”) is buried in syntactic boilerplate.
Lambdas: the solution
A lambda expression is an anonymous function — syntax sugar for creating an instance of a functional interface concisely:
Collections.sort(list, (a, b) -> a.length() - b.length());
The compiler infers that (a, b) are String parameters (from the Comparator<String> context) and that the body returns an int. The lambda is compiled into an instance of Comparator<String> — it is still OOP underneath (the lambda becomes an object that implements the interface), but the syntax is dramatically shorter.
Functional interfaces
A functional interface is an interface with exactly one abstract method — a “SAM” type (Single Abstract Method). Lambdas can only be used where a functional interface is expected.
Examples of functional interfaces:
// Built-in
Comparator<T> // int compare(T o1, T o2)
Runnable // void run()
Callable<V> // V call()
// Custom
@FunctionalInterface
interface StringProcessor {
String process(String input);
}
The @FunctionalInterface annotation is optional but recommended: it marks the intent, documents the interface’s purpose, and lets the compiler error if you accidentally add a second abstract method.
A functional interface can have default and static methods — these do not count toward the single abstract method requirement.
Lambda syntax
(parameters) -> expression
(parameters) -> { statements; }
| Form | Example |
|---|---|
| No parameters | () -> System.out.println("Hello") |
| Single parameter (no type) | x -> x * x |
| Single parameter (with type) | (int x) -> x * x |
| Multiple parameters | (a, b) -> a + b |
| Block body | (a, b) -> { int sum = a + b; return sum; } |
| Expression body | (a, b) -> a + b (implicit return) |
Rules:
- Parameter types can be omitted if the compiler can infer them from context.
- Parentheses around a single parameter can be omitted.
- An expression body is implicitly returned — no
returnkeyword. - A block body must use explicit
returnfor non-void return types.
Target type inference
The compiler determines which functional interface a lambda targets from context. This means the same lambda expression can implement different interfaces depending on where it appears:
// Same lambda, different functional interfaces
Predicate<String> p = s -> s.isEmpty(); // boolean test(String)
Function<String, Boolean> f = s -> s.isEmpty(); // Boolean apply(String)
// Assignment context — the variable's type determines the target
Comparator<String> byLength = (a, b) -> a.length() - b.length();
// Method parameter context — the parameter type determines the target
list.sort((a, b) -> a.length() - b.length());
// Cast context — explicit cast disambiguates
Object o = (Comparator<String>) (a, b) -> a.length() - b.length();
How lambdas work under the hood
The lambda (a, b) -> a.length() - b.length() is NOT compiled into an anonymous class. Instead, the compiler generates an invokedynamic instruction. At runtime, the JVM links this to a factory method that produces an instance of the functional interface. The implementation strategy is JVM-dependent but typically uses method handles — it is more efficient than anonymous inner classes (no separate .class file, lazy initialisation).
Variable capture
Lambdas can access variables from the enclosing scope, subject to the same “effectively final” rule as anonymous classes:
String prefix = "Item: ";
// prefix must be effectively final — not reassigned after initialisation
list.forEach(item -> System.out.println(prefix + item));
The variable does not need to be declared final, but it must not be reassigned (the compiler checks this). This is because the lambda may execute after the enclosing method returns, so it captures the variable’s value, not a reference to a changing variable.
Why lambdas matter for OOP
Lambdas reduce the syntactic overhead of the Strategy pattern. When a strategy is a single expression, a lambda replaces the need for a named strategy class. This encourages developers to use patterns like Observer, Strategy, and Command more freely, since the setup cost is lower.
// Pre-Java 8: Observer with anonymous class
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
System.out.println("Clicked");
}
});
// Java 8+: Observer with lambda
button.addActionListener(e -> System.out.println("Clicked"));