রিকার্সিভ ডিসেন্ট পার্সিং
এই পাঠে যা শিখবেন
- রিকার্সিভ ডিসেন্ট পার্সিং-এর মূল ধারণা — গ্রামার নিয়ম ও পার্সিং ফাংশনের সরাসরি এক-এক সম্পর্ক
- বাম-রিকার্শন সমস্যা — নাইভ রিকার্সিভ ডিসেন্ট কেন ইনফিনিট রিকার্শনে আটকে যায়
- Left-recursion elimination — গ্রামার রূপান্তরের স্ট্যান্ডার্ড কৌশল
- একটি সম্পূর্ণ কার্যকরী রিকার্সিভ ডিসেন্ট পার্সার লেখা ও তার মাধ্যমে এক্সপ্রেশন মান নির্ণয়
১ · রিকার্সিভ ডিসেন্ট পার্সিং কী
রিকার্সিভ ডিসেন্ট পার্সিংRecursive Descent Parsingএকটি টপ-ডাউন পার্সিং টেকনিক যেখানে গ্রামারের প্রতিটি নন-টার্মিনালের জন্য একটি আলাদা পার্সিং ফাংশন লেখা হয়। হলো M5/L21-এ আলোচিত টপ-ডাউন পার্সিং-এর সবচেয়ে সরাসরি বাস্তবায়ন। মূল ধারণাটি অত্যন্ত সরল অথচ শক্তিশালী — গ্রামারের প্রতিটি নন-টার্মিনালের জন্য একটি করে ফাংশন লেখা হয়, এবং সেই ফাংশনগুলো একে অপরকে ঠিক সেভাবেই কল করে যেভাবে গ্রামারের নিয়মগুলো একে অপরকে রেফার করে। এই কোর্স জুড়ে ব্যবহৃত L12-এর গাণিতিক গ্রামার দিয়ে এটি দেখা যাক (মূল আকারে, এখনো রূপান্তরিত নয়) —
$$E \to E + T \mid E - T \mid T$$ $$T \to T * F \mid T / F \mid F$$ $$F \to ( E ) \mid \text{number}$$
লক্ষ্য করুন — কোডের গঠন গ্রামারের গঠনকে আক্ষরিক অর্থে প্রতিফলিত করে: parse_expr
ফাংশন <expr>-এর নিয়ম বাস্তবায়ন করে, parse_term ফাংশন
<term>-এর নিয়ম বাস্তবায়ন করে, এবং parse_factor ফাংশন
<factor>-এর নিয়ম বাস্তবায়ন করে — একটি ফাংশন অন্যটিকে কল করে ঠিক যেভাবে একটি নন-টার্মিনাল
অন্যটিকে তার নিয়মে ব্যবহার করে। এই এক-এক সম্পর্কের কারণেই রিকার্সিভ ডিসেন্ট বোঝা ও ডিবাগ করা তুলনামূলক সহজ।
parse_factor
যদি বন্ধনী দেখে, এটি আবার parse_expr-কে কল করে ফিরে উপরে ওঠে — এটাই রিকার্শন।২ · বাম-রিকার্শন সমস্যা
উপরের মূল গ্রামারটি সরাসরি রিকার্সিভ ডিসেন্টে বাস্তবায়ন করার চেষ্টা করলে একটি গুরুতর সমস্যায় পড়তে হয়।
লক্ষ্য করুন <expr> ::= <expr> + <term> | <term> নিয়মে —
<expr> তার নিজের এক্সপানশনের একদম প্রথম চিহ্ন হিসেবে নিজেই আছে। একে বলা হয়
বাম-রিকার্শনLeft Recursionএকটি নন-টার্মিনাল যখন তার নিজের প্রোডাকশনের বাম-দিকের (প্রথম) চিহ্ন হিসেবে নিজেই উপস্থিত থাকে।।
একটি নাইভ parse_expr ফাংশন এই নিয়মটি সরাসরি অনুসরণ করলে —
নাইভ বাস্তবায়নে parse_expr-এর প্রথম কাজ হবে <expr> পার্স করার চেষ্টা —
অর্থাৎ নিজেকেই আবার কল করা, কোনো টোকেন consume করার আগেই। যেহেতু ইনপুট পজিশন এক
চুলও এগোয়নি, দ্বিতীয় কলও একই কাজ করবে, তৃতীয়টিও — এটি সত্যিকারের একটি ইনফিনিট রিকার্শন, শুধু তাত্ত্বিক
সমস্যা নয়, বাস্তবে প্রোগ্রাম ক্র্যাশ করে (স্ট্যাক ওভারফ্লো)।
৩ · সমাধান — Left-recursion elimination
স্ট্যান্ডার্ড সমাধান হলো গ্রামারটিকে পুনর্লিখা — একই ভাষা বোঝায় এমন একটি সমতুল্য গ্রামার,
কিন্তু বাম-রিকার্শন ছাড়া। কৌশলটি: A ::= A α | β আকারের একটি নিয়মকে (যেখানে β বাম-রিকার্সিভ
নয়) নিচের মতো রূপান্তর করা হয় —
$$A \to \beta\,A' \qquad A' \to \alpha\,A' \mid \varepsilon$$
এখানে বাম-রিকার্শন ডান-রিকার্শনে রূপান্তরিত হয়েছে — এবং ডান-রিকার্শন রিকার্সিভ ডিসেন্টে সম্পূর্ণ নিরাপদ, কারণ প্রতিটি পুনরাবৃত্তিতে অন্তত একটি টোকেন (α-এর প্রথম চিহ্ন) consume হয়। L12-এর পুরো এক্সপ্রেশন গ্রামারে এই রূপান্তর প্রয়োগ করলে —
$$E \to T\,E' \qquad E' \to {+}\,T\,E' \mid {-}\,T\,E' \mid \varepsilon$$ $$T \to F\,T' \qquad T' \to {*}\,F\,T' \mid {/}\,F\,T' \mid \varepsilon$$ $$F \to ( E ) \mid \text{number}$$
E'/T'-এর ডান-রিকার্শনকে সরাসরি রিকার্সিভ ফাংশন কল হিসেবে না লিখে
একটি while লুপ দিয়ে বাস্তবায়ন করা হয় — টেইল-রিকার্শনকে ইটারেশনে রূপান্তরের এটাই স্ট্যান্ডার্ড,
বহুল-ব্যবহৃত কৌশল, এবং নিচের কোডে ঠিক এভাবেই বাস্তবায়িত হয়েছে।
৪ · সম্পূর্ণ পার্সার বাস্তবায়ন
নিচের কোড সেলে রূপান্তরিত গ্রামারের উপর একটি real, কার্যকরী রিকার্সিভ ডিসেন্ট পার্সার লেখা হয়েছে —
parse_expr, parse_term, parse_factor — প্রতিটি টোকেন consume করে এবং
(parse_tree, new_position) রিটার্ন করে। "2 + 3 * 4" পার্স করে তার পার্স ট্রি থেকে মান নির্ণয়
করা হয়েছে — L14-এর ফলাফলের সাথে মিলিয়ে দেখুন, সঠিক precedence-এ উত্তর হওয়া উচিত 14, ২০ নয়।
# ব্যাকরণ (L12-এর গ্রামার, left-recursion elimination করা):
# <expr> ::= <term> <expr'>
# <expr'> ::= + <term> <expr'> | - <term> <expr'> | ε
# <term> ::= <factor> <term'>
# <term'> ::= * <factor> <term'> | / <factor> <term'> | ε
# <factor>::= ( <expr> ) | number
#
# প্র্যাকটিসে <expr'>/<term'>-এর ডান-রিকার্শনকে একটি while লুপ দিয়ে বাস্তবায়ন করা হয় --
# টেইল-রিকার্শনকে ইটারেশনে রূপান্তরের এটাই স্ট্যান্ডার্ড কৌশল।
def tokenize(expr):
tokens = []
i = 0
while i < len(expr):
ch = expr[i]
if ch.isspace():
i += 1
continue
elif ch.isdigit():
start = i
while i < len(expr) and expr[i].isdigit():
i += 1
tokens.append(("NUMBER", expr[start:i]))
elif ch in "+-*/()":
tokens.append(("OP", ch))
i += 1
else:
raise ValueError(f"অজানা ক্যারেক্টার: {ch!r}")
tokens.append(("EOF", ""))
return tokens
def parse_expr(tokens, pos):
"""<expr> ::= <term> <expr'> -- <expr'>-এর রিকার্শন এখানে while লুপ"""
left, pos = parse_term(tokens, pos)
while tokens[pos][0] == "OP" and tokens[pos][1] in ("+", "-"):
op = tokens[pos][1]
pos += 1
right, pos = parse_term(tokens, pos)
left = (op, left, right)
return left, pos
def parse_term(tokens, pos):
"""<term> ::= <factor> <term'>"""
left, pos = parse_factor(tokens, pos)
while tokens[pos][0] == "OP" and tokens[pos][1] in ("*", "/"):
op = tokens[pos][1]
pos += 1
right, pos = parse_factor(tokens, pos)
left = (op, left, right)
return left, pos
def parse_factor(tokens, pos):
"""<factor> ::= ( <expr> ) | number"""
kind, val = tokens[pos]
if kind == "NUMBER":
return ("num", int(val)), pos + 1
elif kind == "OP" and val == "(":
node, pos = parse_expr(tokens, pos + 1)
assert tokens[pos] == ("OP", ")"), "বন্ধনী মিলছে না"
return node, pos + 1
else:
raise SyntaxError(f"অপ্রত্যাশিত টোকেন: {tokens[pos]}")
def evaluate_tree(tree):
if tree[0] == "num":
return tree[1]
op, l, r = tree
lv, rv = evaluate_tree(l), evaluate_tree(r)
if op == "+": return lv + rv
if op == "-": return lv - rv
if op == "*": return lv * rv
if op == "/": return lv / rv
expr_text = "2 + 3 * 4"
tokens = tokenize(expr_text)
tree, final_pos = parse_expr(tokens, 0)
result = evaluate_tree(tree)
print(f"এক্সপ্রেশন: {expr_text!r}")
print(f"পার্স ট্রি: {tree}")
print(f"মান নির্ণয়ের ফলাফল: {result}")
assert result == 14, "precedence ভুল হলে ফলাফল 20 হতো, কিন্তু সঠিক উত্তর 14"
print("precedence সঠিক -- আগে গুণ, তারপর যোগ")
print()
print("--- naive left-recursive সংস্করণ কেন কাজ করবে না (bounded demo) ---")
def naive_left_recursive_expr(tokens, pos, depth=0, max_depth=6):
# <expr> ::= <expr> + <term> | <term> -- ইনপুট consume করার আগেই নিজেকে কল করে
if depth >= max_depth:
return f"(safety-limit-এ থামানো হলো depth={depth} -- বাস্তবে এটি অসীম রিকার্শন হতো)"
return naive_left_recursive_expr(tokens, pos, depth + 1, max_depth)
print(naive_left_recursive_expr(tokens, 0))
রিকার্সিভ ডিসেন্ট পার্সিং সরল ও কোড-গ্রামার এক-এক সম্পর্কের কারণে হাতে-লেখা পার্সারের জন্য জনপ্রিয়, কিন্তু এটি বাম-রিকার্সিভ গ্রামারে সরাসরি কাজ করে না। Left-recursion elimination গ্রামারকে একটি সমতুল্য, ডান-রিকার্সিভ রূপে পুনর্লিখে এই সীমাবদ্ধতা দূর করে — এবং সেই ডান-রিকার্শনকে বাস্তবে একটি সাধারণ লুপ হিসেবেই বাস্তবায়ন করা হয়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ <expr> ::= <expr> + <term> | <term> নিয়মটি ঠিক কেন বাম-রিকার্সিভ, আর <expr'> ::= + <term> <expr'> | ε নিয়মটি কেন নয়?
বাম-রিকার্শনের সংজ্ঞা অনুযায়ী নন-টার্মিনালটি তার নিজের এক্সপানশনের একদম প্রথম চিহ্ন হিসেবে থাকতে হবে।
প্রথম নিয়মে <expr>-এর একটি বিকল্প (<expr> + <term>) শুরুই হয়
<expr> দিয়ে — অর্থাৎ প্রথম চিহ্ন নিজেই। দ্বিতীয় নিয়মে <expr'>-এর
বিকল্পগুলো শুরু হয় টার্মিনাল + দিয়ে, এবং <expr'> রেফারেন্সটি
এক্সপানশনের শেষে — এটি ডান-রিকার্শন, যা প্রথমে একটি টোকেন consume করে তারপর নিজেকে কল করে,
তাই রিকার্সিভ ডিসেন্টে নিরাপদ।
প্র ০২ উপরের কোডে parse_expr-এর ভেতরে <expr'>-এর জন্য আলাদা কোনো ফাংশন নেই, বরং একটি while লুপ আছে। এটা কি গ্রামারের সাথে সাংঘর্ষিক?
না, এটি সম্পূর্ণ সমতুল্য একটি বাস্তবায়ন কৌশল। <expr'> ::= + <term> <expr'> | ε
নিয়মটি বলছে: "যতক্ষণ পরবর্তী টোকেন + বা - হয়, একটি <term> পার্স করে যোগ/বিয়োগ করতে থাকো, নাহলে
থামো (ε)।" এটি হুবহু একটি while লুপের বর্ণনা — ডান-রিকার্শনকে (বিশেষ করে যখন সেটি টেইল-পজিশনে
থাকে) লুপে রূপান্তর করা একটি স্ট্যান্ডার্ড, ফলাফল-অপরিবর্তনকারী কৌশল, আলাদা parse_expr_prime
ফাংশন লেখা আবশ্যক নয়।
প্র ০৩ "2 + 3 * 4" পার্স করার সময় parse_factor প্রথম কবে কল হয়, এবং তখন কী রিটার্ন করে?
কল-চেইন শুরু হয় parse_expr(tokens, 0) দিয়ে, যা সাথে সাথে parse_term(tokens, 0)
কল করে, যা সাথে সাথে parse_factor(tokens, 0) কল করে — প্রথম parse_factor কলটি এভাবেই
ঘটে, একদম শুরুতে। পজিশন 0-এ টোকেনটি ("NUMBER", "2"), তাই parse_factor এটি
সরাসরি একটি লিফ নোড ("num", 2) হিসেবে রিটার্ন করে, সাথে নতুন পজিশন 1। এরপর
parse_term পরবর্তী টোকেন দেখে (যা +, গুণ/ভাগ নয়) — তাই তার লুপ কার্যকর হয় না,
এবং এটি ("num", 2)-ই ফিরিয়ে দেয় parse_expr-এ, যেখানে + দেখে
লুপ শুরু হয়।
অনুশীলন
-
চিন্তা করুন: <factor> ::= ( <expr> ) | number নিয়মে <factor> নিজেকে সরাসরি
রেফার করে না, কিন্তু পরোক্ষভাবে <expr> হয়ে <term> হয়ে আবার <factor>-এ ফিরে আসতে পারে
(বন্ধনীর ভেতরে আরেকটি বন্ধনী)। এটি কি সমস্যাযুক্ত রিকার্শন?
না, এটি সমস্যাযুক্ত নয় — এটি সাধারণ (নন-লেফট) রিকার্শন। সমস্যা তখনই হয় যখন কোনো নন-টার্মিনাল ইনপুট consume না করেই নিজেকে (সরাসরি বা পরোক্ষভাবে) আবার কল করে। কিন্তু
<factor>থেকে<expr>-এ যেতে হলে প্রথমে একটি(টোকেন consume করতে হয় — অর্থাৎ প্রতিটি নেস্টেড কলের আগে ইনপুট পজিশন এগিয়ে যায়, তাই এটি সসীম গভীরতায় শেষ হবে (যতটা গভীর বন্ধনী নেস্টিং ইনপুটে আছে ততটাই)। এটি ঠিক "(((a)))" এর মতো নেস্টেড এক্সপ্রেশন পার্স করার স্বাভাবিক, নিরাপদ উপায়। -
পরীক্ষা করুন: উপরের কোড সেলে expr_text-এর মান "(2 + 3) * 4" করে Run চেপে দেখুন ফলাফল কী হয়, এবং কেন এটি L22-এর মূল উদাহরণ থেকে ভিন্ন উত্তর দেয়।
ফলাফল হবে 20, কারণ 14 নয়। বন্ধনী
<factor> ::= ( <expr> )নিয়মের মাধ্যমে "2 + 3"-কে একটি একক ইউনিটে পরিণত করে দেয় —parse_factorবন্ধনী দেখেই আবারparse_expr-কে রিকার্সিভভাবে কল করে, যা "2 + 3"-কে সম্পূর্ণ একটি<factor>হিসেবে গণ্য করে। এরপর সেই ফলাফল (5) কে 4 দিয়ে গুণ করা হয় — precedence-কে ওভাররাইড করে প্রাকৃতিক গণিতের নিয়ম অনুযায়ী (2+3)×4 = 20 পাওয়া যায়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ পরবর্তী পাঠ L23-এ আমরা LL(1) — একটি টেবিল-চালিত টপ-ডাউন পদ্ধতি — এবং FIRST/FOLLOW সেট শিখব।
- L21 · পার্সিং ওভারভিউ — টপ-ডাউন বনাম বটম-আপ পূর্বের পাঠ টপ-ডাউন ও বটম-আপ পার্সিং-এর বড় ছবি — এই পাঠের ভিত্তি।
- Data Structures & Algorithms কোর্স সঙ্গী কোর্স রিকার্সিভ ডিসেন্ট পার্সার আসলে একটি রিকার্শন-ব্যবহারকারী ট্রি-বিল্ডিং অ্যালগরিদম — এই কোর্সে রিকার্শনের ভিত্তি শেখানো হয়েছে।