Hindley-Milner Type Inference — annotation ছাড়াই টাইপ বের করা
Type Inference and Hindley-Milner
Unification আর Algorithm W — কীভাবে ML-family ভাষাগুলো annotation ছাড়াই সম্পূর্ণ টাইপ বের করে, let-polymorphism কী দেয় যা lambda-parameter দেয় না, আর কেন Java/C# একই জায়গায় দশক-জুড়ে আটকে ছিল।
আগে এটা বুঝি
আগের লেসনে দেখলাম 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 → intsolution 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 |
| ২ | x | lookup | t0 |
| ৩ | λx. x | function-টাইপ গঠন | 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 xTrace করুন: 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 0id 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-এর ভাষায়।
- AST walkপ্রতিটা node-এ W-এর case — literal fact, lambda fresh-var, app equation, let generalize
- Unify + bindequation মেলানো, substitution-map update, occurs check guard
- 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 intLambda-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, দুই দাবি এক কাঠে — ✗ছবিতে
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 τrFlowchart-এর দুইটা ✗ exit-ই user-facing error message-এর জন্মস্থান — আর দুইটার message-এর মানের পার্থক্যই Rust/Elm-style tooling-এর প্রতিযোগিতার মাঠ: “cannot unify” বলাটা সহজ, বলা কঠিন “এই argument-এ আপনি int পাঠিয়েছেন কিন্তু প্রথম use-তে এই function bool চেয়েছিল, source line X-এ” — যেটা করতে হলে constraint-এর provenance (কোন site কী জমা দিল) সাথে রাখতে হয়।
নিজে চালিয়ে দেখুন
~100 লাইনের Hindley-Milner engine — চালান, ভাঙান, বোঝেন
পুরো 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তিনটা যাচাই:
- Principal types — identity generic (
t0 -> t0), twice-combinator পরিচিত shape; numbering আপনার run-এ আলাদা হতে পারে, shape একই। - let-poly চলল, λ-poly আটকাল — হুবহু একই body, শুধু binding mechanism আলাদা; ফল opposite। Concept-section-এর explanation-টা এখন executable।
- 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 হাতে-কলমে।
নিজে বানান
Pair/tuple যোগ করুন — inference ইঞ্জিন বাড়ানোর নমুনা
- নতুন টাইপ-constructor যোগ করুন — ("*", t1, t2)
- AST-তে তিন node — ("pair", a, b), ("fst", e), ("snd", e)
- infer-এ তিনটা case — pair গঠন করে, fst/snd unify করে
- occurs/free_tvs/wrap/copy/show-তে "*" case propagate করুন
- 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তিনটা পর্যবেক্ষণ:
- fst/snd-এর trick — pair-এর টাইপ আগে থেকে জানা নেই, তাই
fresh
a, bবানিয়েunify(τ ≈ a*b)— “এটা যে-ই হোক, pair হতেই হবে” দাবি; ফল হিসেবে a বা b ফেরত। App-case-এর হুবহু একই ছক। - Swap-এর symmetric type —
(t0 * t1) -> (t1 * t0): inference নিজে থেকেই আবিষ্কার করে component-রা বিনিময়যোগ্য — annotation একটাও নেই। - তৃতীয় test-টা সবচেয়ে গভীর — pair-এর ভেতরের function তবু generalize হয়েছে (let-poly pair-এর ভেতর পর্যন্ত ঢোকে, কারণ pair immutable value — value restriction মানল!)।
নিজে বাড়ান:
Either/sum type যোগ করুন:('either', t1, t2)+caseconstruct — এটা Option/result-এর পথ; দেখবেন case-analysis inference-এ branch-unification চায় (if-এর generalization)।fixcombinator (λf. (λx. f (x x)) (λx. f (x x))) লিখে দেখুন occurs check কী বলে — untyped recursion HM-এর জগতের বাইরে কেন, নিজের চোখে।- 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 দেয় না।
বুঝেছেন কি না দেখুন
1Unification-এর তিনটা 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. λ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।
3let g = λx. x in (g 1) আর (λg. g 1) (λx. x) — একই evaluation
ফল দেয়, কিন্তু টাইপ-inference-এ ভাগ্য ভিন্ন। কোনটা কী পায় এবং
formal-semantics lens দিয়ে ব্যাখ্যা করুন কেন এই অসমতা principled।
যুক্তি
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
প্রয়োগ
λ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 দেবে।
6Value restriction ছাড়া let r = ref [] in ...-জাতীয় generalized
mutable reference কেন unsound — concrete attack sequence লিখুন
(write এক টাইপে, read অন্য টাইপে)।
যুক্তি
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