Foundationপ্রথম নীতি থেকে
LEVEL 5লেসন ৪/১৫রিসার্চ-লেভেল১ ঘণ্টা ৫ মিনিট

Hindley-Milner Type Inference — annotation ছাড়াই টাইপ বের করা

Type Inference and Hindley-Milner

Unification আর Algorithm W — কীভাবে ML-family ভাষাগুলো annotation ছাড়াই সম্পূর্ণ টাইপ বের করে, let-polymorphism কী দেয় যা lambda-parameter দেয় না, আর কেন Java/C# একই জায়গায় দশক-জুড়ে আটকে ছিল।

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

  • Unification-কে simultaneous equation solving হিসেবে ব্যাখ্যা করে বলতে পারবেন টাইপ-equation কোথায় জন্ম নেয় আর solver কীভাবে merge করে
  • Algorithm W-কে λx. x (identity function)-এর উপর ধাপে ধাপে trace করে `α → α` ফলটা হাতে বের করতে পারবেন
  • Let-polymorphism আর λ-polymorphism-এর পার্থক্য একই expression-এর দুই রূপ দিয়ে দেখাতে পারবেন — একটা generalize হয়, আরেকটা হয় না, এবং কেন
  • Occurs check কী থামায় (`λx. x x` → infinite type) তা ব্যাখ্যা করে value restriction-এর অস্তিত্বের কারণ বলতে পারবেন
  • ~100 লাইনের HM inference engine চালিয়ে, তাতে pair/tuple support যোগ করে inferred type-গুলো যাচাই করতে পারবেন

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

আগে এটা বুঝি

আগের লেসনে দেখলাম static typing-এর promise: ভুল run-এর আগে। কিন্তু promise-টার সাথে একটা বিল এসেছিল — annotation। Java-তে লিখতে হয় Map<String, List<Integer>> counts = new HashMap<>(); — বামদিকে টাইপ, ডানদিকে constructor, মাঝে একটা ছোট নাম যার ওজন সবচেয়ে কম। Programmer-দের একটা অংশ এখানেই static typing ছেড়ে দেয়: “লিখতে লিখতে ক্লান্ত।”

১৯৭৮ সালে Robin Milner দেখালেন বিলটা পুরোটা দিতে হয় না। ML- তে programmer লেখে:

fun id x = x                    (* compiler বলে: 'a -> 'a *)
fun apply f x = f x             (* compiler বলে: ('a -> 'b) -> 'a -> 'b *)

কোনো annotation নেই — তবু compiler সম্পূর্ণ static guarantee দেয়, আর শুধু টাইপ বের করেই না: বের করে সবচেয়ে সাধারণ (principal) টাইপ — যেটা সব possible type-assignment-এর parent। যন্ত্রটার নাম Hindley-Milner type inference (Hindley 1969-এ logic- জগতে একই যন্ত্র খুঁজেছিলেন), আর এর ইঞ্জিন দুইটা: unification — equation সমাধান — আর Algorithm W — সেই সমাধান-চালানোর নিয়মাবলি।

সবচেয়ে সুন্দর কথাটা হলো: এই যন্ত্রটা আপনি ইতিমধ্যে চিনি। আগের লেসনের formal semantics ছিল rule-based derivation; HM inference ঠিক তার আয়না — semantics ছিল “rule মেনে মান বের করা”, inference হলো “rule মেনে টাইপ বের করা”। Same shape, different payload।

আজকের প্রশ্ন তিনটা: (১) টাইপ-জ্ঞান ছাড়া লেখা expression থেকে টাইপ-equation জন্ম নেয় কোথায়? (২) equation-গুলোর solution একটাই কি না অনেক — আর একটা হলে কোনটা? (৩) কেন let-bound function polymorphic হয় কিন্তু lambda-parameter হয় না — আর এই ফাঁকটাই কেন Java/C#-কে দশকের পর দশক annotation-এর কাছে রেখে দিয়েছিল?

মূল ধারণা

Unification — টাইপের সহ-sমীকরণ সমাধান

গণিতের ক্লাসে সহ-sমীকরণ মনে আছে?

x + y = 10
x − y =  2
───────────
x = 6, y = 4

প্রতিটা সমীকরণ একা insufficient, একসাথে unique solution। Type inference ঠিক এই খেলা — শুধু unknown গুলো type variables (α, β, t0, t1…) আর সমীকরণগুলো টাইপ-সমতা:

α = int          ← "id-কে 5 apply করেছি" থেকে
α = bool         ← "id-কে True apply করেছি" থেকে

দুই সমীকরণ একসাথে contradiction — int ≠ bool — তাই compile error। আর যদি হতো:

α = int
β = α → α        ← substitute করে: β = int → int

solution consistent — প্রোগ্রাম টাইপ-বৈধ। Unification মানে এই merge-process: দুই টাইপ-structure-কে সমান করতে বলা, variable- গুলো বাঁধা, conflict-এ fail। Sudoku-র মতো ভাবুন — প্রতিটা clue এক constraint, পুরো grid-এর একটাই solution (আশা করি), আর solver clue-গুলো propagate করে ফাঁক ভরে।

Algorithm W — চার নিয়মের খেলা

W প্রতিটা expression-node-এ তিনটা কাজ করে: (১) children-দের টাইপ বের করো (recurse), (২) এই node যা জানে তা দিয়ে unify করো, (৩) ফল হিসেবে নিজের টাইপ দাও। সবচেয়ে ছোট উদাহরণে পুরোটা দেখা যায় — identity function λx. x:

ধাপExpression অংশকাজফল
১λx.fresh variable বাঁধোx : t0
২xlookupt0
৩λx. xfunction-টাইপ গঠনt0 → t0

Result: t0 → t0 — এক generic variable-সহ। এটাই principal type: কোনো assumption নেই কে x, তাই সবচেয়ে উদার সত্যটাই বলা গেল। এখন apply: (λx. x) 42 —

