Why this matters
- Lambdas are the entry point to streams,
CompletableFuture, and essentially every modern Java API. Without them none of those read well. - "Behaviour as a parameter" is a design technique, not just shorter syntax, and it replaces a whole category of boilerplate classes.
- The built-in functional interfaces have specific names that every library expects you to know.
From anonymous class to lambda
// Before Java 8
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
// With a lambda
names.sort((a, b) -> Integer.compare(a.length(), b.length()));
// With a method reference and a comparator factory
names.sort(Comparator.comparingInt(String::length));
Each version does the same work. The last one reads as a description of the ordering rather than as code that implements one.
Syntax
// Zero parameters: parentheses required
Runnable task = () -> System.out.println("running");
// One parameter: parentheses optional
Consumer<String> print = value -> System.out.println(value);
// Several parameters
BinaryOperator<Integer> add = (a, b) -> a + b;
// Explicit types, when inference needs help or clarity does
BiFunction<String, Integer, String> repeat = (String text, Integer times) -> text.repeat(times);
// A block body needs an explicit return
Function<String, Integer> parse = text -> {
String trimmed = text.strip();
return Integer.parseInt(trimmed);
};
Rules worth stating once
- An expression body returns its value implicitly; a block body needs
return. - Parameter types are inferred from the target interface, so they are usually omitted.
- Captured local variables must be effectively final — assigned once and never reassigned.
- Instance and static fields may be read and written freely; only locals are restricted.
thisinside a lambda refers to the enclosing instance, not to the lambda.
int base = 10;
Function<Integer, Integer> addBase = value -> value + base; // fine: base never changes
int counter = 0;
// Runnable broken = () -> System.out.println(counter); // error if counter is reassigned later
The restriction exists because the value is copied into the lambda. Allowing reassignment would let the copy and the original disagree, with no way to say which is correct.
The built-in functional interfaces
java.util.function defines the shapes that every modern API uses.
| Aspect | Interface | Shape and typical use |
|---|---|---|
| Function<T,R> | T in, R out — apply | map: transform a value |
| Predicate<T> | T in, boolean out — test | filter: decide inclusion |
| Consumer<T> | T in, nothing out — accept | forEach: a side effect |
| Supplier<T> | Nothing in, T out — get | Lazy or deferred creation |
| UnaryOperator<T> | T in, T out | replaceAll: transform in place |
| BinaryOperator<T> | Two T in, T out | reduce: combine two into one |
| BiFunction<T,U,R> | Two inputs, one output | merge, compute on a map |
Function<T,R>
InterfaceT in, R out — applyShape and typical usemap: transform a valuePredicate<T>
InterfaceT in, boolean out — testShape and typical usefilter: decide inclusionConsumer<T>
InterfaceT in, nothing out — acceptShape and typical useforEach: a side effectSupplier<T>
InterfaceNothing in, T out — getShape and typical useLazy or deferred creationUnaryOperator<T>
InterfaceT in, T outShape and typical usereplaceAll: transform in placeBinaryOperator<T>
InterfaceTwo T in, T outShape and typical usereduce: combine two into oneBiFunction<T,U,R>
InterfaceTwo inputs, one outputShape and typical usemerge, compute on a map
Each has primitive variants — IntPredicate, ToIntFunction, IntSupplier — that avoid boxing.
Predicate<String> notBlank = text -> !text.isBlank();
Function<String, Integer> length = String::length;
Supplier<List<String>> newList = ArrayList::new;
Consumer<String> log = System.out::println;
They also compose, which is where they stop being verbose and start being useful:
Predicate<String> shortEnough = text -> text.length() <= 20;
Predicate<String> valid = notBlank.and(shortEnough);
Predicate<String> invalid = valid.negate();
Predicate<String> either = notBlank.or(shortEnough);
Function<String, String> strip = String::strip;
Function<String, Integer> stripThenLength = strip.andThen(String::length);
Function<String, Integer> sameThing = length.compose(strip); // applied right to left
Consumer<String> logThenStore = log.andThen(this::store);
Method references
When a lambda does nothing but call an existing method, a reference says so more clearly.
// 1. A static method
Function<String, Integer> parse = Integer::parseInt;
// equivalent to: text -> Integer.parseInt(text)
// 2. An instance method of a particular object
Consumer<String> print = System.out::println;
// equivalent to: value -> System.out.println(value)
// 3. An instance method of an arbitrary object of a type
Function<String, String> upper = String::toUpperCase;
// equivalent to: text -> text.toUpperCase() — the receiver becomes the parameter
// 4. A constructor
Supplier<ArrayList<String>> create = ArrayList::new;
Function<Integer, ArrayList<String>> sized = ArrayList::new; // picks the matching constructor
Form 3 is the one that takes a moment: String::toUpperCase looks like it is missing a receiver, and it is —
the first parameter of the functional interface becomes the receiver.
Writing your own functional interface
Any interface with exactly one abstract method qualifies. default and static methods do not count.
@FunctionalInterface
public interface Retry<T> {
T attempt() throws Exception; // the single abstract method
default T withFallback(T fallback) {
try {
return attempt();
} catch (Exception e) {
return fallback;
}
}
}
Retry<String> fetch = () -> httpClient.get(url);
String body = fetch.withFallback("");
The @FunctionalInterface annotation is optional but worth adding: it makes the compiler reject a second
abstract method, which protects every lambda already written against the interface.
Behaviour as a parameter
This is the design idea behind the syntax, and the reason it matters beyond brevity.
// Before: one method per variation
public List<Order> findByStatus(Status status) { }
public List<Order> findByCustomer(String id) { }
public List<Order> findByAmountOver(BigDecimal amount) { }
// After: one method, and the caller supplies the rule
public List<Order> find(Predicate<Order> criteria) {
return orders.stream().filter(criteria).toList();
}
find(order -> order.status() == Status.PENDING);
find(order -> order.customerId().equals(id));
find(order -> order.total().compareTo(threshold) > 0);
find(order -> order.status() == Status.PENDING && order.isOverdue());
The last call is the point: a combination nobody anticipated needs no new method. The library provides the mechanism and the caller provides the policy.
Lambdas versus anonymous classes
| Aspect | Lambda | Anonymous class |
|---|---|---|
| Target | Functional interfaces only | Any interface or abstract class |
| State | Cannot hold fields | Can hold fields and several methods |
| this refers to | The enclosing instance | The anonymous instance |
| Compiled to | An invokedynamic call site | A separate class file |
| Shadowing | Cannot shadow enclosing locals | Can |
Target
LambdaFunctional interfaces onlyAnonymous classAny interface or abstract classState
LambdaCannot hold fieldsAnonymous classCan hold fields and several methodsthis refers to
LambdaThe enclosing instanceAnonymous classThe anonymous instanceCompiled to
LambdaAn invokedynamic call siteAnonymous classA separate class fileShadowing
LambdaCannot shadow enclosing localsAnonymous classCan
Lambdas are not sugar for anonymous classes — the meaning of this differs, and so does the bytecode.
Because lambdas use invokedynamic, the implementation is linked on first use and no extra class file is
generated. That keeps the class count down and lets the JIT inline them effectively.
Common misreadings
- "A lambda is an anonymous class in different clothing."
thismeans something different, and the bytecode is different. - "Captured variables must be declared
final." They must be effectively final — never reassigned. The keyword is optional. - "Lambdas cannot modify anything." They cannot reassign a captured local, but they can mutate the objects those locals point at, and they can write fields.
- "Any interface works as a lambda target." It needs exactly one abstract method.
- "
@FunctionalInterfaceis required." It is optional, and useful because it makes the constraint enforced. - "Method references are always clearer."
String::toUpperCaseis. A chain of them with non-obvious receivers is not — use a lambda when it reads better.
Quick recall
Everything you need if you only revisit this box.
- A lambda implements a functional interface — exactly one abstract method. Java has no function type, which is why the target type decides the meaning.
- Captured locals must be effectively final, because the value is copied. Fields are unrestricted.
thisin a lambda is the enclosing instance, unlike in an anonymous class.- Know the shapes:
Function,Predicate,Consumer,Supplier,UnaryOperator,BinaryOperator,BiFunction— plus primitive variants to avoid boxing. - They compose with
and,or,negate,andThen,compose.andThenreads left to right. - Four method reference forms: static, bound instance, unbound instance, constructor. The unbound form turns the receiver into the first parameter.
- The real value is behaviour as a parameter: one method plus caller-supplied policy replaces a family of near-identical methods.
Test yourself
Answer these before moving on — recall is what makes it stick.