Ciele
- Oboznámiť so špecifikáciou priority a asociativity operácií na úrovni syntaktického modelu jazyka.
- Predstaviť základnú teóriu procesov kompilácie a interpretácie.
- 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:
- Pre každú uroveň priority operácii definujeme samostatný neterminál.
- 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:
- Najvyššia úroveň priority: Zátvorky (spracovávané v
F
) - Stredná úroveň priority: Násobenie (spracovávané v
T
) - 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
Š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í ako2^(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
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:
- 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 tox = 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.
- 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.
-
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. -
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.
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:
- úroveň priority (najvyššia): zátvorky
- úroveň priority: umocňovanie (pravá asociativita)
- úroveň priority: násobenie (ľavá asociativita)
- ú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ž:
- odvodzovací strom výrazu,
- 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