Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ১০/১৫অ্যাডভান্সড৫০ মিনিট

Closure — ফাংশন যখন environment সাথে নিয়ে হাঁটে

Closures and Environments

ফাংশন + তার জন্ম-environment-এর আঠালো জোড়াই closure — substitution model-এর ফাঁক গজানো জায়গাটাই এই concept-এর জন্মস্থান, আর loop-bug থেকে React hooks পর্যন্ত হাজার production story-র মূল।

এই লেসন শেষে আপনি পারবেন

  • Substitution model-এর অসম্ভবতা (free variable সমস্যা) দেখিয়ে environment model কেন দরকার তা ব্যাখ্যা করতে পারবেন — lexical scope-এর formal ভিত্তি হিসেবে
  • Closure = (code pointer, captured environment) pair — এই physical layout-টা আঁকতে পারবেন আর C++ lambda, Python cell, JS function object-এ সেই layout চিনতে পারবেন
  • Capture by value বনাম by reference-এর পার্থক্য তিন ভাষায় (C++ [=]/[&], Python late-binding loop bug, JS var/let) trace করে বলতে পারবেন ঠিক কোন mutation কখন দেখা যায়
  • Escaping closure-এর জন্য environment heap-এ যেতে বাধ্য হয় কেন তা runtime-allocation মডেলে দেখাতে পারবেন, আর non-escaping optimization কীভাবে খরচ শূন্য করে বলতে পারবেন
  • formal-semantics-এর arithmetic evaluator-কে extend করে ~90 line-এর lambda-capable environment-passing interpreter লিখে প্রমাণ করতে পারবেন closure snapshot না, live environment ধরে

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

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 ঝুঁকি!
Pythonby reference (cell)আসল binding — late binding bug
JavaScript letby reference (per-iteration binding)loop-এ প্রতি iteration আলাদা binding
JavaScript varby reference (per-function binding)loop-এ একটাই binding — classic bug
Java (lambda)effectively final onlyvalue-like safety via immutability constraint
Rustmove বা borrow, explicit-ishownership 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);          // 15

Compiler ভেতরে যা বানায় (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”।

add10(5) — closure call-এর পূর্ণ জীবন্ত ট্রেস
  1. Call siteadd10(5): closure-টা just a value — তার code ptr আর env ptr register-এ বসে
  2. PrologueIndirect jump to code ptr; hidden env argument সাথেই যায় (register/stack convention-ভেদে)
  3. Bodyx + n: n এখন কোনো name lookup না — env ptr dereference, fixed offset-এ field read
  4. 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 binding

Python — 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 snapshot

C++ — [=] বনাম [&]:

#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)
সারাংশ: closure-এর আচরণ ঠিক করে সে কোন frame/cell-এ আঙুল দিয়েছে — N closure → 1 mutable box হলে bug (সবাই শেষ state), N boxes বা capture-time freeze (default arg, [=]) হলে fix।

নিজে চালিয়ে দেখুন

EXPERIMENT

Late binding autopsy — reproduce, inspect __closure__, fix

Linux/macOS terminal: python3 + node· ১৫ মিনিট

ধাপ ১ — 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.py
bug: [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 $?             # 15

adder-এর 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 তাত্ত্বিক বিষয় না, মাপা যায়।

নিজে বানান

BUILD IT

Lambda-capable interpreter — environment passing, ~90 lines

Python · ●●●○○
  1. Expr AST: Num, Var, Lam(param, body), App(fn, arg), BinOp
  2. Env: dict + parent link; lookup walks the chain
  3. Lam evaluation → Closure(code, env) pair
  4. App: extend env with param binding, eval body — NOT substitution
  5. 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.py
all tests passed — closures carry environments

সবচেয়ে গুরুত্বপূর্ণ দুই লাইন খেয়াল করুন:

  1. isinstance(node, Lam) branch: return Closure(node.p, node.body, env) — lambda evaluate হওয়া মাত্রই current env-এর reference সহ pair তৈরি। এই একটা লাইনই পুরো closure theory।
  2. App branch: 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)।

নিজে বাড়ান:

  1. Set(name, val) form যোগ করুন — nearest-frame assignment (Python-এর nonlocal)। তারপর two-counter test: দুই make-call-এর closure-রা কি state share করে? (উত্তর না — কেন, এক লাইনে লিখুন।)
  2. Y-combinator দিয়ে anonymous recursion চালান: Y = λf.(λx.f (λy.x x y)) (λx.f (λy.x x y)) — parser কি recursion keyword ছাড়াই factorial চালাতে পারল?
  3. 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 ব্যর্থতার ভিত্তিতে।

বুঝেছেন কি না দেখুন

1

Closure-এর দুই 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 ঠিক হয়ে যায়।

2

Python-এ 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।

4

React 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-এ ভিন্ন উত্তর দেয়।

যুক্তি

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-এর প্রামাণ্য নমুনা