Evaluation Strategy — strict না lazy: expression কখন মূল্যায়িত হয়
Evaluation Strategies: Strict vs Lazy
f(x, expensive())-এর দ্বিতীয় argument কখন গণনা হয় — ভাষা ভেদে উত্তর তিন রকম; strict/lazy-র এই চুক্তিটাই infinite data structure, space leak আর Python-এর mutable-default bug-এর মূল উৎস।
আগে এটা বুঝি
formal-semantics লেসনে আপনি নিজের হাতে একটা evaluator লিখেছিলেন — environment,
substitution, ধাপে ধাপে reduction। সেই evaluator-এ একটা সিদ্ধান্ত আমরা নিয়েছিলাম
প্রায় অনৈচ্ছিকভাবে: application দেখলে আগে argument গণনা করি, তারপর body
চালাই। মনে আছে সেই লাইনটা? Argument-এর eval call — body-র আগে।
কিন্তু থামুন। এই সিদ্ধান্তটা কি বাধ্যতামূলক ছিল? Body-তে x-এর কোনো ব্যবহারই
নেই। তাহলে fibonacci 30 গণনা করাটা কি প্রোগ্রামের অর্থের দাবি, নাকি
আমাদের evaluator-এর নিজের পছন্দ? গণিতে f(x) = 42 হলে f(যা-ই-হোক)-এর
মান একটাই। অথচ Python, C, Java, JavaScript — আপনার চেনা প্রায় সব ভাষা সেটা
কষবে। কেন?
উত্তরটা একটা নামে পরিচিত: evaluation strategy — কোন expression কখন মূল্যায়িত হবে তার ভাষাগত চুক্তি। আর এই মনে হওয়া “অকারণের তাত্ত্বিক তফাত”টা বাস্তবে ফুঁসে ওঠে: Python-এর mutable default argument bug, Haskell-এর অসীম list, আর lazy ভাষার space leak — সবগুলোর জন্মই এই একটা সিদ্ধান্তের পরিণতি।
আজকের প্রশ্ন তিনটা: (১) argument গণনার দায় কবে কার — caller-এর না callee-র? (২) “এখনো গণনা করব না” বলা সিদ্ধান্তটা রানটাইমে ঠিক কী জিনিস হয়ে বেঁচে থাকে? (৩) গণনা পিছিয়ে রাখলে কী পাওয়া যায়, আর কী হারায়?
মূল ধারণা
তিনটা চুক্তি
Expression (λx. B) A — মানে function λx.B-কে argument A দিয়ে ডাকা।
A-কে বলি argument expression, B-কে body। তিনটা strategy তিন
জায়গায় ভিন্ন সিদ্ধান্ত নেয়:
| Strategy | Argument কখন গণনা হয় | একই argument কতবার | কোথায় দেখেন |
|---|---|---|---|
| Call-by-value (CBV) | Body-র আগে, ঠিক একবার | ঠিক একবার | Python, C, Java, JavaScript, ML |
| Call-by-name (CBN) | Body-র ভেতরে, প্রতি ব্যবহারে | প্রতি occurrence-এ | Algol 60 (ঐতিহাসিক), macro expansion |
| Call-by-need | প্রথম ব্যবহারে একবার, তারপর cached | ঠিক একবার | Haskell, GHC |
Call-by-value-কে বলে strict evaluation — argument-এর মান জানা ছাড়া function-কে ঢোকানো যায় না। বাকি দুটো non-strict — argument অমীমাংসিত expression হয়ে ভেতরে ঢুকতে পারে।
Call-by-value — predictable-এর দাম
CBV-র operational semantics formal-semantics লেসনের সেই নিয়মই:
eval(App(f, a)):
fv = eval(f) # function-এর মান বের করো
av = eval(a) # ← argument FIRST, ঠিক একবার
return eval(body_of_f, env + {param: av})সুবিধাগুলো বাস্তব প্রকৌশলের: ordering নির্দিষ্ট (f(g(), h())-এ side
effect-এর ক্রম অনুমানযোগ্য), খরচ দৃশ্যমান (argument ঠিক একবারই কষে —
profiler-এর সংখ্যা বিশ্বাসযোগ্য), আর hardware-সঙ্গত — calling convention
লেসনে দেখেছেন, register/stack-এ argument-এর মানই বসে।
দামটা উপরের উদাহরণেই: (λx. 42) (fibonacci 30) — ফল ৪২, তবু সেকেন্ডখানেকের
গণনা। Strict ভাষা এই অপচয় স্বীকার করে নেয়, কারণ যেকোনো expression-এর
side effect থাকতে পারে (log, I/O, exception) — গণনা বাদ দিলে সেই effect-ও
মিস হতো, প্রোগ্রামের আচরণই বদলে যেত।
Call-by-name — গণনাটা নিজেই ঢুকুক
CBN argument গণনা না করে expression হিসেবেই body-তে substitute করে — ঠিক গণিতের β-reduction-এর মতো:
(λx. x + x) (fib 30)
→ (fib 30) + (fib 30) # substitute শেষ — এখনো কিছুই গণনা হয়নি!
→ ... প্রতিটা occurrence নিজে নিজে গণনা হতে থাকে ...(λx. 42) (fib 30) এখন instant ৪২ — অপচয় শূন্য। কিন্তু নতুন সমস্যা: x
body-তে তিনবার থাকলে fib 30 তিনবার কষবে। Substitution মানে work-এর
duplication — এটাই CBN-এর বাস্তব ভাষা হিসেবে মৃত্যুর কারণ।
Call-by-need — একবার কষো, মনে রাখো
Call-by-need (সংক্ষেপে laziness) সেই duplication-টা একটা কৌশলে ঠিক করে:
substitute না করে argument-expression-টাকে মোড়ানো অবস্থায় environment-এ রাখো;
প্রথম ব্যবহারে একবার গণনা করে ফল ওই মোড়ানো জিনিসটার ভেতরেই লিখে রাখো।
এই মোড়ানো জিনিসটার নামই <Term id="thunk">Thunk</Term>:
x → ⟨Thunk: expr = fib 30, cached=false⟩ # এখনো ঘুমাচ্ছে
প্রথম x: force → 832040, cached=true # একবার কষল
দ্বিতীয় x: cached → 832040 # ফ্রিতিনটার তুলনা এক বাক্যে: CBV আগে কষে, CBN প্রতিবার কষে, call-by-need প্রথম ব্যবহারে একবার কষে।
Selective strictness — short-circuit
Strict/non-strict কোনো ভাষার all-or-nothing বৈশিষ্ট্য না — প্রায় সব strict ভাষায় কয়েকটা operator সচেতনভাবে non-strict:
user = None
name = user and user.name # user None হলে দ্বিতীয় পা আর চলেই না
label = custom or "default" # custom truthy হলে fallback আর মূল্যায়িত হয় না
ok = x != 0 and 100 / x > 5 # division guard — crash এড়ানোর কৌশলFormal ভাষায়: a and b আসলে if a then b else False-এর sugar — এখানে b-র
position non-strict: প্রথম operand falsy হলে দ্বিতীয়টা touch-ই হয় না।
Haskell এটাকে ordinary equation দিয়ে সংজ্ঞায়িত করে, special mechanics ছাড়াই:
(&&) :: Bool -> Bool -> Bool
False && _ = False -- দ্বিতীয় argument না দেখেই উত্তর!
True && x = xপ্রথম সারিটাই প্রমাণ — pattern False && _-এর _ মানে দ্বিতীয় argument
কখনো force হয় না। Laziness এখানে নতুন machinery না, শুধু definition-এর
স্বাভাবিক ফল। Python-এ একটা সূক্ষ্মতা মনে রাখুন: and/or bool ফেরত দেয়
না, operand-ই ফেরত দেয় — 0 or "hi" হলো "hi", 3 or "hi" হলো 3।
Fallback-idiom এই value-returning non-strictness-এর উপরই দাঁড়িয়ে।
ভেতরে কী ঘটছে
Python-এর default argument — strictness-এর একটা ফুটন্ত প্রমাণ
Python strict। কিন্তু একটা rule অনেকে খেয়াল করে না: default expression
গণনা হয় def execute হওয়ার মুহূর্তে — ঠিক একবার, definition টাইমে —
call-টাইমে না। ফলে গণনার object-টা সব call শেয়ার করে, mutable হলে বিপদ:
def append_to(item, bucket=[]): # একটাই list, জন্ম def-এর সময়
bucket.append(item)
return bucket
append_to(1) # [1]
append_to(2) # [1, 2] ← নতুন list প্রত্যাশা, পুরনোটাই ফিরল!
append_to(3) # [1, 2, 3]
append_to.__defaults__ # ([1, 2, 3],) ← প্রমাণ: function object-এর
# ভেতরে বসে আছে, জীবিত, বাড়ছেএটা Python bug না — strict evaluation আর “default একবার গণনা হয়” চুক্তির সরাসরি পরিণতি। Expression কখন evaluate হয় — এই লেসনের প্রশ্নটা এখানে এসে production bug হয়ে দাঁড়ায়। Canonical fix:
def append_to(item, bucket=None):
if bucket is None:
bucket = [] # গণনা call-টাইমে — প্রতিবার তাজা
bucket.append(item)
return bucketEffects আর ordering — laziness-এর আসল মাথাব্যথা
Strict ভাষায় statement-এর লেখার ক্রমই execution-এর ক্রম। Laziness সেই নিশ্চয়তা তুলে নেয়: আগে লেখা expression-এর গণনা পরে, অন্য জায়গায়, বা কখনোই নাও হতে পারে। Haskell-এ type system নিজেই “putStrLn দুইটা কোন ক্রমে?” প্রশ্ন তোলে — সমাধানটাই IO monad: effect explicit chain-এ গেঁথে “কখন” প্রশ্নটা type-এর নিয়মেই ঠিক হয়। এই মূল্যটা মনে রাখুন — realworld section-এ দেখবেন কেন সবাই এই দাম দিতে রাজি না।
Space leak — memoization-এর অন্ধকার পাশ
Memoization cell মানে প্রতিটা পিছিয়ে-রাখা গণনা মেমরিতে জমা হয়:
sumSlow = foldl (+) 0 [1..100000000]
-- thunk chain জমে: 1+(2+(3+(4+...))) — কোটি কোটি thunk heap-এ বসে থাকে
sumFast = foldl' (+) 0 [1..100000000]
-- Data.List.foldl' প্রতি ধাপে force করে — accumulator সবসময় plain numberLazy-র দাবি “দরকারি জিনিসই কষবে” সত্য — কিন্তু “দরকার” মুহূর্তটা দেরিতে
এলে মেমরিই মারে। এই সংঘর্ষ ম্যানেজ করতেই Haskell-এ seq, bang patterns,
strict data field — strictness ফেরত আনার সরঞ্জামগুলো আলাদা করে শেখানো হয়।
- Application sitecaller argument গণনা করে না — শুধু (expr, env) জোড়া মোড়ানো হয়; call নিজেই O(1)
- Environment slotcallee-র env-এ parameter বসে ⟨Thunk⟩ রূপে — closures লেসনের heap record-ই, প্লাস cached cell
- First useforce: expr ধারণকারী env-এ evaluate হয়; ফল জমে cached=true, value লেখা হয়
- Later usescached cell থেকে সরাসরি মান — গণনা আর একবারও না
উদাহরণ
একই প্রোগ্রাম, তিন বাস্তবতা
Program: (λx. x + x) ((λy. y) 21) — তিন strategy-র হাতে-trace:
CBV : argument (λy.y) 21 → 21 [১ বার] → body: 21+21 → 42
CBN : substitute → ((λy.y)21)+((λy.y)21)
→ প্রথম পা → 21 [গণনা ১ম], দ্বিতীয় পা → 21 [গণনা ২য় — duplicate!]
→ 42
need: x → ⟨Thunk⟩ [কিছুই গণনা হয়নি]
→ প্রথম x: force → 21, cached [গণনা ১ বার]
→ দ্বিতীয় x: cached 21 [শূন্য] → 42এবার উল্টো কেস: (λx. 42) ((λy. y) 21) —
CBV : argument গণনা হয় → অপচয় (x কেউ ব্যবহারই করেনি)
CBN : argument গণনা হয় না → শূন্য খরচ
need: argument গণনা হয় না → শূন্য খরচদুই দিকেই একটা করে win: strict জেতে “ঠিক একবারের নিশ্চয়তায়”, lazy জেতে “অব্যবহৃত-মুক্তি”-তে।
Infinite structure — laziness-এর জাদুখেলা
take 3 (map (^2) [1..])
→ 1 : take 2 (map (^2) [2..]) -- head দরকার → square 1 force
→ 1 : 4 : 9 : [] -- list অসীম, উত্তর সসীম; এরপর কিছুই জন্মায়নি[1..] মেমরিতে অসীম কিছু ধরে না — গণনার বর্ণনা (ঠিক আমাদের thunk);
take-এর demand-ই তাকে ধাপে ধাপে জাগায়। এই demand-driven generation-টাই
experiment-এ Python generator দিয়ে হুবহু বানাবেন — Python-এর
<Term id="coroutine">Coroutine</Term>-ভিত্তিক generator আসলে manual-mode
laziness।
ছবিতে
কখন কষে — এক নজরে
expression: (λx. x + x) ARG [ARG = দামি গণনা]
CBV : [ARG গণনা ▶▶▶▶]──value──► [body(value)] ──► ফল
└─ x ব্যবহৃত হোক বা না হোক, ARG কষবেই — ঠিক একবার
CBN : [body(ARG-copy)] ─► [ARG গণনা] + [ARG-copy গণনা] ─► ...
└─ প্রতিটা occurrence একটা fresh copy; সবগুলোই নিজে নিজে কষে
need: [body(⟨Thunk: ARG⟩)]
│ x প্রথম ব্যবহার
▼
[force: ARG গণনা] ──► ⟨Thunk: ARG, cached=True, value=V⟩
│ x পরের ব্যবহার
▼
[cached V — গণনা শূন্য]before force: after force:
env env
├ x → ⟨Thunk⟩ ├ x → ⟨Thunk⟩
│ expr: (λy.y) 21 │ expr: (λy.y) 21
│ cached: False │ cached: True
│ value: ? │ value: 21 ◄── জীবনে একবারই লেখা হয়
▼ ▼
(heap object) (একই heap object — mutate হয়েছে)নিজে চালিয়ে দেখুন
Generator বনাম List — ১০ কোটি সংখ্যার বিচার
ধাপ ১ — একই গণনা দুই strategy-তে, আলাদা process-এ মাপুন (peak RSS process-wide metric, তাই দুই invocation বাধ্যতামূলক):
# genexp.py
import resource, sys, time
N = 10 ** 8 # RAM ৪ GB-র নিচে হলে N = 10 ** 7 করুন
def peak_rss_mb():
raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return raw / (1024 * 1024) if sys.platform == "darwin" else raw / 1024
t0 = time.perf_counter()
if sys.argv[1] == "list":
total = sum([x * x for x in range(N)]) # আগে পুরো list, পরে যোগ
else:
total = sum(x * x for x in range(N)) # একবারে একটা element
elapsed = time.perf_counter() - t0
print(f"mode={sys.argv[1]:<5} sum={total:,} time={elapsed:5.2f}s "
f"peakRSS={peak_rss_mb():6.0f} MB")python3 genexp.py list
python3 genexp.py genTypical pattern (সংখ্যা machine-ভেদে বদলাবে, অনুপাত বদলাবে না):
mode=list sum=33333...833335000000 time=14.9s peakRSS= 4400 MB
mode=gen sum=33333...833335000000 time= 9.7s peakRSS= 12 MBএকই sum, একই গণিত — পার্থক্য শুধু কখন গণনা হলো। List-এ ১০ কোটি intermediate object একসাথে জীবিত ছিল; generator-এ কখনোই একের বেশি না।
ধাপ ২ — Haskell-style infinite stream, Python-এ:
def naturals(start=1):
while True: # কখনো ফেরে না — তবু নিরাপদ!
yield start
start += 1
def take(it, n):
return [next(it) for _ in range(n)]
squares = (n * n for n in naturals()) # অসীম squares-এর "বর্ণনা"
print(take(squares, 5)) # [1, 4, 9, 16, 25]
print(take(squares, 3)) # [36, 49, 64] — সেখান থেকেই!while True লিখেও process hang করে না — generator body চলে demand-এ। খেয়াল
করুন squares-এ প্রথম take-এর পর state থেকে যায়: consume হওয়া মান
আর ফেরে না। Haskell-এর immutable lazy list আর Python-এর one-shot generator —
দুই laziness, দুই চুক্তি।
ধাপ ৩ — mutable default bug reproduce + প্রমাণ: hood section-এর
append_to আর __defaults__ output নিজে চালিয়ে মেলান — smoking gun ওই
tuple-টাই।
যা প্রমাণিত: “expression কখন evaluate হয়” তাত্ত্বিক খেলা না — এটা ৪ GB বনাম ১২ MB, আর প্রতি তৃতীয় Python codebase-এ ঘুমন্ত একটা bug।
list comprehension demand-এর আগেই সব গণনা করে ফেলে (strict), generator expression প্রতি element demand-এ গণনা করে (lazy) — footprint-এ O(n) বনাম O(1); infinite stream আর mutable default bug দুটোই এই একই নীতির পরিণতি।
নিজে বানান
Call-by-need interpreter — এক AST, তিন strategy
- formal-semantics লেসনের arithmetic/λ AST ফেরত আনুন — Num, Var, Lam, App, Add
- CBV evaluator লিখুন — argument body-র আগে, ঠিক একবার
- Call-by-name evaluator লিখুন — argument expression মোড়ানো অবস্থায় env-এ, প্রতি ব্যবহারে পুনর্গণনা, log-এ দেখান
- Thunk class যোগ করুন — expr, env, cached, value — আর force() যা প্রথম ব্যবহারে গণনা করে ফল জমায়
- একই প্রোগ্রাম তিন evaluator-এ চালিয়ে log-এর লাইন গুনে memoization প্রমাণ করুন
লক্ষ্য: formal-semantics লেসনের evaluator-কে এতটুকু বদলানো যে সে গণনা পিছিয়ে রাখতে পারে — তিন strategy-র পার্থক্য log-এর লাইনে।
# lazy_eval.py — CBV vs call-by-name vs call-by-need
# চালান: python3 lazy_eval.py
# ── AST: tuple-based, formal-semantics লেসনের মতোই ──
def num(n): return ('num', n)
def var(name): return ('var', name)
def lam(p, body): return ('lam', p, body) # λp.body
def app(f, a): return ('app', f, a) # f(a)
def add(a, b): return ('add', a, b)
def show(e):
t = e[0]
if t == 'num': return str(e[1])
if t == 'var': return e[1]
if t == 'lam': return '(λ' + e[1] + '.' + show(e[2]) + ')'
if t == 'add': return '(' + show(e[1]) + '+' + show(e[2]) + ')'
return '(' + show(e[1]) + ' ' + show(e[2]) + ')'
# ── Strategy ১: call-by-value ──
def eval_cbv(e, env):
tag = e[0]
if tag == 'num': return e[1]
if tag == 'var': return env[e[1]]
if tag == 'add': return eval_cbv(e[1], env) + eval_cbv(e[2], env)
if tag == 'lam': return ('closure', e, env)
f = eval_cbv(e[1], env)
arg = eval_cbv(e[2], env) # ← argument আগে, ব্যবহৃত হোক বা না হোক
_, (_, p, body), fenv = f
call_env = dict(fenv); call_env[p] = arg
return eval_cbv(body, call_env)
# ── Strategy ২: call-by-name (memoization ছাড়া) ──
def eval_name(e, env):
tag = e[0]
if tag == 'num': return e[1]
if tag == 'var':
_, ex, en = env[e[1]] # binding = unforced expr
print(' [name: re-eval] ' + show(ex)) # প্রতি ব্যবহারে পুনর্গণনা!
return eval_name(ex, en)
if tag == 'add': return eval_name(e[1], env) + eval_name(e[2], env)
if tag == 'lam': return ('closure', e, env)
f = eval_name(e[1], env)
_, (_, p, body), fenv = f
call_env = dict(fenv); call_env[p] = ('unforced', e[2], env) # বর্ণনা ঢোকে
return eval_name(body, call_env)
# ── Strategy ৩: call-by-need (thunk + memoization) ──
class Thunk:
__slots__ = ('expr', 'env', 'cached', 'value')
forced = 0 # মোট force গণনা
def __init__(self, expr, env):
self.expr, self.env = expr, env
self.cached, self.value = False, None
def force(t):
if not isinstance(t, Thunk): return t # ইতিমধ্যে plain value
if t.cached: return t.value # দ্বিতীয়বার: ফ্রি
Thunk.forced += 1
print(' [need: force #' + str(Thunk.forced) + '] ' + show(t.expr))
t.value = eval_need(t.expr, t.env)
t.cached = True # ← memoization-এর মুহূর্ত
return t.value
def eval_need(e, env):
tag = e[0]
if tag == 'num': return e[1]
if tag == 'var': return force(env[e[1]]) # env slot-এ Thunk!
if tag == 'add':
return force(eval_need(e[1], env)) + force(eval_need(e[2], env))
if tag == 'lam': return ('closure', e, env)
f = eval_need(e[1], env)
_, (_, p, body), fenv = f
call_env = dict(fenv); call_env[p] = Thunk(e[2], env) # এখনো ঘুমাচ্ছে
return eval_need(body, call_env)
# ── Demo: একই প্রোগ্রাম তিন বাস্তবতায় ──
if __name__ == '__main__':
prog = app(lam('x', add(var('x'), var('x'))),
app(lam('y', var('y')), num(21))) # (λx.x+x)((λy.y)21)
print('== CBV:'); print(' result:', eval_cbv(prog, {}))
print('== call-by-name:'); print(' result:', eval_name(prog, {}))
print('== call-by-need:'); print(' result:', eval_need(prog, {}))
unused = app(lam('x', num(42)), app(lam('y', var('y')), num(999999)))
print('== CBV (unused arg):', eval_cbv(unused, {}))
print('== need (unused arg):', eval_need(unused, {}),
'| total forces:', Thunk.forced)প্রত্যাশিত output:
== CBV:
result: 42
== call-by-name:
[name: re-eval] ((λy.y) 21)
[name: re-eval] 21
[name: re-eval] ((λy.y) 21)
[name: re-eval] 21
result: 42
== call-by-need:
[need: force #1] ((λy.y) 21)
[need: force #2] 21
result: 42
== CBV (unused arg): 42
== need (unused arg): 42 | total forces: 2লাইন গুনুন: name ৪টা re-eval log লিখেছে (বাইরের expression দুই occurrence,
ভেতরের y প্রতিবার) — work-এর duplication চোখের সামনে। Need মোট ২টা
force (#1 বাইরের thunk, #2 তার ভেতরের y) — nested thunk-গুলোও memoize হয়;
unused-argument কেসে force সংখ্যা একটিও বাড়েনি (total forces আগের ২-ই)।
এই সংখ্যাগুলোই call-by-need-এর পুরো ঔচিত্য।
নিজে বাড়ান:
let x = expr in bodyform যোগ করুন — call-by-need-এ shared Thunk বসলেlet ones = 1 : onescyclic structure কীভাবে সম্ভব হয় দেখুন।- Force-count দিয়ে
(λx. x + x + x) ARG-এ name বনাম need-এর evaluation অনুপাত মাপুন — occurrence বাড়ালে অনুপাত কীভাবে বাড়ে। - একটা
seq(v)primitive যোগ করুন যা argument force করে — Haskell-এরfoldl'-এর ভেতরে ঠিক এটাই ঘটে।
বাস্তব সিস্টেমে
Evaluation strategy বাস্তবে যেখানে দেখা যায়
Excel/spreadsheet। Cell-এর formula একটা lazy expression graph — dependency change হলেই recalc, দেখা হচ্ছে না এমন cell-এর হিসাব পিছিয়ে। পৃথিবীর সবচেয়ে ব্যবহৃত lazy evaluation engine হয়তো Excel-ই।
Apache Spark। df.filter(...).select(...) লেখা মাত্র কিছুই চলে না —
transformation-গুলো DAG-এ জমে, .count()/.show()-এর demand-এই execution
plan optimize করে চলে। Lazy DAG ছাড়া query optimizer-এর জায়গাটাই ছিল না।
Python generators / Java Streams / Rust Iterators। map/filter chains
সব lazy — terminal operation (collect, sum) demand তৈরি করে। আপনি প্রতিদিন
call-by-need-এর হাতে-লেখা রূপই চালাচ্ছেন।
React rendering। Component tree demand-driven — state change শুধু affected subtree জাগায়, ঠিক spreadsheet-এর মতো।
Unix pipeline। grep error huge.log | head -5 — head ৫টা লাইন পেয়ে
pipe বন্ধ করে, grep SIGPIPE-এ মরে। Producer অসীম, consumer সসীম — OS-স্তরের
call-by-need, io-models লেসনের pipe blocking-এর উৎপাদনশীল রূপ।
যে ভুলগুলো সবাই করে
“Lazy evaluation মানে প্রোগ্রাম সবসময় দ্রুত চলে।”
উল্টোটাও ঘন ঘন সত্য। Laziness অব্যবহৃত গণনা বাঁচায়, কিন্তু প্রতিটা value-কে heap object (thunk) বানিয়ে দেয় — allocation, pointer-chase, GC-pressure সবই বাড়ে, আর space leak (foldl উদাহরণ) তো আছেই। GHC নিজেই strictness analysis চালিয়ে যেখানে সম্ভব thunk সরিয়ে strict কোড generate করে — production Haskell আসলে hybrid। সঠিক বাক্য: laziness composability দেয়, speed নয়।
“Call-by-name আর call-by-need একই জিনিস — Haskell lazy তাই যা।”
ফলাফলের মানের দিক থেকে (pure code-এ) একই, performance-এ আকাশ-পাতাল। CBN প্রতি ব্যবহারে পুনর্গণনা করে — exponential blowup সম্ভব; need memoize করে। আজকের build-এর log-ই প্রমাণ: একই program-এ name ৪টা re-eval লাইন, need মোট ২টা force — আর দ্বিতীয় ব্যবহার cache hit। Algol 60 ছিল call-by-name (Jensen’s device-সহ); Haskell call-by-need। পার্থক্যটাই thunk machinery।
“Python-এ generator আছে, তাই Python lazy language।”
না। Python-এর expression arguments সব strict — f(g())-এ g() আগেই চলে।
Generator একটা first-class object যেটা আপনি manually demand করেন (next),
ঠিক SICP-র explicit delay/force-এর মতো — laziness ভাষায় না, library-তে।
Default argument-এর def-time evaluation-ই প্রমাণ: ভাষার core নিয়মগুলো
পুরোপুরি strict।
“Mutable default bug হলো Python-এর design flaw — ভাঙা জিনিস।”
চুক্তিটা সামঞ্জস্যপূর্ণ, flaw না: “default একবার গণনা হয়, def-এর সময়” — আর
Python-এ variable মানে binding, fresh copy না। একই নিয়মেই default-এ রাখা
object-এর identity বজায় থাকে — আর এটাই feature হয়ে ওঠে caching/memoization
pattern-এ (def fib(n, _cache={}): ক্লাসিক trick)। সমস্যা mutable value
শেয়ার করার অভিপ্রায়হীনতায় — তাই fix-ও semantic (None sentinel), hack না।
বুঝেছেন কি না দেখুন
1তিনটা evaluation strategy-তে (λx. 7) A — প্রতিটাতে A ঠিক কতবার গণনা
হয়? উত্তরগুলো এক লাইনে লিখুন।
স্মরণ
(λx. 7) A — প্রতিটাতে A ঠিক কতবার গণনা
হয়? উত্তরগুলো এক লাইনে লিখুন।Call-by-value: ঠিক ১ বার — body-র আগে, ব্যবহার হোক বা না হোক।
Call-by-name: ০ বার (body-তে x নেই, substitution-এ A-এর কোনো copy-ই
থাকে না)। Call-by-need: ০ বার — Thunk তৈরি হয় কিন্তু কখনো force হয় না।
শেষ দুটোই “অব্যবহৃত গণনা ফ্রি” প্রতিশ্রুতির প্রমাণ।
2(λx. x + x) (2 + 3) — call-by-name কয়টা addition করে, call-by-need কয়টা?
প্রতিটার জন্য evaluation ধাপগুলো লিখুন।
প্রয়োগ
(λx. x + x) (2 + 3) — call-by-name কয়টা addition করে, call-by-need কয়টা?
প্রতিটার জন্য evaluation ধাপগুলো লিখুন।Call-by-name: substitute → (2+3) + (2+3) → প্রথম 2+3=৫, দ্বিতীয় 2+3=৫,
তারপর ৫+৫=১০ — মোট ৩টা addition। Call-by-need: প্রথম use-এ force →
2+3=৫ (একটা addition), cache; দ্বিতীয় use cache-hit; শেষে ৫+৫=১০ — মোট
২টা addition। Occurrence n হলে name কষে n+১টা (n বার argument + ১ বার
outer), need সবসময় ২টা — argument ১ বার + outer ১ বার।
3Python-এ def connect(host, retries=3, timeout=30, pool=[]): ... — এই
signature-এ কোনটা সবচেয়ে বিপজ্জনক এবং কেন? retries আর pool-এর মধ্যে
পার্থক্যটা ব্যাখ্যা করুন।
যুক্তি
def connect(host, retries=3, timeout=30, pool=[]): ... — এই
signature-এ কোনটা সবচেয়ে বিপজ্জনক এবং কেন? retries আর pool-এর মধ্যে
পার্থক্যটা ব্যাখ্যা করুন।pool — কারণ mutable। সব default def-টাইমে একবারই গণনা হয় (এটা retries
আর pool — দুজনের জন্যই সত্য), কিন্তু immutable int-এর শেয়ারিং অদৃশ্য:
retries += 1 নতুন object bind করে, shared default কখনো mutate হয় না।
pool.append(...) কিন্তু shared object-কেই mutate করে — সব caller
একই list ব্যবহার করে বসে। নিয়মটা মনে রাখুন: default-এ রাখা যেকোনো mutable
object হলো অদৃশ্য global state — fix হলো None-sentinel আর per-call creation।
4আপনি একটা log-analysis tool লিখছেন: ১০০ GB compressed log, user সাধারণত
প্রথম ১০টা match দেখে থামে। Strict processing pipeline না streaming/generator
pipeline — কোনটা বেছে নেবেন, কী কী সংখ্যা দেখে? Trade-off গুলো লিখুন।
ডিজাইন
Streaming/generator pipeline। সংখ্যা: user প্রথম ১০টা match-এ থামে — সম্ভাব্য ভোগ মোট ডেটার ক্ষুদ্র fraction; strict pipeline ১০০ GB decompress+scan করবে অপচয়ে (memory ও wall-clock), generator pipeline O(demanded) খরচে থামবে। Trade-off: streaming-এ random access/backtracking নেই (দ্বিতীয়বার একই region মানে re-decompress); resource cleanup সাবধানে (abandoned generator-এর file handle — context manager); full-pass aggregation (মোট count) লাগলে laziness- এর লাভ শূন্য। Demand distribution জানা থাকলেই সিদ্ধান্ত data-driven।
5Haskell-এ False && (loop forever) instantly ফেরত দেয় False, কিন্তু
C-তে 0 && (while(1);) নয় — wait, আসলে C-ও short-circuit করে! তাহলে দুই
ভাষার পার্থক্যটা ঠিক কোথায়? কোন কোন expression position-এ Haskell non-strict
কিন্তু C না?
যুক্তি
False && (loop forever) instantly ফেরত দেয় False, কিন্তু
C-তে 0 && (while(1);) নয় — wait, আসলে C-ও short-circuit করে! তাহলে দুই
ভাষার পার্থক্যটা ঠিক কোথায়? কোন কোন expression position-এ Haskell non-strict
কিন্তু C না?Short-circuit operator-এ দুই ভাষাই non-strict — পার্থক্য অন্য সব জায়গায়।
C-তে non-strict position মাত্র তিনটা: &&, ||, ?: — function-call
argument সব strict (f(diverge()) ঝুলে যাবেই)। Haskell-এ প্রতিটা function
argument non-strict: f (diverge) চলবে যদি f argument না চায়। কারণ
structurally: C-তে call মানে stack-এ value বসানো (calling convention — value
লাগবেই), Haskell-এ call মানে thunk pass করা (pointer-ই যথেষ্ট)। একই কারণে C-তে
if special form হতে হয়, Haskell-এ ordinary function হতে পারে।
এরপর কী
এরপর কী
Evaluation strategy ঠিক করে normal path-এ কোন গণনা হয় আর কোন মুহূর্তে।
কিন্তু প্রোগ্রামের আরেকটা রাস্তা আছে যেটা এতক্ষণ অদৃশ্য ছিল: গণনার মাঝপথেই
control উল্টো দিকে লাফিয়ে যাওয়া — exception। throw করলে ঠিক কী ঘটে?
Stack frame-গুলো কীভাবে ভেঙে পড়ে, destructor/cleanup কে চালায়, আর কেন
“zero-cost exceptions” নামের একটা জিনিস আসলে throw-এ মাইক্রোসেকেন্ড খরচ
করে? পরের লেসনে আমরা try/catch-কে silicon পর্যন্ত নামাব — setjmp/longjmp
থেকে DWARF unwind table পর্যন্ত, আর নিজের হাতে একটা exception system বানাব
C-তে।
পরের লেসন: Exception Internals — throw থেকে catch পর্যন্ত সিলিকনের রাস্তা।
আরও পড়ুন
- Structure and Interpretation of Computer Programs, Section 3.5 — Streams — Harold Abelson, Gerald Jay Sussman, Julie Sussman · delay/force আর stream-এর classic treatment — এই লেসনের thunk model-এর ভিত্তি
- Programming in Haskell — Graham Hutton · Lazy evaluation আর infinite list-এর পাঠ্যপুস্তক আলোচনা — [1..] উদাহরণের উৎস
- Types and Programming Languages, Chapter 5 — The Untyped Lambda-Calculus — Benjamin C. Pierce · Call-by-value operational semantics-র formal ভিত্তি — formal-semantics লেসনের ধারাবাহিকতা
- Programming Language Pragmatics — Control Flow chapters — Michael L. Scott · Short-circuit evaluation আর evaluation order-এর ভাষা-ভেদে survey