Topik ini memperkenalkan struktur runtime untuk fungsi pada bahasa mini, yaitu Environment sebagai rantai scope dan Closure sebagai pasangan body dengan Environment yang ditangkap saat fungsi dibuat. Pembaca memperoleh kelas Environment yang menata scope sebagai dict berantai ke parent, kelas Closure yang menyimpan parameter, body, dan lingkungan tangkapan, serta demo make_counter yang menunjukkan tangkapan variabel bekerja pada pemanggilan terpisah. Implementasi pada topik ini berdiri sendiri dan tidak terintegrasi dengan StackVM pada modul 5, sebab bahasa mini v1 yang dibangun sepanjang jalur memang belum memuat fungsi maupun closure. Tujuannya adalah menunjukkan konsep runtime yang dibutuhkan ketika fitur tersebut ditambahkan. Setelah membaca topik ini, pembaca dapat menjelaskan cara kerja lookup yang naik ke parent, menjelaskan kapan tangkapan environment terjadi, dan menjelaskan mengapa dua closure yang berbeda memegang state terpisah.
Environment sebagai rantai scope#
Environment menyimpan pemetaan nama ke nilai pada satu scope, lengkap dengan rujukan ke parent. Bentuk ini digunakan karena scope pada bahasa mini memang bersifat bersarang: sebuah blok if membentuk scope di dalam scope fungsi, yang berada di dalam scope global. Lookup nama dilakukan dengan memeriksa scope lokal terlebih dahulu, lalu naik ke parent bila nama tidak ditemukan, hingga mencapai scope global. Bila nama tidak ada pada salah satu scope pun, lookup gagal dengan error NameError. Lookup ini disebut lexical lookup karena rujukan ke parent sudah ditetapkan saat scope dibuat, bukan ditentukan oleh urutan pemanggilan saat runtime.
Dua operasi tulis perlu dibedakan. Operasi define menulis nama pada scope lokal tanpa mempedulikan parent. Operasi set mencari nama mulai dari scope lokal ke atas, lalu memutakhirkan tempat nama itu ditemukan. Bila nama tidak ada sama sekali, set menulisnya pada scope lokal sebagai jaring pengaman. Pembedaan ini penting karena deklarasi let x = ... seharusnya selalu membuat nama baru di scope lokal, sedangkan penugasan x = ... seharusnya mengubah variabel yang sudah ada, mungkin di scope parent. Tanpa pembedaan, penugasan akan mudah berubah menjadi deklarasi baru.
Satu konsekuensi penting dari rantai scope adalah shadowing. Bila sebuah nama dideklarasikan pada scope lokal dan nama yang sama juga ada pada scope parent, lookup akan menemukan versi lokal terlebih dahulu, sehingga versi parent tersembunyi. Shadowing bukan error melainkan fitur, sebab tanpa fitur itu nama helper sementara pada blok dalam akan mudah bertabrakan dengan nama di luar. Pada implementasi referensi, shadowing diizinkan begitu saja oleh dict lokal, karena vars[name] = value selalu menimpa pasangan lama pada dict yang sama.
Tangkapan variabel oleh closure#
Closure adalah pasangan antara body fungsi dan Environment yang aktif saat fungsi dibuat. Tangkapan ini terjadi pada saat evaluasi ekspresi fungsi, bukan saat fungsi dipanggil. Akibatnya, walaupun pemanggilan terjadi jauh setelah fungsi dibuat dan di lingkungan yang berbeda, body tetap melihat variabel yang ada pada lingkungan saat fungsi dibuat. Inilah yang membuat fungsi seperti make_counter dapat menyimpan state di antara pemanggilan: variabel current yang diakses oleh body berada pada Environment yang ditangkap, bukan pada Environment pemanggil.
Saat dipanggil, sebuah Environment baru dibangun dengan parent berupa Environment yang ditangkap. Argumen diikat ke parameter pada Environment baru ini. Setelah itu, body dievaluasi pada Environment baru. Evaluasi ini dapat melihat argumen, lalu naik ke Environment tangkapan untuk variabel bebas, dan terus naik mengikuti rantai parent bila diperlukan. Pada demo make_counter, body closure memanggil env.get("current") untuk membaca dan env.set("current", ...) untuk menulis. Karena current tidak ada pada scope lokal pemanggilan, lookup naik ke Environment tangkapan dan menemukannya di sana, sehingga mutasi terjadi pada state yang ditangkap, bukan pada scope sementara pemanggilan.
Dua closure yang dibuat oleh dua pemanggilan make_counter yang berbeda memegang dua Environment tangkapan yang berbeda pula. Pada keluaran demo, pemanggilan c_a() dan c_b() menghasilkan deretan angka yang berbeda dan tidak saling mengganggu, sebab masing-masing closure memiliki tangkapan current sendiri. Sifat ini disebut state encapsulation: state berada di dalam closure, dan tidak dapat diakses dari luar kecuali melalui pemanggilan closure itu sendiri.
Implementasi referensi#
Berkas closure_demo.py berisi kelas Environment dan Closure plus fungsi make_counter. Kelas Environment menggunakan dict bernama vars untuk menyimpan pasangan nama dan nilai pada scope lokal, serta field parent untuk merujuk scope pembungkus. Metode get melakukan pencarian berurutan dari lokal ke parent, sedangkan set mengikuti aturan tulis ke tempat nama ditemukan. Metode has digunakan secara internal untuk mendukung keputusan set tanpa menggandakan logika pencarian.
"""Environment berantai dan closure sederhana.
Topik ini memperkenalkan struktur runtime untuk fungsi pada bahasa
mini, yaitu Environment sebagai rantai scope dan Closure sebagai
pasangan body + Environment saat fungsi dibuat. Implementasi ini
berdiri sendiri dan tidak terintegrasi dengan VM modul 5.
Antarmuka publik:
Environment(parent=None).define(name, value)
Environment.get(name) -> value
Environment.set(name, value)
Closure(params, body, env).call(args, interp) -> Any
"""
from typing import Any, Callable
class Environment:
"""Rantai scope: dict lokal + rujukan ke parent.
Lookup naik ke parent bila nama tidak ditemukan pada scope lokal.
Define selalu menulis pada scope lokal. Set naik ke parent bila
nama ditemukan di sana, atau menulis pada scope lokal bila tidak.
"""
def __init__(self, parent: "Environment | None" = None) -> None:
self.vars: dict[str, Any] = {}
self.parent = parent
def define(self, name: str, value: Any) -> None:
self.vars[name] = value
def get(self, name: str) -> Any:
if name in self.vars:
return self.vars[name]
if self.parent is not None:
return self.parent.get(name)
raise NameError(f"nama tidak terdefinisi: {name!r}")
def set(self, name: str, value: Any) -> None:
if name in self.vars:
self.vars[name] = value
return
if self.parent is not None and self.parent.has(name):
self.parent.set(name, value)
return
self.vars[name] = value
def has(self, name: str) -> bool:
if name in self.vars:
return True
return self.parent.has(name) if self.parent is not None else False
class Closure:
"""Pasangan (params, body, captured_env).
Closure menangkap Environment yang aktif saat ia dibuat. Saat
dipanggil, sebuah Environment baru dibangun dengan parent = env
yang ditangkap, lalu argumen diikat ke parameter pada scope
baru tersebut.
"""
def __init__(self, params: list[str], body: Callable[[Environment], Any],
env: Environment) -> None:
self.params = params
self.body = body
self.env = env
def call(self, args: list[Any]) -> Any:
if len(args) != len(self.params):
raise TypeError(
f"arity tidak cocok: harapan {len(self.params)}, diberikan {len(args)}"
)
local = Environment(parent=self.env)
for name, value in zip(self.params, args):
local.define(name, value)
return self.body(local)
def make_counter(start: int) -> Closure:
"""Membangun closure yang menangkap Environment pemanggil.
Fungsi ini mencontohkan tangkapan: counter mengakses `current`
dari Environment yang dibangun di dalam make_counter, walaupun
pemanggilan counter sendiri terjadi di Environment lain.
"""
def body(env: Environment) -> Any:
current = env.get("current")
env.set("current", current + 1)
return current
captured = Environment()
captured.define("current", start)
return Closure(params=[], body=body, env=captured)
def main() -> None:
print("=== Environment sebagai rantai scope ===")
global_env = Environment()
global_env.define("pi", 3)
global_env.define("nama", "bahasa-mini")
inner = Environment(parent=global_env)
inner.define("pi", 3.14) # shadowing
print(f"global.pi = {global_env.get('pi')}")
print(f"inner.pi = {inner.get('pi')} (shadowing)")
print(f"inner.nama = {inner.get('nama')} (naik ke parent)")
print()
print("=== Mutasi variabel parent lewat set ===")
inner.set("nama", "bahasa-mini-v2")
print(f"global.nama setelah inner.set = {global_env.get('nama')}")
print()
print("=== Closure make_counter ===")
counter = make_counter(0)
for _ in range(3):
print(f"counter() = {counter.call([])}")
print()
print("=== Dua closure dengan tangkapan terpisah ===")
c_a = make_counter(100)
c_b = make_counter(200)
print(f"c_a() = {c_a.call([])}")
print(f"c_b() = {c_b.call([])}")
print(f"c_a() = {c_a.call([])}")
print(f"c_b() = {c_b.call([])}")
if __name__ == "__main__":
main()Demo pertama membangun global_env dengan dua nama, lalu inner yang meng-shadow pi. Pemanggilan inner.get("nama") menunjukkan lookup naik ke parent. Demo kedua menggunakan inner.set untuk mengubah nama di scope parent, dan perubahan terlihat dari global_env. Demo ketiga memanggil make_counter tiga kali, dan nilai current naik dari 0 ke 2. Demo keempat membuat dua closure terpisah sehingga state keduanya tidak saling mengganggu. Keluaran berikut diperoleh dengan menjalankan python3 closure_demo.py.
=== Environment sebagai rantai scope ===
global.pi = 3
inner.pi = 3.14 (shadowing)
inner.nama = bahasa-mini (naik ke parent)
=== Mutasi variabel parent lewat set ===
global.nama setelah inner.set = bahasa-mini-v2
=== Closure make_counter ===
counter() = 0
counter() = 1
counter() = 2
=== Dua closure dengan tangkapan terpisah ===
c_a() = 100
c_b() = 200
c_a() = 101
c_b() = 201Kesalahan umum#
Kesalahan paling sering adalah menangkap Environment yang salah. Bila pembuat closure menangkap Environment pemanggil alih-alih Environment yang dibangun saat fungsi dibuat, setiap pemanggilan akan melihat state pemanggil terbaru, bukan state yang dimaksudkan. Penyebabnya biasanya adalah penggunaan parent=env dengan env yang masih berubah setelah fungsi dibuat. Perbaikannya adalah membekukan Environment tangkapan dengan membuat Environment baru pada saat fungsi dibuat, sehingga rujukan yang disimpan oleh closure tidak berubah.
Kesalahan kedua adalah lupa membedakan define dan set. Bila set selalu menulis pada scope lokal, penugasan pada variabel parent akan secara diam-diam membuat variabel baru di scope lokal, sehingga mutasi tidak terlihat dari luar. Perbaikannya adalah mengikuti pencarian seperti pada get: bila nama ditemukan di parent, mutasi terjadi di parent. Implementasi referensi menggunakan has untuk mendukung keputusan ini tanpa menggandakan logika.
Ringkasan#
- Environment menyimpan pemetaan nama ke nilai pada satu scope, dengan rujukan ke parent. Lookup naik dari lokal ke parent hingga menemukan nama atau gagal.
- Operasi
definemenulis pada scope lokal, sedangkansetmencari nama dan memutakhirkan tempat nama itu ditemukan. Pembedaan ini mencegah penugasan berubah menjadi deklarasi. - Closure adalah pasangan body dan Environment tangkapan. Saat dipanggil, Environment baru dibangun dengan parent berupa tangkapan, lalu argumen diikat ke parameter pada scope baru.
- Dua closure yang dibuat oleh dua pemanggilan berbeda memegang dua tangkapan berbeda, sehingga state keduanya terpisah seperti yang ditunjukkan oleh demo
make_counter.
Referensi#
- Closures — Wikipedia — Penjelasan closure sebagai pasangan fungsi dan lingkungan tangkapan, termasuk contoh pada beberapa bahasa.
- Crafting Interpreters — Robert Nystrom — Bab 10 dan 11 membahas Environment berantai dan resolusi variabel pada interpreter.
- Scope — Wikipedia — Pengantar scope leksikal versus scope dinamis yang menjadi dasar rantai Environment.