Úvod do kompilácie a interpretácie. Rekurzívna syntaktická analýza zhora nadol.

Ciele

  1. Oboznámiť so špecifikáciou priority a asociativity operácií na úrovni syntaktického modelu jazyka.
  2. Predstaviť základnú teóriu procesov kompilácie a interpretácie.
  3. Konštruovať gramatiku aritmetických výrazov a implementovať ich syntaxou riadenú interpretáciu.

Úvod

Postup

Krok 1

Pri návrhu programovacích jazykov a vyhodnocovaní výrazov existujú dva dôležité koncepty, ktoré určujú, ako sa výrazy analyzujú a vyhodnocujú, ide o:

  • prioritu a,
  • asociativitu operátorov.

Tieto vlastnosti operátorov pritom možno zachytiť taktiež na úrovni syntaktického modelu jazyka (gramatiky), na čo sa teraz pozrieme bližšie.

Špecifikácia priority operátorov
Priorita operátorov určuje, ktoré operácie majú prednosť pri vyhodnocovaní. Štandardne má napríklad operácia násobenie vyššiu prioritu ako operácia sčítania. Preto sa výraz $1 + 2 * 3$ vyhodnotí na hodnotu $7$ a nie na $9$. Pri syntaktickej špecifikácii priority operátorov v gramatike sa pritom budeme riadiť nasledujúcimi pravidlami:

  1. Pre každú uroveň priority operácii definujeme samostatný neterminál.
  2. Pravidlá sa pre jednotlivé úrovne (neterminály) zreťazia tak, že smerom dovnútra (vnáranie k operandom) sa zvyšuje priorita.

Demonštrujme tento postup na nasledujúcom príklade gramatiky aritmetických výrazov.

Nech $G$ je gramatika $(\{$E, T, F$\},\{$+, *, (, )$\}$,$P$,E$)$, kde $P$ je nasledujúca množina prepisovacích pravidiel v EBNF:

${}$ ${}$
E ::= T {"+" T}
T ::= F {"*" F}
F ::= cislo | "(" E ")"

Táto gramatika vynucuje nasledujúce úrovne priority:
  1. Najvyššia úroveň priority: Zátvorky (spracovávané v F)
  2. Stredná úroveň priority: Násobenie (spracovávané v T)
  3. Najnižšia úroveň priority: Sčítanie (spracovávané v E)

Štruktúra gramatiky priamo určuje prioritu operátorov. Každá úroveň v gramatike zodpovedá úrovni priority operátorov. Táto hierarchia sa dosahuje prostredníctvom odkazov medzi pravidlami. Pravidlo pre E sa odkazuje na T, ktoré zase odkazuje na F.

Porovnajme vplyv tvaru gramatiky na prioritu operátorov prostredníctvom zostrojenia derivačných stromov pre ten istý aritmetický výraz.

Prepisovanie pravidlá gramatiky $G$ so štandardnou prioritou operátorov

${}$ ${}$
E ::= T {"+" T}
T ::= F {"*" F}
F ::= cislo | "(" E ")"

Prepisovanie pravidlá gramatiky $G_2$ s vymenenou prioritou operátorov

${}$ ${}$
T ::= E {"*" E}
E ::= F {"+" F}
F ::= cislo | "(" T ")"

1+2*3

Strom odvodenia pre gramatiku $G_1$
Obr. 1: Strom odvodenia pre gramatiku $G_1$

Strom odvodenia pre gramatiku $G_2$
Obr. 2: Strom odvodenia pre gramatiku $G_2$

Špecifikácia asociativity operátorov
Asociativita operátorov určuje, v akom poradí sa budú vyhodnocovať operácie s rovnakou prioritou. Pri implementácii syntaktického analyzátora pomocou rekurzívnych funkcií sa asociativita vyjadruje tvarom pravidiel gramatiky nasledovne:

Asociativita Tvar pravidla v EBNF Tvar pravidla v BNF
ľavá E ::= T { "op" T } <E> ::= <E> op <T> | <T>
pravá E ::= T [ "op" E ] <E> ::= <T> op <E> | <T>
neasociatívnosť E ::= T [ "op" T ] <E> ::= <T> op <T> | <T>

Poznámka

