পাঠ ১৭ · ৫৮-এর মধ্যে · মডিউল ৪
Home / Courses / Concepts of Programming Languages & Compiler Design / রেগুলার এক্সপ্রেশন

লেক্সিক্যাল স্পেসিফিকেশনের জন্য রেগুলার এক্সপ্রেশন

Regular expressions for lexical specification
৮ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • regex-এর তিনটি মৌলিক অপারেটর এবং কেন এই তিনটিই সব regular ল্যাঙ্গুয়েজ প্রকাশে যথেষ্ট
  • a+, a?, character class-এর মতো শর্টহ্যান্ড কীভাবে এই তিনটি মৌলিক অপারেটর থেকে ডেরাইভ হয়
  • IDENTIFIER, NUMBER, WHITESPACE-এর জন্য precise regex প্যাটার্ন লেখা
  • from-scratch একটি regex AST বানানো এবং একটি রিকার্সিভ matches() ফাংশন লেখা (re মডিউল ছাড়াই)

১ · রেগুলার এক্সপ্রেশন কী

একটি রেগুলার এক্সপ্রেশন (Regular Expression)Regexএকটি formal নোটেশন যা একটি regular ল্যাঙ্গুয়েজ (স্ট্রিং-এর সেট) সংক্ষিপ্তভাবে বর্ণনা করে — L15-এর Chomsky হায়ারার্কির Type-3 ল্যাঙ্গুয়েজের সাথে ঠিক মিলে যায়। হলো একটি বর্ণমালার (alphabet) উপর প্যাটার্ন বর্ণনা করার একটি সংক্ষিপ্ত, ফরমাল নোটেশন। L15-এ আমরা দেখেছি প্রোগ্রামিং ল্যাঙ্গুয়েজের লেক্সিক্যাল গঠন (টোকেন) ইচ্ছাকৃতভাবে regular (Type 3) রাখা হয় — কারণ regular ল্যাঙ্গুয়েজ চেনার জন্য দক্ষ, লিনিয়ার-টাইম ফাইনাইট-অটোমাটা-ভিত্তিক পদ্ধতি আছে (L18-এ বিস্তারিত)। regex হলো সেই regular ল্যাঙ্গুয়েজগুলো লেখার জন্য মানুষের-পড়ার-উপযোগী নোটেশন — L16-এর "প্যাটার্ন" ধারণার ফরমাল বাস্তবায়ন।

২ · তিনটি মৌলিক অপারেটর

গুরুত্বপূর্ণ তাত্ত্বিক সত্য (এই পাঠের ভিত্তি): নিচের তিনটি অপারেটর একা যেকোনো regular ল্যাঙ্গুয়েজ প্রকাশ করার জন্য যথেষ্ট। লক্ষ্য করুন — এটি পাইথনের re মডিউলের সিনট্যাক্স নয়, বরং regex-এর তাত্ত্বিক ভিত্তি, যা আমরা এখানে একদম শুরু থেকে বানাব।

Concatenation
ab মানে "a-এর পর b" — দুটি প্যাটার্নকে পরপর জোড়া লাগানো।
Alternation (union)
a|b মানে "a অথবা b" — দুটি বিকল্প প্যাটার্নের যেকোনো একটি মিলতে পারে।
Kleene star
a* মানে "a-এর শূন্য বা তার বেশি পুনরাবৃত্তি" — জিরো রিপিটিশনও গ্রহণযোগ্য।
শর্টহ্যান্ড — মৌলিক তিনটি থেকে ডেরাইভড

বাস্তবে প্রায়ই ব্যবহৃত সুবিধাজনক নোটেশনগুলো — যেমন a+ ("এক বা তার বেশি"), a? ("শূন্য বা একটি"), অথবা character class [a-z] — এগুলো নতুন কোনো ক্ষমতা যোগ করে না, শুধু উপরের তিনটি মৌলিক অপারেটরের শর্টহ্যান্ড। যেমন — $a^{+} = a\,a^{*}$ (অন্তত একটি a, তারপর শূন্য বা আরও), এবং $a? = (a \mid \varepsilon)$ (a অথবা কিছুই না — $\varepsilon$ খালি স্ট্রিং বোঝায়)। character class [a-z] হলো ২৬টি char প্যাটার্নের একটি বড় alternation চেইনের শর্টহ্যান্ড মাত্র।

