Konštrukcia jazykových procesorov – Syntaktická analýza.

Ciele

  1. Pochopiť princíp syntaktickej analýzy metódou rekurzívneho zostupu.
  2. Prepojiť poznatky o lexikálnej a syntaktickej analýze pri konštrukcii jednoduchého jazykového procesora aritmetických výrazov – implementovať ich lexikálny a syntaktický analyzátor.
  3. Uviesť princíp syntaxou riadenej interpretácie výrazov a implementovať ho do syntaktikého analyzátora.

Úvod

V tomto cvičení sa detailnejšie oboznámime s druhou fázou spracovania zdrojového kódu, syntaktickou analýzov, ktorá umožňuje overiť, či zdrojový kód zodpovedá gramatickým pravidlám programovacieho jazyka. Ukážeme si, ako syntaktická analýza transformuje lineárny prúd tokenov získaných lexikálnou analýzou na štruktúrovanú reprezentáciu programu vo forme stromu odvodenia alebo AST, ktoré zachytávajú hierarchické vzťahy medzi jednotlivými konštrukciami jazyka.

Osobitnú pozornosť budeme venovať rekurzívnej syntaktickej analýze zhora nadol, ktorá patrí medzi najjednoduchšie a najčitateľnejšie metódy spracovania gramatík. Na záver kapitoly si ukážeme praktický príklad: zostavíme gramatiku jazyka aritmetických výrazov, implementujeme jeho lexikálny a sytaktický analyzátor spolu so syntaxou riadenou interpretáciou, ktorá umožní výrazy nielen syntakticky overiť, ale aj vyhodnotiť ich hodnotu.

Postup

Krok 1

Syntaktická analýza alebo parsing je druhá fáza spracovania zdrojového kódu, v rámci ktorej dochádza ku kontrole, či vstupná sekvencia tokenov zodpovedá gramatickým pravidlám jazyka. V jej priebehu sa vytvára vnútornú reprezentácia vstupného reťazca, často vo forme stromu odvodenia alebo AST.

Pri tvorbe syntaktického analyzátora (angl. parser) je možné postupovať dvoma spôsobmi:

  • zhora nadol (z angl. top-down),
  • zdola nahor (z angl. bottom-up).

Rekurzívny zostup je najjednoduchší spôsob zostavenia syntaktického analyzátora zhora nadol, pretože začína od najvyššieho alebo najvzdialenejšieho gramatického pravidla a postupuje smerom nadol do vnorených podvýrazov, kým sa nedostane k listom odvodzovacieho stromu. To je v kontraste s analyzátormi zdola nahor, ktoré začínajú elementárnymi výrazmi a postupne ich skladajú do zložitejších syntaktických výrazov.

Analyzátor rekurzívneho zostupu je doslovný preklad pravidiel gramatiky priamo do imperatívneho kódu. Nasledujúca tabuľka prakticky prezentuje základné princípy implementácie takéhoto syntaktického analyzátora.

EBNF Implementácia
A ::= ... def A(): ...
Neterminálny symbol A Volanie A()
Terminály symbol a get_next_symbol() if (current_symbol == a) else error()
X | Y match current_symbol: $\phantom{pričompričompričompričompričom}$ $\phantom{case}$$\phantom{case}$ $\phantom{case}$case FIRST(X): ... $\phantom{pri}$pričom platí FIRST(X)$\cap$ FIRST(Y)$= \emptyset$ $\phantom{case}$ $\phantom{case}$case FIRST(Y): ...
[ X ] if (current_symbol in FIRST(X)): ...
{ X } while (current_symbol in FIRST(X)): ...

Poznámka

Funkcia FIRST(X) vracia množinu terminálnych symbolov, ktoré môžu stáť na začiatku slova odvodeného z vetnej formy X. Podrobnejšie sa tejto množine budeme venovať v 8. cvičení.

