Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ১১/১৫কঠিন৪৫ মিনিট

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-এর মূল উৎস।

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

  • call-by-value, call-by-name, call-by-need — তিনটা strategy-তেই একই lambda expression-এ কোন subexpression কতবার evaluate হয় তা হাতে trace করে দেখাতে পারবেন
  • thunk = zero-argument closure + memoization cell — এই দুই অংশ আলাদা করে ব্যাখ্যা করতে পারবেন, আর call-by-name-এর duplicate-work সমস্যা call-by-need ঠিক কীভাবে সমাধান করে তা দেখাতে পারবেন
  • Python-এর default argument কেন def-এর সময় ঠিক একবারই evaluate হয়, আর mutable default gotcha কীভাবে reproduce, debug আর fix করতে পারবেন
  • short-circuit evaluation-কে selective strictness হিসেবে ব্যাখ্যা করে বলতে পারবেন কোন operand strict position-এ, কোনটা non-strict position-এ
  • generator দিয়ে Haskell-style infinite stream বানিয়ে memory measurement দিয়ে প্রমাণ করতে পারবেন list comprehension বনাম generator expression-এর মধ্যে O(n) space বনাম constant space-এর পার্থক্য

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

আগে এটা বুঝি

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 তিন জায়গায় ভিন্ন সিদ্ধান্ত নেয়:

StrategyArgument কখন গণনা হয়একই 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 bucket

Effects আর 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 number

Lazy-র দাবি “দরকারি জিনিসই কষবে” সত্য — কিন্তু “দরকার” মুহূর্তটা দেরিতে এলে মেমরিই মারে। এই সংঘর্ষ ম্যানেজ করতেই Haskell-এ seq, bang patterns, strict data field — strictness ফেরত আনার সরঞ্জামগুলো আলাদা করে শেখানো হয়।

একটা lazy argument-এর জীবনচক্র
  1. Application sitecaller argument গণনা করে না — শুধু (expr, env) জোড়া মোড়ানো হয়; call নিজেই O(1)
  2. Environment slotcallee-র env-এ parameter বসে ⟨Thunk⟩ রূপে — closures লেসনের heap record-ই, প্লাস cached cell
  3. First useforce: expr ধারণকারী env-এ evaluate হয়; ফল জমে cached=true, value লেখা হয়
  4. 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 হয়েছে)
Environment slot-টা প্রথমে শুধু বর্ণনা ধরে রাখে; force হলে সেই একই slot ফলাফলে রূপান্তরিত — memoization আসলে এই in-place mutation-ই।

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

EXPERIMENT

Generator বনাম List — ১০ কোটি সংখ্যার বিচার

Linux/macOS, Python 3.8+· ১৫ মিনিট

ধাপ ১ — একই গণনা দুই 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 gen

Typical 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 দুটোই এই একই নীতির পরিণতি।

নিজে বানান

BUILD IT

Call-by-need interpreter — এক AST, তিন strategy

Python · ●●●○○
  1. formal-semantics লেসনের arithmetic/λ AST ফেরত আনুন — Num, Var, Lam, App, Add
  2. CBV evaluator লিখুন — argument body-র আগে, ঠিক একবার
  3. Call-by-name evaluator লিখুন — argument expression মোড়ানো অবস্থায় env-এ, প্রতি ব্যবহারে পুনর্গণনা, log-এ দেখান
  4. Thunk class যোগ করুন — expr, env, cached, value — আর force() যা প্রথম ব্যবহারে গণনা করে ফল জমায়
  5. একই প্রোগ্রাম তিন 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-এর পুরো ঔচিত্য।

নিজে বাড়ান:

  1. let x = expr in body form যোগ করুন — call-by-need-এ shared Thunk বসলে let ones = 1 : ones cyclic structure কীভাবে সম্ভব হয় দেখুন।
  2. Force-count দিয়ে (λx. x + x + x) ARG-এ name বনাম need-এর evaluation অনুপাত মাপুন — occurrence বাড়ালে অনুপাত কীভাবে বাড়ে।
  3. একটা 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 ঠিক কতবার গণনা হয়? উত্তরগুলো এক লাইনে লিখুন।

স্মরণ

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 ধাপগুলো লিখুন।

প্রয়োগ

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 ১ বার।

3

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

5

Haskell-এ 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