Lambdas & Functional Interfaces
Lambdas & Functional Interfaces
Up to this point in the track, every piece of behavior in your programs has been a method living inside a class. In this post you learn how to treat behavior itself as a value — something you can pass to a method, return from a method, and store in a variable. The machinery behind it is the functional interface plus lambda expressions, and this post sets up the Streams API you'll meet next.
The functional interface, in one paragraph
A functional interface is any interface with exactly one abstract method. That's the whole definition. The interface may also carry default methods, static methods, and declarations of Object's methods — none of those break the rule, because only the single abstract method counts.
You met functional interfaces in the interfaces post. The point to lock in now: because there is only one method to implement, the compiler can figure out which method you're implementing without you naming it. That single, unnamed method is the one slot a lambda expression fills.
@FunctionalInterface
interface StringCheck {
boolean test(String s); // the single abstract method
default boolean isNotEmpty(String s) { return !s.isEmpty(); } // fine
static StringCheck and(StringCheck a, StringCheck b) { // fine
return s -> a.test(s) && b.test(s);
}
}
The @FunctionalInterface annotation is optional but worth using: the compiler will fail the build if someone later adds a second abstract method. It's a contract guard, not decoration.
Lambda syntax: four steps of simplification
Lambdas are best learned as progressive simplification of something you already know — the anonymous class. Watch one idea shrink through four shapes. All four do exactly the same thing.
Step 1: the anonymous class. Verbose, but every detail is explicit:
StringCheck startsWithJ = new StringCheck() {
@Override
public boolean test(String s) {
return s.startsWith("J");
}
};
Step 2: the lambda with declared types. The compiler already knows the target type (StringCheck) and its one method (boolean test(String)), so the class shell, method name, return type, and parameter type become noise. What remains is the parameter list, the arrow, and the body:
StringCheck startsWithJ = (String s) -> { return s.startsWith("J"); };
Step 3: inferred parameter types. Drop the types the compiler can infer from the interface:
StringCheck startsWithJ = (s) -> { return s.startsWith("J"); };
Step 4: the single-expression body. When the body is one expression, drop the braces and the return — the expression's value is the return value. And when there is a single parameter, the parentheses around it may go too:
StringCheck startsWithJ = s -> s.startsWith("J");
Here's the decision rule: always write step 4 unless it hurts readability. Step 4 is the idiomatic Java you will see in real codebases. Step 2 is worth reaching for when inference surprises you — for instance, an overloaded method where the compiler can't pick the right functional interface; writing the types out loud resolves the ambiguity.
A complete runnable example, since nothing here should be taken on faith:
import java.util.List;
public class LambdaFirst {
@FunctionalInterface
interface StringCheck {
boolean test(String s);
}
public static void main(String[] args) {
List<String> languages = List.of("Java", "Kotlin", "Python", "JavaScript");
StringCheck startsWithJ = s -> s.startsWith("J");
for (String lang : languages) {
if (startsWithJ.test(lang)) {
System.out.println(lang);
}
}
}
}
Java
JavaScript
The effectively-final rule — and why it exists
Here is the trap almost every Java developer walks into:
// DOES NOT COMPILE
public class CaptureTrap {
public static void main(String[] args) {
int count = 0;
Runnable r = () -> {
count = count + 1; // compiler error: variable must be effectively final
System.out.println(count);
};
r.run();
}
}
A local variable used inside a lambda must be effectively final — never reassigned after its first assignment. (This isn't new to lambdas: anonymous classes had the same restriction, stated as "must be final." Lambdas just relaxed the keyword to the effective state.)
Why does the restriction exist? Because the lambda does not capture the variable — it captures a copy of its value. Imagine the lambda were allowed to run later, on another thread, long after main's stack frame was gone. There is no live count slot to write to; the copy the lambda holds would drift out of sync with anything else. Rather than let you write code that silently mutates a stale copy, the language forbids the mutation entirely.
Knowing the workaround is useful; knowing when it smells is more useful:
import java.util.concurrent.atomic.AtomicInteger;
public class CaptureWorkaround {
public static void main(String[] args) {
AtomicInteger count = new AtomicInteger(0); // the reference never changes...
Runnable r = () -> {
count.incrementAndGet(); // ...but the object behind it is mutable
System.out.println("hits: " + count.get());
};
r.run();
r.run();
}
}
hits: 1
hits: 2
This works because the rule governs the reference (count always points at the same AtomicInteger), not the object's contents. The older int[] holder = new int[1] trick works for the same reason.
The decision rule: if you reach for AtomicInteger or a one-element array just to share mutable state with a lambda, stop and reconsider the design. Nine times out of ten you actually want to return a value from the stream operation (the next post shows how count(), collect(), and reduce() do exactly this) or restructure so the state lives where it's computed. Mutable capture inside lambdas is a concurrency bug waiting for a thread to trigger it. AtomicInteger is legitimately right when you genuinely need a shared counter across threads; an array-as-box is almost always a smell.
Method references: when the lambda just calls something
A very common lambda shape is "take the argument, call one method on it, return the result": s -> s.toUpperCase(). When the body is a single method call, you can replace the lambda with a method reference. There are four kinds, and the choice between them is mechanical — it depends on whose method is being called:
- Static method:
ClassName::methodName—Integer::parseIntmeanss -> Integer.parseInt(s). Use it when the lambda delegates to a static utility. - Instance method of a particular object:
objectName::methodName—logger::infomeansmsg -> logger.info(msg). Use it when the receiver is fixed and known. - Instance method of an arbitrary object of a type:
ClassName::methodName—String::toUpperCasemeanss -> s.toUpperCase(). Use it when the first lambda parameter is the receiver. This is the one that trips people up:String::lengthis not the same shape as"abc"::length— the first needs aStringargument supplied at call time, the second doesn't. - Constructor:
ClassName::new—ArrayList::newmeans() -> new ArrayList(). Use it wherever a factory lambda would just callnew.
import java.util.List;
import java.util.function.Supplier;
public class MethodRefs {
public static void main(String[] args) {
List<String> names = List.of("ada", "grace", "alan");
names.stream().map(String::toUpperCase).forEach(System.out::println); // kind 3, kind 2
Supplier<StringBuilder> sbFactory = StringBuilder::new; // kind 4
System.out.println(sbFactory.get().append("ready").length());
}
}
ADA
GRACE
ALAN
5
Decision rule: prefer a method reference whenever the lambda body is a single method call and the reference reads clearly. Keep the lambda when you'd have to contort the reference — s -> s.substring(0, Math.min(3, s.length())) is clearer than any reference could be. Readability wins; brevity is a side effect.
The standard vocabulary: java.util.function
Before lambdas, teams invented their own single-method interfaces everywhere (Callback, Handler, Predicate2...), and APIs couldn't agree on a name. Since Java 8, the standard vocabulary lives in java.util.function. Learn four, and the rest follow the pattern:
Predicate<T>— takes aT, returnsboolean. The "test" shape: filtering, validation.Function<T, R>— takes aT, returns anR. The "transform" shape: mapping one type to another.Consumer<T>— takes aT, returns nothing. The "side effect" shape: printing, logging, saving.Supplier<T>— takes nothing, returns aT. The "factory/lazy value" shape.
Two things matter beyond the four. First, the package ships primitive variants — IntPredicate, LongFunction, DoubleConsumer, and friends. They exist to avoid the cost of boxing int into Integer every time the lambda runs. In a hot loop over millions of values, Predicate<Integer> boxes on every test; IntPredicate never does. Reach for the primitive variant when the lambda runs on primitive data at volume.
Second, the decision rule for your own interfaces: prefer the standard ones over inventing your own unless the method's name carries domain meaning your team needs. A Predicate<Customer> composes with every stream and collection API out of the box; a homegrown CustomerFilter with an identical test method does not, and everyone reading your code has to learn it from scratch. Write a custom functional interface only when the signature genuinely needs three parameters, checked-exception semantics, or a name that makes the domain clearer.
Lambdas vs anonymous classes: not rivals
It's tempting to treat the lambda as the "modern replacement" for the anonymous class. That framing breaks the moment you need state. A lambda can only hold behavior; an anonymous class can hold fields:
import java.util.Comparator;
public class StatefulComparator {
public static void main(String[] args) {
// An anonymous class CAN keep per-instance state a lambda cannot:
Comparator<String> rotating = new Comparator<String>() {
private int calls = 0; // state — impossible in a lambda
@Override
public int compare(String a, String b) {
calls++;
return (calls % 2 == 0) ? a.compareTo(b) : b.compareTo(a);
}
};
System.out.println(rotating.compare("x", "y")); // reversed: calls == 1
System.out.println(rotating.compare("x", "y")); // natural: calls == 2
}
}
1
-1
The decision rule: use a lambda for behavior, an anonymous class when you need state, multiple methods, or a constructor. (Anonymous classes can also declare constructors and extend abstract classes — lambdas can't do either.) One is not better; they cover different shapes of problem. That said, needing state inside a comparator like this is itself unusual — notice how the example feels contrived, which is the point: in practice, pure behavior dominates, which is why lambdas dominate.
Before and after: a realistic filtering-and-sorting task
Let's tie it together with something you'd actually write: take a list of employees, keep the engineers hired in the last five years, and sort them by salary descending.
Before — anonymous classes, Java 7 style:
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class StaffReportOld {
record Employee(String name, String role, int yearsOfService, double salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee("Maya", "Engineer", 3, 142000),
new Employee("Dev", "Manager", 6, 165000),
new Employee("Priya", "Engineer", 7, 158000),
new Employee("Sam", "Engineer", 2, 131000));
List<Employee> filtered = new ArrayList<>();
for (Employee e : staff) {
if (e.role().equals("Engineer") && e.yearsOfService() <= 5) {
filtered.add(e);
}
}
Collections.sort(filtered, new Comparator<Employee>() {
@Override
public int compare(Employee a, Employee b) {
return Double.compare(b.salary(), a.salary());
}
});
for (Employee e : filtered) {
System.out.println(e.name() + ": " + e.salary());
}
}
}
After — lambdas and the standard vocabulary, modern Java:
import java.util.Comparator;
import java.util.List;
import java.util.function.Predicate;
public class StaffReportNew {
record Employee(String name, String role, int yearsOfService, double salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee("Maya", "Engineer", 3, 142000),
new Employee("Dev", "Manager", 6, 165000),
new Employee("Priya", "Engineer", 7, 158000),
new Employee("Sam", "Engineer", 2, 131000));
Predicate<Employee> isRecentEngineer =
e -> e.role().equals("Engineer") && e.yearsOfService() <= 5;
staff.stream()
.filter(isRecentEngineer)
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.map(e -> e.name() + ": " + e.salary())
.forEach(System.out::println);
}
}
Maya: 142000.0
Sam: 131000.0
Same program, and notice what actually changed beyond brevity. The filter rule is now a named, reusable value (isRecentEngineer) instead of a condition buried inside a loop — you can test it, compose it (isRecentEngineer.and(...)), or hand it to another method. The sort key is Comparator.comparingDouble(Employee::salary).reversed() instead of a hand-rolled comparator with an easy-to-flip sign. Both versions compile and run; the second one carries less of the accidental complexity, which is the whole payoff of this post.
The one thing to remember: a lambda is an instance of a functional interface — one method, implemented as behavior you can pass around. Everything in this post (syntax, method references, the java.util.function vocabulary) is convenience built on that one fact.
In the next post, streams take this idea and turn it into a pipeline: filter, map, and reduce are methods whose arguments are exactly the lambdas you just learned to write.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment