Functional programming, the Java way
Before Java 8, passing behavior around meant writing a verbose anonymous class for every callback, comparator, or event handler. Lambdas changed that: behavior became a value you could store in a variable, pass as an argument, and return from a method. But a lambda is not a free-floating closure like in other languages — it is always tied to a functional interface, and understanding that link explains almost every rule that follows. This guide walks through the syntax, the standard interface library, composition, method references, and the subtle differences from the anonymous classes lambdas replaced.
What a lambda is
A lambda expression is an anonymous function: a parameter list, an arrow, and a body. It is compact syntax for implementing the single abstract method of a functional interface. The compiler matches the lambda's shape to that method, so the body becomes the method implementation — no class name, no boilerplate.
// parameters -> body
Runnable r = () -> System.out.println("hi"); // no params, expression body
Function<Integer,Integer> sq = x -> x * x; // one param, parens optional
Comparator<String> c = (a, b) -> a.length() - b.length(); // multi-param needs parens
// block body needs braces AND an explicit return
BinaryOperator<Integer> add = (a, b) -> { return a + b; };
The two rules worth memorising: a single-expression body has no braces and no return
(the value is implicit), while a block body needs both braces and an explicit return
if it produces a value. Parameter types are almost always inferred from the target, so you
omit them.
Functional interfaces, SAM, and @FunctionalInterface
A functional interface is an interface with exactly one abstract method — a SAM
(Single Abstract Method). That method is the target a lambda implements. The interface can
still declare any number of default and static methods, and methods inherited from
Object (like equals) don't count toward the limit.
@FunctionalInterface
interface Transformer {
String apply(String s); // the one abstract method
default String twice(String s) { return apply(apply(s)); } // doesn't break SAM
// String other(String s); // adding this would FAIL the build
}
Transformer upper = s -> s.toUpperCase();
The @FunctionalInterface annotation is optional and has no runtime effect — a lambda
works on any SAM interface whether or not it's annotated. What it buys you is a compile
error if someone later adds a second abstract method, protecting every caller who passed
a lambda. Treat it as documentation that the compiler enforces.
The java.util.function family
You rarely need to write your own functional interface, because java.util.function
ships the general-purpose ones. They cluster into four families — transform, produce,
consume, test — plus operator and two-argument variants.
| Interface | Abstract method | Shape | Used by |
|---|---|---|---|
Function<T,R> | R apply(T) | T → R | Stream.map |
BiFunction<T,U,R> | R apply(T,U) | (T,U) → R | Map.merge |
Supplier<T> | T get() | () → T | Optional.orElseGet |
Consumer<T> | void accept(T) | T → void | forEach |
Predicate<T> | boolean test(T) | T → boolean | Stream.filter |
UnaryOperator<T> | T apply(T) | T → T | List.replaceAll |
BinaryOperator<T> | T apply(T,T) | (T,T) → T | Stream.reduce |
Function<String,Integer> length = String::length; // transform
Supplier<Double> rng = Math::random; // produce
Consumer<String> print = System.out::println; // consume
Predicate<Integer> isEven = n -> n % 2 == 0; // test
UnaryOperator and BinaryOperator are just Function/BiFunction specialised so input
and output share a type — reach for them when that's true (String::trim, Integer::sum)
because they read more clearly than the general form.
Composing functions and predicates
Functional interfaces come with default methods that combine instances into new ones,
leaving the originals untouched. For Function, andThen runs left-to-right (this,
and then that) while compose runs inside-out, matching the maths f(g(x)). For
Predicate, and, or, and negate build compound conditions.
Function<Integer,Integer> times2 = x -> x * 2;
Function<Integer,Integer> plus3 = x -> x + 3;
times2.andThen(plus3).apply(5); // (5*2)+3 = 13 — f then g
times2.compose(plus3).apply(5); // (5+3)*2 = 16 — g then f
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> shortStr = s -> s.length() < 10;
Predicate<String> valid = notBlank.and(shortStr); // && short-circuits
Composition keeps small, named pieces reusable and lets you assemble complex logic without
nesting lambdas. Java 11 added a static Predicate.not(...) so you can negate a method
reference directly: filter(Predicate.not(String::isBlank)).
Method references: four kinds
When a lambda does nothing but forward its arguments to a single existing method, a
method reference (::) says the same thing with less noise. The compiler infers which
method from the target interface's signature. There are exactly four kinds.
| Kind | Syntax | Equivalent lambda |
|---|---|---|
| Static | Integer::parseInt | s -> Integer.parseInt(s) |
| Bound instance | System.out::println | s -> System.out.println(s) |
| Unbound instance | String::toUpperCase | s -> s.toUpperCase() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
String prefix = "Hello, ";
Function<String,String> greet = prefix::concat; // BOUND — receiver captured now
Function<String,Integer> len = String::length; // UNBOUND — receiver is the 1st arg
The trap is bound vs unbound. A bound reference puts a specific object (a value or
variable) on the left of :: and uses it as the receiver. An unbound reference puts a
type name on the left, and the lambda's first argument becomes the receiver — so the
interface has one extra leading parameter.
Effectively-final capture and why it exists
A lambda may capture local variables from the enclosing method, but only ones that are
final or effectively final — assigned exactly once and never reassigned, even without
the final keyword. Reassigning a captured local is a compile error. Crucially, this rule
applies only to locals; instance and static fields are fair game.
int factor = 3; // effectively final
Function<Integer,Integer> f = x -> x * factor; // OK — captures the value 3
int total = 0;
// list.forEach(n -> total += n); // COMPILE ERROR — total reassigned
int[] box = {0}; // mutate object state instead
list.forEach(n -> box[0] += n);
The reason is lifetime and safety: a lambda can outlive the method that created it (be
stored, queued, or run on another thread), and Java captures locals by value. Allowing
reassignment would create two diverging copies of the "same" variable. The idiomatic fix
isn't the array trick or AtomicInteger — it's to avoid mutation and let a stream do the
work: list.stream().mapToInt(Integer::intValue).sum().
Lambdas vs anonymous classes
Lambdas look like terser anonymous classes, but the differences run deep — and they are
classic interview material. The headline gotcha is this: inside a lambda it refers to
the enclosing instance, because a lambda introduces no new scope. Inside an anonymous
class, this is the anonymous object itself.
| Aspect | Lambda | Anonymous class |
|---|---|---|
this | the enclosing instance | the anonymous object |
| Scope | shares the method's scope | introduces its own |
| Compilation | invokedynamic, no extra .class | a separate .class file |
| State | only the one SAM | fields and multiple methods |
class Widget {
String name = "panel";
Runnable make() {
return () -> System.out.println(this.name); // "panel" — Widget's this
}
}
Compilation differs too. An anonymous class generates a Widget$1.class at build time; a
lambda compiles to an invokedynamic instruction plus a private synthetic method, and
the JVM's LambdaMetafactory builds the implementation lazily at runtime. That means no
class-file explosion, smaller jars, and a stateless capture-free lambda can even be reused
as a singleton.
Target typing
A lambda has no type of its own — its type is decided by the context it appears in, called the target type. The compiler reads that context, finds the single abstract method, and checks the lambda's parameters and return against it.
Runnable r = () -> doWork(); // target type Runnable
Callable<Void> c = () -> { doWork(); return null; }; // target type Callable
Comparator<String> byLen = (a, b) -> a.length() - b.length();
This is why the same arrow shape can implement different interfaces, and why a lambda
cannot be assigned to var or to Object — there's no abstract method to infer against.
Primitive specializations
Generic interfaces only hold reference types, so Function<Integer,Integer> autoboxes
every int into a heap Integer — wasteful in hot loops and streams. The primitive
specializations work directly on int, long, and double to eliminate that overhead.
| Specialization | Shape |
|---|---|
IntFunction<R> | int → R |
ToIntFunction<T> | T → int |
IntUnaryOperator | int → int |
IntPredicate | int → boolean |
IntSupplier / IntConsumer | () → int / int → void |
IntUnaryOperator square = x -> x * x; // no boxing anywhere
square.applyAsInt(5); // 25
IntStream, LongStream, and DoubleStream are built on these, which is exactly why
mapToInt(...).sum() outperforms mapping to boxed Integers. When you control the type,
prefer the primitive specialization in any performance-sensitive path.
Recap
A lambda is anonymous-function syntax for the single abstract method of a
functional interface; @FunctionalInterface makes that contract compiler-enforced. The
java.util.function family (Function, Supplier, Consumer, Predicate, and their
operator and bi-arg variants) covers almost every case, and their andThen/compose/
and/or/negate defaults let you compose small pieces into big behavior. Method
references in their four kinds (static, bound, unbound, constructor) trim lambdas that
just forward arguments. Captured locals must be effectively final because lambdas
capture by value and can outlive their method. Finally, lambdas differ from anonymous
classes in lexical this, scope, and invokedynamic compilation — and the
primitive specializations exist to dodge autoboxing. Master these and the entire
streams API becomes readable.