JAVA / STREAMS, LAMBDAS AND OPTIONAL
Lambda syntax and capturing effectively final variables
Write lambdas in every parameter and body form, and predict exactly which local variables a lambda may capture and why the compiler insists on it.
What you will learn
- Omit parentheses only for one inferred parameter; keep them for var or explicit types
- Choose an expression body for one-liners and a braced body with return for statements
- Explain why captured locals are copies and therefore must be effectively final
- Capture a fresh in-loop local instead of the for counter, and mutate fields not locals
Understanding Lambda syntax and capturing effectively final variables
The arrow separates a parameter list from a body, and both sides have rules that are easy to get wrong. Parentheses are optional only for exactly one parameter whose type is inferred; zero parameters require an empty pair, and explicit types or var require parentheses even for a single parameter. A body that is a single expression yields that expression's value with no return and no semicolon inside it, while a braced body is an ordinary block where every path must return if the interface method has a return type. The parameter types themselves are never declared out of necessity: they come from the abstract method of the target interface, which is why the identical text (a, b) -> a * b means int arithmetic in one assignment and boxed Long arithmetic in another.
When a lambda body reads a local variable, it does not get access to that variable's storage; the current value is copied into the lambda instance at the moment the instance is created. That copy is unavoidable because the lambda can be stored in a field, returned to a caller, or handed to another thread, so it may well run long after the enclosing method's stack frame has been discarded and there is nothing left to point at. Java therefore only allows capture of locals it can prove are assigned once and never reassigned anywhere in the method, which is what effectively final means; the check covers the whole method body, so an assignment written below the lambda breaks a capture written above it. The compiler rejects the code rather than let the copy silently drift away from the variable.
The restriction applies only to locals, method parameters, catch parameters and for-each variables. Fields behave completely differently: the lambda captures this (or the enclosing class for static fields) and reads or writes the field through that reference, so total += n compiles fine and every invocation sees the current value. Effectively final also pins the reference and not the object, so a captured StringBuilder or ArrayList can still be modified from inside the body. Finally, a lambda introduces neither a new this nor a new name scope, which is why this refers to the enclosing instance and a lambda parameter may not reuse the name of a local that is already in scope.
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
public class LambdaBasics {
public static void main(String[] args) {
Supplier<String> greet = () -> "hello"; // zero parameters need ()
Function<String, Integer> len = s -> s.length(); // one inferred parameter, no ()
BiFunction<Integer, Integer, Integer> max = (Integer a, Integer b) -> {
if (a >= b) return a; // braced body must return
return b;
};
int factor = 3; // assigned once: effectively final
Function<Integer, Integer> scale = n -> n * factor;
System.out.println(greet.get());
System.out.println(len.apply("lambda"));
System.out.println(max.apply(7, 4));
System.out.println(scale.apply(10));
System.out.println(outlivesTheFrame().get());
}
static Supplier<String> outlivesTheFrame() {
String prefix = "count=";
int value = 41;
return () -> prefix + (value + 1); // values are copied into the lambda instance
}
}A lambda captures the value of a local variable at the instant it is created, not the variable itself, and that copy semantics is the whole reason the local must be effectively final.
Worked examples
Loop counters versus loop variables
Shows why a for-each variable can be captured directly while a classic loop counter needs a per-iteration copy.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
public class LoopCapture {
public static void main(String[] args) {
List<Supplier<String>> rows = new ArrayList<>();
for (String name : List.of("ann", "bob")) { // fresh variable each iteration
rows.add(() -> "hi " + name);
}
for (int i = 0; i < 2; i++) {
int copy = i; // i is reassigned, copy never is
rows.add(() -> "index " + copy);
}
for (Supplier<String> row : rows) {
System.out.println(row.get());
}
}
}Example explained
Line 1for (String name : ...) declares name anew on every pass, so each lambda captures a different value that is never reassigned.
Line 2i is mutated by i++, so () -> "index " + i would not compile; int copy = i; introduces a variable assigned exactly once per iteration.
Line 3All four lambdas are invoked after both loops have finished, which only works because each one carries its own copy instead of reading a shared slot.
Locals are copied, fields are shared
Demonstrates that a lambda may freely write a field through the captured this, while the local it reads must stay effectively final.
import java.util.function.Consumer;
import java.util.function.Supplier;
public class FieldsAreDifferent {
private int total = 0; // a field, not a local
void run() {
int step = 5; // local: must stay effectively final
Consumer<Integer> add = n -> this.total += n * step;
add.accept(2);
add.accept(3);
Supplier<Object> who = () -> this;
System.out.println("total=" + total);
System.out.println("same instance: " + (who.get() == this));
}
public static void main(String[] args) {
new FieldsAreDifferent().run();
}
}Example explained
Line 1this.total += n * step compiles because total is reached through the captured this reference, so the lambda writes the object's memory rather than a copy.
Line 2step is a local and is copied by value, so the compiler forbids any reassignment of it anywhere inside run().
Line 3() -> this returns the enclosing object itself, showing a lambda has no this of its own, unlike the body of an anonymous class.
Line 4Writing a field from a lambda is legal but unsynchronised, so the same body inside a parallel pipeline would lose updates.
Parameter list forms and inferred types
Contrasts var parameters with fully inferred ones and shows where the parameter types actually come from.
import java.util.function.BinaryOperator;
import java.util.function.IntBinaryOperator;
public class ParameterForms {
public static void main(String[] args) {
BinaryOperator<String> join = (var a, var b) -> a + "-" + b; // Java 11 or later
IntBinaryOperator mul = (a, b) -> a * b; // types come from the interface
System.out.println(join.apply("left", "right"));
System.out.println(mul.applyAsInt(6, 7));
}
}Example explained
Line 1(var a, var b) requires parentheses and Java 11 or later, and mixing forms such as (var a, String b) in one list is a compile error.
Line 2In mul the parameters are primitive ints because IntBinaryOperator.applyAsInt takes two ints, so a * b is primitive multiplication with no declaration written anywhere.
Line 3The same text (a, b) -> a * b assigned to a BinaryOperator<Long> would unbox, multiply and rebox instead, because a lambda has no type until a target interface supplies one.
Important notes
Effectively final constrains the reference, not the object behind it: a captured list or StringBuilder can still be mutated from the lambda, which compiles happily and is a frequent source of races.
A lambda adds no new name scope, so a parameter may not reuse the name of an enclosing local, even though an anonymous class is allowed to shadow it.
Common mistakes
Capturing the counter of a classic for loop directly: i++ reassigns it, so the class refuses to compile with "local variables referenced from a lambda expression must be final or effectively final", and no amount of restarting the program helps.
Trying to accumulate with int sum = 0; nums.forEach(n -> sum += n);: the assignment to a captured local is rejected outright, and the usual workaround int[] sum = new int[1] compiles but starts losing updates the moment the same code runs on a parallel stream.
Assuming only the code above the lambda matters, then adding label = "done"; near the end of the method: an already working capture becomes illegal and the error is reported at the lambda rather than at the new assignment, which makes it look unrelated.
Try it yourself
Change, predict, then run
In a browser editor, fill a List<Supplier<String>> inside a classic for (int i = 0; i < 3; i++) loop so that each supplier returns "row " + its own index, then print all three after the loop. Keep the counter loop and make it compile without arrays, AtomicInteger or fields.
Open the Java workspaceCheck your understanding
A method declares a local int limit, builds a lambda that reads it, returns that lambda, and the caller invokes it much later. Why does Java insist that limit be effectively final?
- The lambda keeps a copy of the value taken when it was created, and the enclosing frame may be gone by the time it runs, so the variable itself cannot be shared
- The lambda holds a live reference to the variable, and finality stops another thread from changing it while the lambda reads it
- Lambdas are compiled into static methods, which cannot read any local state at all unless it is declared final
- The rule only applies to lambdas that escape the method; a lambda invoked immediately may read locals that are reassigned
Show answer
Capture copies the value into the lambda instance, so after the method returns there is no stack slot left to consult and only a fixed value can survive. Option 4 is tempting because an escaping lambda is exactly where a stale copy would hurt, but the compiler applies one uniform rule and never analyses whether the lambda escapes; option 2 is wrong about the mechanism, since a live reference would make later mutations visible and would turn this into a synchronisation issue rather than a compile error.