Rekurzívny zostup interpretuje pravidlá gramatiky ako predpis pre štruktúru procedúr. Pri analyzátore s jedným symbolom dopredného pohľadu možno postupovať mechanicky, podľa nasledujúcich zásad zhrnutých taktiež v predchádzajúcej tabuľke:

  • Hlavným princípom implementácie syntaktického analyzátora je vytvorenie procedúry s názvom neterminálneho symbolu na ľavej strane tohto pravidla pre každé pravidlo gramatiky.
  • Neterminálnemu symbolu na pravej strane každého pravidla zodpovedá volanie procedúry.
  • Terminálnemu symbolu zodpovedá volanie procedúry, ktorá prečíta zo vstupného reťazca ďalší terminálny symbol.
  • Postupnosti symbolov na pravej strane každého pravidla zodpovedá postupnosť volaní procedúr uvedených v predchádzajúcich bodoch.
  • Alternácii | a výrazu voliteľnosti [ ] zodpovedá príkaz vetvenia (if alebo match-case, ktorý predstavuje switch-case konštrukciu v jazyku Python), vstup do príkazu vetvenia je bodom rozhodnutia.
  • Kleeneho uzáveru resp. iterácii { } zodpovedá príkaz cyklu (while). Vstup do príkazu cyklu je bodom rozhodnutia.
  • Samotný proces analýzy sa spustí volaním procedúry zodpovedajúcej začiatočnému symbolu gramatiky.

Tok riadenia syntaktického analyzátora vyplývajúci z postupnosti volaní jednotlivých procedúr predstavuje odvodzovací strom analyzovaného (akceptovaného) výrazu. Takto vytvorený strom poskytuje prehľadnú štruktúru, ktorá odhaľuje vnútornú reprezentáciu výrazu a zároveň slúži ako základ pre ďalšie fázy jeho prekladu, resp. interpretácie.

Celý proces postupnej transformácie vstupu od zdrojového textu, cez lexikálnu analýzu produkujúcu prúd tokenov, až po syntaktickú analýzu konštruujúcu strom odvodenia je schematicky znázornený na konkrétnom príklade vstupu na nasôedujúcom obrázku.

Proces postupnej transformácie vstupnej vety prechodom cez iniciálne fázy kompilácie
Obr. 1: Proces postupnej transformácie vstupnej vety prechodom cez iniciálne fázy kompilácie

Krok 2

V tomto kroku pristúpime ku konštrukcii gramatiky aritmetických výrazov, implementácii lexikálneho a syntaktického analyzátora a jeho záverečnej modifikácii na syntaxou riadený interpretátor.

Syntax jazyka

Abeceda jazyka aritmetických výrazov, pozostáva z nasledujúcich terminálnych symbolov:

  • terminálne symboly pre číselne konštanty (celé nezáporné čísla),
  • ^ pre binárnu operáciu umocňovania s najvyššou úrovňou priority a štandardnou pravou asociativitou,
  • * pre binárnu operáciu násobenia s nižšou úrovňou priority ako umocňovanie a ľavou asociativitou.
  • + a - pre binárne operácie sčítania a odčítania s rovnakou najnižšou úrovňou priority a ľavou asociativititou,
  • jazyk bude podporovať aj zátvorkované výrazy využitím terminálnych symbolov ( a ).

Výsledná gramatika: $G=(\{$E, T, F, X$\},\{$+, -, *, ^, (, )$\}, P$,E$)$ kde $P$ je nasledujúca množina prepisovacích pravidiel v EBNF:

${}$ ${}$
E ::= T {("+" | "-") T}
T ::= F {"*" F}
F ::= X ["^" F ]
X ::= cislo | "(" E ")"

Návrh a implementácia lexikálneho analyzátora

Nasledujúca séria obrázkov 7 až 9 ilustruje postupnú konštrukciu formálneho modelu lexikálneho analyzátora na báze DKA podľa vyššie prezentovaného postupu.

Množina DKA z kroku 1.
Obr. 2: Množina DKA z kroku 1.

NKA z kroku 2.
Obr. 3: NKA z kroku 2.

Výsledný DKA z kroku 3.
Obr. 4: Výsledný DKA z kroku 3.

Prvým krokom pri implementácii lexikálneho analyzátora je návrh dátovej štruktúry pre token, ktorú realizujeme prostredníctvom nasledujúcej definície triedy Token:

# Trieda reprezentujúca token
class Token:
    def __init__(self, type, value=None):
        self.type = type        # Typ tokenu (z enum TokenType)
        self.attribute = value  # Hodnota tokenu (relevantná hlavne pre čísla)
    
    def __repr__(self):
        # Reťazcová reprezentácia tokenu
        if self.attribute is not None:
            return f"<{self.type}, {self.attribute}>"
        return f"<{self.type}>"

Typy tokenov môžeme definovať pomocou enumeračného typu TokenType nasledovne:

# Definícia typov tokenov
class TokenType(Enum):
    NUM_CONST = auto() # číselná konštanta (celé nezáporne číslo)
    PLUS = auto()      # operátor sčítania '+'
    MINUS = auto()     # operátor odčítania '-'
    MULTIPLY = auto()  # operátor násobenia '*'
    POWER = auto()     # operátor umocnenia '^'
    LPAREN = auto()    # ľavá zátvorka '('
    RPAREN = auto()    # pravá zátvorka ')'
    EOF = auto()       # koniec vstupu (End Of File)

Poznámka

Token signalizujúci koniec vstupu EOF je dôležitý pre detekciu úplného spracovania reťazca.

Teraz sa podrobne pozrieme na implementáciu samotného lexikálneho analyzátora.

Inicializácia lexikálneho analyzátora: Vstupný reťazec sa uloží do internej premennej, pozícia aktuálne čítaného znaku vo vstupnom reťazci sa nastaví na hodnotu 0 a aktuálne čítaný znak na prvý znak vo vstupnom reťazci alebo na hodnotu None, v prípade ak je vstupný reťazec prázdny.

# Lexikálny analyzátor - rozpoznáva a extrahuje tokeny zo vstupného reťazca
class Scanner:
    def __init__(self, input_text):
        self.input_text = input_text
        self.pos = 0
        self.current_char = self.input_text[0] if input_text else None

Pomocná metóda advance: Metóda posunie aktuálnu pozíciu vo vstupnom reťazci a aktualizuje práve čítaný znak. Ak sa pozícia dostane mimo rozsahu reťazca, aktuálny znak sa nastaví na hodnotu None.

    def advance(self):
        self.pos += 1
        if self.pos < len(self.input_text):
            self.current_char = self.input_text[self.pos]
        else:
            self.current_char = None

Ignorovanie bielych znakov: Lexikálny analyzátor ignoruje medzery, pretože pre analýzu aritmetických výrazov nie sú významné.

    def skip_whitespace(self):
        while self.current_char is not None and self.current_char.isspace():
            self.advance()

Hlavný algoritmus lexikálneho analyzátora: Táto metóda prechádza vstupný reťazec po znakoch a identifikuje jednotlivé lexémy:

    def get_next_token(self):
        while self.current_char is not None:
            # Ignorovanie medzier
            if self.current_char.isspace():
                self.skip_whitespace()
                continue
                
            # Rozpoznávanie jednotlivých lexém a priradenie im zodpovedajúcich tokenov
            match self.current_char:
                case '0':
                    self.advance()
                    return Token(TokenType.NUM_CONST, 0)
                # Rozpoznávanie numerálov
                case ch if ch.isdigit():
                    # Vracia token typu NUM_CONST s číselnou hodnotou.
                    # TODO: Implementujte rozpoznávanie čísla pomocou Hornerovej schémy
                case '+':
                    self.advance()
                    return Token(TokenType.PLUS)
                case '-':
                    self.advance()
                    return Token(TokenType.MINUS)
                case '*':
                    self.advance()
                    return Token(TokenType.MULTIPLY)
                case '^':
                    self.advance()
                    return Token(TokenType.POWER)
                case '(':
                    self.advance()
                    return Token(TokenType.LPAREN)
                case ')':
                    self.advance()
                    return Token(TokenType.RPAREN)
                case _:
                    # Ak narazí na neznámy znak, zahlási chybu
                    raise ValueError(f"Nerozpoznaný znak: '{self.current_char}'")

        # Keď prejde celý vstupný text, vráti token konca vstupného textu
        return Token(TokenType.EOF)