Všimnime si, že asociativita operátorov je vo svojej podstate vyjadrená rekurziou (rekurzívnym neterminálom). Pričom ľavo asociatívne operátory sú definované prostredníctvom ľavo rekurzívnych neterminálov a pravo asociatívne operátory prostredníctvom pravo rekurzívnych neterminálov.

Asociativita zohráva dôležitú úlohu, v prípade ak sa v sekvencii objaví viacero operátorov s rovnakou prioritou za sebou. Napríklad v aritmetických výrazoch:

  • Ľavá asociativita: 5−3−2 sa vyhodnotí ako (5−3)−2=0.
  • Pravá asociativita: 2^2^3 sa vyhodnotí ako 2^(2^3)=256 (pretože ľavá asociativita by nebola pri umocňovaní užitočná - rovnaký výsledok sa dá dosiahnúť vďaka pravidlám pre mocniny pomocou súčinu exponentov (2^2)^3=2^(2*3)).
  • Neasociatívne operátory: Napríklad operátor porovnania je neasociatívny.

Porovnajme vplyv tvaru gramatiky na asociativitu operátorov prostredníctvom zostrojenia derivačných stromov pre ten istý aritmetický výraz.

Prepisovanie pravidlá gramatiky $G_1$ s ľavo asociatívnou operáciou umocňovania

${}$ ${}$
P ::= T {"^" T}
T ::= cislo | "(" P ")"

Prepisovanie pravidlá gramatiky $G_2$ s pravo asociatívnou operáciou umocňovania

${}$ ${}$
P ::= T ["^" P]
T ::= cislo | "(" P ")"

2^2^3

Strom odvodenia pre gramatiku $G_1$
Obr. 3: Strom odvodenia pre gramatiku $G_1$

Strom odvodenia pre gramatiku $G_2$
Obr. 4: Strom odvodenia pre gramatiku $G_2$

Krok 2

Interpreter je počítačový program, ktorý interpretuje iný program vyjaredný v konkrétnom programovacom jazyku. Interpretre môžu program interpretovať riadok po riadku alebo preložia program do tzv. medzikódu a tento medzikód potom vykonávajú. Ak je v programe syntaktická chyba, prvý druh interpretra vykoná program po túto syntaktickú chybu a zahlási chybu na príslušnom riadku, druhý druh interpretra program nezačne vykonávať a počas prekladu do medzikódu zahlási chybu.

Základné fázy interpretácie
Interpretér spracováva zdrojový kód v nasledujúcich krokoch:

  1. Lexikálna analýza (Tokenizácia)
    Prvou fázou interpretácie je lexikálna analýza, úlohou ktorej je čítať program zo vstupu zapísaný v zdrojovom jazyku po jednotlivých znakoch a rozdeliť ho na jednotlivé lexémy (logicky oddelené lexikálne jednotky). Token je vnútorný kódovaný tvar lexémy, lexikálny analyzér (scanner) nahradí každú lexému kódom, ktorý je ekvivalentom k danej lexéme. Z pohľadu syntaktickej analýzy je to terminálny symbol. Pojmy lexéma a token si teraz priblížime na nasledujúcom príklade príkazu z jazyka C, a to x = y + 5;.
${}$ ${}$ ${}$ ${}$ ${}$ ${}$ ${}$
Lexémy x = y + 5 ;
Tokeny <ID,"x"> <ASSIGN> <ID,"y"> <PLUS> <NUMBER,5> <SEMICOLON>

Výstupom lexikálnej analýzy je prúd tokenov (z angl. stream).

Upozornenie

Prúd (stream) nie je list, resp. pole.

  1. Syntaktická analýza (Parsing)
    Kontroluje, či postupnosť tokenov zodpovedá gramatickým pravidlám jazyka, a vytvára jeho vnútornú reprezentáciu, často vo forme abstraktného syntaktického stromu (AST).

Pri tvorbe syntaktického analyzátora 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 synkatické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, ako je LR, 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. Preštudujte si schému implementácie takéhoto syntaktického analyzátora v nasledujúcej tabuľke.

