Closure — ফাংশন যখন environment সাথে নিয়ে হাঁটে
Closures and Environments
ফাংশন + তার জন্ম-environment-এর আঠালো জোড়াই closure — substitution model-এর ফাঁক গজানো জায়গাটাই এই concept-এর জন্মস্থান, আর loop-bug থেকে React hooks পর্যন্ত হাজার production story-র মূল।
আগে এটা বুঝি
formal-semantics লেসনে আমরা expression-দের meaning দিয়েছিলাম substitution দিয়ে: (λx. x+1) 5 → 5+1 → 6। Argument-কে body-তে copy-paste করে দিলেই হয়ে গেল। সহজ, pure, সুন্দর। আজ সেই জগতে একটা brick ঢুকবে — আর সেই brick-ই এমন এক concept-এর জন্ম দেবে যা ছাড়া modern JavaScript, Python, Rust, Swift, Java — কোনোটাই লেখা যেত না। Brick-টা এই ফাংশনটা:
def make_counter():
count = 0
def step():
nonlocal count
count += 1 # ← এই count-টা step-এর নিজের না!
return count
return step # ← make_counter মরে গেল... তাহলে count কোথায় বাস করে?make_counter() return করলে তার Stack Frame ধ্বংস হওয়ার কথা — runtime-systems লেসনে শিখেছেন। কিন্তু step-এর ভেতরে count-এর reference তখনও জীবিত। Substitution model-এর কাছে প্রশ্নের উত্তর নেই: substitute করব কী, কোথায়? count value না — একটা বাসযোগ্য জায়গা, যেটা mutate-ও হয়।
উত্তরটাই আজকের বিষয়: ফাংশন একা চলে না — code pointer + তার জন্ম-environment-এর আঠালো জোড়া হিসেবে চলে। নাম closure: ফাংশনটা free variable-দের environment-কে “বন্ধ” (close over) করে সাথে নিয়ে হাঁটে। Pair-টা memory-তে কীভাবে বসে, কখন stack থেকে heap-এ পালায়, capture-by-value vs by-reference কোথায় গণিত বদলায় — এই লেসনের গল্প।
মূল ধারণা
Free variable — সমস্যার নাম
λx. x + y
x = bound variable (λ-র নিজের parameter)
y = FREE VARIABLE (body-তে দরকার, কিন্তু কেউ দেয়নি!)Free variable-টার meaning কোথা থেকে আসবে? দুইটা ঐতিহাসিক উত্তর, দুইটাই ব্যর্থ:
উত্তর ১ — global-only: “y মানে global y”। তাহলে make_counter-এর count global হতো — প্রতি counter-এ shared! Nested helper function-গুলো তাহলে caller-এর data access করতে পারে না — abstraction-এর পাঞ্জা ছিঁড়ে ফেলা।
উত্তর ২ — dynamic scoping: “y মানে call-chain-এ যে caller সর্বশেষে y define করেছে”। Implementation সহজ, কিন্তু ভয়ংকর consequence: ফাংশনের meaning নির্ভর করে কে ডাকল তার উপর। Library লেখক হিসেবে আপনার ফাংশন আর predictable না — user-এর একটা variable name-ই আপনার logic বদলে দিতে পারে। Static reasoning, type checking, compiler analysis — সব ভেঙে পড়ে।
উত্তর ৩ (জেতারটা) — lexical scoping + environment: প্রতিটা ফাংশন তার textual (source-code) context-এর environment-এ bind হয় — যেখানে define হলো, সেখানকার, যেখানে call হয় সেখানকার না। আর সেই context-এর environment-টাকে ফাংশন সাথে বয়ে বেড়ায়। এই বহনযোগ্য environment-ই closure।
Environment model — frames আর bindings
Environment = name→value-এর table (frame), parent-এর সাথে chain বাঁধা:
lookup(x): frame-এ x আছে? → ওই value; না? → parent, তার parent...
root-এও না? → UnboundVariable error
extend(env, names, values) → নতুন frame {name:value,...}, parent=env
((λx. body) arg) in env:
new_env = extend(env, [x], [eval(arg, env)])
result = eval(body, new_env)Substitution-এর সাথে পার্থক্য: argument copy-paste হয় না, নতুন frame-এ bind হয় — body ওই frame-এর চোখে evaluate হয়। Mutation (count += 1) এখন meaningful — কোনো frame-এর slot বদলায়, আর সেই frame যতজন চেনে তারাই বদলটা দেখে।
Closure — জোড়াটার anatomy
┌────────────────────────────────┐
│ Closure │
│ ┌─────────────┬─────────────┐ │
│ │ code ptr │ env ptr │ │
│ └─────────────┴─────────────┘ │
└────────────────────────────────┘
calling convention শুধু code ptr জানে;
body-র প্রতিটা free-variable access ঘুরে যায় env ptr দিয়ে।Capture semantics — value না reference?
| ভাষা | Default capture | মানে |
|---|---|---|
C++ [=] | by value | তৈরির সময় copy — snapshot; পরের mutation invisible |
C++ [&] | by reference | আসল variable — lifetime ঝুঁকি! |
| Python | by reference (cell) | আসল binding — late binding bug |
JavaScript let | by reference (per-iteration binding) | loop-এ প্রতি iteration আলাদা binding |
JavaScript var | by reference (per-function binding) | loop-এ একটাই binding — classic bug |
| Java (lambda) | effectively final only | value-like safety via immutability constraint |
| Rust | move বা borrow, explicit-ish | ownership rules apply — checker-verified |
দেখুন পুরো spectrum-টা আসলে একটা single question-এর উত্তর: “captured binding-টা কি আসল জায়গাটা (reference), না তার একটা ছবি (value)?” — আর ভাষাগুলো সেফটি-বনাম-ফ্লেক্সিবিলিটি দরদামে ভিন্ন জায়গায় দাঁড়িয়ে।
Escaping — কখন environment heap-এ যায়
non-escaping: callback শুধু নিজের enclosing call-এর ভেতরেই ডাকা হয়
→ environment caller-এর stack frame-এই থাকতে পারে — খরচ ~0
escaping: closure বাইরে বেরোয় (return/store/pass to thread)
→ creator-এর frame মরবে, কিন্তু closure বাঁচবে!
→ captured variables HEAP-এ সরাতে হবে — GC/Rc/ownership
এখানে এসে মেশে (runtime-systems + memory-models payoff!)Escape analysis করে compiler decide করে। Rust-এ closures-এর তিন trait-ই এই escape-কথার ভাষা: Fn (many calls, read-only borrow), FnMut (mutable borrow), FnOnce (captures consume করে — move out)। Java-তে historically non-escaping inner class-ই শুধু possible ছিল (anonymous class), lambda-র পরে escaping সহজ হয়েছে — দাম সবসময় allocation।
ভেতরে কী ঘটছে
Desugaring — closure আসলে compiler কী বানায়
Closure কোনো magic primitive না — syntactic sugar একটা struct + function-এর উপর। Compiler-এর পুরো কাজ (closure conversion / lambda lifting):
// আপনি লিখলেন:
function makeAdder(n) {
return x => x + n;
}
const add10 = makeAdder(10);
add10(5); // 15Compiler ভেতরে যা বানায় (conceptual desugar):
struct AdderEnv { int n; } // captured vars-এর struct
int adder_code(void *env_ptr, int x) {
return x + ((AdderEnv*)env_ptr)->n; // free var access → field access!
}
add10 = { code: adder_code, env: malloc(AdderEnv{10}) }
add10(5) ⇒ adder_code(add10.env, 5) ⇒ 5 + 10দেখুন — কোনো mystery না: free variable access হয়ে গেল env struct-এর field access, call হয়ে গেল indirect call with hidden first argument। এই hidden argument-টাই কিছু implementation-এ explicit (Python-এর self-এর মতো, CPython bytecode-এ LOAD_DEREF), কিছুতে ABI-তে লুকানো।
Python-এর cell — late-binding-এর যন্ত্র
CPython-এ nested function-এর captured local-গুলো বাস করে cell object-এ — variable আর value-র মাঝে একটা indirection box:
make_counter-এর frame:
'count' ──► Cell{ contents: 0 } ◄── দুইজনের shared জায়গা
▲ │
step.__closure__[0] │
│ │
step's LOAD_DEREF STORE_DEREF (count += 1)
del/make_counter-return-এর পরে: cell-টা refcount>0 (step ধরে আছে)
→ heap-এই বেঁচে থাকে → counter কাজ করেই যায়।আর loop-late-binding bug-এর জন্ম এখানেই: loop variable-টা যদি সব iteration-এ একটাই cell হয়, closure-রা সবাই একই box দেখে — শেষ value!
JS var vs let — machine level
for (var i...) → i বাস করে FUNCTION scope-এ: একটাই binding,
সব timeout-closure একই i দেখে → সবাই শেষ মান
for (let i...) → spec বলে প্রতি iteration-এ fresh binding:
engine (V8) internally প্রতি iter আলাদা context
allocate করে — closure রা নিজের iteration-এরটা ধরেDefunctionalization — এক ধাপ আরও নিচে
Interpreter/compiler theory-র শেষ trick: higher-order functions-কে data + dispatch loop-এ রূপান্তর। প্রতিটা closure-এর জায়গায় একটা tagged union {variant, captured} আর একটা central apply(frame) switch — first-class function নেই, শুধু first-class data। Embedded interpreters, continuation-passing compilers, আর আজকের build-এর evaluator-এর ভেতরে ঠিক এই রূপান্তরের আভাস আছে — “function as value” শেষ পর্যন্ত “pointer pair as value”।
- Call siteadd10(5): closure-টা just a value — তার code ptr আর env ptr register-এ বসে
- PrologueIndirect jump to code ptr; hidden env argument সাথেই যায় (register/stack convention-ভেদে)
- Bodyx + n: n এখন কোনো name lookup না — env ptr dereference, fixed offset-এ field read
- Return15 ফেরত; env এখনো জীবিত (add10 ধরে আছে) — পরের call-এর জন্য ready
উদাহরণ
একই bug, তিন ভাষা — capture semantics-এর প্রমাণ
JavaScript — var বনাম let:
var fs = [];
for (var i = 0; i < 3; i++) fs.push(() => i);
console.log(fs.map(f => f())); // [3, 3, 3] ← তিনজনই একই i!
let gs = [];
for (let j = 0; j < 3; j++) gs.push(() => j);
console.log(gs.map(g => g())); // [0, 1, 2] ← প্রতি iteration fresh bindingPython — late binding:
fns = [lambda: i for i in range(3)] # তিনটা lambda, একটাই i-cell
print([f() for f in fns]) # [2, 2, 2] ← সবাই শেষ মান!
gns = [lambda i=i: i for i in range(3)] # default arg = capture-time eval
print([f() for f in gns]) # [0, 1, 2] ← fix: per-call snapshotC++ — [=] বনাম [&]:
#include <vector>
#include <functional>
#include <cstdio>
std::vector<std::function<int()>> byVal(), byRef();
auto build() {
int counter = 0;
std::vector<std::function<int()>> v;
v.push_back([=]() { return counter; }); // copy at creation → 42?
v.push_back([&]() { return counter; }); // reference → live!
counter = 99; // capture-এর পরে mutate
return v; // ⚠ [&] wala dangling!
}
int main() {
auto v = build();
printf("%d\n", v[0]()); // 0 ([=]-এ capture হয়েছিল 0-এর সময়)
printf("%d\n", v[1]()); // undefined behavior! counter-এর frame মরে গেছে
}তিনটা ভাষা, একই মূল ঘটনা: closure ধরে binding, value না — তাই capture-এর পরের mutation দৃশ্যমান (JS let, Python cell), আর creator-এর মৃত্যুর পরে reference-capture বিপজ্জনক (C++ [&])। ভাষাগুলোর পার্থক্য শুধু কোন বিপদটা default করে দিয়েছে।
ছবিতে
Loop-এ closure — এক binding বনাম N binding
Python/JS-var pattern (late binding):
iteration 0,1,2 সব একই frame/cell:
frame: i ──► Cell{ 0→1→2 ... শেষে 2 }
▲ ▲ ▲
fn[0] ─────── fn[1] ─── fn[2] (তিনটা closure,
একটাই destination)
call সময়: সবাই পড়ে 2 → [2, 2, 2]JS-let pattern (fresh binding প্রতি iteration):
iter0: j0 ──► Cell{0} ◄── fn[0]
iter1: j1 ──► Cell{1} ◄── fn[1]
iter2: j2 ──► Cell{2} ◄── fn[2]
call সময়: [0, 1, 2]Closure pair-এর memory map — কোথায় কী থাকে:
stack (make_counter চলাকালে) heap (escaping হলে)
┌────────────────────┐
│ make_counter frame │ ┌───────────────────────┐
│ count ► cell{0} ──┼────────────►│ Cell{ count: 1 } │◄─┐
└────────────────────┘ ├───────────────────────┤ │
frame dies on return, │ closure: │ │
but heap-এর অর্ধটা বাঁচে: │ code: step_impl │ │
│ env : ●──────────────┼──┘
└───────────────────────┘
(GC/refcount/owner ঠিক করবে
এই env কখন মরবে — lesson 6-9 payoff)নিজে চালিয়ে দেখুন
Late binding autopsy — reproduce, inspect __closure__, fix
ধাপ ১ — Python: bug, cell-inspection, fix:
# late_binding.py
fns = [lambda: i for i in range(3)]
print("bug:", [f() for f in fns]) # [2, 2, 2]
cells = {id(f.__closure__[0]) for f in fns}
print("distinct cells:", len(cells)) # 1 — সবাই এক box!
fixed = [lambda i=i: i for i in range(3)]
print("fix:", [f() for f in fixed]) # [0, 1, 2]
print("distinct defaults:",
len({f.__defaults__ for f in fixed})) # 3 — প্রতি closure-এ আলাদা value
# প্রমাণ যে snapshot না, live environment:
def make():
n = 10
def get(): return n
def bump(): nonlocal n; n += 1
return get, bump
get, bump = make()
print(get()) # 10
bump(); bump()
print(get()) # 12 — get-এর দেখা n-টাই বদলে গেল: live binding!python3 late_binding.pybug: [2, 2, 2]
distinct cells: 1
fix: [0, 1, 2]
distinct defaults: 3
10
12__closure__[0]-এর identity দেখুন — তিন lambda-র cell একই object (len == 1), অর্থাৎ late-binding শুধু আচরণ-observation না, memory-layout-এর সত্য। আর শেষ চার লাইন দেখাচ্ছে closure-এর শক্তিটাই: get আর bump একই environment share করে।
ধাপ ২ — Node: var/let:
node -e '
const fs=[], gs=[];
for (var i=0; i<3; i++) fs.push(()=>i);
for (let j=0; j<3; j++) gs.push(()=>j);
setTimeout(()=>console.log("var:", fs.map(f=>f())), 50); // var: [3,3,3]
setTimeout(()=>console.log("let:", gs.map(g=>g())), 60); // let: [0,1,2]
'setTimeout ইচ্ছাকৃতভাবে দিলাম — event-loop-এ ফেরার সময় loop বহু আগেই শেষ, অর্থাৎ var-এর shared i তখন ৩। এটা ঠিক সেই production shape যেটা প্রতি junior JS developer-কে একবার কাঁদিয়েছে।
ধাপ ৩ — C-তে closure-এর কঙ্কাল (gcc -S):
/* lam.c — lambda-like pair ফেরত দেওয়া */
typedef struct { int (*code)(void*, int); void *env; } Closure;
static int adder(void *env, int x) {
return x + *(int *)env; /* captured n = env deref */
}
static Closure make(int n) {
static int store; store = n; /* demo: real version heap-allocates */
return (Closure){ .code = adder, .env = &store };
}
int main(void) {
Closure c = make(10);
return c.code(c.env, 5); /* 15 → exit code */
}gcc -O1 -S -o - lam.c | sed -n '/adder:/,/ret/p' # adder-এর asm দেখুন
gcc -O1 -o lam lam.c && ./lam; echo $? # 15adder-এর assembly-তে দেখবেন captured n-এর কোনো constant নেই — শুধু env pointer dereference (mov (%rdi), %eax জাতীয়)। এটাই closure conversion-এর machine-level চেহারা: free variable = hidden struct field।
Python-এর list-of-lambda-র সব সদস্য একই cell share করে (identity check দিয়ে প্রমাণ), default-argument fix প্রতি closure-এ আলাদা binding বানায়, আর JS-এ var/let-এর পার্থক্য runtime-এ সরাসরি দেখা যায় — capture semantics তাত্ত্বিক বিষয় না, মাপা যায়।
নিজে বানান
Lambda-capable interpreter — environment passing, ~90 lines
- Expr AST: Num, Var, Lam(param, body), App(fn, arg), BinOp
- Env: dict + parent link; lookup walks the chain
- Lam evaluation → Closure(code, env) pair
- App: extend env with param binding, eval body — NOT substitution
- Test: closure captures live environment (shared mutable counter)
formal-semantics লেসনের arithmetic evaluator মনে আছে? সেটা substitution-based ছিল। এখন environment-passing-এ পুরোটা লিখি — প্লাস lambda আর closure:
"""mini_lambda.py — environment-passing interpreter with closures"""
import re
from dataclasses import dataclass
# ---------- AST ----------
@dataclass
class Num: v: int
@dataclass
class Var: name: str
@dataclass
class Lam: p: str; body: object
@dataclass
class App: f: object; a: object
@dataclass
class BinOp: op: str; l: object; r: object
# ---------- Parser (tiny recursive descent) ----------
TOKEN = re.compile(r"\s*(lambda|[a-zA-Z_]\w*|\d+|[()+\-*/.]|\\)")
def tokenize(s):
out, i = [], 0
while s[i:]:
m = TOKEN.match(s, i)
if not m: raise SyntaxError(f"bad char at {s[i:]!r}")
out.append(m.group(1)); i = m.end()
return out
def parse(src):
toks = tokenize(src); pos = [0]
def peek(): return toks[pos[0]] if pos[0] < len(toks) else None
def eat(t=None):
tok = toks[pos[0]]; pos[0] += 1
if t and tok != t: raise SyntaxError(f"want {t}, got {tok}")
return tok
def expr():
if peek() in ("lambda", "\\"):
eat(); p = eat(); eat(".")
return Lam(p, expr())
return add_()
def atom():
t = peek()
if t == "(":
eat(); e = expr(); eat(")")
return e
if t.isdigit(): return Num(int(eat()))
return Var(eat())
def app():
f = atom()
while True: # juxtaposition = application
t = peek()
if t and t not in ("lambda", "\\") and \
(t == "(" or t.isdigit() or t[0].isalpha()):
f = App(f, atom())
else: break
return f
def mul_():
e = app()
while peek() in ("*", "/"):
e = BinOp(eat(), e, app())
return e
def add_():
e = mul_()
while peek() in ("+", "-"):
e = BinOp(eat(), e, mul_())
return e
ast = expr()
if pos[0] != len(toks): raise SyntaxError("trailing input")
return ast
# ---------- Environment ----------
class Env:
__slots__ = ("names", "parent")
def __init__(self, names, parent):
self.names, self.parent = names, parent
def get(self, k):
e = self
while e:
if k in e.names: return e.names[k]
e = e.parent
raise NameError(k)
# ---------- Closure + evaluator ----------
class Closure:
__slots__ = ("param", "body", "env") # THE pair!
def __init__(self, param, body, env):
self.param, self.body, self.env = param, body, env
def ev(node, env):
if isinstance(node, Closure): return node # closures are values too
if isinstance(node, Num): return node.v
if isinstance(node, Var): return env.get(node.name)
if isinstance(node, Lam): return Closure(node.p, node.body, env)
if isinstance(node, BinOp):
l, r = ev(node.l, env), ev(node.r, env)
return {"+" : l + r, "-": l - r, "*": l * r, "/": l // r}[node.op]
if isinstance(node, App):
f = ev(node.f, env); a = ev(node.a, env)
return ev(f.body, Env({f.param: a}, f.env)) # extend, not substitute!
raise TypeError(node)
# ---------- Tests ----------
run = lambda src: ev(parse(src), None)
assert run("(lambda x. x + 1) 41") == 42
assert run("(lambda x. (lambda y. x + y) 3) 4") == 7 # nested closure
assert run("((lambda a. lambda b. a * b) 6) 7") == 42 # curried application
# ---- THE test: live environment, not snapshot ----
def demo_live_capture():
env = Env({"x": 10}, None)
clo = ev(parse("lambda d. x"), env) # captures env by reference
env.names["x"] = 99 # later mutation
return ev(App(clo, Num(0)), None) == 99 # closure sees NEW value
assert demo_live_capture(), "closure must capture ENVIRONMENT, not snapshot!"
print("all tests passed — closures carry environments")python3 mini_lambda.pyall tests passed — closures carry environmentsসবচেয়ে গুরুত্বপূর্ণ দুই লাইন খেয়াল করুন:
isinstance(node, Lam)branch:return Closure(node.p, node.body, env)— lambda evaluate হওয়া মাত্রই currentenv-এর reference সহ pair তৈরি। এই একটা লাইনই পুরো closure theory।Appbranch:ev(f.body, Env({f.param: a}, f.env))— body চলে closure-এর জন্ম-env-কে parent করে। Call-site-এর env না — lexical scoping-এর প্রোগ্রামাতিক সংজ্ঞা ঠিক এই লাইনে।
আর demo_live_capture-test-টাই প্রমাণ: env-এর x mutate করলে closure নতুন value দেখে — snapshot না, live environment (Python-এর cell, C-এর env-pointer — একই সত্যের দুই dialect)।
নিজে বাড়ান:
Set(name, val)form যোগ করুন — nearest-frame assignment (Python-এরnonlocal)। তারপর two-counter test: দুইmake-call-এর closure-রা কি state share করে? (উত্তর না — কেন, এক লাইনে লিখুন।)- Y-combinator দিয়ে anonymous recursion চালান:
Y = λf.(λx.f (λy.x x y)) (λx.f (λy.x x y))— parser কি recursion keyword ছাড়াই factorial চালাতে পারল? - Defunctionalize:
Closure-কে tagged tuple করে আরapply-কে central dispatch করে একই interpreter লিখুন — first-class function মুছে গেল, first-class data-ই রইল।
বাস্তব সিস্টেমে
Closure যেখানে production-কে চালায়
Event-driven UI আর web। Browser-এ প্রতিটা event handler একটা closure — button click-এর callback তার surrounding state ধরে রাখে। React-এর useState hook-এর পুরো mental model closure-based: render-scope-এর state-এর উপর বন্ধ। Stale-closure bug React-এর সবচেয়ে পরিচিত footgun — ঠিক আজকের late-binding family-রই member।
Iterators/generators সব ভাষায়। Python generator, JS iterator, Rust iterator adapters, Java Stream — প্রতিটাই internally একটা closure যার environment-এ iteration-state (cursor, accumulator) বাস করে। map/filter/reduce-এর fluent chains closure-ই বহন করে।
Callbacks আর async। Node.js-এর পুরো পৃথিবী, Go-র http.HandlerFunc(func(w,r){...}), C-এর qsort-এর comparator (function pointer + optional arg = manual closure!) — pthread_create-এর context void*-টা আসলে hand-rolled closure environment।
Performance-sensitive design। Rust iterator chain zero-cost কারণ non-escaping closures inline হয়ে যায় — capture fields stack-এ, কোনো allocation নেই। Java lambda invokedynamic-এর পেছনে LambdaMetafactory runtime-এ class spin করে; capturing lambda-ই allocation দেয়। Hot path-এ closure = allocation কি না — এই প্রশ্নটা profile পড়ার আগেই মাথায় রাখা চাই।
History anchor: funarg problem (1960s Lisp) — upward funarg-এ Lisp 1.5 dynamic scope ভেঙে পড়ায় Scheme (Sussman-Steele 1975) lexical scope + closure standard করল; SML/Haskell formalized; mainstream জিতল 2000s-এ (JS, Python, Ruby, পরে Java 8, C++11)। আপনি যে callback আজ লিখলেন, তার ভিত্তি ৫০ বছরের তত্ত্ব।
যে ভুলগুলো সবাই করে
“Closure variable-এর মান ধরে রাখে (snapshot নেয়)।”
Snapshot হলে counter কাজ করত না — প্রতি call-এ পুরনো মানেই ফেরত যেত। Closure ধরে binding/jayga — mutable cell বা env slot। তাই capture-এর পরের mutation দেখা যায় (Python cell, JS let, C++ [&])। Snapshot-behavior চাইলে explicit করতে হয়: C++ [=], Python default-arg trick, JS-এর IIFE-copy।
“var আর let-এর পার্থক্য শুধু style/hair-splitting।”
Machine level-এ পার্থক্য structural: var = per-function binding (loop-এর সব closure একই slot-এ), let = per-iteration fresh binding (spec-mandated)। একই কোড দুই keyword-এ দুই output — experiment-এ দেখা [3,3,3] বনাম [0,1,2]। Style debate না, scope-semantics debate।
“Closure মানেই ভারী heap object, তাই performance কোডে lambda এড়ানো উচিত।”
Non-escaping closure প্রায়ই free — compiler environment-টা caller-এর frame-এ রাখে, inline করে দেয় (Rust iterator-রা production-এ hand-written loop-এর সমান codegen)। খরচ তখনই, যখন escape করে: env heap-এ যায়, GC/refcount/ownership machinery জুড়ে। প্রশ্ন “lambda না loop” না — “এই closure escape করে কি?”
“Dynamic scoping-ও একটা valid alternative — শুধু taste-এর পার্থক্য।”
Taste না, correctness property: dynamic scope-এ ফাংশনের meaning call-context-এর উপর নির্ভরশীল — library function আর locally-reasonable করা অসম্পূর্ণ, compiler analysis (type-checking free vars, dead-code elimination) ভেঙে পড়ে। এজন্যই প্রায় সব modern ভাষা lexical; exceptions shell-variable আর কিছু DSL config-এ সীমাবদ্ধ। ইতিহাস সিদ্ধান্তটা নিয়েছে — empirical ব্যর্থতার ভিত্তিতে।
বুঝেছেন কি না দেখুন
1Closure-এর দুই component কী, আর free variable access compile-এ কীসে রূপান্তরিত হয়?
স্মরণ
Component: (১) code pointer (body-র compiled/interpreted form), (২) environment reference (captured bindings-এর frame/cell/struct)।
Access: free variable-এর প্রতিটা use হয়ে যায় env-dereference + fixed-offset field read — Python-এ LOAD_DEREF, C-implementation-এ *(type*)((char*)env + off)। Name-lookup runtime-এ আর হয় না — capture time-এই position ঠিক হয়ে যায়।
2Python-এ fns = [lambda: i for i in range(3)] কেন [2,2,2] দেয়, অথচ gns = [lambda i=i: i for i in range(3)] দেয় [0,1,2]? Fix-টা ঠিক capture-এর কোন dimension বদলায়?
যুক্তি
fns = [lambda: i for i in range(3)] কেন [2,2,2] দেয়, অথচ gns = [lambda i=i: i for i in range(3)] দেয় [0,1,2]? Fix-টা ঠিক capture-এর কোন dimension বদলায়?List-comprehension-এ i-র একটাই binding — তিন lambda-র __closure__-এ একই cell object (experiment-এ identity দিয়ে প্রমাণিত)। Loop শেষে cell-এর content 2, তাই সব call 2।
Default-arg version-এ capture mechanism-টাই বদলায়: i=i evaluation happens definition-time, per-call — মানটা closure-এর নয়, function-object-এর __defaults__-এ copy হয় (তিনটা আলাদা)। Body-র i এখন local parameter, cell-access না। অর্থাৎ live-shared-binding → frozen-per-function-value।
3একটা closure যদি কোনো collection-এ store হয়ে function return-এর পরেও ব্যবহৃত হয়, captured variable-দের ভাগ্য কী হবে — tracing GC ভাষায়, refcounting ভাষায়, Rust ownership-এ? তিন case-ই বলুন।
প্রয়োগ
Escaping closure মানে env-এর captured variables heap-resident — তখন:
Tracing GC: env object-টা closure-এর কাছ থেকে reachable — root chain পৌঁছালে survive করবে, creator-frame মরার কোনো প্রভাব নেই।
Refcounting: env-এর count-এ closure-এর reference যোগ হয়েছে — creator মরলে count কমবে কিন্তু শূন্যে নামবে না (closure ধরে আছে)। তবে env-এর ভেতরে cycle গড়ালে লেসন-৮-এর ফাঁদ।
Rust: captured-by-move হলে ownership closure-এ transfer — env-এর data closure-এর lifetime-এ বাঁচে; borrow হলে borrow-checker আগেই আটকাত (creator-এর data return-এর পরে বাঁচে না) — তাই escaping borrow-compile হয় না, move বাধ্যতামূলক বা clone।
4React team stale-closure bug ঠেকাতে বলল: hook-এর state-read সবসময় fresh হতে হবে, কিন্তু render-closure-এর ergonomics রাখতে হবে। Capture semantics-এর কোন দুই design-lever এখানে টানা যায় — trade-off সহ?
ডিজাইন
Lever ১ — fresh-binding per invocation (JS-let style): প্রতি render-এ handler নতুন closure পায় নতুন state-binding-এ। Ergonomics ভাঙে না, কিন্তু প্রতি render-এ function-identity বদলায় → dependency-array churn, memoization invalidation cost।
Lever ২ — indirect cell access (ref-style): closure stable থাকে, পড়ে mutable container-এর ভেতর থেকে (useRef pattern)। Function identity stable — memoization-friendly; কিন্তু ergonomics ভাঙে (.current ceremony) আর re-render trigger-এর গ্যারান্টি হারায় (mutation invisible)।
React বাস্তবে দুটোই মেশায়: state reads fresh-render-bound, refs escape hatch। Design-lesson: capture semantics API-র user-facing behavior — implementation detail না।
5আপনার interpreter-এর App branch-এ যদি ev(f.body, Env({f.param: a}, env)) লিখতেন (closure-এর env-এর বদলে call-site-এর env) — ঠিক কোন scoping model implement হতো? একটা concrete program দিয়ে দেখান যেটা দুই model-এ ভিন্ন উত্তর দেয়।
যুক্তি
App branch-এ যদি ev(f.body, Env({f.param: a}, env)) লিখতেন (closure-এর env-এর বদলে call-site-এর env) — ঠিক কোন scoping model implement হতো? একটা concrete program দিয়ে দেখান যেটা দুই model-এ ভিন্ন উত্তর দেয়।Dynamic scoping implement হতো — body চলত ডাককারীর context-এ।
Counter-example: (lambda x. (lambda y. x) 100) জাতীয় nested case-এর বদলে পরিষ্কারটা: program = ((lambda f. f 1) (lambda z. x)) যেখানে top-level-এ x=999, কিন্তু একটা intermediate scope-এ x=7:
(lambda x. ((lambda f. f 0) (lambda z. x))) 7 — lexical-এ inner lambda-র x তার জন্ম-env-এর 7; dynamic-এ call-chain-এ সর্বশেষ x-binding খোঁজে (f-এর body-তে থাকলে 999-type pollution)। মানে: same source, different answer — আর এটাই dynamic scoping-এর fatal flaw: function-এর meaning caller-dependent।
এরপর কী
এরপর কী
আজ দেখলাম ফাংশন কীভাবে environment বয়ে বেড়ায়। কিন্তু একটা প্রশ্ন ইচ্ছা করে এড়িয়ে গেছি: argument pass করার সময় আসলে কী transfer হয়? C-তে মান কপি হয়, Python-এ object-reference কপি হয়, Java-তে “pass-by-value-তে references” — আর Haskell-এ তো argument তখনই evaluate হয় না যখন pass হয়, যখন দরকার তখন! Call-by-value, call-by-reference, call-by-sharing, call-by-name, call-by-need — evaluation strategies-এর এই taxonomy ঠিক করে দেয় কোন ভাষায় mutation visible, aliasing কোথায় জন্মায়, laziness কখন performance-এর বন্ধু আর space-leak-এর শত্রু। Formal semantics-এর β-reduction থেকে Python-এর notorious mutable-default-argument gotcha পর্যন্ত — পরের লেসনে।
পরের লেসন: Evaluation Strategies — call-by-value, call-by-name, call-by-need।
আরও পড়ুন
- Structure and Interpretation of Computer Programs, 2nd ed., section 3.2 — The Environment Model of Evaluation — Harold Abelson, Gerald Jay Sussman, Julie Sussman · Environment model-এর canonical pedagogical treatment — এই লেসনের conceptual backbone
- SCHEME: An Interpreter for Extended Lambda Calculus — Gerald J. Sussman, Guy L. Steele Jr. · AI Memo 349 (1975) — closure-কে first-class করে তোলা ভাষার জন্মপত্র, 'funarg problem'-এর সমাধান
- The Definition of Standard ML — Revised — Robin Milner, Mads Tofte, Robert Harper, David MacQueen · Lexical scope আর static binding-এর formal definition-এর প্রামাণ্য নমুনা