পাঠ ৫৮ · ৫৮-এর মধ্যে · মডিউল ১৩
Home / Courses / Concepts of Programming Languages & Compiler Design / চূড়ান্ত প্রকল্প

চূড়ান্ত প্রকল্প — একটি মিনি ইন্টারপ্রেটার বানানো

Capstone — building a mini interpreter
১৮ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • আগের মডিউলগুলোর টুকরো টুকরো ধারণা কীভাবে একটি একক, সুসংগত পাইপলাইনে জোড়া লাগে
  • একটি সম্পূর্ণ, কার্যকরী লেক্সার-পার্সার-সিম্বল-টেবিল-ইভালুয়েটর পাইপলাইন বাস্তবায়ন
  • একটি বাস্তব ছোট প্রোগ্রাম টোকেন থেকে চূড়ান্ত আউটপুট পর্যন্ত সম্পূর্ণভাবে ট্রেস করা
  • কেন একটি ইন্টারপ্রেটারের সিম্বল টেবিল প্রয়োজন, এমনকি আলাদা কোনো স্ট্যাটিক টাইপ-চেকিং ছাড়াই

১ · এই কোর্সের সবকিছু, একসাথে

এই পাঠ নতুন কোনো তত্ত্ব শেখায় না — এটি একটি সংশ্লেষণ। আমরা একটি MiniInterpreter বানাব যা M4/L20-এর লেক্সার, M5/L22-এর পার্সার, M6/L28-L29-এর সিম্বল টেবিল, এবং M6/L30 ও M9/L41-এর ইভালুয়েশন ধারণা — সবগুলো একটি একক, কার্যকরী পাইপলাইনে জোড়া দেয়। ভাষাটি ছোট কিন্তু বাস্তব — সংখ্যা, আইডেন্টিফায়ার, + - * /, বন্ধনী, অ্যাসাইনমেন্ট (=), এবং print সমর্থন করে।

সোর্স কোড (মাল্টি-লাইন স্ট্রিং) লেক্সার (M4/L20) -> টোকেন স্ট্রিম পার্সার (M5/L22) -> AST সিম্বল টেবিল (M6/L28-L29) ট্রি-ওয়াকিং ইভালুয়েটর (M6/L30) প্রকৃত এক্সিকিউটেড আউটপুট
এই পাঁচটি ধাপের প্রতিটিই এই কোর্সের একটি আলাদা মডিউলে আলাদাভাবে শেখানো হয়েছে — এই পাঠ শুধু এদের একসাথে জোড়া দিচ্ছে।

২ · লেক্সার — M4/L20-এর স্টাইল পুনর্ব্যবহার

একটি হাতে-লেখা, চরিত্র-বাই-চরিত্র লেক্সার — সংখ্যা, আইডেন্টিফায়ার/কীওয়ার্ড (print), অপারেটর (+ - * /), বন্ধনী, =, এবং নিউলাইন (স্টেটমেন্ট বিভাজক হিসেবে) চেনে। M4/L16-এর "ম্যাক্সিমাল মাঞ্চ" নীতি অনুযায়ী একাধিক সংখ্যা/অক্ষর একসাথে একটি টোকেনে জমা হয়।

৩ · পার্সার — M5/L22-এর গ্রামার পুনর্ব্যবহার

M5/L22-এর বাম-রিকার্শন-মুক্ত এক্সপ্রেশন গ্রামার (expr, term, factor) হুবহু পুনর্ব্যবহার করা হয়েছে, উপরে একটি সরল স্টেটমেন্ট-লেভেল গ্রামার যোগ করে —

  • statement ::= IDENT '=' expr NEWLINE | 'print' '(' expr ')' NEWLINE
  • expr ::= term expr_tail, expr_tail ::= ('+'|'-') term expr_tail | ε
  • term ::= factor term_tail, term_tail ::= ('*'|'/') factor term_tail | ε
  • factor ::= NUMBER | IDENT | '(' expr ')'

৪ · সিম্বল টেবিল ও ইভালুয়েটর — M6/L28-L30 পুনর্ব্যবহার