EBNF Implementácia
A ::= ... Definícia A() { ... }
Neterminal A Volanie A()
Terminal a if (symbol == a) next_symbol(); else error();
X | Y | Z switch (symbol) { case FIRST(X) : ... }
[ X ] if (symbol in FIRST(X) ) { ... }
{ X } while (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ť na 8. cvičení.

  • Hlavným princípom implementácie syntaktického analyzátora je vytvorenie procedúry s menom neterminálového symbolu na ľavej strane tohto pravidla, pre každé pravidlo gramatiky.
  • Neterminálnemu symbolu na pravej strane každého pravidla zodpovedá volanie procedúdy.
  • 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.
  • Alternatíve ...|... a výrazu voliteľnosti [...] zodpovedá príkaz vetvenia (case, if, switch) vstup do príkazu vetvenia je bodom rozhodnutia.
  • Uzáveru resp opakovaniu {...} zodpovedá príkaz cyklu (while). Vstup do príkazu cyklu je bodom rozhodnutia.
  • Samotný proces analýzy sa spustí volaním procedúri začiatočného 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.

  1. Sémantická analýza
    Kontroluje, či program dáva zmysel z hľadiska sémantiky jazyka. To zahŕňa kontrolu typov, rozlíšenie premenných a funkcií, a ďalšie kontextové informácie.

  2. Interpretácia (Vykonanie)
    Interpretér vykonáva príkazy programu priamo podľa ich významu, pričom spracováva výpočty, volania funkcií a prácu s pamäťou.

Rôzne formy vety medzi iniciálnymi fázami práce jazykového procesora
Obr. 5: Rôzne formy vety medzi iniciálnymi fázami práce jazykového procesora

Krok 3

V tomto kroku pristúpime ku konštrukcii gramatiky aritmetických výrazov a implementácii ich syntaxou riadenej interpretácii teda interpretéra.

Gramatika aritmetických výrazov
Vytvoríme si kalkulačku s nasledujúcimi operáciami:

  • sčítanie +
  • odčítanie -
  • násobenie *
  • umocňovanie ^

Najprv si definujeme prioritu a asociativitu operátorov a pomocných symbolov pričom budeme vychádzať z ich štandardného použitia v matematike:

  1. úroveň priority (najvyššia): zátvorky
  2. úroveň priority: umocňovanie (pravá asociativita)
  3. úroveň priority: násobenie (ľavá asociativita)
  4. úroveň priority (najnižšia): sčítanie a odčítanie (ľavá asociativita)

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

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

Kostra projektu na stiahnutie je dostupná tu.

Lexikálna analýza (tokenizácia)
Najprv vytvoríme lexikálny analyzátor, ktorý rozdelí vstupný reťazec na lexémy a zakóduje ich do tokenov.

Úloha 3.1

Vašou úlohou je analyzovať zdrojový kód pre lexikálny analyzátor a doplniť funkcionalitu rozpoznania čísla, metódu number pomocou Hornerovej schémy.

from enum import Enum, auto

# Definícia typov tokenov rozpoznávaných lexerom
class TokenType(Enum):
    NUMBER = auto()    # Číselná hodnota (prirodzené čí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)

# Trieda reprezentujúca token - kombinácia typu a atribútu (hodnoty)
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"Token({self.type}, {self.attribute})"
        return f"Token({self.type})"

# Lexikálny analyzátor - rozpoznáva a extrahuje tokeny zo vstupného reťazca
class Lexer:
    def __init__(self, input_text):
        self.input_text = input_text                       # Vstupný analýzovaný vstupný text
        self.pos = 0                                  # Aktuálne analyzovaná pozícia vo vstupnom texte
        self.current_char = self.input_text[0] if input_text else None # Aktuálny znak alebo None ak je vstupný text prázdny
    
    def advance(self):
        """Posunie pozíciu na ďalší znak a aktualizuje current_char.
        Ak sa dostane za koniec textu, nastaví current_char na None."""
        self.pos += 1
        if self.pos < len(self.input_text):
            self.current_char = self.input_text[self.pos]
        else:
            self.current_char = None  # Dosiahli sme koniec textu
    
    def skip_whitespace(self):
        """Preskočí všetky medzery a nové riadky vo vstupe."""
        while self.current_char is not None and self.current_char.isspace():
            self.advance()
    
    def number(self):
        """Vracia token typu NUMBER s číselnou hodnotou."""
        # TODO: Implementujte rozpoznávanie čísla pomocou Hornerovej schémy


    
    def get_next_token(self):
        """Hlavná metóda lexikálneho analyzátora.
        Analyzuje text a vracia ďalší token v poradí."""
        while self.current_char is not None:
            # Ignorovanie medzier a formátovanie
            if self.current_char.isspace():
                self.skip_whitespace()
                continue
            
            # Rozpoznávanie čísla 
            if self.current_char.isdigit():
                return self.number()
            
            # Rozpoznávanie operátorov a zátvoriek - jeden znak = jeden token
            if self.current_char == '+':
                self.advance()
                return Token(TokenType.PLUS)
            
            if self.current_char == '-':
                self.advance()
                return Token(TokenType.MINUS)
            
            if self.current_char == '*':
                self.advance()
                return Token(TokenType.MULTIPLY)
            
            if self.current_char == '^':
                self.advance()
                return Token(TokenType.POWER)
            
            if self.current_char == '(':
                self.advance()
                return Token(TokenType.LPAREN)
            
            if self.current_char == ')':
                self.advance()
                return Token(TokenType.RPAREN)
            
            # Ak narazíme na neznámy znak, vyhodíme chybu
            raise ValueError(f"Nerozpoznaný znak: '{self.current_char}'")
        
        # Keď prejdeme celý text, vrátime token konca vstupu
        return Token(TokenType.EOF)

Syntaktický analyzátor a interpreter
Teraz implementujeme rekurzívny zostupný parser, ktorý bude syntakticky analyzovať a súčasne interpretovať vstup v podobe aritmetických výrazov. Potrebuje preto pristúpiť k miernej úprave implementácie syntaktického analyzátora prezentovanej v predchádzajúcom kroku. Namieto procedúr budeme využívať funkcie návratová hodnota ktorých bude zodpovedať hodnote aktuálne vyhodnocovavej časti výrazu.

Poznámka

Návratové hodnoty týchto funkcií sú na obrázkoch zobrazujúcich toky riadenia pri syntaktickej analýze aritmetických výrazov rekurzívnym zostupom vyznačené modrou farbou.

Úloha 3.2

Vašou úlohou je analyzovať zdrojový kód pre syntaktický analyzátor a interpreter v jednom a doplniť implementáciu pravidiel gramatiky $G$ pre neterminály T a F, teda metódy T(self) F(self).

from Lexer import TokenType

# Syntaktický analyzátor - implementuje rekurzívny zostup podľa gramatiky
class Parser:
    def __init__(self, lexer):
        self.lexer = lexer                        # Lexikálny analyzátor, ktorý dodáva tokeny
        self.current_token = self.lexer.get_next_token()  # Načítame prvý token
    
    def consume(self, expected_token_type):
        """
        Kontrolná metóda - kontroluje, či aktuálny token je očakávaného typu.
        Ak áno, posunie sa na ďalší token. Ak nie, vyhodí chybu.
        
        Táto metóda implementuje prediktívnu analýzu - očakávame konkrétny token
        na základe gramatických pravidiel.
        """
        if self.current_token.type == expected_token_type:
            self.current_token = self.lexer.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}")
    
    def E(self):
        """
        Implementuje gramatické pravidlo:
        E ::= T {("+" | "-") T}
        
        Spracuje sčítanie a odčítanie s ľavou asociativitou.
        Najnižšia priorita operácií v gramatike.
         
        """
        # Najprv vyhodnotíme prvé T
        result = self.T()
        
        # Potom spracujeme všetky ďalšie operácie sčítania a odčítania
        while self.current_token.type in (TokenType.PLUS, TokenType.MINUS):
            token = self.current_token
            
            if token.type == TokenType.PLUS:
                self.consume(TokenType.PLUS)  # Overíme a posunieme sa za '+'
                result += self.T()     # Vyhodnotíme hodnotu T a pripočítame ju do výsledku
                
            elif token.type == TokenType.MINUS:
                self.consume(TokenType.MINUS)  # Overíme a posunieme sa za '-'
                result -= self.T()   # Vyhodnotíme hodnotu T a odčítame ju od výsledku
                
        
        return result
    
    def T(self):
        """
        TODO: Implementujte gramatické pravidlo:
        T ::= F {"*" F}
        """

    
    def F(self):
        """
        TODO: Implementujte gramatické pravidlo:
        F ::= P ["^" F]
        """

    
    def P(self):
        """
        Implementuje gramatické pravidlo:
        P ::= cislo | "(" E ")"
        
        Spracuje základné prvky výrazu - buď číslo alebo výraz v zátvorke.
        Najvyššia priorita v gramatike.
        """
        token = self.current_token
        
        if token.type == TokenType.NUMBER:
            # Jednoducho vrátime hodnotu čísla
            self.consume(TokenType.NUMBER)  # Overíme a posunieme sa za číslo
            return token.attribute
            
        elif token.type == TokenType.LPAREN:
            # Vyhodnotíme výraz v zátvorkách
            self.consume(TokenType.LPAREN)  # Overíme a posunieme sa za '('
            result = self.E()        # Rekurzívne vyhodnotíme výraz medzi zátvorkami
            self.consume(TokenType.RPAREN)  # Overíme a posunieme sa za ')'
            return result
            
        else:
            # Ak nie je ani číslo ani zátvorka, ide o chybu
            raise SyntaxError(f"Syntaktická chyba: Neočakávaný token {token}")
    
    def parse(self):
        """
        Začne syntaktickú analýzu a vráti výsledok výrazu.
        Vstupný bod pre parser.
        """
        return self.E()  # Začíname volaním funkcie zopdovedajúcej zažiatočnému symbolu gramatiky

