Lambda boleh membaca variabel yang berada di sekitarnya — variabel lokal, parameter method, bahkan field objek. Kemampuan ini disebut closure: fungsi yang “membawa” konteks pembuatannya ke mana-mana, sehingga fungsi tetap bisa bekerja jauh setelah method pembuatnya selesai. Namun Java memasang satu syarat yang membuat programmer baru sering terkejut: variabel lokal yang ditangkap boleh dibaca, tetapi tidak boleh diubah — harus final atau effectively final. Pada Chapter ini kita membuktikan tiga hal dengan eksekusi nyata: nilai tangkapan disalin saat lambda dibuat, pelanggaran aturan berakhir di error kompilasi, dan cara idiomatis menghitung dari dalam lambda.

Nilai yang Ditangkap adalah Salinan #

Mari kita mulai dari bukti paling mendasar: lambda tidak menyimpan tautan ke variabel lokal, melainkan salinan nilainya pada saat lambda dibuat. Method buatSapaan berikut mengembalikan lambda yang membaca parameter nama; setelah method selesai, parameter itu secara teknis sudah tidak hidup, tetapi lambda tetap ingat isinya:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

public class TangkapNilai {
    static Supplier<String> buatSapaan(String nama) {
        // parameter nama disalin ke dalam lambda saat lambda DIBUAT
        return () -> "Halo, " + nama + "!";
    }

    public static void main(String[] args) {
        // Dua lambda menangkap nilai yang berbeda
        Supplier<String> sapaBudi = buatSapaan("Budi");
        Supplier<String> sapaSiti = buatSapaan("Siti");

        // Dipanggil setelah parameter nama selesai hidup
        System.out.println(sapaBudi.get());
        System.out.println(sapaSiti.get());

        // Setiap iterasi membuat salinan tersendiri untuk lambda
        List<Supplier<Integer>> lonceng = new ArrayList<>();
        for (int i = 1; i <= 3; i++) {
            int salinan = i;   // variabel baru tiap iterasi, effectively final
            lonceng.add(() -> salinan * 10);
        }
        for (Supplier<Integer> s : lonceng) {
            System.out.println("lonceng: " + s.get());
        }
    }
}

Output yang Dihasilkan:

text
Halo, Budi!
Halo, Siti!
lonceng: 10
lonceng: 20
lonceng: 30

Tiga pengamatan dari hasil eksekusi tersebut:

  1. Baris 6-8 (return () -> "Halo, " + nama + "!";): lambda dibuat di dalam method, lalu lolos keluar dari method lewat nilai kembalian. JVM menyimpan salinan nama di dalam objek lambda — karena itu lambda tetap berfungsi setelah buatSapaan selesai dan parameternya tidak lagi bisa diakses.
  2. Baris 14-15 (sapaBudi dan sapaSiti): dua panggilan method yang sama menghasilkan dua lambda dengan tangkapan berbeda — "Budi" dan "Siti". Satu baris kode, dua konteks berbeda; inilah yang dimaksud fungsi “menutup” variabel lingkungannya.
  3. Baris 21-28 (loop dengan int salinan = i;): variabel loop i berubah-ubah sehingga tidak boleh ditangkap langsung. Trik int salinan = i membuat variabel baru pada setiap iterasi — variabel yang masing-masing effectively final — dan setiap lambda menangkap salinannya sendiri: hasilnya 10, 20, 30, bukan 30 tiga kali. Setiap salinan dibuat saat lambda dibuat, bukan saat lambda dipanggil.

Saat Pelanggaran Terjadi #

Sekarang sengaja kita langgar. Program berikut mencoba menaikkan variabel lokal jumlah dari dalam Consumer yang dijalankan forEach:

import java.util.List;
import java.util.function.Consumer;

public class PenghitungGagal {
    public static void main(String[] args) {
        List<String> nama = List.of("Budi", "Siti", "Dewi");
        int jumlah = 0;

        Consumer<String> hitung = n -> {
            System.out.println("Memproses " + n);
            jumlah++;   // variabel lokal diubah dari dalam lambda
        };

        nama.forEach(hitung);
        System.out.println("Total: " + jumlah);
    }
}

Berkas tersebut ditolak compiler javac sebelum sempat berjalan, dengan pesan persis seperti ini:

text
PenghitungGagal.java:11: error: local variables referenced from a lambda
expression must be final or effectively final

Pesan ini menyebut dua pilihan: final (kata kunci eksplisit) atau effectively final — variabel tanpa kata kunci final yang sejak inisialisasi tidak pernah diberi nilai baru. Compiler menganalisis seluruh method: satu saja penugasan ulang (jumlah++ sama artinya dengan jumlah = jumlah + 1) membatalkan status effectively final, dan variabel itu langsung dilarang masuk lambda.