একটি SymbolTable ক্লাস স্কোপের একটি স্ট্যাক রাখে (M6/L29-এর স্কোপ রেজোলিউশন ধারণা) — lookup সবচেয়ে ভেতরের স্কোপ থেকে বাইরের দিকে খুঁজে, না পেলে একটি স্পষ্ট এরর তোলে। ইভালুয়েটর AST-কে বটম-আপ ট্রি-ওয়াক করে (M6/L30) — প্রতিটি এক্সপ্রেশন নোড রিকার্সিভভাবে মূল্যায়িত হয়ে একটি সংখ্যায় পরিণত হয়, প্রতিটি স্টেটমেন্ট নোড হয় সিম্বল টেবিল আপডেট করে (অ্যাসাইনমেন্ট) নয়তো real আউটপুট তৈরি করে (print)।

এই কোর্সের সম্পূর্ণ যাত্রা, একনজরে

লেক্সার টেক্সটকে টোকেনে ভাঙে (M4) → পার্সার টোকেনকে একটি গঠনবদ্ধ AST-তে সাজায় (M5) → সিম্বল টেবিল নামের অর্থ ট্র্যাক করে (M6) → ইভালুয়েটর সেই গঠনকে প্রকৃত মান ও পার্শ্ব-প্রতিক্রিয়ায় (side effect, এখানে print) রূপান্তর করে (M6/M9) — এটাই ঠিক L01-এর ভূমিকায় দেখানো পাইপলাইন, এখন সম্পূর্ণভাবে বাস্তবায়িত।

৫ · বাস্তবায়ন — সম্পূর্ণ পাইপলাইন, একটি বাস্তব প্রোগ্রামে

নিচের কোড সেলে সম্পূর্ণ MiniInterpreter বাস্তবায়ন করা হয়েছে, এবং একটি ছোট প্রোগ্রাম চালানো হয়েছে — x = 3 + 4, y = x * 2, তারপর print(y) ও print(x + y)। টোকেন স্ট্রিম, সম্পূর্ণ AST, এবং প্রকৃত এক্সিকিউটেড আউটপুট — সব ধাপ আলাদাভাবে দেখানো হয়েছে।

Python
# ---------- ১) লেক্সার (M4/L20 স্টাইল) ----------
class Token:
    def __init__(self, type_, value):
        self.type = type_
        self.value = value
    def __repr__(self):
        return f"Token({self.type},{self.value!r})"

KEYWORDS = {"print"}

def tokenize(source):
    tokens = []
    i, n = 0, len(source)
    while i < n:
        ch = source[i]
        if ch in " \t":
            i += 1; continue
        if ch == "\n":
            tokens.append(Token("NEWLINE", "\\n")); i += 1; continue
        if ch.isdigit():
            start = i
            while i < n and source[i].isdigit():
                i += 1
            tokens.append(Token("NUMBER", int(source[start:i]))); continue
        if ch.isalpha() or ch == "_":
            start = i
            while i < n and (source[i].isalnum() or source[i] == "_"):
                i += 1
            word = source[start:i]
            tokens.append(Token("PRINT", word) if word in KEYWORDS else Token("IDENT", word))
            continue
        simple = {"+": "PLUS", "-": "MINUS", "*": "STAR", "/": "SLASH",
                  "(": "LPAREN", ")": "RPAREN", "=": "ASSIGN"}
        if ch in simple:
            tokens.append(Token(simple[ch], ch)); i += 1; continue
        raise ValueError(f"লেক্সিক্যাল এরর: অজানা ক্যারেক্টার {ch!r}, পজিশন {i}")
    tokens.append(Token("EOF", None))
    return tokens


# ---------- ২) স্কোপযুক্ত সিম্বল টেবিল (M6/L28-L29 স্টাইল) ----------
class SymbolTable:
    def __init__(self):
        self.scopes = [{}]   # স্কোপের স্ট্যাক -- এখানে শুধু গ্লোবাল স্কোপ ব্যবহৃত হচ্ছে

    def assign(self, name, value):
        for scope in reversed(self.scopes):
            if name in scope:
                scope[name] = value
                return
        self.scopes[-1][name] = value   # নতুন ভ্যারিয়েবল -- বর্তমান স্কোপে ঘোষিত হয়

    def lookup(self, name):
        for scope in reversed(self.scopes):
            if name in scope:
                return scope[name]
        raise NameError(f"অঘোষিত ভ্যারিয়েবল: {name!r}")