concat star (*) char 'b' char 'a'
regex "a*b"-এর AST — root একটি concat নোড, যার বাম সন্তান star('a') আর ডান সন্তান char('b')। এই একই গাছ L18-এ NFA বানানোর ভিত্তি হবে।

৩ · Worked প্যাটার্ন — IDENTIFIER, NUMBER, WHITESPACE

L16-এ আমরা টোকেন, লেক্সিম ও প্যাটার্নের পার্থক্য দেখেছি। এখন সেই প্যাটার্নগুলো precise regex দিয়ে সংজ্ঞায়িত করা যাক — এই ঠিক এই তিনটি প্যাটার্ন L18 (NFA→DFA), L19 (DFA মিনিমাইজেশন), ও L20 (লেক্সার) জুড়ে পুনরায় ব্যবহৃত হবে।

টোকেন প্যাটার্ন — এই মডিউল জুড়ে পুনঃব্যবহৃত

$IDENTIFIER = letter\,(letter \mid digit)^{*}$ — একটি অক্ষর দিয়ে শুরু, তারপর শূন্য বা তার বেশি অক্ষর/সংখ্যা।

$NUMBER = digit^{+} = digit\,digit^{*}$ — অন্তত একটি সংখ্যা-অঙ্ক।

$WHITESPACE = (space \mid tab)^{+}$ — অন্তত একটি স্পেস বা ট্যাব ক্যারেক্টার।

৪ · from-scratch একটি regex ম্যাচার

এই কোর্সের নিয়ম অনুযায়ী (দেখুন CLAUDE.md) আমরা পাইথনের re মডিউল ব্যবহার করব না — বরং regex-কে একটি ছোট্ট AST (Abstract Syntax Tree) হিসেবে নেস্টেড টাপল দিয়ে উপস্থাপন করব (('char', c), ('concat', r1, r2), ('union', r1, r2), ('star', r)), এবং একটি রিকার্সিভ matches(node, s) ফাংশন লিখব। কৌশলটি সহজ কিন্তু শক্তিশালী: প্রতিটি সাব-প্যাটার্ন প্রতিটি সম্ভাব্য শুরুর পজিশন থেকে কতদূর পর্যন্ত পৌঁছাতে পারে তার একটি সেট রিটার্ন করে (একাধিক সম্ভাব্য পথের ট্র্যাক রাখা — ঠিক যেভাবে L18-এর NFA-ও একসাথে একাধিক সম্ভাব্য স্টেট ট্র্যাক করে) — পুরো স্ট্রিং-এর দৈর্ঘ্য এই সেটে থাকলে তবেই সম্পূর্ণ ম্যাচ।

Python
# regex AST নোড: ('char', c) | ('concat', r1, r2) | ('union', r1, r2) | ('star', r)
# পাইথনের re মডিউল ব্যবহার করা হয়নি -- একদম হাতে-লেখা রিকার্সিভ ম্যাচার

def match_positions(node, s, start):
    """node প্যাটার্নটি s-এর start পজিশন থেকে যত পজিশন পর্যন্ত পৌঁছাতে পারে তার সেট।"""
    kind = node[0]
    if kind == 'char':
        c = node[1]
        if start < len(s) and s[start] == c:
            return {start + 1}
        return set()
    if kind == 'concat':
        left, right = node[1], node[2]
        result = set()
        for p in match_positions(left, s, start):
            result |= match_positions(right, s, p)
        return result
    if kind == 'union':
        left, right = node[1], node[2]
        return match_positions(left, s, start) | match_positions(right, s, start)
    if kind == 'star':
        inner = node[1]
        reachable = {start}
        frontier = {start}
        while frontier:
            new_positions = set()
            for p in frontier:
                for q in match_positions(inner, s, p):
                    if q not in reachable:
                        new_positions.add(q)
            frontier = new_positions
            reachable |= new_positions
        return reachable
    raise ValueError(f"অজানা নোড: {kind}")