Mengapa Java seketat itu? Dua alasan. Pertama, race condition: lambda bisa berjalan di thread lain atau jauh setelah method selesai, sementara variabel lokal hidup di stack frame method — salinan satu nilai aman dibawa, sedangkan tautan hidup ke stack tidak mungkin. Kedua, kejelasan: jika lambda bisa mengubah variabel luar, urutan eksekusi forEach atau stream paralel akan mengubah hasil program — jenis nondeterminisme yang sulit dijejak. Dengan aturan ini, tangkapan selalu bersifat nilai yang dibekukan.

Solusi yang Idiomatis #

Larangan menugaskan ulang variabel tidak berarti penghitung mustahil. Kita pindahkan angka yang berubah ke dalam objek — dan AtomicInteger dari paket java.util.concurrent.atomic adalah wadah standarnya:

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class PenghitungAman {
    public static void main(String[] args) {
        List<String> nama = List.of("Budi", "Siti", "Dewi");

        // Referensi jumlah tetap efektif final; ISI objeknya boleh berubah
        AtomicInteger jumlah = new AtomicInteger(0);

        nama.forEach(n -> {
            System.out.println("Memproses " + n);
            jumlah.incrementAndGet();
        });

        System.out.println("Total: " + jumlah.get());
    }
}

Output yang Dihasilkan:

text
Memproses Budi
Memproses Siti
Memproses Dewi
Total: 3

Bedah solusinya:

  1. Baris 10 (AtomicInteger jumlah = new AtomicInteger(0);): variabel jumlah hanya diinisialisasi sekali dan tidak pernah ditugaskan ulang — statusnya effectively final, sah ditangkap lambda. Yang berubah adalah isi objek yang ditunjuknya, bukan variabelnya.
  2. Baris 13-16 (jumlah.incrementAndGet()): method incrementAndGet() menaikkan nilai secara atomik dan mengembalikan nilai baru. Pada stream paralel atau multi-thread, operasi atomik ini bebas race condition — alasan utama AtomicInteger dipilih ketimbang int[] sederhana.
  3. Baris 19 (jumlah.get()): nilai akhir dibaca setelah seluruh forEach selesai. Keluarannya 3, tepat jumlah nama yang diproses — perilaku yang gagal dicapai berkas sebelumnya.
  4. Alternatif sederhana: wadah array satu elemen (int[] jumlah = {0}; lalu jumlah[0]++) bekerja untuk kode satu thread karena elemen array boleh diubah. Untuk kode yang berpeluang paralel, tetap pilih AtomicInteger.

Aturan effectively final menutup satu sisi gelap gaya fungsional: lambda yang tidak diam-diam mengubah dunia sekitarnya. Sisi gelap satunya lagi adalah side effect secara umum — method yang mengubah list luar, mencetak, atau menulis berkas. Chapter berikutnya membedah garis batas antara pure function yang mudah diuji dan fungsi ber-efek samping yang harus dikelola dengan sadar.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PenghitungAman.java

Unduh berkas
java
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class PenghitungAman {
    public static void main(String[] args) {
        List<String> nama = List.of("Budi", "Siti", "Dewi");

        // Referensi jumlah tetap efektif final; ISI objeknya boleh berubah
        AtomicInteger jumlah = new AtomicInteger(0);

        nama.forEach(n -> {
            System.out.println("Memproses " + n);
            jumlah.incrementAndGet();
        });

        System.out.println("Total: " + jumlah.get());
    }
}

PenghitungGagal.java

Unduh berkas
java
import java.util.List;
import java.util.function.Consumer;

public class PenghitungGagal {
    public static void main(String[] args) {
        List<String> nama = List.of("Budi", "Siti", "Dewi");
        int jumlah = 0;

        Consumer<String> hitung = n -> {
            System.out.println("Memproses " + n);
            jumlah++;   // variabel lokal diubah dari dalam lambda
        };

        nama.forEach(hitung);
        System.out.println("Total: " + jumlah);
    }
}

TangkapNilai.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

public class TangkapNilai {
    static Supplier<String> buatSapaan(String nama) {
        // parameter nama disalin ke dalam lambda saat lambda DIBUAT
        return () -> "Halo, " + nama + "!";
    }

    public static void main(String[] args) {
        // Dua lambda menangkap nilai yang berbeda
        Supplier<String> sapaBudi = buatSapaan("Budi");
        Supplier<String> sapaSiti = buatSapaan("Siti");

        // Dipanggil setelah parameter nama selesai hidup
        System.out.println(sapaBudi.get());
        System.out.println(sapaSiti.get());

        // Setiap iterasi membuat salinan tersendiri untuk lambda
        List<Supplier<Integer>> lonceng = new ArrayList<>();
        for (int i = 1; i <= 3; i++) {
            int salinan = i;   // variabel baru tiap iterasi, effectively final
            lonceng.add(() -> salinan * 10);
        }
        for (Supplier<Integer> s : lonceng) {
            System.out.println("lonceng: " + s.get());
        }
    }
}