Effectively-Final Capture in Lambdas
Before You Start
Check each box you can do from memory. A box you cannot check yet is not a problem; it points you to a quick refresher, not a grade.
Not sure? Take the 60-second self-check.
1. Lambda syntax. Write a lambda that compares two String objects by length.
Check
(a, b) -> Integer.compare(a.length(), b.length())
2. Local scope. A variable declared inside a method is a local variable. Can a lambda defined in the same method read that variable?
Check
Yes, but only if the local is effectively final: it is never reassigned after the lambda captures it.
3. Final vs. effectively final. What is the difference between a final local variable and an effectively final one?
Check
A final variable is explicitly declared final and the compiler enforces it. An effectively final variable is never reassigned, but the final keyword is absent. Java allows both in lambda capture. The guarantee the lambda needs (a stable value from capture to invocation) is the same in both cases.
Try This First
Consider this code:
int threshold = 100;
Comparator<Integer> cmp = (a, b) -> a > threshold ? 1 : -1;
threshold = 200;
Before reading further: predict whether it compiles, and if not, on which line the error appears.
Check
It does not compile. The error appears at the lambda (line 2). threshold is reassigned on line 3, so it is not effectively final. The compiler detects the reassignment and rejects the capture at the lambda declaration site.
What You Need To Walk In With
The Insight: A lambda captures the value of a local variable at the time of capture, not a live reference to the variable’s slot. For the captured value to be stable, the variable must not change after the capture. Java enforces this with the effectively-final rule: any local a lambda reads must either be declared final or never reassigned. This is not an arbitrary restriction; it ensures the lambda’s behavior is predictable regardless of when it is invoked relative to the enclosing method.
When you encounter a “local variable must be final or effectively final” compile error, the place to look is the lambda declaration, not the reassignment: the lambda is where the capture is attempted, and any reassignment anywhere in the same method disqualifies the variable. You can: determine whether a local is effectively final from a code excerpt, predict which line a compiler error appears on, apply the copy-to-final technique to fix a loop counter capture, and choose among the four escape valves when mutable state genuinely belongs inside a lambda.
How It Works
What capture means
A lambda can reference local variables from its enclosing method. This is called capture. The lambda does not store a reference to the variable’s memory slot; it captures the value at the point of declaration.
String suffix = ".com";
Comparator<String> byDomainEnd = (a, b) -> {
int aEnd = a.endsWith(suffix) ? 1 : 0;
int bEnd = b.endsWith(suffix) ? 1 : 0;
return Integer.compare(aEnd, bEnd);
};
// suffix is never reassigned; the capture is legal.
The effectively-final rule
JLS §4.12.4 defines effectively final: a local variable is effectively final if it is never assigned after its initial assignment. Explicit final is one way to achieve this; never reassigning is the other.
The compiler reports an error at the lambda body, not at the reassignment site. This means a reassignment anywhere in the method after the capture site is enough to make the lambda illegal.
Why Java enforces this
If captured variables could be mutated, the lambda’s behavior would depend on when it runs relative to the mutation, which would make it unpredictable. Java’s choice to copy the value at capture time avoids this; the price is the effectively-final restriction.
The four escape valves
When mutable state is genuinely required inside a lambda:
- Refactor: redesign so the mutable state lives outside the comparator entirely.
- One-element array:
final int[] counter = {0};(the array reference is final; the element is mutable). Legal but a code smell. - AtomicInteger: thread-safe mutable integer that satisfies the effectively-final rule because the reference is final.
- Anonymous class with a field: the cleanest solution when the state belongs to the comparator object.
For comparators in CSCD 211 lab work, none of these are needed. A comparator should be a pure function of its two arguments.
Loop counter capture
A for-loop variable is reassigned each iteration. It is not effectively final. Capturing it inside a lambda fails:
for (int i = 0; i < 5; i++) {
cmps.add((a, b) -> a == i ? -1 : 1); // COMPILE ERROR: i is not effectively final
}
Fix: copy the counter to a fresh local at each iteration:
for (int i = 0; i < 5; i++) {
final int captured = i;
cmps.add((a, b) -> a == captured ? -1 : 1);
}
Each iteration creates a new captured that is never reassigned, so it is effectively final.
Quick check
Check your understanding
A for-loop variable i is used inside a lambda added to a list. The loop runs three times. Which statement explains why this does not compile?
Worked Example: Predict Then Check
For each snippet, state whether it compiles and, if not, on which line the error appears:
// A
int limit = 10;
Comparator<Integer> c1 = (a, b) -> Integer.compare(a % limit, b % limit);
// B
int limit = 10;
Comparator<Integer> c2 = (a, b) -> Integer.compare(a % limit, b % limit);
limit = 20;
// C
int limit = 10;
limit = 10; // redundant reassignment to same value
Comparator<Integer> c3 = (a, b) -> Integer.compare(a % limit, b % limit);
Reasoning
A: limit is never reassigned. Effectively final. Lambda compiles.
B: limit is reassigned on the last line. Not effectively final. The compiler rejects the lambda on line 2 (where the capture happens), because any reassignment in the method disqualifies the variable, regardless of order.
C: limit is assigned twice: initial assignment (int limit = 10) and a reassignment (limit = 10). Even though both assignments write the same value, there are two assignments. The variable is NOT effectively final. Compile error at the lambda.
Show answers
| Compiles? | Reason | |
|---|---|---|
| A | Yes | limit is never reassigned |
| B | No (error at lambda) | limit is reassigned after the lambda |
| C | No (error at lambda) | limit is assigned twice; two assignments disqualify it |
Common Misconceptions
Misconception 1: the loop counter is effectively final because its value does not change within the lambda
Wrong mental model: “I read
iinside the lambda; it is the same value every time the lambda runs in this iteration, so it should be fine.”
Why it breaks: The rule is about the variable across the entire method, not about what the lambda sees during its own execution. i is reassigned by the loop update (i++). That disqualifies it, regardless of what happens inside the lambda body.
How to correct: Copy the value to a new variable at the start of each iteration: final int captured = i; captures a fresh, never-reassigned local for each lambda.
Source: JLS §4.12.4.
Quick check
Check your understanding
A student argues: ‘The loop body runs one iteration at a time, so i holds only one value while the lambda is being created. The lambda should compile.’ What is wrong with this reasoning?
Misconception 2: the int[]{...} trick is idiomatic and recommended
Wrong mental model: “I have seen
final int[] counter = {0}; counter[0]++;in examples; this is the right way to have mutable state in a lambda.”
Why it is a smell: The trick works because the array reference is final while the element is mutable. But a comparator that mutates state across calls is usually doing something that does not belong in a comparator (logging, instrumenting). For that use case, an anonymous class with a field is cleaner. For a pure sort comparator, mutable state is never needed.
How to correct: First ask whether the state is genuinely part of the comparator’s ordering logic. If not, move it out of the comparator entirely. If yes, use an anonymous class with a field.
Source: Bloch, Effective Java, Item 46.
Formal Definition and Interface Contract
From JLS §4.12.4 (Final Variables):
A local variable [...] is effectively final if it is not declared final but its value is never changed after it is initialized.
A local variable or parameter [...] may be used but not declared in a lambda body only if it is final or effectively final.
From JLS §15.27.2 (Lambda Body):
It is a compile-time error if [...] a local variable [...] that is used but not declared in a lambda body is not effectively final.
Mental Model
Think of the lambda as a note passed to a friend. You write the note when you create the lambda. On the note you can record the current value of any local variable, but only if the variable’s value is locked in (effectively final). If the variable could change after you write the note, the note might not reflect what you meant to say. Java forces you to use a locked value so the note is always accurate.
Connections
Within CSCD 210/211: The same effectively-final rule applies to anonymous classes. Lambdas and anonymous classes share this restriction because both defer execution to a later point.
Looking back: Target Typing and Functional Interfaces covered what a lambda can be. This lesson covers what a lambda can see from the enclosing method.
Looking ahead: Method references have no capture at all because the method reference body does not read any local variables from the enclosing scope.
Practice
Level 1
Determine whether each capture is legal. If not, state why and provide a fix:
// a)
final String prefix = "Mr.";
Comparator<String> c1 = (a, b) -> a.startsWith(prefix) ? -1 : 1;
// b)
String prefix = "Mr.";
prefix = "Ms.";
Comparator<String> c2 = (a, b) -> a.startsWith(prefix) ? -1 : 1;
Show answer
(a) Legal. prefix is declared final and never reassigned. The capture is valid.
(b) Illegal. prefix is reassigned before the lambda. Fix: do not reassign, or create a new final local:
String prefix = "Mr.";
prefix = "Ms.";
final String captured = prefix; // captured is effectively final
Comparator<String> c2 = (a, b) -> a.startsWith(captured) ? -1 : 1;
Level 2
A student writes:
for (int tier = 1; tier <= 3; tier++) {
Comparator<Player> c = (a, b) -> a.getTier() == tier ? -1 : 1;
sort(c, tier);
}
Explain the compile error and provide the corrected loop.
Show answer
Error: tier is the loop variable; it is reassigned by tier++. It is not effectively final. The compiler rejects the capture at the lambda.
Fix:
for (int tier = 1; tier <= 3; tier++) {
final int capturedTier = tier;
Comparator<Player> c = (a, b) -> a.getTier() == capturedTier ? -1 : 1;
sort(c, capturedTier);
}
Each iteration declares a fresh capturedTier that is never reassigned, so it is effectively final.
Level 3
A student needs a comparator that sorts Order[] by total, but only among orders above a user-supplied threshold; orders below the threshold are always sorted last. The threshold is set in the enclosing method and never changes after being set. Write the comparator as a lambda and explain why the capture is legal.
Thought process
If the threshold local is set once and never reassigned, it is effectively final. The lambda can capture it. The body computes positions based on the threshold.
Show answer
double threshold = getThresholdFromUser(); // set once, never reassigned
Comparator<Order> c = (a, b) -> {
boolean aAbove = a.getTotal() >= threshold;
boolean bAbove = b.getTotal() >= threshold;
if (aAbove && !bAbove) return -1;
if (!aAbove && bAbove) return 1;
return Double.compare(a.getTotal(), b.getTotal());
};
Why the capture is legal: threshold is assigned once and never reassigned after the lambda declaration. It is effectively final. The lambda reads a stable value.
Go Deeper (optional)
You do not need any of this to write correct lambdas. It is here because some of these connections are genuinely interesting, and knowing them helps when you see the same idea surface later in a different form.
Where this shows up in real work. The effectively-final rule governs every Java Stream pipeline you will write in production code: map, filter, forEach, and collect all accept lambdas, and every one of those lambdas is subject to the same capture rules. Understanding why the compiler rejects a loop variable inside a stream pipeline is exactly this rule. Bloch’s Effective Java Item 46 frames it this way: lambdas and stream operations should be side-effect-free, and effective finality is the language’s enforcement of that principle at the local-variable level.
The order-theory view. A Comparator is not just code; it is a binary relation that imposes a strict total order on a set. The three contract axioms (irreflexivity, asymmetry, transitivity) are exactly the axioms of a strict total order in mathematics. The effectively-final rule protects the axioms: if the captured value could drift between two calls to the same comparator, the relation could become inconsistent, breaking the transitivity guarantee that sorting algorithms depend on.
The design-pattern view. Passing a Comparator object to sort is the Strategy pattern from Gamma et al. (1994): the algorithm (sort) is separated from the policy (how to compare). A lambda is the simplest possible Strategy implementation, one with no fields and no state. The effectively-final rule keeps it that way. When a lambda needs mutable state, it is no longer the simplest tool, and that tension is the signal to reach for an anonymous class or a named class instead.
The language-history view. The effectively-final restriction is Java’s approximation of closure semantics in functional languages, where variables in the enclosing scope are immutable by default. Kotlin and Scala make this explicit at the declaration site: val (immutable, capturable) versus var (mutable, not capturable). Java chose not to add a second keyword but achieved the same guarantee by counting reassignments at compile time.
Check Yourself
Close the notes and answer each one from memory, then reveal it. Pulling an idea back from memory is one of the strongest ways to make it stick.
Check your understanding
Which statement best describes what ‘effectively final’ means for a local variable?
A local variable is assigned twice, but both assignments write the identical value. Is it effectively final?
Predict the outcome of compiling this snippet: int limit = 10; Comparator<Integer> c = (a, b) -> Integer.compare(a % limit, b % limit); limit = 20;
A student writes a for-loop that adds a lambda to a list, but the lambda captures the loop variable i directly. Why does this not compile?
When mutable state inside a lambda is genuinely required, which option does the lesson identify as the cleanest solution when the state belongs to the comparator object itself?