def matches(node, s):
    return len(s) in match_positions(node, s, 0)

def union_of_chars(chars):
    node = ('char', chars[0])
    for c in chars[1:]:
        node = ('union', node, ('char', c))
    return node

letters = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
LETTER = union_of_chars(letters)
DIGIT = union_of_chars(digits)

# IDENTIFIER = letter (letter|digit)*
IDENTIFIER = ('concat', LETTER, ('star', ('union', LETTER, DIGIT)))
# NUMBER = digit digit*  (অর্থাৎ digit+)
NUMBER = ('concat', DIGIT, ('star', DIGIT))

print("IDENTIFIER প্যাটার্ন পরীক্ষা:")
for s in ["x", "x1", "total", "a1b2c3", "1x", "", "1"]:
    print(f"  matches(IDENTIFIER, {s!r}) = {matches(IDENTIFIER, s)}")

print("\nNUMBER প্যাটার্ন পরীক্ষা:")
for s in ["42", "0", "007", "", "4x", "x4"]:
    print(f"  matches(NUMBER, {s!r}) = {matches(NUMBER, s)}")

    
লক্ষ্য করুন — "1x" IDENTIFIER-এ ম্যাচ করেনি (সংখ্যা দিয়ে শুরু হতে পারে না) এবং "4x" NUMBER-এ ম্যাচ করেনি (শেষের দিকে অ-সংখ্যা ক্যারেক্টার আছে বলে সম্পূর্ণ স্ট্রিং কভার হয়নি)। matches() সবসময় সম্পূর্ণ স্ট্রিং কভার করা হয়েছে কিনা যাচাই করে — আংশিক ম্যাচ যথেষ্ট নয়।
মূল কথা · Key takeaway

মাত্র তিনটি মৌলিক অপারেটর — concatenation, alternation, star — দিয়ে যেকোনো regular ল্যাঙ্গুয়েজ প্রকাশ করা যায়, এবং একটি regex-কে একটি AST হিসেবে দেখলে তার উপর রিকার্সিভভাবে ম্যাচিং চালানো যায় — কোনো "জাদু" নেই, পুরোটাই ফরমাল কাঠামো। কিন্তু এই backtracking-স্টাইল রিকার্সিভ ম্যাচার বড় ইনপুটে ধীর হতে পারে — L18-এ আমরা দেখব কীভাবে regex-কে একটি ফাইনাইট অটোমাটায় রূপান্তর করলে ম্যাচিং প্রতি ক্যারেক্টারে ধ্রুব সময়ে (constant time) হয়ে যায়।

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

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

প্র ০১ যদি a+ এবং a? শুধুই শর্টহ্যান্ড হয়, তাহলে কেন বাস্তব regex ইঞ্জিন এগুলো আলাদা সিনট্যাক্স হিসেবে রাখে — সরাসরি aa* লিখতে বলে না কেন?

তাত্ত্বিক ক্ষমতার দিক থেকে কোনো পার্থক্য নেই — a+ এবং aa* ঠিক একই ভাষা বর্ণনা করে। কিন্তু writability (L04-এর ভাষার একটি ডিজাইন গোল) বাস্তব ব্যবহারে গুরুত্বপূর্ণ: জটিল প্যাটার্নে বারবার একই সাব-এক্সপ্রেশন দুবার লেখা (একবার সরাসরি, একবার star-এর ভিতরে) ভুল হওয়ার সম্ভাবনা বাড়ায় এবং পড়া কঠিন করে তোলে। শর্টহ্যান্ড কোনো নতুন ক্ষমতা যোগ করে না, কিন্তু এক্সপ্রেসিভনেস ও নির্ভুলতা বাড়ায় — ঠিক যেমন EBNF (L12) প্লেইন BNF-এর উপর একটি নোটেশনাল সুবিধা যোগ করেছিল, নতুন ক্ষমতা নয়।

প্র ০২ উপরের কোড সেলে match_positions ফাংশন একটি সিঙ্গেল পজিশন না রিটার্ন করে একটি সেট রিটার্ন করে কেন?