ধাপ A: f-এর টাইপ = t0 → t0      (উপর থেকে)
ধাপ B: argument-এর টাইপ = int    (literal)
ধাপ C: fresh result t1
ধাপ D: unify (t0 → t0) with (int → t1)
       ├─ t0 = int               ← প্রথম position match
       └─ t0 = t1  ⟹ t1 = int   ← substitution cascade!
ফল: int ✔

D-এর দ্বিতীয় লাইনটা খেয়াল করুন — একটা binding অন্যটাকে টেনে আনে। এটাই substitution cascade: সমীকরণ-জালে একটা পিন নড়ালে পুরো জাল নড়ে।

Occurs check — λx. x x কেন নিষিদ্ধ

λx. x x

Trace করুন: x : t0; ভেতরের application দেয় equation t0 = t0 → t1। কোনো finite type এই সমীকরণ মানে না — t0-এর definition-এ t0 নিজেই আছে, infinite regress। Unifier-এর আত্মরক্ষা occurs check: variable বাঁধার আগে ডানপাশের structure-এ সেই variable-ই আছে কি না খোঁজা — থাকলে “infinite type” error।

মজার echo: আগের আগের লেসনের x = x + 1! গণিতে x = x + 1-এর finite real solution নেই; টাইপ-জগতে α = α → β-এর finite type solution নেই। একই structural সমস্যা, দুই domain।

Let-polymorphism — W-এর আসল superpower

এখন মূল পার্থক্যটা। এই expression-টা দেখুন:

let id = λx. x in
  if id True then id 5 else 0

id True চায় id : bool → bool; id 5 চায় id : int → int। দুই requirement conflict — তবু ML/Haskell এটা accept করে, কারণ let-binding-এর টাইপ generalize হয়: inference শেষে বাকি থাকা free variable-গুলোর উপর universally quantified হয়ে যায় — ∀α. α → α। প্রতিটা use-site তারপর instantiate করে নিজের fresh copy নেয় — প্রথমটা bool → bool, দ্বিতীয়টা int → int, কোনো conflict-ই নেই।

এখন হুবহু একই কাজ lambda দিয়ে:

(λid. if id True then id 5 else 0) (λx. x)

এটা reject হয়! কারণ lambda-parameter-এর টাইপ একটা একক variable (t0) — monomorphic। প্রথম use-তেই t0 = bool → t1 bind হয়ে যায়, দ্বিতীয় use-তে int মেলাতে গিয়ে explosion।

এই ফাঁকের একটা বিখ্যাত ফাটলও আছে: value restriction। ML-তে mutable reference যোগ হওয়ার পরে দেখা গেল val r: 'a list ref = ref [] জাতীয় generalized storage অসাউন্ড — এক টাইপে লিখে অন্য টাইপে পড়া যায়। সমাধান: শুধু syntactic value (function, literal, constructor-form) generalize হয়; mutable cell-রা weak type-variable নেয়। Haskell pure বলে restriction-এর দরকার কম — এই পার্থক্যটাই দুই ভাষার spec-এর একটা signature।

ভেতরে কী ঘটছে

দুই-phase দৃষ্টি: constraint generation → constraint solving

Textbook-এ W-কে single-pass দেখানো হয়, কিন্তু production implementation-রা (Rust, GHC, Scala) প্রায়ই দুই phase-এ ভাগ করে:

Phase ১: GENERATE                 Phase ২: SOLVE
expression হাঁটো                  equation-queue খালি না হওয়া পর্যন্ত:
প্রতিটা node constraint emit          │ একটা eq pop করো
  app  → τf ≈ τa → τr                 │ দুই side simplify করো (prune)
  lit  → τ ≈ int                      │ head দুটো match?
  if   → τc ≈ bool                        ├─ var = T   → bind (occurs check!)
  ...                                     ├─ T₁ → T₂ ≈ S₁ → S₂
                                          │   → দুই sub-eq push
                                          └─ int ≈ bool → ERROR
শেষে: বাঁচা variable-গুলোর principal type

দুই presentation equivalent — single-pass আসলে generate+solve interleaved (on-the-fly union-find সহ, যা আমাদের কোডে থাকবে)। দুই- phase ভাবনার সুবিধা debugging-এ: Rust-এর borrow-checker error যখন “constraint originated here, conflicting requirement there” দেখায়, সেটা ঠিক phase-১-এর emission-site আর phase-২-এর conflict-site দুই location report করা।

Principal type — কেন “একটা best উত্তর” আছেই

HM-এর theorem: যদি কোনো type assignment সম্ভব হয়, তবে একটা principal type আছে যার instance সব অন্য possible assignment। t0 → t0 এমন — int → int, str → str সব তার instance। এটাই modularity-র ভিত্তি: library author একবার টাইপ বের করলে, user-রা যত ভাবেই use করুক সেই generic contract-এই বসে। Java-র world-এ এই property নেই — subtyping-এর কারণে “best” type অনেকগুলো incomparable candidate-এ ছড়িয়ে যায় (List<Object>, List<? extends Number>, List<?>…)। Subtyping-ই HM-এর principal-type magic-এর প্রধান শত্রু — এই এক বাক্য Java/C#-এর দশক-লম্বা inference-lag-এর পুরো ব্যাখ্যা।

Instantiation বনাম generalization — কোডে যে দুই function

আমাদের implementation-এ এই দুই ধারণা দুই function হয়ে ফিরবে:

  • generalize(env, τ): τ-র free variable-গুলো থেকে env-এ যারা আগে-থেকে-বাঁধা তাদের বাদ দিয়ে বাকিদের ∀ দিয়ে মুড়িয়ে দেয় — ('forall', (vars…), τ)।
  • instantiate(σ): প্রতিটা quantified variable-এর fresh clone — প্রতিটা use-তে নিজস্ব copy।

এই জুটিই let-polymorphism-এর পুরো mechanics — বাকিটা bookkeeping। আর union-find style path compression (prune) থাকছে যাতে বারবার binding-chain হাঁটা না লাগে — production unifiers ঠিক এটাই করে, disjoint-set data structure-এর ভাষায়।