# ---------- ৩) রিকার্সিভ ডিসেন্ট পার্সার (M5/L22 গ্রামার) ----------
class ParseError(Exception):
    pass

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0

    def peek(self):
        return self.tokens[self.pos]

    def advance(self):
        tok = self.tokens[self.pos]; self.pos += 1; return tok

    def expect(self, type_):
        tok = self.peek()
        if tok.type != type_:
            raise ParseError(f"{type_} প্রত্যাশিত, পাওয়া গেছে {tok.type}")
        return self.advance()

    def skip_newlines(self):
        while self.peek().type == "NEWLINE":
            self.advance()

    def parse_program(self):
        statements = []
        self.skip_newlines()
        while self.peek().type != "EOF":
            statements.append(self.parse_statement())
            self.skip_newlines()
        return ("program", statements)

    def parse_statement(self):
        tok = self.peek()
        if tok.type == "IDENT":
            name = self.advance().value
            self.expect("ASSIGN")
            expr = self.parse_expr()
            if self.peek().type == "NEWLINE": self.advance()
            return ("assign", name, expr)
        if tok.type == "PRINT":
            self.advance(); self.expect("LPAREN")
            expr = self.parse_expr()
            self.expect("RPAREN")
            if self.peek().type == "NEWLINE": self.advance()
            return ("print", expr)
        raise ParseError(f"স্টেটমেন্টের শুরুতে অপ্রত্যাশিত টোকেন: {tok.type}")

    def parse_expr(self):
        node = self.parse_term()
        while self.peek().type in ("PLUS", "MINUS"):
            op = self.advance().type
            rhs = self.parse_term()
            node = ("binop", "+" if op == "PLUS" else "-", node, rhs)
        return node

    def parse_term(self):
        node = self.parse_factor()
        while self.peek().type in ("STAR", "SLASH"):
            op = self.advance().type
            rhs = self.parse_factor()
            node = ("binop", "*" if op == "STAR" else "/", node, rhs)
        return node

    def parse_factor(self):
        tok = self.peek()
        if tok.type == "NUMBER":
            self.advance(); return ("num", tok.value)
        if tok.type == "IDENT":
            self.advance(); return ("var", tok.value)
        if tok.type == "LPAREN":
            self.advance()
            node = self.parse_expr()
            self.expect("RPAREN")
            return node
        raise ParseError(f"factor-এ অপ্রত্যাশিত টোকেন: {tok.type}")


# ---------- ৪) ট্রি-ওয়াকিং ইভালুয়েটর (M6/L30, M9/L41 স্টাইল) ----------
class MiniInterpreter:
    def __init__(self):
        self.symtab = SymbolTable()
        self.output = []

    def run(self, source):
        tokens = tokenize(source)
        ast = Parser(tokens).parse_program()
        self.exec_node(ast)
        return tokens, ast, self.output

    def exec_node(self, node):
        kind = node[0]
        if kind == "program":
            for stmt in node[1]:
                self.exec_node(stmt)
        elif kind == "assign":
            _, name, expr = node
            self.symtab.assign(name, self.eval_expr(expr))
        elif kind == "print":
            _, expr = node
            value = self.eval_expr(expr)
            self.output.append(value)
            print(f"  >>> print আউটপুট: {value}")
        else:
            raise ValueError("অজানা স্টেটমেন্ট নোড: " + kind)

    def eval_expr(self, node):
        kind = node[0]
        if kind == "num":
            return node[1]
        if kind == "var":
            return self.symtab.lookup(node[1])       # M6/L28-এর lookup -- নাম-রেজোলিউশন
        if kind == "binop":
            _, op, l, r = node
            lv, rv = self.eval_expr(l), self.eval_expr(r)
            if op == "+": return lv + rv
            if op == "-": return lv - rv
            if op == "*": return lv * rv
            if op == "/": return lv // rv
        raise ValueError("অজানা এক্সপ্রেশন নোড: " + kind)


