Lambda Expressions and Functional Interfaces in Java — What Replaced Anonymous Classes and What Didn't
by Eric Hanson, Backend Developer at Clean Systems Consulting
What lambdas actually are
A lambda expression is a concise way to implement a functional interface — any interface with exactly one abstract method. The compiler infers the target type from context and generates an implementation at runtime without creating a named class file.
// Anonymous class — creates a .class file, has identity, can have state
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// Lambda — no class file, no identity, same behavior
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
// Method reference — even more concise when the method already exists
Comparator<String> byLength = Comparator.comparingInt(String::length);
The lambda and the anonymous class are functionally equivalent for Comparator — both implement compare. The lambda is shorter and allocates less. For this case and the majority of single-method interface implementations, lambdas are strictly better.
The functional interfaces worth knowing
java.util.function ships 43 functional interfaces covering the common cases. Four are the ones you use constantly:
Function<T, R> — takes a T, returns an R. The general transformation:
Function<String, Integer> length = String::length;
Function<String, String> upper = String::toUpperCase;
// Compose with andThen / compose
Function<String, Integer> upperLength = upper.andThen(length);
Predicate<T> — takes a T, returns boolean. Filtering:
Predicate<String> nonEmpty = s -> !s.isEmpty();
Predicate<String> shortWord = s -> s.length() < 5;
Predicate<String> nonEmptyShort = nonEmpty.and(shortWord);
Predicate<String> emptyOrLong = nonEmptyShort.negate();
Consumer<T> — takes a T, returns nothing. Side effects:
Consumer<Order> logOrder = order -> log.info("Processing: {}", order.id());
Consumer<Order> auditOrder = order -> auditService.record(order);
Consumer<Order> logThenAudit = logOrder.andThen(auditOrder);
Supplier<T> — takes nothing, returns a T. Lazy evaluation and factory patterns:
Supplier<List<String>> freshList = ArrayList::new;
Supplier<LocalDateTime> now = LocalDateTime::now;
// Lazy default in Optional
Optional<Config> config = findConfig().or(() -> Optional.of(defaultConfig()));
The primitive specializations — IntFunction<R>, ToIntFunction<T>, IntUnaryOperator — avoid boxing overhead for int, long, and double. In hot paths processing large collections of primitives, prefer these over Function<Integer, Integer>.
Designing your own functional interfaces
When the standard interfaces don't fit — different arity, checked exceptions, domain-specific naming — define your own. The @FunctionalInterface annotation is not required but serves two purposes: it documents intent and causes a compile error if the interface accidentally has more than one abstract method:
@FunctionalInterface
public interface OrderProcessor {
ProcessingResult process(Order order) throws ProcessingException;
}
The checked exception is why you need a custom interface here. Function<Order, ProcessingResult> cannot declare throws ProcessingException — the standard functional interfaces don't declare checked exceptions, and you can't add one at the call site.
Naming matters more than with regular interfaces because the name is all the documentation a lambda call site has. OrderProcessor at a call site tells the reader what the lambda does. Function<Order, ProcessingResult> requires them to infer it.
Method references — the four forms
Method references are lambdas that delegate directly to an existing method. Four forms with different meanings:
// Static method reference
Function<String, Integer> parse = Integer::parseInt;
// equivalent to: s -> Integer.parseInt(s)
// Instance method reference on a particular instance
String prefix = "Hello";
Predicate<String> startsWithHello = prefix::startsWith; // wait — actually this is an instance method on `prefix`
// equivalent to: s -> prefix.startsWith(s) -- no, this is: s -> "Hello".startsWith(s)
// Instance method reference on an arbitrary instance of a type
Function<String, String> toUpper = String::toUpperCase;
// equivalent to: s -> s.toUpperCase()
// The lambda's first argument becomes the receiver
// Constructor reference
Supplier<ArrayList<String>> newList = ArrayList::new;
// equivalent to: () -> new ArrayList<>()
The third form — instance method on an arbitrary instance — is the one that confuses developers. String::toUpperCase has the signature Function<String, String> because the first argument becomes the receiver. String::substring with one argument has signature BiFunction<String, Integer, String> — first argument is the receiver, second is the parameter.
What lambdas cannot replace
State across invocations. Lambdas are stateless. They can close over effectively-final variables from the enclosing scope, but they can't hold mutable instance state that persists between calls. An anonymous class can:
// Anonymous class with mutable state across calls
Runnable counter = new Runnable() {
private int count = 0;
@Override
public void run() {
System.out.println("Called " + (++count) + " times");
}
};
A lambda cannot replicate this. If you need a callable with internal mutable state, use a named class or an anonymous class.
Multiple method implementations. Lambdas implement exactly one abstract method. If the interface has multiple abstract methods, it's not a functional interface and cannot be implemented with a lambda. Anonymous classes handle multi-method interfaces fine:
// MouseListener has five abstract methods — not a functional interface
component.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) { handlePress(e); }
@Override
public void mouseReleased(MouseEvent e) { handleRelease(e); }
});
Type tokens. As covered in the type erasure article — anonymous subclasses that capture generic type information for reflection. new TypeReference<List<Order>>() {} is an anonymous class because the anonymous subclass's supertype declaration carries the type argument. A lambda cannot produce a subclass.
this reference. Inside a lambda, this refers to the enclosing class instance. Inside an anonymous class, this refers to the anonymous class instance. When you need to pass this as the anonymous implementation — for callback registration, listener removal, or self-referential behavior — anonymous classes are required:
// Lambda — `this` is the enclosing object, not the Runnable
executor.submit(() -> {
doWork();
// can't cancel or identify this specific task by reference
});
// Anonymous class — `this` is the Runnable instance
Runnable task = new Runnable() {
@Override
public void run() {
doWork();
if (shouldCancel()) {
executor.remove(this); // `this` is this specific Runnable
}
}
};
Composing functions — the fluent pipeline pattern
The default methods on Function, Predicate, and Consumer enable pipelines without intermediate variables:
Function<String, String> normalize = String::trim
.andThen(String::toLowerCase)
.andThen(s -> s.replaceAll("\\s+", "_"));
List<String> results = rawInputs.stream()
.filter(Predicate.not(String::isBlank))
.map(normalize)
.distinct()
.collect(Collectors.toList());
andThen composes left-to-right: f.andThen(g) applies f then g. compose is right-to-left: f.compose(g) applies g then f. andThen is almost always the readable choice — it matches the natural reading direction.
Predicate.not() (Java 11+) negates a method reference cleanly. Predicate.not(String::isBlank) is clearer than s -> !s.isBlank() in a stream chain where the negation is easy to miss.
The lambda capture rules that bite developers
Effectively final. Variables captured by a lambda must be effectively final — not just declared final, but not reassigned after the point of capture. The compiler enforces this. The workaround for capturing a mutable counter in a lambda is an array or AtomicInteger:
// Compile error — count is not effectively final
int count = 0;
list.forEach(item -> count++);
// Workaround — array element is mutable, array reference is final
int[] count = {0};
list.forEach(item -> count[0]++);
// Better workaround for concurrent cases
AtomicInteger count = new AtomicInteger(0);
list.forEach(item -> count.incrementAndGet());
Static context. A lambda in a static method cannot capture instance fields or this — the same restriction as any static context. This is obvious in named methods and occasionally surprising in lambda-heavy static utility code.
Exception transparency. Checked exceptions thrown inside a lambda must be handled inside the lambda or declared on the functional interface. You cannot throw a checked exception from a Runnable or Function without wrapping it:
// Compile error — IOException is checked
Function<Path, String> readFile = path -> Files.readString(path);
// Option 1: wrap in unchecked
Function<Path, String> readFile = path -> {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
// Option 2: custom functional interface that declares the exception
@FunctionalInterface
interface ThrowingFunction<T, R> {
R apply(T t) throws IOException;
}
The ThrowingFunction approach keeps the lambda clean at the cost of a custom interface. For domain-specific pipelines that consistently deal with checked exceptions, a custom functional interface per exception type is worth defining once.
The line between lambdas and anonymous classes
Use a lambda when: the interface is functional (one abstract method), the implementation is stateless, you don't need this to refer to the implementation itself, and you don't need the implementation to serve as a type token.
Use an anonymous class when: you need mutable state across calls, the interface has multiple methods, you need this to be the implementation instance, or you're capturing generic type information for reflection.
Everything else is the lambda's territory, and it's most of it.