কারণ একটি প্যাটার্ন কোনো নির্দিষ্ট বিন্দুতে একাধিকভাবে "সফল" হতে পারে — বিশেষ করে star-এর ক্ষেত্রে, যেখানে "শূন্যবার পুনরাবৃত্তি" এবং "একবার পুনরাবৃত্তি" দুটোই বৈধ সম্ভাবনা, প্রতিটি ভিন্ন শেষ-পজিশনে নিয়ে যায়। একটি একক পজিশন রিটার্ন করলে (যেমন প্রথম সফল পথ বেছে নিয়ে) ভুল ফলাফল আসতে পারে — কারণ সেই একটি পথ পরবর্তী concat-এর জন্য কাজ না করলেও, সেট-এর অন্য কোনো পজিশন কাজ করতে পারত। "সব সম্ভাব্য পজিশনের সেট" ট্র্যাক রাখা ঠিক এই সমস্যা এড়ায় — এবং এটিই সরাসরি L18-এর NFA-র "একসাথে একাধিক স্টেট" ধারণার পূর্বাভাস।

প্র ০৩ IDENTIFIER প্যাটার্ন letter (letter|digit)*-এ কেন প্রথম সিম্বলটি শুধু letter, কিন্তু বাকিগুলো letter|digit হতে পারে?

এটি একটি ইচ্ছাকৃত ভাষা-ডিজাইন সিদ্ধান্ত (L04-এর মতো একটি ট্রেড-অফ), সাধারণ প্রোগ্রামিং ল্যাঙ্গুয়েজ কনভেনশন মেনে: যদি একটি identifier সংখ্যা দিয়ে শুরু করতে পারত (যেমন 12x), তাহলে লেক্সার এটিকে NUMBER টোকেন 12 এবং আলাদা IDENTIFIER টোকেন x-এর মাঝে দ্বিধায় পড়ত অথবা ভুল ব্যাখ্যা করত — মানুষের চোখেও 12x "বারো এক্স" নাকি একটি ভেরিয়েবল নাম বোঝা কঠিন হতো। "letter দিয়ে শুরু" নিয়মটি এই অস্পষ্টতা একেবারে শুরুতেই দূর করে দেয়।

অনুশীলন

  1. নিজে লিখুন: WHITESPACE = (space|tab)+ প্যাটার্নটির জন্য একটি regex AST (নেস্টেড টাপল) হাতে লিখুন, ধরে নিন ('char', ' ') স্পেস আর ('char', '\t') ট্যাব বোঝায়।

    WHITESPACE = ('concat', ('union', ('char', ' '), ('char', '\t')), ('star', ('union', ('char', ' '), ('char', '\t')))) — অর্থাৎ প্রথমে একটি স্পেস-অথবা-ট্যাব (বাধ্যতামূলক, অন্তত একটি), তারপর শূন্য বা তার বেশি আরও স্পেস-অথবা-ট্যাব। এটিই (space|tab)+-এর সরাসরি X X* ডেরিভেশন, যা প্র-০১-এর উত্তরের সাথে সামঞ্জস্যপূর্ণ।

  2. পরীক্ষা করুন: উপরের কোড সেলে IDENTIFIER প্যাটার্নটি "a_b" স্ট্রিং-এর উপর চালিয়ে দেখুন ফলাফল কী আসে, এবং কেন।

    ফলাফল False। কারণ আমাদের সংজ্ঞা letter (letter|digit)*-এ আন্ডারস্কোর (_) অন্তর্ভুক্ত নেই — শুধু letters ও digits সেট থেকে ক্যারেক্টার অনুমোদিত। বাস্তব ভাষায় (Python, C) identifier-এ আন্ডারস্কোর প্রায়ই অনুমোদিত থাকে — সেটির জন্য LETTER-এর সংজ্ঞা সম্প্রসারিত করে আন্ডারস্কোরকে একটি অতিরিক্ত alternation বিকল্প হিসেবে যোগ করতে হবে, যা এই পাঠের সরলীকৃত সংজ্ঞায় ইচ্ছাকৃতভাবে বাদ রাখা হয়েছে।

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

আগের পাঠ
টোকেন, লেক্সিম ও প্যাটার্ন