# ---------- সম্পূর্ণ পাইপলাইন চালানো ----------
source = "x = 3 + 4\ny = x * 2\nprint(y)\nprint(x + y)\n"
print("সোর্স প্রোগ্রাম:")
print(source)

interp = MiniInterpreter()
tokens, ast, output = interp.run(source)

print("== ধাপ ১: টোকেন স্ট্রিম (M4) ==")
for t in tokens:
    print(" ", t)

print("\n== ধাপ ২: AST (M5) ==")
print(" ", ast)

print("\n== ধাপ ৩: এক্সিকিউশন (উপরে print আউটপুট ইতিমধ্যে দেখানো হয়েছে) ==")
print("সংগ্রহ করা print আউটপুট:", output)

print("\n== চূড়ান্ত যাচাই ==")
print("x =", interp.symtab.lookup("x"), " y =", interp.symtab.lookup("y"))
assert interp.symtab.lookup("x") == 7
assert interp.symtab.lookup("y") == 14
assert output == [14, 21]
print("PASS -- x=7, y=14, print(y)->14, print(x+y)->21 -- সবকিছু সঠিক")

print("\n== অঘোষিত ভ্যারিয়েবলের এরর হ্যান্ডলিং (M5/L27, M6/L28 স্টাইল) ==")
try:
    MiniInterpreter().run("print(z)\n")
except NameError as e:
    print("সঠিকভাবে ধরা পড়েছে:", e)

    
লক্ষ্য করুন — কোনো ফলাফল হাতে বসিয়ে দেওয়া হয়নি। tokenize সত্যিই সোর্স টেক্সট থেকে টোকেন তৈরি করেছে, Parser সত্যিই সেই টোকেন থেকে একটি AST বানিয়েছে (M5/L22-এর গ্রামারের সাথে হুবহু মিলিয়ে যাচাইযোগ্য), এবং MiniInterpreter সত্যিই সেই AST ট্রি-ওয়াক করে সিম্বল টেবিল আপডেট করেছে ও print-এর real আউটপুট তৈরি করেছে। x = 3 + 4 = 7, y = 7 * 2 = 14, print(y) = 14, print(x + y) = print(7 + 14) = 21 — প্রতিটি ধাপ কোড নিজে গণনা করেছে, আমরা শুধু হাতে-যাচাই করেছি ফলাফল প্রত্যাশিত মানের সাথে মেলে।
মূল কথা · Key takeaway — এবং কোর্সের শেষ কথা

একটি ইন্টারপ্রেটার — যত ছোটই হোক না কেন — এই কোর্সের প্রতিটি মডিউলের একটি বাস্তব প্রয়োগ। লেক্সিং, পার্সিং, সিম্বল টেবিল, ইভালুয়েশন — কোনোটিই বিচ্ছিন্ন তত্ত্ব নয়, প্রতিটি একটি বড় যন্ত্রের অপরিহার্য একটি অংশ। আপনি যদি এই পাঠ পর্যন্ত পৌঁছে থাকেন — অভিনন্দন, আপনি এখন জানেন সোর্স কোডের একটি লাইন কীভাবে ধাপে ধাপে একটি চলমান প্রোগ্রামে পরিণত হয়, শুরু থেকে শেষ পর্যন্ত।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ এই ইন্টারপ্রেটারের কোনো আলাদা স্ট্যাটিক টাইপ-চেকিং ধাপ নেই (M7-এর অর্থে), তবুও এটি একটি সিম্বল টেবিল (M6/L28) ব্যবহার করে কেন? print(z)-এ z অঘোষিত হলে কী ঘটবে?

সিম্বল টেবিল দরকার নাম রেজোলিউশনের জন্য — একটি ভ্যারিয়েবল নামকে তার প্রকৃত মানের সাথে যুক্ত করা — এটি টাইপ-চেকিং থেকে সম্পূর্ণ আলাদা প্রশ্ন। যখন eval_expr একটি ("var", "z") নোড মূল্যায়ন করে, তখন symtab.lookup("z") কল হয়; যদি "z" কখনো কোনো স্কোপে অ্যাসাইন করা না হয়ে থাকে, lookup সঠিকভাবে NameError তোলে — উপরের কোড সেলের শেষে ঠিক এই আচরণটাই দেখানো হয়েছে। এটি প্রমাণ করে সিম্বল টেবিল টাইপ-চেকিং না থাকলেও প্রয়োজনীয় — যেকোনো ভাষায়, নাম রেজোলিউশন একটি মৌলিক, পৃথক প্রয়োজন।

