Java | Java 詳細・モダン文法:ラムダ式・関数型 – ラムダのスコープ

Java Java
スポンサーリンク

ラムダのスコープを一言でいうと

ラムダ式の「スコープ」は、

「ラムダ式の“中から”“外のどこまで”見えるのか・アクセスできるのか」

という話です。

ここをちゃんと理解していないと、

  • 「ローカル変数をラムダの中で書き換えようとしてコンパイルエラー」
  • 「for 文の変数をキャプチャしてバグる」
  • 「無名クラスとの違いがモヤっとしたまま」

みたいなところで必ずつまずきます。

キーワードは 3 つです。

  1. ラムダは「外側の変数」をキャプチャできるが、「実質的 final」でなければならない
  2. ラムダの中の this は、「外側のクラスの this」のまま
  3. ラムダのスコープルールは、“普通のブロックとほぼ同じ”で、無名クラスとは微妙に違う

これを、例を交えながら丁寧に見ていきます。


ラムダから見える“外側の世界”

メソッドのローカル変数は「読み取り専用でキャプチャできる」

まず、「ラムダ外で宣言した変数を、ラムダの中から使えるか?」を確認します。

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

タイトルとURLをコピーしました