Lesson 07 / 15
Lambda Expressions
When a lambda expression captures a local variable around it, that variable being effectively final is mandatory at compile time; when it captures a field, no such constraint exists, and the same lambda gives a different result once the object changes later. What gets frozen in a captured local variable is its value, not the object it points to.
Contents
The previous lesson looked at the functional interface itself: it measured that single abstract method gets held at compile time, that the behavior promise gets held nowhere. The moment a lambda expression gets written, one more thing happens at that type’s site — the expression refers to names outside its own body, and captures those names. Is capturing a local variable bound to the same constraint as capturing a field, and do the two produce the same result?
This lesson answers this question directly with a run: two lambdas with the same body, doing the same work — one reading a local variable, the other a field — produce how many distinct results across two separate calls? The concept of closure itself — a function carrying its own defining environment along with it — was built in the Programming Fundamentals and Objects and Functions in JavaScript courses and does not get reopened here. What gets measured is not what a closure is, it is what constraint Java bounds it with.
A Lambda Captures a Variable by Its Value
- FJ8 — The same multiplication gets written with two separate
IntUnaryOperators: one reads a local variable, the other a static field. The factor is 2 when both get built. - FJ9 — After the lambda gets built, the field gets changed to 5, then to 7; the local variable never gets touched — it cannot be touched at all, and the section below measures why.
- FJ10 — Both lambdas get read twice with the same
applyAsInt(10)call; the returned values get compared directly.
// Capture.java - variable or field: which one does the lambda freeze
import java.util.function.IntUnaryOperator;
public class Capture {
static int factor = 2;
public static void main(String[] args) {
int localFactor = 2;
IntUnaryOperator localCapturer = x -> x * localFactor;
System.out.println("local capture, first call : " + localCapturer.applyAsInt(10));
factor = 5;
System.out.println("local capture, field changed : " + localCapturer.applyAsInt(10));
IntUnaryOperator fieldCapturer = x -> x * factor;
System.out.println("field capture, factor=5 : " + fieldCapturer.applyAsInt(10));
factor = 7;
System.out.println("field capture, factor=7 : " + fieldCapturer.applyAsInt(10));
}
}
local capture, first call : 20 local capture, field changed : 20 field capture, factor=5 : 50 field capture, factor=7 : 70
localCapturer gives 20 on both calls — even though the static
field changed to 5 in the line between them. The moment the lambda got
built, it took localFactor’s value at that moment and
embedded that value inside itself; whatever happens outside, it
reads this copy. fieldCapturer, though, gives two separate results
on two calls: 50, then 70. This lambda did not embed a value,
it embedded a path to the factor field, and reads the current
value through that path on every call.
The same body shape — x -> x * something — produces a different
count of results across two separate capture forms: one result for
the local variable, two separate results for the field. The
question’s answer is plain here: capturing a local variable and
capturing a field are not bound to the same constraint, and the two do
not produce the same result.
The Effectively-Final Constraint Is at Compile Time
Why the local variable stayed fixed got seen in the previous
measurement; is this fixedness a compiler decision, though, or did it
just come out this way in this example? Java’s rule is explicit: if a
lambda expression captures a local variable in the enclosing scope,
that variable has to be effectively final — that is, it must
never get reassigned after its first assignment, even if the word
final never gets written.
- FJ11 — The same method body gets tried in two forms: the local variable gets assigned once and read by the lambda; in the second form, the same variable gets assigned once more after the lambda gets built.
- FJ12 — The error message text the compiler produces does not get printed, only whether it compiles gets reported.
// Compile.java - shared core: does the given source text compile
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.file.*;
import java.util.spi.ToolProvider;
class Compile {
static boolean compiles(String source, String className) throws Exception {
Path dir = Files.createTempDirectory("scale");
Path file = dir.resolve(className + ".java");
Files.writeString(file, source);
PrintWriter silent = new PrintWriter(Writer.nullWriter());
return ToolProvider.findFirst("javac").orElseThrow()
.run(silent, silent, "-d", dir.toString(), file.toString()) == 0;
}
}
// EffectivelyFinal.java - why reassigning the local variable breaks the lambda
public class EffectivelyFinal {
static final String BASE = """
import java.util.function.IntSupplier;
class Trial {
static IntSupplier make() {
int counter = 0;
IntSupplier s = () -> counter;
%s
return s;
}
}
""";
public static void main(String[] args) throws Exception {
System.out.println("unchanged local : "
+ (Compile.compiles(BASE.formatted(""), "Trial") ? "compiled" : "did not compile"));
System.out.println("reassignment after lambda : "
+ (Compile.compiles(BASE.formatted("counter = 1;"), "Trial") ? "compiled" : "did not compile"));
}
}
unchanged local : compiled reassignment after lambda : did not compile
The second form, where the counter variable gets one more assignment
after the lambda gets built, does not compile — and this is an
error that falls at compile time, not runtime. Whether the
source’s counter = 1; line would run before or after the lambda gets
called does not matter; the compiler does not execute the control
flow, it only looks at whether there is more than one assignment
within the variable’s scope. If there is one assignment, the variable
is effectively final and can get captured; if a second assignment
shows up — even one that control flow would never reach — the capture
does not compile.
This is the same pattern as the previous lesson: here too, the
compiler looks at a structure, not a behavior. It counts not
whether the local variable actually changes, but how many times it
gets assigned in the source. There is no such count for a field — the
factor field can get reassigned as many times as wanted, and the
compiler raises no objection, because capturing a field does not
produce a copy the way capturing a local variable does. The
constraint’s reasoning follows from this too: a local variable gets
erased from the stack once the method returns, but the lambda can live
on past that; the compiler resolves this conflict by copying the
variable’s value and embedding it inside the lambda itself, and to
keep that copy consistent, it requires that variable to stay
single-assignment. A field, though, does not live on the stack, it
lives in the object or the class itself; the lambda keeps track of
which object it belongs to, and no copy needs to be made.
The Same Name Is Two Separate Variables in Classic and Enhanced Loops
The effectively-final constraint’s most visible consequence shows up
in loops. The enhanced for loop (for (String s : list)) and
the classic for loop (for (int i = 0; ...; i++)) look like
they do the same work, but once a lambda wants to capture the loop
variable, the two come apart.
- FJ13 — A three-item list gets iterated with an enhanced for; the lambda built in every iteration adds that iteration’s variable to a list. Once the loop finishes, every lambda in the list gets called.
- FJ14 — The same attempt gets repeated with a classic for and an incrementing counter; what is expected here is not a runtime result, it is whether it compiles.
// Loop.java - are the classic and enhanced for loops the same for lambda capture
import java.util.*;
import java.util.function.Supplier;
public class Loop {
public static void main(String[] args) {
List<String> source = List.of("pear", "cherry", "apple");
List<Supplier<String>> capturers = new ArrayList<>();
for (String s : source) {
capturers.add(() -> s);
}
System.out.print("enhanced for, three lambdas' result: ");
for (Supplier<String> c : capturers) System.out.print(c.get() + " ");
System.out.println();
}
}
enhanced for, three lambdas' result: pear cherry apple
Three lambdas give three separate values: pear, cherry, apple.
This looks, at first glance, like it contradicts the “capture freezes
the value” rule — was the loop variable s not reassigned three
times? No: the enhanced for builds a new local variable named
s on every iteration; that single-assignment variable gets replaced
by another variable on the next iteration, the same variable never
gets reassigned. Three lambdas captured three separate s’s, and that
is why three separate values got frozen.
// ClassicLoop.java - can the counter variable be captured in a classic for
public class ClassicLoop {
static final String SOURCE = """
import java.util.*;
import java.util.function.IntSupplier;
class Trial {
static void make() {
List<IntSupplier> capturers = new ArrayList<>();
for (int i = 0; i < 3; i++) {
capturers.add(() -> i);
}
}
}
""";
public static void main(String[] args) throws Exception {
System.out.println("classic for, can i be captured : "
+ (Compile.compiles(SOURCE, "Trial") ? "compiled" : "did not compile"));
}
}
classic for, can i be captured : did not compile
In the classic for loop, the situation is exactly the opposite. i
is a single variable, and i++ reassigns it on every iteration;
three iterations mean three separate assignments on the same i.
This directly breaks the effectively-final definition, and compilation
stops at the line where the lambda tries to capture i — the exact
same rule as the counter = 1; example measured earlier, here inside
a loop body. Even though the two loops look syntactically similar, the
enhanced for builds a new name every turn while the classic for
reassigns a single name over and over — and lambda capture is exactly
what brings this difference out.
Four Syntaxes, the Same Capture Rule
A lambda expression’s body can be a single expression, it can be a
block in curly braces, its parameter type can get written explicitly
or not at all — and if a method already exists that does the same
work, the lambda can get specified without writing a lambda at all,
with a method reference (Class::method). These four forms are
separate syntaxes; as long as the target interface is the same, all
four go through the same compile path.
- FJ15 — The same
Function<Integer, Integer>gets written in four separate forms and gets called with the same input (6).
// Syntax.java - four writings, same result
import java.util.function.Function;
public class Syntax {
static int square(int x) { return x * x; }
public static void main(String[] args) {
Function<Integer, Integer> expression = x -> x * x;
Function<Integer, Integer> bodied = x -> { return x * x; };
Function<Integer, Integer> typed = (Integer x) -> x * x;
Function<Integer, Integer> methodReference = Syntax::square;
System.out.println("expression form : " + expression.apply(6));
System.out.println("body form : " + bodied.apply(6));
System.out.println("explicit type : " + typed.apply(6));
System.out.println("method reference : " + methodReference.apply(6));
}
}
expression form : 36 body form : 36 explicit type : 36 method reference : 36
All four lines give the same result, because all four bind to the
same target — Function<Integer, Integer>’s single abstract method.
The body form requires the word return and allows more than one
statement; the expression form implicitly returns a single value.
typed writes its parameter type explicitly; expression and
bodied infer this type from the target interface. methodReference
writes no lambda at all — it points, by name, to the already existing
square method.
A method reference is not exempt from the capture rule, it just may
have nothing to capture. Syntax::square, pointing to a static
method, captures nothing, because a static method has no instance it
is bound to. But a reference pointing to an instance method
through that instance — something like list::size — captures the
list variable exactly the way the lambda () -> list.size() does,
and is subject to the same constraints, the same result; both writings
go through the same compile step, one is only a shorthand for the
other. Syntax changes, but the capture rule does not — the rule’s
condition is not the lambda keyword, it is reading a name from the
enclosing scope.
The Frozen Value, the Unfrozen Object
The measurement so far is open to a misreading: the sentence “the local variable is fixed” might give the impression that the captured object stays fixed too. The boundary measurement separates this.
- FJ16 — A list variable gets assigned once and never reassigned — satisfying the effectively-final rule. The lambda reads the list’s size, not the list itself.
// Reference.java - a local variable freezes its value, not the object
import java.util.*;
import java.util.function.Supplier;
public class Reference {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("alpha"));
Supplier<Integer> sizeReader = () -> list.size();
System.out.println("size when lambda built : " + sizeReader.get());
list.add("beta");
list.add("gamma");
System.out.println("size after list changes: " + sizeReader.get());
}
}
size when lambda built : 1 size after list changes: 3
The list variable got assigned once and never reassigned again; the
effectively-final constraint held from start to end. Despite this,
sizeReader.get() gives two separate numbers on two calls: 1,
then 3. The contradiction is only apparent; what the constraint
froze was the list variable itself — that is, which object it
points to — not that object’s content. list still points to the
same ArrayList object, but two more items got added inside that
object, and the lambda reads the current content on every call.
This bounds the previous section’s observation that “local capture stays fixed”: what stays fixed is the reference, not the mutable object the reference points to. The effectively-final rule gives no immutability guarantee; it only requires that a name point to the same object throughout the flow. The immutable string measured in the Primitive Types and Wrappers lesson and the mutable list captured here, for this reason, behave separately — in the first, the object itself can never change; in the second, the object can change in place, and capture does not stop this.
Summary
- When a lambda expression captures a local variable, it copies that variable’s value and embeds it inside itself; when it captures a field, it makes no copy, it keeps the path to the field.
- Two lambdas with the same body produce a different count of results across two capture forms: one unchanging result in local capture, separate results changing with the field in field capture.
- A captured local variable being effectively final is mandatory at compile time; a second assignment to the same variable, even after the lambda gets built, stops compilation.
- No such constraint exists for field capture; a field can get reassigned after the capture date too, and the compiler does not intervene.
- Boundary measurement: the effectively-final constraint freezes not the object the variable points to, only the variable itself. If the list the captured reference points to changes later, the lambda sees the changed content.
- A lambda expression’s four syntaxes (expression, body, explicit type, method reference) bind to the same target and give the same result; a method reference is not exempt from the capture rule, it just may have nothing to capture.
Next Step
This lesson measured what a single lambda expression takes from its surroundings, and how the four syntaxes bind to the same target. The standard library’s real form of use is not a single lambda like this, it is several lambdas chained one after another — a stream chain. Even though every link works correctly on its own, a separate question is born once it gets strung into a chain: which part of the chain depends on the source’s order, which one gives the same order under every condition, which one never runs without a terminal operation, and which rule is left entirely to the caller. The next lesson answers this question by running it across a stream chain’s ten separate operations.
To keep your progress and take notes, Log in
My notes
Log in to take notes.