প্র ০২ যদি সোর্স প্রোগ্রামে y = x * 2-এর আগে x = 3 + 4 না থাকত (ক্রম উল্টে দেওয়া হতো), তাহলে কী হতো?

y = x * 2 এক্সিকিউট হওয়ার সময় eval_expr(("var", "x")) কল হতো, যা symtab.lookup("x") কল করত — কিন্তু x তখনো কোনো স্কোপে অ্যাসাইন করা হয়নি, তাই NameError: অঘোষিত ভ্যারিয়েবল: 'x' উঠত। এটি দেখায় এই ইন্টারপ্রেটার সোর্স কোডের ক্রম অনুযায়ী এক্সিকিউট করে (M9/L41-এর evaluation order ধারণা) — একটি ভ্যারিয়েবল ব্যবহারের আগেই তার অ্যাসাইনমেন্ট এক্সিকিউট হয়ে যেতে হবে।

প্র ০৩ এই ইন্টারপ্রেটারের পার্সার M5/L22-এর গ্রামার হুবহু পুনর্ব্যবহার করেছে। যদি মূল গ্রামারে বাম-রিকার্শন এলিমিনেট করা না থাকত, তাহলে এই ক্যাপস্টোনে কী সমস্যা হতো?

M5/L22-এ দেখানো হয়েছিল একটি বাম-রিকার্সিভ গ্রামার (expr ::= expr + term | term) দিয়ে সরাসরি রিকার্সিভ ডিসেন্ট পার্সার লিখলে parse_expr নিজেকে কোনো ইনপুট consume না করেই আবার কল করত — অসীম রিকার্শন, একটি প্রকৃত স্ট্যাক ওভারফ্লো (M11/L50-এর "stack overflow" ধারণার সরাসরি বাস্তব প্রকাশ)। এই ক্যাপস্টোনের parse_expr/parse_term ফাংশন তাই ডান-রিকার্সিভ/ইটারেটিভ রূপ (while লুপ, M5/L22-এর expr' রূপান্তরেরই একটি বাস্তব প্রয়োগ) ব্যবহার করে, যা নিরাপদে টার্মিনেট করে।

অনুশীলন

  1. চিন্তা করুন: উপরের কোড সেলের source ভ্যারিয়েবলে যদি একটি নতুন লাইন z = (x + y) / 3 যোগ করা হয়, z-এর চূড়ান্ত মান কত হবে বলে আপনি হাতে গণনা করেন?

    x=7, y=14 হওয়ায় z = (7 + 14) / 3 = 21 / 3 = 7 (পূর্ণসংখ্যা ভাগ, যেহেতু ইভালুয়েটরে / অপারেটর // ব্যবহার করে বাস্তবায়িত)। বন্ধনী থাকায় parse_factor প্রথমে ভেতরের x + y এক্সপ্রেশন সম্পূর্ণ মূল্যায়ন করবে (M5/L22-এর গ্রামারের '(' expr ')' নিয়ম অনুযায়ী), তারপর ৩ দিয়ে ভাগ হবে।

  2. পরীক্ষা করুন: উপরের কোড সেলে source-এর শেষে print(x - y) যোগ করে Run চেপে দেখুন — নতুন AST ও আউটপুটে কী পরিবর্তন হয়?

    টোকেন স্ট্রিমে PRINT, LPAREN, IDENT('x'), MINUS, IDENT('y'), RPAREN, NEWLINE নতুন যুক্ত হবে। AST-তে program-এর statement তালিকায় একটি নতুন ("print", ("binop", "-", ("var","x"), ("var","y"))) নোড যুক্ত হবে। এক্সিকিউশনে নতুন একটি print আউটপুট: -7 দেখা যাবে (x - y = 7 - 14 = -7), এবং output তালিকা হবে [14, 21, -7]।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
কেস স্টাডি: বাস্তব ভাষাগুলোতে প্যারাডাইম তুলনা