Closure dan Lingkup

Memahami closure yang menangkap lingkungan leksikal melalui contoh klasik counter.

Daftar isi

Fungsi pada Clojure mengikat nilai dari lingkup leksikalnya, yaitu lingkup yang ditentukan oleh posisi penulisan kode dan bukan oleh alur pemanggilan. Ketika sebuah fungsi dikembalikan dari let atau dari fungsi lain, binding yang dirujuknya tetap hidup mengikutinya. Fungsi tersebut beserta lingkungan tertangkapnya disebut closure. Topik ini membahas cara kerja penangkapan lingkungan tersebut melalui fungsi pengali, kemudian menutup pembahasannya dengan counter klasik yang menyimpan state di dalam closure.

Lingkungan leksikal yang tertangkap#

Badan fungsi boleh merujuk binding dari lingkup yang melingkupinya, baik parameter fungsi luar maupun binding let. Apabila fungsi dalam itu dikembalikan sebagai nilai, binding yang dirujuknya ikut keluar dari lingkup asalnya dan tetap hidup selama fungsi itu digunakan. Mekanisme inilah yang dimaksud penangkapan lingkungan (closure) pada Clojure. Contoh berikut menggunakan fungsi pembuat pengali agar perbedaan antar lingkungan terlihat pada hasil eksekusinya.

clojure Fungsi yang menangkap parameter lingkungannya
;; pengali menangkap binding faktor dari lingkungan leksikalnya
(defn pengali [faktor]
  (fn [x] (* x faktor)))

(def kali-3 (pengali 3))
(def kali-10 (pengali 10))

(println "(kali-3 5)      =" (kali-3 5))
(println "(kali-10 5)     =" (kali-10 5))
(println "((pengali 4) 7) =" ((pengali 4) 7))
text Keluaran
(kali-3 5)      = 15
(kali-10 5)     = 50
((pengali 4) 7) = 28

Badan (fn [x] (* x faktor)) merujuk faktor yang bukan parameternya sendiri, melainkan parameter pengali. Setiap pemanggilan pengali membuka lingkungan baru, sehingga kali-3 dan kali-10 masing-masing membawa faktor yang berbeda dan tetap hidup setelah pengali selesai dieksekusi. Tidak ada variabel global yang terlibat, dan tidak ada nilai yang bocor ke luar selain melalui fungsi yang dikembalikan. Lingkungan setiap closure tertutup rapat dan hanya dapat dijangkau melalui fungsi yang mengembalikannya.

Penangkapan lingkungan juga membuat ketergantungan sebuah fungsi terlihat pada definisinya. Nilai faktor yang digunakan kali-3 dapat dilacak ke pemanggilan pengali yang menghasilkannya, tanpa perlu menelusuri var lain di namespace. Bandingkan dengan pendekatan var global yang dapat ditulis ulang dari bagian kode mana pun, sehingga perilaku fungsi bergantung pada riwayat pemanggilan yang tidak terlihat. Closure menghindarkan masalah tersebut karena lingkungannya tertutup sejak definisi dan tidak dapat diganti dari luar.

Counter klasik#

Contoh klasik closure adalah counter, yaitu fungsi tanpa argumen yang setiap dipanggil mengembalikan hitungan berikutnya. State-nya ditampung dalam let berisi atom, kemudian fungsi yang dikembalikan menangkap binding tersebut. atom adalah tipe referensi yang menyimpan satu nilai dan dapat diperbarui secara aman, dan pembahasan lengkapnya ditanggung modul State dan Konkurensi. Fungsi swap! menerapkan fungsi pembaruan atas nilai atom lalu mengembalikan hasil barunya.

clojure Dua penghitung dari pembuat yang sama
;; counter klasik: closure menutupi atom yang hidup lebih lama dari let-nya
(defn buat-penghitung []
  (let [c (atom 0)]
    (fn [] (swap! c inc))))

(def hitung-a (buat-penghitung))
(def hitung-b (buat-penghitung))

(println "a:" (hitung-a) (hitung-a) (hitung-a))
(println "b:" (hitung-b))
(println "a lagi:" (hitung-a))
text Keluaran
a: 1 2 3
b: 1
a lagi: 4