HM pipeline — expression থেকে final type
  1. AST walkপ্রতিটা node-এ W-এর case — literal fact, lambda fresh-var, app equation, let generalize
  2. Unify + bindequation মেলানো, substitution-map update, occurs check guard
  3. Principal resultবাঁচা variable-সহ সবচেয়ে সাধারণ টাইপ — অথবা নিখুঁত error message

উদাহরণ

হাতে-হাতে W: λf. λx. f (f x) — “twice” combinator

একটা ধাপ বেশি কঠিন উদাহরণে পুরো যন্ত্রটা ঘোরাই:

Expression:  λf. λx. f (f x)

ধাপ ১  λf.        fresh t0          f : t0
ধাপ ২  λx.        fresh t1          x : t1
ধাপ ৩  f          lookup            t0
ধাপ ৪  (f x)      app: fresh t2,
                  unify t0 ≈ t1 → t2
                  ├─ t0 = (t1 → t2)
ধাপ ৫  f (f x)    app: fresh t3,
                  unify t0 ≈ t2 → t3
                  ├─ prune: t0 এখন (t1→t2), তাই
                  │  t1 → t2 ≈ t2 → t3
                  ├─ t1 = t2        ← দুই position মিলে
                  └─ t2 = t3
ধাপ ৬  substitute: t0 = (t1 → t1), সব জায়গায়
ফল:   (t1 → t1) → t1 → t1

প্রতিটা application একটা equation, প্রতিটা equation আগের binding-কে refine করছে, আর শেষে দাঁড়ায় পরিচিত সিগনেচার — Haskell-এ আপনি এটা (a -> a) -> a -> a নামে চেনেন। লক্ষ্য করুন ধাপ ৫-এর সূক্ষ্মতা: unify করার আগে prune (path compression) লাগল, নইলে t0-এর পুরনো alias দেখে ভুল branch-এ যেত। Production bug-দের একটা ক্লাসিক উৎস ঠিক এই forgotten-prune।

এবার একই যন্ত্রে একটা failure:

Expression:  λid. if id True then id 5 else 0

ধাপ ১  λid.       fresh t0          id : t0
ধাপ ২  id True    app: t0 ≈ bool → t1   ⟹ t0 := bool → t1
ধাপ ৩  id 5       app: prune(t0) = (bool → t1)
                  unify (bool → t1) ≈ (int → t2)
                  ✗ cannot unify bool with int

Lambda-bound id monomorphic — প্রথম use-তেই তার ভাগ্য সিল হলো। Concept section-এর let-version একই কাঠামো accept করবে — এই জুটিই experiment-এর test-suite-এ আছে।

        let id = λx. x in  (id True … id 5)

                     ┌─────────────────────┐
                     │ id-র definition:    │
                     │ τdef = t0 → t0      │
                     │ free: t0 → GENERALIZE│
                     │ σ = ∀α. α → α       │
                     └──────────┬──────────┘
              instantiate ↓           ↓ instantiate
        ┌────────────────┐      ┌────────────────┐
        │ use ১: id True │      │ use ২: id 5    │
        │ copy: α₁ → α₁  │      │ copy: α₂ → α₂  │
        │ eq: α₁ ≈ bool  │      │ eq: α₂ ≈ int   │
        └────────────────┘      └────────────────┘
                ↓                       ↓
           bool → bool             int → int
              (কোনো conflict নেই — দুই আলাদা copy)

        তুলনায় λ-bound version: একটাই t0, দুই দাবি এক কাঠে — ✗
Unification-এর সমীকরণ-জাল — প্রতিটা use-site তার দাবি জমা দেয়, solver সবগুলো একসাথে মেলায়।

ছবিতে

              visit(('app', f, a))
                      │
        ┌─────────────┴─────────────┐
        ▼                           ▼
  τf = W(env, f)             τa = W(env, a)
        │                           │
        └────────────┬──────────────┘
                     ▼
          τr = fresh variable
                     ▼
        ┌─────────────────────────┐
        │ unify(τf, τa → τr)      │
        │                         │
        │ prune both sides        │
        │ ├─ both arrows? recurse │
        │ ├─ var vs anything?     │
        │ │    occurs check ──✗──▶│ "infinite type"
        │ │    bind var := other  │
        │ ├─ base vs base?        │
        │ │    equal? ok          │
        │ └─ else ────────────✗──▶│ "cannot unify A with B"
        └────────────┬────────────┘
                     ▼
              return τr
Algorithm W-এর এক node-visit — app-case-এর পূর্ণ control flow, আমাদের Python কোডের হুবহু map।

Flowchart-এর দুইটা ✗ exit-ই user-facing error message-এর জন্মস্থান — আর দুইটার message-এর মানের পার্থক্যই Rust/Elm-style tooling-এর প্রতিযোগিতার মাঠ: “cannot unify” বলাটা সহজ, বলা কঠিন “এই argument-এ আপনি int পাঠিয়েছেন কিন্তু প্রথম use-তে এই function bool চেয়েছিল, source line X-এ” — যেটা করতে হলে constraint-এর provenance (কোন site কী জমা দিল) সাথে রাখতে হয়।

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

EXPERIMENT

~100 লাইনের Hindley-Milner engine — চালান, ভাঙান, বোঝেন

Python 3.8+ (কোনো dependency নেই)· ২৫ মিনিট

পুরো engine — copy-paste করে চালান:

# hm.py — Hindley-Milner type inference, minimal-but-real
import itertools

_fresh_counter = itertools.count()

def fresh():
    return ('tv', next(_fresh_counter))

def reset():
    """প্রতিটা test একই numbering থেকে শুরু হোক"""
    global _fresh_counter
    _fresh_counter = itertools.count()

# টাইপ:  'int' | 'bool' | ('tv', n) | ('qtv', n) | ('->', a, b)
# AST:   ('int', n) ('bool', b) ('var', x) ('lam', x, body)
#        ('app', f, arg) ('let', x, e1, e2) ('if', c, t, e)