Úloha 2.1

Analyzujte zdrojový kód pre lexikálny analyzátor a doplňte chýbajúcu časť zodpovednú za rozpoznanie tokenu typu NUM_CONST pomocou Hornerovej schémy.

Poznámka

Hornerova schéma je efektívna metóda na výpočet hodnoty polynómu alebo čísla reprezentovaného v určitej číselnej sústave. V kontexte lexikálnej analýzy sa využíva na prevod numerálu, napr. 123 na jeho celočíselnú hodnotu. Napríklad číslo $123$ možno prepočítať takto: $$(((1*10)+2)*10)+3=123$$ Tento prístup je výpočtovo efektívny, pretože minimalizuje počet násobení a sčítaní a umožňuje spracovávať čísla postupne po cifrách.

Návrh a implementácia syntaktického analyzátora

Po vytvorení lexikálneho analyzátora sa teraz zameriame na syntaktickú analýzu, ktorej cieľom je overiť, či sekvencia tokenov zodpovedá pravidlám gramatiky. Pristúpime preto k implementácii syntaktického analyzátora metódou rekurzívneho zostupu, ktorú opíšeme v nasledujúcich krokoch.

Inicializácia syntaktického analyzátora: Syntaktický analyzátor využíva lexikálny analyzátor ako sprostredkovateľa tokenov získavaných zo vstupného reťazca. Pri jeho inicializácii sa preto inštancia lexikálneho analyzátora uloží do internej premennej, aby mohla byť opakovane využívaná počas analýzy. Zároveň sa hneď na začiatku vykoná načítanie prvého tokenu zo vstupu.

# Syntaktický analyzátor
class Parser:
    def __init__(self, scanner):
        self.scanner = scaner
        self.current_token = self.scanner.get_next_token()

Pomocná metóda consume: Táto metóda slúži na kontrolu, či aktuálny token zodpovedá očakávanému typu podľa gramatických pravidiel. V prípade zhody zabezpečí posun na nasledujúci token. Ak typ tokenu nezodpovedá očakávaniu, ohlási syntaktickú chybu.

    def consume(self, expected_token_type):
        if self.current_token.type == expected_token_type:
            self.current_token = self.scanner.get_next_token()  # Prejdeme na ďalší token
        else:
            raise SyntaxError(f"Syntaktická chyba: očakávaný token {expected_token_type}, " 
                             f"získaný {self.current_token.type}")

Procedúra E: Táto procedúra je zodpovedná za spracovanie operácií sčítania a odčítania s najnižšou prioritou a ľavou asociativitou. Prvý operand sa spracuje volaním procedúry T, následne sa opakovane spracovávajú ďalšie podľa výskytu + alebo - vo vstupnom reťazci.

    def E(self):
        self.T()
        while self.current_token.type in (TokenType.PLUS, TokenType.MINUS):
            token = self.current_token
            
            if token.type == TokenType.PLUS:
                self.consume(TokenType.PLUS)
                self.T()
                
            elif token.type == TokenType.MINUS:
                self.consume(TokenType.MINUS) 
                self.T()

Štruktúra procedúr procedúry T je úplne analogická k štruktúre procedúry E. Istá variácia nastáva pri procedúre F, ktorá na rozdiel od E a T nepoužíva cyklus, ale podmienku a rekurzívne volanie. Táto zmena odráža pravú asociativitu operátora umocňovania, na rozdiel od ľavej asociativity operátorov sčítania odčítania a násobenia.

Procedúra X: Táto procedúra je zodpovedná za spracovanie základných stavebných prvkov aritmetických výrazov – číselných konštánt a taktiež vnorených výrazov v zátvorkách. Keďže ide o najnižšiu úroveň zostupu v gramatike, analyzátor v tomto bode očakáva výlučne numerál, alebo otváraciu zátvorku ako začiatok vnoreného výrazu.

Pri praktickej implementácii však nestačí pokryť len tieto dve možnosti. Je nevyhnutné počítať aj s prípadom, že sa na vstupe nachádza iný, neplatný symbol, teda taký, ktorý nezodpovedá žiadnemu inému prípadu tohto pravidla. Z tohto dôvodu implementácia obsahuje aj predvolenú vetvu (z angl. default), ktorá takúto situáciu identifikuje ako syntaktickú chybu.