Binding c hanya terlihat di dalam let, tetapi fungsi yang dikembalikan merujuknya, sehingga c ikut tertangkap dan hidup sepanjang fungsi itu dipanggil. Setiap pemanggilan buat-penghitung membuka let yang baru, sehingga hitung-a dan hitung-b menyimpan state yang terpisah. Baris b: 1 membuktikan hitung-b memulai dari nol, sedangkan baris a lagi: 4 membuktikan hitungan a berlanjut dari tempat terakhirnya. State tidak pernah terlihat dari luar karena satu-satunya aksesnya adalah fungsi yang dikembalikan.

Lingkungan yang berbagi#

Dua closure yang didefinisikan di dalam satu let yang sama menangkap binding yang sama pula. Perilaku ini membuktikan bahwa yang tertangkap adalah lingkungannya, bukan salinan nilai pada saat definisi. Contoh berikut memasang satu atom dan dua fungsi yang membacanya, yaitu fungsi penaik dan fungsi pembaca.

clojure Dua closure berbagi satu lingkungan
;; dua closure dari let yang sama berbagi lingkungan
(let [c (atom 0)]
  (def naik (fn [] (swap! c inc)))
  (def baca (fn [] @c)))

(println "(naik) =" (naik))
(println "(naik) =" (naik))
(println "(baca) =" (baca))
text Keluaran
(naik) = 1
(naik) = 2
(baca) = 2

naik menaikkan nilai c, sedangkan baca membaca nilai terkininya melalui deref @c. Hasil 2 pada (baca) menunjukkan bahwa kedua fungsi melihat atom yang sama, karena keduanya ditulis di dalam let yang sama. Penggunaan def di sini sah karena letaknya di top level, sehingga var naik dan baca tersimpan pada namespace. Kontras dengan hitung-a dan hitung-b pada bagian sebelumnya yang berasal dari let terpisah, sehingga state keduanya tidak pernah bercampur.

Kebalikan pernyataan itu juga berlaku, yaitu satu let dapat dievaluasi lebih dari sekali pada REPL, dan setiap evaluasinya membuka atom yang baru. Var naik dan baca kemudian menunjuk fungsi yang menangkap atom terbaru, sehingga hitungan kembali dari nol. Perilaku ini normal dan bukan tanda kerusakan, tetapi layak diingat saat hasil REPL terasa berjalan ulang dari awal.

Kesalahan umum#

Kesalahan pertama berupa anggapan bahwa closure yang dibuat dari satu fungsi pembuat berbagi state. Setiap pemanggilan fungsi pembuat membuka lingkungan yang baru, sehingga dua closure dari dua pemanggilan tidak pernah saling memengaruhi. Apabila dua fungsi memang harus berbagi, definisikan keduanya di dalam satu let yang sama, sebagaimana pola naik dan baca.

Kesalahan kedua berupa def yang diletakkan di dalam let yang berada di dalam fungsi. Var hasil def bersifat global pada namespace dan didefinisikan ulang pada setiap pemanggilan fungsi tersebut, sehingga perilakunya sulit dilacak. def pada pola berbagi lingkungan sebaiknya tetap di top level, sedangkan fungsi yang berada di dalam fungsi lain cukup dikembalikan sebagai nilai.

Kesalahan ketiga berupa penambahan state pada binding biasa yang tidak pernah berubah. Binding let bersifat immutable, sehingga hitungan yang berjalan memerlukan tipe referensi seperti atom, bukan pengubahan nilai binding lama. Perbaikannya mengikuti pola buat-penghitung, yaitu atom di dalam let yang diperbarui melalui swap!.

Ringkasan#

  • Closure menyimpan binding leksikal yang dirujuk badannya, dan binding itu tetap hidup selama fungsi tersebut digunakan.
  • Counter klasik menyusun let berisi atom, lalu mengembalikan fungsi yang menangkap atom tersebut, sehingga state hanya dapat diakses melalui fungsi itu.
  • Dua closure dari satu let yang sama berbagi lingkungan, sedangkan closure dari let terpisah memiliki state yang terpisah.
  • atom menyediakan penyimpanan yang dapat diperbarui bagi state yang ditangkap closure.

Referensi#