def prune(t, subs):
    """union-find: variable-এর শেষ binding পর্যন্ত হাঁটো (path compression)"""
    while isinstance(t, tuple) and t[0] == 'tv' and t[1] in subs:
        subs[t[1]] = resolve(subs[t[1]], subs)     # compression
        t = subs[t[1]]
    return t

def resolve(t, subs):
    t = prune(t, subs)
    if isinstance(t, tuple) and t[0] == '->':
        return ('->', resolve(t[1], subs), resolve(t[2], subs))
    return t

def occurs(v, t, subs):
    t = prune(t, subs)
    if t[0] == 'tv':
        return t[1] == v
    if t[0] == '->':
        return occurs(v, t[1], subs) or occurs(v, t[2], subs)
    return False

def unify(a, b, subs):
    a, b = prune(a, subs), prune(b, subs)
    if a == b:
        return
    if isinstance(a, tuple) and a[0] == 'tv':
        if occurs(a[1], b, subs):
            raise TypeError(f"infinite type: t{a[1]} = {show(b)}")
        subs[a[1]] = b                              # equation solved!
        return
    if isinstance(b, tuple) and b[0] == 'tv':
        unify(b, a, subs)
        return
    if isinstance(a, tuple) and isinstance(b, tuple) \
            and a[0] == '->' and b[0] == '->':
        unify(a[1], b[1], subs)
        unify(a[2], b[2], subs)
        return
    raise TypeError(f"cannot unify {show(a)} with {show(b)}")

def free_tvs(t, subs, acc=None):
    acc = set() if acc is None else acc
    t = prune(t, subs)
    if t[0] == 'tv':
        acc.add(t[1])
    elif t[0] == '->':
        free_tvs(t[1], subs, acc)
        free_tvs(t[2], subs, acc)
    return acc

def generalize(env, t, subs):
    bound = set()
    for s in env.values():
        bound |= free_tvs(s, subs)
    qv = sorted(free_tvs(t, subs) - bound)
    if not qv:
        return t                                    # monomorphic
    qs = set(qv)

    def wrap(t):
        t = prune(t, subs)
        if t[0] == 'tv':
            return ('qtv', t[1]) if t[1] in qs else t
        if t[0] == '->':
            return ('->', wrap(t[1]), wrap(t[2]))
        return t

    return ('forall', tuple(qv), wrap(t))

def instantiate(sigma, subs):
    if not (isinstance(sigma, tuple) and sigma[0] == 'forall'):
        return sigma                                # monomorphic as-is
    mapping = {v: fresh() for v in sigma[1]}

    def cp(t):
        t = prune(t, subs)
        if isinstance(t, tuple) and t[0] in ('tv', 'qtv'):
            return mapping.get(t[1], t)
        if isinstance(t, tuple) and t[0] == '->':
            return ('->', cp(t[1]), cp(t[2]))
        return t

    return cp(sigma[2])

def infer(env, e, subs):
    tag = e[0]
    if tag == 'int':
        return 'int'
    if tag == 'bool':
        return 'bool'
    if tag == 'var':
        if e[1] not in env:
            raise NameError(f"unbound variable: {e[1]}")
        return instantiate(env[e[1]], subs)
    if tag == 'lam':
        tv = fresh()
        body_t = infer({**env, e[1]: tv}, e[2], subs)
        return ('->', tv, body_t)
    if tag == 'app':
        tf = infer(env, e[1], subs)
        ta = infer(env, e[2], subs)
        tr = fresh()
        unify(tf, ('->', ta, tr), subs)
        return tr
    if tag == 'let':                                 # ★ let-polymorphism
        t1 = infer(env, e[2], subs)
        sigma = generalize(env, t1, subs)
        return infer({**env, e[1]: sigma}, e[3], subs)
    if tag == 'if':
        unify(infer(env, e[1], subs), 'bool', subs)
        tt = infer(env, e[2], subs)
        unify(infer(env, e[3], subs), tt, subs)
        return tt
    raise ValueError(tag)

def show(t, subs=None):
    subs = {} if subs is None else subs
    names = {}                                   # canonical naming: t0, t1, ...

    def nm(v):
        if v not in names:
            names[v] = f"t{len(names)}"
        return names[v]

    def go(x):
        x = prune(x, subs)
        if x == 'int' or x == 'bool':
            return x
        if isinstance(x, tuple) and x[0] in ('tv', 'qtv'):
            return nm(x[1])
        if isinstance(x, tuple) and x[0] == 'forall':
            return go(x[2])
        if isinstance(x, tuple) and x[0] == '->':
            l = prune(x[1], subs)
            left = go(l)
            if isinstance(l, tuple) and l[0] == '->':
                left = f"({left})"               # ← prune-এর পরেই paren-check
            return f"{left} -> {go(x[2])}"
        return repr(x)

    return go(t)

ID = ('lam', 'x', ('var', 'x'))

tests = [
    ("λx.x",                    ID),
    ("(λx.x) 42",               ('app', ID, ('int', 42))),
    ("λf.λx.f (f x)",           ('lam', 'f', ('lam', 'x',
                                  ('app', ('var', 'f'),
                                   ('app', ('var', 'f'), ('var', 'x')))))),
    ("let id=λx.x in if id True then id 5 else 0",
                                ('let', 'id', ID,
                                 ('if', ('app', ('var', 'id'), ('bool', True)),
                                        ('app', ('var', 'id'), ('int', 5)),
                                        ('int', 0)))),
]

for name, ast in tests:
    try:
        reset()
        subs = {}
        print(f"{name:<46} : {show(infer({}, ast, subs), subs)}")
    except Exception as err:
        print(f"{name:<46} : ERROR {err}")

print("— failure cases —")

LAM_POLY = ('app',                                  # λ-bound id: reject হবে
            ('lam', 'id',
             ('if', ('app', ('var', 'id'), ('bool', True)),
                    ('app', ('var', 'id'), ('int', 5)),
                    ('int', 0))),
            ID)
OMEGA = ('app', ('lam', 'x', ('app', ('var', 'x'), ('var', 'x'))),
                ('lam', 'x', ('app', ('var', 'x'), ('var', 'x'))))

