Closure adalah lambda yang membawa lingkungannya, yaitu kumpulan variabel luar yang ditangkap pada saat lambda dibuat, sehingga variabel itu tetap hidup dan tetap dapat diubah selama lambda masih ada. Sifat inilah yang membuat lambda dapat menjadi pabrik keadaan, tetapi juga sumber kebingungan ketika penangkapan terjadi di dalam perulangan. Topik ini membahas perilaku variabel yang ditangkap, cara kompilator menyusunnya, jebakan penangkapan variabel perulangan beserta solusi baku, dan lambda static yang menolak penangkapan. Setelah topik ini, keputusan tentang apa yang ditangkap sebuah lambda dapat diambil secara sadar.
Variabel yang ditangkap dan masa hidupnya#
Lambda yang merujuk variabel lokal di luar badannya membentuk closure atas variabel tersebut, dan yang ditangkap adalah variabelnya, bukan salinan nilainya. Konsekuensinya ada dua, yaitu perubahan nilai terlihat oleh semua pihak yang memegang variabel yang sama, dan masa hidup variabel memanjang mengikuti masa hidup delegate yang menangkapnya. Fragmen berikut memanfaatkan keduanya untuk membuat penghitung sederhana.
// Fungsi lokal yang mengembalikan lambda penangkap variabel hitung.
Func<int> BuatPenghitung()
{
int hitung = 0;
// Lambda menutup atas variabel hitung, bukan menyalin nilainya.
return () => ++hitung;
}
// Setiap pemanggilan BuatPenghitung menciptakan closure dengan hitung tersendiri.
Func<int> penghitungA = BuatPenghitung();
Func<int> penghitungB = BuatPenghitung();
Console.WriteLine($"A: {penghitungA()}, {penghitungA()}, {penghitungA()}");
Console.WriteLine($"B: {penghitungB()}, {penghitungB()}, {penghitungB()}");A: 1, 2, 3
B: 1, 2, 3Variabel hitung dideklarasikan di dalam BuatPenghitung, padahal biasanya variabel lokal mati saat metodenya selesai. Lambda yang dikembalikan menangkap variabel tersebut, sehingga hitung tetap hidup di dalam closure dan nilainya bertambah setiap lambda dipanggil. Panggilan terhadap BuatPenghitung yang kedua menciptakan variabel hitung yang baru, sehingga penghitungB memulai dari 1 dan tidak melanjutkan hitungan penghitungA.
Cara kompilator menyusun closure#
Kompilator menyusun closure dengan memindahkan variabel yang ditangkap dari tumpukan ke dalam kelas buatan yang medannya dipegang bersama oleh lambda dan metode asalnya. Variabel hitung pada fragmen sebelumnya, misalnya, berpindah menjadi medan sebuah kelas, dan lambda membaca serta menulis medan itu. Mekanisme ini menjelaskan kedua konsekuensi sekaligus, karena medan kelas hidup selama objeknya hidup, dan semua yang memegang objek yang sama melihat nilainya sama.
Variabel yang tidak ditangkap cukup hidup di tumpukan, sedangkan variabel yang ditangkap wajib berpindah ke memori yang usianya tidak terikat oleh satu pemanggilan metode. Perpindahan inilah sebabnya penangkapan tidak tanpa biaya, karena setiap closure menciptakan objek tambahan yang dikelola pemulung memori. Kebiasaan menandai lambda static pada lambda yang memang tidak menangkap apa pun membantu menahan banyaknya objek kecil semacam ini.
Pemahaman tersebut menjadi penting ketika variabel yang ditangkap berasal dari perulangan. Variabel perulangan for dideklarasikan satu kali untuk seluruh perulangan, sehingga yang dihasilkan kompilator juga hanya satu medan, dan seluruh lambda yang dibuat di dalam perulangan membagi medan itu. Program berikut memperlihatkan akibatnya beserta solusi baku. Salin programnya ke berkas Program.cs, lalu jalankan dengan dotnet run --file Program.cs.
// Program closure: jebakan penangkapan variabel perulangan dan solusinya.
// Jebakan: ketiga lambda membagi satu variabel i yang sama.
var daftarAksi = new List<Action>();
for (int i = 0; i < 3; i++)
{
daftarAksi.Add(() => Console.Write($"{i} "));
}
daftarAksi[0]();
daftarAksi[1]();
daftarAksi[2]();
Console.WriteLine();
// Solusi: setiap iterasi memiliki variabel posisi yang baru.
var daftarBaik = new List<Action>();
for (int i = 0; i < 3; i++)
{
int posisi = i;
daftarBaik.Add(() => Console.Write($"{posisi} "));
}
daftarBaik[0]();
daftarBaik[1]();
daftarBaik[2]();
Console.WriteLine();3 3 3
0 1 2 Baris pertama keluaran memperlihatkan jebakannya, yaitu ketiga lambda mencetak 3 karena semuanya memegang satu variabel i yang sama, dan perulangan selesai ketika i mencapai 3. Baris kedua memperlihatkan solusinya, yaitu nilai i disalin ke variabel posisi yang dideklarasikan di dalam badan perulangan, sehingga setiap iterasi memperoleh variabel yang berbeda dan setiap lambda membawa variabelnya sendiri. Solusi ini bekerja karena deklarasi di dalam badan perulangan dieksekusi ulang pada setiap iterasi.
Menolak penangkapan dengan lambda static#
Tidak semua lambda membutuhkan lingkungannya, dan lambda yang memang tidak menangkap apa pun lebih ringan karena tidak ada kelas buatan yang harus dibuat. Kata kunci static di depan lambda melarang penangkapan secara eksplisit, sehingga lambda static tidak boleh merujuk variabel lokal maupun this, dan pelanggarannya ditolak kompilator.
// Lambda static dilarang menangkap variabel lokal maupun this.
int ambang = 100;
Func<int, bool> pakaiAmbang = nilai => nilai > ambang;
Func<int, bool> tanpaAmbang = static nilai => nilai > 100;
Console.WriteLine(pakaiAmbang(120));
Console.WriteLine(tanpaAmbang(120));True
TrueLambda pakaiAmbang menangkap variabel ambang, sedangkan tanpaAmbang menggunakan literal 100 sehingga cukup ditandai static. Penandaan static tidak mengubah keluaran, tetapi menjadikan niatnya kontrak yang diperiksa kompilator, yaitu lambda ini tidak menyimpan apa pun dari lingkungannya. Apabila seseorang kelak menambahkan rujukan ke variabel luar pada lambda tersebut, error muncul sebelum program dijalankan.
Kesalahan umum#
Tiga kesalahan berikut paling sering muncul ketika closure ditemui pertama kali.
- Menganggap lambda menyimpan salinan nilai. Penghitung pada bagian pertama membuktikan sebaliknya, karena nilai
hitungberlanjut antar-pemanggilan, sehingga lambda perlu diperlakukan sebagai pemegang variabel itu sendiri. - Mendaftarkan lambda di dalam perulangan
fortanpa menyalin variabelnya. Semua lambda berbagi satu variabel dan memperlihatkan nilai akhirnya, sehingga salinanint posisi = i;di dalam badan perulangan wajib dibuat bila nilai per iterasi dibutuhkan. - Menulis closure tanpa kebutuhan. Lambda yang tidak menangkap apa pun lebih hemat karena tidak menciptakan objek tambahan, dan penandaan
staticmemastikan sifat itu terjaga.
Ringkasan#
- Closure adalah lambda beserta variabel luar yang ditangkapnya, dan variabel yang ditangkap hidup serta berubah selama lambda masih ada.
- Kompilator memindahkan variabel yang ditangkap ke medan kelas buatan, sehingga semua lambda atas variabel yang sama membagi satu medan.
- Variabel perulangan
fordideklarasikan satu kali untuk seluruh perulangan, dan solusi jebakannya adalah menyalin nilai ke variabel baru di dalam badan perulangan. - Lambda
staticmenolak penangkapan sepenuhnya, sehingga niat tanpa penangkapan diperiksa oleh kompilator.
Referensi#
- Lambda expressions and anonymous functions — Microsoft Learn
— Referensi lambda, termasuk aturan penangkapan variabel luar dan lambda
static. - Iteration statements - for, foreach, do, and while — Microsoft Learn
— Referensi pernyataan perulangan yang melatarbelakangi lingkup variabel
fordanforeach.