---
title: 'Lambda Expressions'
source: 'https://academia.sh/en/courses/java-standard-library/lambda-expressions'
course: 'The Standard Library and Streams'
language: en
updated: '2026-08-17T18:09:42+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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
  `IntUnaryOperator`s: 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.

```java
// 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.

```java
// 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;
    }
}
```

```java
// 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.

```java
// 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.

```java
// 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`).

```java
// 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.

```java
// 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.