for name, ast in [("λid. if id True then id 5 else 0", LAM_POLY),
                  ("λx. x x applied to itself", OMEGA)]:
    try:
        reset()
        subs = {}
        print(f"{name:<46} : {show(infer({}, ast, subs), subs)}")
    except Exception as err:
        print(f"{name:<46} : ERROR ({type(err).__name__}) {err}")

প্রত্যাশিত output:

λx.x                                          : t0 -> t0
(λx.x) 42                                     : int
λf.λx.f (f x)                                 : (t0 -> t0) -> t0 -> t0
let id=λx.x in if id True then id 5 else 0    : int
— failure cases —
λid. if id True then id 5 else 0              : ERROR (TypeError) cannot unify bool with int
λx. x x applied to itself                     : ERROR (TypeError) infinite type: t0 = t0 -> t1

তিনটা যাচাই:

  1. Principal types — identity generic (t0 -> t0), twice-combinator পরিচিত shape; numbering আপনার run-এ আলাদা হতে পারে, shape একই।
  2. let-poly চলল, λ-poly আটকাল — হুবহু একই body, শুধু binding mechanism আলাদা; ফল opposite। Concept-section-এর explanation-টা এখন executable।
  3. Occurs check — λx. x x-এ error message-টা নিজেই সমীকরণটা ছাপায়: t0 = t0 -> t1। Compiler error আসলে failed equation-এর echo।
এটা কী প্রমাণ করে

Annotation ছাড়া লেখা expressions থেকে principal types বের হয় (identity → t0 -> t0, twice combinator সহ), let-bound function দুই incompatible use-তেও চলে কিন্তু λ-bound হয় না, আর λx. x x occurs-check-এ infinite-type error দেয় — HM-এর তিন core behavior হাতে-কলমে।

নিজে বানান

BUILD IT

Pair/tuple যোগ করুন — inference ইঞ্জিন বাড়ানোর নমুনা

Python · ●●●○○
  1. নতুন টাইপ-constructor যোগ করুন — ("*", t1, t2)
  2. AST-তে তিন node — ("pair", a, b), ("fst", e), ("snd", e)
  3. infer-এ তিনটা case — pair গঠন করে, fst/snd unify করে
  4. occurs/free_tvs/wrap/copy/show-তে "*" case propagate করুন
  5. Swap function-এর inferred type যাচাই করুন

Product type-যোগ করার সময় দেখবেন engine-এর প্রতিটা traversal function-এ একই ছোট diff লাগে — কারণ -> আর * দুটোই দুই-সন্তান type constructor। নিচের সম্পূর্ণ block-টা experiment-এর hm.py-র পরে paste করলেই pair-aware engine তৈরি (same-name function-রা override হয়ে যাবে):

# hmpairs.py — experiment-এর engine + pair support (সম্পূর্ণ)
import itertools

_fresh_counter = itertools.count()

def fresh():
    return ('tv', next(_fresh_counter))

def reset():
    global _fresh_counter
    _fresh_counter = itertools.count()

BIN = ('->', '*')          # দুই-সন্তান constructor — একই traversal pattern

def prune(t, subs):
    while isinstance(t, tuple) and t[0] == 'tv' and t[1] in subs:
        subs[t[1]] = resolve(subs[t[1]], subs)
        t = subs[t[1]]
    return t

def resolve(t, subs):
    t = prune(t, subs)
    if isinstance(t, tuple) and t[0] in BIN:
        return (t[0], resolve(t[1], subs), resolve(t[2], subs))
    return t

def occurs(v, t, subs):
    t = prune(t, subs)
    if t[0] == 'tv':
        return t[1] == v
    if t[0] in BIN:                                   # ← '*' সহ
        return occurs(v, t[1], subs) or occurs(v, t[2], subs)
    return False

def unify(a, b, subs):
    a, b = prune(a, subs), prune(b, subs)
    if a == b:
        return
    if isinstance(a, tuple) and a[0] == 'tv':
        if occurs(a[1], b, subs):
            raise TypeError(f"infinite type: t{a[1]} = {show(b)}")
        subs[a[1]] = b
        return
    if isinstance(b, tuple) and b[0] == 'tv':
        unify(b, a, subs)
        return
    if isinstance(a, tuple) and isinstance(b, tuple) \
            and a[0] == b[0] and a[0] in BIN:         # ← এক case-ই দুটোর জন্য
        unify(a[1], b[1], subs)
        unify(a[2], b[2], subs)
        return
    raise TypeError(f"cannot unify {show(a)} with {show(b)}")

def free_tvs(t, acc=None):
    acc = set() if acc is None else acc
    if isinstance(t, tuple):
        if t[0] == 'tv':
            acc.add(t[1])
        elif t[0] in BIN:
            free_tvs(t[1], acc)
            free_tvs(t[2], acc)
    return acc

def generalize(env, t, subs):
    bound = set()
    for s in env.values():
        if isinstance(s, tuple) and s[0] != 'forall':
            bound |= free_tvs(prune(s, subs))
    qv = sorted(free_tvs(prune(t, subs)) - bound)
    if not qv:
        return t
    qs = set(qv)

    def wrap(x):
        x = prune(x, subs)
        if x[0] == 'tv':
            return ('qtv', x[1]) if x[1] in qs else x
        if x[0] in BIN:
            return (x[0], wrap(x[1]), wrap(x[2]))
        return x

    return ('forall', tuple(qv), wrap(t))

def instantiate(sigma, subs):
    if not (isinstance(sigma, tuple) and sigma[0] == 'forall'):
        return sigma
    mapping = {v: fresh() for v in sigma[1]}

    def cp(x):
        x = prune(x, subs)
        if isinstance(x, tuple) and x[0] in ('tv', 'qtv'):
            return mapping.get(x[1], x)
        if isinstance(x, tuple) and x[0] in BIN:
            return (x[0], cp(x[1]), cp(x[2]))
        return x

    return cp(sigma[2])

