ラムダのスコープを一言でいうと
ラムダ式の「スコープ」は、
「ラムダ式の“中から”“外のどこまで”見えるのか・アクセスできるのか」
という話です。
ここをちゃんと理解していないと、
- 「ローカル変数をラムダの中で書き換えようとしてコンパイルエラー」
- 「for 文の変数をキャプチャしてバグる」
- 「無名クラスとの違いがモヤっとしたまま」
みたいなところで必ずつまずきます。
キーワードは 3 つです。
- ラムダは「外側の変数」をキャプチャできるが、「実質的 final」でなければならない
- ラムダの中の
thisは、「外側のクラスの this」のまま - ラムダのスコープルールは、“普通のブロックとほぼ同じ”で、無名クラスとは微妙に違う
これを、例を交えながら丁寧に見ていきます。
ラムダから見える“外側の世界”
メソッドのローカル変数は「読み取り専用でキャプチャできる」
まず、「ラムダ外で宣言した変数を、ラムダの中から使えるか?」を確認します。
import java.util.function.Consumer;
public class LambdaScopeBasic {
public static void main(String[] args) {
String prefix = ">> ";
Consumer<String> printer = s -> {
System.out.println(prefix + s);
};
printer.accept("hello");
}
}
Javaこれはコンパイルも実行も OK です。
ラムダの中から、外側で定義した prefix を普通に使えています。
ここで重要なのは、
「ラムダは、外側のローカル変数を読める(キャプチャできる)」
ということです。
でも、ここでよくやりがちな“書き換え”をしようとすると、コンパイルエラーになります。
public class LambdaScopeBasicError {
public static void main(String[] args) {
String prefix = ">> ";
Consumer<String> printer = s -> {
// prefix = prefix + s; // コンパイルエラー
System.out.println(prefix + s);
};
}
}
Javaエラーの原因はこうです。
「ラムダがキャプチャできるローカル変数は、“実質的 final(effectively final)”でなければならない」
このルールがめちゃくちゃ大事です。
「実質的 final(effectively final)」とは何か
Java 8 までは、外側のローカル変数を無名クラスやラムダで使うとき、
変数に final を付ける必要がありました。
final String prefix = ">> ";
Javaしかし今は、明示的に final を付けなくても、
「一度代入したら、その後に再代入していない変数」
であれば、コンパイラはそれを「実質的 final」とみなしてくれます。
String prefix = ">> "; // 以後、prefix に再代入がなければ OK
Consumer<String> c = s -> System.out.println(prefix + s); // 使える
Java一方、途中で値を変えようとすると NG です。
String prefix = ">> ";
prefix = "### "; // ここで「実質的 final」ではなくなる
Consumer<String> c = s -> System.out.println(prefix + s); // コンパイルエラー
Javaまとめると、
- ラムダでキャプチャできるローカル変数は
「そのメソッド内で、最初に代入されてから“再代入されていない”こと」が条件 - 読むだけなら OK、書き換えようとすると NG
と覚えてください。
なぜ「実質的 final」じゃないとダメなのか
ラムダが実行されるタイミングとローカル変数の寿命
直感的な理由をイメージで説明します。
- ローカル変数は、「そのメソッドが動いている間」だけ生きています
- 一方で、ラムダは「作られた後に、いつ実行されるか分からない」
たとえば:
public static void main(String[] args) {
String prefix = ">> ";
Runnable r = () -> System.out.println(prefix + "run");
// ここで main メソッドが終わった後に r が別スレッドで呼ばれるかもしれない
}
Javaもしローカル変数を「参照として」持ってしまうと、
メソッド終了後にその変数が消えてしまって、ラムダが実行できなくなります。
そこで Java は、「ローカル変数の“値のコピー”をラムダに閉じ込める」という戦略を取っています。
けれど、「値のコピー」を安全に行うためには、
「コピーを作った後に、元の変数が書き換えられない」
ことが必要です。
だから、「実質的 final な変数だけ、ラムダでキャプチャできる」という制約があるわけです。
ラムダ内の this / 変数スコープと、無名クラスとの違い
ラムダの this は「外側の this」
無名クラスとラムダで実はけっこう違うのが this の意味です。
無名クラスの場合:
public class Sample {
String name = "Sample";
void run() {
Runnable r = new Runnable() {
String name = "Inner";
@Override
public void run() {
System.out.println(this.name); // "Inner"
}
};
r.run();
}
}
Javaここでの this は「無名クラス自身」を指します。
ラムダの場合:
public class Sample {
String name = "Sample";
void run() {
Runnable r = () -> {
System.out.println(this.name); // "Sample"
};
r.run();
}
}
Javaラムダの中の this は、「外側のクラスの this」のままです。
つまり、
- 無名クラスの
this→ 無名クラスのインスタンス - ラムダの
this→ 外側のクラスのインスタンス
ここは、スコープの感覚としてかなり重要な違いです。
変数名のシャドウイング(同じ名前の変数を内側で定義する)について
無名クラスでは、外側と同じ名前のローカル変数やフィールドを、
内側で“隠す”ことができます。
int x = 10;
Runnable r = new Runnable() {
int x = 20;
@Override
public void run() {
System.out.println(x); // 20
}
};
Java一方、ラムダでは「外側のローカル変数と同じ名前のパラメータやローカル変数」を宣言できません。
int x = 10;
// コンパイルエラー:ラムダのパラメータで x を再定義できない
Runnable r = () -> {
// int x = 20; // これもダメ
};
Java理由は、「ラムダのスコープルールは、あくまで“普通のブロック”と同じ」と考えられているからです。
普通のブロックでも、同じスコープ内で同名の変数は宣言できませんよね。
int x = 10;
// int x = 20; // コンパイルエラー
Javaラムダも同じ感覚です。
無名クラスは「新しいクラス・新しいスコープ」を作るので、名前の衝突を避けられますが、
ラムダは「外側のスコープをそのまま引き継いだ“小さな関数”」なので、
外側のローカル変数名を再定義することはできません。
for ループとラムダのスコープでハマりやすいところ
ループ変数をキャプチャするパターン
次のようなコードを考えます。
import java.util.ArrayList;
import java.util.List;
public class ForLoopLambda {
public static void main(String[] args) {
List<Runnable> list = new ArrayList<>();
for (int i = 0; i < 3; i++) {
list.add(() -> System.out.println(i));
}
list.forEach(Runnable::run);
}
}
Javaこれ、どう出力されると思いますか?
実際には、コンパイルエラーになります。
理由は、「i が実質的 final ではないから」です。
i++ で値が変わっているので、ラムダにキャプチャできません。
この場合、「その時点の i の値を一度別の変数にコピーしてからキャプチャする」という書き方が必要です。
for (int i = 0; i < 3; i++) {
int x = i; // 実質的 final
list.add(() -> System.out.println(x));
}
Javaこうすると、
iは for で変化する- その都度、新しいローカル変数
xを作り、その値をラムダがキャプチャ
という形になります。
実行結果は
0
1
2
となります。
ポイントは、
「ラムダが参照しているのは、“そのときの i そのもの”ではなく、“その場でコピーした x”」
というところです。
クラスフィールドや配列要素の書き換えは OK
ローカル変数はダメだが、フィールドは変更できる
さっき「ラムダからキャプチャした変数は書き換えられない」と言いましたが、
それは“ローカル変数”の話です。
クラスのフィールドなら話は別です。
import java.util.function.Consumer;
public class FieldUpdateExample {
private int count = 0;
public void run() {
Consumer<Integer> adder = x -> {
count += x; // フィールドは変更できる
};
adder.accept(10);
adder.accept(5);
System.out.println(count); // 15
}
public static void main(String[] args) {
new FieldUpdateExample().run();
}
}
Javaここでは、count はクラスのインスタンスフィールドなので、
ラムダの中から自由に書き換えられます。
同様に、配列や List の中身を変えるのも OK です。
public static void main(String[] args) {
int[] arr = {0};
Runnable r = () -> {
arr[0]++; // OK
};
r.run();
r.run();
System.out.println(arr[0]); // 2
}
Javaここで制限されているのは、「ローカル変数への“再代入”」です。
int x = 0;
Runnable r = () -> {
// x++; // NG (x への再代入)
};
int[] arr = {0};
Runnable r2 = () -> {
arr[0]++; // OK (配列要素の変更)
};
Java「参照そのもの(x)を変えるのはダメだが、参照先の“中身”(配列の要素やフィールド)を変えるのは OK」
という線引きだと思ってください。
まとめ:ラムダのスコープを自分の言葉で整理する
ラムダのスコープで、特に大事なポイントを整理するとこうなります。
- ラムダは外側のローカル変数をキャプチャできるが、「実質的 final」でないとダメ
→ 一度値を代入したら、その後その変数に再代入してはいけない - ラムダの
thisは「外側のクラスの this」のまま
→ 無名クラスと挙動が違うので注意 - ラムダの中では、外側のローカル変数名を再宣言(シャドウイング)できない
→ 普通のブロックスコープと同じルール - ループ変数をキャプチャするときは、「その都度別のローカル変数にコピーしてから」キャプチャする
- ローカル変数への再代入は NG だが、フィールドや配列要素など、“参照先の中身”の変更は OK