Hlavná trieda interpretera
Nakoniec vytvoríme hlavnú triedu interpretera aritmetických výrazov a kód máme hotový.


# Hlavná trieda interpretera, ktorá spája lexer a parser
class Calculator:
    def evaluate(self, expression):
        """
        Vyhodnotí aritmetický výraz a vráti výsledok.
        
        Args:
            expression (str): Reťazec obsahujúci aritmetický výraz
            
        Returns:
            int: Výsledok výrazu, alebo chybovú správu
        """
        try:
            lexer = Lexer(expression)   # Vytvoríme lexikálny analyzátor pre daný výraz
            parser = Parser(lexer)      # Vytvoríme syntaktický analyzátor
            result = parser.parse()     # Vyhodnotíme výraz podľa gramatiky
            return result
        except Exception as e:
            return f"Chyba: {str(e)}"   # Vrátenie chyby

Demonštrácia funkcionality
Ukážme si, ako naša kalkulačka spracuje niekoľko výrazov.


def test_calculator():
    calc = Calculator()
    
    # Základné operácie
    print("2 + 3 =", calc.evaluate("2 + 3"))                 # Očakávaný výsledok: 5
    print("5 - 3 =", calc.evaluate("5 - 3"))                 # Očakávaný výsledok: 2
    print("2 * 3 =", calc.evaluate("2 * 3"))                 # Očakávaný výsledok: 6
    print("2 ^ 3 =", calc.evaluate("2 ^ 3"))                 # Očakávaný výsledok: 8
    
    # Priorita operátorov
    print("2 + 3 * 4 =", calc.evaluate("2 + 3 * 4"))         # Očakávaný výsledok: 14 (nie 20)
    print("(2 + 3) * 4 =", calc.evaluate("(2 + 3) * 4"))     # Očakávaný výsledok: 20
    print("2 * 3 ^ 2 =", calc.evaluate("2 * 3 ^ 2"))         # Očakávaný výsledok: 18 (nie 36)
    
    # Asociativita
    print("5 - 3 - 1 =", calc.evaluate("5 - 3 - 1"))         # Očakávaný výsledok: 1 (ľavá asociativita)
    print("2 ^ 2 ^ 3 =", calc.evaluate("2 ^ 2 ^ 3"))         # Očakávaný výsledok: 256 (pravá asociativita)
    
    # Komplexný výraz
    print("2 * (3 + 4) ^ 2 - 5 =", calc.evaluate("2 * (3 + 4) ^ 2 - 5"))  # Očakávaný výsledok: 93


if __name__ == "__main__":
    test_calculator()

Doplňujúce úlohy

Úloha A.1

Do gramatiky $G$ pridajte syntax operátora unárne mínus.

Ú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ér. 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,

grafická reprezentácia ktorého 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,
poprosil by som Vás o vyplnenie tohto dotazníka.

Tento dotazník slúži na ohodnotenie vašich skúseností s pripravenými materiálmi z šiesteho cvičenia. Na vylepšovaní učiva sa aktívne pracuje a Vaše odpovede nám pomôžu zlepšiť kvalitu výučby.

Ďakujem