def X(self):
    match self.current_token.type:
        case TokenType.NUM_CONST:
            self.consume(TokenType.NUM_CONST)
        case TokenType.LPAREN:
            self.consume(TokenType.LPAREN)
            self.E()
            self.consume(TokenType.RPAREN)            
        case _:
            raise SyntaxError(f"Syntaktická chyba: Neočakávaný token {self.current_token}")

Vstupný bod syntaktickej analýzy: Procedúra parse je hlavný spúšťač syntaktickej analýzy. Zavolá procedúru E a následne overí, či po spracovaní neostali ďalšie nespotrebované tokeny (čo by signalizovalo chybu). Je to bežný koncový krok v analyzátoroch.

    def parse(self):
        self.E()
        if self.current_token.type != TokenType.EOF:
            raise SyntaxError(f"Očakávaný koniec vstupu, nájdené: {self.current_token}")

Úloha 2.2

Vašou úlohou je analyzovať zdrojový kód pre syntaktický analyzátor a doplniť implementáciu pravidiel gramatiky $G$ pre neterminálne symboly T a F.

Krok 3

Syntaktická analýza môže byť rozšírená o priame vyhodnocovanie výrazu už počas jeho spracovania, čo predstavuje tzv. syntaxou riadená interpretácia. Na tento účel je však potrebné pristúpiť k miernej úprave implementácie syntaktického analyzátora prezentovanej v predchádzajúcom kroku: Namieto procedúr sú použité funkcie, ktorých návratová hodnota zodpovedá výsledku (hodnote) práve spracovávanej časti výrazu.

Poznámka

Návratové hodnoty týchto funkcií sú na Obrázkoch 5, 6, 7 a 8 v poslednom kroku predchádzajúceho cvičenia zobrazujúcich stromy odvodenia resp. toky riadenia pri syntaktickej analýze aritmetických výrazov rekurzívnym zostupom vyznačené modrou farbou.

Úloha 3.1

Upravte existujúcu implementáciu syntaktického analyzátora tak, aby namiesto pasívneho overovania syntaktickej korektnosti aritmetických výrazov vykonávala ich interpretáciu, t. j. počas syntaktickej analýzy zároveň vyhodnocovala výraz a vracala jeho výslednú hodnotu.

Doplňujúce úlohy

Úloha A.1

Do gramatiky jazyka aritmetických výrazov pridajte syntax operátora unárne mínus so štandardnou prioritou. Gramatiku modifikujte tak, aby bolo možné použiť unárne mínus ľubovoľne veľakrát za sebou bez nutnosti využitia zátvoriek.

Úloha A.2

Modifikujte zdrojový kód na základe upravenej gramatiky.

Úloha A.3

Modifikujte zdrojový kód tak, aby riešenie fungovalo ako štandardný interpretátor. Po spustení bude program očakávať vstup, po zadaní vstupu dôjde k jeho interpretácii a program znová čaká na ďalší vstup.

Úloha A.4

Doplňte zdrojový kód syntaktického analyzátora tak, aby počas parsovania výrazu konštruoval taktiež:

  1. odvodzovací strom výrazu,
  2. strom abtraktnej syntaxe výrazu,

ktorého grafická reprezentácia sa zobrazí po úspešnom ukončení syntaktickej analýzy. Pre vykreslenie stromu môžete v jazyku Python použiť napríklad externú knižnicu PrettyPrintTree.

Dotazník na ohodnotenie učiva

Vážení študenti,
týmto by sme Vás chceli požiadať o poskytnutie spätnej väzby k formátu a obsahu materiálov na cvičenia vo forme vyplnenie nasledujúceho dotazníka .

Tento dotazník slúži na ohodnotenie vašich skúseností s pripravenými materiálmi zo šiesteho cvičenia. Na vylepšovaní materiálov na základe vašej spätnej väzby aktívne pracujeme, a to s cieľom zvýšiť kvalitu, zrozumiteľnosť a prínosnosť výučby.