Instrukcja

Transkrypt

Instrukcja
Instrukcja Laboratoryjna
Instrukcja Laboratoryjna
Parser gramatyki bezkontekstowej – BISON
Część statyczna
1. Wprowadzenie
BISON jest parserem konwertującym opis gramatyki bezkontekstowej LALR(1) do programu
w języku C, który następnie będzie parsował zadaną gramatykę. Jest on kompatybilny ze
wszcześniejszymi implementacjami Yacc’a.
2. Podstawowe pojęcia
a. Języki i gramatyki bezkontekstowe
Parser BISON umożliwia parsowanie tylko gramatyk bezkontekstowych (ang. contex-free
grammar). Chociaż jest przystosowany do parsowania prawie wszystkich gramatyk
bezkontekstowych, jest on optymalizowany dla gramatyk typu LALR(1). Krótko mówiąc, w tych
gramatykach musi być możliwe stwierdzenie, jak należy opisywać każdą porcję danych na wejściu,
wykorzystując tylko pojedynczy znak. Dodatkowo parsery te są deterministyczne, co znaczy, że za
każdym razem te same dane wejściowe dadzą takie same wyniki działania parsera.
W formalnym opisie reguł gramatycznych języka każdy rodzaj syntaktycznych jednostek lub
sposobów ich grupowania jest nazywany symbolem. Te ,które są budowane poprzez grupowanie
mniejszych konstrukcji zgodnie z regułami gramatyki, nazywają się symbolami nieterminalnymi
(ang. nonterminal symbols), natomiast te, które nie mogą zostać podzielone, nazywane są
symbolami terminalnymi (ang. terminal symbols). W poniższym opracowaniu tokenem (ang. token
type) będzie nazywany pojedynczy symbol terminalny, natomiast część odpowiadająca pojedynczemu
symbolowi nieterminalnemu będzie nazywana grupowaniem (ang. grouping).
Formalnym opisem gramatyki jest konstrukcja matematyczna. Aby zdefiniować język dla
BISONa, należy opisać gramatykę zgodnie ze składnią BISONa.
b. Parser LL(1). Akcje shift i reduce.
Parser jest maszyną stanową (ang. state machine). Jego "program" stanowi wbudowany graf
stanów oraz tranzycji pomiędzy nimi. Z każdym stanem skojarzona jest akcja parsera. Po każdej akcji
parser podejmuje decyzję o kolejnej tranzycji. Aby wykonać właściwą tranzycję, parser musi
rozpoznać zaistniałą sytuację na podstawie dostępnych czynników decyzyjnych. Pierwszym
czynnikiem decyzyjnem jest bieżacy stan, w którym znajduje się parser. Drugim czynnikiem jest
bieżący stan wejścia (strumienia tokenów). LL(1) oznacza, że parser bierze pod uwagę jedynie
pierwszy token będący właśnie na wejściu strumienia; gramatyki LL(1) to takie gramatyki, w których
graf parsera pozstaje rozstrzygalny przy uwzględnieniu tylko pierwszego tokenu z wejścia.
Tak więc parser LL(1) wykorzystuje dwa czynniki decyzyjne, stąd implementacja jego grafu
jest 2-wymiarową tablicą. Ze względów technicznych "obserwowany" token w wielu implementacjach
znajduje się na szczycie stosu parsera, zamiast fizycznie na wejściu strumienia.
Graf parsera jest budowany na podstawie gramatyki, którą parser ma za zadanie analizować.
Graf ten nie zmienia się w czasie pracy parsera – zapisany jest jako stała typu tablicowego. Podczas
analizy parser wykonuje jedynie swój "program" – wszystkie decyzje zostały już podjęte na etapie
kompilacji gramatyki do postaci grafu (dzięki temu gotowy parser jest bardzo szybki).
Centralną stukturą danych parsera jest stos symboli. Na stosie odkładane są symbole
terminalne pobierane ze strumienia wejściowego. Po skompletowaniu odpowiedniego ciągu symoli są
one redukowane (usuwane ze stosu) do pojedynczego symbolu nieterminalnego (odkładany na stos).
Cykl ten jest powtarzany według reguł gramatyki. Celem parsera jest uzyskanie w ostatniej redukcji
tylko jednego symbolu na stosie – głowy gramatyki. Taki wynik oznacza poprawne zakończenie
analizy składniowej zadanego wejścia.
(c) Karolina Nurzyńska
Page 1
Instrukcja Laboratoryjna
W każdym kroku parser może wykonać jedną z dwóch akcji: shift lub reduce. Akcja shift
polega na przepisaniu symbolu z wejścia na stos. Akcja reduce oznacza redukcję n symboli
z wierzchołka stosu do jednego symbolu nieterminalnego według określonej reguły gramatyki.
Aby zilustrować pracę parsera, rozważmy przedstawioną poniżej krótką gramatykę, która
opisuje składnię instrukcji przypisania.
asgn: lvalue '=' expr ';'
;
lvalue: _ID
;
expr: _ID
| expr '+' expr
;
Na poniższym rysunku zostały przedstawione kolejne kroki parsera dla przykładowego ciągu
wejściowego. Pojewienie się na stosie jedynego symbolu będącego głową gramatyki jest
przyjmowane za poprawne zakończenie procesu analizy.
c. Wartości semantyczne
W czasie parsowania najpierw sprawdza się do jakiej reguły gramatycznej pasuje token,
od tego też zależy typ tokena, poza tym z każdym tokenem jest związana wartość semantyczna
(ang. semantic value), która zawiera wszystkie znaczące informacje o tokenie.
d. Akcje semantyczne
Aby parser był przydatnym programem musi, poza parsowaniem danych z wejścia,
wykonywać akcje i dawać wyniki działania. Podczas definiowania reguł gramatycznych w BISONie
można tworzyć akcje (ang. action), które zostaną wykonane przez program.
W większości przypadków celem wykonania akcji jest obliczenie wartości semantycznej całej
konstrukcji na podstawie poszczególnych wartości semantyczych składników wyrażenia.
e. Lokacje
Wiele aplikacji, takich jak interpretery czy kompilatory, musi produkować bogaty opis
informacji o błędach działania. Aby to osiągnąć należy umożliwić śledzenie lokacji (ang. locations)
oraz lokacji tekstowych każdej konstrukcji syntaktycznej. BISON zapewnia mechanizm obsługi lokacji.
Tak jak token ma przypisaną semantyczna wartość, tak też jest związany z lokacją. Ponad
to wyjściowy parser jest wyposażony w domyślną strukturę danych do składowania informacji
o lokacjach.
3. Ogólny wygląd pliku do opisu gramatyki w BISONie
Na wejście BISONa jest pobierany plik ze specyfikacją gramatyki bezkontekstowej, a na
wyjściu jest produkowana funkcja w języku C, która rozpoznaje poprawne instacje tej gramatyki.
Zgodnie z konwencją plik wygenerowany przez BISON ma rozszerzenie *.y.
Opis gramatyki w BISONie składa sie z czterech głównych sekcji zaprezetowanych poniżej
z odpowiednimi ogranicznikami. Komentarze zamknięte w /* ... */ mogą pojawiać się w każdej
z sekcji. Jako rozszerzenie GNU można również wprowadzać komentarze jednoliniowe po znakach
//.
(c) Karolina Nurzyńska
Page 2
Instrukcja Laboratoryjna
%{
Prolog
%}
Deklaracje
%%
Reguły gramatyczne
%%
Epilog
(c) Karolina Nurzyńska
Page 3
Instrukcja Laboratoryjna
a. Prolog
Ta część zawiera makro definicje i deklaracje funkcji i zmiennych, które będą wykorzystywane
w akcjach lub regułach gramatycznych. Część ta jest kopiowana na początek pliku parsera
poprzedzającej definicję yyparse. Można używać tutaj dyrektywy #include, aby dostać się do
deklaracji z plików nagłówkowych. Jeżeli takie deklaracje nie są potrzebne, można tę część pominąć
wraz z ogranicznikami ‘%{‘ i ‘%}’.
Możliwe jest również stworzenie kilku sekcji prologu przeplatających się z sekcją deklaracji.
Takie skontruowanie tej części pozwala na tworzenie deklaracji w C i BISONie, które odnoszą się
jedna do drugiej.
b. Sekcja deklaracji
Sekcja deklaracji zawiera deklaracjie, które definiują symbole terminalne i nieterminalne,
specyfikują pierwszeństwo itd. W niektórych prostych gramatykach sekcja ta może zostać pominięta.
c. Sekcja reguł gramatycznych
W tej sekcji zostają wypisane reguły gramatycznie i nic więcej. Zawsze musi być
przynajmnniej jedna reguła gramatyczna i na poczatku musi znaleźć się ‘%%’ (poprzedzając reguły
gramatyczne).
d. Epilog
Część ta jest bezpośrednio kopiowana do pliku parsera na jego koniec, tak samo jak prolog na
początek. Jest to najlepsze miejsce do umieszczania jakichkolwiek dodatkowych rzeczy, ktore
powinny znależć się w parserze, a nie powinny wyprzedzać definicji yyparse. Przykładowo można
tutaj umieszczać definicjie funkcji yylex lub yyerror. Należy jednak pamiętać o deklaracji tych
funkcji w prologu, gdyź jest to wymagane przez składnię języka C. Jeżeli ta sekcja jest pusta, można
ominąć separator ‘%%’ po sekcji reguł gramatycznych.
Należy pamiętać, że parser BISONa definiuje wiele makr i definicji, których nazwy zaczynają
się od znaków ‘yy’ lub ‘YY’ dlatego należy unikać używania tak rozpoczynających się nazw w
części epilogu (chyba, że są to nazwy udokumentowane w instrukcji BISONa).
4. Symbole terminalne i nieterminalne
Symbole w gramatyce BISONa reprezentują klasyfikatory gramatyczne języka. Symbol
terminalny znany jako token reprezentuje klasę syntaktycznie równoważnych tokenów. Korzysta się
z tych symboli do tworzenia reguł gramatycznych, aby stwierdzić, że dana klasa tokenów jest
dozwolona w danym miejscu. Symbol w parserze jest reprezentowany przez kod numeryczny i funkcja
yylex zwraca kody tokenów, aby wskazać jaki token został odczytany. Użytkownik piszący
gramatykę nie musi znać wartości numerycznej tych kodów, wystarczy znać stałe symboliczne.
Symbol nieterminalny jest przedstawicielem klasy syntaktycznie równoważnych grup. Nazwa
jest wykorzystywana do zapisu reguł gramatycznych.
Nazwy symboli mogą składać się z liter i cyfr (ale nie na początku) podkreśleń i kropek. Z tym,
że kropki mają tylko sens w przypakdu symboli nieterminalnych.
Symbole terminalne można zapisać na trzy sposoby w gramatyce:
• Nazwany token (ang. named token type) może zostać napisany jako indentyfikator, tak jak to
jest robione w języku C. Powinien być on pisany wielką literą. Każda z nazw musi być
definiowana wykorzystując deklaracje %token
• Znakowy token (ang. character token type lub literal character token) jest zapisywany
w gramatyce, korzystając z takiej samej składni, jaka jest używana w języku C dla zapisu
stałych znakówych, np. ‘+’. Taki token nie musi być deklarowany, jeśli nie jest to potrzebne do
specyfikowania jego semantycznej wartości, połączeń oraz pierwszeństwa. Zgodnie
z konwencją token znakowy jest wykorzystywany tylko do reprezentacji tokena składającego
się ze szczególnego znaku. Stąd, token ‘+’ reprezentuje +. Wszystkie sekwencje ucieczek
wykorzystywane w C i zapisane za pomocą literałów znakowych mogą być wykorzystywane
(c) Karolina Nurzyńska
Page 4
Instrukcja Laboratoryjna
w BISONie, ale nie należy korzystać ze znaku null, gdyż jego numeryczny kod zero
oznacza koniec pobierania danych.
• Token ciągu znaków (ang. literal string token) jest również zapisywany jako stały łańcuch
(dwu)znakowy w C, np. „>=”. Nie musi być on deklarowany, jeżeli nie trzeba określać jego
typu, powiązań oraz pierwszeństwa. Można połączyć ten token z nazwą symboliczną,
korzystając z deklaracji %token. Jeżeli to nie zostanie wykonane, analizator leksykalny
będzie musiał pobrać numer tokenu z tabeli yytname. (UWAGA: ten typ tokenów nie jest
zgodny ze specyfikacją Yacc) Zgodnie z konwencją, również ten token jest używany do
reprezentacji łańcuchów znkowych.
Należy pamiętać, że wartości tokenów są dodatnie. Ujemna lub zerowa wartość tokena na
wejściu parsera oznacza koniec danych wejściowych zwrócony przez funkcje yylex. Każdy nazwany
token staje się makrem w pliku parsera, dlatego też yylex może korzystać z tych samych nazw dla
kodów. W przypadku definiowania yylex w oddzielnym pliku należy udostępnić definicje makr
tokenów. Wykorzystuje się do tego opcje ‘-d’ przy wywołaniu programu BISON. W wyniku czego
definicje makr zostaną zapisane w osobnym pliku nagłówkowym o nazwie name.tab.h, który
można dołączyć do innych plikow źródłowych w razie potrzeby.
5. Składnia reguł gramatycznych
Reguły gramatyczne mają następująca formę:
wynik: składniki ...
;
Gdzie wynik jest symbolem nieterminalnym, który opisuje regułę, natomiast składniki są
różnymi symbolami terminalnymi lub nieterminalnymi, które wchodzą w skład reguły.Na przykład:
wyn: wyn ‘+’ wyn
;
Oznacza, że dwie grupy typu wyn z tokenem ‘+’ między nimi mogą być złączone w większą
grupę typu wyn. Białe znaki w regule nie są znaczące, służą tylko do oddzielenie symboli. Między
komponentami mogą być umieszczone akcje, które determinują reguły semantyczne. Akcja wygląda
w następujący sposób:
{ kod w C}
Zazwyczaj jest tylko jedna akcja następująca po składniku.
Wielokrotne reguły dla tego samego wyniku mogą być zapisywane oddzielnie lub złączone za
pomocą znaku ‘|’ :
wynik: składniki_pierwszej_reguly ...
| składniki_drugiej_reguly...
...
;
Jeżeli składnik w regule jest pusty, oznacza to, że wynik może być równoważny pustemu
łańcuchowi znaków Poniższy przykład pokazuje, jak zdefiniować sekwencje oddzielone przecinkiem
składające się z zerowej lub wielu grup wyn:
(c) Karolina Nurzyńska
Page 5
Instrukcja Laboratoryjna
wynsekw: /*empty*/
| wynsekw1
;
wynsekw1: wyn
| wynsekw1 ‘,’ wyn
;
a. Reguły rekursywne
Reguła jest nazwana rekursywaną, gdy jej wynikowy symbol nieterminalny pojawia się
również po prawej stronie. Większość gramatyk wymaga reguł rekursywnych, gdyż jest to jedyny
sposób zdefiniowania wyrażeń sekwencyjnych. Można wyróżnić dwa typy definicji rekursywnych:
wynsekw1:
|
;
wynsekw1:
|
;
wyn
wynsekw1 ‘,’ wyn
wyn
wyn ‘,’ wynsekw1
W pierwszej sekwencji wynsekw1 jest pierwszym symbolem po lewej stronie w prawej
części reguły, dlatego też rekurencję tę nazywa się lewostronną (ang. left recursion). W drugim
przypadku mamy rekurencje prawostronną (ang. right recursion).
Każdy typ sekwencji może zostać opisany za pomocą zarówno rekurencji prawo- jak
i lewostronnej, jednak sugeruje się korzystanie z rekurencji lewostronnej, ponieważ może
to sparsować sekwencję składającą się z każdej liczby elementów niezależnie od miejsca na stosie. W
przypadku prawostronnej rekurencji wykorzystuje się stos o wiele bardziej, gdyż wszystkie znaki
muszą się w nim znaleźć, nim reguła zacznie się redukować.
Można również wyróżnić rekurencję pośrednią (ang. indirect recursion) lub wzajemną (ang.
mutual recursion), która ma miejsce, gdy wynik jednej z reguł nie pojawia się bezpośrednio po jej
prawej stronie, ale pojawia się w regule dla jednego z symboli nieterminalnych po jego prawej stronie:
wyr: podst
| podst ‘+’ podst
;
podst: stala
| ‘(‘ wyr ’)’
;
b. Definiowanie semantyki języka
Reguły gramatycznie określają tylko składnię. Semantyka jest determinowana za pomocą
wartości semantycznych związanych z różnymi tokenami i grupowaniami. W wyniku wykonywania
akcji zmieniana może być ich wartość.
Typy danych wartości semantycznych
W prostym programie może być wystarczające użycie jednego typu danych dla wszystkich
wartości semantycznych. Domyślnym typem w BISONie jest int. Aby określić jako domyślny inny
typ, należy zdefiniować makro YYSTYPE
#define YYSTYPE double
Definicja makra powinna znaleźć się w części prologu.
Więcej typów danych wartości semantycznych
W większości programów potrzebnych jest wiele typów danych dla różnych tokenów
i grupowań. Na przykład stała numeryczna może być typu int lub long int, podczas gdy łańcuch
stałej może wymagać char*. Aby móc korzystać z wjększej ilości typów dla wartości
semantycznych w jednym parserze, BISON wymaga określenia dwóch rzeczy:
(c) Karolina Nurzyńska
Page 6
Instrukcja Laboratoryjna
•
•
określenia kolekcji potrzebnych typów danych za pomocą deklaracji %union,
wybrania jednego z tych typów dla każdego z symboli (terminalnych i nieterminalnych), dla
których wartości semantyczne będa wykorzystywane. Można to wykonać za pomocą
deklaracji %token lub %type.
c. Akcje semantyczne
Akcja semantyczna akompaniuje regule syntaktycznej i zawiera kod C, który powinien być
wykonany za każdym razem, gdy instancja tej reguły zostanie rozpoznana. Zadaniem większości akcji
jest obliczenie wartości semantycznej dla grupy na podstawie wartości semantycznych jej składników.
Akcja składa się z kodu w języku C wziętego w nawiasy. Akcja może zostać umieszczona w każdym
miejscu reguły. Większość reguł ma tylko jedną akcję na jej końcu po wszystkich składnikach. Ale
wystepują również akcje w środku reguł, jednak należy z nich korzystać tylko w szczególnych
przypadkach.
Wartość semantyczna każdego składnika reguły jest reprezentowana w kodzie C jako $n,
gdzie n określa numer na liście składników danej reguły. Semantyczna wartość dla konstruowanej
grupy to $$. Na przykład:
war: ...
| war ‘+’ war {$$ = $1 + $3;}
;
Ta reguła konstruuje war z dwóch mniejszych grup war połączonych przez znak plus. W tej
sekcji $1 i $3 odnoszą się do semantycznych wartości komponentów grup war, które są pierwszym
i trzecim symbolem po prawej stronie reguły. Suma jest zapamiętywana w $$ i staje się wartością
semantyczną wyrażenia. Jeżeli nie zostanie sprecyzowana akcja dla reguły BISON, przypisze jej
domyślną wartość $$ = $1;.
$n gdzie n jest zerem lub wartością ujemną są dozwolone i odnoszą się do tokenów
i grupowań znajdujących się na stosie przed właśnie dopasowywaną regułą. Jednak używanie ich jest
bardzo ryzykowne, gdyż trzeba wtedy zapewnić, że taki stos istnieje.
Typy danych i wartości w akcjach semantycznych
Jeżeli został wybrany jeden typ dla wartości semantycznych, $$ i $n zawsze reprezentują
właśnie ten typ. Jeżeli jednak zostało użyte polecenie %union, aby stworzyć różnorodne typy
danych, wtedy należy zadeklarować wybór między tymi typami dla wszystkich symboli terminalnych
i nieterminalnych, które mają wartość semantyczną. W tym przypadku za każdym razem, gdy korzysta
się z $$ lub $n ich typ danych jest zależny od symbolu, do którgo odwołuje się w regule:
war: ...
| war ‘+’ war {$$ = $1 + $3;}
;
W powyższym przypadku, jako że korzystamy z tego samego symbolu war, wiadomo,
że $$, $1 i $3 są tego samego typu. W innym razie można sprecyzować, o jaki typ danych chodzi
podczas odwoływania się do wartości poprzez dodanie ‘<typ>’ zaraz po znaku ‘$’ na początku
odniesienia. Na przykład, jeżeli została stwożona unia:
%union
{
int itype;
double dtype;
}
Należy napisać $<itype>1, aby odwołać się do int lub $<dtype>2, aby odwołać się do
double.
(c) Karolina Nurzyńska
Page 7
Instrukcja Laboratoryjna
Akcje semantyczne w środku reguł
Czasami może być użytecznym dodanie akcji w środku reguły. Takie akcje tworzy się w ten
sam sposób, ale są one wykonywane nim parser rozpozna następne składniki. Mogą one odnosić się
do składników poprzedzających wykorzystując $n, ale nie do następujących, bo te nie zostały jeszcze
sparsowane.
Ważną różnicą między akcją normalną a tą w środku reguły jest problem określenia typu
otrzymanego rezultatu. W tym przypadku należy użyć konstrukcji ‘$<...>n’ do określenia typu
danych przy odwoływaniu się do wartości wynikowej akcji. Również w przypadku akcji wewnętrznych
nie można określić wartości $$, jest to możliwe tylko w akcji na końcu reguły.
6. Sekcja deklaracji
W tej sekcji znajdują się symbole wykorzystane do formułowania reguł gramatyki jak również
typy danych dla wartości semantycznych. Wszystkie nazwy tokenów muszą być tutaj zadeklarowane
jak również symbole nieterminalne, jeżeli trzeba podać ich typ danych dla wartości semantycznej.
Dodatkowo powinna być tutaj zadeklarowana głowa gramatyki po słowie %start. Jeżeli nie zostanie
ona określona domyślnie, BISON wybiera pierwsza regułę.
a. Nazwy tokenów
Podstawowym sposobem określenia nazwy tokena jest:
%token name
BISON konwertuje tę definicję do makra, z którego można korzystać w funkcji yylex.
Dodatkowo można korzystać z %left (łączność lewostronna), %right (łączność prawostronna)
oraz %nonassoc zamiast %token do określenia połączeń oraz pierwszeństwa.
Można również nadać numeryczne wartości tokenom:
%token NUM 30
Nie jest to zalecane, gdyż może wywołać konflikt wartości makr.
W przypadku wielu typów najpierw należy określić unie:
%union
{
int itype;
double dtype;
}
%token <itype> NUM
Można również połączyć literał z tokenem:
%token arrow „=>”
W przypadku deklaracji unii można nadać jej nazwę:
%union typ
{
int itype;
double dtype;
}
Wtedy unia odpowiada typowi union typ, jeżeli nie jest on zdefiniowany, to przyjmuje
wartość domyślną YYSTYPE.
Symbole nieterminalne definiuje się tak samo jak terminalne, ale za pomocą:
(c) Karolina Nurzyńska
Page 8
Instrukcja Laboratoryjna
%type <type> name
b. Akcje semantyczne przed parsowaniem
Czasami może być konieczne wykonanie pewnych akcji inicjalizacyjnych przed rozpoczęciem
parsowania. W tym celu korzysta sie z dyrektywy %inicital-action{ kod w C}
7. Konflikty
Podczas kompilacji gramatyki do grafu parsera LL(1) bardzo często zdarza się, że dla tej
samej sytuacji (kombinacja stanu parsera oraz symbolu na wejściu) możliwych jest więcej niż jedna
akcja parsera. Oznacza to, że gramatyka jest niejednoznaczna (ang. ambiguous) w sensie LL(1).
Sytuację taką nazywamy konfliktem w gramatyce.
Konflikty występują w dwóch rodzajach:
• konflikty shift/reduce, kiedy możliwa jest zarówno akcja shift jak i reduce,
• konflikty reduce/reduce, kiedy możliwa jest więcej niż jedna akcja reduce według różnych
reguł gramatyki.
a. Konflikty shift/reduce
Konflikt shift/reduce (s/r) zwykle nie przeszkadza w poprawnej pracy parsera. Na dodatek
składnia większości języków wysokiego poziomu z definicji zawiera konstrukcje skutkujące
w konfliktach s/r. Sztandarowym przykładem jest tu instrukcja warunkowa, w której człon else jest
zwykle opcjonalny.
Rozważmy następujący zapis gramatyki:
(1)if_stmt : _IF condition _THEN statement
(2)
| _IF condition _THEN statement _ELSE statement
;
condition : operand logical_op operand
;
operand : _ID
| _INT_LITERAL
;
logical_op : '=' '='
| '>'
| '<'
;
(3) asgn_stmt : _ID '=' _INT_LITERAL ';'
;
statement : asgn_stmt
(4)
| if_stmt
;
oraz ciąg wejściowy:
if a==1 then
if b==2 then
c = 1;
else
c = 2;
•
•
W momencie napotkania słowa else parser ma do wyboru dwie możliwości:
wykonać akcję reduce wg reguły (3) dla ciągu: c = 1; a następnie wg reguły (1) dla ciągu: if
b==2 then c = 1; co w konsekwencji wymusi późniejszą redukcję wg reguły (4) dla ciągu: if
b==2 then c = 1; oraz redukcję wg reguły (2) dla ciągu: if a==1 then if b==2 then c = 1; else c
= 2; W efekcie człon else zostanie dopasowany do zewnętrznej instrukcji if.
wykonać akcję shift co w konsekwencji wymusi późniejszą redukcję wg reguły (3) dla ciągów:
c = 1; oraz c = 2; a następnie redukcję wg reguły (2) dla ciągu: if b==2 then c = 1; else c = 2;
(c) Karolina Nurzyńska
Page 9
Instrukcja Laboratoryjna
i wreszcie redukcję wg reguły (1) dla ciągu: if a==1 then if b==2 then c = 1; else c = 2;
W efekcie człon else zostanie dopasowany do wewnętrznej instrukcji if.
Rezultatem oczekiwanym przez użytkownika jest oczywiście dopasowanie członu else do
wewnętrznej instrukcji if. Z pomocą przychodzi tutaj reguła zachłanności yacca, zgodnie z którą parser
preferuje akcję shift, czyli dlasze gromadzenie tokenów na stosie zamiast akcji reduce.
Jedynie w rzadkich przypadkach konflikt s/r zostaje rozstrzygnięty niezgodnie
z oczekiwaniami użytkownika. Gramatyki zawierające konfilkty s/r są zjawiskiem normalnym. Dlatego
nie będziemy się tutaj zajmować omawianiem sposobów uniknięcia konfliktów s/r.
b. Konflikt reduce/reduce
O wiele groźniejszy jest konflikt reduce/reduce (r/r). Wystąpienie takiego konfliktu wskazuje
na błąd w gramatyce. Jegoskutkiem będą "martwe" tj. nigdy nie redukowane w określonym kontekście
reguły. Konflikt r/r zwykle ma ściśle określoną przyczynę, którą należy zlokalizować oraz
wyeliminować.
Przykład 1.
Jedna z najczęstszych przyczyn konfiktów r/r są dublujące się ścieżki możliwych redukcji dla
określonych ciągów wejścowych. Rozważmy poniższą gramatykę:
%token _ID
%%
stmt : fun_call
| expr
;
fun_call : _ID
| _ID '(' args ')'
;
args : expr
| args ',' expr
;
expr : primary
| expr '+' expr
| primary '=' expr
;
primary : _ID
| primary '[' expr ']'
;
Bez reguł dla symbolu stmt nie posiada ona koflików r/r. Jednak dodanie tylko reguł dla
stmt powoduje postanie konfliktu r/r. Może się więc wydawać, że te właśnie reguły są bezpośrednią
przyczyną konfliku. Prawda jest nieco inna. Konflikt pojawia się, gdyż dla prostego ciągu:
a
pojawiają się dwie możliwe ścieżki redukcji:
_ID -> primary -> expr -> stmt
oraz
_ID -> func_call -> stmt
Przykład 2.
Konflikty r/r mogą też pojawiać się w sprawdzonych już gramatykach na skutek dodawania
akcji semantycznych. Poniższa gramatyka nie zawiera konfliktu r/r:
(c) Karolina Nurzyńska
Page 10
Instrukcja Laboratoryjna
%token _CONST _ID _INT _DOUBLE
%%
decl : type modifier_opt method_decl
| type data_decl
;
type : _INT
| _DOUBLE
;
modifier_opt :
| _CONST
;
method_decl : _ID '(' ')'
;
data_decl : _ID
;
Dodanie akcji semantycznych w regułach dla symbolu decl:
decl : type { saveType($1); } modifier_opt method_decl
| type { saveType($1); } data_decl
;
powoduje pojawienie się konfliktu r/r, którego bezpośrednią przyczyną jest konieczność "redukcji"
jednej z reguł-akcji już po odczycie fragmentu odpowiadającego za deklarację typu. Wybierając jedną
z akcji, parser wybiera tym samym jedną z reguł dla symbolu decl, a na to jest jeszcze
zdecydowanie za wcześnie. Tym sposobem parser w połowie przypadków będzie podejmował błędną
decyzję.
Usuwanie konfliktów r/r
Niestety nie ma jednej uniwersalnej metody na wykrywanie przyczyn oraz usuwanie konfliktów
r/r. Każdy konflikt musi być osobno analizowany. Pomocny w tym może plik opisu grafu parsera
generowany przez BISON'a w wyniku zastosowania opcji ‘–verbose’. Wsród najczęściej
stosowanych technik wspomagających usuwanie konfliktów r/r można wymienić:
•
ekspansję reguł (ang. rules expanding),
•
przeniesienie elementów analizy z poziomu syntaktycznego na semantyczny
(ang. syntax-semantic balance),
•
przeniesienie elementów analizy z poziomu syntaktycznego na leksykalny
(ang. lexical-syntax balance).
Dalej zostanie przedstawionych kilka przykładowych rozwiązań.
(c) Karolina Nurzyńska
Page 11
Instrukcja Laboratoryjna
Rozwiązanie dla przykładu 1.
%token _ID
%%
stmt : fun_call
| expr
| _ID { if (is_func_id($1)) $$ = func_call($1); else $$ =
primary($1); }
;
fun_call : _ID '(' args ')'
;
args : expr
| args ',' expr
;
expr : primary '+' primary
| primary '+' expr
| primary '=' primary
| primary '=' expr
;
primary : _ID
| primary '[' expr ']'
;
W celu usunięcia konfliktu dublujące się (na poziomie reguł dla symbolu stmt) reguły:
fun_call : _ID
oraz
expr : primary
zostały połączone we wspólną regułę:
stmt : _ID
Właściwe rostrzygnięcie kontekstu przeniesione zostało z gramatyki do akcji semantycznej,
tak więc mamy tu zastosowaną technikę syntax-semantic balance. Usunięcie oryginalnych reguł może
pociągać za sobą konieczność ich uzupełnienia w niektórych przypadkach. Taka sytuacja zaistniała
w powyższym przykładzie po usunięciu reguły: expr : primary. W założeniu usunięcie reguły
miało jedynie zlikwidować ścieżkę produkcji pozwalającą dopasować symbol expr do pojedynczego
tokenu _ID. Jednak usunięcie tej reguły powoduje również, iż kolejne reguły:
expr : expr '+' expr
oraz
expr: primary '=' expr
tracą swój oryginalny sens. Aby przywrócić im poprzednie działanie, dodane zostały trzy nowe reguły
dla symbolu expr.
(c) Karolina Nurzyńska
Page 12
Instrukcja Laboratoryjna
Rozwiązanie dla przykładu 2.
%token _CONST _ID _INT _DOUBLE
%%
decl : decl_type modifier method_decl
| decl_type data_decl
;
decl_type : type { saveType($1); }
;
type : _INT
| _DOUBLE
;
modifier :
| _CONST
;
method_decl : _ID '(' ')'
;
data_decl : _ID
;
W celu usunięcia konfliktu wykorzystany został fakt, iż obie akcje semantyczne mają dokładnie
tą samą treść (co nie jest oczywiste dla yacc'a). To samo dotyczy fragmentów reguł przed akcjami.
Wspólne części reguł zostały więc wyłączone pod jeden nowy symbol decl_type. W ten sposób
ponownie mamy tylko jedną ścieżkę produkcji aż do momentu, kiedy kontekst pozwala rozróżnić
między deklaracją metody a deklaracją danych.
Przykład 3.
Rozważmy poniższą gramatykę:
%token _CONST _STATIC _REG _PUBLIC _PRIVATE _ID
%%
decl : scope_opt const_opt id_list
| memclass_opt scope_opt id_list
;
const_opt :
| _CONST
;
memclass_opt :
| _STATIC
| _REG
;
scope_opt :
| _PUBLIC
| _PRIVATE
;
id_list : _ID
| id_list ',' _ID
;
Zawiera ona konflikt r/r ze względu na dublujące się ścieżki produkcji. Tym razem zamiast
analizować dokładnie źródło konfliktu wykonamy ekspansję reguł dla symboli scope_opt,
const_opt oraz memclass_opt. W wyniku otrzymamy gramatykę:
(c) Karolina Nurzyńska
Page 13
Instrukcja Laboratoryjna
%token _CONST _STATIC _REG _PUBLIC _PRIVATE _ID
%%
decl : id_list
| _PUBLIC id_list
| _PRIVATE id_list
| _CONST id_list
| _PUBLIC _CONST id_list
| _PRIVATE _CONST id_list
| id_list
| _PUBLIC id_list
| _PRIVATE id_list
| _STATIC id_list
| _STATIC _PUBLIC id_list
| _STATIC _PRIVATE id_list
| _REG id_list
| _REG _PUBLIC id_list
| _REG _PRIVATE id_list
;
id_list : _ID
| id_list ',' _ID
;
która oczywiście nadal zawiera konflikty, ale do ich usunięcia wystarczy już tyko eliminacja
dublujacych się reguł dla symbolu decl. Po tym kroku otrzymamy już gramatykę bez konfliktów:
%token _CONST _STATIC _REG _PUBLIC _PRIVATE _ID
%%
decl : id_list
| _PUBLIC id_list
| _PRIVATE id_list
| _CONST id_list
| _PUBLIC _CONST id_list
| _PRIVATE _CONST id_list
| _STATIC id_list
| _STATIC _PUBLIC id_list
| _STATIC _PRIVATE id_list
| _REG id_list
| _REG _PUBLIC id_list
| _REG _PRIVATE id_list
;
id_list : _ID
| id_list ',' _ID
;
Inna metoda usunięcia konfliktu polega na połączeniu obu reguł dla symblu decl.
W rezultacie otrzymamy gramatykę dopuszczającą nadzbiór konstrukcji dopuszczanych przez
poprzednią wersję. Konstrukcje zabronione, czyli łączenie tokenu _CONST z tokenami _STATIC oraz
_REG będą wykrywane w ramach akcji semantycznej. Ponownie zastosowaliśmy więc technikę
syntax-semantic balance:
(c) Karolina Nurzyńska
Page 14
Instrukcja Laboratoryjna
%token _CONST _STATIC _REG _PUBLIC _PRIVATE _ID
%%
decl : memclass_opt scope_opt const_opt
{ check_modifiers($1,$2,$3); }
id_list
;
const_opt :
| _CONST
;
memclass_opt :
| _STATIC
| _REG
;
scope_opt :
| _PUBLIC
| _PRIVATE
;
id_list : _ID
| id_list ',' _ID
;
Przykład 4.
Rozważmy uproszczony fragment gramatyki dla języka C++, a w szczególności dla konstrukcji
alokacji pamięci przy pomocy operatora new:
%token _INT _DOUBLE
%token _ID
%token _NEW
%%
allocation_expr : _NEW '(' new_args ')' expr
| _NEW expr
;
new_args : expr
| new_args ',' expr
;
expr : name initializer_opt
| '(' type_name ')' expr
;
type_name : _INT
| _DOUBLE
| _ID
;
name : _ID
| name '.' _ID
;
initializer_opt :
| '(' init_args ')'
;
init_args : expr
| init_args ',' expr
;
Gramatyka zawiera konflikt r/r, którego przyczyną jest istnienie dwóch ścieżek produkcji dla
ciągu:
new (a) X()
(c) Karolina Nurzyńska
Page 15
Instrukcja Laboratoryjna
Mianowicie, fragment (a) może zostać dopasowany jako lista argumentów operatora new
lub też jako operator rzutu typu (type cast) na obiekcie X. Wszystko zależy od tego, czy identyfikator a
jest nazwą obiektu, czy też nazwą typu (zdefiniowana przez typedef). Silniejsza klasyfikacja
tokenów w analizatorze leksykalnym pozwoliłaby skutecznie rozstrzygnąć tą sytuację na poziomie
gramatyki. Możemy zatem skorzystać z techniki lexical-syntax balance, przez wprowadzenie
dodatkowego tokenu _TYPEID i przeniesienie ciężaru rozpoznania pomiędzy nazwą obiektu,
a nazwą typu na poziom leksyklany:
%token _INT _DOUBLE
%token _ID _TYPEID
%token _NEW
%%
allocation_expr : _NEW '(' new_args ')' expr
| _NEW expr
;
new_args : expr
| new_args ',' expr
;
expr : name initializer_opt
| '(' type_name ')' expr
;
type_name : _INT
| _DOUBLE
| _TYPEID
;
name : _ID
| name '.' _ID
;
initializer_opt :
| '(' init_args ')'
;
init_args : expr
| init_args ',' expr
;
Teraz gramatyka nie zawiera konfliktu. Zauważmy jednak, że rozpoznanie identyfikatora, a
jako nazwy obiektu lub typu nie jest to możliwe wyłącznie przy pomocy informacji leksykalnej – w obu
przypadkach mamy do czynienia z takim samym identyfikatorem. Analizator leksykalny będzie więc
musiał korzystać z wypracowanych już w trakcie analizy informacji semantycznych (!). Ilustruje
to poniższy rysunek.
Takie rozwiązanie jest obecnie stosowane w znakomitej większości kompilatorów języka
C/C++. Dostępne gramatyki C/C++ w formacie yacc'a zawierają całe grupy tokenów, których
rozpoznanie może nastąpić tylko z dodatkowym udziałem informacji semantycznej. Dzięki temu same
gramatyki C/C++ zostają uproszczone na tyle, że możliwe jest ich zapisanie w postaci LL(1).
(c) Karolina Nurzyńska
Page 16
Instrukcja Laboratoryjna
Deklaracje:
%left
%nonassoc
%right
%start
%token
%type
%union
$$
$n
Pojęcia:
akcja semantyczna
akcja shift
akcja reduce
głowa gramatyki
gramatyka bezkontekstowa
grupowanie
konflikty shift/reduce
konflikty reduce/reduce
lokacje
token
reguły gramatyczne
rekurencja lewostronna
rekurencja prawostronna
symbol nieterminalny
symbol terminalny
wartośc semantyczna
Literatura
1. Ch. Donnelly, R. Stallman: “BISON, The Yacc-compatible Parser Generator”, 16 September 2005,
BISON Version 2.1
2. M. Forczek: “Instrukcja laboratoryjna Yacc”, 2001-2002 Gliwice, ALDEC-ADT
(c) Karolina Nurzyńska
Page 17