def infer(env, e, subs):
    tag = e[0]
    if tag == 'int':
        return 'int'
    if tag == 'bool':
        return 'bool'
    if tag == 'var':
        if e[1] not in env:
            raise NameError(f"unbound variable: {e[1]}")
        return instantiate(env[e[1]], subs)
    if tag == 'lam':
        tv = fresh()
        return ('->', tv, infer({**env, e[1]: tv}, e[2], subs))
    if tag == 'app':
        tf, ta = infer(env, e[1], subs), infer(env, e[2], subs)
        tr = fresh()
        unify(tf, ('->', ta, tr), subs)
        return tr
    if tag == 'let':
        sigma = generalize(env, infer(env, e[2], subs), subs)
        return infer({**env, e[1]: sigma}, e[3], subs)
    if tag == 'if':
        unify(infer(env, e[1], subs), 'bool', subs)
        tt = infer(env, e[2], subs)
        unify(infer(env, e[3], subs), tt, subs)
        return tt
    if tag == 'pair':                                 # গঠন: product type
        return ('*', infer(env, e[1], subs), infer(env, e[2], subs))
    if tag in ('fst', 'snd'):                         # destructure দাবি
        tp = infer(env, e[1], subs)
        a, b = fresh(), fresh()
        unify(tp, ('*', a, b), subs)                  # "pair হতেই হবে"
        return a if tag == 'fst' else b
    raise ValueError(tag)

def show(t, subs):
    names = {}                                   # canonical naming: t0, t1, ...

    def nm(v):
        if v not in names:
            names[v] = f"t{len(names)}"
        return names[v]

    def go(x):
        x = prune(x, subs)
        if x == 'int' or x == 'bool':
            return x
        if x[0] in ('tv', 'qtv'):
            return nm(x[1])
        if x[0] == 'forall':
            return go(x[2])
        if x[0] == '->':
            l = prune(x[1], subs)
            left = go(l)
            if isinstance(l, tuple) and l[0] in BIN:   # বাঁটা composite → paren
                left = f"({left})"
            r = prune(x[2], subs)
            right = go(r)
            if isinstance(r, tuple) and r[0] == '*':
                right = f"({right})"             # a -> (b * c)
            return f"{left} -> {right}"
        if x[0] == '*':
            return f"{go(x[1])} * {go(x[2])}"
        return repr(x)

    return go(t)

ID = ('lam', 'x', ('var', 'x'))

tests = [
    ("λx.x", ID),
    ("fst (pair 1 True)",
     ('fst', ('pair', ('int', 1), ('bool', True)))),
    ("snd (pair 1 True)",
     ('snd', ('pair', ('int', 1), ('bool', True)))),
    ("λp. (snd p, fst p)",
     ('lam', 'p', ('pair', ('snd', ('var', 'p')),
                           ('fst', ('var', 'p'))))),
    ("let p = pair (λx.x) 5 in fst p",
     ('let', 'p', ('pair', ID, ('int', 5)), ('fst', ('var', 'p')))),
]
for name, ast in tests:
    try:
        reset()
        subs = {}
        print(f"{name:<34} : {show(infer({}, ast, subs), subs)}")
    except Exception as err:
        print(f"{name:<34} : ERROR ({type(err).__name__}) {err}")

প্রত্যাশিত output:

λx.x                               : t0 -> t0
fst (pair 1 True)                  : int
snd (pair 1 True)                  : bool
λp. (snd p, fst p)                 : (t0 * t1) -> (t1 * t0)
let p = pair (λx.x) 5 in fst p     : t0 -> t0

তিনটা পর্যবেক্ষণ:

  1. fst/snd-এর trick — pair-এর টাইপ আগে থেকে জানা নেই, তাই fresh a, b বানিয়ে unify(τ ≈ a*b) — “এটা যে-ই হোক, pair হতেই হবে” দাবি; ফল হিসেবে a বা b ফেরত। App-case-এর হুবহু একই ছক।
  2. Swap-এর symmetric type — (t0 * t1) -> (t1 * t0): inference নিজে থেকেই আবিষ্কার করে component-রা বিনিময়যোগ্য — annotation একটাও নেই।
  3. তৃতীয় test-টা সবচেয়ে গভীর — pair-এর ভেতরের function তবু generalize হয়েছে (let-poly pair-এর ভেতর পর্যন্ত ঢোকে, কারণ pair immutable value — value restriction মানল!)।

নিজে বাড়ান:

  1. Either/sum type যোগ করুন: ('either', t1, t2) + case construct — এটা Option/result-এর পথ; দেখবেন case-analysis inference-এ branch-unification চায় (if-এর generalization)।
  2. fix combinator (λf. (λx. f (x x)) (λx. f (x x))) লিখে দেখুন occurs check কী বলে — untyped recursion HM-এর জগতের বাইরে কেন, নিজের চোখে।
  3. Error-message upgrade: unify-এর প্রতিটা bind-এ “who demanded this” provenance string জমা রাখুন; failure-তে প্রথম-conflict-এর দুই demand-site report করুন — mini-Rust-diagnostic।

বাস্তব সিস্টেমে

Hindley-Milner যেখানে বাস্তবে দেখা যায়

ML-family — জন্মস্থান। OCaml, Standard ML, F#, Elm, Reason — সবার inference-core HM (F# একটু বেশি: OO-interop-এর জন্য annotation কিছু জায়গায় চায়)। Elm-এর বিখ্যাত friendly compiler errors — unification-conflict-এর provenance-reporting-এর শিল্প।

Haskell। HM + extensions: type classes (ad-hoc overload — পরের লেসনের বিষয়) dictionary-passing-এ অনুবাদ হয়, rank-N types, GADTs — প্রতিটা extension principal-type property-র কোনো-না-কোনো অংশ trade করে। Pure-HM subset এখনো language-এর হৃৎপিণ্ড।

Rust। Local inference HM-flavored: expression-এর ভেতরে types unification হয়, কিন্তু item-signature (fn boundaries) explicit declaration চায় — ঠিক value-restriction/modularity-র একই দর্শন: local freedom, boundary discipline। Trait resolution unification- এর উপরে এক তলা (trait obligations solving)।

