Step 1 of 7 · Reading · ~3 min
Learn
Generics, Streams, and Functional Java
Functional Interfaces and Lambdas
In Java Intermediate you used the standard Function, Predicate, Supplier and friends and wrote method references. This lesson is about designing your own functional interfaces with default and static methods, what a lambda actually captures, laziness through Supplier, and the checked-exception problem. You will be able to build a small combinator library where several lambdas compose into one.
Your own functional interface
Any interface with exactly one abstract method can be the target of a lambda. Add default methods for combinators and static methods for factories:
@FunctionalInterface
interface Transform<A, B> {
B apply(A in); // the one abstract method
default <C> Transform<A, C> then(Transform<B, C> next) { // combinator: returns a new lambda
return in -> next.apply(apply(in));
}
static <A> Transform<A, A> identity() { return a -> a; } // factory
}
Transform<String, Integer> len = String::length;
Transform<String, String> label = len.then(n -> n + " chars");
label.apply("lambda"); // "6 chars"
@FunctionalInterface makes the compiler reject a second abstract method. Inside then, the lambda calls apply(in) on the enclosing object: a lambda does not have its own this; this and unqualified method names mean the enclosing instance. An anonymous class has its own this. That is the one real semantic difference between them.
Return a lambda when behaviour depends on a value you have in hand. A function that returns a function is currying:
Function<Integer, Function<Integer, Integer>> add = a -> b -> a + b;
Function<Integer, Integer> plusTen = add.apply(10); // partially applied
plusTen.apply(5); // 15
What a lambda captures
A lambda can read local variables only if they are effectively final, because it keeps a copy of the value. A counter that must change from inside a lambda has to live in an object:
int hits = 0;
list.forEach(x -> hits++); // error: hits is not effectively final
AtomicInteger hits2 = new AtomicInteger();
list.forEach(x -> hits2.incrementAndGet()); // fine: the reference is final, the object mutates
Fields are different: a lambda reads this.field live, not a copy.
Laziness with Supplier
Arguments are evaluated before the call. opt.orElse(load()) calls load() even when the Optional has a value; opt.orElseGet(() -> load()) calls it only when needed. Passing a Supplier<String> to a logging method defers building an expensive message until the level check passes. Short-circuit operators rely on the same idea: Predicate.and(b) never calls b if the first test is false.
Checked exceptions
The standard interfaces declare no throws, so map(Class::forName) does not compile. Define a throwing variant and adapt it once:
@FunctionalInterface
interface ThrowingFunction<T, R> { R apply(T t) throws Exception; }
static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> fn) {
return t -> {
try { return fn.apply(t); }
catch (Exception e) { throw new RuntimeException(e); }
};
}
// names.stream().map(unchecked(Class::forName)) now compiles
Identity elements
When you fold many lambdas into one, the empty case needs a neutral element: x -> true for combining predicates with and, x -> x for composing functions. Without one, an empty list has no meaningful result.
Common mistakes
- Evaluating all conditions when the first failure should end the check. Extra work and extra side effects.
- Using
orElse(expensiveCall()). The call always runs. - Assuming a lambda captures a variable, not its value. The compiler refuses reassigned locals.
- Expecting
thisto be the lambda. It is the enclosing object.
Your exercise
Finish a validation Rule<T> interface: build a rule from a predicate and a message, chain two rules so the first failure wins, and fold a list of rules into one. The starter counts how many predicate calls are made, so a rule that checks more than it must is visible in the output.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…