Wydawnictwa NaukowoTechniczne
Alfred V. Aho Ravi Sethi Jeffrey D. Ullman
Kompilatory Reguły, metody i narzędzia
Książka ta „„.może służyć jako podstawa wstępnego wykładu o budowie kompilatorów. Zaleliśmy się problemami najczęściej występującymi w budowie translatorów języków, niezależnie od języka źródhwego czy maszyny docelowe!, f-J Przedstawione w tej książce zasady i techniki pisania kompilatorów sq na tyle ogólne, że mogą być wykorzystywane wielokrotnie podczas kariery naukowca informatyka".
Oto słynne dzieła o kompilatorach, znane w świecie informatyków jako „ksiqika ze smokiem" (ze względu na okładkę oryginału).
Wydawnictwa Naukowo-Techniczne polecają je studentom informatyki. Mamy nadzieję, źe po przeczytaniu ga upomju się i czymś, przez co powinien przejść każdy przyszły programista, a mianowicie z napisaniem pierwszego kompilatora w życiu.
Alfred V. Aho Ravi Sethi Jeffrey D. Ullman Kompilatory
Książka została zeskanowana w celach edukacyjnych. Skan ma służyć promowaniu książki "Kompilatory", po jego przejrzeniu proszę skasować i kupić książkę w postaci tradycyjnej
Dzisiejszy skan sponsorowały literki CS i W A R K A
Kompilatory Reguły, metody i narzędzia
Wydawnictwa NaukowoTechniczne Warszawa
W skład serii „Klasyka Informatyki" wchodzą dzieła najwybitniejszych uczonych świata w dziedzinie informatyki - książki o nieprzemijającej wartości, stanowiące bazę solidnego, klasycznego wykształcenia każdego profesjonalnego informatyka. Wydawnictwa Naukowo-Techniczne przygotowały tę serię ze szczególną pieczołowitością, powierzając tłumaczenie poszczególnych tomów znakomitym specjalistom. Wyboru książek dokonano w ścisłej współpracy z polskim środowiskiem akademickim, dedykując serię głównie studentom informatyki i młodym pracownikom naukowym.
Alfred V. Aho Ravi Sethi Jeffrey D. Ullman
Kompilatory Reguły, metody i narzędzia Z angielskiego przełożyli:
Przemysław Kozankiewicz Łukasz Sznuk
Jeffrey D. Ullman jest profesorem informatyki na Stanford University. Ma stopień bakałarza z matematyki stosowanej (Columbia University) i doktora (Princeton University). Interesuje się teorią baz danych, integracją baz danych, wyszukiwaniem danych i edukacją wspomaganą infrastrukturą informatyczną. Jest autorem i współautorem 14 książek i 170 artykułów. Otrzymał wiele nagród, m.in. Stypendium Guggenheima. Jest członkiem National Academy of Engineering.
Dane o oryginale ALFRED V. AHO AT&T Bell Laboratories Murray Hill, New Jersey RAVI SETHI AT&T Bell Laboratories Murray Hill, New Jersey JEFFREY D. U L L M A N Stanford University Stanford, California Compilers Principles, Techniąues, and Tools Translation copyright © 2001 by WYDAWNICTWA NAUKOWO-TECHNICZNE Compilers. Principles, Techniąues, and Tools Original English language title: Compilers, First Edition by Alfred Aho Copyright © 1986 Ali Rights Reserved Published by arrangement with the original publisher, ADDISON WESLEY LONGMAN, a Pearson Education Company Prowadzenie serii Elżbieta
Beuermann
Redaktor Irena Puchalska Okładkę i strony tytułowe projektował Paweł G. Rubaszewski Redaktor techniczny Ewa Kosińska Korekta Zespół Skład i łamanie IMTEX UNIX jest znakiem towarowym AT&T Bell Laboratories. DEC, PDP i VAX są znakami towarowymi Digital Equipment Corporation. Ada jest znakiem towarowym Ada Joint Program Office, Department of Defense, United States Government. © Copyright for the Polish edition by Wydawnictwa Naukowo-Techniczne Warszawa 2002 Ali Rights Reserved Printed in Poland Utwór w całości ani we fragmentach nie może być powielany ani rozpowszechniany za pomocą urządzeń elektronicznych, mechanicznych, kopiujących, nagrywających i innych, w tym również nie może być umieszczany ani rozpowszechniany w postaci cyfrowej zarówno w Internecie, jak i w sieciach lokalnych bez pisemnej zgody posiadacza praw autorskich. Adres poczty elektronicznej:
[email protected] Strona WWW: www.wnt.com.pl ISBN 83-204-2656-1
Przedmowa
Książkę napisaliśmy na podstawie pracy Principles of Compiler Design Alfreda V. A h o i JefFreya D. Ullmana. Podobnie jak tamta, m o ż e ona służyć jako podstawa wstępnego wykładu o budowie kompilatorów. Zajęliśmy się problemami najczęściej występujący mi w budowie translatorów języków, niezależnie od j ę z y k a źródłowego czy maszyny docelowej. Zapewne niewielu czytelników będzie tworzyć czy pielęgnować kompilator d u ż e g o języka programowania — pomysły i techniki opisane w tej książce będą mogli zastosować do projektowania programów innego typu. Przykładowo, techniki dopasowania wzorca, używane w budowie analizatorów leksykalnych, są również wykorzystywane w edyto rach tekstu, systemach wyszukiwania informacji i programach rozpoznających wzorce. Gramatyki bezkontekstowe i definicje sterowane składnią są stosowane do budowy wielu małych języków, takich j a k systemy składu i tworzenia ilustracji użyte d o przygotowania tej książki*. Techniki optymalizacji kodu są używane w weryfikatorach p r o g r a m ó w oraz w programach tworzących „strukturalne" programy z programów niestrukturalnych. Jak korzystać z książki W książce omówiliśmy szczegółowo istotne tematy związane z budową kompilatorów. W rozdziale 1 przedstawiliśmy podstawowe informacje o kompilatorach, niezbędne do zrozumienia pozostałych rozdziałów książki. W rozdziale 2 omówiliśmy translator, tłumaczący wyrażenia z postaci infiksowej na postfiksową, zbudowany przy użyciu różnych technik, opisanych szerzej w dalszej części książki. W rozdziale 3 przedstawiliśmy analizę leksykalną, wyrażenia regularne, automaty skończone i narzędzia do generowania analizatorów leksykalnych stosowane do przetwa rzania tekstu. W rozdziale 4 zawarliśmy szczegółowy opis głównych technik analizy składnio wej, począwszy od metody zejść rekurencyjnych, dobrej do ręcznego implementowania, a skończywszy na obliczeniowo trudniejszych metodach LR, używanych w generatorach analizatorów składniowych.
PRZEDMOWA
VIII
W rozdziale 5 zajęliśmy się translacją sterowaną składnią, używaną zarówno do specyfikowania, j a k i implementowania translacji. W rozdziale 6 przedstawiliśmy najważniejsze pojęcia stosowane w statycznej analizie semantycznej; szczegółowo omówiliśmy kontrolę typów i unifikację. W rozdziale 7 opisaliśmy różne organizacje pamięci, z których korzystają środowiska przetwarzania programu. W rozdziale 8 omówiliśmy języki pośrednie i pokazaliśmy w jaki sposób m o ż n a przetłumaczyć często spotykane konstrukcje języków programowania na kod pośredni. W rozdziale 9 znalazły się informacje o generacji kodu maszynowego. Przedstawi liśmy podstawowe metody generowania kodu w lot oraz optymalne metody generowania kodu dla wyrażeń. Są tam również informacje o optymalizacji przez szparkę (lokalnej) i generatorach generatorów kodu. W rozdziale 10 wyczerpująco opisaliśmy optymalizację kodu. Przedstawiliśmy rów nież metody analizy przepływu danych oraz najważniejsze metody optymalizacji glo balnej. W rozdziale 11 omówiliśmy p e w n e pragmatyczne zagadnienia, które pojawiają się podczas implementacji kompilatora; inżynieria oprogramowania oraz testowanie są szcze gólnie ważne w budowie kompilatorów. W rozdziale 12 przedstawiliśmy kilka istniejących kompilatorów, które napisano, używając niektórych technik przedstawionych w tej książce. W dodatku A opisaliśmy prosty język, podzbiór Pascala, którego m o ż n a używać jako podstawy wykonywania implementacji. Na podstawie materiałów z tej książki prowadziliśmy wykłady na studiach licen cjackich oraz magisterskich w A T & T Bell Laboratories, Columbia, Princeton i Stanford. Proponujemy, by wstępny wykład o kompilatorach obejmował materiał zawarty w poniższych podrozdziałach książki: wprowadzenie
rozdz. 1 i p. 2.1-2.5
analiza leksykalna
2.6, 3.1-3.4
tablice symboli analiza składniowa
2.7, 7.6 2.4, 4 . 1 - 4 . 4
translacja sterowana składnią
2.5, 5.1-5.5
kontrola typów organizacja pamięci w czasie wykonywania programu
6 . 1 , 6.2 7.1-7.3
generowanie kodu pośredniego
8.1-8.3
generowanie kodu
9.1-9.4
optymalizacja kodu
10.1, 10.2
Informacje potrzebne do wykonania projektu programistycznego, takiego jak w dodat ku A, są w rozdz. 2. Wykład o narzędziach do budowy kompilatorów może zawierać opis generatorów analizatorów leksykalnych z p. 3.5, generatorów analizatorów składniowych z p. 4.8 i 4.9, generatorów generatorów kodu z p. 9.12 oraz materiał o budowie kompilatorów z rozdz. 11. W zaawansowanym wykładzie o kompilatorach należy przedstawić algorytmy uży wane w generatorach analizatorów leksykalnych i składniowych, opisane w rozdz. 3 i 4,
PRZEDMOWA
IX
oraz materiał dotyczący równoważności, przeciążania, polimorfizmu i unifikacji typów z rozdz. 6, organizacji pamięci z rozdz. 7, metod generacji kodu sterowanej wzorcem z rozdz. 9 i optymalizacji kodu z rozdz. 10. Ćwiczenia Ćwiczenia bez gwiazdek są przeznaczone dla osób chcących sprawdzić zrozumienie po danych definicji, a z jedną gwiazdką — dla słuchaczy wykładów na wyższym poziomie. Ćwiczenia z d w o m a gwiazdkami są strawą dla umysłu. Podziękowania Na różnych etapach pisania tej książki wiele osób zgłaszało cenne uwagi. Osoby, u któ rych w związku z tym zaciągnęliśmy dług wdzięczności, to: Bill Appelbe, Nelson Beebe, Jon Bentley, Lois Bogess, Rodney Farrow, Stu Feldman, Charles Fischer, Chris Fraser, Art Gittelman, Erie Grosse, Dave Hanson, Fritz Henglein, Robert Henry, Gerard Hołzmann, Steve Johnson, Brian Kernighan, Ken Kubota, Daniel Lehmann, Dave M c Q u e e n , Dianne Maki, Alan Martin, D o u g Mcllroy, Charles McLaughlin, John Mitchell, Elliott Organick, Robert Paige, Phil Pfeiffer, Rob Pike, Kari-Jouko Raiha, Dennis Ritchie, Sriram Sankar, Pauł Stoecker, Bjarne Stroustrup, Tom Szymański, K i m Trący, Peter Weinberger, Jennifer Widom i Reinhard Wilhelm. Książkę* złożyliśmy za pomocą doskonałego programu dostępnego w systemie ope racyjnym U N I X . Polecenie używane do składania tekstu to p i c files I t b l
I eqn
I troff
-ms
p i c to j ę z y k Briana Kernighana służący do składania ilustracji; Brianowi winni jesteśmy specjalne podziękowania za pogodne przyjęcie naszych specjalnych i rozległych potrzeb dotyczących rysunków, t b l to język M i k e ' a Leska służący do przygotowania tabel. e q n to język Briana Kernighana i Lorindy Cherry, służący do składania wzorów matematycz nych, t r o f f to program Joego Ossany, służący do formatowania tekstu dla fotoskładarki, którą w naszym przypadku był Mergenthaler Linotron 202/N. Pakiet makrodeflnicji ms dla programu t r o f f został napisany przez M i k e ' a Leska. Ponadto, tekstem zarządzaliśmy za pomocą programu make Stu Feldmana. Odwołania w tekście wykonaliśmy, używa jąc programów awk Ala Aho, Briana Kernighana i Petera Weinbergera oraz s e d Lee McMahona. Szczególne podziękowania należą się Patricii Solomon za p o m o c w przygotowaniu rękopisu do składu. Jej p o g o d a ducha i umiejętności maszynopisania były dla nas bardzo cenne. J. D. Ullman podczas pisania książki był wspomagany przez Eistein Fellowship z Israeli Academy of Arts and Sciences. Pragniemy również podziękować A T & T Bell Laboratories za p o m o c w przygotowaniu rękopisu. A.Y.A., R.S., J.D.U.
Spis treści
Rozdział 1 Wprowadzenie do kompilacji
1
ł.l Kompilatory
1
1.2 Analiza programu źródłowego
5
1.3 Fazy kompilatora
9
1.4 Programy pokrewne kompilatorom
15
1.5 Grupowanie faz
19
1.6 Narzędzia do budowy kompilatorów
21
Uwagi bibliograficzne Rozdział 2 Prosty kompilator jednoprzebiegowy
22 24
2.1 Przegląd
24
2.2 Definicja składni
25
2.3 Translacja sterowana składnią
31
2.4 Analiza składniowa
38
2.5 Translator dla prostych wyrażeń
46
2.6 Analiza leksykalna
51
2.7 Dołączenie tablicy symboli
57
2.8 Abstrakcyjne maszyny stosowe
60
2.9 Połączenie technik
66
Ćwiczenia
74
Ćwiczenia programistyczne
77
Uwagi bibliograficzne
77
Rozdział 3 Analiza leksykalna
79
3.1 Rola analizatora leksykalnego
80
3.2 Buforowanie wejścia
84
3.3 Specyfikacja symboli leksykalnych
87
3.4 Rozpoznawanie symboli leksykalnych
93
3.5 Język do specyfikacji analizatorów leksykalnych
100
3.6 Automaty skończone
107
3.7 Od wyrażeń regularnych do NAS
115
3.8 Projekt generatora analizatorów leksykalnych
122
3.9 Optymalizacja dopasowywania wzorców bazujących na DAS
127
Ćwiczenia
138
Ćwiczenia programistyczne
147
Uwagi bibliograficzne
148
Rozdział 4 Analiza składniowa
150
4.1 Rola analizatora składniowego
151
4.2 Gramatyki bezkontekstowe
156
4.3 Tworzenie gramatyki
162
4.4 Analiza zstępująca
172
4.5 Analiza wstępująca
185
4.6 Metoda pierwszeństwa operatorów
193
4.7 Analizatory LR
204
4.8 Używanie gramatyk niejednoznacznych
234
4.9 Generatory analizatorów
244
Ćwiczenia
253
Uwagi bibliograficzne
262
Rozdział 5 Translacja sterowana składnią
265
5.1 Definicje sterowane składnią
266
5.2 Konstrukcja drzew składniowych
273
5.3 Obliczenia wstępujące definicji S-atrybutowanych
279
5.4 Definicje L-atrybutowane
282
5.5 Translacja zstępująca
286
5.6 Obliczanie wstępujące atrybutów dziedziczonych
293
5.7 Obliczanie rekurencyjne
300
5.8 Pamięć przeznaczona na wartości atrybutów w czasie kompilacji
303
5.9 Przypisanie pamięci w trakcie konstrukcji kompilatora
307
5.10 Analiza definicji sterowanych składnią
313
Ćwiczenia
319
Uwagi bibliograficzne
322
Rozdział 6 Kontrola typów
325
6.1 Systemy typów
326
6.2 Specyfikacja prostego kontrolera typów
330
6.3 Równoważność określeń typów
334
6.4 Konwersja typów
340
6.5 Przeciążanie funkcji i operatorów
342
6.6 Funkcje polimorficzne
345
6.7 Algorytm unifikacji
356
Ćwiczenia
361
Uwagi bibliograficzne
366
Rozdział 7 Środowiska przetwarzania 7.1 Język źródłowy
368 368
7.2 Organizacja pamięci
374
7.3 Strategie rezerwacji pamięci
379
7.4 Dostęp do nazw nielokalnych
389
7.5 Przekazywanie parametrów
401
7.6 Tablice symboli
406
7.7 Mechanizmy dostarczane przez język, służące do dynamicznej rezerwacji pamięci
417
7.8 Techniki dynamicznej rezerwacji pamięci
419
7.9 Przydział pamięci w Fortranie
422
Ćwiczenia
431
Uwagi bibliograficzne
436
Rozdział 8 Generowanie kodu pośredniego
438
8.1 Języki pośrednie
439
8.2 Deklaracje
447
8.3 Instrukcje przypisania
452
8.4 Wyrażenia logiczne
461
8.5 Instrukcje wyboru
469
8.6 Poprawianie
473
8.7 Wywołania procedur
478
Ćwiczenia
480
Uwagi bibliograficzne
483
Rozdział 9 Generowanie kodu
484
9.1 Zagadnienia związane z projektowaniem generatora kodu
485
9.2 Maszyna docelowa
490
9.3 Zarządzanie pamięcią w czasie wykonywania programu
492
9.4 Bloki bazowe i grafy przepływu
498
9.5 Informacje o następnym użyciu
504
9.6 Prosty generator kodu 9.7 Przydział i wyznaczanie rejestrów
506 511
9.8 Reprezentacja bloków bazowych przy użyciu dagów
516
9.9 Optymalizacja przez szparkę
523
9.10 Generowanie kodu z dagów 9.11 Algorytm generowania kodu metodą programowania dynamicznego
527 537
9.12 Generatory generatorów kodu
541
Ćwiczenia
548
Uwagi bibliograficzne
551
Rozdział 10 Optymalizacja kodu
554
10.1 Wprowadzenie
555
10.2 Podstawowe źródła optymalizacji
559
10.3 Optymalizacja bloków bazowych
566
10.4 Pętle w grafach przepływu
570
10.5 Wprowadzenie do globalnej analizy przepływu danych
576
10.6 Iteracyjne rozwiązywanie równań przepływu danych
590
10.7 Przekształcenia poprawiające kod
599
10.8 Obsługa synonimów
613
10.9 Analiza przepływu danych w strukturalnych grafach przepływu
624
10.10 Efektywne algorytmy przepływu danych
635
10.11 Narzędzia do analizy przepływu danych
644
10.12 Wykrywanie typów
657
10.13 Symboliczny program uruchomieniowy dla zoptymalizowanego kodu
665
Ćwiczenia
673
Uwagi bibliograficzne
679
Rozdział 11 Chcesz napisać kompilator?
683
11.1 Zaplanowanie kompilatora
683
11.2 Metody tworzenia kompilatorów
685
11.3 Środowisko budowy kompilatora
689
11.4 Testy i pielęgnowanie kompilatorów
691
Rozdział 12 Kilka kompilatorów
692
12.1 EQN — preprocesor do składania wzorów matematycznych
692
12.2 Kompilatory Pascala
693
12.3 Kompilatory C
694
12.4 Kompilatory Fortran H
696
12.5 Kompilator BLISS-11
699
12.6 Kompilator optymalizujący Modula-2
701
Dodatek A Projekt programistyczny A.l Wstęp A.2 Struktura programu A.3 Składnia podzbioru Pascala A.4 Konwencje leksykalne A.5 Propozycje ćwiczeń A.6 Ewolucja interpretera A.7 Rozszerzenia
Bibliografia Skorowidz
Wprowadzenie do kompilacji
Przedstawione w tej książce zasady i techniki pisania kompilatorów są na tyle ogól ne, że mogą być wykorzystywane wielokrotnie podczas kariery naukowca informatyka. W czasie pisania kompilatorów korzysta się z języków programowania, architektur sprzę tu, teorii języków, algorytmów i inżynierii programowania. Szczęśliwie, do stworzenia translatorów dla całkiem dużej liczby języków i maszyn używa się tylko kilku podstawo wych technik. W tym rozdziale przedstawiliśmy poszczególne składniki kompilatorów, środowiska, w których pracują, oraz niektóre narzędzia programistyczne ułatwiające ich pisanie.
1.1
Kompilatory
Kompilator, mówiąc najprościej, jest programem, który czyta kod napisany w jednym języku - języku źródłowym - i tłumaczy go na równoważny program w drugim języku języku wynikowym (rys. 1.1). Ważnym elementem tego procesu translacji jest zgłaszanie użytkownikowi komunikatów o ewentualnych błędach w programie źródłowym.
Program źródłowy
Kompilator
Program wynikowy
i Komunikaty o błędach Rys. 1.1. Kompilator
Na pierwszy rzut oka, różnorodność kompilatorów m o ż e przytłaczać. Istnieją tysiące języków źródłowych, od tradycyjnych języków programowania, j a k Fortran czy Pascal, do wyspecjalizowanych języków, które powstały dla bardzo różnych zastosowań komputera. Języki wynikowe są zróżnicowane w takim samym stopniu; językiem wynikowym może
być inny język programowania albo język maszynowy dowolnego komputera, od mikro procesora do superkomputera. Czasami kompilatory, w zależności od tego, jak zostały skonstruowane i do jakiego celu przeznaczone, są klasyfikowane jako: jednoprzebiegowe, wieloprzebiegowe, typu załaduj i uruchom (ang. load-and-gó), uruchomieniowe (ang. debugging) lub optymalizujące. Pomimo widocznej złożoności zadań, jakie kompilator musi wykonać, podstawowe jego funkcje są takie same. Rozumiejąc te zadania, można budować kompilatory dla różnych odmian języków źródłowych i maszyn docelowych, używając tych samych podstawowych technik. Nasza wiedza o tym, jak zorganizować i pisać kompilatory jest zdecydowanie więk sza niż na początku lat 50., kiedy zaczęły się one pojawiać. Większość wczesnych prac na ich temat dotyczy przede wszystkim translacji wyrażeń arytmetycznych na język maszy nowy. Ponieważ wiele eksperymentów i implementacji było wykonywanych niezależnie przez kilka grup, trudno jest podać dokładną datę powstania pierwszego kompilatora. W latach 50. kompilatory były powszechnie uważane za programy trudne do na pisania. Przykładowo, implementacja pierwszego kompilatora Fortranu pochłonęła 18 osobolat (Backus i inni [1957]). Od tego czasu odkryto systematyczne techniki obcho dzenia się z wieloma ważnymi zadaniami pojawiającymi się podczas kompilacji, a także wynaleziono odpowiednie języki implementacji, środowiska i narzędzia programistycz ne. Dzięki temu, nawet całkiem duży kompilator może zostać zaimplementowany przez studenta jednosemestralnego kursu projektowania kompilatorów.
Model kompilacji typu analiza-synteza Każda kompilacja składa się z dwóch części: analizy i syntezy. Pierwsza część polega na rozłożeniu programu na części składowe i stworzeniu jego pośredniej reprezentacji. Druga, wymagająca najbardziej wyspecjalizowanych metod — na przekształceniu repre zentacji pośredniej w program wynikowy. W podrozdziale 1.2 opisaliśmy w nieformalny sposób analizę, a w podrozdziale 1.3 przedstawiliśmy zarys syntezy kodu wynikowego w zwykłym kompilatorze. Podczas analizy są ustalane operacje wynikające z programu źródłowego i następ nie są zapisywane w hierarchicznej strukturze nazywanej drzewem. Często używa się specjalnego rodzaju drzewa, zwanego drzewem składniowym, w którym każdy węzeł re prezentuje operację, a jego potomkowie — argumenty tej operacji. Przykładowe drzewo składniowe dla instrukcji przypisania jest przedstawione na rys. 1.2.
pozycja
+
początek tempo
60
Rys. 1.2. Drzewo składniowe dla p o z y c j a : = p o c z a t e k + t e m p o * 6 0
Wiele narzędzi programistycznych, które operują na programach źródłowych, doko nuje pewnego rodzaju analizy. Oto kilka przykładów:
1.1
KOMPILATORY
1.
Edytory strukturalne. Edytor strukturalny pobiera jako wejście ciąg poleceń do bu dowy programu źródłowego. Jego funkcją jest nie tylko tworzenie kodu programu i jego modyfikacja jak w zwykłym edytorze, ale także jego analiza w celu wprowa dzenia odpowiedniej struktury hierarchicznej do programu. Edytor strukturalny ma więc ułatwiać przygotowanie programu, wykonując dodatkowe zadania. Może spraw dzać, na przykład, czy kod jest wpisany poprawnie, może automatycznie wpisywać odpowiednie słowa kluczowe (np. kiedy użytkownik wpisze w h i l e , edytor dopisze wymagane d o i przypomni, że wyrażenie warunkowe musi znajdować się między tymi słowami kluczowymi), może umożliwić szybkie przeskakiwanie między od powiadającymi sobie b e g i n i e n d lub lewym i prawym nawiasem. Często nawet wyjście takiego edytora może przypominać wyjście kompilatora po fazie analizy. Formatery kodu programu (ang. pretty printers). Program taki analizuje kod progra mu i drukuje go tak, aby jego struktura była wyraźnie widoczna. Komentarze, na przykład, mogą być przedstawione specjalną czcionką, a instrukcje wcięte na sze rokość proporcjonalną do głębokości ich zagnieżdżenia w hierarchicznej strukturze programu. Kontrolery statyczne. Kontroler statyczny wczytuje program, analizuje go i stara się znaleźć potencjalne błędy bez uruchamiania programu. Część analizy często przy pomina tę zawartą w kompilatorach optymalizujących (omówionych w rozdz. 10). Kontroler statyczny może, na przykład, wykryć te części programu, które nigdy nie zostały wykonane, albo użycie zmiennych przed ich zdefiniowaniem. Dodatkowo może wyłapać błędy logiczne, na przykład próby użycia zmiennej rzeczywistej jako wskaźnika (przy użyciu technik kontroli typów omówionych w rozdz. 6). Interpretery. Zamiast tworzenia kodu wynikowego za pomocą translacji, interpre ter wykonuje po prostu instrukcje zawarte w programie źródłowym. Przykłado wo, dla instrukcji przypisania interpreter może zbudować drzewo, takie jak na rys. 1.2, p o czym wykonać operacje na węzłach w trakcie przechodzenia tego drze wa. W korzeniu interpreter musi wykonać przypisanie, więc wywoła procedurę ob liczającą wyrażenie z prawego potomka korzenia, po czym wynik zachowa w miej scu określonym identyfikatorem p o z y c j a . W prawym potomku korzenia procedura musi obliczyć sumę dwóch wyrażeń. Wywoła się ona rekurencyjnie, aby obliczyć wartość wyrażenia t e m p o * 6 0 , po czym doda tę wartość do wartości zmiennej początek.
2.
3.
4.
Interpretery często są używane do wykonywania języków poleceń, gdyż każdy operator w takim języku jest z reguły wykonaniem złożonej procedury, jak edytor lub kompilator. Podobnie niektóre „bardzo wysokopoziomowe" języki, jak APL, są zwykle interpretowane, ponieważ wiele informacji na temat danych, jak np. rozmiar i struktura tablic, nie mogą być wyznaczone w czasie kompilacji. O kompilatorach myślimy tradycyjnie jako o programach, które tłumaczą język źródło wy, jak Fortran, na asembler lub język maszynowy jakiegoś komputera. Istnieją jednak zastosowania, pozornie nie związane z kompilatorami, w których techniki budowy kom pilatorów są regularnie używane. W każdym z poniższych przykładów część analizy w niniejszym lub większym stopniu przypomina zawartą w zwykłym kompilatorze. 1.
Formatery tekstu. Formater tekstu pobiera z wejścia strumień znaków. Jego więk szość stanowi tekst, który należy wpisać, natomiast reszta to polecenia formatujące
2.
3.
oznaczające paragrafy, rysunki, indeksy dolne i górne itp. W następnym podrozdzia le omówimy niektóre aspekty analizy przeprowadzanej przez formatery tekstu. Kompilatory krzemowe (ang. silicon compilers). Język źródłowy kompilatora krze mowego jest podobny (lub identyczny) do konwencjonalnego języka programowania, jednak zmienne, które ten język reprezentuje, nie są umieszczone w pamięci, a repre zentowane są przez sygnały logiczne (0 lub 1) albo grupy tych sygnałów w obwodzie przełączeniowym. Wynikiem takiego kompilatora jest opis układu elektronicznego w odpowiednim języku. Szersze omówienie kompilatorów krzemowych znajduje się w pracach: Johnsona [1983], Ullmana [1984] i Trickeya [1985]. Interpretery zapytań. Interpreter zapytań tłumaczy predykat zawierający operatory relacyjne lub logiczne na polecenia służące przeszukaniu bazy danych w celu zna lezienia rekordów spełniających ten predykat. (Patrz: Ullman [1982] i Date [1986]).
Kontekst kompilatora Do utworzenia pliku wykonywalnego, oprócz samego kompilatora, może być również potrzebne użycie innych programów. Program źródłowy może być podzielony na moduły przechowywane w oddzielnych plikach. Zbieraniem plików programu może się także zajmować oddzielny program, zwany preprocesorem. Preprocesor może również rozwijać skróty, zwane makrami, w instrukcje języka źródłowego. Na rysunku 1.3 przedstawiono typową „kompilację". Program wynikowy, stworzony przez kompilator, może wymagać dalszego przetwarzania zanim zostanie uruchomiony. Kompilator z rys. 1.3 tworzy kod w asemblerze, który jest tłumaczony przez asembler na kod maszynowy, a potem łączony z funkcjami bibliotecznymi w kod, który dopiero może być uruchamiany na komputerze.
Szkieletowy program źródłowy
i
Preprocesor Program źródłowy
* Kompilator • Wynikowy program w asemblerze
i
Asembler Przemieszczalny kod maszynowy
*
Program ładuj ący/konsolidor
*
Biblioteka, przemieszczalne pliki obiektowe
Bezwzględny kod maszynowy Rys. 1.3. System przetwarzania języków
W następnych dwóch podrozdziałach omówiliśmy składniki kompilatora, natomiast pozostałe programy z rys. 1.3 — w p. 1.4.
1.2
Analiza programu źródłowego
W tym podrozdziale omówiliśmy analizę i jej zastosowanie w pewnych językach służą cych do formatowania tekstu. Dokładniejsze przedstawienie tematu znajduje się w rozdz. 2, 3, 4 i 6. W kompilacji część zajmująca się analizą składa się z trzech faz: 1.
Analizy liniowej, w której strumień znaków, składający się na program wejściowy, jest wczytywany od lewej do prawej i grupowany w symbole leksykalne* (ang. tokeri), czyli ciągi znaków mających razem pewne znaczenie. Analizy hierarchicznej, w której znaki lub symbole leksykalne są grupowane hierar chicznie w zagnieżdżone struktury mające wspólne znaczenie. Analizy semantycznej, w której przeprowadzane są pewne testy, mające zapewnić, że składniki programu pasują do siebie pod względem znaczenia.
2. 3.
Analiza leksykalna W kompilatorze analiza liniowa jest zwana analizą leksykalną kładowo, w analizie leksykalnej znaki instrukcji przypisania
lub skanowaniem**.
Przy
p o z y c j a : =poczatek-f-tempo*60 są pogrupowane w następujące symbole leksykalne: 1) 2) 3) 4) 5) 6) 7)
identyfikator p o z y c j a , symbol przypisania : =, identyfikator p o c z ą t e k , znak plus, identyfikator t e m p o , znak mnożenia, liczba 6 0 .
Odstępy rozdzielające znaki tych symboli leksykalnych zostaną wyeliminowane podczas analizy leksykalnej. Analiza składniowa Analiza hierarchiczna jest zwana analizą składniową lub syntaktyczną. Polega ona na gru powaniu symboli leksykalnych programu źródłowego w wyrażenia gramatyczne, które są używane przez kompilator do syntezy kodu wynikowego. Zwykle wyrażenia gramatyczne są reprezentowane przez drzewo wyprowadzenia, takie jak przedstawione na rys. 1.4. * W literaturze spotyka się także określenie atom leksykalny (przyp. tłum.). ** Moduł wykonujący analizę leksykalną nazywa się lekserem, skanerem lub, po prostu, analizatorem leksykal nym (przyp. tłum.).
instrukcja przypisania
identyfikator
wyrażenie
pozycj a wyrażenie identyfikator początek
wyrażenie
wyrażenie
identyfikator
liczba
tempo
60
Rys. 1.4. Drzewo wyprowadzenia dla p o z y c j a : = p o c z a t e k + t e m p o * 6 0
W wyrażeniu p o c z a t e k + t e m p o * 6 0 , fraza t e m p o * 6 0 jest jednostką logiczną, gdyż według zwykłej zasady obliczania wyrażeń arytmetycznych, mnożenie jest wyko nywane wcześniej niż dodawanie. Ponieważ po wyrażeniu p o c z a t e k + t e m p o występuje znak *, wyrażenie to nie zostało na rys. 1.4 połączone w pojedynczą frazę. Hierarchiczna struktura programu jest zwykle określona zasadami rekurencyjnymi. Poniższe zasady mogą być, na przykład, częścią definicji wyrażeń: 1. 2. 3.
Każdy identyfikator jest wyrażeniem. Każda liczba jest wyrażeniem. Jeśli wyrażenie i wyrażenie-, są wyrażeniami, to wyrażeniami są również x
wyrażenie +wy rażenie wyrażenie * wyrażę nie ( wyrażenie ) x
x
]
x
Zasady 1. i 2. są (nierekurencyjnymi) zasadami podstawowymi, natomiast 3. definiuje wyrażenia jako operatory zastosowane do innych wyrażeń. Stąd według zasady 1. wy rażeniami są p o c z ą t e k i t e m p o . Według zasady 2. wyrażeniem jest 6 0 , natomiast według zasady 3. otrzymujemy najpierw, że t e m p o * 6 0 jest wyrażeniem, i w końcu, że p o c z a t e k + t e m p o * 6 0 jest wyrażeniem. Podobnie instrukcje w wielu językach są zdefiniowane rekurencyjnie za pomocą zasad podobnych do poniższych: 1.
Jeśli identyfikator•, jest identyfikatorem i wyrażenie^ jest wyrażeniem, to identyfikator : x
2.
jest instrukcją. Jeśli wyrażenie x
=wyrażenie^
jest wyrażeniem i instrukcja
2
while ( wyrażenie ) do instrukcja if ( wyrażenie ) then instrukcja x
x
są instrukcjami.
2
2
jest instrukcją, to
Podział na analizę leksykalną i składniową jest właściwie umowny. Zwykle wybiera się taki podział, aby jak najbardziej uprościć całe zadanie analizy. Jednym z czynników mających wpływ na ten podział jest to, czy konstrukcje języka są rekurencyjne, czy też nie. Konstrukcje leksykalne nie wymagają rekursji, natomiast składniowe często. Gramatyki bezkontekstowe są formalizacją tych rekurencyjnych zasad i mogą być użyte do sterowania analizą składniową. Gramatyki te wprowadziliśmy w rozdz. 2 i szerzej omówiliśmy w rozdz. 4. Rekurencja, na przykład, nie jest wymagana do rozpoznawania identyfikatorów, które są zwykle ciągami liter i cyfr, zaczynającymi się od litery. Zwykle rozpoznanie identy fikatorów wymaga jednokrotnego przejrzenia strumienia wejściowego, aż d o momentu, w którym wczytywany znak nie jest ani literą, ani cyfrą, i następnie zgrupowania wszyst kich wczytanych liter i cyfr w symbol leksykalny identyfikatora. Wczytane znaki są zapisywane w tablicy, zwanej tablicą symboli, i usuwane z wejścia, aby można było wczytać następny symbol leksykalny. Taka metoda przeszukiwania liniowego nie jest jednak skuteczna przy analizie wy rażeń i instrukcji. Przykładowo, nie możemy poprawnie dopasować nawiasów w wyra żeniach lub b e g i n i e n d w instrukcjach, bez nakładania jakiegoś rodzaju struktury hierarchicznej na wejście. Drzewo wyprowadzenia z rysunku 1.4 opisuje składniową strukturę wejścia. Bardziej popularną reprezentację struktury składniowej stanowi drzewo składniowe przedstawione na rys. 1.5(a). Jest ono skompresowaną reprezentacją drzewa wyprowadzenia, w którym operatory pojawiają się jako węzły wewnętrzne, a ich argumenty są potomkami tych węzłów. Konstrukcja takich drzew jest przedstawiona w p. 5.2. W rozdziale 2, i dokładniej w 5, omówiliśmy translację sterowaną składnią, w której kompilator używa pewnego rozszerzonego opisu hierarchii programu w celu wygenerowania wyniku.
pozycja
+
początek tempo
(a)
pozycja *
+
początek 60
tempo
(b)
* inttorcal
60
Rys. 1.5. Analiza semantyczna wprowadza konwersję liczby całkowitej na rzeczywistą
Analiza semantyczna Analiza semantyczna polega na kontroli programu źródłowego wyszukującej błędy se mantyczne oraz na zbieraniu informacji dla kolejnej fazy, jaką jest generacja kodu. Do analizy semantycznej, w celu zidentyfikowania operatorów i argumentów wyrażeń i in strukcji, używa się hierarchicznej struktury otrzymanej z analizy składniowej. Ważnym elementem analizy semantycznej jest kontrola typów. Polega ona na spraw dzeniu, czy każdy operator ma argumenty zgodne ze specyfikacją języka. Przykładowo, wiele definicji języków programowania wymaga, aby kompilator zgłaszał błąd za każ-
dym razem, gdy liczba rzeczywista jest używana jako indeks tablicy. Jednakże istnieją języki, w których zmienne różnych typów mogą zostać użyte w argumentach operatorów. Możliwe jest, na przykład, zastosowanie dwuargumentowego operatora arytmetycznego do jednej wartości całkowitej i jednej rzeczywistej. W takim przypadku kompilator mo że potrzebować przekształcić liczbę całkowitą w rzeczywistą. Kontrola typów i analiza semantyczna są omówione w rozdz. 6. Przykład 1.1. Wewnątrz komputera bitowy format liczby całkowitej różni się od for matu liczby rzeczywistej, nawet gdy obie liczby mają tę samą wartość. Załóżmy, że wszystkie identyfikatory z rys. 1.5 zostały zadeklarowane jako rzeczywiste i że 60 zosta ło zakwalifikowane jako liczba całkowita. Kontrola typów z rys. 1.5(a) stwierdza, że * jest zastosowana do liczby rzeczywistej t e m p o i całkowitej 6 0 . Ogólnie, liczba całkowita musi zostać przekonwertowana na rzeczywistą. Na rysunku 1.5(b) został dodany operator inttoreal, który przeprowadza wprost potrzebną konwersję. Skoro operator inttoreal ma stały argument, zamiast konwersji kompilator może w miejsce stałej całkowitej wstawić odpowiednią stałą rzeczywistą. • Analiza w formaterach tekstu Wejście formatera tekstu jest rodzajem specyfikacji hierarchii prostokątnych bloków, które na urządzeniu wyjściowym są wypełniane jakimś wzorem bitowym (reprezentującym jasne i ciemne piksle). W ten sposób, na przykład, pracuje system TJHX* (Knuth [1984a]). Każdy znak nie będący częścią polecenia reprezentuje blok zawierający wzór bitmapowy tego znaku o odpowiedniej czcionce i rozmiarze. Występujące po sobie znaki nie oddzielone odstę pem (spacją łub znakiem końca wiersza) są grupowane w słowa, składające się z ciągów ułożonych poziomo bloków, jak na rys. 1.6. Grupowanie znaków w słowa (lub polecenia) jest leksykalnym elementem analizy formatera tekstu.
w a
ow a
Rys. 1.6. Grupowanie znaków i słów w bloki
Bloki w TEX-U mogą być budowane z mniejszych bloków, rozmieszczonych w po ziomie lub w pionie. Polecenie \ h b o x { <lista bloków> } grupuje listę bloków, umieszczając j e obok siebie poziomo, natomiast operator \ v b o x pionowo. Zatem, jeśli napiszemy w TgX-u \hbox{\vbox{!
1}
\vbox{@
2}}
otrzymamy układ bloków jak na rys. 1.7. Ustalenie hierarchicznego układu bloków jest częścią analizy składniowej w Tr-pC-u. * TgX - czytaj tech (przyp. tłum.).
1.3
9
FAZY KOMPILATORA
@ 1 2
•
Rys. 1.7. Hierarchia bloków w Tr-.X-u
Innymi przykładami formaterów tekstu są preprocesor wzorów matematycznych EQN (Kernighan and Cherry [1975]) i procesor matematyczny w Tr-jX-u. Składają one wyrażenia matematyczne przy użyciu operatorów, takich jak s u b i s u p . Wymienione operatory służą do tworzenia indeksów dolnych oraz górnych i po otrzymaniu na wejściu tekstu BLOK
s u b blok
EQN zmniejsza rozmiar bloku i dołącza go do BLOKU przy jego prawym dolnym rogu (rys. 1.8). Operator s u p działa analogicznie, dołączając blok przy prawym górnym rogu.
Rys. 1.8. Sposób tworzenia wyrażenia z indeksem dolnym
Operatory te mogą być zagnieżdżane, więc na przykład napis w EQN a
sub
{i
sub
2}
daje w wyniku a. . Grupowanie operatorów s u b i s u p w symbole leksykalne jest częścią analizy leksykalnej w EQN. Jednak, do wyznaczenia położenia i rozmiaru bloków jest potrzebna struktura składniowa napisu. 2
1.3
Fazy kompilatora
Kompilator działa w fazach, które po kolei przekształcają program z jednej postaci na inną. Typowe elementy kompilatora przedstawiono na rys. 1.9. W praktyce jednak nie które fazy mogą być łączone (omówiono to w p. 1.5), a pośrednia reprezentacja między fazami nie musi być konstruowana wprost. W podrozdziale 1.2 omówiliśmy pierwsze trzy fazy składające się na część ana lizującą kompilator. Znajdujące się na rysunku dwa elementy: zarządzanie tablicą sym boli i obsługa błędów, są przedstawione jako współdziałające z sześcioma fazami: analizą leksykalną, analizą składniową, analizą semantyczną, generacją kodu pośrednie go, optymalizacją kodu i generacją kodu. Nieformalnie, będą one również nazywane „fazami".
Program źródłowy
* ,
Analizator
kodu Program wynikowy Rys. 1.9. Fazy kompilatora
Zarządzanie tablicą symboli Ważną funkcją kompilatora jest zapamiętywanie identyfikatorów używanych w programie źródłowym i zbieranie informacji o różnych atrybutach tych identyfikatorów. Atrybuty te mogą dostarczać informacji o zajętej pamięci dla identyfikatora, o j e g o typie, zasięgu (gdzie w programie jest on dostępny i widoczny); w przypadku nazw procedur podają również liczbę i typy argumentów, metody przekazywania każdego argumentu (np. przez referencję) oraz typ wyniku, jeśli ta procedura zwraca wynik. Tablica symboli jest strukturą danych zawierającą dla wszystkich identyfikatorów rekordy z ich atrybutami. Taka struktura musi umożliwiać szybkie znalezienie rekordu dla każdego identyfikatora oraz szybkie zapisanie i odczytanie danych z rekordu. Tablice symboli są omówione w rozdz. 2 i 7. Każdy identyfikator w programie źródłowym, napotykany przez analizę leksykalną, jest dodawany do tablicy symboli. Oczywiście, większości atrybutów identyfikatorów nie można wyznaczyć w prosty sposób podczas analizy leksykalnej. Przykładowo, w Pascalu w deklaracji var
pozycja,
początek,
tempo
: real
;
typ rzeczywisty nie jest znany w chwili, gdy analizator leksykalny wczytuje identyfikatory pozycja, p o c z ą t e k i tempo. Pozostałe fazy wstawiają do tablicy symboli informacje o identyfikatorach, aby po tem wykorzystać j e do różnych celów; na przykład, podczas analizy semantycznej i gene racji kodu pośredniego do sprawdzenia, czy program źródłowy używa tych identyfikato-
rów poprawnie, i do wygenerowania poprawnych operacji na nich działających. Generator kodu, aby mógł działać, potrzebuje dokładnych informacji na temat pamięci przydzielonej identyfikatorom. Wykrywanie i zgłaszanie błędów Podczas każdej fazy kompilacji można napotkać błędy w programie źródłowym. Po wykryciu takiego błędu, musimy się nim jakoś zająć, aby kompilacja mogła być konty nuowana i mogła wykryć dalsze błędy. Kompilator, który zatrzymuje się po napotkaniu pierwszego błędu, jest mało użyteczny. Większość błędów kompilator wykrywa podczas fazy analizy składniowej i seman tycznej. W fazie analizy leksykalnej mogą zostać wykryte błędy, jeśli znaki pojawiające się na wejściu nie stanowią żadnego symbolu leksykalnego języka. Kiedy strumień nie pasuje do zasad budowy strukturalnej (składniowej) języka, błędy są znajdowane przez fazę analizy składniowej. Podczas analizy semantycznej kompilator wykrywa konstrukcje z poprawną strukturą składniową, ale na których nie daje się zastosować użytej operacji, na przykład dodania dwóch identyfikatorów, z których jeden jest nazwą tablicy, a drugi procedury. Obsługa błędów w poszczególnych fazach kompilacji jest omówiona w czę ściach książki dotyczących tych faz.
Fazy analizy Podczas postępu translacji zmienia się wewnętrzna reprezentacja programu źródłowego w kompilatorze. Zilustrujemy ten proces, rozważając translację instrukcji pozycja: =poczatek+tempo*60
(1.1)
Na rysunku 1.10 przedstawiono reprezentacje tej instrukcji w poszczególnych fazach. Podczas fazy analizy leksykalnej wczytuje się znaki programu źródłowego i gru puje je w strumień symboli leksykalnych, z których każdy reprezentuje spójną logicznie sekwencję znaków, takich jak identyfikator, słowo kluczowe ( i f , w h i l e itp.), znaki przestankowe (przecinki, średniki i inne) lub operatory kilkuznakowe, jak : =. Ciągi zna ków tworzących symbole leksykalne są zwane leksemami. Niektóre symbole leksykalne są rozszerzone o tzw. wartość leksykalną. Przykłado wo, kiedy wczytywany jest identyfikator t e m p o , analizator leksykalny nie tylko generuje symbol id, ale również do tablicy symboli wpisuje leksem t e m p o (oczywiście tylko za pierwszym razem po jego napotkaniu). Wartość leksykalna odpowiadająca symbolowi id jest wskaźnikiem do tablicy symboli i wskazuje t e m p o . W tym podrozdziale, aby podkreślić, że wewnętrzna reprezentacja identyfikatora nie jest po prostu ich sekwencją znaków, użyjemy i d i d i i d dla oznaczenia symboli p o z y c j a , p o c z ą t e k i t e m p o . Reprezentacja wyrażenia (1.1) po analizie leksykalnej może mieć postać: p
id! : - i d + i d * 6 0 2
1
2
3
(1.2)
W powyższym wyrażeniu operator : = i liczba 60 również powinny być przedstawione jako symbole leksykalne, aby odzwierciedlić ich wewnętrzną reprezentację. Zajęliśmy się tym jednak w rozdz. 2; analiza leksykalna jest dokładnie omówiona w rozdz. 3.
pozycj a:=poczatek+tempo*60
ł Analizator leksykalny
} id :=id +id *60 1
2
3
__i
Analizator składniowy
id! id id
60
3
Analizator semantyczny
id, TABLICA SYMBOLI
id
1 pozycja 2 początek 3 tempo 4
id:
Inttoreal i
i
60
Generator kodu pośredniego
ł templ:=inttoreal(60) temp2 : = i d 3 * t e m p l temp3:=id2+temp2 idl:=temp3
*
Optymalizator kodu
l templ :-id3*60.0 idl:=id2+templ
+
Generator kodu
T~ MOVF MULF MOVF ADDF MOVF
i d 3 , R2 # 6 0 . 0 , R2 i d 2 , Rl R2, Rl Rl, i d l
Rys. 1.10. Translacja pojedynczej instrukcji
i
id,
+ id
/
\ /
T
id 1
*
2
id^
r
i i i
\
60
r
id; 2 idj 3
liczba ,60
(b)
(a)
Rys. 1.11. Struktura danych (b) dla drzewa (a)
Fazy druga i trzecia, czyli analiza składniowa i semantyczna, omówiliśmy j u ż w p. 1.2. Podczas analizy składniowej przekształcamy strumień symboli leksykalnych do struktury hierarchicznej (rys. 1.1 l(a)); typową strukturę danych dla tego drzewa przedstawiono na rys. l . ł l ( b ) . Węzły wewnętrzne są rekordami, zawierającymi jedno pole z rodzajem ope ratora oraz dwa pola ze wskaźnikami do lewego i prawego potomka. Liście są rekordami, zawierającymi dwa lub więcej pól: jedno identyfikujące symbol leksykalny, a pozostałe zawierające informacje o symbolu leksykalnym. Ewentualne dalsze pola służą do prze chowywania dodatkowych potrzebnych informacji. Analizę składniową i semantyczną omówiono, odpowiednio, w rozdz. 4 i 6. Generacja kodu pośredniego Po zakończeniu analizy składniowej i semantycznej niektóre kompilatory generują wprost reprezentację pośrednią programu źródłowego. Ta reprezentacja może być traktowana jak program dla pewnej abstrakcyjnej maszyny, a powinna ona mieć dwie cechy: dać się łatwo utworzyć oraz przetłumaczyć na program wynikowy. Pośrednia reprezentacja może mieć wiele postaci. W rozdziale 8 omówiliśmy pośred nią reprezentację nazywaną „kodem trójadresowym" (ang. three-address code), przypo minającym asembler dla maszyny, w której każdy adres pamięci może działać jak rejestr. Kod trójadresowy składa się z sekwencji rozkazów, z których każdy ma co najwyżej trzy argumenty. Program źródłowy (1.1) w kodzie trójadresowym może mieć postać templ:=inttoreal(60) temp2:=id3*templ temp3:=id2+temp2 idl:=temp3 Reprezentacja trójadresowa musi spełniać trzy własności. Po pierwsze, każdy rozkaz oprócz przypisania może mieć co najwyżej jeden operator. Generując reprezentację po średnią, kompilator musi więc ustalić kolejność operacji. W powyższym przypadku mno żenie jest wykonywane przed dodawaniem. Po drugie, kompilator musi wygenerować tymczasowe identyfikatory, aby przechowywać pośrednie wartości obliczone przez roz kaz. Po trzecie, niektóre z instrukcji „trójadresowych" mają mniej niż trzy argumenty, na przykład pierwszy i ostatni z rozkazów (1.3). W rozdziale 8 omówiliśmy główne reprezentacje pośrednie używane w kompila torach. Ogólnie, reprezentacje te muszą umożliwiać również inne operacje oprócz obli-
czania wyrażeń, jak konstrukcje przebiegu sterowania programu i wywołania procedur. W rozdziałach 5 i 8 przedstawiliśmy algorytmy generacji kodu pośredniego dla typowych konstrukcji języków programowania.
Optymalizacja kodu W fazie optymalizacji kodu staramy się poprawić kod pośredni, w celu otrzymania kodu maszynowego działającego szybciej. Niektóre optymalizacje są trywialne. Najprostszy algorytm generuje kod pośredni (1.3), używając pojedynczego rozkazu na każdy ope rator w drzewie składniowym. Zwykle jednak istnieje lepsza metoda przeprowadzenia niektórych obliczeń przy użyciu dwóch rozkazów, na przykład t e m p l :=id3*60 .0
. (1.4) f
idl:=id2+templ W fazie optymalizacji kodu kod pośredni może zostać przekształcony przez kompilator do powyższej postaci za pomocą dwóch zasad: po pierwsze, konwersja z wartości całko witej do rzeczywistej liczby 60 może być wykonana jednorazowo w czasie kompilacji, więc operator i n t t o r e a l może zostać wyeliminowany. Po drugie, t e m p 3 jest używa ne jednokrotnie tylko w przypisaniu na i d l , można zatem podstawić i d l za t e m p 3 i usunąć ostatnią instrukcję z (1.3). W ten sposób otrzymano kod (1.4). Między metodami optymalizacji kodu w różnych kompilatorach są bardzo duże róż nice. W kompilatorach, nazywanych „kompilatorami optymalizującymi", mających naj bardziej rozbudowany kod, znacząca część czasu kompilacji jest przeznaczona na samą fazę optymalizacji. Istnieją jednak proste metody optymalizacji, które znacząco zwięk szają prędkość działania programu, a tylko w niewielkim stopniu wydłużają kompilację. Wiele z tych metod jest omówionych w rozdz. 9, a technologie używane w najbardziej zaawansowanych kompilatorach optymalizujących są przedstawione w rozdz. 10.
Generacja kodu Ostatnią fazą kompilacji jest generacja kodu, będącego zwykle przemieszczalnym kodem maszynowym lub kodem asemblera. Każdej zmiennej zostaje przypisany adres w pamię ci i następnie rozkazy kodu pośredniego są tłumaczone na sekwencję rozkazów maszy nowych. Ważnym zadaniem na tym etapie jest przypisanie właściwych zmiennych do rejestrów. Kod (1.4), po translacji na kod maszynowy pewnej maszyny, używający rejestrów 1 i 2, przyjmuje postać MOVF MULF MOVF ADDF MOVF
i d 3 , R2 # 6 0 . 0 , R2 i d 2 , Rl R2, Rl Rl, idl
(1.5)
Pierwszy argument tych rozkazów oznacza źródło, a drugi przeznaczenie. Litera F w na zwie każdego rozkazu oznacza operacje na wartościach zmiennopozycyjnych. Powyższy
1
kod przepisuje zawartość adresu i d 3 do rejestru 2, następnie przemnaża go przez stałą rzeczywistą 60.0. Znak # oznacza, że 6 0 . 0 należy traktować jako stałą. Trzeci rozkaz przepisuje i d 2 do rejestru 1, a następnie wartości z rejestrów 1 i 2 są sumowane w reje strze 1. Na końcu, wartość z rejestru 1 jest przepisywana pod adres i d l . Powyższy kod jest zatem implementacją przypisania z rys. 1.10. Generacji kodu dotyczy rozdz. 9.
1.4
Programy pokrewne kompilatorom
Jak wynika z rysunku 1.3, dane wejściowe kompilatora mogą być produkowane przez jeden łub więcej preprocesorów. Jednak dane wyjściowe mogą również wymagać dalszego przetwarzania przed otrzymaniem działającego kodu maszynowego. W tym podrozdziale omówimy kontekst, w jakim typowo działa kompilator. Preprocesory Preprocesory przygotowują kod źródłowy dla kompilatorów. Mogą one wykonywać na stępujące funkcje: 1. 2.
3.
4.
Przetwarzanie makropoleceń. Preprocesor może pozwalać użytkownikowi na defi niowanie tzw. makropoleceń lub makr, które są skrótami dłuższych konstrukcji. Włączanie plików. Preprocesor może włączać pliki nagłówkowe w tekst programu. Na przykład, preprocesor C w miejsce instrukcji # i n c l u d e < g l o b a l . h> wsta wia zawartość pliku < g l o b a l . h > . Preprocesory „racjonalne". Ten rodzaj preprocesorów uzupełnia stare języki o bar dziej nowoczesne konstrukcje i struktury danych. Może udostępniać odpowiednie makra dla konstrukcji, które w danym języku nie istnieją, na przykład while lub if. Rozszerzenia języka. Takie preprocesory dodają do języka możliwości zawarte we wbudowanych w nie makrach. Na przykład Eąuel (Stonebraker i in. [1976]) jest językiem zapytań baz danych, osadzonym w języku C. Instrukcje zaczynające się od # # są przetwarzane przez preprocesor na niezwiązane z C instrukcje dostępu do bazy danych i następnie tłumaczone na wywołania konkretnych procedur.
Procesory makropoleceń mają do czynienia z dwoma rodzajami instrukcji: defi niowaniem makr i ich używaniem. Definicje są z reguły oznaczane jakimś unikalnym znakiem lub słowem kluczowym, jak d e f i n e łub m a c r o . Zawierają one nazwę defi niowanego makropolecenia oraz jego treść. Często procesory makr pozwalają w definicji na użycie parametrów formalnych, to znaczy symboli, które podczas rozwijania są wymie niane na wartości (wartościami są tutaj ciągi znaków). Użycie makra składa się z nazwy makra oraz parametrów aktualnych, czyli wartości dla parametrów formalnych. Procesor makr podstawia parametry aktualne w miejsce formalnych w treść makra i zastępuje wywołanie makra jego treścią. /.' 1
Pominęliśmy teraz ważną kwestię przydziału pamięci dla identyfikatorów z programu^ źródłowego, jak oka&e się w rozdz. 7, organizacja pamięci w czasie wykonywania programu zależy od kompilowanego języka. Decyzje o przydziale pamięci mogą być podejmowane zarówno podczas generacji kodu wynikowego^ jak i pośredniego.
Przykład 1.2. Wspomniany w podrozdziale 1.2 system składu tekstu TĘX umożliwia tworzenie makr. Definicje makr mają postać \ d e f i n e <szablon> {}* Nazwa makra jest ciągiem dowolnych liter poprzedzonym znakiem odwrotnego ukośni ka. Szablon jest ciągiem dowolnych znaków zawierającym parametry formalne w postaci dwuznakowych ciągów # 1 , # 2 , # 9 . Te parametry mogą występować w tre ści makropolecenia dowolną liczbę razy. Poniższe makro, na przykład, definiuje cytat z czasopisma Journal of the ACM \define\JACM {{\sl J.
#1;#2;#3. ACM} { \ b f # 1 } : # 2 ,
pp.
#3.}
Nazwą makra jest \JACM, a szablonem # 1 ; # 2 ; # 3 . (średniki oddzielają parametry, a za ostatnim parametrem występuje kropka). Użycie makra musi mieć postać zgodną z szablonem, w którym za parametry można podstawić dowolne ciągi znaków . Zatem, pisząc 1
\JACM
17;4;715-728.
oczekujemy, że otrzymamy J. ACM 17:4, pp. 715-728. Wyrażenie { \ s l J . ACM} powoduje wypisanie znaków J.ACM czcionką pochyłą, nato miast { \ b f # 1 } oznacza wypisanie pierwszego parametru aktualnego, oznaczającego tutaj numer tomu, czcionką półgrubą. W 1EX-U, W definicji makra \ JACM, do oddzielenia numeru tomu, wydania i nu merów stron można użyć dowolnych znaków przestankowych lub innych napisów. Można nawet nie używać żadnych znaków do ich rozdzielenia. Wtedy TgK za kolejne parametry będzie przyjmował pojedyncze znaki lub napisy w nawiasach klamrowych { }. •
Asembler Niektóre kompilatory produkują kod w asemblerze, taki jak (1.5). Kod ten jest przeka zywany do dalszego przetwarzania do asemblera. Natomiast inne kompilatory wykonują pracę asemblera i same generują przemieszczalny kod maszynowy, który może zostać bezpośrednio przekazany do programu ładującego lub konsolidatora. Zakładamy na tym etapie, że Czytelnik posiada pewną podstawową wiedzę na temat tego, jak wygląda ję zyk asemblera, i co w ogóle robi asembler. Tutaj omówimy związek asemblera z kodem maszynowym. Kod asemblera jest mnemonicznym zapisem kodu maszynowego, w którym uży wa się nazw zamiast binarnych kodów operacji i adresów pamięci. Typowa sekwencja rozkazów w asemblerze może mieć postać * W systemie TgX definicje zaczynają się od słowa \ d e f , a nie od \ d e f i n e (przyp. tłum.). Tak naprawdę to prawie dowolne ciągi. Wczytywanie użycia makra odbywa się od lewej do prawej, więc wszystkie znaki do pierwszego wystąpienia symbolu znajdującego się w szablonie za #i zostają przypisane do parametru #t\ Zatem, jeśli chcielibyśmy podstawić a b ; c d za # 1 , okazałoby się, że tylko a b zostało przypisane do # 1 , a c d zostałoby przypisane do # 2 . 1
MOV a , R l ADD # 2 , R l MOV R l , b
(1.6)
Kod ten przenosi zawartość adresu a do rejestru 1, następnie dodaje do niego stałą równą 2, traktując zawartość rejestru 1 jako liczbę stałopozycyjną, i na koniec zapisuje wynik w miejscu oznaczonym przez b . Kod ten oblicza zatem b : = a + 2 . Bardzo często w asemblerze są dostępne makra podobne do tych z preprocesorów.
Asemblacja dwuprzebiegowa Najprostszy asembler dwukrotnie wczytuje dane wejściowe. Jednokrotne przejrzenie tych danych jest nazywane przebiegiem. W trakcie pierwszego przebiegu wszystkie identyfi katory oznaczające adresy są zapisywane w tablicy symboli (oddzielnej od standardowej tablicy symboli kompilatora). Każdemu identyfikatorowi, w chwili jego napotkania, jest przypisywany adres pamięci, więc po wczytaniu kodu (1.6) tablica symboli może wyglą dać tak, jak na rys. 1.12. Założyliśmy, że słowo (ang. word) składa się z czterech bajtów oraz że każdy identyfikator jest słowem, a adresy zaczynają się od bajtu 0.
IDENTYFIKATOR
ADRES
a 0 b 4 Rys. 1.12. Tablica symboli asemblera z identyfikatorami z kodu (1.6)
W czasie drugiego przebiegu asembler wczytuje dane wejściowe ponownie. Tym razem tłumaczy kod każdej operacji na sekwencję bitów reprezentującą ją w kodzie maszynowym oraz tłumaczy każdy identyfikator na przypisany mu adres w pierwszym przebiegu. Wynikiem drugiego przebiegu jest zwykle tzw. kod maszynowy przemieszczalny lub relokowalny, czyli taki, który może być załadowany pod dowolny adres L. Wykonywa ne jest to przez dodanie do wszystkich adresów w kodzie wartości L. Kod wynikowy asemblera musi zatem zawierać informację o adresach, które muszą być relokowane.
Przykład 1.3. Poniżej znajduje się kod dla hipotetycznej maszyny, mogący powstać w trakcie translacji (1.6) 0001 0011 0010
0 1 00 0 1 10 0 1 00
00000000 00000010 00000100
* (1.7) *
Pierwsza kolumna jest czterobitowym kodem instrukcji, gdzie 0 0 0 1 , 0 0 1 0 i 0 0 1 1 oznaczają odpowiednio załaduj, zapisz i dodaj. Przez załadowanie i zapisanie rozumiemy przesłanie danej z pamięci do rejestru i z rejestru do pamięci. Następne dwa bity oznaczają rejestr, 0 1 we wszystkich powyższych instrukcjach oznacza rejestr 1. Kolejne dwa bity są znacznikiem trybu adresowania: 00 oznacza zwykłe adresowanie i wtedy ostatnich
osiem bitów jest adresem pamięci, 10 jest adresowaniem „natychmiastowym" i ostatnie bity są przyjmowane za argument. Ten tryb pojawia się w drugim rozkazie. W kodzie (1.7), obok pierwszego i trzeciego rozkazu występuje *, oznaczająca bit przemieszczenia (relokacji), który jest przypisany do każdego argumentu wymagającego przemieszczenia. Załóżmy, że przestrzeń adresowa danych ma się znajdować pod adresem L. Obecność symbolu * oznacza, że L musi być dodane do adresu w rozkazie. Zatem, jeśli L— 0 0 0 0 1 1 1 1 , tzn. 15, to adresami a i b będą odpowiednio 15 i 19. Rozkazy kodu (1.7) zostaną przetworzone do kodu bezwzględnego, czyli nieprzemieszczalnego, do postaci 0 0 0 1 0 1 00 0 0 1 1 0 1 10 0 0 1 0 0 1 00
00001111 00000010 00010011
(1.8)
Zauważmy, że skoro przy drugim rozkazie w kodzie (1.7) nie było *, więc do adresu wewnątrz tego rozkazu nie dodaje się L. Jest to dokładnie tak, jak powinno być, ponieważ bity te oznaczają stałą 2, a nie adres 2. • Programy ładujące i konsolidatory Zwykle program ładujący służy do dwóch zadań: ładowania i konsolidacji. Proces łado wania polega na wczytaniu kodu maszynowego przemieszczalnego, relokacji potrzebnych adresów (w taki sposób, jak w przykładzie 1.3) oraz umieszczeniu przetworzonych roz kazów i danych pod odpowiednimi adresami w pamięci. Konsolidator (linker) umożliwia łączenie wielu plików zawierających przemieszczalny kod maszynowy w pojedynczy program. Pliki te mogą być wynikiem kilku różnych kompilacji, a niektóre z nich mogą być bibliotekami procedur systemowych dostępnych dla wszystkich programów, które ich wymagają. W łączonych plikach mogą pojawić się referencje zewnętrzne, w których kodzie ist nieją odwołania z jednego pliku do adresu w innym pliku. Odwołanie to może dotyczyć danych zdefiniowanych w jednym pliku i użytych w innym lub może być adresem punktu wejścia do procedury znajdującej się w jednym pliku i wywoływanej w drugim. Przemieszczalny kod maszynowy musi mieć tablicę symboli zawierającą wszystkie adresy lub procedury, do których mogą być zewnętrzne odwołania. Jeśli nie wiemy wcześniej, do których procedur i adresów będą odwołania, musimy dołączyć do przemieszczalnego kodu maszynowego całą tablicę symboli asemblera. Na przykład, kod (1.7) może zostać poprzedzony informacją a b
0 4
Jeśli plik załadowany razem z (1.7) odnosi się do b , to ta referencja zostanie zastąpiona przez 4 plus adres, pod którym zostaną umieszczone dane pliku (1.7).
1.5
1.5
GRUPOWANIE FAZ
19
Grupowanie faz
Dyskusja na temat faz w podrozdziale 1.3 dotyczyła logicznej organizacji kompilatora. W trakcie implementacji często grupuje się dwie lub więcej faz w jedną.
Przód i tył kompilatora Często fazy są dzielone na dwie grupy: na przód i tył kompilatora. Przód składa się z faz, które zależą przede wszystkim od języka źródłowego i są niemal niezależne od języka wynikowego. Zwykle przód kompilatora składa się z analizatora leksykalnego, składniowego, semantycznego, tablicy symboli i generatora kodu pośredniego; zawiera również obsługę błędów dla tych faz, a także może dokonywać pewnej optymalizacji. Tył kompilatora zawiera elementy zależne od maszyny docelowej oraz nieza leżne od języka źródłowego (ale zależne od języka pośredniego). Na tył kompilatora skła da się optymalizacja i generacja kodu oraz potrzebne operacje obsługi błędów i tablicy symboli. Bardzo często do napisania kompilatora dla tego samego języka wejściowego, ale dla innej maszyny docelowej, używa się tego samego przodu i pisze nowy tył. Jeśli tył był dobrze zaprojektowany, prawdopodobnie nie trzeba będzie w nim wiele zmieniać. Ta kwestia jest omówiona dokładniej w rozdz. 9. Czasem spotyka się kompilatory dla wielu różnych języków źródłowych i dla jednego kodu pośredniego; wtedy jest używany wspólny tył dla wielu przodów. W ten sposób można otrzymać wiele kompilatorów dla tej samej maszyny. Jednak przez subtelne różnice w założeniach różnych języków taka metoda ma ograniczone zastosowanie.
Przebiegi Kilka faz kompilacji jest zwykle zaimplementowanych jako pojednyczy przebieg, polega jący na jednorazowym wczytaniu pliku wejściowego i wygenerowaniu pliku wyjściowego. W praktyce istnieje wiele sposobów grupowania faz w przebiegi, dlatego w tej książce skupiamy się głównie na fazach, nie na przebiegach. W rozdziale 12 omówiliśmy niektóre typowe kompilatory i sposób podziału ich faz na przebiegi. Jak wcześniej wspomnieliśmy, różne fazy często są grupowane w pojedynczy prze bieg, a ich działanie jest przeplatane w obrębie tej fazy. W jeden przebieg można, na przykład, zgrupować analizę leksykalną, składniową, semantyczną i generację kodu po średniego. W takim przypadku, symbole leksykalne po analizie leksykalnej mogą zostać bezpośrednio przekształcone do kodu pośredniego. Analizator składniowy jest w tym przebiegu „modułem zarządzającym". Na podstawie wczytywanych symboli stara się on znaleźć strukturę gramatyczną. Analizator składniowy pobiera symbole w miarę potrze by, wywołując funkcję analizatora leksykalnego zwracającą następny symbol leksykalny. Po znalezieniu struktury gramatycznej fragmentu kodu źródłowego, analizator składnio wy wywołuje dla niego generator kodu pośredniego, który z kolei po wywołaniu analizy semantycznej generuje fragment kodu pośredniego. Kompilator, który jest zorganizowany w ten sposób, omówiono w rozdz. 2.
Zmniejszanie liczby przebiegów Wskazane jest, aby program miał relatywnie niewielką liczbę przebiegów, ponieważ zapis i odczyt plików pośrednich pochłania dużo czasu. Aczkolwiek, jeśli zgrupujemy kilka faz w jeden przebieg, to możemy zostać zmuszeni do trzymania całego programu w pamięci, ponieważ następna faza może potrzebować informacji w innej kolejności niż wygenero wała ją faza poprzednia. Wewnętrzna postać programu może mieć dużo większą objętość niż program źródłowy czy wynikowy, więc ilość dostępnej pamięci może mieć duże zna czenie. W niektórych fazach zgrupowanie ich w pojedynczy przebieg może stwarzać pewne problemy. Na przykład, jak wcześniej wspomniano, interfejs między analizatorem leksy kalnym a składniowym może być ograniczony do przekazywania pojedynczego symbolu leksykalnego. Często trudno jest jednakże przeprowadzić generację kodu wynikowego przed całkowitym utworzeniem reprezentacji pośredniej. Języki, jak PL/I i Algol 68, po zwalają na użycie zmiennych przed ich zadeklarowaniem, a nie można wygenerować kodu wynikowego zanim nie zostaną poznane typy zmiennych użytych w kodzie źródłowym. Podobnie, wiele języków pozwala na użycie rozkazu g o t o , który wykonuje skok d o przodu w kodzie. Zanim nie zostanie wygenerowany kod, do którego jest wykonywany skok, nie można ustalić jego adresu. W niektórych przypadkach można pozostawić wolne miejsce dla brakującej informa cji i wypełnić j e dopiero wtedy, gdy potrzebna informacja będzie dostępna. W szczegól ności, generacja kodu pośredniego i wynikowego może zostać połączona w jeden przebieg za pomocą techniki zwanej poprawieniem (ang. backpatching), czyli „łataniem w tył ko du". Nie przedstawimy jej tutaj dokładnie, ponieważ aspekty generacji kodu pośredniego są omówione dopiero w rozdz. 8. Na razie opiszemy backpatching na przykładzie asem blera. Omówiliśmy już dwuprzebiegowy asembler, który w czasie pierwszego przebiegu ustalał wszystkie identyfikatory reprezentujące lokacje pamięci oraz ich adresy. Następnie w drugim przebiegu podstawiał za identyfikatory właściwe adresy. Działanie tych przebiegów może zostać połączone w następujący sposób. Po znale zieniu w kodzie asemblera instrukcji będącej odwołaniem do przodu kodu, np. GOTO
do_przodu
generujemy jedynie szkielet rozkazu składający się z operacji w kodzie maszynowym dla GOTO i z pustego miejsca na adres. Wszystkie rozkazy z miejscami pustymi dla adresu d o _ p r z o d u są trzymane na liście związanej z pozycją d o _ p r z o d u w tablicy symboli. Puste miejsca są wypełniane w momencie napotkania takiego rozkazu, jak do_przodu:
MOV g d z i e k o l w i e k ,
Rl
Identyfikator d o _ p r z o d u zaczyna wskazywać adres tego rozkazu. Teraz możemy już „załatać" poprzedni kod, przechodząc po liście tego identyfikatora. Elementy listy za wierają informację o wszystkich rozkazach wymagających tego adresu, wystarczy więc podstawić właściwy adres w ich puste miejsca. To podejście jest łatwe do implemen tacji pod warunkiem, że możliwe jest trzymanie w pamięci wszystkich rozkazów aż do momentu wyznaczania adresów wszystkich identyfikatorów. To podejście jest rozsądne dla asemblera, który może trzymać w pamięci cały wy nik swojego działania. Ponieważ w przypadku asemblera reprezentacja pośrednia i kod
1.6
NARZĘDZIA DO BUDOWY KOMPILATORÓW
21
wynikowy są prawie tym samym i mają podobną długość, łatanie w tył kodu na całej jego długości jest możliwe. Jednak w przypadku, gdy kod pośredni wymaga dużej ilości pamięci, trzeba uważać na odległość, na jaką wykonujemy łatanie.
1.6
Narzędzia do budowy kompilatorów
Autor kompilatora, jak każdy programista, może ułatwić sobie tworzenie programu, uży wając narzędzi programistycznych, jak programy uruchomieniowe (ang. debuggers), pro gramy do zarządzania wersjami, programy profilujące i inne. Z rozdziału 11 przekonamy się, jak niektóre z tych narzędzi mogą być pomocne przy pisaniu kompilatorów. Oprócz tych typowych narzędzi programistycznych istnieją narzędzia specjalistyczne, ułatwiające implementację poszczególnych faz kompilatorów. Omówimy je krótko poniżej, a dokład ny opis przedstawiliśmy w dalszych rozdziałach. Niedługo po tym, jak powstały pierwsze kompilatory, zaczęły pojawiać się systemy ułatwiające proces ich pisania. Systemy te były nazywane kompilatorami kompilatorów, generatorami kompilatorów lub systemami tworzenia translatorów. W większości były one zorientowane na konkretny model języka i najbardziej nadawały się do generowania kompilatorów dla języków podobnych do tego modelu. Zakładano, na przykład, że analizatory leksykalne dla wszystkich języków są z grub sza takie same, oprócz tego, że rozpoznają różne słowa kluczowe i symbole. W rze czywistości, wiele kompilatorów generuje stałe procedury dla analizatora leksykalnego. Procedury te różnią się jedynie listą rozpoznawanych słów kluczowych i lista ta jest je dyną rzeczą, jaka musi być podana generatorowi. To podejście jest poprawne, ale tylko wtedy, gdy nie m a potrzeby rozpoznawania niestandardowych symboli leksykalnych, j a k np. identyfikatorów zawierających również inne znaki niż litery i cyfry. Do tworzenia konkretnych składników kompilatorów stworzono też ogólne narzę dzia, które używają wyspecjalizowanych języków do specyfikacji i implementacji skład nika, a algorytmy, na których się opierają, są dość złożone. Najlepsze narzędzia ukry wają detale wewnętrznego algorytmu i produkują komponenty, które łatwo jest połączyć z resztą kompilatora. Poniżej znajduje się lista użytecznych narzędzi do konstrukcji kom pilatora. 1.
2.
Generatory analizatorów składniowych. Produkują one analizatory składniowe, zwy kle na podstawie pliku wejściowego z opisem gramatyki bezkontekstowej. We wcze snych kompilatorach analiza składniowa zabierała nie tylko dużą część czasu dzia łania kompilatora, ale również wymagała dużego wysiłku intelektualnego podczas jego pisania. Obecnie tę fazę uważa się za jedną z łatwiejszych w implementa cji. Wiele „małych języków" użytych przy pisaniu tej książki, jak PIC (Kernighan [1982]) i EQN, zaimplementowano w ciągu kilku dni przy użyciu generatora parserów opisanego w p. 4.7. Wiele generatorów parserów używa potężnych algorytmów parsowania, które są zbyt złożone do ręcznej implementacji. Generatory analizatorów leksykalnych. Generują one automatycznie analizatory lek sykalne, zwykle na podstawie specyfikacji zawierającej wyrażenia regularne (omó wione w rozdz. 3). Działanie wynikowego analizatora leksykalnego jest w efekcie
oparte na automacie skończonym. Typowy generator skanerów i jego implementacja 3.
4.
5.
są omówione w p . 3.5 i 3.8. Systemy translacji sterowanej składnią. Produkują one zestawy procedur, które prze chodząc przez drzewo składniowe, takie jak na rys. 1.4, generują kod pośredni. Podstawowa zasada nakazuje, by każdemu węzłowi w drzewie składniowym by ła przypisana j e d n a lub więcej translacji. Pojedyncza translacja jest zdefiniowana w zależności od translacji w sąsiednich węzłach drzewa. Takie systemy są omówio ne w rozdz. 5. Automatyczne generatory kodu. Program tego typu pobiera zestaw zasad definiu jących translację kodu pośredniego na język maszynowy maszyny docelowej. Te zasady muszą być na tyle dokładne, aby mogły obsłużyć różne metody dostępu do danych, ponieważ zmienne mogą być przechowywane w rejestrach, pod stałym adre sem lub na stosie. Podstawową techniką jest dopasowywanie szablonów. Instrukcje kodu pośredniego są wymieniane na szablony reprezentujące sekwencje rozkazów maszynowych w ten sposób, że założenia dotyczące przechowywanych zmiennych pasują pomiędzy kolejnymi szablonami. Ponieważ zmienne mogą być przechowy wane w różny sposób (np. w jednym z kilku rejestrów lub w pamięci), istnieje wiele możliwości dopasowania szablonów d o kodu pośredniego. Potrzebny jest wybór odpowiednio dobrego dopasowania, ale bez zbytniego zwiększania czasu działania kompilatora. Narzędzia tego typu są omówione w rozdz. 9. Systemy przepływu danych (ang. data-flow engines). Do przeprowadzenia dobrej optymalizacji kodu potrzeba dużo informacji, między innymi z analizy przepływu danych, która polega na zbieraniu informacji o transmitowaniu wartości zmiennych między wszystkimi częściami programu. Różne zadania tego typu mogą być w za sadzie wykonywane przez tę samą procedurę, która wczytuje podane przez użyt kownika szczegóły związków między instrukcjami kodu pośredniego i zbieranymi informacjami. Narzędzie tego typu jest omówione w p . 10.11.
UWAGI B I B L I O G R A F I C Z N E Knuth [1972], opisując w 1962 roku historię pisania kompilatorów, zauważył, że „W tej tematyce bardzo wiele odkryć tych samych technik dokonali jednocześnie ludzie pracu jący niezależnie". Dalej przedstawił obserwację, że kilka osób w rzeczywistości odkryło „różne aspekty tej samej techniki i po kilku latach szlifowania powstał bardzo ładny algo rytm, którego istnienia żaden z początkowych autorów nie był świadomy". Przypisywanie sobie zasług przy tworzeniu tych technik jest więc zajęciem ryzykownym. Bibliografia w tej książce może stanowić więc jedynie pomoc przy dalszym studiowaniu literatury. Uwagi historyczne na temat rozwoju języków programowania i kompilatorów, aż do powstania Fortranu, można znaleźć w pracy Knutha i Trabb Pardo [1977]. Praca Wexelblata [1981] zawiera wspomnienia osób uczestniczących w rozwoju kilku języków programowania. Niektóre podstawowe wczesne prace na temat kompilacji zostały zebrane przez Rosena [1967] i Pollacka [1972]. W styczniu 1961 roku w czasopiśmie Communications of the ACM podsumowano aktualny wtedy stan wiedzy na temat pisania kompilatorów. Randell i Russell [1964] przedstawili dokładne sprawozdanie na temat wczesnego kom pilatora Algola 60.
UWAGI BIBLIOGRAFICZNE
23
Od początku lat 60. teoretyczne badania na temat składni miały znaczący wpływ na rozwój technologii kompilatorów, być może co najmniej tak duży, jak na wszystkie inne obszary informatyki. O ile badania nad składnią trwały długo i zostały już zakoń czone, o tyle kompilacja jako całość wciąż jest ich tematem. Zalety tych badań staną się bardziej oczywiste, kiedy w następnych rozdziałach przyjrzymy się szczegółom procesu kompilacji.
Prosty kompilator jednoprzebiegowy
Rozdział ten stanowi wstęp do rozdziałów 3 - 8 ; przedstawiliśmy w nim kilka najbar dziej podstawowych technik kompilacji. Omówiliśmy przykładowy program w języku C, tłumaczący wyrażenia z notacji infiksowej (wrostkowej) na notację postfiksową (przyrost kową, odwrotną notację polską). Nacisk położyliśmy przede wszystkim na przód kompi latora, czyli na analizę leksykalną, składniową i generację kodu pośredniego. Generacja kodu i optymalizacja są omówione w rozdz. 9 i 10.
2.1
Przegląd
Język programowania można zdefiniować, opisując wygląd programów w nim napisanych (składnia języka) oraz ich znaczenie (semantyka języka). Do określania składni języka najczęściej używa się gramatyk bezkontekstowych lub notacji Backusa-Naura. O ile łatwo jest opisać składnię, to semantyka języka jest znacznie'trudniejsza do wyrażenia. Często więc semantykę będziemy opisywać nieformalnie i ilustrować przykładami. Oprócz określenia składni, gramatyki bezkontekstowe mogą pomóc w przeprowa dzeniu translacji programów. Jedna z technik kompilacji zajmująca się gramatyką, zwana translacją sterowaną składnią, przydaje się podczas tworzenia przodu kompilatora i jest dokładnie omówiona w tym rozdziale. Omawiając translację sterowaną składnią, skonstruujemy kompilator tłumaczący wy rażenia w notacji infiksowej na postfiksową, w której operatory występują p o swoich argumentach. Na przykład, wyrażenie 9 - 5 + 2 w notacji postfiksowej ma postać 9 5 - 2 + . Notacja ta może być bezpośrednio przekształcona na kod maszynowy, który wszystkie operacje przeprowadza przy użyciu stosu. Na początku skonstruujemy prosty program tłumaczący wyrażenia składające się z cyfr rozdzielonych znakami plus i minus na no tację postfiksową. Jak podstawowe zasady staną się j u ż jasne, rozbudujemy program o obsługiwanie bardziej ogólnych konstrukcji. Każdy z tworzonych translatorów będzie otrzymywany w wyniku rozbudowy poprzedniego. W naszym kompilatorze analizator leksykalny przekształca strumień znaków wej ściowych na strumień symboli leksykalnych, które stają się danymi wejściowymi ko-
lejnej fazy, jak na rys. 2.1. Przedstawiony na rysunku „translator sterowany składnią" (ang. syntcuc-directed translator) jest połączeniem analizatora składaniowego oraz gene ratora kodu pośredniego. Jednym z powodów analizowania wyrażeń składających się tylko z cyfr i operatorów była chęć uproszczenia analizy leksykalnej, tak aby każdy sym bol leksykalny był tworzony z pojedynczego znaku. Następnie rozszerzymy język o inne konstrukcje leksykalne, jak liczby, identyfikatory i słowa kluczowe. Dla tak roz szerzonego języka skonstruujemy analizator leksykalny, który składa symbole leksykalne z kilku kolejnych znaków. Tworzenie analizatorów leksykalnych jest dokładnie omówione w rozdz. 3.
Strumień znaków
Analizator leksykalny
Strumień Translator symboli —»-| sterowany leksykalnych składnią
Reprezentacja pośrednia
Rys. 2.1. Struktura przodu tworzonego kompilatora
2.2
Definicja składni
W tym podrozdziale przedstawiliśmy notację specyfikującą składnię języka, zwaną gra matyką bezkontekstową (w skrócie gramatyką). Będziemy jej używać w całej książce, jako części specyfikacji przodu kompilatorów. Hierarchiczne struktury wielu konstrukcji w językach programowania w naturalny sposób są opisywane przez gramatyki. Rozważmy, na przykład, instrukcję warunkową w C o postaci if ( wyrażenie ) instrukcja else instrukcja Instrukcja ta jest połączeniem słowa kluczowego if, nawiasu otwierającego, wyrażenia, nawiasu zamykającego, instrukcji, słowa kluczowego else i jeszcze jednej instrukcji (w C nie ma słowa kluczowego then). Oznaczając wyrażenie symbolem wyr, a instrukcję instr, zasadę tworzenia tej struktury można wyrazić jako instr —> if ( wyr ) instr else instr
(2.1)
Powyższe wyrażenie można czytać jako „może mieć postać". Zasadę taką nazywamy produkcją. W produkcjach elementy leksykalne, jak słowo kluczowe if lub nawiasy, są zwane symbolami leksykalnymi. Symbole wyr i instr reprezentują ciąg symboli leksykal nych i są zwane symbolami nieterminalnymi. Gramatyki bezkontekstowe składają się z czterech elementów: 1. 2. 3.
4.
Zbioru symboli leksykalnych zwanych symbolami terminalnymi. Zbioru symboli nieterminalnych. Zbioru produkcji, z których każda składa się z symbolu nieterminalnego, zwanego lewą stroną produkcji, strzałki oraz sekwencji symboli leksykalnych i nieterminali, zwanych prawą stroną produkcji. Jednego wyznaczonego symbolu nieterminalnego zwanego symbolem startowym.
Przyjęliśmy w tej książce, że specyfikacje gramatyk zawierają listę kolejnych produk cji, z których pierwsza dotyczy symbolu startowego. Zakładamy, że cyfry, symbole, jak cyfra
(2.4)
cyfra - > 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
(2.5)
Trzy produkcje nieterminala lista mogą zostać zgrupowane i zapisane równoznacznie w postaci lista —> lista + cyfra \ lista - cyfra | cyfra Zgodnie z ustaloną konwencją, symbolami leksykalnymi są symbole +
- 0 1 2 3 4 5 6 7 8 9
Symbole nieterminalne podane kursywą to lista i cyfra. Symbolem startowym jest lista, ponieważ produkcja, w której jest ona lewą stroną, została podana jako pierwsza.
•
Mówi się, że produkcja jest dla symbolu nieterminalnego, jeśli ten symbol znajduje się po lewej stronie produkcji. Ciąg symboli leksykalnych jest ciągiem zera lub wielu symboli. Jeśli zawiera zero symboli, jest oznaczany e i nazywany symbolem pustym. Gramatyka wyprowadza ciągi znaków, rozpoczynając od symbolu startowego i po wtarzając zamianę nieterminali na prawe strony produkcji dla nich. Wszystkie ciągi sym boli leksykalnych, które mogą zostać wyprowadzone z symbolu startowego, tworzą język definiowany przez gramatykę. Przykład 2.2. Język zdefiniowany przez gramatykę z przykładu 2.1 składa się z list cyfr porozdzielanych znakami plus i minus. Dziesięć produkcji dla nieterminala cyfra oznacza, że może on oznaczać jeden z symboli 0 , 1 , . . . , 9. Z produkcji (2.4) wynika, że pojedyncza cyfra jest listą, natomiast z produkcji (2.2) i (2.3) — że dowolna lista oraz występujące po niej symbole plus albo minus, a dalej pojedyncza cyfra również stanowią listę.
1
W rozdziale 4, w którym gramatyki są omówione dokładnie, pojedyncze litery zapisane kursywą mają nieco inne znaczenie. Litery, jak X, Y lub Z, oznaczają nie tylko nieterminale, ale także symbole leksykalne. Oczywiście, nazwy dwu- lub więcej literowe pisane kursywą wciąż oznaczają tylko symbole nieterminalne.
Z produkcji (2.2)-(2.5) można zdefiniować język, który teraz omawiamy. Na przy kład, do wniosku, że 9 - 5 + 2 jest listą, można dojść w następujący sposób: a) b)
9 jest listą na podstawie produkcji (2.4), ponieważ 9 jest cyfrą, 9 - 5 jest listą na podstawie produkcji (2.3), ponieważ 9 jest listą, a 5 jest cyfrą,
c)
9 - 5 + 2 jest listą na podstawie produkcji (2.2), ponieważ 9 - 5 jest listą, a 2 jest cyfrą.
Rozumowanie to jest przedstawione na rys. 2.2. Każdy węzeł drzewa jest oznaczony symbolem gramatyki. Węzeł wewnętrzny i jego dzieci oznaczają produkcje — węzeł wewnętrzny odpowiada lewej stronie produkcji, a dzieci — prawej stronie. Drzewa takie nazywają się drzewami wyprowadzeń i są omówione poniżej. • lista cyfra
lista cyfra
lista cyfra
Rys. 2.2. Drzewo wyprowadzenia dla 9-5+2 według gramatyki z przykładu 2.1 Przykład 2.3. Innym rodzajem listy jest sekwencja instrukcji pooddzielanych średnika mi wewnątrz bloków begin-end w Pascalu. Jedną z różnic jest to, że między symbolami leksykalnymi begin i end może znajdować się pusta lista. Możemy rozpocząć budowę gramatyki dla bloków begin-end od następujących produkcji: blok -> begin opcj- instr end opcj- instr —> lista-instr lista-instr
-+ lista-instr
\e ; instr \ instr
Drugą produkcją dla opcj-instr („opcjonalna lista instrukcji") jest e, oznaczający pusty ciąg symboli; opcj-instr może zostać nim zastąpiona i wtedy blok będzie się składał z dwuelementowego ciągu symboli leksykalnych begin end. Zauważmy również, że pro dukcje dla lista-instr są analogiczne do tych dla listy z przykładu 2 . 1 , z tą różnicą, że średnik zastąpił znaki plus i minus, a instr symbol cyfra. W powyższych produkcjach nie zawarliśmy produkcji dla samej instr, ponieważ niedługo będziemy omawiać właściwe produkcje dla różnych typów instrukcji, jak instrukcje warunkowe, przypisania. •
Drzewa wyprowadzeń Drzewo wyprowadzenia obrazuje, jak z symbolu startowego można wyprowadzić napis w danym języku. Jeśli dla nieterminala A istnieje produkcja A to w drzewie wy prowadzenia może znajdować się węzeł wewnętrzny oznaczony A, mający trójkę węzłów dzieci oznaczonych od lewej do prawej, odpowiednio, X, Y i Z:
A X
Y
Z
Formalnie, dla danej gramatyki bezkontekstowej, drzewo wyprowadzenia wem o następujących własnościach: 1. 2. 3. 4.
jest drze
Korzeń jest oznaczony symbolem startowym. Każdy liść jest oznaczony symbolem leksykalnym lub e. Każdy węzeł wewnętrzny jest oznaczony nieterminalem. Jeśli A jest oznaczeniem jakiegoś węzła wewnętrznego, a X , X , ..., X są ozna czeniami kolejnych jego dzieci, to A ~> X X •••X jest produkcją. X , X , ..., X mogą być tutaj zarówno terminalami, jak i nieterminalami. Szcze gólnym przypadkiem jest A —> e, wtedy węzeł A ma jedno dziecko oznaczone sym bolem e. x
}
{
0
2
2
n
n
n
Przykład 2.4. Korzeń na rysunku 2.2 jest oznaczony symbolem lista czyli symbo lem startowym gramatyki z przykładu 2 . 1 . Dzieci tego węzła są oznaczone symbolami, kolejno, lista, + i cyfra. Zauważmy, że t
lista —> lista + cyfra jest produkcją gramatyki z przykładu 2.1. Ten sam wzór jest powtórzony dla - w le wym dziecku korzenia, a trzy węzły opisane cyfra mają po jednym dziecku oznaczonym cyfrą. • Liście drzewa wyprowadzenia czytane od lewej do prawej tworzą wartość drzewa, która jest ciągiem wygenerowanym lub wyprowadzonym z nieterminala w korzeniu drze wa. Na rysunku 2.2 wyprowadzonym ciągiem jest 9 - 5 + 2. Wszystkie liście na tym rysunku zostały narysowane na najniższym poziomie. Odtąd nie będziemy już umiesz czać liści w ten sposób. W każdym drzewie naturalny porządek od lewej do prawej jest przekazywany od korzenia do liści, to znaczy, jeśli a i b są dziećmi tego samego węzła i a znajduje się na lewo od b, to wszyscy potomkowie a znajdą się na lewo od potomków b. Inna definicja języka generowanego przez gramatykę określa go jako zbiór ciągów znaków, które mogą być wygenerowane przez jakieś wyprowadzenie. Proces znajdywa nia drzewa wyprowadzenia dla danego ciągu symboli leksykalnych nazywa się analizą składniową tego ciągu. Niejednoznaczność Należy być ostrożnym, mówiąc o konkretnym wyprowadzeniu ciągu znaków według gra matyki. Oczywiste jest, że każde drzewo wyprowadzenia określa dokładnie jeden ciąg, ale dany ciąg symboli leksykalnych może mieć kilka drzew wyprowadzeń na podstawie da nej gramatyki. Gramatyka taka jest zwana niejednoznaczną. Aby pokazać, że gramatyka jest niejednoznaczna, wystarczy wskazać ciąg znaków mający więcej niż jedno drzewo wyprowadzenia. Ponieważ ciąg mający kilka wyprowadzeń może mieć również kilka znaczeń, do celów kompilacji należy uzupełnić ją o dodatkowe zasady rozstrzygające niejednoznaczność albo użyć gramatyki jednoznacznej.
Przykład 2.5. Załóżmy, że w przykładzie 2.1 nie rozróżniano cyfr i list. Gramatyka miałaby wtedy postać ciąg -» ciąg + ciąg j ciąg - ciąg
| 0 | l | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Połączenie notacji cyfry i listy w nieterminal ciąg dodaje nowe znaczenie, ponieważ pojedyncza cyfra jest specjalnym przypadkiem listy. Z rysunku 2.3 wynika jednak, że wyrażenie 9 - 5 + 2 ma teraz więcej niż jedno drzewo wyprowadzenia. Te dwa drzewa odpowiadają dwóm sposobom wstawienia na wiasów: ( 9 - 5 ) + 2 i 9 - (5 + 2 ) . Drugie wyrażenie daje oczywiście wartość 2, inną niż normalna wartość wyrażenia, czyli 6. Gramatyka z przykładu 2.1 nie pozwalała na taką interpretację. •
ciąg ciąg
+
9
ciąg ciąg
5
ciąg
-
5
ciąg
2
Rys. 2.3. Dwa drzewa wyprowadzeń dla 9-5+2
Łączność operatorów Zgodnie z konwencją, 9 + 5 + 2 oznacza (9 + 5 ) + 2 , a 9 - 5 - 2 oznacza ( 9 - 5 ) - 2 . Gdy po obu stronach pewnego argumentu, jak np. 5, występują operatory, trzeba ustalić konwencję, którego operatora dotyczy ten argument. Mówimy, że operator + jest łą czny lewostronnie, ponieważ argument, po którego obu stronach występuje znak +, jest używany do wyliczenia lewego operatora. W większości języków programowania cztery operatory arytmetyczne: dodawanie, odejmowanie, mnożenie i dzielenie są łączne lewostronnie. Niektóre zwykłe operatory, jak potęgowanie, są łączne prawostronnie. Innym przy kładem jest operator przypisania w C — symbol =. W języku C wyrażenie a = b = c jest traktowane tak samo, jak a = ( b = c ) . Łańcuchy znaków, jak a = b = c , są generowane z operatorami prawostronnie łącznymi przez następującą gramatykę: prawa —> litera = prawa \ litera litera —> a | b | • • • | z Różnica między drzewami wyprowadzeń dla operatora lewostronnie (np. - ) i pra wostronnie łącznego (np. =) jest przedstawiona na rys. 2.4. Zauważmy, że drzewo wy prowadzenia dla 9 - 5 - 2 rośnie w dół po lewej stronie, natomiast dla a = b = c — po prawej.
lista
prawa
/ l \ lista
cyfra
I
I
cj^a
2
a
/l\ /wte
-
I
I
/W
-
c>/ra
litera
-
/ I \ /itera
I 5
prawa
=
prawa
I b
litera
I
I
9
c
PriorytetRys. operatorów 2.4. Drzewa wyprowadzeń dla operatorów lewo- i prawostronnie łącznych Rozważmy wyrażenie 9 + 5 * 2 . Są dwie możliwe interpretacje tego wyrażenia: ( 9 + 5 ) * 2 lub 9+ ( 5 * 2 ) . Sama łączność operatorów nie rozstrzyga niejednoznaczności. Z tego po wodu, używając więcej niż jednego operatora w wyrażeniach, musimy znać ich względny priorytet. Mówimy, że * ma wyższy priorytet niż +, jeśli operację * należy wykonać przed operacją +. W zwykłej arytmetyce, mnożenie i dzielenie mają wyższy priorytet niż doda wanie i odejmowanie. Zatem, argumentem operatora * w wyrażeniach 9 + 5*2 i 9 * 5 + 2 w obu przypadkach jest 5. Innymi słowy, wyrażenia te oznaczają odpowiednio 9+ ( 5 * 2 ) i (9*5)+2. Składnia wyrażeń. Gramatyka dla wyrażeń arytmetycznych może zostać skonstru owana na podstawie tablicy zawierającej łączność i priorytet operatorów. Zaczniemy od czterech podstawowych operatorów arytmetycznych i tablicy ich priorytetów, zawierają cej operatory w kolejności rosnących priorytetów (operatory o tym samym priorytecie znajdują się w tym samym wierszu): lewostronnie łączne: lewostronnie łączne:
+ * /
Dla dwóch poziomów priorytetów należy stworzyć dwa nieterminale wyr i skł oraz dodatkowy nieterminal czyn do generacji podstawowych jednostek wyrażeń. Podstawowe jednostki w wyrażeniach stanowią teraz cyfry i wyrażenia w nawiasach czyn -¥ cyfra | ( wyr ) Pod uwagę należy teraz wziąć operatory * i / , mające najwyższy priorytet. Ponieważ operatory te są łączne lewostronnie, produkcje te są podobne do produkcji dla list, które też są łączne lewostronnie skł —> skł * czyn | skł / czyn | czyn Podobnie, wyr generuje listy składników porozdzielane operatorami + i -
wyr —>• wyr + skł | wyr - s/tf | skł Zatem gramatyka wynikowa ma postać wyr —• wyr + skł \ wyr - skł \ skł skł —> skł * czyn \ skł I czyn \ czyn czyn ->• cyfra | ( wyr ) W gramatyce tej wyrażenia są traktowane jak listy składników porozdzielanych znakami + i - , a składniki jak listy czynników porozdzielanych * i / . Zwróćmy uwagę, że każde wyrażenie w nawiasach jest również czynnikiem, więc przy użyciu nawiasów można stworzyć wyrażenie o dowolnie głębokim zagnieżdżeniu (i również dowolnie głębokie drzewa wyprowadzeń). Składnia instrukcji. Słowa kluczowe umożliwiają rozpoznawanie instrukcji w więk szości języków. Wszystkie instrukcje Pascala, oprócz przypisania i wywołania procedury, zaczynają się od słowa kluczowego. Niektóre instrukcje Pascala są zdefiniowane przez następującą (niejednoznaczną) gramatykę, w której symbol id oznacza identyfikator: instr —• | | | |
id : ~ if wyr if wyr while begin
wyr then instr then instr else instr wyr do instr opcj- instr end
Nieterminal opcj-instr generuje listę (być może pustą) instrukcji pooddzielanych średni kami przy użyciu produkcji z przykładu 2.3.
2.3
Translacja sterowana składnią
W trakcie translacji konstrukcji programistycznych, oprócz generowanego kodu kompila tor musi śledzić różne wielkości. Na przykład, musi znać typ konstrukcji, adres pierwsze go rozkazu w kodzie wynikowym i liczbę wygenerowanych rozkazów. Możemy w takich przypadkach mówić o atrybutach dla tej konstrukcji. Atrybut może reprezentować do wolną wielkość, np. typ, ciąg znaków, adres pamięci albo cokolwiek innego. W tym podrozdziale przedstawimy formalizm, zwany definicją sterowaną składnią służącą do specyfikacji translacji dla konstrukcji programistycznych. Definicja sterowa na składnią specyfikuje translację konstrukcji w zależności od atrybutów przypisanych elementom składniowym. W następnych rozdziałach definicje sterowane składnią będą używane do specyfikacji dużej części translacji, które mają miejsce w przodzie kompi latora. Do specyfikacji translacji wprowadzimy również notację bardziej proceduralną, zwa ną schematem translacji. W tym rozdziale omówimy jej zastosowanie do translacji wy rażeń infiksowych na postfiksowe. Obszerniejsza dyskusja na temat definicji sterowanych składnią i ich implementacji jest zawarta w rozdz. 5.
Notacja postfiksową Notacja postfiksową sposób: 1. 2.
dła wyrażenia E może być zdefiniowana rekurencyjnie w następujący
Jeśli E jest zmienną lub stałą, to wyrażeniem w notacji postfiksowej jest po prostu E. Jeśli E jest wyrażeniem o postaci E op E , gdzie op jest operatorem binarnym, to w notacji postfiksowej będzie miało postać E\ E op, gdzie E' i E! są wyrażeniami E i E zapisanymi w notacji postfiksowej. Jeśli E ma postać ( E ), to notacja postfiksową dla E jest notacją postfiksową dla E . x
2
f
2
x
3.
x
2
2
x
x
Notacja postfiksową nie potrzebuje nawiasów, ponieważ dla ustalonego położenia i liczby argumentów poszczególnych operatorów istnieje tylko jeden sposób policzenia wyrażenia. Na przykład, wyrażenie ( 9 - 5 ) +2 w notacji postfiksowej ma postać 9 5 - 2 + , a 9 - ( 5 + 2) ma postać 9 5 2 + - .
Definicje sterowane składnią Definicja sterowana składnią opiera się na gramatyce bezkontekstowej, która specyfikuje strukturę składniową wejścia. Każdemu symbolowi gramatyki jest przyporządkowany zbiór atrybutów, a każdej produkcji zbiór reguł semantycznych służących do obliczenia wartości atrybutów dla symboli pojawiających się w produkcjach. Gramatyka razem ze zbiorem reguł semantycznych składają się na definicję sterowaną składnią. Translacja polega na przekształceniu danych wejściowych w wyjściowe. Dla każ dego wejścia x, wyjście jest określone w następujący sposób. Najpierw należy stworzyć drzewo wyprowadzenia dla x. Załóżmy, że węzeł n w drzewie składniowym jest oznaczo ny symbolem gramatyki X. Pisząc X.a, odwołujemy się do atrybutu a dla X w tym węźle. Wartość X.a w węźle n jest obliczana przy użyciu reguły semantycznej dla tego atrybutu, związanej z produkcją symbolu X w węźle n. Drzewo wyprowadzenia pokazujące war tości atrybutów w każdym węźle jest nazywane drzewem wyprowadzenia z przypisami (ang. annotated parse tree).
Atrybuty syntezowane Atrybut nazywa się syntezowanym, jeśli jego wartość jest określana na podstawie warto ści atrybutów w dzieciach danego węzła. Atrybuty syntezowane można łatwo policzyć, przechodząc drzewo od liści do korzenia. W tym rozdziale omówiono jedynie atrybuty syntezowane, inny rodzaj atrybutów, atrybuty „dziedziczone" są omówione w rozdz. 5.
Przykład 2.6. Definicja sterowana składnią dla translacji wyrażeń składających się z cyfr oraz znaków plus i minus w notacji postfiksowej jest przedstawiona na rys. 2.5. Z każdym symbolem nieterminalnym jest związany atrybut zawierający ciąg znaków, reprezentujący w notacji postfiksowej wyrażenie generowane w drzewie wyprowadzenia przez ten nieterminal. Pojedyncza cyfra zapisana w notacji postfiksowej jest tą samą cyfrą, na przykład reguła semantyczna związana z produkcją skł ->• 9 definiuje, że skł.t w węźle drzewa
R E G U Ł A SEMANTYCZNA
PRODUKCJA
wyr wyr wyr skł skł
—> —> -+ ->
skł
wyr wyr s££ 0 1
x
x
+ skł - skł
9
wyr.t wyr.t wyr.t skł.t skł.t
:= := := := :=
wyr t wyr t skł.t ' 0' ' 1 '
skł.t
:=
' 9'
v
v
\\ skł.t || ' + ' \\ skł.t \\ ' -'
Rys. 2.5. Definicja sterowana składnią translacji notacji infiksowej na postfiksową wyprowadzenia staje się 9, jeśli ta podukcja jest używana dla tego węzła. Jeżeli jest stosowana produkcja wyr —> skł, wartością wyr.t staje się wartość skł.t. Wyrażenia zawierające znak plus są tworzone na postawie produkcji wyr ->• wyr + skł (indeks przy wyr służy do odróżnienia wyrażenia z lewej strony produkcji od prawej). Lewy argument operatora plus jest otrzymywany z wyr , a prawy argument ze skł. Reguła semantyczna x
x
x
wyr.t \= wyr t v
\\ skł.t \\ ' +'
związana z tą produkcją definiuje wartość atrybutu wyr.t jako złączenie ciągów znaków zawierających notację postfiksową, czyli wyr .t i skł.t oraz symbolu plus. Operator |] w regułach semantycznych oznacza łączenie ciągów. Na rysunku 2.6 przedstawiono drzewo wyprowadzenia z przypisami odpowiadające drzewu z rys. 2.2. Wartości atrybutu t we wszystkich węzłach policzono przy użyciu reguł semantycznych związanych z produkcjami w tych węzłach. Wartość atrybutu w korzeniu jest notacją postfiksową ciągu znaków generowanego przez drzewo wyprowadzenia. • x
wyr.t = 9 5 - 2 + skł.t =2
wyr.t = 9 5 skł.t =5
wyr.t =9 skł.t =9 9
-
5
+
2
Rys. 2.6. Wartości atrybutów w węzłach drzewa wyprowadzenia Przykład 2.7. Załóżmy, że pewien robot może otrzymywać polecenia ruchu po jednym kroku z jego aktualnej pozycji na wschód, północ, zachód i południe. Sekwencja tych rozkazów jest generowana na podstawie następującej gramatyki: sekw —> sekw rozkaz \ start rozkaz wschód | północ | zachód | południe Na rysunku 2.7 pokazano zmiany w pozycji robota po podaniu mu instrukcji start zachód południe wschód wschód wschód północ północ
(2,1) północ (-1,0). zachód
start (0,0) północ
południe ( - 1 - 1 ) wschód
wschód wschód
(2-1)
Rys. 2.7. Śledzenie pozycji robota
Pozycja, na tym rysunku, oznaczona jest (x,y) gdzie x i y są liczbami kro ków odpowiednio na wschód i północ od pozycji startowej. (Jeśli x jest ujemne, to robot znajduje się na zachód od pozycji startowej, jeśli natomiast ujemne jest y, to na po łudnie). Stworzymy teraz definicję sterowaną składnią translacji sekwencji rozkazów robota do jego położenia. Do przechowywania położenia wynikającego z sekwencji rozkazów generowanej przez nieterminal sekw użyjemy dwóch atrybutów, sekw.x i sekw.y. Począt kowo sekw generuje start, a sekw.x i sekw.y są razem ustawione na 0, jak widać na rys. 2.8 w skrajnie lewym węźle wewnętrznym drzewa wyprowadzenia dla start zachód południe. y
sekw.x = - 1 sekw.y = - 1 rozkaz. dx = 0 rozkaz, dy = -l
sekw.x = - 1 sekw.y - 0 sekw.x = 0 sekw.y — 0
rozkaz. dx = - 1 rozkaz, dy = 0
start
zachód
południe
Rys. 2.8. Drzewo wyprowadzenia z przypisami dla sekwencji start zachód południe
Zmiana pozycji wynikająca z pojedynczego rozkazu wyprowadzonego z rozkaz jest opisana atrybutami rozkaz.dx i rozkaz.dy. Na przykład, jeśli z rozkaz zostało wyprowa dzone zachód, to rozkaz.dx = — 1 i rozkaz.dy = 0. Załóżmy, że sekwencja sekw została utworzona z sekwencji sekw przez dodanie nowego rozkazu rozkaz. Nowa pozycja robota może zostać wyliczona na podstawie reguły {
sekw.x :~ sekw.y
sekw .x+rozkaz.dx sekw .y+rozkaz.dy {
{
Definicja sterowana składnią translacji sekwencji rozkazów do pozycji robota jest przed stawiona na rys. 2.9.
•
PRODUKCJA
sekw sekw
start —^ sekw
x
rozkaz
rozkaz ~> wschód rozkaz -» północ rozkaz ~^ zachód rozkaz -> południe
R E G U Ł A SEMANTYCZNA
sekw.x sekw.y sekw.x sekw.y rozkaz.dx rozkaz-dy rozkaz.dx rozkaz.dy rozkaz.dx rozkaz-dy rozkaz. dx rozkaz.dy
= = = = = = = = = = =
0 0 sekw x sekw .y 1 0 0 1 -1 0 0 v
{
+ -f
rozkaz.dx rozkaz.dy
Rys. 2.9. Definicja sterowana składnią dla pozycji robota
Przechodzenie drzewa w głąb Definicja sterowana składnią nie narzuca kolejności obliczania poszczególnych atrybu tów w drzewie wyprowadzenia. Dozwolone są wszystkie kolejności spełniające waru nek, że przed wyliczeniem konkretnego atrybutu zostaną wyliczone wszystkie atrybu ty, od których on zależy. W ogólnym przypadku możliwe jest wyliczenie jednej czę ści atrybutów w chwili, gdy węzeł jest napotykany po raz pierwszy, kolejnej części — pomiędzy odwiedzinami jego potomków, a ostatniej części — po odwiedzeniu wszystkich potomków. Metody ustalania odpowiednich kolejności obliczeń są omówione dokładnie w rozdz. 5. Wszystkie translacje z tego rozdziału można zaimplementować jako obliczanie w us talonej kolejności reguł semantycznych dla atrybutów w drzewie wyprowadzenia. Prze chodzenie drzewa zaczyna się w korzeniu i polega na odwiedzeniu w pewnej kolejności każdego węzła. W tym rozdziale reguły semantyczne są obliczane przy użyciu procedu ry przechodzenia drzewa w głąb, zdefiniowanej na rys. 2.10. Procedura ta rozpoczyna
procedurę odwiedzin: węzeł) ; begin for każdego dziecka m węzła n, od lewej do prawej do odwied£(m); oblicz reguły semantyczne dla węzła n end Rys. 2.10. Procedura przechodzenia drzewa w głąb
działanie w korzeniu i rekurencyjnie odwiedza kolejne dzieci każdego węzła w porządku od lewej do prawej (rys. 2.11). Reguły semantyczne dla konkretnego węzła są obliczane natychmiast po odwiedzeniu wszystkich jego potomków. Taka kolejność przechodzenia drzewa jest nazywana przechodzeniem w głąb — polega ona na jak najszybszym wcho dzeniu w głąb struktury drzewa.
Rys. 2.11. Przykład przechodzenia drzewa w głąb
Schematy translacji W pozostałej części rozdziału do definiowania translacji będziemy używać specyfikacji proceduralnej. Schematem translacji nazywamy gramatykę bezkontekstową, zawierają cą akcje semantyczne, czyli fragmenty programu włączone w prawe strony produkcji. Schemat translacji przypomina definicję sterowaną składnią, z tą różnicą, że kolejność obliczania reguł semantycznych jest z góry ustalona. Pozycja, w której akcja jest wy konywana, jest określona przez umieszczenie akcji w nawiasach klamrowych po prawej stronie produkcji, na przykład reszta —• + skł { print(' +') } reszta, Wygenerowanie wyniku schematu translacji dla wyprodukowanej przez gramaty kę sekwencji polega na wykonaniu wszystkich akcji w drzewie wyprowadzenia w ko lejności przechodzenia drzewa w głąb. Rozważmy drzewo wyprowadzenia zawierają ce węzeł oznaczony nieterminalem reszta i reprezentujący powyższą produkcję. Akcja { print( + ' ) } zostanie wykonana po powrocie z poddrzewa dla skł i przed wejściem do poddrzewa dla reszta . Na rysunku drzewa wyprowadzenia dla schematu translacji akcje są oznaczane do datkowymi węzłami potomnymi połączonymi przerywaną linią z węzłem związanym z odpowiednią produkcją. Rysunek 2.12 zawiera drzewo wyprowadzenia dla przykładowej produkcji. Węzeł dla akcji semantycznej nie ma potomków, więc akcja jest wykonywana po pierwszym napotkaniu tego węzła. r
{
reszta +
skł
{printC+')}
reszta^
Rys. 2.12. Tworzenie nowego liścia dla akcji semantycznej
Wynik translacji W tym podrozdziale omówiliśmy sposób, w jaki akcje semantyczne w schematach trans lacji zapisują wynik do pliku lub bufora znaków. Metodę pokażemy na przykładzie prze kształcania 9 - 5 + 2 w 9 5 - 2 + , w którym każdy znak z wyrażenia 9 - 5 + 2 jest drukowany dokładnie raz, bez używania dodatkowej pamięci do translacji podwyrażeń. Jeśli wynik translacji jest tworzony stopniowo w ten sposób, szczególną uwagę należy zwrócić na kolejność wypisywania znaków.
Zauważmy, że omówione j u ż definicje sterowane składnią miały następującą ważną własność: łańcuch reprezentujący translację nieterminala z lewej strony produkcji jest sklejeniem translacji nieterminali z prawej, w takiej samej kolejności, jak w produkcji. Napis ten ponadto może, ale nie musi, zawierać „wklejone" pewne dodatkowe elementy. Definicja sterowana składnią mająca powyższą własność jest nazywana prostą. Rozważmy pierwszą produkcję i regułę semantyczną z definicji sterowanej składnią z rys. 2.5 PRODUKCJA
wyr —>• wyr
R E G U Ł A SEMANTYCZNA
+ skł
x
wyr.t :— wyr .t
\\ skł.t \\ ' + '
x
W translacji wyr.t jest sklejeniem translacji wyr i skł oraz symbolu +. Zwróćmy uwagę, że wyr po prawej stronie produkcji występuje przed skł. Dodatkowy napis pojawia się między skł.t i reszta t w następującej regule seman tycznej: x
x
v
PRODUKCJA
R E G U Ł A SEMANTYCZNA
reszta -» + skł reszta
reszta.t :— skł.t || ' +' ||
x
reszta .t {
ale nadal nieterminal skł pojawia się przed reszta po prawej stronie. Proste definicje sterowane składnią mogą zostać zaimplementowane przy wykorzy staniu schematów translacji, zawierających akcje drukujące dodatkowe napisy w takiej kolejności, w jakiej pojawiają się w definicji. Akcje w następujących produkcjach drukują dodatkowe napisy (patrz (2.6) i (2.7)): x
wyr -» wyr + skł { print(' + ')} reszta —> + skł { print( + ') } reszta {
r
x
Przykład 2.8. Rysunek 2.5 zawiera prostą definicję translacji wyrażeń na notację post fiksową. Schemat translacji wyprowadzony z tej definicji jest przedstawiony na rys. 2.13, natomiast drzewo wyprowadzenia z akcjami dla 9 - 5 + 2 — na rys. 2.14. Zwróćmy uwa gę, że chociaż rysunki 2.6 i 2.14 dotyczą tego samego przekształcenia, translacja w obu przypadkach jest konstruowana w inny sposób. Wynik translacji z rys. 2.6 jest odczyty wany z korzenia drzewa wyprowadzenia, natomiast dla drzewa z rys. 2.14 — stopniowo w miarę jego przechodzenia.
wyr wyr wyr skł skł skł
—> wyr + skł -¥ wyr - skł —> skł 0 -> 1 9
r
{ print( +') } { print(' -') } { printC 0 ' ) } { print(' 1 ' ) } { print(' 9')
}
Rys. 2.13. Akcje translacji wyrażeń na notację postfiksową
Korzeń na rysunku 2.14 odpowiada pierwszej produkcji z rys. 2.13. W trakcie prze chodzenia drzewa w głąb najpierw są wykonywane wszystkie akcje w poddrzewie dla lewego argumentu wyr z najbardziej lewego poddrzewa korzenia. Potem następuje odwie-
wyr
wyr I skł / \ 9 {print('9')}
skł / 5
\ {print('5')}
Rys. 2.14. Akcje tłumaczące 9-5+2 na 9 5 - 2 + dzenie liścia +, w którym nie ma żadnej akcji. Następnie wykonuje się akcję dla prawego argumentu skł i, na koniec, akcję semantyczną { print(' + ' ) } z dodatkowego węzła. Ponieważ prawymi stronami produkcji dla skł są pojedyncze cyfry, akcje dla tych produkcji wypisują te cyfry. Dla produkcji wyr —>• skł nie trzeba nic wypisywać, natomiast dla dwóch pierwszych instrukcji należy wypisać operatory. Po wykonaniu akcji w trakcie przechodzenia drzewa w głąb (rys. 2.14) otrzymamy wynik 9 5 - 2 + . • Większość metod analizy składniowej jest zachłanna, to znaczy stara się skonstru ować jak największą część drzewa składniowego przed pobraniem kolejnego symbolu z wejścia czytanego od lewej do prawej. W prostym schemacie translacji (wyprowadzo nym z prostej definicji sterowanej składnią) akcje są wykonywane także w porządku od lewej do prawej. Dlatego, aby zaimplementować prosty schemat translacji, wystarczy wykonać akcje w kolejności analizy składniowej danych wejściowych i nie trzeba wcale konstruować drzewa wyprowadzenia.
2.4
Analiza składniowa
Wykonanie analizy składniowej pozwala ustalić, czy ciąg symboli leksykalnych może zostać wygenerowany przez gramatykę. Zastanawiając się nad tym problemem, dobrze jest pamiętać o tym, że konstruowane jest drzewo wyprowadzenia, chociaż sam kompilator w rzeczywistości może tego nie robić. Analizator składniowy musi jednak takie drzewo skonstruować, gdyż w przeciwnym przypadku nie można by zagwarantować poprawności samej translacji. W tym podrozdziale przedstawiamy metodę analizy składniowej, którą można zasto sować do skonstruowania translatorów sterowanych składnią. Pełny program w języku C, implementujący schemat translacji z rys. 2 . 1 , znajduje się w następnym podrozdziale. Do samodzielnego napisania programu wygodnie jest użyć jednego z narzędzi do generacji translatora bezpośrednio ze schematu translacji. W podrozdziale 4.9 znajduje się opis takiego narzędzia, które jest w stanie zaimplementować schemat translacji z rys. 2.13 bez żadnych modyfikacji. Dla każdej gramatyki można skonstruować analizator składniowy. Jednak grama tyki używane w praktyce mają specjalną postać. Dla każdej gramatyki bezkontekstowej
3
istnieje analizator składniowy działający w czasie rzędu 0 ( n ) dla ciągu wejściowego składającego się z n symboli leksykalnych, a dla języków programowania potrzebne są gramatyki, które mogą być szybko analizowane. Okazuje się, że do większości praktycz nych języków programowania wystarczają algorytmy o złożoności liniowej. Analizatory składniowe w językach programowania prawie zawsze wykonują pojedyncze wczytanie danych wejściowych od lewej do prawej, sprawdzając w przód co najwyżej jeden symbol leksykalny. Większość metod można zaliczyć do jednej z dwóch klas, zwanych metodami zstę pującymi (ang. top-down) i wstępującymi (ang. bottom~up). Określenia te oznaczają kolej ność, w jakiej są konstruowane węzły drzewa wyprowadzenia. W pierwszym przypadku zaczyna się od korzenia i dodaje się kolejne węzły w kierunku liści, w drugim - zaczyna się od liści i dochodzi się do korzenia. Popularność analizatorów zstępujących wynika z faktu, że wydajne analizatory tego typu mogą być konstruowane ręcznie przy uży ciu metod zstępujących. Analizatory wstępujące mogą być jednak stosowane do szerszej klasy gramatyk oraz schematów translacji i z tego powodu narzędzia programistyczne, które generują analizatory składniowe bezpośrednio z gramatyk, częściej używają metod wstępujących.
Zstępująca analiza składniowa Zstępującą analizę składniową przedstawiamy na przykładzie gramatyki, która jest dobrze dopasowana d o metod zstępujących. Rozważamy również ogólną konstrukcję zstępują cych analizatorów składniowych. Poniższa gramatyka generuje podzbiór typów Pascala. Symbol leksykalny dwiekropki, oznaczający „ . . " , jest używany do podkreślenia, że ta sekwencja znaków jest traktowana jako jedna jednostka typ —> prosty | Tid | array [ prosty ] of typ prosty integer | char | liczba dwiekropki liczba Zstępujące konstruowanie drzewa wyprowadzenia zaczyna się w korzeniu (oznaczo nym oczywiście symbolem startowym gramatyki) i polega na wielokrotnym wykonywa niu dwóch kroków (przykład przedstawiony na rys. 2.15): 1. 2.
Wybór w węźle oznaczonym symbolem nieterminalnym A jednej z produkcji dla A i konstrukcji dzieci tego węzła dla symboli z prawej strony wybranej produkcji. Przejście do następnego węzła, dla którego należy skonstruować poddrzewo.
Dla niektórych gramatyk powyższe kroki dają się zaimplementować tak, aby wykony wały się podczas pojedynczego przeglądania danych wejściowych od strony lewej do prawej. Aktualny symbol leksykalny, który został właśnie wczytany, nazywamy symbo lem bieżącym. Na samym początku symbolem bieżącym jest pierwszy symbol leksykalny z wejścia. Rysunek 2.16 ilustruje analizę napisu array [ liczba dwiekropki liczba ] of integer
typ
(a)
(b) typ
array
(c)
array
I
liczba
(d)
array
prosty
dwie kropki
I
prosty
1
of
typ
of
typ
liczba
I
I liczba
array
I
dwie kropki
prosty
liczba
I
(e) liczba
dwie kropki
liczba
prosty
of
typ
I prosty
I integer Rys. 2.15. Kroki budowy drzewa wyprowadzenia metodą zstępującą
Początkowo symbolem bieżącym jest array, natomiast drzewo wyprowadzenia składa się tylko z korzenia oznaczonego nieterminalem typ, jak na rys. 2.16(a). Naszym celem jest takie skonstruowanie reszty drzewa wyprowadzenia, aby napis generowany przez drzewo wyprowadzenia był taki sam j a k napis wejściowy. Aby napisy były identyczne, nieterminal typ z rys. 2.16(a) musi wyprowadzać napis zaczynający się od symbolu array. W gramatyce (2.8) znajduje się tylko jedna produkcja dla typ, z której można wyprowadzić taki napis, więc wybieramy ją i tworzymy dzieci dla korzenia oznaczone symbolami z prawej strony wybranej produkcji. W każdym z trzech kroków na rys. 2.16 strzałki zaznaczają symbol bieżący na wejściu oraz rozważany w danej chwili węzeł w drzewie wyprowadzenia. Po utworzeniu wszystkich dzieci drzewa pod uwagę jest brane pierwsze z lewej. Na rysunku 2.16(b) jest przedstawiony stan tuż po utworzeniu dzieci korzenia, a rozważanym węzłem jest węzeł oznaczony array. Gdy rozważany węzeł jest oznaczony symbolem terminalnym takim samym jak sym bol bieżący, to przesuwamy się do przodu zarówno w drzewie wyprowadzenia, jak i na wejściu. Symbolem bieżącym staje się teraz następny symbol z wejścia, a rozważanym węzłem drzewa staje się następny węzeł potomny. Na rysunku 2.16(c) strzałka w drzewie wyprowadzenia została przesunięta na następnego potomka korzenia, a strzałka wska-
DRZEWO WYPROWADZENIA
typ |
(a) array WEJŚCIE
f
liczba
dwiekropki
liczba
]
of
integer
I
DRZEWO
WYPROWADZENIA
typ
array l
(b) WEJŚCIE
array
[
liczba
dwiekropki
liczba
]
of
integer
t
DRZEWO
WYPROWADZENIA
typ
array
(c) WEJŚCIE
array
[
liczba
dwiekropki
liczba
]
of
integer
ł
Rys. 2.16. Analiza składniowa metodą zstępującą podczas przeglądania wejścia od lewej do prawej
żująca wejście — na następny leksem, czyli [. Po kolejnym kroku strzałka w drzewie wskaże potomka oznaczonego nieterminalem prosty. Gdy rozważany jest węzeł oznaczo ny nieterminalem, cały proces należy powtórzyć dla tego nieterminala. W ogólnym przypadku, znalezienie odpowiedniej produkcji dla nieterminala może wymagać zastosowania metody prób i błędów. To znaczy, możemy próbować zastosować jakąś produkcję i gdy to się nie uda, wrócić do miejsca wyboru i zacząć próbować inne produkcje. Jeśli, po zastosowaniu produkcji, nie udaje się dopasować drzewa do napisu wejściowego, to wybrana produkcja nie jest właściwa. Istnieje jednak specjalny przypadek, zwany przewidującą analizą składniową, w którym nie m a nawrotów i prób stosowania różnych produkcji.
Przewidująca analiza składniowa Metoda zejść rekurencyjnych jest zstępującą metodą analizy składniowej, w której — aby przetworzyć dane wejściowe, wykonuje się grupę rekurencyjnych procedur. Każda z tych procedur jest związana z poszczególnymi nieterminalami z gramatyki. Rozważmy teraz specjalny przypadek metody zejść rekurencyjnych, zwanej przewidującą analizą składniową, w której symbol bieżący jednoznacznie wyznacza procedurę dla każdego
nieterminala. Kolejność wywołań procedur wyznacza niejawnie drzewo wyprowadzenia dla wejścia. Przewidująca analiza składniowa z rysunku 2.17 składa się z dwóch procedur roz poznających nieterminale typ i prosty z gramatyki (2.8) oraz z dodatkowej procedury wczytaj (ang. match), która upraszcza kod dwóch pierwszych, przeprowadzając spraw dzanie poprawności aktualnego symbolu leksykalnego i wczytywanie następnego. Pro cedura wczytaj ustawia więc zmienną bieżący, która zawiera ostatnio wczytany symbol leksykalny. procedurę wczytajit: symbleks); begin if bieżący = t then bieżący := następny^, symbol else błąd end; procedurę typ; begin if bieżący in { integer, char, liczba } then prosty else if bieżący = ' T ' then begin wczytajC T ' ) ; wczytaj(id) end else if bieżący = array then begin wczytaji&rray); wczytajC {'); prosty; wczytajC ]'); end else błąd end;
wczytaj(of); typ
procedurę prosty; begin if bieżący = integer then
M>czyto/(integer) else if bieżący = char then wczytaj(char) else if bieżący = liczba then begin wczytaj(\iczba); wcz>'ta/(dwiekropki); n>czy/fl/(liczba) end else błąd end; Rys. 2.17. Przewidująca analiza składniowa zapisana w pseudokodzie Analiza składniowa zaczyna się od wywołania procedury dla typ, czyli symbolu startowego gramatyki. Po otrzymaniu na wejściu takich samych danych jak na rys. 2.16, bieżący zawiera początkowo symbol leksykalny array. Procedura typ wykonuje kod wczytaj(array);
wczytajC
['); prosty; wczytajC ] '); wczytaj(oT); typ
odpowiadający prawej stronie produkcji
(2.9)
typ —• array [ prosty
] of typ
Zauważmy, że dla każdego terminala następuje jego porównanie z symbolem bieżącym, natomiast dla nieterminali występuje wywołanie jego procedury. Po sprawdzeniu symboli leksykalnych array i [, symbolem bieżącym staje się leksem liczba. Zostaje teraz wywołana procedura prosty, a w niej kod wczyta/(Iiczba); wczyta/(dwiekropki); wczyta/(liczba); Produkcje gramatyki są wybierane na podstawie symbolu bieżącego. Jeśli prawa strona produkcji zaczyna się od symbolu leksykalnego, to ta produkcja jest wybierana po napotkaniu tego symbolu leksykalnego. Rozważmy teraz prawą stronę zaczynającą się od nieterminala typ
prosty
(2.10)
Produkcja ta może zostać wybrana, jeśli symbol bieżący może zostać wygenerowany z prosty. Załóżmy, że znajdujemy się we fragmencie kodu (2.9) przed wykonywaniem procedury typ, a symbolem bieżącym jest integer. Nie istnieje produkcja dla typ zaczy nająca się od symbolu leksykalnego integer. Istnieje jednak dla prosty, więc używana jest produkcja (2.10), zakodowana jako wywołanie procedury prosty z wnętrza procedury typ, jeśli symbolem bieżącym jest integer. Przewidująca analiza składniowa opiera swoje działanie na informacji o tym, które symbole mogą być generowane jako pierwsze przez prawe strony produkcji. Dokładniej, jeśli a jest prawą stroną produkcji dla nieterminala A, zdefiniujemy F I R S T ( a ) jako zbiór wszystkich symboli leksykalnych, które mogą zaczynać napisy wygenerowane z a . Jeśli a jest równe e lub można z niego wygenerować e, to również e należy do F I R S T ( a ) . Na przykład 1
¥IRST(prosty) = { integer, char, liczba } FIRST(t id) = { T } FIRST(array [ prosty ] of type) = { array } W praktyce wiele prawych stron produkcji zaczyna się od leksemów, co upraszcza kon strukcję zbiorów FIRST. Dokładny algorytm liczenia tych zbiorów znajduje sie w p. 4.4. Zbiory FIRST muszą być rozpatrywane wtedy, gdy istnieją dwie produkcje A —> a i A —> /3. Aby można było stosować metodę zejść rekurencyjnych bez nawrotów, zbiory F I R S T ( a ) i FIRST(j3) muszą być rozłączne. Dopiero wtedy symbol bieżący umożliwia podjęcie decyzji, którą produkcję wybrać: jeśli symbol bieżący należy do F I R S T ( a ) , wybieramy produkcję a, a jeśli do FIRST(j3), to fi.
Kiedy używać e-produkcji Produkcje zawierające e p o prawej stronie wymagają specjalnego traktowania. Analiza tor stosujący metodę zejść rekurencyjnych wybierze e-produkcję wtedy, gdy nie da się zastosować żadnej innej produkcji. Rozważmy na przykład gramatykę 1
Produkcje zawierające 6 po prawej .stronie produkcji powodują utrudnienie w wyznaczaniu pierwszych sym boli generowanych przez nieterminal. Przykładowo, jeśli z nieterminala B można wyprowadzić pusty napis, a istnieje produkcja A -» BC, to pierwsze symbole generowane przez C mogą być także pierwszymi symbo lami generowanymi przez A. Jeśli C może również generować €, to FTRST(/t) i FIRST(fiC) zawierają e.
instr -> begin opcj- instr end opcj-instr —>• lista-instr \e W trakcie analizy opcj-instr, jeśli symbol bieżący nie należy do FTRST(/wta_instr), należy wybrać e-produkcję. Wybór taki jest właściwy, jeśli symbolem bieżącym jest end, jeśli natomiast symbolem bieżącym jest coś innego, pojawi się błąd, który zostanie wykryty w procedurze instr. Projektowanie przewidującego analizatora składniowego Przewidujący analizator składniowy jest programem zawierającym procedurę dla każdego nieterminala. Każda procedura wykonuje dwie czynności: 1.
2.
Na podstawie symbolu bieżącego musi zadecydować o wyborze produkcji. Produkcja z prawą stroną a jest wybierana, jeśli symbol bieżący należy do F I R S T ( a ) . Jeśli istnieje symbol należący do zbiorów FIRST dla co najmniej dwóch różnych produkcji dla tego samego nieterminala, to ta metoda analizy składniowej nie może zostać zastosowana dla tej gramatyki. Produkcja z e po prawej stronie jest wybierana, gdy symbol bieżący nie należy do żadnego ze zbiorów FIRST dla pozostałych produkcji. Kod procedury odpowiada prawej stronie wybranej produkcji. Dla nieterminala w kodzie znajduje się wywołanie procedury dla tego nieterminala, dla symbolu leksykalnego następuje sprawdzenie, czy symbol bieżący jest tym symbolem lek sykalnym. Jeśli w jakimś miejscu symbol bieżący nie odpowiada symbolowi ter minalnemu, zgłaszany jest błąd. Na rysunku 2.17 jest przedstawiony kod programu otrzymany po zastosowaniu tych zasad do gramatyki (2.8).
Schemat translacji tworzy się, rozszerzając gramatykę, i podobnie tworzy się transla tor sterowany składnią — przez rozszerzanie analizatora przewidującego. Algorytm służą cy do tego jest przedstawiony w p. 5.5. Opisana poniżej uproszczona metoda konstrukcji na razie wystarczy, ponieważ schematy translacji zaimplementowane w tym rozdziale nie przypisują atrybutów nieterminalom: 1. 2.
Należy skonstruować analizator przewidujący, ignorując akcje w produkcjach. Skopiować akcje ze schematu translacji do analizatora składniowego. Jeśli akcja pojawia się po symbolu gramatyki X w produkcji p, to jest kopiowana po kodzie implementującym X. W przeciwnym razie, gdy akcja znajduje się na początku pro dukcji, jest kopiowana przed kodem implementującym daną produkcję.
Taki translator skonstruowaliśmy w p. 2.5.
Lewostronna rekurencja Analizator składniowy działający metodą zejść rekurencyjnych może się zapętlić. Pro blem pojawia się przy produkcjach lewostronnie rekurencyjnych, jak wyr -» wyr + skł w których pierwszy symbol w prawej stronie produkcji jest taki sam, jak nieterminal po lewej stronie. Załóżmy, że procedura dla wyr decyduje się zastosować tę produkcję.
Prawa strona zaczyna się od wyr, więc procedura dla wyr jest wywoływana rekurencyjnie i analizator składniowy zapętla się. Zauważmy, że symbol bieżący zmienia się jedynie wtedy, gdy procedura implementująca prawą stronę produkcji wykonuje kod od powiadający terminalowi. Ponieważ produkcja zaczyna się od nieterminala wyr, nie ma możliwości wczytania żadnego symbolu z wejścia i to powoduje pętlę nieskończoną. Lewostronna rekurencja w produkcji może zostać usunięta przez przepisanie tej produkcji. Rozważmy nieterminal A z dwoma produkcjami
A -> Aa | /3 gdzie a i /J są ciągami terminali i nieterminali, które nie zaczynają się od A. Na przykład, dla wyr —> wyr + skł \ skł
A = wyr, a = + skł i jS = skł. Nieterminal A jest lewostronnie rekurencyjny, ponieważ w produkcji A —> Aa nieter minal A jest pierwszym symbolem z prawej strony. Wielokrotne stosowanie tej produkcji twórz ciąg sekwencji a na prawo od A, jak na rys. 2.18(a). Gdy A jest w końcu zamieniane na /3, otrzymujemy sekwencję złożoną z j3 i dowolnej liczby symboli a (w szczególności żadnego).
R
R
R
p
a
a
a (a)
a
a
a (b)
Rys. 2.18. Lewo- i prawostronnie rekurencyjne sposoby generacji napisu
Ten sam efekt (jak na rys. 2.18(b)) można osiągnąć przez przepisanie produkcji dla A w następujący sposób:
A -> PR R -¥ aR
(2.11)
W powyższych produkcjach R jest nowym nieterminalem. Produkcja R -> aR jest pra wostronnie rekurencyjna, ponieważ zawiera ona R jako ostatni symbol po prawej stronie. Produkcje prawostronnie rekurencyjne prowadzą do drzew, które rosną w kierunku pra wym, jak na rys. 2.18(b). Drzewa rosnące na dół w prawo powodują trudniejszą transla cję wyrażeń zawierających operatory lewostronnie łączne, takie jak minus. W następnym
podrozdziale przekonamy się jednak, że poprawną translację wyrażeń na notację postfik sową można osiągnąć poprzez uważne zaprojektowanie schematu translacji bazującej na gramatyce prawostronnie rekurencyjnej. W rozdziale 4 rozważyliśmy bardziej ogólne rodzaje lewostronnej rekurencji i po każemy, jak można usunąć ją z gramatyki.
2.5
Translator dla prostych wyrażeń
Używając technik wprowadzonych w trzech poprzednich podrozdziałach tego rozdziału, możemy skonstruować translator sterowany składnią w postaci programu w C, przekształ cającego wyrażenia arytmetyczne na odwrotną notację polską. Aby program początkowy nie był zbyt rozbudowany, zaczniemy od wyrażeń składających się z cyfr porozdziela nych znakami plus i minus. W następnych dwóch podrozdziałach rozszerzyliśmy język o liczby, identyfikatory i inne operatory. Wyrażenia pojawiają się jako struktury w wielu językach, warto jest więc przyjrzeć się dokładnie ich translacji.
wyr -> wyr + skł wyr —> wyr - skł wyr —> skł skł 0
{ print(' +' ) } { print(' - ')} r
{ print(
0') }
skł -» 1
{ printC 1') }
skł ->
{ printC 9 ' ) }
9
Rys. 2.19. Początkowa specyfikacja translatora z notacji infiksowej na postfiksową
Schemat translacji sterowanej składnią często może służyć za specyfikację transla tora. Schemat z rys. 2.19 (powtórzony z rys. 2.13) zostanie użyty jako definicja trans lacji, którą należy wykonać. Często zdarza się, że gramatyka z takiego schematu musi zostać zmodyfikowana, tak aby można było napisać dla niej analizator przewidujący. W szczególności, gramatyka dla schematu z rys. 2.19 jest lewostronnie rekurencyjna i, jak przekonaliśmy się w poprzednim podrozdziale, nie daje się jej bezpośrednio użyć w analizatorze przewidującym. Eliminując lewostronną rekurencję, możemy otrzymać gramatykę pasującą do użycia w translatorze stosującym metodę zejść rekurencyjnych. Składnia abstrakcyjna i składnia konkretna Jeśli myślimy o translacji pewnego napisu wejściowego, to wygodnie jest zacząć od drzewa składni abstrakcyjnej, w którym każdy węzeł reprezentuje operator, a dzieci węzła — jego argumenty. W odróżnieniu od tego, drzewo wyprowadzenia jest nazywane drzewem składni konkretnej, a gramatyka, na której się ono opiera, jest nazywana składnią konkretną. Drzewa składni abstrakcyjnej, lub po prostu drzewa składniowe, różnią się od drzew wyprowadzeń, ponieważ w drzewach składniowych nie pojawiają się sztuczne konstrukcje nieprzydatne do translacji.
Przykładowe drzewo składniowe dla 9-5+2 jest przedstawione na rys. 2.20. Skoro operatory + i - mają ten sam priorytet, a operatory o tym samym priorytecie są obliczane od lewej do prawej, to w drzewie w jedno podwyrażenie zostało zgrupowane 9-5. Porównując ten rysunek z rys. 2.2, zauważymy, że w drzewie składniowym operatorowi odpowiada węzeł wewnętrzny, zamiast liścia w drzewie wyprowadzenia.
+
Rys. 2.20. Drzewo składniowe dla 9-5+2
Schemat translacji powinien bazować na gramatyce, dla której drzewa wyprowadze nia są najbardziej podobne do drzew składniowych. Grupowanie podwyrażeri w grama tyce z rys. 2.19 przypomina grupowanie w drzewach składniowych. Niestety, gramatyka z rys. 2.19 jest lewostronnie rekurencyjna, a więc nieprzydatna w takiej postaci do analizy przewidującej. Pojawia się pewien konflikt: z jednej strony potrzebujemy gramatyki nada jącej się do analizy składniowej, a z drugiej potrzebujemy zupełnie innej gramatyki, aby ułatwić samą translację. Oczywistym rozwiązaniem jest eliminacja lewostronnej rekurencji. Operację tę należy przeprowadzać uważnie, o czym przekonamy się z poniższego przykładu.
Przykład 2.9. Poniższa gramatyka, mimo że generuje taki sam język, j a k gramatyka z rys. 2.19, i można jej użyć w metodzie zejść rekurencyjnych, nie nadaje się jednak do translacji wyrażeń na notację posfiksową. wyr —> skł reszta reszta —> + wyr | - wyr | e skł -¥ 0 | 1 | • • • | 9 Problemem w tej gramatyce jest to, że argumenty operatorów generowanych przez reszta —> + wyr i reszta -+ - wyr nie są oczywiste z samych produkcji. Z poniższych możliwych translacji reszta.t z wyr.t żadnej nie można zaakceptować reszta
- wyr
reszta —> - wyr
{reszta.t:—
'
\\ wyr.t}
{reszta.t ;= wyr.t \ \ ' - ' }
(2.12) (2.13)
(Pokazaliśmy jedynie produkcję i akcję semantyczną dla operatora minus). Translacją 9-5 jest 95-. Jednak, jeśli użyjemy akcji (2.12), to znak minus pojawi się przed wyr.t i w wyniku translacji 9-5 zostanie niepoprawnie przekształcona na 9-5. Jeśli natomiast użyjemy (2.13) i analogicznej reguły dla plusa, to operatory wspólnie przesuną się na prawy koniec i 9-5+2 zostanie przekształcone na 952 + - (poprawnym wynikiem powinno być 95-2+). •
Dostosowanie schematu translacji Technika eliminacji lewostronnej rekurencji, przedstawiona na rys. 2.18, może również zostać zastosowana do produkcji zawierających akcje semantyczne. W podrozdziale 5.5 rozszerzyliśmy tę metodę, uwzględniając również atrybuty syntezowane. Technika ta po lega na przekształceniu produkcji A —• Aa | Aj3 | y w produkcję
A R
-»•
yR aR\
flR\e
Gdy akcje semantyczne znajdują się wewnątrz produkcji, możemy j e przenosić wraz z innymi symbolami podczas transformacji. Jeśli przyjmiemy A - wyr, a = + skł { print(' + ' ) }, /3 = - skł { print{' - ' ) } i y = skł, to powyższa transformacja produkuje schemat translacji (2.14). Produkcje dla wyr z rys. 2.19 zostały przekształcone na produkcje dla wyr i nowego nieterminala reszta (2.14). Produkcje dla skł pozostają takie same, jak na rys. 2.19. Zauważmy, że gramatyka różni się od tej z przykładu 2.9, a ta różnica umożliwia przeprowadzenie poprawnej translacji wyr —> skł reszta reszta -» + skł { print(
r
r
+ ') } reszta | - skł { prinł(
-')
} reszta \ e
skł -> 0 {print{'0')}
skł -> 1
n
{printCl')}
skł - » 9 { printC
9')}
Na rysunku 2.21 przestawiono translację wyrażenia 9 - 5 + 2 przy zastosowaniu powyższej gramatyki. wyr skł 9
{pńnt( '9')}
reszta -
skł
/ 5
{prinĄ^)}^
reszta
^
\ {print( '5')}
W*.
7 skł
+
{print('+')}
/\ 2
{printCl'))
reszta
i e
Rys. 2.21. Translacja wyrażenia 9-5+2 na 95-2 + Procedury dla nieterminali wyr, skł i reszta Zaimplementujemy teraz translator w C, używając schematu translacji sterowanej składnią (2.14). Rysunek 2.22 zawiera zakodowane w C funkcje w y r , s k l i r e s z t a stanowiące główną część translatora. Funkcje te są implementacją odpowiadających im nieterminali z (2.14). Funkcja w c z y t a j , którą przedstawiliśmy na rys. 2.24, jest odpowiednikiem w C funkcji z rys. 2.17 sprawdzającej, czy bieżący symbol jest taki, jak oczekiwany, i pobie-
rającej następny symbol leksykalny. Ponieważ symbole leksykalne składają się z poje dynczych znaków, w c z y t a j musi porównywać i czytać pojedyncze znaki. wyr ()
{ skl {); reszta ();
} reszta ()
{ if (bieżący =='+') { wczytaj('+'); sklO; putchar('+'); reszta();
} else if (bieżący =-'-') { wczytajC-'); skl(); putchar ('-') ; reszta(); } else ; } skl () { if (isdigit(bieżący)) { putchar(bieżący); wczytaj(bieżący);
} else error();
} Rys. 2.22. Funkcje dla nieterminali wyr, reszta i skł Dla osób, które nie znają programowania w C , podstawowe cechy różniące ten język i inne języki wywodzące się z Algola, np. Pascal, omówiliśmy w rozdziałach, w których zajęliśmy się wykorzystaniem tych cech. Program w C składa się z sekwencji definicji funkcji. Wykonywanie programu zaczyna się od funkcji m a i n . Definicje funkcji nie mogą być zagnieżdżane. Nawiasy zawierające listę parametrów funkcji są wymagane nawet wtedy, gdy funkcja nie ma żadnych parametrów; piszemy więc w y r ( ) , s k l () i r e s z t a ( ) . Funkcje komunikują się dwoma sposobami: przekazując parametry przez wartość lub przez dostęp do zmiennych globalnych. Funkcje s k l () i r e s z t a () mają, na przykład, dostęp do symbolu bieżącego przez globalny identyfikator b i e ż ą c y . Języki C i Pascal używają następujących symboli do przypisania i porównań:
OPERACJA
C
przypisanie test równości test nierówności
~ —= i—
PASCAL
:— = o
Funkcje dla nieterminali naśladują prawe strony produkcji. Na przykład, produkcja y reszta jest odzwierciedlona w wywołaniach s k l () i r e s z t a () z funkcji wyr ( ) . W
r
Innym przykładem jest funkcja r e s z t a ( ) , która, jeśli bieżącym symbolem jest znak plus, wykorzystuje pierwszą produkcję z (2.14) dla reszta, jeśli bieżącym symbo lem jest znak minusa, to drugą, a jeśli jest inny, to produkcję pustą reszta —> e. Pierwsza produkcja dla reszta jest zaimplementowana wewnątrz pierwszej gałęzi instrukcji if na rys. 2.22. Jeśli symbolem bieżącym jest +, znak ten jest sprawdzany przez wywołanie w c z y t a j ( ' + ' ) . Po wywołaniu funkcji s k l ( ) , procedura p u t c h a r ( ' + ' ) ze stan dardowej biblioteki C implementuje akcję semantyczną polegającą na wypisaniu znaku plus. Ponieważ trzecia produkcja dla reszta ma po prawej stronie e, więc po ostatnim else funkcja r e s z t a () nic nie robi. Dziesięć produkcji dla skł generuje dziesięć cyfr. Na rysunku 2.22 funkcja i s d i g i t sprawdza, czy symbol bieżący jest cyfrą. Cyfra jest drukowana, jeśli ten test zakończył się pomyślnie, w przeciwnym razie jest zgłaszany błąd. (Zauważmy, że w c z y t a j zmienia symbol bieżący, więc wypisywanie musi się odbyć przed sprawdza niem cyfry). Przed pokazaniem całości kodu wykonamy jeszcze zmianę przyspieszającą działanie kodu z rys. 2.22.
Optymalizacja translatora Niektóre wywołania rekurencyjne można zamienić na iteracje. Sytuacja, w której ostatnią instrukcją wywoływaną w procedurze jest rekurencyjne wywołanie samej siebie, nazy wana jest rekurencją końcową. Na przykład, wywołania r e s z t a () na końcu czwartego i siódmego wiersza funkcji r e s z t a () są rekurencją końcową, ponieważ sterowanie przechodzi na koniec treści funkcji po każdym z wywołań. Zamieniając rekurencję końcową na iterację można przyspieszyć program. Dla pro cedury bezparametrowej, rekurencją końcowa może być w prosty sposób zamieniona na skok na początek procedury. Kod dla r e s z t a może zostać zapisany w następujący sposób:
reszta()
{ L:
if (bieżący ~ '+') { wczytaj ('+'); skl (); putchar('+'); goto L;
} else if (bieżący =='-') { wczytajC-'); skl(); putchar('-'); goto L; } else ; } Dopóki symbolem bieżącym jest plus lub minus, procedura r e s z t a wczytuje ten sym bol, wywołuje s k l , aby wczytać cyfrę, i powtarza ten proces. Zauważmy, że ponieważ w c z y t a j usuwa znaki plus i minus za każdym razem, kiedy jest wywoływana, proces powtarza się dopóty, dopóki na wejściu pojawiają się na przemian znaki plus lub minus oraz cyfry. Jeśli te zmiany zostaną wprowadzone do rys. 2.22, to funkcja r e s z t a jest wywoływana tylko z w y r (patrz wiersz 3). Dwie funkcje mogą być połączone w jedną, jak na rys. 2.23. W języku C instrukcja może być wywoływana w pętli po napisaniu w h i l e (1) instr
ponieważ warunek 1 oznacza prawdę. Z takiej pętli można wyjść po wywołaniu instrukcji break. Kod z rys. 2.23 pozwala na wygodne dodawanie nowych operatorów. wyr () { skl() ; while(1) i f ( b i e ż ą c y == ' +' ) {
wczytaj ('+'); skl(); putchar('+');
} else if (bieżący =='-') { wczytaj ('-'); skl (); putchar('-');
} else break;
} Rys. 2.23. Połączenie funkcji wyr i r e s z t a z rys. 2.22
Pełny program Program pełny jest pokazany na rys. 2.24. Pierwszy wiersz, zaczynający się od # i n c l u d e , ładuje < c t y p e . h > — plik ze standardowymi procedurami, zawierający między innymi deklarację funkcji i s d i g i t . Symbole leksykalne, zawierające pojedyncze znaki, są dostarczane przez standardo wą funkcję g e t c h a r , która czyta następny znak z pliku wejściowego. Zmienna b i e ż ą c y w wierszu 2 na rys. 2.24 została zadeklarowana jako liczba całkowita, aby można było potem dodać również wieloznakowe symbole leksykalne. Ponieważ b i e ż ą c y zosta ło zadeklarowane poza wszystkimi funkcjami, więc jest globalne dla wszystkich funkcji, które są zdefiniowane po wierszu 2. Funkcja w c z y t a j sprawdza symbol leksykalny i jeśli zgadza się on z wartością pa rametru, to następuje wczytanie następnego. Jeśli natomiast symbol różni się od wartości parametru, to zgłaszany jest błąd. Funkcja e r r o r za pomocą funkcji p r i n t f ze standardowej biblioteki wypisuje wiadomość „ b ł ą d s k ł a d n i " , a potem kończy działanie programu, wywołując kolejną funkcję z biblioteki standardowej e x i t (1).
2.6
Analiza leksykalna
Uzupełnimy teraz translator z p. 2.5 o moduł analizy leksykalnej wczytujący i konwer tujący dane wejściowe w strumień symboli leksykalnych dla analizatora składniowego. Przypomnijmy (z p. 2.2), że zdania w języku składają się z ciągów symboli leksykalnych. Każdy symbol leksykalny składa się z sekwencji pewnej liczby znaków. Analizator leksy kalny izoluje analizator składniowy od tej reprezentacji znakowej symbolu. Opis analizy leksykalnej dla naszego języka zaczniemy od wymienienia funkcji, które chcemy, aby miał analizator leksykalny.
#include /* ładuje plik z deklaracją funkcji isdigit int bieżący;
;
main ()
{ bieżący = getchar(); wyr(); putcharC\n' ) ; /* dołącz na koniec znak nowego wiersza */
} wyr () { skl () ; while (1) if (bieżący == '+') { wczytaj('+'); skl(); putchar('+');
} else if (bieżący =='-') { wczytajC-'); skl(); putchar ('-');
} else break; } skl () { if (isdigit(bieżący)) { putchar(bieżący); wczytaj(bieżący);
} else error ();
} wczytaj(t) int t; { if (bieżący == t) bieżący - getchar(); else error();
} error() { printf("błąd składni\n"); /* wypisz komunikat o błędzie */ exit(l); /* a potem zakończ program */
} Rys. 2.24. Program w C tłumaczący wyrażenia z notacji infiksowej na postfiksową
Usuwanie znaków odstępu i komentarzy Translator wyrażeń (omówiony w poprzednim podrozdziale) analizował każdy znak z wej ścia, każdy dodatkowy znak powodował więc błąd. Wiele języków dopuszcza „białe znaki'* (odstępy, znaki tabulacji, nowe wiersze) między leksemami. Komentarze również powinny być ignorowane przez analizator składniowy i translator, można więc j e również traktować j a k białe znaki. Po eliminacji białych znaków w analizatorze leksykalnym, analizator składniowy nie będzie musiał ich rozważać. Inną metodą jest uwzględnienie białych znaków w samym analizatorze składniowym, lecz jest to trudne w implementacji. Stałe Za każdym razem, gdy w wyrażeniu pojawia się pojedyncza cyfra, rozsądne wydaje się pozwolenie na wczytywanie zamiast niej dowolnej liczby całkowitej. Ponieważ stała całkowita jest sekwencją cyfr, więc można j e uwzględnić, albo dodając odpowiednie produkcje do gramatyki, albo tworząc symbol leksykalny dla tej stałej. Zadanie składania cyfr w liczby całkowite jest zwykle przeprowadzane przez analizator leksykalny po to, aby podczas analizy liczby były traktowane jak jednostki. Niech liczba będzie symbolem leksykalnym reprezentującym liczbę całkowitą. Gdy wśród danych wejściowych analizatora leksykalnego pojawia się sekwencja cyfr, do anali zatora składniowego zostanie przekazany pojedynczy symbol leksykalny liczba. Wartość tej liczby zostanie przekazana jako atrybut symbolu liczba. Logiczne jest, że analizator leksykalny przekazuje symbol razem z wartością liczby. Jeśli zapiszemy symbol i jego atrybut jako parę, to wejście 31 + 28 + 29 zostanie przekształcone w sekwencję par < + , > < + , > Symbol leksykalny + nie ma żadnego atrybutu. Drugi element pary, czyli atrybut, nie odgrywa żadnej roli podczas analizy składniowej, ale jest potrzebny później. Rozpoznawanie identyfikatorów i słów kluczowych Identyfikatory w różnych językach są używane do nazywania zmiennych, tablic, funkcji itp. Gramatyka języka traktuje identyfikatory jako pojedyncze symbole leksykalne. Ana lizator składniowy dla tej gramatyki powinien otrzymywać leksem, n p . id, za każdym razem, gdy na wejściu znajduje się identyfikator. Na przykład, dla wejścia licznik = licznik + przyrost;
(2.15)
analizator leksykalny wygeneruje następujący strumień symboli: id = id + id ;
(2.16)
Ten strumień symboli leksykalnych stanowi dane wejściowe dla analizatora składniowego. Kiedy mówimy o analizie leksykalnej wiersza (2.15), warto jest rozróżniać symbole leksykalne id od przypisanych im leksemów licznik i p r z y r o s t . Translator musi
oczywiście wiedzieć, że pierwsze dwa symbole id powstały z leksemów
licznik,
a ostatni z p r z y r o s t . Gdy z danych wejściowych wczytywany jest ciąg znaków, składający się na iden tyfikator, potrzebny jest mechanizm stwierdzający, czy taki ciąg znaków nie pojawił się wcześniej. Jak wspomniano w rozdz. 1, do tego celu używa się tablicy symboli. Ciąg znaków składający się na identyfikator jest zapisywany w tablicy symboli, a wskaźnik tego identyfikatora w tablicy symboli jest zapamiętywany jako atrybut symbolu id. Wiele języków wykorzystuje ustalone ciągi znaków, jak b e g i n , e n d , i f , jako znaki przestankowe łub do identyfikacji pewnych konstrukcji. Te ciągi znaków, zwane słowa mi kluczowymi, spełniają zasady tworzenia identyfikatorów; potrzebna jest więc metoda rozróżnienia ich od identyfikatorów. Problem staje się prostszy, jeśli słowa kluczowe są zarezerwowane, to znaczy, nie mogą zostać użyte jako identyfikatory. W takim przypadku ciąg znaków tworzy identyfikator tylko wtedy, gdy nie jest słowem kluczowym. Problem powstaje również wtedy, gdy te same znaki pojawiają się w kilku sym bolach leksykalnych jednocześnie, jak na przykład w Pascalu w =, czyli operator „nie mniejszy niż". W prze ciwnym razie symbolem leksykalnym jest >, czyli operator „większy niż"; znaczy to, że analizator leksykalny wczytał z wejścia o jeden znak za dużo i znak ten musi zostać zwrócony do wejścia, ponieważ może być początkiem następnego symbolu.
Czytaj znak Analizator leksykalny
Przekaż symbol leksykalny i jego atrybuty
Analizator składniowy
Zwróć znak Rys. 2.25. Umieszczenie analizatora leksykalnego między wejściem a analizatorem składniowym
Analizator leksykalny i składniowy tworzą parę producent-konsument. Analizator leksykalny produkuje symbole leksykalne, a składniowy je konsumuje. Wyprodukowane symbole mogą być przechowywane w buforze symboli, aż nie zostaną skonsumowane. Wzajemne oddziaływanie między tymi dwoma modułami jest ogramiczone jedynie wiel kością bufora, ponieważ analizator leksykalny nie może działać, gdy bufor jest pełny, a składniowy, gdy jest pusty. Zwykle bufor przechowuje tylko jeden symbol leksykalny.
W tym przypadku analizator leksykalny może być zaimplementowany jako procedura wywoływana przez analizator składniowy, zwracająca na żądanie symbole. Czytanie z wejścia i zawracanie na nie znaków zwykle implementuje się za pomocą bufora. Cały blok znaków jest czytany jednocześnie z wejścia i umieszczany w buforze. Wskaźnik aktualnej pozycji w buforze wskazuje porcję znaków do zanalizowania. Zwró cenie pojedynczego znaku polega na przesunięciu wskaźnika o jedną pozycję do tyłu. Znaki wejściowe mogą być zapamiętywane w celu zgłaszania komunikatów o błędach zawierających lepszą informację o miejscu wystąpienia błędu. Buforowanie wejścia ma też zalety — wczytywanie bloku znaków zwykle jest bardziej wydajne niż wczytywanie pojedynczych znaków. Techniki buforowania wejścia są omówione w p. 3.2.
Analizator leksykalny Skonstruujemy teraz minimalny analizator leksykalny dla translatora wyrażeń z p. 2.5. Przyczyną utworzenia tego analizatora jest chęć uwzględnienia białych znaków i liczb w wyrażeniach. W następnym podrozdziale rozszerzymy analizator leksykalny o uwzględ nianie identyfikatorów. Na rysunku 2.26 pokazano jak analizator leksykalny — zapisany w C jako funkcja l e k s e r — implementuje oddziaływania z rys. 2.25. Funkcje g e t c h a r i u n g e t c , pochodzące ze standardowego pliku nagłówkowego < s t d i o . h > , zajmują się buforowa niem wejścia. Funkcja l e k s e r wczytuje i zwraca znaki z wejścia, wywołując g e t c h a r i u n g e t c . Jeśli c zostało zadeklarowane jako znak, dwie instrukcje c = getchar();
ungetc(c,
stdin);
nie zmieniają strumienia wejściowego. Wywołanie g e t c h a r przypisuje następny znak z wejścia do zmiennej c , natomiast wywołanie u n g e t c zwraca z powrotem znak c na standardowe wejście s t d i n .
Używa g e t c h a r () do wczytania znaku Zwraca znak c przy użyciu ungetc(c,stdin)
lekser() Analizator leksykalny
Zwraca symbol • leksykalny wywołującemu
1 lekswart
Ustawia wartość atrybutu w zmiennej globalnej
Rys. 2.26. Implementacja oddziaływań z rys. 2.25
Jeśli język implementacji nie pozwala funkcjom na zwracanie struktur danych, to symbole leksykalne i ich atrybuty muszą być przekazane osobno. Funkcja l e k s e r zwra ca liczbę całkowitą oznaczającą symbol leksykalny. Symbol dla pojedynczego znaku mo że mieć wartość będącą zwykłym kodem tego znaku. Symbol — taki jak liczba — może zostać zakodowany za pomocą liczby większej niż wszystkie kody pojedynczych znaków, np. 2 5 6. Aby można było w prosty sposób zmieniać kodowanie symboli, użyjemy stałej symbolicznej LICZBA do kodu symbolu liczba. W Pascalu przypisanie liczby całkowi-
tej do stałej L I C Z B A może być wykonane za pomocą deklaracji const. W języku C, do przypisania 2 5 6 do stałej L I C Z B A używa się dyrektywy #define
LICZBA
256
Funkcja l e k s e r zwraca L I C Z B A , gdy z wejścia zostaje wczytana sekwencja cyfr. Zmienna globalna l e k s w a r t zawiera wartość tej sekwencji cyfr, więc — j e ś l i na wej ściu po 7 znajduje się 6 — zmiennej l e k s w a r t jest przypisywana wartość 7 6. Dopuszczenie liczb w wyrażeniach wymaga zmiany gramatyki z rys. 2.19. Za mienimy pojedyncze cyfry na nieterminal czyn i dodamy dodatkowe produkcje i akcje semantyczne: czyn
( wyr ) \ liczba { print(liczba,
wartość)
}
Kod w języku C dla czyn z rys. 2.27 jest bezpośrednią implementacją powyższych produkcji. Kiedy b i e ż ą c y jest równy L I C Z B A , wartość atrybutu liczba.wartość jest przekazywana przez zmienną globalną l e k s w a r t . Wypisywanie tej wartości odbywa się przy użyciu standardowej funkcji p r i n t f . Pierwszym argumentem p r i n t f jest napis specyfikujący format wypisywania pozostałych argumentów. W miejscu, w którym w tym napisie znajduje się %d, wypisywana jest liczba dziesiętna reprezentująca wartość następnego argumentu. Instrukcja p r i n t f z rys. 2.27 wypisuje spację, reprezentację dziesiętną zmiennej l e k s w a r t , i kolejną spację.
czyn () { if (bieżący = = ' ( ' ) { wczytaj(' ('); wyr(); wczytaj(')'); } else if (bieżący == LICZBA) { printf(" %d lekswart); wczytaj(LICZBA); }
else error (); }
Rys. 2.27. Program w C dla czyn uwzględniający liczby Implementacja funkcji l e k s e r jest przedstawiona na rys. 2.28. Za każdym ra zem, kiedy wykonywane jest wnętrze instrukcji while w wierszach 8-28, do zmiennej t jest wczytywany jeden znak (wiersz 9). Jeśli znak jest odstępem, znakiem tabulacji (zapisanym jako ' \ t ' ) , to analizatorowi składniowemu nie jest zwracany żaden symbol leksykalny. Wtedy pętla jest powtarzana. Jeśli wczytywany jest znak nowego wiersza, to o jeden zwiększana jest zmienna globalna n r w i e r s z a , aby w wypadku błędu wiadomo było, w którym wierszu się znajdujemy, a ewentualny komunikat mógł wskazać miejsce wystąpienia tego błędu. Również w przypadku nowego wiersza nie jest zwracany żaden symbol leksykalny. Kod wczytujący sekwencje cyfr znajduje się w wierszach 1 4 - 2 3 . Funkcja i s d i g i t ( t ) z pliku nagłówkowego < c t y p e . h > jest używana w wierszach 14 i 17 do sprawdzenia, czy wczytany znak t jest cyfrą. Jeśli tak jest, to jego wartość licz bowa jest obliczana z wyrażenia t - ' 0 ' , jeśli kod cyfry jest w standardzie ASCII lub
(1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13) (14) (15) (16) (17) (18) (19) (20) (21) (22) (23) (24) (25) (26) (27) (28) (29)
łinclude <stdio.h> #include int n r w i e r s z a = 1; int l e k s w a r t = BRAK; int {
l e k s e r {) int t; while (1) { t = g e t c h a r () ; i f ( t == ' ' | | t « ' \ t ' ) ; / * pozbądź się spacji i znaków tabulacji */ e l s e i f ( t == ' \ n ' ) n r w i e r s z a = n r w i e r s z a + 1; e l s e i f ( i s d i g i t (t) ) { l e k s w a r t = t - ' 0' ; t = g e t c h a r () ; while { i s d i g i t (t) ) { lekswart = lekswart*10 + t - ' 0 ' ; t = g e t c h a r () ; } ungetc (t, s t d i n ) ; r e t u r n LICZBA; } else { l e k s w a r t = BRAK; return t; } }
}
Rys. 2.28. Kod w C dla analizatora leksykalnego usuwającego znaki białe i odczytującego liczby EBCDIC. W innych standardach kodowania znaków konwersja ta może wyglądać inaczej. W podrozdziale 2.9 połączyliśmy ten analizator leksykalny z translatorem wyrażeń.
2.7
Dołączenie tablicy symboli
Struktura danych, zwana tablicą symboli, służy przede wszystkim do przechowywania in formacji o różnych konstrukcjach w języku źródłowym. Informacje te są zbierane przez fazy analizy kompilatora, a wykorzystywane przez fazy syntezy do generacji kodu wyni kowego. W trakcie analizy leksykalnej, na przykład, wczytane ciągi znaków są zapisywa ne w tablicy symboli. Późniejsze fazy mogą dołączyć do tych wpisów takie informacje, jak typ identyfikatora, jego rodzaj (np. procedura, zmienna lub etykieta) i położenie w pamięci. Faza generacji kodu będzie wykorzystywać te informacje do tworzenia po prawnego kodu zapisującego i odczytującego zmienne. W podrozdziale 7.6 jest dokładnie omówiona implementacja i sposób użycia tablic symboli. Poniżej omówiliśmy współpracę analizatora leksykalnego z tablicą symboli.
Interfejs tablicy symboli Funkcje tablicy symboli są związane głównie z zapisywaniem i odczytywaniem leksemów. Zapisując leksem, zapisujemy również związany z nim symbol leksykalny. Na tablicy symboli będą wykonywane następujące operacje: d o d a j (s, t ) : Zwraca indeks nowego wpisu dla Ieksemu s i symbolu leksykalnego t. z n a j d z (s): Zwraca indeks wpisu dla Ieksemu s lub 0, gdy s nie znaleziono. Analizator leksykalny używa operacji znajdź do sprawdzenia, czy w tablicy symboli istnieje już wpis dla Ieksemu. Jeśli nie, to przy użyciu operacji dodaj leksem jest umiesz czany w tablicy. W następnym punkcie omówiliśmy implementację, w której zarówno analizator leksykalny, jak i składniowy znają format tablicy symboli. Obsługa zarezerwowanych słów kluczowych Powyższe funkcje tablicy symboli mogą obsłużyć każdy zbiór zarezerwowanych słów kluczowych. Rozważmy, na przykład, symbole div i mod z leksemami, odpowiednio, d i v i mod. Tablicę symboli możemy zainicjować za pomocą: dodaj dodaj
( " d i v " , div) ; ("mod", mod);
Każde wywołanie procedury z n a j d ź
( " d i v " ) zwróci leksem div, więc d i v nie może
zostać użyte jako identyfikator. Dowolne zarezerwowane słowa kluczowe mogą być obsługiwane w ten sposób po właściwym zainicjowaniu tablicy symboli. Implementacja tablicy symboli Na rysunku 2.29 przedstawiono szkic struktury danych dla przykładowej implementacji tablicy symboli. N i e chcemy ustawiać stałej liczby znaków do pamiętania pojedynczego Ieksemu identyfikatora. Ustalona długość ciągu znaków Ieksemu może nie być wystar czająca d o zapamiętania bardzo długiego identyfikatora, a dla krótkich identyfikatorów, jak np. i , będzie to strata pamięci. Na rysunku 2.29 do pamiętania znaków tworzących identyfikatory jest przeznaczona oddzielna tablica l e k s e m y . Ciągi znaków kończą się specjalnym kodem końca ciągu EOS (ang. end-of-string), który nie może pojawiać się wewnątrz identyfikatorów. Każdy wpis w tablicy symboli t a b s y m jest rekordem złożo nym z dwóch pól: lekswsk wskazującym początek Ieksemu i s y m b l e k s . Dodatkowe pola mogą przechowywać wartości atrybutów, choć w tym przypadku nie będziemy ich wykorzystywać. Na rysunku 2.29 pozycja 0 w tablicy symboli jest pusta, ponieważ funkcja z n a j d ź zwraca 0, gdy ciąg nie znajduje się w tablicy symboli. W rekordach 1 i 2 znajdują się wpisy dla słów kluczowych d i v i mod, w 3 i 4 — identyfikatory l i c z n i k oraz i . Analizator leksykalny w pseudokodzie, który uwzględnia identyfikatory, jest przed stawiony na rys. 2.30 (implementacja w języku C jest w p. 2.9). Białe znaki i liczby całkowite są traktowane w ten sam sposób, jak w omawianym analizatorze leksykalnym z rys. 2.28.
2.7
59
DOŁĄCZENIE TABLICY SYMBOLI
TABLICA tabsym
lekswsk
symbleks div mod id id
•
d
i
V
EOS m
o
d EOS 1
i
c
Atrybuty
z
n
i
k EOS i EOS
TABLICA leksemy
Rys. 2.29. Tablica symboli i tablica do przechowywania napisów
function lekser: integer; var leksbuf: array [0..100] of char; c: char; begin loop begin wczytaj znak do c; if c jest odstępem lub tabulacją then nic nie rób else if c jest znakiem nowego wiersza then nrwiersza := nrwiersza + 1 else if c jest cyfrą then begin ustaw lekswart na wartość liczby złożonej z tej i następnych cyfr; return LICZBA end else if c jest literą then begin umieść c wraz z kolejnymi literami i cyframi w leksbuf; p :- znajdz(leksbuf); if p — O then p := wstaw(leksbuf ID); lekswart := p\ return pole symbleks z pozycji p w tablicy symboli end else begin / * symbol jest jednoznakowy */ ustaw lekswart na BRAK; / * nie ma atrybutu * / return kod znaku c end end end Rys. 2.30. Pseudokod dla analizatora leksykalnego
Analizator leksykalny czyta literę i zaczyna zapisywać litery i cyfry w buforze l e k s b u f . Ciąg znaków zapisany w l e k s b u f jest wyszukiwany w tablicy symboli przy użyciu operacji z n a j d ź . Skoro tablica symboli została zainicjowana wpisami d i v i mod, jak na rys. 2.29, operacja z n a j d ź wyszuka te wpisy, ponieważ l e k s b u f zawie ra d i v oraz mod. Jeśli w tablicy symboli nie m a wpisu dla ciągu znaków z l e k s b u f , czyli z n a j d ź zwraca 0, to l e k s b u f zawiera znaki nowego Ieksemu. Wpis dla nowe go identyfikatora jest tworzony przy użyciu d o d a j . P o utworzeniu tego wpisu, p jest indeksem w tablicy symboli dla ciągu znaków w l e k s b u f . Indeks ten jest przekazywa ny do analizatora składniowego przez przypisanie go na l e k s w a r t , a rodzaj symbolu leksykalnego jest zwracany z pola s y m b l e k s z wpisu p . Domyślna akcja polega na zwróceniu liczbowego kodu znaku jako symbolu lek sykalnego. Ponieważ symbole jednoznakowe nie mają atrybutów, więc l e k s w a r t jest ustawiane na wartość B R A K .
2.8
Abstrakcyjne maszyny stosowe
Przód kompilatora tworzy reprezentację pośrednią programu źródłowego, z której tył kompilatora generuje program wynikowy. Jedną z popularnych postaci reprezentacji po średniej jest kod na abstrakcyjną maszynę stosową. Jak wspomnieliśmy w rozdz. 1, dzięki podziałowi kompilatora na przód i tył, łatwo jest przystosować kompilator do działania na innej maszynie. W tym podrozdziale omówiliśmy, czym jest abstrakcyjna maszyna stosowa i w jaki sposób można generować dla niej kod. Maszyna taka ma oddzielną pamięć na rozkazy i dane, a wszystkie operacje arytmetyczne są wykonywane na stosie. Liczba rozkazów jest ograniczona i można j e podzielić na trzy klasy: operacje całkowite, działania na stosie i operacje przebiegu sterowania programu. Na rysunku 2.31 przedstawiono przykład takiej maszyny. Wskaźnik pc wskazuje wykonywany rozkaz. Znaczenie tych rozkazów omówiliśmy w kolejnych podrozdziałach. STOS
ROZKAZY
push 5 rvalue 2 + rvalue 3 *
16 wierzchołek
pc
Rys. 2.31. Stan maszyny stosowej po wykonaniu pierwszych czterech rozkazów
Rozkazy arytmetyczne Maszyna abstrakcyjna musi implementować wszystkie operatory z języka pośredniego. Podstawowe operacje, jak dodawanie i odejmowanie, są bezpośrednio zawarte w języku
maszyny stosowej. Jednak bardziej skomplikowane operacje mogą być zaimplemento wane jako kilka rozkazów. Aby uprościć jej opis, założymy, że dla każdego operatora arytmetycznego jest odpowiedni rozkaz. Kod maszyny abstrakcyjnej dla wyrażenia arytmetycznego symuluje obliczenie no tacji posfiksowej tego wyrażenia za pomocą stosu. Obliczenie to polega na przetworzeniu notacji postfiksowej od lewej strony do prawej. Każdy argument w chwili jego napotkania jest wkładany na stos. Gdy napotykany jest operator argumentowy, jego pierwszy argu ment znajduje się na k — 1 pozycji poniżej wierzchołka stosu, a ostatni na wierzchołku. Obliczenie wartości operatora odbywa się dla tych k wartości i polega na ich zdjęciu ze stosu i włożeniu na stos rezultatu. Na przykład, do obliczenia wyrażenia w notacji postfiksowej 1 3 + 5 * wykonuje się następujące czynności: 1. 2. 3. 4. 5.
Włóż na stos 1. Włóż na stos 3. Dodaj dwie wartości z wierzchołka stosu, usuń j e i umieść na stosie wynik 4. Włóż na stos 5. Pomnóż dwie wartości z wierzchołka stosu, usuń je i umieść na stosie wynik 20.
Wartość z wierzchołka stosu po zakończeniu działania (tutaj 20) jest wartością całego wyrażenia. W języku pośrednim wszystkie wartości są liczbami całkowitymi, 0 oznacza f a ł s z , a wartość niezerowa — p r a w d ę . Do obliczenia operatorów logicznych a n d i o r oba argumenty muszą być wcześniej obliczone. L-wartości i R-wartości Różne znaczenie mają identyfikatory z lewej i prawej strony przypisania. W każdym z dwóch przypisań i:=5; i:=i+l; prawa strona oznacza wartość całkowitą, a lewa określa, gdzie tę wartość należy umieścić. Podobnie, jeśli p i q są wskaźnikami znaków oraz pT:=qT; prawa strona qt specyfikuje znak, a lewa pT specyfikuje, gdzie ten znak ma zostać zapisany. Określenia l-wartość i r-wartość odnoszą się do wartości, które znajdują się odpowiednio po lewej i prawej stronie przypisania*. Zatem o r-wartościach myślimy po prostu jak o „wartościach", a o /-wartościach jak o lokacjach. Operacje na stosie Oprócz oczywistych rozkazów służących do umieszczania liczb na stosie i pobierania ich z niego, istnieją także rozkazy dostępu do pamięci danych:
* Nazwy l-wartość
i r-wartość
pochodzą od angielskich określeń left value i right value (przyp. tłum.).
push v umieszcza v na stosie r v a l u e / umieszcza na stosie zawartość pamięci o adresie / l v a l u e / umieszcza na stosie adres pamięci / pop zdejmuje ze stosu jedną wartość := r-wartość z wierzchołka stosu jest umieszczana w /-wartości zapisanej poniżej wierzchołka; obie wartości są zdejmowane ze stosu copy umieszcza na stosie kopię wartości z jego wierzchołka
Translacja wyrażeń Kod dla maszyny stosowej obliczający wyrażenie odpowiada odwrotnej notacji pol skiej tego wyrażenia. Z definicji, notacja postfiksową wyrażenia E + F jest złączeniem notacji postfiksowej wyrażenia £ , notacji postfiksowej wyrażenia F i operatora -f-. Podob nie, kod maszyny stosowej obliczający E + F jest złączeniem kodu liczącego £ , kodu li czącego F oraz rozkazu dodającego obie wartości. Translator wyrażeń do kodu maszy ny stosowej można więc otrzymać przez przerobienie translatorów omówionych w p. 2.6 i 2.7. Poniżej będziemy generować kod dla wyrażeń, którego pamięć jest adresowana sym bolicznie. (Przydział adresów pamięci identyfikatorom jest omówiony w rozdz. 7). Wy rażenie a + b jest tłumaczone na rvalue rvalue +
a b
co oznacza: umieść na stosie zawartości pamięci o adresach a i b, następnie zdejmij ze stosu dwie wartości, dodaj je, a wynik umieść z powrotem na stosie. Translacja przypisań na język maszyny stosowej odbywa się w następujący sposób: /-wartość identyfikatora, do którego następuje przypisanie, jest umieszczana na stosie, wyrażenie jest obliczane i na koniec jego r-wartość jest przypisywana do identyfikatora. Przykładowo, przypisanie dzień:=(1461*r)
div
4+ ( 1 5 3 * m + 2 )
div
5+d
(2.17)
jest tłumaczone na kod z rys. 2.32.
lvalue dzi n push 1461 rvalue r * push 4 div push 153 rvalue m
push 2 + push 5 div +
rvalue d +
Rys. 2.32. Translacja przypisania dzień:-(1461*r) div 4+(153*m+2) div 5+d
Uwagi te można wyrazić ogólnie w następujący sposób. Każdy nieterminal ma artybut ł zawierający jego translację. Atrybut symbleks dla id zawiera napis z nazwą identyfikatora instr -> id : = wyr { instr.t := ' lvalue'
id.symbleks
|| wyr.t
'}
Przepływ sterowania Jeśli w kodzie maszyny stosowej nie ma instrukcji skoków warunkowych lub bezwa runkowych, to rozkazy są wykonywane po kolei. Miejsce docelowe skoków może być wyspecyfikowane kilkoma sposobami: 1. 2. 3.
Argument rozkazu zawiera adres skoku. Argument rozkazu zawiera względną odległość skoku (dodatnią lub ujemną). Cel skoku ma nazwę symboliczną (czyli maszyna musi rozumieć etykiety).
Pierwsze dwa sposoby mogą również pobierać adres względny lub bezwzględny ze stosu. Wybieramy trzeci sposób, ponieważ takie generowanie skoków jest najprostsze. Po nadto, adresy symboliczne nie muszą być zmieniane, jeśli po wygenerowaniu kodu dla maszyny stosowej uczynimy pewne poprawki w kodzie, polegające na dodaniu lub usu nięciu rozkazów. Rozkazy przepływu sterowania maszyny stosowej są następujące: label / goto / gofalse / gotrue / halt
cel skoków do etykiety /; nie ma innych skutków działania wykonuje skok do label / pobiera wartość ze stosu; skok, gdy jest ona równa 0 pobiera wartość ze stosu; skok, gdy jest ona różna od 0 zatrzymuje wykonanie
Translacja instrukcji Na rysunku 2.33 przedstawiono schemat kodu dla abstrakcyjnej maszyny stosowej dla instrukcji if i while. Poniższy opis dotyczy głównie tworzenia etykiet.
WHILE
IF
label test Kod dla wyr
Kod dla wyr
gofalse wyjście
gofalse wyjście
Kod dla instry
Kod dla instr\
label wyjście
goto test label wyjście
Rys. 2.33. Schemat kodu dla instrukcji if i while
Przyjrzyjmy się schematowi kodu dla instrukcji if. W programie wynikowym może się znajdować jedynie jedna etykieta l a b e l wy j ś c i e , w innym przypadku pojawi się niejednoznaczność, który kod powinien zostać wykonany po skoku g o t o w y j ś c i e . Potrzebny więc będzie jakiś mechanizm pozwalający na wymianę etykiety w y j ś c i e w schemacie kodu na unikalną etykietę dla każdej instrukcji if. Załóżmy, że nowa-etykieta jest procedurą zwracającą nowo utworzoną unikalną etykietę. W poniższej akcji semantycznej, etykieta zwrócona przez nowa-etykieta jest zapisywana w lokalnej zmiennej wyjście: instr —> if wyr then instr
{ wyjście nowa-etykieta; instr.t := wyr.t \\ ' g o f a l s e ' wyjście || instr t || ' l a b e l ' wyjście }
{
(2.18)
v
Generowanie wyniku translacji Translatory wyrażeń z podrozdziału 2.5 używały instrukcji print do generacji translacji wyrażeń. Podobne instrukcje mogą być wykorzystane do translacji instrukcji. Zamiast instrukcji print użyjemy jednak procedury emituj, która ukrywa detale związane z sa mym drukowaniem, jak na przykład podział rozkazów kodu maszynowego na oddzielne wiersze. Przy użyciu procedury emituj, możemy zapisać następująco (zamiast (2.18)): instr —> if wyr
{ wyjście := nowa-etykieta;
then instr {
r
{ emituj(
l a b e l ' , wyjście)
emitujC g o f a l s e ' , wyjście);
}
}
Gdy w produkcji pojawiają się akcje, elementy po jej prawej stronie są rozważane od lewej do prawej. Dla powyższej produkcji akcje są wykonywane w następującej kolej ności: najpierw akcje zawarte w wyprowadzeniu wyr, potem do wyjście jest wpisywana nowa etykieta, emitowany jest rozkaz g o f a l s e , następnie są wywoływane akcje z wy prowadzenia instr i na koniec jest emitowany rozkaz wyjście. Przy założeniu, że akcje podczas wyprowadzania wyr i instr^ generują kod dla tych nieterminali, powyższa pro dukcja implementuje schemat kodu z rys. 2.33. {
Pseudokod do translacji przypisania i instrukcji warunkowej jest przedstawiony na rys. 2.34. Ponieważ zmienna wyjście jest zmienną lokalną w instr, więc jej wartość nie jest zmieniana podczas wywołań procedur wyr i instr. Generacja etykiet wymaga jeszcze kilku uwag. Załóżmy, że etykiety mają postać LI, L2, . . . . Takie etykiety w pseudokodzie są generowane za pomocą litery L i kolejnych liczb całkowitych. Jeśli wyjście jest zadeklarowane jako liczba całkowita i nowa-etykieta generuje liczbę całkowitą, to emituj musi być tak napisane, aby generowało odpowiednią etykietę na podstawie tej liczby całkowitej. Schemat kodu dla while z rys. 2.33 może zostać przekształcony w podobny spo sób. Translacja sekwencji instrukcji jest po prostu złączeniem translacji poszczególnych instrukcji w tej sekwencji i zostawiamy ją do wykonania Czytelnikowi.
procedurę instr; var test, wyjście: integer; / * dla etykiet * / begin if bieżący = id then begin emituj( lvalue', lekswart); wczytaj{\d); end else if bieżący = ' if' then begin r
wczytajC
:-');
wyr
wczytajC if'); wyr; wyjście := nowa-etykieta;
emitujC gofalse', wyjście); wczytajC then'); instr; emitujC
label', wyjście)
end / * w tym miejscu powinien być kod dla pozostałych instrukcji * / else error;
Rys. 2.34. Pseudokod do translacji instrukcji
Translacja większości konstrukcji z pojedynczym wejściem i pojedynczym wyjściem jest podobna do translacji instrukcji while. Przedstawimy ją, przyglądając się przepływowi sterowania w wyrażeniach. Przykład 2.10.
Analizator leksykalny z p. 2.7 zawierał wyrażenia warunkowe o postaci
if t = spacja or r = tab then - • • Jeśli t jest spacją, to nie ma sensu sprawdzać, czy t jest znakiem tabulacji, ponieważ j u ż z pierwszego porównania wynika, że wyrażenie jest prawdą. Wyrażenie wyr
x
or wyr
2
może zostać zaimplementowane jako if wyr
then true else wyr
{
2
Czytelnik może sprawdzić, że poniższy kod implementuje operator or kod dla wyr
x
copy gotrue wyjście pop kod dla wyr label wyjście
/ * kopia wartości wyr
*/
{
/ * zdejmij wartość wyr
x
*/
2
Przypomnijmy, że rozkazy g o t r u e i g o f a l s e , w celu uproszczenia generacji kodu dla wyrażeń warunkowych i instrukcji while, zdejmują wartość z wierzchołka stosu. Kopiując wartość wyr , zapewniamy, że wartość w wierzchołku stosu jest równa true, gdy rozkaz g o t r u e powoduje skok. • x
2.9
Połączenie technik
Przedstawiliśmy kilka technik sterowanych składnią do tworzenia przodu kompilatora. Aby podsumować poznane techniki, utworzymy program w języku C, będący translato rem z postaci infiksowej do postfiksowej dla języka składającego się z ciągu wyrażeń zakończonych średnikami. Wyrażenia składają się z liczb, identyfikatorów i operatorów +, - , *, / , d i v i mod. Wynikiem translatora będą wszystkie wyrażenia w notacji postfik sowej. Translator ten będzie rozszerzeniem programów napisanych w p. 2.5-2.7. Wydruk całości programu znajduje się na końcu tego podrozdziału.
Opis translatora Na rysunku 2.35 przedstawiono schemat translacji sterowanej składnią, będący projektem translatora. Symbol leksykalny id reprezentuje niepustą sekwencję liter i cyfr zaczyna jącą się od litery, liczba jest sekwencją cyfr, a koniec znakiem końca pliku. Symbole leksykalne są rozdzielone sekwencjami spacji, znaków tabulacji i nowych wierszy („białe znaki"). Atrybut leksem symbolu id zawiera ciąg znaków tworzący ten symbol; atrybut wartość symbolu liczba zawiera jego wartość liczbową.
start —>• lista koniec lista —> wyr ; lista Ic wyr —> wyr + skl | wyr - skł | skł skł ~> skł + czyn | skł - czyn | skł div czyn | skł mod czyn I czyn czyn —> ( wyr ) | id | liczba
{ print(' + ') } { print(' -')} { print(' *') } { printC /') } { print(' D I V ) } { print(' MOD') }
{ print(id. leksem) } { pnm(liczba, wartość) }
Rys, 2.35. Specyfikacja translatora z notacji infiksowej na postfiksową
Kod dla tego translatora jest podzielony na siedem modułów zapisanych w oddziel nych plikach. Wykonywanie zaczyna się od modułu m a i n . c zawierającego wywołanie procedury inicjującej i n i t ( ) oraz procedury p a r s e r () przeprowadzającej translację. Pozostałe sześć modułów przedstawiono na rys. 2.36. Plik nagłówkowy g l o b a l . h za wiera elementy wspólne dla więcej niż jednego modułu. Pierwsza instrukcja w każdym module #include
"global.h"
powoduje dołączenie pliku nagłówkowego jako części modułu. Przed omówieniem kodu translatora, pokrótce przedstawimy wszystkie moduły i ich konstrukcję.
Wyrażenia infiksowe
i lekser.c
init.c
ł
/
parser.c
tabsym.c
^
error.c
emiter.c
T Wyrażenia postfiksowe Rys. 2.36. Moduły translatora z notacji infiksowej na postfiksową Moduł analizy leksykalnej l e k s e r . c Procedura analizatora leksykalnego nazywa się 1 e k s e r () i jest wywoływana przez ana lizator składniowy do pobierania symboli leksykalnych. Procedura, zaimplementowana na podstawie pseudokodu z rys. 2.30, wczytuje z wejścia pojedyncze znaki i zwraca j e ana lizatorowi składniowemu. Wartość atrybutu przypisanego leksemowi jest przekazywana przez zmienną globalną l e k s w a r t . Analizator składniowy spodziewa się otrzymać następujące symbole: + -
* /
DIV MOD ( )
ID LICZBA KONIEC
ID reprezentuje identyfikator, LICZBA liczbę, a KONIEC znak końca pliku. Białe znaki są przez analizator leksykalny usuwane. W tablicy na rys. 2.37 są symbole leksykalne i ich atrybuty generowane przez analizator leksykalny.
CIĄG ZNAKÓW
białe znaki sekwencja cyfr div mod inne sekwencje liter i cyfr zaczynające się od litery . znak końca pliku inne znaki
SYMBOL
W A R T O Ś Ć ATRYBUTU
LICZBA DIV MOD
numeryczna wartość sekwencji
ID KONIEC ten znak
indeks do tablicy symboli BRAK
Rys. 2.37. Opis symboli leksykalnych
Analizator leksykalny z modułu obsługującego tablicę symboli wykorzystuje pro cedurę z n a j d ź do wyznaczenia, czy dany identyfikator był już wcześniej widziany, i procedurę d o d a j zapisującą nowy leksem w tablicy symboli. Dodatkowo, analiza tor leksykalny zwiększa zmienną n r w i e r s z a za każdym razem, kiedy wczytuje znak nowego wiersza.
Moduł analizatora składniowego p a r s e r . c Analizator składniowy jest tworzony przy użyciu technik opisanych w p . 2.5. Najpierw, ze schematu translacji z rys. 2.35, jest eliminowana rekurencją lewostronna w taki sposób, aby gramatyka z tego schemtu mogła być analizowana metodą zejść rekurencyjnych. Przetworzony schemat jest pokazany na rys. 2.38.
start -» lista koniec lista —> wyr ; lista Ic wyr —> skł resztaskł resztaskł —• + skł { print(' +') } resztaskł | - skł { print(' -') } resztaskł
I
e
skł —> czyn resztaczyn resztaczyn —• * czyn { print( *')} resztaczyn | / czyn { print(' /') } resztaczyn | div czyn { print( D I V ) } resztaczyn | mod czyrt { print('MOD') } resztaczyn Ic czy/i — > ( w y r ) | id { print (id. leksem) } | liczba { p r r ó / ( l i c z b a wartość) } Rys. 2.38. Schemat translacji po eliminacji rekurencji lewostronnej r
r
Następnie są konstruowane funkcje dla nieterminali wyr, skł i czyn, podobnie jak na rys. 2.24. Funkcja p a r s e r () stanowi implementację symbolu startowego gramatyki. Wywołuje ona funkcję l e k s e r do pobierania nowych leksemów. Analizator składniowy używa funkcji e m i t u j do wygenerowania wyjścia i funkcji e r r o r do zgłaszania błędów składni.
Moduł emitera e m i t e r . c Moduł emitera zawiera funkcję e m i t u j ( s , t w a r t )
generującą wyjście dla symbolu
leksykalnego s z wartością atrybutu s w a r t .
Moduły tablicy symboli symbol. c i i n i t . c Moduł tablicy symboli s y m b o l . c implementuje struktury danych pokazane na rys. 2.29. Pozycje w tablicy t a b s y m są parami składającymi się ze wskaźnika tablicy l e k s e m y i liczby całkowitej oznaczającej zapamiętany symbol leksykalny. Operacja d o d a j ( s , t ) zwraca pozycję w tablicy t a b s y m dla Ieksemu s tworzącego symbol leksykalny t . Funkcja z n a j d ź ( s ) zwraca pozycję w tablicy t a b s y m dla Ieksemu s lub 0, jeśli s nie ma w tablicy. Tablica t a b s y m jest wypełniana początkowymi słowami kluczowymi przez moduł i n i t . c. Symbole leksykalne dla wszystkich słów kluczowych i napisy, z których są
one zbudowane, są zapisywane w tablicy s l o w a k l u c z , takiego samego typu jak t a b sym. Funkcja i n i t () przegląda tablicę s l o w a k l u c z i przy użyciu funkcji d o d a j dodaje słowa kluczowe do tablicy symboli. To przemieszczenie umożliwia przejście do wygodniejszej reprezentacji symboli leksykalnych dla słów kluczowych. Moduł obsługi błędów e r r o r . c Moduł obsługi błędów jest bardzo prosty, a umożliwia zgłaszanie błędów. Po napotkaniu błędu składni, kompilator drukuje komunikat o tym, w którym wierszu pojawił się błąd, po czym zatrzymuje się. Lepsza technika powodowałaby przejście do najbliższego śred nika i dalszą analizę. Proponujemy Czytelnikowi samodzielne wykonanie odpowiednich modyfikacji. Bardziej zaawansowane techniki zgłaszania błędów są omówione w rozdz. 4. Utworzenie kompilatora Kod kompilatora znajduje się w siedmiu plikach: l e k s e r . c, p a r s e r . c, e m i t e r . c, s y m b o l , c, i n i t . c , e r r o r . c i m a i n . c . Plik m a i n . c zawiera główną funkcję, m a i n ( ) , która wywołuje i n i t ( ) , następnie p a r s e r () i kończy działanie programu, wywołując e x i t ( 0 ) . W systemie UNIX kompilator może zostać utworzony po wywołaniu polecenia cc
lekser.c
parser.c
emiter.c
symbol.c
init.c
error.c
init.o
error.o
main. c lub kompilując wszystkie pliki, osobno poleceniem cc
- c nazwa . c
i konsolidując pliki wynikowe nazwa. cc
lekser.o
parser.o
o
emiter.o
symbol.o
main. o Polecenie c c tworzy plik a . o u t zawierający translator. Translator może zostać spraw dzony po wywołaniu polecenia a . o u t i wpisaniu wyrażeń do translacji, na przykład 2+3*5; 12 d i v
5 mod
2;
lub innych wyrażeń. Spróbuj sam. Wydruk programu Poniżej znajduje się wydruk translatora składający się z jednego pliku nagłówkowego g l o b a l . h oraz siedmiu plików źródłowych. Aby zachować przejrzystość, program jest napisany w podstawowym stylu języka C. /•***
global .h
***********
*************************/
tinclude <stdio.h> #include
/* /*
funkcje we/wy */ funkcje operujące na znakach
#define BROZM
/*
rozmiar bufora
128
*/
*/
#define BRAK #define EOS #define #define #define #define #define
LICZBA DIV MOD ID KONIEC
1
\0 256 257 258 259 260 /*
int lekswart; int nrwiersza;
wartość atrybutu symbolu leksykalnego */
/*
struct tabsymelem { char *leksem; int symbleks;
element tablicy symboli
*/
}; struct tabsymelem tabsym[];
/****
lekser.c
/*
tablica symboli
*/
**************************************
#include "global.h" char leksbuf[BROZM]; int nrwiersza = 1; int lekswart = BRAK; int
lekser ()
/*
analizator leksykalny
*/
{ int t; while (1) { t = getchar (); if (t == ' ' ]| t == '\t') ; /* ignoruj białe znaki */ else if (t ~ '\n') nrwiersza = nrwiersza + 1; else if (isdigit (t)) { /* t jest cyfrą */ ungetc (t, stdin); scanf("%d", &lekswart); return LICZBA;
} else if (isalpha(t)) { int p, b = 0; while (isalnum(t)) { leksbuf[bj = t; t = getchar (); b = b + 1; if (b >= BROZM)
/* t jest literą */ /* t jest literą lub cyfrą */
error("błąd kompilatora");
} leksbuf[b] = EOS; if (t != EOF) ungetc(t, stdin); p = znajdź(leksbuf); if (p « 0) p = dodaj(leksbuf, ID); lekswart = p; return tabsym[p].symbleks;
} else if (t == EOF ) return KONIEC; else { lekswart = BRAK; return t;
} } } /•***
parser. c
#include "global.h" int bieżący; parser ()
/*
przetwarza listę wyrażeń
*/
{ bieżący = lekser (); while (bieżący != KONIEC) { wyr (); wczytaj(';');
} } wyr ()
{ int s; skl () ; while (1) switch (bieżący) { case '+': case ' : s = bieżący; wczytaj (bieżący); skl(); emituj(s, BRAK); continue; default: return;
} } skl ()
{ int s; czyn (); while (1) switch (bieżący) { case ' *' : case '/': case DIV: case MOD: s = bieżący; wczytaj(bieżący) ; czyn(); emituj(s, BRAK); continue; default: return;
} } czyn()
{ switch(bieżący) { case ' (' : wczytaj C C ) ; wyr () ; wczytaj (')'); break; case NUM: emituj(NUM, lekswart); wczytaj(NUM); break; case ID: emituj(ID, lekswart); wczytaj (ID); break; default: error("błąd składni") ;
} } wczytaj(s) int s;
{ if (bieżący == s) bieżący = lekser() ; else error("błąd składni");
} /**+*
emiter. c
**************************************/
#include "global.h" emituj(s, swart) /* int s, swart;
generuje wyjście
*/
{ switch(s) { case '+': case case '*': case '/': printf("%c\n", t ) ; break; case DIV: printf("DIV\n"); break; case MOD: printf("MOD\n"); break;
case LICZBA: printf("%d\n", swart); break; case ID: printf("%s\n", tabsym[swart].nazwa); break; default: printf("leksem %d, wartość %d\n", s, swart);
} } /+***
symbol.c
**************************************/
#include "global.h" tdefine LEKSMAX 999 #define SYMMAX 100
/* rozmiar tablicy leksemy */ /* rozmiar tablicy symboli */
char leksemy[LEKSMAX]; int ostatniznak = - 1 ; /* ostatnia pozycja w tablicy leksemy struct tabsymelem tabsym[SYMMAX]; int ostatnielem = 0; /* ostatnia pozycja w tabsym */ int
znajdź(s) char s[];
/* zwraca pozycję dla s */
{ int p; for (p = ostatnielem; p > 0; p = p - 1) if (strcmp(tabsym[p].leksem, s) == 0) return p; return 0;
} int
dodaj(s, symbleks) char s[]; int symbleks;
/* zwraca pozycję dla s */
{ int dług; dług = strlen(s); /* strlen oblicza długość s */ if (ostatnielem + 1 >= SYMMAX) error("tablica symboli jest pełna"); if (ostatniznak + dług + 1 >= LEKSMAX) error ("tablica symboli jest pełna"); ostatnielem = ostatnielem + 1; tabsym[ostatnielem].symbleks = symbleks; tabsym[ostatnielem].leksem = &leksemy[ostatniznak + 1 ] ; ostatniznak = ostatniznak + dług + 1; strcpy(tabsym[ostatnielem] .leksem, s) ; return ostatnielem;
}
init.c
*******************#********************/
finclude "global.h" struct tabsymelem slowaklucz[] = { "div", DIV, "mod", MOD,
0,
0
}; init ()
/*
dodaj słowa kluczowe do tablicy symboli
*/
{ struct tabsymelem *p; for (p = slowaklucz; p->symbleks; p++) dodaj(p->leksem, p->symbleks);
} /***#
error.c
***************************************/
#include "global.h" error(m) /* char *m;
generuje wszystkie komunikaty o błędach
*/
{ fprintf{stderr, "linia %d: %s\n", nrwiersza, m ) ; exit(l); /* zakończenie programu z błędem */
} main. c #include "global.h" main () { init () ; parser(); exit (0);
/*
zakończenie programu z sukcesem
*/
}
ĆWICZENIA 2.1 Rozważmy gramatykę bezkontekstową S-+SS+\SS*\a a) [a)] Pokaż, że napis aa+a* może zostać wygenerowany przez tę gramatykę. b) Skonstruuj drzewo wyprowadzenia dla tego napisu. c) Jaki język jest generowany przez tę gramatykę? Uzasadnij odpowiedź.
75
ĆWICZENIA 2.2 Jakie języki generują poniższe gramatyki? Uzasadnij każdą odpowiedź. a) 5 ^ 0 5 1 | 0 1 b)S-^+SS\-SS\a c) S -> S ( S ) S | c d ) S - » a S b S | b S a S | e e)S->a|S + S | S S | S * |
(5)
2.3 Która z gramatyk z ćwiczenia 2.2 nie jest jednoznaczna? 2.4 Skonstruuj jednoznaczne gramatyki bezkontekstowe dla każdego z poniższych ję zyków. W każdym przypadku wykaż poprawność gramatyki. a) b) c) d)
Wyrażenia arytmetyczne w notacji postfiksowej. Lewostronnie łączna lista identyfikatorów oddzielonych przecinkami. Prawostronnie łączna lista identyfikatorów oddzielonych przecinkami. Wyrażenia arytmetyczne na liczbach całkowitych i identyfikatorach z czterema operatorami +, - , * oraz / .
e) Dodaj jednoargumentowe operatory plus i minus do operatorów arytmetycznych z punktu d). *2.5 a) Pokaż, że wszystkie liczby binarne generowane przez poniższą gramatykę są podzielne przez 3. Podpowiedz: użyj indukcji ze względu na liczbę węzłów w drzewie wyprowadzenia. liczba —• 1 1 | 10 0 1 | liczba 0 | liczba
liczba
b) Czy ta gramatyka generuje wszystkie liczby binarne o wartościach podzielnych przez 3? 2.6 Skonstruuj gramatykę bezkontekstową dla liczb rzymskich. 2.7 Skonstruuj schemat translacji sterowanej składnią, tłumaczący wyrażenia z notacji infiksowej na notację prefiksową, w której operatory pojawiają się przed argumen tami. Przykładowo, —xy jest notacją prefiksową dla x — y. Podaj i opisz drzewo wyprowadzenia dla ciągów wejściowych 9-5+2 i 9-5*2. 2.8 Skonstruuj schemat translacji sterowanej składnią, tłumaczący wyrażenia arytme tyczne z notacji postfiksowej na notację infiksową. Podaj i opisz drzewa wyprowa dzenia dla ciągów wejściowych 95-2* i 952*-. 2.9 Skonstruuj schemat translacji sterowanej składnią, tłumaczący liczby całkowite na liczby rzymskie. 2.10 Skonstruuj schemat translacji sterowanej składnią, tłumaczący liczby rzymskie na liczby całkowite. 2.11 Skonstruuj metodą zejść rekurencyjnych analizatory składniowe dla gramatyk z ćwi czenia 2.2 a), b), c). 2.12 Skonstruuj translator sterowany składnią, sprawdzający, czy nawiasy w ciągu wej ściowym są zrównoważone. 2.13 Poniższe zasady definiują translację słów angielskich na „świńską łacinę". a) Jeśli słowo zaczyna się niepustym ciągiem spółgłosek, przenieś ten ciąg na koniec słowa i dodaj końcówkę AY. Na przykład p i g staje się i g p a y . b) Jeśli słowo zaczyna się samogłoską, dodaj końcówkę YAY. Na przykład o w i staje się o w l y a y .
c) U znajdujące się za Q jest traktowane jako spółgłoska. d) Y na początku słowa jest traktowane jako samogłoska, jeśli nie występuje po niej inna samogłoska. e) Słów jednoliterowych nie należy zmieniać. Skonstruuj schemat translacji sterowanej składnią dla „świńskiej łaciny". 2.14 W języku programowania C instrukcja for ma postać: for
( wyr
}
; wyr
2
; wyr^ ) instr
Pierwsze wyrażenie jest wykonywane przed pętlą i zwykle jest wykorzystywane do inicjowania zmiennej indeksowej pętli. Drugie wyrażenie jest testem wykony wanym przed każdym przebiegiem pętli. Wartość 0 powoduje zakończenie pętli. Sama pętla składa się z instrukcji {instr w y r ; } . Trzecie wyrażenie jest wykony wane pod koniec każdej iteracji i zwykle jest używane do zwiększania zmiennej indeksowej. Znaczenie instrukcji for jest podobne do wyr ; w h i l e ( wyr ) { instr wyr^ ; } 3
}
2
Skonstruuj schemat translacji sterowanej składnią, tłumaczący instrukcję for na kod maszyny stosowej. *2.15 Rozważ następującą instrukcję for: for i := 1 step 10 - j until 10* j do j := j -ł-1 Do zdefiniowania semantyki tej instrukcji można użyć jednej z trzech definicji. Jedną z możliwości jest jednorazowe obliczenie ograniczenia 10*/ i przyrostu 10 — j przed wykonaniem pętli, jak w języku PL/I. Przykładowo, jeśli przed pętlą j — 5, to pętla wykona się 10 razy i zakończy. Drugą, całkowicie inną możliwością jest obliczanie ograniczenia i przyrostu za każdym razem w trakcie działania pętli. Jeśli na początku, na przykład, j = 5, to pętla nigdy się nie zakończy. Trzecie znaczenie jest stosowane w takich językach, jak Algol. Gdy przyrost jest ujemny, test zakończenia pętli polega na sprawdzeniu, czy i < 10*j, zamiast i > 10*y. Dla każdej z tych trzech definicji semantycznych skonstruuj schemat translacji sterowanej składnią, tłumaczący tę pętlę na język maszyny stosowej. 2.16 Rozważ następujący fragment gramatyki dla instrukcji if-then i if-then-else: instr —>• if wyr then instr | if wyr then instr else instr | inne gdzie inne oznacza inne instrukcje języka a) Pokaż, że ta gramatyka nie jest jednoznaczna. b) Skonstruuj równoważną gramatykę jednoznaczną, która łączy każdą instrukcję else z najbliższym niezłączonym jeszcze then. c) Skonstruuj schemat translacji sterowanej składnią, bazujący na tej gramatyce, tłumaczący powyższe instrukcje na kod maszyny stosowej. *2.17 Skonstruuj schemat translacji sterowanej składnią dla wyrażeń arytmetycznych w notacji infiksowej, tłumaczący to wyrażenie również na notację infiksową, ale bez wypisywania niepotrzebnych nawiasów. Narysuj drzewo wyprowadzenia dla wej ścia (((l+2)*(3*4))+5).
UWAGI BIBLIOGRAFICZNE
77
ĆWICZENIA P R O G R A M I S T Y C Z N E P2.1 P2.2 P2.3 P2.4 P2.5
Zaimplementuj translator liczb całkowitych na liczby rzymskie, bazujący na sche macie translacji z rozwiązania ćwiczenia 2.9. Zmodyfikuj translator z p. 2.9 tak, aby tworzył kod dla abstrakcyjnej maszyny stosowej opisanej w p. 2.8. Zmodyfikuj moduł obsługi błędów translatora z p. 2.9 tak, aby p o napotkaniu błędu przechodził do następnego wyrażenia. Rozszerz translator z p . 2.9 o obsługę wyrażeń z języka Pascal. Rozszerz kompilator z p. 2.9 o translację na kod maszyny stosowej instrukcji generowanej przez poniższą gramatykę: instr —> id : = wyr | if wyr then instr | while wyr do instr | begin opc- instr end opc_instr —> lista,-instr \ e lista-instr —> lista_instr ; instr \ instr
*P2.6 Skonstruuj zestaw wyrażeń testowych dla kompilatora z p . 2.9 tak, aby każda produkcja była wykorzystywana co najmniej raz w tych wyrażeniach. Skonstruuj program testujący, który może zostać wykorzystany jako ogólne narzędzie do te stowania kompilatorów. Użyj tego programu do sprawdzenia swojego kompilatora na tych wyrażeniach testowych. P2.7 Skonstruuj zestaw instrukcji testowych dla kompilatora z ćwiczenia P2.5 tak, aby każda produkcja była wykorzystywana co najmniej raz w tych instrukcjach. Użyj programu testującego z ćwiczenia P2.6 do sprawdzenia swojego kompilatora na tych instrukcjach testowych.
UWAGI B I B L I O G R A F I C Z N E W tym rozdziale zasygnalizowaliśmy wiele tematów, które dokładniej omówiliśmy w roz działach następnych; tam też znajdują się odnośniki do literatury. Gramatyki bezkontekstowe zostały wprowadzone przez C h o m s k y ' e g o [1956] w trak cie badań nad językami naturalnymi. Ich wykorzystanie w składni języków programo wania pojawiło się niezależnie. John Backus podczas pracy nad zarysem Algola 6 0 „za adaptował w pośpiechu [pracę Emila Posta]" (Wexełblat [1981, s. 162]). Powstała notacja była odmianą gramatyki bezkontekstowej. Między rokiem 4 0 0 a 200 p.n.e. uczony Panini użył odpowiednika notacji składni do specyfikacji zasad gramatyki sanskrytu (Ingerman [1967]). W liście Knutha [1964] zawarta jest propozycja, by B N F — skrót od Postaci Nor malnej Backusa (ang. Backus Normal Form) — czytane jako Postać Backusa-Naura (ang. Backus Naur Form), aby podkreślić wkład Naura w raport Algola 60 (Naur [1963]). Definicje sterowane składnią są formą definicji indukcyjnych, w których indukcję stosuje się po strukturze składniowej. Jako takie były długo używane w matematyce. Ich zastosowanie w językach programowania zaczęło się od użycia gramatyki służącej do
opisu struktury raportu Algola 60. Wkrótce potem Irons [1961] skonstruował kompilator sterowany składnią. Metoda zejść rekurencyjnych jest używana od początku łat 60. Bauer [1976] przy pisuje tę metodę Lucasowi [1961]. Hoare [1962b, s. 128] opisał kompilator Algola zor ganizowany jako „zbiór procedur, z których każda służy do przetwarzania pojedynczej jednostki składniowej z raportu Algola 60". Foster [1968] omówił eliminację lewostronnej rekurencji z produkcji zawierających akcje semantyczne, które nie modyfikują wartości atrybutów. McCarthy [1963] zalecił, aby translatory języków bazowały na składni abstrakcyj nej. W tej samej pracy McCarthy [1963, s. 24] pozostawił „Czytelnikowi samodzielne upewnienie się", że rekurencją końcowa w funkcji liczącej silnię odpowiada programowi iteracyjnemu. Zalety podziału kompilatora na przód i tył były badane przez Stronga i innych [1958]. W raporcie wprowadzili nazwę UNCOL (uniwersalny język komputerowy, ang. universal computer oriented language) dla uniwersalnego języka pośredniego. Ten pomysł jest wciąż ideałem. Dobrą metodą nauki technik implementacyjnych jest czytanie kodu istniejących kom pilatorów. Niestety, kod taki często nie jest publikowany. Randell i Russell [1964] po dali wyczerpujący opis wczesnego kompilatora Algola. Kod kompilatora można również poznać z pracy McKeemana, Horninga i Wortmana [1970]. Książka Barrona [1981] jest zbiorem dokumentów opisujących implementację Pascala. Zawarte są w nich uwagi 0 implementacji w kompilatorze Pascala P (Nori i inni [1981]), detale generacji kodu (Ammann [1977]) i kod implementacji Pascala S (podzbioru Pascala zaprojektowane go przez Wirtha [1981] dla studentów). Knuth [1985] przedstawił niezwykle przejrzysty 1 dokładny opis translatora Tj^C. Kernighan i Pike [1984] dokładnie opisali, jak zbudować program kalkulatora stoło wego na podstawie schematu translacji sterowanej składnią przy użyciu narzędzi do kon strukcji kompilatorów z systemu UNIX. Równanie (2.17) pochodzi od Tantzena [1963].
ROZDZIAŁ
Analiza leksykalna
W rozdziale tym omówiliśmy metody specyfikacji i implementacji analizatorów leksykal nych. Prostym sposobem budowy takich analizatorów jest skonstruowanie diagramu dla struktury symboli leksykalnych języka źródłowego i mozolne tłumaczenie tego diagramu na kod programu. W ten sposób mogą być tworzone wydajne analizatory. Techniki używane w implementacji analizatorów leksykalnych można również za stosować w innych dziedzinach, jak języki zapytań i systemy wyszukiwania informacji. We wszystkich aplikacjach zasadniczym problemem jest specyfikacja i zaprojektowanie modułu, który na podstawie wzorców zbudowanych z ciągów znaków wywołuje odpo wiednie akcje. Ponieważ programowanie oparte na wzorcach jest użyteczne, przedstawi my język oparty na wzorcach i akcjach, zwany Lex, służący do specyfikacji analizatorów leksykalnych. W języku tym, wzorce są specyfikowane za pomocą wyrażeń regularnych. Kompilator Leksa generuje na ich podstawie wydajny automat skończony, rozpoznający te wyrażenia regularne. Kilka innych języków wykorzystuje wyrażenia regularne do opisów wzorców. Przy kładami są język AWK i powłoka systemu UNIX. Język AWK, służący do wyszukiwania wzorców, stosuje wyrażenia regularne do wyboru wiersza z wejścia do przetwarzania. Powłoka systemu UNIX pozwala użytkownikowi na wybór plików na podstawie wyraże nia regularnego, na przykład polecenie rm * . o powoduje usunięcie wszystkich plików o nazwach kończących się na „ . o " . 1
Narzędzia programistyczne, które automatyzują konstrukcję analizatorów leksykal nych, umożliwiają osobom posiadającym różne umiejętności użycie dopasowywania wzor ców we własnych aplikacjach. Na przykład Jarvis [1976] użył generatora analizatorów leksykalnych do stworzenia programu rozpoznającego niedoskonałości w drukowanych płytkach z obwodami elektrycznymi. Obwody te są skanowane cyfrowo i przekształca ne na „łańcuchy" segmentów wierszy znajdujących się pod różnymi kątami. Analizator szukał wzorców odpowiadających niedoskonałościom w łańcuchu segmentów. Podsta wową zaletą generatora analizatorów leksykalnych jest możliwość wykorzystania dobrze znanego dopasowywania wzorców; pozwala to na stworzenie wydajnego analizatora lek sykalnego przez osoby nie będące ekspertami w technikach dopasowywania wzorców.
1
Wyrażenie *. o jest odmianą zwykłej notacji wyrażeń regularnych. W ćwiczeniach 3.10 i 3.14 wspomnieliśmy o niektórych wariantach tych notacji.
3.1
Rola analizatora leksykalnego
Analizator leksykalny stanowi pierwszą fazę kompilatora. Jego głównym zadaniem jest czytanie znaków z wejścia i produkcja sekwencji symboli leksykalnych do analizy skła dniowej. To oddziaływanie, przedstawione schematycznie na rys. 3.1, jest najczęściej implementowane w taki sposób, że analizator leksykalny jest podprogramem lub współprogramem analizatora składniowego. Po otrzymaniu polecenia „daj następny symbol leksykalny" od analizatora składniowego, analizator leksykalny czyta znaki z wejścia, aż uda mu się zidentyfikować następny symbol leksykalny.
Program źródłowy
Analizator leksykalny
leksykalny _
Analizator składniowy
Daj następny symbol leksykalny
Rys. 3.1. Oddziaływanie między analizatorem leksykalnym i składniowym
Analizator leksykalny jest częścią kompilatora czytającą tekst źródłowy; może on więc wykonywać pewne dodatkowe zadania związane z interfejsem użytkownika. Jed nym z zadań może być pomijanie z pliku wejściowego komentarzy i białych znaków, czyli odstępów, znaków tabulacji i nowych wierszy. Innym rodzajem zadania jest dopasowy wanie komunikatów o błędach do miejsca w kodzie źródłowym. Analizator leksykalny może, na przykład, śledzić liczbę wczytanych wierszy z wejścia, aby kompilator — razem z ewentualnym komunikatem o błędzie — mógł wyświetlić odpowiedni numer wiersza. W niektórych kompilatorach analizator leksykalny m a za zadanie kopiować program źró dłowy i dodawać do niego komunikaty o błędach. Jeśli język źródłowy udostępnia makra preprocesora, mogą zostać one zaimplementowane w fazie analizy leksykalnej. Czasami analizatory leksykalne są dzielone na dwie fazy, z których pierwsza na zywa się skanowaniem, a druga analizą leksykalną. Skaner jest odpowiedzialny za wy konywanie prostych zadań, a właściwy analizator leksykalny zajmuje się tymi bardziej skomplikowanymi. W kompilatorze Fortranu, na przykład, skaner może zostać użyty do eliminacji odstępów z wejścia.
Zagadnienia analizy leksykalnej Istnieje kilka powodów, dla których część analizy w kompilacji jest rozdzielona na analizę leksykalną i składniową. 1.
Jednym z najważniejszych powodów jest prostota projektowania. Rozdzielenie ana lizy leksykalnej od składniowej pozwala uprościć obie fazy. Na przykład, analizator
2.
3.
składniowy zawierający reguły dotyczące komentarzy i białych znaków jest znacznie bardziej skomplikowany, niż gdyby białe znaki i komentarze były usuwane w ana lizatorze leksykalnym. Jeśli projektujemy nowy język, właściwe rozdzielenie zadań między analizę leksykalną i składniową prowadzi do lepszej konstrukcji całego ję zyka. Poprawia się wydajność kompilatora. Oddzielny analizator leksykalny umożliwia utworzenie bardziej wyspecjalizowanego i przez to bardziej wydajnego modułu ana lizy. Wczytywanie programu źródłowego i rozbijanie go na symbole leksykalne pochłania dużą ilość czasu. Wyspecjalizowane techniki czytania znaków z wejścia i przetwarzania symboli leksykalnych mogą więc znacząco zwiększyć wydajność kompilatora. Zwiększana jest przenośność kompilatora. Osobliwości kodowania znaków wejścio wych i inne anomalie zależne od urządzeń mogą być ograniczone do analizato ra leksykalnego. Reprezentacja specjalnych lub niestandardowych symboli, jak T w Pascalu, może zostać odizolowana od analizatora leksykalnego.
Do automatycznej konstrukcji oddzielonych analizatorów leksykalnego i składniowego wynaleziono wyspecjalizowane narzędzia. W dalszej części tej książki przedstawiliśmy kilka przykładów takich narzędzi. Symbole leksykalne, wzorce, leksemy W trakcie omawiania analizy leksykalnej nazwy symbol leksykalny, wzorzec i leksem będą miały specyficzne znaczenia. Przykłady ich użycia są przedstawione na rys. 3.2. Ogólnie, ten sam symbol leksykalny może zostać wygenerowany z całego zbioru róż nych ciągów znaków wejściowych. Zbiór ten opisuje reguła, zwana wzorcem, związana z symbolem leksykalnym. Mówi się, że wzorzec pasuje do każdego ciągu z tego zbioru. Leksem jest sekwencją pasującą do wzorca tego symbolu leksykalnego. Na przykład, w instrukcji Pascala const
pi
=
3.1416;
ciąg znaków p i stanowi znaki symbolu leksykalnego „identyfikator".
SYMBOL LEKSYKALNY
const if relacja id liczba literał
PRZYKŁADY LEKSEMÓW
const if , >, > =
p i , l i c z n i k , D2 3 . 1 4 1 6 , 0, 6 . 0 2 E 2 3 " c o r e dumped"
NIEFORMALNY OPIS WZORCA
const if < lub lub >= ciąg liter i cyfr z literą na początku dowolna stała liczbowa dowolne znaki między " a " oprócz "
Rys. 3.2. Przykłady symboli leksykalnych
Symbole leksykalne są traktowane jak symbole terminalne gramatyki języka źródło wego, dlatego ich nazwy są drukowane grubszą czcionką. Leksemy pasujące do wzorca
symbolu leksykalnego reprezentują znaki z programu źródłowego, które mogą być trak towane razem jako jednostka leksykalna. W większości języków programowania, symbolami leksykalnymi są następujące konstrukcje: słowa kluczowe, operatory, identyfikatory, stałe, literały znakowe, znaki prze stankowe, jak nawiasy, przecinki, średniki. W powyższym przykładzie instrukcji Pascala, gdy w programie źródłowym pojawi się ciąg znaków p i , do analizatora składniowego jest przekazywany symbol leksykalny reprezentujący identyfikator. Przekazanie symbolu leksykalnego jest implementowane jako przekazanie liczby całkowitej odpowiadającej te mu symbolowi leksykalnemu. W tym przypadku jest to liczba całkowita odpowiadająca symbolowi leksykalnemu id z rys. 3.2. Wzorzec jest regułą opisującą zbiór symboli podstawowych, który może reprezento wać konkretny symbol leksykalny w programie źródłowym. Wzorzec dla symbolu leksy kalnego c o n s t z rys. 3.2 jest po prostu pojedynczym ciągiem znaków c o n s t stanowią cym właściwe słowo kluczowe. Wzorzec dla symbolu leksykalnego relacja jest zbiorem wszystkich sześciu operatorów porównania Pascala. Aby dokładniej opisać wzorce dla bardziej złożonych symboli leksykalnych — jak id (dla identyfikatorów) łub liczba — będziemy wykorzystywać notację wyrażeń regularnych przedstawioną w p. 3.3. Konwencje przyjęte w niektórych językach wpływają na złożoność analizy leksykal nej. W takich językach jak Fortran, niektóre konstrukcje powinny znajdować się na odpo wiednich pozycjach w wierszach wejścia. Zatem położenie Ieksemu może mieć znaczenie przy sprawdzaniu poprawności programu źródłowego. W projektowaniu języków progra mowania istnieje trend w kierunku dowolnego formatowania wejścia, w którym kon strukcje mogą znajdować się na dowolnych pozycjach w wierszu źródłowym. W związku z tym ten aspekt analizy leksykalnej staje się mniej ważny. Sposób traktowania znaków odstępu jest bardzo różny w poszczególnych językach. W niektórych językach, jak Fortran lub Algol 68, odstępy w ciągach znaków nie ma ją znaczenia; można j e dodać w celu poprawienia czytelności programu. Konwencje traktowania znaków odstępu mogą w dużym stopniu skomplikować zadanie identyfikacji symboli leksykalnych. Popularnym przykładem ilustrującym możliwą trudność w rozpoznawaniu symboli leksykalnych jest instrukcja DO w Fortranie. Dla instrukcji
DO 5 I = 1,25 przed odczytaniem kropki dziesiętnej nie jesteśmy w stanie stwierdzić, czy DO jest sło wem kluczowym, czy raczej częścią identyfikatora D05I. Instrukcja
DO 5 I - 1,25 składa się z siedmiu symboli leksykalnych: słowa kluczowego DO, etykiety 5, identyfika tora I, operatora =, stałej 1, przecinka i stałej 2 5. W tym przypadku, przed wczytaniem przecinka nie wiadomo, czy DO jest słowem kluczowym. Aby zlikwidować tę niejedno znaczność, kompilator Fortranu 77 pozwala na wstawienie dodatkowego przecinka mię dzy etykietą a indeksem pętli. Użycie tego przecinka jest wskazane, ponieważ umożliwia zapis pętli DO w bardziej czytelnej postaci. W wielu językach niektóre ciągi znaków są zarezerwowane, tzn. ich znaczenie jest wstępnie zdefiniowane i nie może być zmieniane przez użytkownika. Jeśli słowo klu czowe nie jest zarezerwowane, to analizator leksykalny musi odróżniać słowa kluczowe
od identyfikatorów zdefiniowanych przez użytkownika. W PL/1 słowa kluczowe nie są zarezerwowane, więc reguły odróżniania ich od identyfikatorów są dość skomplikowane. Ilustruje to poniższy przykład
IF THEN THEN THEN = ELSE; ELSE ELSE = THEN; Atrybuty symboli leksykalnych Jeżeli znaki z wejścia pasują do więcej niż jednego wzorca, analizator leksykalny musi mieć dodatkową informację dla dalszych faz kompilacji o konkretnym, właśnie dopa sowanym leksemie. Na przykład, do wzorca dla liczba pasują dwa napisy 0 i 1, ale generator kodu musi znać konkretny dopasowany ciąg znaków. Analizator leksykalny zbiera informacje o symbolach leksykalnych w przypisanych im atrybutach. Symbole leksykalne mają wpływ na decyzje w trakcie analizy składniowej, natomiast atrybuty — na translację symboli leksykalnych. W praktyce, symbole leksykal ne mają zwykle pojedynczy atrybut — wskaźnik pozycji w tablicy symboli, w której jest symbol podstawowy dla tego Ieksemu. Dla celów diagnostycznych może istnieć potrzeba posiadania zarówno informacji o leksemie, jak i numerze wiersza, w którym pojawił się symbol leksykalny w kodzie źródłowym. Obie te informacje mogą być przechowywane w pozycji tablicy symboli. Przykład 3.1. cji Fortranu
Symbole leksykalne i przypisane do nich wartości atrybutów dla instruk
E = M * C ** 2 są następujące: < i d , wskaźnik pozycji w tablicy symboli dla E> < i d , wskaźnik pozycji w tablicy symboli dla M> < i d , wskaźnik pozycji w tablicy symboli dla
O
Zauważmy, że w niektórych parach nie jest potrzebna wartość atrybutu, ponieważ pierw szy element pary całkowicie identyfikuje symbol pierwotny. Symbol leksykalny liczba w przykładzie ma atrybut wyrażający jego wartość. Kompilator może czasem przecho wywać ciągi znaków tworzące liczby w tablicy symboli i wtedy atrybut stanowi wskaźnik pozycji w tablicy symboli. • Błędy leksykalne Mało błędów można dostrzec już w trakcie analizy leksykalnej, ponieważ jednocześnie „widzi" ona bardzo mały fragment programu źródłowego. Jeśli napis f i pojawia się w programie źródłowym po raz pierwszy w kontekście
fi
( a == f(x) ) ...
analizator leksykalny nie jest w stanie stwierdzić, czy f i jest literówką, czy identyfi katorem niezadeklarowanej funkcji. Skoro fi jest poprawnym identyfikatorem, analiza leksykalna musi zwrócić symbol leksykalny dla identyfikatora i błąd zostanie wykryty przez następne fazy. Załóżmy teraz, że analizator leksykalny nie może kontynuować działania, ponieważ żaden ze wzorców dla symboli nie pasuje d o prefiksu pozostałych danych wejściowych. Wydaje się, że najłatwiejszą strategią przy napotkaniu błędu jest „tryb paniki". Polega on na kasowaniu kolejnych znaków z wejścia do znalezienia poprawnego symbolu lek sykalnego. Ta technika może czasem zmylić analizator składniowy, ale w środowisku interakcyjnym jest to właściwe. Innymi możliwymi akcjami podczas napotkania błędu są: 1) 2) 3) 4)
skasowanie obcego znaku, wstawienie brakującego znaku, wymiana złego znaku na poprawny, zamiana miejscami dwóch sąsiednich znaków.
Powyższe transformacje błędów można wypróbować, aby naprawić dane wejściowe. Najprostsza strategia polega na sprawdzeniu, czy prefiks pozostałych danych wejścio wych może zostać przekształcony w poprawny symbol leksykalny za pomocą pojedynczej transformacji. Według tej strategii, większość błędów leksykalnych wynika z pojedynczej transformacji błędu; w praktyce zwykle tak się dzieje, ale nie zawsze. Jednym ze sposobów znajdowania błędów w programie jest obliczanie minimalnej liczby transformacji potrzebnych do przekształcenia programu do postaci poprawnej syntaktycznie. Mówimy, że błędny program ma k błędów, jeśli najkrótsza sekwencja trans formacji przekształcająca kod do jakiegoś poprawnego programu ma długość k. Poprawa błędów za względu na minimalną odległość jest wygodnym pojęciem teoretycznym, jed nak rzadko stosowanym w praktyce, ponieważ jest zbyt złożona w implementacji. Kilka eksperymentalnych kompilatorów używa jednak kryterium minimalnej odległości do wy konywania poprawek lokalnych.
3.2
Buforowanie wejścia
W tym podrozdziale przedstawiliśmy niektóre aspekty wydajności związane z buforowa niem wejścia. Najpierw omówiliśmy schemat dwóch buforów wejściowych - użyteczny, gdy do identyfikacji symboli leksykalnych potrzebny jest znak bieżący. Następnie opi saliśmy niektóre użyteczne techniki przyspieszające analizator leksykalny, jak użycie „wartowników" do oznaczenia końca bufora. Istnieją trzy główne podejścia do implementacji analizatora leksykalnego: 1.
Użycie generatora analizatorów leksykalnych, jak Lex (omówiony w p . 3.5), produ kujący analizatory leksykalne ze specyfikacji opartej na wyrażeniach regularnych. W tym przypadku generator dostarcza procedury czytające i buforujące wejście.
3.2
BUFOROWANIE WEJŚCIA
2.
Napisanie analizatora leksykalnego w konwencjonalnym języku programowania, ko rzystającego z funkcji wejścia-wyjścia udostępnianych przez język.
3.
Napisanie analizatora leksykalnego w asemblerze i samodzielne czytanie danych wejściowych.
Te trzy możliwości są uporządkowane według zwiększającej się trudności imple mentacji. Niestety, trudniejsze w implementacji podejścia często prowadzą do szybszych analizatorów leksykalnych. Ponieważ analiza leksykalna jest jedyną fazą kompilatora czytającą program źródłowy znak po znaku, możliwe jest spędzenie trochę więcej czasu w tej fazie, nawet jeśli następne fazy są bardziej złożone. Prędkość analizy leksykalnej jest więc ważną sprawą w projekcie kompilatora. Większość tego podrozdziału dotyczy pierwszego podejścia, czyli automatycznego generatora; omówione są również techni ki, które przydają się przy projektowaniu ręcznym. Podrozdział 3.4 dotyczy diagramów przejść, które są przydatne podczas ręcznego projektowania analizatora leksykalnego.
Pary buforów W wielu językach źródłowych analizator leksykalny musi czasem obejrzeć kilka znaków naprzód, mimo że właśnie wzorzec pewnego Ieksemu pasuje do wejścia. Analizatory leksykalne omówione w rozdz. 2 używały funkcji u n g e t c do zwracania symboli z po wrotem na wejście. Ponieważ przenoszenie znaków w ten sposób może pochłaniać dużo czasu, wynaleziono wyspecjalizowane techniki buforowania zmniejszające czas związany z przetwarzaniem znaków wejściowych. Można użyć wielu różnych schematów buforo wania. Ponieważ techniki te w dużej mierze zależą od parametrów konkretnego systemu, przedstawimy jedynie zarys jednej klasy technik. Użyjemy bufora podzielonego na dwie części po N znaków, j a k na rys. 3.3. N, typowo, jest liczbą znaków w jednym bloku na dysku, np. 1024 lub 4096.
_
_
f
przedni początekłeksemu
Rys. 3.3. Podział bufora na dwie części
Wczytywanie W znaków do jednej z połówek odbywa się za pomocą pojedynczego wywołania systemowego read, zamiast wywołania read dla poszczególnych znaków. Jeśli na wejściu pozostało mniej niż N znaków, to do bufora po wszystkich wczytanych znakach jest wstawiany specjalny symbol eof. Symbol ten oznacza koniec pliku wejściowego i nie może występować wewnątrz niego. Przechowywane są dwa wskaźniki bufora wejściowego. Ciąg znaków między tymi wskaźnikami jest aktualnym leksemem. Początkowo, oba wskaźniki wskazują pierwszy znak następnego Ieksemu. Pierwszy wskaźnik, przedni, porusza się do przodu, aż zo stanie znaleziony ciąg znaków pasujący do wzorca. W chwili, kiedy następny leksem zostanie znaleziony, wskaźnik przedni jest przesuwany na znak na j e g o prawym końcu.
Po przetworzeniu Ieksemu oba wskaźniki są przesuwane na pierwszy znak za tym leksemem. Komentarze i znaki białe w takim schemacie mogą być traktowane jako wzorce, które nie generują symboli leksykalnych. Jeśli wskaźnik przedni ma się przesunąć przez połowę bufora, prawa połowa jest wypełniana przez następne N znaków z wejścia. Jeśli natomiast wskaźnik ten ma się prze sunąć za prawy koniec bufora, lewa połowa jest wypełniana nowymi znakami, a wskaźnik przedni jest przenoszony na początek bufora. Ten schemat buforowania działa poprawnie w większości przypadków, jednak m o ż e podglądać ograniczoną liczbę symboli. Z tego wynika, że taki analizator leksykalny może nie być w stanie rozpoznać symboli leksykalnych, dla których odległość — na jaką musi się przesunąć wskaźnik przedni — jest większa niż długość bufora. Jeśli w programie PL/I, na przykład, zobaczymy DECLARE
( A R G 1 , ARG2,
...
, ARGrt )
nie jesteśmy w stanie stwierdzić, czy DECLARE jest słowem kluczowym, czy nazwą tablicy, zanim nie zobaczymy znaku, który znajduje się za prawym nawiasem. W obu przypadkach leksem kończy się na drugim E, ale liczba znaków, które trzeba podejrzeć, jest proporcjonalna do liczby argumentów, która w zasadzie jest nieograniczona.
Wartownicy Jeśli użyjemy schematu dokładnie takiego jak na rys. 3.3, to za każdym razem musimy sprawdzać, czy wskaźnik przedni nie znalazł się poza połową bufora. Jeśli tak się zda rzy, to druga połowa bufora musi zostać załadowana. Oznacza to, że kod przesuwający wskaźnik musi wykonywać takie testy, jak na rys. 3.4.
if przedni jest na końcu lewej polowy then begin załaduj prawą połowę; przedni := przedni + 1 end else if przedni na końcu prawej połowy then begin załaduj lewą połowę; przesuń przedni na początek lewej połowy end else przedni := przedni + 1; Rys. 3.4. Kod przesuwający wskaźnik przedni
Poza końcami połówek bufora, kod z rys. 3.4 przeprowadza dwa testy na każde przesunięcie wskaźnika. Możemy zredukować liczbę testów do jednego, jeśli obie połowy bufora rozszerzymy na końcu o znak wartownika. Wartownik jest specjalnym znakiem, który nie może być częścią programu źródłowego. Naturalnym wyborem jest eof. Na rysunku 3.5 przedstawiono taką samą sytuację, jak na rys. 3.3 p o dodaniu wartowników do buforów.
:
:
: E :
: = :
: M
: * :eof
C : * : * :
2 :eof:
eof
t przedni początek_leksemu
Rys. 3.5. Wartownicy na krańcach obu połówek bufora
Dla bufora z rysunku 3.5 możemy użyć kodu z rys. 3.6, przesuwającego wskaźnik przedni (i sprawdzający koniec pliku źródłowego). Przez większość czasu kod wykonuje tylko pojedynczy test, polegający na sprawdzeniu, czy przedni wskazuje na eof. Tylko w sytuacji dojścia d o końca pliku lub połowy bufora wykonywanych jest więcej testów. Jeśli między symbolami eof wczytywanych jest N znaków, średnia liczba testów na jeden znak jest bardzo bliska 1. przedni := przedni + 1; if przednil = eof then begin if przedni jest na końcu lewej połowy then begin załaduj prawą połowę; przedni := przedni + 1 end else if przedni na końcu prawej połowy then begin załaduj lewą połowę; przesuń przedni na początek lewej połowy end else / * eof oznacza koniec wejścia * / zakończ analizę leksykalną end Rys. 3.6. Kod z uwzględnieniem wartownika
Potrzebujemy także wiedzieć, jakie decyzje należy podejmować podczas przetwa rzania znaków przeglądanych w czasie przesuwania wskaźnika przedni. Trzeba wiedzieć, czy znaleziony został koniec symbolu leksykalnego, czy jest to jeszcze część słowa klu czowego, czy coś innego. Jedną z metod zapisania tych testów jest instrukcja case (o ile język implementacji ją ma). Sprawdzenie warunku if przednit
= eof
można zaimplementować jako jeden z przypadków w instrukcji case.
3.3
Specyfikacja symboli leksykalnych
Najczęściej używaną notacją specyfikacji wzorców są wyrażenia regularne. D o każdego wzorca pasuje zbiór ciągów znaków, dlatego można przyjąć, że wyrażenie regularne jest nazwą tego zbioru. W podrozdziale 3.5 opisaliśmy rozszerzenie tej notacji w języku sterowanym wzorcami i służącym do analizy leksykalnej.
Napisy i języki Terminy alfabet lub słownik oznaczają dowolny zbiór symboli. Typowymi przykłada mi tych symboli są litery i znaki. Zbiór { 0 , 1 } jest alfabetem binarnym. Przykładami alfabetów komputerowych są ASCII i EBCDIC. Napis nad pewnym alfabetem jest skończoną sekwencją symboli z tego alfabetu. W teorii języków jako synonim napisu używa się określenia słowo lub zdanie. Długość napisu s, oznaczana \s\, jest liczbą symboli w s. Na przykład, b a n a n jest napisem dłu gości 5. Napis pusty, oznaczany e, jest specjalnym napisem o długości zero. Zestawienie określeń używanych w niektórych napisach umieszczono na rys. 3.7. Termin język oznacza dowolny zbiór napisów nad pewnym ustalonym alfabetem. Definicja ta jest bardzo szeroka. Według niej językami są języki abstrakcyjne, jak 0 , zbiór pusty lub {e}, zbiór zawierający tylko napis pusty. Również są nimi: zbiór wszyst kich poprawnych programów w Pascalu i zbiór wszystkich zdań w języku angielskim, które są poprawne gramatycznie. Te dwa ostatnie języki są oczywiście bardzo trudne do wyspecyfikowania. Zauważmy, że definicja języka nie przypisuje żadnego znaczenia napisom w języku. Metody przypisywania znaczenia napisom są opisane w rozdz. 5. Jeśli x i y są napisami, to złączeniem x i y, zapisywanym xy, jest napis utworzony przez dodanie y na koniec do x. Na przykład, jeśli x = p i e s , a y = k o t , to xy = p i e s k o t . Pusty napis jest elementem neutralnym złączenia, czyli se = es = s. Jeśli o złączeniu będziemy myśleć jak o iloczynie, możemy zdefiniować podnoszenie do potęgi. Niech s° = e oraz dla i > 0 niech s — s ~ s. Skoro es = s, to s = s. Zatem s = ss, s = sss itd. l
2
l
l
1
3
DEFINICJA
TERMIN
prefiks s sufiks s
podciąg spójny s
prefiks, sufiks, podsłowo właściwe podciąg
s
s
napis otrzymany przez usunięcie zera lub więcej symboli z końca napisu s, np. b a n jest prefiksem napisu b a n a n napis otrzymany przez usunięcie zera lub więcej symboli z początku napisu s, np. n a n jest prefiksem napisu b a n a n napis otrzymany przez usunięcie prefiksu i sufiksu z s, np. a n a jest podciągiem napisu b a n a n ; każdy prefiks i su fiks jest podciągiem, ale nie każdy podciąg jest prefiksem lub sufiksem; dla dowolnego napisu s, s i e są prefiksami, sufiksami i podciągami s każdy niepusty napis x, który jest odpowiednio prefiksem, sufiksem lub podsłowem s, takim, że s ^ x napis otrzymany przez usunięcie zera lub więcej symboli (niekoniecznie kolejnych) z napisu s, np. b n n jest podcią giem napisu b a n a n Rys. 3.7. Określenia części napisów
Operacje na językach Kilka operacji można zastosowć do języków. W analizie leksykalnej przydaje się suma, złączenie i domknięcie, które są zdefiniowane na rys. 3.8. Uogólnimy także operator
DEFINICJA
OPERACJA
suma L i M zapisywana LUM złączenie L i M zapisywane LM
LUM = {s\s £ L lub s E M } LM — {st\s G L oraz r € M } oo
domknięcie
L * = UL''
L
L* oznacza „zero lub więcej złączeń" L
zapisywane L* dodatnie
domknięcie
L+ =
L
UL' 1=1
zapisywane L
+
+
L
oznacza „co najmniej jedno złączenie" L
Rys. 3.8. Definicje operacji na językach
l
x
podnoszenia do potęgi na całe języki, definiując L° = {e} oraz L =U- L. L złączonym z sobą i — 1 razy.
Zatem U jest
P r z y k ł a d 3.2. Niech L będzie zbiorem {A, B, . . . , Z, a, b , . . . , z } , a C zbiorem {0, 1, 9 } . L jest alfabetem składającym się z wielkich i małych liter, a C z cyfr dziesiętnych. Skoro symbole mogą być traktowane jako napisy o długości jeden, to zbiory L i C są językami skończonymi. Poniżej znajduje się kilka przykładów nowych języków utworzonych za pomocą L i C przy zastosowaniu operatorów zdefiniowanych na rys. 3.8. 1. 2.
L U C jest zbiorem liter i cyfr. LC jest zbiorem napisów składających się z litery i występującej po niej cyfry.
3. 4. 5.
L jest zbiorem wszystkich napisów czteroliterowych. L* jest zbiorem wszystkich napisów złożonych z liter, włączając w to napis pusty e. L ( L U C ) * jest zbiorem wszystkich napisów złożonych z liter i cyfr, zaczynających się od litery.
6.
C
4
+
jest zbiorem wszystkich napisów złożonych z co najmniej jednej cyfry.
•
Wyrażenia regularne W Pascalu identyfikator składa się z litery i występującej po niej sekwencji dowolnej liczby liter i cyfr. Oznacza to, że identyfikator jest elementem zbioru zdefiniowanego w przykładzie 3.2 w punkcie 5. W tym podrozdziale przedstawiliśmy notację, zwaną wyrażeniami regularnymi, która umożliwia dokładne zdefiniowanie takich zbiorów. Za pomocą tej notacji identyfikatory Pascala można zdefiniować jako litera ( l i t e r a | cyfra ) * Kreska pionowa oznacza „lub", nawiasy są używane do grupowania podwyrażeń, gwiazd ka oznacza „dowolna ilość egzemplarzy" wyrażenia w nawiasach, ustawienie obok siebie litera i reszty wyrażenia oznacza ich złączenie. Wyrażenie regularne jest budowane z prostszych wyrażeń regularnych przy użyciu zestawu reguł definiowania. Każde wyrażenie regularne r definiuje język L(r). Reguły
definiowania specyfikują, jak L(r) jest tworzony na podstawie łączenia różnymi sposo bami języków zdefiniowanych przez podwyrażenia r. Poniżej znajdują się reguły definiowania wyrażeń regularnych nad alfabetem £ . Każ da reguła zawiera specyfikację języka definiowanego przez definiowane przez nią wyra żenie regularne. 1.
e jest wyrażeniem regularnym oznaczającym {e}, czyli zbiór zawierający tylko napis pusty. Jeśli a jest symbolem z £ , to a jest wyrażeniem regularnym oznaczającym {a}, tzn. zbiorem zawierającym tylko napis a. Chociaż na wyrażenie regularne a, napis a i symbol a używamy tej samej notacji, są to jednak różne pojęcia. To, o czym w danym momencie mówimy, wynika z konkretnego kontekstu.
2.
3.
Załóżmy, że r i s są wyrażeniami regularnymi oznaczającymi języki L(r) i L(s). Wtedy a) (r)|(s) jest wyrażeniem regularnym oznaczającym L(r) UL(s). b) ( )( ) J wyrażeniem regularnym oznaczającym L(r)L(s). c) ( r ) * jest wyrażeniem regularnym oznaczającym (L(r))*. d) (r) jest wyrażeniem regularnym oznaczającym L ( r ) . r
s
e s t
1
Język określony przez wyrażenie regularne nazywa się zbiorem regularnym. Specyfikacja wyrażenia regularnego jest przykładem definicji rekurencyjnej. Regu ły 1. i 2. tworzą bazę definicji. Symbolem podstawowym nazwiemy e lub symbol z E pojawiający się w wyrażeniu regularnym. Reguła 3. jest krokiem indukcyjnym. Niepotrzebne nawiasy mogą być usunięte, jeśli przyjmiemy następujące konwencje: 1) 2) 3)
operator jednoargumentowy * ma największy priorytet i jest lewostronnie łączny, złączenie ma drugi priorytet i jest lewostronnie łączne, znak | ma najmniejszy priorytet i jest lewostronnie łączny.
Po przyjęciu tych konwencji, wyrażenie (a)\((b) * (c)) można zapisać a\b*c. Oba te wyrażenia oznaczają napis, który albo jest pojedynczym a, albo dowolną liczbą b i występującym po nich jednym c. P r z y k ł a d 3.3.
Niech E =
{a,b}.
1. 2.
Wyrażenie regularne a\b oznacza zbiór {a,b}. Wyrażenie regularne (a\b)(a\b) oznacza {aa,ab,ba,bb}, zbiór wszystkich napisów z liter a i b o długości dwa. Inne wyrażenie regularne dla tego samego zbioru to aa\ab\ba\bb.
3.
Wyrażenie regularne a* oznacza zbiór wszystkich napisów zera lub więcej liter a,
4.
5.
1
czyli {e,a,aa,aaa, • • -}. Wyrażenie regularne (a\b) * oznacza zbiór wszystkich napisów zawierających do wolną liczbą egzemplarzy a lub b, to znaczy zbiór wszystkich napisów złożonych z a i b. Innym wyrażeniem regularnym dla tego zbioru jest {a * b *) *. Wyrażenie regularne a \ a * b oznacza zbiór składający się z napisu a oraz ze wszyst kich napisów złożonych z zera lub więcej a zakończonych b. •
Reguła ta oznacza, że można dodać nawiasy na zewnątrz wyrażenia regularnego, jeśli istnieje taka potrzeba.
Jeśli dwa wyrażenia regularne r i s opisują ten sam język, mówi się, że r i s są równoważne i zapisuje r = s. Na przykład (a\b) — (b\a). Wyrażenia regularne spełniają pewne prawa algebraiczne, można ich więc użyć do przekształcenia wyrażeń regularnych do postaci równoważnych. Na rysunku 3.9 przed stawiono te prawa zastosowane do wyrażeń r, s i t.
OPIS
AKSJOMAT
r\s = s\r r\(s\t) r(st) r(s\t) (s\t)r er re
= = = = = —
(r\s)\t (rs)t rs\rt sr\tr r r
r * = (r|e)* y *fc *ł* y *fc
| jest przemienne | jest łączne złączenie jest łączne rozdzielność złączenia względem | e jest elementem neutralnym złączenia relacja między * a e * jest idempotentna
Rys. 3.9. Prawa algebraiczne dla wyrażeń regularnych
Definicje regularne Wyrażeniom regularnym, dla wygodnej notacji, można nadać nazwy i używać tych nazw tak jak symboli. Jeśli £ jest alfabetem symboli podstawowych, to definicją regularną
jest
sekwencja definicji d ^ r
x
d —> r 2
0
d -> r n
n
gdzie d są różnymi nazwami, a r są wyrażeniami regularnymi nad symbolami z T,\J{d d ,---jd^}, czyli ze zbioru symboli podstawowych i poprzednio zdefinio wanych nazw. Ograniczenie, że r- zawiera symbole jedynie z E i z poprzednio zdefinio wanych nazw, umożliwa konstrukcję wyrażenia regularnego nad L dla każdego r przez podstawienie kolejno wyrażeń regularnych pod nazwy w wyrażeniu ich zawartości. Gdy by r zawierało dj dla pewnego j ^ i, to r mogłoby być zdefiniowane rekurencyjnie i ten proces podstawiania nigdy by się nie zakończył. i
v
i
2
{
i
i
Przykład 3.4.
Jak wcześniej wspomnieliśmy, zbiór identyfikatorów w Pascalu jest zbio
rem napisów złożonych z liter i cyfr zaczynających się od litery. Definicja regularna tego zbioru jest następująca: litera - » A | B | - - - | z | a | b | - - - | z cyfra -> 0 j 1 | • • • j 9 id -> litera ( l i t e r a | cyfra )*
•
Przykład 3.5.
Liczby bez znaku w Pascalu są napisami, takimi jak 5 2 8 0 , 3 9 . 3 7 ,
6 . 3 3 6 E 4 , 1 . 8 9 4 E - 4 . Poniższa definicja regularna jest precyzyjną specyfikacją tej klasy napisów cyfra cyfry opc_ ułamek opc_ wykładnik liczba
-> 0 | 1 | • • • | 9 -> cyfra cyfra* —> . cyfry | e — > ( E ( + | - | e ) cyfry ) | e —> cyfry opc_ ułamek opc_ wykładnik
Według tej definicji opc_ ułamek jest albo kropką dziesiętną, za którą występuje jedna lub więcej cyfr, albo napisem pustym, natomiast opc_ wykładnik jest albo E, za którym występuje opcjonalnie + lub - i jedna lub więcej cyfr, albo napisem pustym. Zauważmy, że za kropką musi znajdować się co najmniej jedna cyfra, czyli do wzorca liczba nie pasuje 1., a pasuje 1.0. •
Skróty notacyjne Niektóre konstrukcje pojawiają się tak często w wyrażeniach regularnych, że wygodnie jest wprowadzić skróty notacyjne. 1.
+
Co najmniej jedno wystąpienie. Jednoargumentowy operator przyrostkowy ozna cza „co najmniej j e d n o wystąpienie". Jeśli r jest wyrażeniem regularnym oznaczają cym język L(r), to ( r ) jest wyrażeniem regularnym oznaczającym język ( L ( r ) ) . Zatem, wyrażenie regularne a oznacza zbiór wszystkich napisów złożonych z zera lub większej liczby a. Operator ma taki sam priorytet i łączność jak operator *. Dwie równości algebraiczne r* = r | e oraz r — r r * dotyczą operatorów domknięcia i domknięcia dodatniego. Dowolna ilość wystąpień. Jednoargumentowy operator ? oznacza „co najwyżej jedno wystąpienie". Notacja r? jest skrótem dla r\e. Jeśli r jest wyrażeniem regularnym, to (r)? jest wyrażeniem regularnym oznaczającym język L ( r ) U { e } . Na przykład, przy użyciu operatorów i ?, definicja regularna dla liczba z przykładu 3.5 może zostać zapisana w następujący sposób: +
+
+
+
+
2.
+
+
cyfra cyfry o p c . ułamek opc_ wykładnik liczba 3.
-> 0 | 1 | • • • | 9 -» cyfra -> ( . cyfry )? —> ( E ( + | - )? cyfry )? -> cyfry o p c - u ł a m e k opc_ wykładnik +
Klasy znaków. Notacja [ a b c ] , gdzie a, b i c są symbolami alfabetu, oznacza wyraże nie regularne a | b | c . Zapisana skrótowo klasa znaków [ a - z ] oznacza wyrażenie regularne a | b | • • • | z. Używając klas znaków, możemy opisać identyfikatory jako napisy generowane przez następujące wyrażenie regularne: [A-Za-z][A-Za-zO-9]*
Zbiory nieregularne Niektóre języki nie dają się opisać jakimkolwiek wyrażeniem regularnym. Aby zilustro wać ograniczenia możliwości wyrażeń regularnych, podamy przykład konstrukcji języka programowania, które nie mogą być opisane wyrażeniem regularnym. Pozycje literatury zawierające udowodnienie tego stwierdzenia omówiliśmy w uwagach bibliograficznych. Wyrażenie regularne nie może być użyte do opisu zrównoważonych lub zagnieżdżo nych struktur. Przykładowo, zbiór wszystkich napisów zawierających poprawnie wpisane nawiasy nie może być opisany wyrażeniem regularnym. Jednak zbiór ten daje się opisać gramatyką bezkontekstową. Powtarzane napisy nie mogą być opisane wyrażeniem regularnym. Zbioru {wcn>| w jest napisem złożonym z a i b } nie można opisać wyrażeniem regularnym ani gramatyką bezkontekstową. Wyrażenia regularne mogą jedynie opisać ustaloną liczbę powtórzeń albo dowolną liczbę powtórzeń danej konstrukcji. Nie można sprawdzić, czy dwie dowolne liczby są sobie równe. Wyrażeniami regularnymi nie można zatem opisać np. napisów Holleritha o postaci nKa^- -a pochodzących z wczesnych wersji Fortranu, ponieważ liczba znaków występująca za znakiem H musi być taka, j a k wartość liczby dziesiętnej przed H. n
3.4
Rozpoznawanie symboli leksykalnych
W poprzednim podrozdziale rozważaliśmy problem, jak wyspecyfikować symbole lek sykalne. Teraz odpowiemy na pytanie, jak je rozpoznawać; jako przykładu będziemy używać poniższej gramatyki. Przykład 3.6.
Rozważmy poniższy fragment gramatyki
instr —> if wyr then instr | if wyr then instr else instr
I
6
wyr —> człon oprel człon j człon człon —> id | liczba gdzie terminale if, then, else, oprel, id i liczba generują zbiory napisów zdefiniowane przez poniższe definicje regularne if then else oprel id liczba
-> if -> then else -)• < | | > = litera ( litera | cyfra )* -> c y f r a ( , cyfra+ )? ( E ( + | - )? c y f r a )? +
+
gdzie litera i cyfra są takie same, jak poprzednio zdefiniowane.
Analizator leksykalny dla tego fragmentu języka musi rozpoznawać słowa kluczowe i f , t h e n , e l s e , a także leksemy opisane oprel, id, liczba. Dla uproszczenia przyjmie my, że słowa kluczowe są zarezerwowane, czyli nie mogą być używane jako identyfika tory. Tak jak w przykładzie 3.5, liczba reprezentuje liczby całkowite i rzeczywiste bez znaku z Pascala. Dodatkowo założymy, że leksemy są oddzielone znakami białymi, składającymi się z niepustych ciągów odstępów, tabulacji i nowych wierszy. Nasz analizator leksykalny będzie usuwał te białe znaki, porównując napis z poniższą definicją regularną dla bz ogr -> odstęp | tab | cr bz —»• o g r +
Jeśli bz zostanie dopasowany, analizator leksykalny nie będzie analizatorowi składnio wemu zwracał symbolu leksykalnego. Będzie za to działał dalej, aby znaleźć symbol znajdujący się za znakami białymi i dopiero go zwrócić. Naszym celem jest takie skonstruowanie analizatora leksykalnego, aby wydzielał leksem dla następnego symbolu z bufora wejściowego i jako wynik produkował parę składającą się z właściwego symbolu leksykalnego i wartości jego atrybutu, przy użyciu danych z tablicy na rys. 3.10. Wartościami atrybutów dla operatorów relacyjnych są stałe symboliczne LT, LE, EQ, NE, GT, GE. •
WYRAŻENIE
SYMBOL
REGULARNE
LEKSYKALNY
bz if then else id liczba
if then else id liczba oprel oprel oprel oprel oprel oprel
W A R T O Ś Ć ATRYBUTU
wskaźnik do tablicy symboli wskaźnik do tablicy symboli LT LE EQ NE GT GE
Rys. 3.10. Wzorce wyrażeń regularnych dla leksemów
Diagramy przejść Jako pośredni krok w konstrukcji analizatora leksykalnego, będziemy najpierw konstru ować diagramy przejść. Diagramy przejść opisują akcje, które są wykonywane, gdy analizator leksykalny zostanie wywołany przez składniowy w celu zwrócenia następ nego Ieksemu, jak na rys. 3.1. Załóżmy, że bufor wejściowy jest taki, jak na rys. 3.3, i że wskaźnik początku symbolu wskazuje następny znak za ostatnio znalezionym leksemem. Diagram przejść zostanie wykorzystany d o śledzenia informacji na temat zna-
ków, które były wczytywane, gdy wskaźnik przedni przesuwał się do przodu. Wykonane to zostanie przez przesuwanie się po węzłach diagramu w trakcie wczytywania kolejnych znaków. Węzły w diagramie przejść są rysowane jako okręgi i są nazywane stanami. Stany te są połączone strzałkami, zwanymi krawędziami. Krawędzie ze stanu s mają etykiety ozna czające, jakie znaki mogą pojawić się na wejściu po przejściu do stanu s. Etykieta inny oznacza dowolny znak, który nie jest przypisany do żadnej innej krawędzi wychodzącej ze stanu s. W tym podrozdziale przyjęliśmy, że diagramy przejść są deterministyczne, czyli że żaden symbol wejściowy nie może jednocześnie pasować do dwóch krawędzi wychodzą cych z jednego stanu. W następnych podrozdziałach nie będziemy j u ż przyjmować tego ograniczenia, znacznie ułatwiając projektowanie analizatora leksykalnego i, przy użyciu odpowiednich narzędzi, nie komplikując implementacji. Jeden ze stanów jest nazywany stanem początkowym i oznaczony jest krawędzią start. Jest to stan diagramu, w którym znajduje się sterowanie na początku rozpoznawania symbolu leksykalnego. Z niektórymi stanami mogą być związane akcje, wykonywane, gdy sterowanie osiągnie ten stan. Przy wchodzeniu do stanu z wejścia jest wczytywany pojedynczy znak. Jeśli istnieje krawędź ze stanu aktualnego z etykietą odpowiadającą wczytanemu znakowi, to nowym stanem aktualnym staje się stan wskazywany przez tę krawędź. W przeciwnym przypadku zwracany jest błąd w rozpoznaniu Ieksemu. Na rysunku 3.11 pokazano diagram przejść dla wzorców >= oraz >. Stanem po czątkowym jest stan 0, w którym wczytujemy kolejny znak z wejścia. Jeśli znakiem tym jest >, przesuwamy się po krawędzi oznaczonej >. W przeciwnym przypadku nie udaje się rozpoznać ani >, ani >=.
start
>
Rys. 3.11. Diagram przejść dla >=
Po przejściu do stanu 6 wczytujemy kolejny znak. Jeśli wczytamy =, to przenosimy się ze stanu 6 do 7. W przeciwnym przypadku przejście jest dokonywane do stanu 8 po krawędzi oznaczonej etykietą inny. Podwójny okrąg w stanie 7 oznacza, że jest to stan akceptujący, w którym został znaleziony leksem >=. Zauważmy, że aby dojść do stanu akceptującego 8, musimy wczytać znak > i jesz cze jeden dodatkowy znak. Skoro dodatkowy znak nie jest częścią operatora >, wskaźnik przedni bufora musi zostać cofnięty o jeden znak. Stany, w których musi nastąpić cof nięcie, są oznaczane za pomocą *. Może istnieć kilka diagramów przejść — każdy specyfikujący grupę symboli lek sykalnych. Jeśli przechodzenie po jednym z diagramów zakończy się porażką, to można cofnąć wskaźnik przedni do pozycji, na której znajdował się przed użyciem tego dia gramu, i spróbować kolejnego diagramu. Ponieważ na początku wczytywania Ieksemu wskaźniki początek-Ieksemu i przedni wskazują tę samą pozycję, wskaźnik przedni może
zostać cofnięty do pozycji wskaźnika początek-Ieksemu. Jeśli dla wszystkich diagramów przejść nastąpi porażka, to znaleziony został błąd leksykalny i trzeba wywołać procedurę zgłaszania błędu.
P r z y k ł a d 3.7. Diagram przejść dla symbolu leksykalnego oprel jest przedstawiony na rys. 3.12. Zauważmy, że rys. 3.11 jest częścią tego bardziej skomplikowanego diagramu przejść. • start
return(oprel, L E ) return(oprel, N E ) return(opreI, L T )
Q)
return(oprel, EQ) return(oprel, GE) return(oprel, GT)
Rys. 3.12. Diagram przejść dla operatorów relacyjnych
P r z y k ł a d 3.8. Ponieważ słowa kluczowe składają się z liter, są one wyjątkami od za sady mówiącej o tym, że ciąg liter i cyfr zaczynający się od litery jest identyfikatorem. Zamiast zakodować ten wyjątek w diagramie przejść, wygodniej jest traktować słowa kluczowe jako specjalne identyfikatory, jak w p. 2.7. Gdy zostanie osiągnięty stan akcep tujący (rys. 3.13), wykonywany jest pewien fragment kodu, który sprawdza, czy wczytany symbol podstawowy jest identyfikatorem, czy słowem kluczowym.
litera lub cyfra start
V
litera
inny
*
—*"CP i return(dąjsymbolQ,
dodaj_idQ)
Rys. 3.13. Diagram przejść dla identyfikatorów i słów kluczowych Prosta technika oddzielania identyfikatorów od słów kluczowych polega na odpo wiednim zainicjowaniu tablicy symboli, w której trzymana jest informacja o identyfikato rach. Dla symboli leksykalnych z rys. 3.10 musimy wprowadzić napisy i f , t h e n i e l s e do tablicy symboli przed rozpoczęciem wczytywania danych wejściowych. Trzeba także odnotować w tablicy symboli symbol, który ma zostać zwrócony, gdy rozpoznany zosta nie jeden z tych napisów. Instrukcja return obok stanu akceptującego na rys. 3.13 używa funkcji dajsymbolC) i dodaj^id()> aby otrzymać symbol i wartość atrybutu. Procedura
dodaj-id() ma dostęp do bufora, w którym znajduje się identyfikator. Tablica symboli jest przeglądana w celu sprawdzenia, czy znaleziony leksem jest w niej zaznaczony jako słowo kluczowe. W tym przypadku dodąj-id{) zwraca 0. Jeśli symbol podstawowy jest znaleziony w tablicy symboli jako zmienna programu, to dodaj-id{) zwraca wskaźnik pozycji w tablicy symboli. Jeśli symbol podstawowy nie zostanie znaleziony, jest insta lowany w tablicy symboli jako zmienna i funkcja ta zwraca wskaźnik nowo utworzonej wartości. Procedura dajsymbol() w podobny sposób wyszukuje leksem w tablicy symboli. Jeśli leksem jest słowem kluczowym, zwracany jest właściwy symbol leksykalny. W przeciw nym przypadku zwracany jest symbol id. Zauważmy, że diagram przejść nie zmienia się, jeśli dodatkowe słowa kluczowe mają być rozpoznawane. Wystarczy zainicjować tablicę symboli napisami i symbolami leksykalnymi dla dodatkowych słów kluczowych. • Jeśli analizator leksykalny jest kodowany ręcznie, technika polegająca na umieszcza niu słów kluczowych w tablicy symboli jest prawie niezbędna. Bez zrobienia tego, liczba stanów analizatora leksykalnego dla typowego języka programowania może wynieść kil kaset. Użycie tej techniki pozwala zmniejszyć liczbę stanów w analizatorze leksykalnym do mniej niż stu. Przykład 3.9.
Podczas konstrukcji analizatora dla liczb bez znaku przy użyciu definicji
regularnej liczba -> c y f r a
+
( . cyfra
+
)? ( E( + | - )? c y f r a
+
)?
pojawia się kilka kwestii. Zauważmy, że ta definicja ma postać cyfry ułamek? wykład nik?, w której ułamek i wykładnik są opcjonalne. Leksem dla danego symbolu leksykalnego musi być możliwie najdłuższy. Przykła dowo analizator leksykalny nie może się zatrzymać po zobaczeniu 12 ani nawet 1 2 . 3 , jeśli ciągiem wejściowym jest 12 . 3 E 4 . Zaczynając w stanach 25, 20 i 12 (rys. 3.14), stany akceptujące zostaną osiągnięte po wczytaniu odpowiednio 1 2 , 1 2 . 3 i 1 2 . 3 E 4 (przy założeniu, że za 12 . 3E4 na wejściu występuje znak nie będący cyfrą). Diagramy przejść ze stanami początkowymi 2 5 , 20 i 12 są przeznaczone do rozpoznania odpowied nio cyfry, cyfry ułamek i cyfry ułamek? wykładnik, więc stany początkowe muszą być wypróbowane w odwrotnej kolejności, czyli 12, 20, 25. Procedura dodaj_liczbe, wpisująca leksem do tablicy liczb i zwracająca wskaźnik do niego, jest wywoływana, gdy zostanie osiągnięty któryś ze stanów akceptujących 19, 24, 27. Analizator leksykalny zwraca symbol liczba oraz wskaźnik jako wartość leksy kalną. • Informacje o języku, nie znajdujące się w definicjach regularnych symboli leksykal nych, mogą zostać użyte do sprecyzowania błędu w wejściu. Na przykład, dla wejścia 1 . <x, w stanach 14 i 22 z rys. 3.14 poniesiemy porażkę po wczytaniu znaku *i
r albo r~>
r
2
{
r
(r) r /r x
A
r
2
p
(alb)
jeśli dalej występuje r
2
abc/123
Rys. 3.48. Wyrażenia regularne w Leksie
a) Specjalne znaczenie symboli operatorów \ " .
A
$ [ ] * + ? { } | /
musi zostać wyłączone, jeśli operator ma być zastosowany jako zwykły znak. Uzyskać to można, używając znaku jednym z dwóch sposobów. Wyrażenie "s" dopasowuje napis s dosłownie, jeśli nie zawiera on cudzysłowów. Na przykład, do "**" można dopasować napis **. Napis ten można również dopasować do wyrażenia \ * \ * . Zauważmy, że samo * jest operatorem domknięcia. Zapisz wyrażenie regularne, do którego pasuje napis " \ . b) W Leksie, klasa dopełniająca znaków jest klasą znaków, w której pierwszym symbolem jest . Do klasy dopełniającej pasuje każdy znak nie znajdujący się w niej. Zatem do [ a ] pasuje każdy znak oprócz a, a do [ A - Z a - z ] pasuje każdy znak, który nie jest dużą ani małą literą. Wykaż, że dla każdej definicji regularnej z klasą dopełniającą znaków, istnieje równoważne wyrażenie regularne bez dopełniającej klasy znaków. A
A
A
141
ĆWICZENIA
c) D o wyrażenia regularnego r{m,n) pasuje od m do n wystąpień wzorca r. Przykładowo, do a{l, 5} pasuje napis od jednego do pięciu a. Wykaż, że dla każdego wyrażenia regularnego zawierającego operatory powtarzania istnieje równoważne wyrażenie bez operatorów powtarzania. A
d) Operator dopasowuje początek wiersza. Jest to ten sam operator, co w przy padku dopełniającej klasy znaków, ale kontekst, w którym ten operator się pojawia, zawsze determinuje jego konkretne znaczenie. Operator $ dopasowuje koniec wiersza. Do [ a e i o u ] *$ pasuje, na przykład, każdy wiersz, który nie zawiera małej litery samogłoski. Czy dla każdego wyrażenia regularnego zawierającego operatory i $ istnieje równoważne wyrażenie bez tych opera torów? A
A
A
3.11 Napisz w Leksie program kopiujący plik i zastępujący każdą niepustą sekwencję znaków białych przez pojedynczy odstęp. 3.12 Napisz w Leksie program kopiujący program w Fortranie i zastępujący każde wystąpienie DOUBLE PRECISION wystąpieniem REAL. 3.13 Użyj swojej specyfikacji słów kluczowych i identyfikatorów dla Fortranu 77 z ćwi czenia 3.9 do identyfikacji symboli leksykalnych w następujących instrukcjach:
IF (I) IF(I) IF (I) IF (I) IF(I)
- SYMBOL ASSIGN5SYMBOL 10,20,30 GOT015 THEN
Czy potrafisz napisać własną specyfikację słów kluczowych i identyfikatorów z Lek sa? 3.14 W systemie UNIX, polecenie powłoki s h używa operatorów z rys. 3.49 w wyra żeniach z nazwami plików do opisywania zbiorów nazw plików. Na przykład, do wyrażenia * . o pasują wszystkie nazwy kończące się na . o, do s o r t . ? pasują wszystkie wyrażenia o postaci s o r t . c , gdzie c jest dowolnym znakiem. Klasy znaków mogą być zapisywane [ a - z ] . Jak wyrażenia z nazwami plików mogą być reprezentowane za pomocą wyrażeń regularnych?
WYRAŻENIE
P A S U J E D O NIEGO
PRZYKŁAD
' s'
napis s znak c dowolny napis dowolny znak dowolny znak w s
'V V
V
* [s]
* . 0
sortl.? sort.[cso]
Rys. 3.49. Wyrażenia dla nazw plików w s h
3.15 Zmodyfikuj algorytm 3.1, aby znajdował najdłuższy prefiks wejścia, który jest akceptowany przez DAS.
3.16 Skonstruuj NAS dla poniższych wyrażeń przy użyciu algorytmu 3.3. Pokaż se kwencję ruchów wykonywaną przez poszczególne automaty dla napisu wejściowe go ababbab. a) (a\b)* b) ( a * | Z ? * ) * c) ((€\a)b*)* d) (a\b) * abb(a\b) * 3.17 Przekształć NAS z ćwiczenia 3.16 w DAS, używając algorytmu 3.2. Pokaż se kwencję ruchów wykonywaną przez poszczególne automaty dla napisu wejściowe go ababbab. 3.18 Skonstruuj DAS dla wyrażeń regularnych z ćwiczenia 3.16, używając algorytmu 3.5. Porównaj rozmiar DAS ze skonstruowanym w ćwiczeniu 3.17. 3.19 Skonstruuj DAS dla diagramów przejść dla symboli z rys. 3.10. 3.20 Rozszerz tabelę z rys. 3.40 o operatory ? oraz z wyrażeń regularnych. +
3.21 Zminimalizuj liczbę stanów w DAS z ćwiczenia 3.18, używając algorytmu 3.6. 3.22 Możemy wykazać, że dwa wyrażenia regularne są równoważne, pokazując, że ich DAS o minimalnej liczbie stanów są takie same, oprócz nazw stanów. Używając tej techniki, udowodnij, że wszystkie poniższe wyrażenia są równoważne: a)
(a\b)*
b) c)
{(e\a)b*)*
3.23 Skonstruuj DAS o minimalnej liczbie stanów dla następujących wyrażeń regular nych: a)
(a\b)*a(a\b)
b) (a\b)*a(a\b)(a\b) c) (a\b)*a{a\b)(a\b)(a\b) **d) udowodnij, że każdy DAS dla wyrażenia regularnego (a\b) * a(a\b)(a\b) • • • (a\b), zawierającego na końcu n — 1 wyrażeń (a\b), musi mieć co najmniej 2 stanów. n
3.24 Skonstruuj reprezentację tabeli przejść dla automatu z ćwiczenia 3.19 w takiej po staci, jak na rys. 3.47. Wybierz stany domyślne, wypróbuj dwie metody konstrukcji tablicy następny i porównaj ilość zajętej w obu przypadkach pamięci: a) zaczynając od najgęstszych stanów (czyli tych, które zawierają największą liczbę wpisów różnych od ich stanów domyślnych), umieść wpisy w tablicy następny, b) dodaj wpisy dla stanów do tablicy następny
w kolejności losowej.
3.25 Odmiana schematu kompresji tabeli przejść z p. 3.9 polega na uniknięciu rekurencyjnej procedury następny-stan dzięki ustalonej domyślnej pozycji dla każdego stanu. Skonstruuj reprezentację tabeli przejść z rys. 3.47 dla ćwiczenia 3.19 przy użyciu techniki nierekurencyjnej. Porównaj wymagania pamięciowe z otrzymanymi z ćwiczenia 3.24. 3.26 Niech b b '--b będzie napisem wzorca, zwanego słowem kluczowym. Diagram przejść dla tego słowa kluczowego nazywany jest drzewem trie i ma m + 1 stanów, z których każdy odpowiada prefiksowi tego słowa kluczowego. Dla 1 ^ s 0 and fc / b do t := f{t)\ if b =b then begin / : = / + !; f(s+ 1) else f{s+\) :=0 end {
v+1
s+[
m
(+{
end;
(+l
Rys. 3.50. Algorytm obliczający funkcję porażki dla ćwiczenia 3.26 3.27 Algorytm K M P (patrz D. E. Knuth, J. M. Haris, V. R. Pratt [1977]) z rys. 3.51 używa funkcji porażki / , skonstruowanej w ćwiczeniu 3.26 do sprawdzenia, czy słowo kluczowe b ---b jest spójnym podciągiem napisu a --a . Stany w drzewie trie dla b ---b są numerowane od 0 do m, jak w ćwiczeniu 3.26(b). {
x
m
{
n
m
a) Użyj algorytmu K M P do sprawdzenia, czy ababaa jest spójnym podciągiem abababaab. *b) Udowodnij, że algorytm K M P zwraca „tak" wtedy i tylko wtedy, gdy jest spójnym podciągiem
b ---b x
m
a ---a . x
n
*c) Wykaż, że algorytm K M P działa w czasie 0(m + n). *d) Wykaż, że dla danego słowa kluczowego y, funkcja porażki może zostać uży ta do skonstruowania DAS w czasie 0(\y\) z \y\ 4-1 stanami dla wyrażenia regularnego . *y. *, gdzie . oznacza dowolny symbol wejściowy.
/ * sprawdzanie, czy
a • • -a x
n
zawiera podciąg
b ---b x
m
*/
s:=Q;
for / := 1 ton dobegin while s > 0 and a ^ b dos := f(s); if a = ^ j then s :— s-\- 1 i f s = m t h e n r e t u r n „tak" end; r e t u r n „nie*' {
s + ]
}
Rys. 3.51. Algorytm KMP **3.28 Okresem napisu s nazywamy taką liczbę całkowitą p, dla której s może zostać wyrażona jako (uv) u dla pewnego k ^ 0, gdzie |av| — p i v ^ e. Na przykład, 2 i 4 są okresami napisu abababa. k
a) Udowodnij, że p jest okresem napisu s wtedy i tylko wtedy, gdy st = dla pewnych napisów r i u o długości /?. b) Wykaż, że jeśli p i ą są okresami napisu 5 oraz + # ^ |^| + N W D ( / ? , # ) , to NWD(p, jest okresem s, gdzie NWD(/?,