Scala আর TypeScript। Subtyping-যুক্ত জগতে HM সরাসরি বসে না — constraint-generation local inference + flow analysis hybrid। TypeScript-এর contextual typing (const x: number = ...-এর callback- parameter গুলো auto-typed) ঠিক এই hybrid-এর product।

Java/C#-এর ধীর যাত্রা। Java 5 (2004)-এ generics এল explicit annotation-সহ; diamond operator (new HashMap<>(){}) Java 7-তে target-typing আনল; Java 8-এ target-typing lambdas-এ; আজও method- parameter-এর full inference নেই — কারণগুলো concept-section-এ: subtyping + overloading principal type-কে non-unique করে ফেলে। C# একই পথে সামান্য এগিয়ে (var, generic-method inference)।

Verification tools। Dafny, LiquidHaskell, F* — সবগুলোর ভেতরে unifier আছে; SMT solver-দের theory-of-datatypes reasoning মূলত unification-এর সাধারণীকরণ। আপনি আজ যে যন্ত্র লিখলেন, সেটার আত্মীয়রা proof-assistant-দের হৃৎপিণ্ডে কাজ করছে।

IDE experience-র ভিত্তি। IntelliJ/RustRover/VS Code-এ hover-এ type দেখা — প্রতিবার keystroke-এ partial Algorithm W চলে। Incremental inference (শুধু dirty region re-run) আধুনিক editor-latency engineering- এর মূল সমস্যাগুলোর একটা।

যে ভুলগুলো সবাই করে

“Type inference মানে dynamic typing — inference-যুক্ত ভাষা run-time-এ টাইপ দেখে।”

সম্পূর্ণ ভিন্ন দুই axis আবার! Inference মানে টাইপ কে বের করে (programmer না compiler), dynamic/static মানে কখন enforce হয়। OCaml-এ annotation-zero কোডও সম্পূর্ণ statically checked — সব equation compile-এ solve হয়, runtime-এ কোনো tag-check-ই হয় না (আগের লেসনের OCaml-tagless representation মনে করুন — inference- এর performance-dividend এটাই)। Inference static typing-এর ব্যবহার- খরচ কমায়, category বদলায় না।

“Compiler টাইপ 'অনুমান' করে — guess, তাই ভুলও করতে পারে।”

‘Inference’ শব্দটা misleading; যন্ত্রটা আসলে deduction — সমীকরণ solve। এক expression-এর প্রতিটা use-site একটা করে কঠোর দাবি জমা দেয়, unifier সব দাবির একমাত্র common solution বের করে বা প্রমাণ করে সমাধান নেই। Guess-এর কোনো room নেই: একই input-এ দুই compiler run দুই উত্তর দিলে সেটা compiler bug — determinism theorem আছে (principal type uniqueness)।

“Occurs check শুধু pedantic নিয়ম — practical কোডে দরকার নেই।”

Occurs check ছাড়া unifier t0 = t0 -> t1-কে silently bind করে তারপর infinite loop-এ ঢোকে (bind করে নিজের ভেতরেই নিজেকে expand করতে থাকে) — crash, wrong answer, বা memory exhaustion। আর semantics-গতভাবে সেই টাইপ দাঁড় করানো মানে untyped λ-calculus-এর Ω-term admit করা — nontermination-কে type-level বৈধতা। Performance- obsessed systems (Rust-এর কিছু path) কখনো occurs-deferred mode নেয়, তবে soundness-এর দায় সেখানে পরে মেটাতে হয়।

“let-polymorphism-এর বদলে সব জায়গায় λ-polymorphism দিলেই ভালো — কেন এই সীমাবদ্ধতা?”

System F-এ (explicit ∀) সব function polymorphic — কিন্তু দাম: full inference undecidable হয়ে যায়। HM-এর decidable, principal- typed, linear-time-ish magic ঠিক এই সীমা মেনে নেওয়ার ফল। Value restriction-ও একই family-র সিদ্ধান্ত: mutable state-এর সাথে naive generalization unsound — ML তাই values-only generalizes। ভাষা- ডিজাইনের পুরনো সত্য: expressiveness আর decidability একসাথে পাওয়া যায় না — Haskell-এর rank-N extension-গুলো তাই explicit signature চায়, silent দেয় না।

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

1

Unification-এর তিনটা outcome-এর নাম লিখুন আর প্রতিটার একটা করে উদাহরণ দিন (টাইপ-equation আকারে)।

স্মরণ

১. Bind (success with variable): t0 ≈ int → int — t0 := (int → int); variable বাঁধা পড়ল, substitution-map-এ entry।

২. Structural match (success without new info): int ≈ int, বা a → b ≈ c → d — দুই sub-unification-এ decompose।

৩. Fail (error): int ≈ bool — “cannot unify”; অথবা occurs- check fail t0 ≈ t0 → t1 — “infinite type”। দুই failure-এর message আলাদা কারণ দুই diagnosis আলাদা।

2

λx. λy. x (K combinator)-এর Algorithm W trace হাতে করুন — final principal type কী?

প্রয়োগ

ধাপ ১: λx. → fresh t0, x : t0। ধাপ ২: λy. → fresh t1, y : t1। ধাপ ৩: body x lookup → t0। ধাপ ৪: inner λ গঠন: t1 → t0। ধাপ ৫: outer λ গঠন: t0 → (t1 → t0)।

Final: t0 -> t1 -> t0 — প্রথম argument ফেরত দেয়, দ্বিতীয়টা discard। লক্ষ্য y-এর t1 result-এ আসেই না — discard-এর টাইপোগ্রাফিক স্বাক্ষর: parameter-variable result-এ absent মানে “যেকোনো টাইপ চলবে”। Haskell-এ a -> b -> a।

3

let g = λx. x in (g 1) আর (λg. g 1) (λx. x) — একই evaluation ফল দেয়, কিন্তু টাইপ-inference-এ ভাগ্য ভিন্ন। কোনটা কী পায় এবং formal-semantics lens দিয়ে ব্যাখ্যা করুন কেন এই অসমতা principled।

যুক্তি

let-version: g-এর টাইপ generalize হয় ∀α. α → α, instantiate-করে g 1 চলে — int। Lambda-version: g : t0 monomorphic — এক use মাত্র, তাই আসলে এখানে conflict হয় না, চলে যায়! Conflict দরকার দুই use: (λg. if g True then g 1 else 0)(λx.x) — এটা reject।

Principled ব্যাখ্যা: formal-semantics-এ let-এর B-rule premise-এ definition-এর পূর্ণ মান আগে হিসাব হয় — তাই body-analyze-এর সময় সেই knowledge stable, quantify নিরাপদ। Application-এর rule-এ argument-এর মান body-analyze-এর পরে substitute হয় — knowledge future-dependent, quantify অসাউন্ড। Inference-এর asymmetry টা evaluation-order-এর information-flow-র সরাসরি reflection।

4

নিচের প্রতিটার জন্য HM accept করবে না reject — কারণসহ এক লাইনে: (a) λx. if x then x else False (b) let r = pair 1 (λy.y) in snd r 5 (c) λf. f f

প্রয়োগ

(a) Accept — bool -> bool: condition থেকে x ≈ bool, branches unify (x আর False)।

(b) Accept — snd r : t0 → t0 (generalized — pair immutable value), apply 5 → int। Pair-ভেতরের function-ও let-generalize হয়।

(c) Reject — occurs check: t0 ≈ t0 → t1 জন্মায়, infinite type। Untyped λ-calculus-এর self-application টাইপ-জগতে নিষিদ্ধ — আর এই নিষেধাজ্ঞাই termination-এর (partial) গ্যারান্টির উৎস।

5

আপনার team-এর নতুন DSL-এ config-transformations লেখা হবে। সিদ্ধান্ত নিতে হবে: HM inference (annotation-free) না explicit signatures? তিনটা মাপকাঠিতে evaluate করুন — error-quality, API-evolution, learning-curve — আর recommendation দিন।

ডিজাইন

Error quality: inference wins initially (zero boilerplate), কিন্তু conflict দূরের লাইনে report হয়; explicit signatures দিলে error boundary-তেই আটকায়। Hybrid সম্ভব: inference + optional sig, mismatch- এ sig-vs-inferred diff ছাপা (Rust/Elm model)।

API evolution: explicit wins — published signature change হলে callers-এর কোথায় ভাঙবে তা sig-diff থেকেই mechanical; pure-inferred library-তে innocuous refactor-এর ভেতরে hidden type-change callers ভাঙে (Elm তাই top-level annotation encourage করে)।

Learning curve: inference wins onboarding-এ; কিন্তু debugging unification-error skill শিখতে হয়।

Recommendation: module-boundary-তে explicit, module-ভেতরে inference — Rust-এর ঠিক সমঝোতা। DSL-এর users সম্ভবত non-experts, তাই error- message investment (provenance reporting) যেকোনো পথের চেয়ে বেশি ROI দেবে।

6

Value restriction ছাড়া let r = ref [] in ...-জাতীয় generalized mutable reference কেন unsound — concrete attack sequence লিখুন (write এক টাইপে, read অন্য টাইপে)।

যুক্তি

Naive generalization: r : ∀α. α list ref। Attack:

r := [1, 2, 3];      // instantiate α := int — int list লেখা হলো
let s: string list = !r   // instantiate α := string — একই cell পড়া হলো
head(s)              // runtime-এ int-কে string হিসেবে interpret —
                     // memory-unsafe garbage pointer!

প্রতিটা use-site নিজের fresh α পায়, কিন্তু storage একটাই — polymorphism মানে separate copies, mutable ref মানে shared cell; দুই প্রতিশ্রুতি টক্কর খায়। Value restriction তাই mutable construction- কে generalize করে না — r-এর variable weak (monomorphic) থাকে, এক use-ই তার টাইপ স্থির করে দেয়। Haskell-এ ST-monad এই সমস্যার type-level solution (rank-2 types দিয়ে escape-prevention) — একই unsoundness-এর দুই ভিন্ন প্রতিকার।

এরপর কী

এরপর কী

আজ compiler-কে দেখলাম গোয়েন্দার মতো কাজ করতে — expression-এর use-site-গুলোর দাবি জমা নিয়ে সমীকরণ সমাধান করে, আপনার হাতে কলম না ধরেই টাইপ বের করা। কিন্তু খেয়াল করুন কী করলাম না: একটা function-কে দুই রকম caller-কে serve করানোর খরচটা — runtime- এ আসলে কী ঘটে যখন একই sort int-এর উপরে চলে, string-এর উপরে চলে? Compile-time-এ কি দুইটা আলাদা machine-code হয়, নাকি একটাই code দুই ধরনের data-তে চলে? খরচ কে বহন করে?

পরের লেসনে — Polymorphism — তিনটা বিভিন্ন উত্তর আছে দেখব: parametric (generics — Rust/Swift-এ monomorphisation বনাম Java/Go- তে type erasure), ad-hoc (overloading, typeclasses, traits — statically resolved), subtype (OO-র virtual dispatch — vtable- indirect call)। আর assembly খুলে প্রমাণ করব তিনটার runtime cost সত্যিই ভিন্ন — zero-cost, boxing, আর indirection।

আরও পড়ুন

  • "A Theory of Type Polymorphism in Programming" — Robin Milner · 1978-এর মূল পেপার — ML-এর type inference আর let-polymorphism এখান থেকে; Turing Award (1991)-এর অন্যতম কারণ
  • "Basic Polymorphic Typechecking" — Luca Cardelli · Algorithm W-এর সবচেয়ে readable implementation-note — এই লেসনের Python কোডের ধাঁচ এর ঘনিষ্ঠ
  • Types and Programming Languages, Chapter 22 — Type Reconstruction — Benjamin C. Pierce · Unification algorithm-এর correctness আর principal types-এর formal proof