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 <=, napisy przedstawione półgrubą czcionką, jak while, są terminalami. Słowa napisane kursywą są nieterminalami i można przyjąć, że wszystkie pozostałe słowa są symbo lami leksykalnymi . Niektóre produkcje z tym samym nieterminalem po lewej stronie będziemy czasem zapisywać w postaci zgrupowanej, kolejne prawe strony będą wtedy pooddzielane symbolem | oznaczającym „lub". 1
Przykład 2.1. W niektórych przykładach w tym rozdziale używamy wyrażeń składa jących się z cyfr oraz znaków plus i minus, np. 9 - 5 + 2 , 3 - 1 , 7. Ponieważ znaki plus i minus muszą pojawiać się między dwoma cyframi, tego typu wyrażenia będziemy traktować jako „listy cyfr pooddzielanych znakami plus lub minus". Poniższa gramatyka opisuje składnię takich wyrażeń lista
-» lista + cyfra
(2.2)
lista
—¥ lista - cyfra
(2.3)
lista
-> 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 <, <= i o . Techniki rozpoznawania takich symboli leksykalnych są omówione w rozdz. 3.
Interfejs do analizatora leksykalnego Gdy analizator leksykalny jest umieszczony między analizatorem składniowym a wej ściem translatora, musi komunikować się z nimi w sposób przedstawiony na rys. 2.25. Czyta on znaki z wejścia, grupuje j e w symbole leksykalne i przekazuje te symbole ra zem z wartościami ich atrybutów do dalszych faz kompilatora. W niektórych sytuacjach analizator leksykalny musi wczytać kilka znaków naprzód, zanim zadecyduje, który sym bol ma być przekazany do analizatora składniowego. Na przykład, analizator leksykalny dla Pascala po natrafieniu na znak > musi wczytać kolejny. Jeśli jest nim znak =, to symbolem leksykalnym jest sekwencja >=, 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 = lub o 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 -)• < | <= | = | o I > | > = 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 <
-
= o >
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 <. Zamiast zwracać liczbę 1, możemy zgłosić błąd i kontynuować analizę, jak gdyby na wejściu znajdowało się 1 . 0 < x . Możemy z tego skorzystać do uproszczenia diagramów przejść, ponieważ obsługa błędów może zostać użyta do powrócenia do normalnego stanu z sy tuacji prowadzących do porażki.
cyfra
cyfra
cyfra
Rys. 3.14. Diagram przejść dla liczb bez znaku z Pascala
Istnieje kilka sposobów, dzięki którym można uniknąć nadmiarowego dopasowywa nia wzorca w diagramach przejść z rys. 3.14. Jednym z nich jest przepisanie diagramów przejść i połączenie ich w pojedynczy; zwykle nie jest to zadanie proste. Inną metodą, gdy nastąpi porażka, jest zmiana działania — przejście do następnego diagramu. W dal szej części rozdziału przedstawiliśmy dokładniej metodę polegającą na przechodzeniu przez wiele stanów akceptujących i, gdy nastąpi porażka, na powrocie do ostatniego stanu akceptującego.
P r z y k ł a d 3.10. Sekwencja diagramów przejść dla wszystkich symboli leksykalnych z przykładu 3.6 jest otrzymywana z diagramów z rys. 3.12, 3.13 i 3.14. Stany o niższej numeracji są wypróbowywane przed tymi o wyższej. Pozostała tylko kwestia znaków białych. Sposób traktowania bz, reprezentującego takie znaki, różni się od wzorców omawianych powyżej, ponieważ dla symbolu bz nie jest zwracany żaden leksem. Diagram przejść rozpoznający bz jest następujący: ogr start
Nic nie jest zwracane, gdy osiągany jest stan akceptujący. Wracamy po prostu do stanu początkowego pierwszego diagramu przejść, aby znaleźć kolejny wzorzec. Najpierw — jeśli jest to możliwe — należy szukać symboli pojawiających się czę sto, potem należy szukać symboli pojawiających się rzadziej, ponieważ każdy dia gram przejść zaczyna być sprawdzany dopiero wtedy, kiedy we wszystkich poprzed nich diagramach osiągnięto porażkę. Skoro znaki białe zwykle pojawiają się często, to umieszczenie ich diagramu przejść na początku powinno być wydajniejsze od umiesz czenia na końcu. •
Implementacja diagramu przejść Sekwencja diagramów przejść może zostać przekształcona w program wyszukujący sym bole leksykalne wyspecyfikowane przez te diagramy. Użyjemy systematycznej metody działającej dla dowolnych diagramów przejść i tworzącej programy o rozmiarach propor cjonalnych do liczby stanów i krawędzi w diagramach. Każdemu stanowi odpowiada segment kodu. W przypadku, gdy ze stanu wychodzą krawędzie, to kod ten wczytuje znak i wybiera odpowiednią krawędź. Do wczytania ko lejnego znaku z bufora wejściowego, przesunięcia wskaźnika przedniego i przekazania wartości znaku jest używana funkcja d a j _ z n a k () . Jeśli wczytany znak jest ozna czeniem pewnej krawędzi lub odpowiada pewnej klasie znaków oznaczającej krawędź, to sterowanie jest przekazywane do kodu odpowiadającego stanowi wskazywanemu przez tę krawędź. Jeśli nie istnieje taka krawędź, a stan aktualny nie jest stanem akceptującym, to wywoływana jest procedura p o r a ż k a () cofająca wskaźnik przedni do pozycji początku Ieksemu i inicjująca poszukiwanie Ieksemu następnym diagramem przejść. Jeśli nie ma następnych diagramów przejść do wypróbowania, to p o r a ż k a () wywołuje procedurę obsługi błędu. l
Do przekazywania leksemów używana jest globalna zmienna w a r t o ś ć . l e k s y k a l n a , której przypisywane są wartości zwracane przez funk cję d o d a j _ i d () dla identyfikatorów i funkcję d o d a j _ 1 i c z b e () dla liczb. Klasa ieksemu jest zwracana przez główną procedurę analizatora leksykalnego, zwaną n a s t ę p n y ^ symbol (). D o ustawienia stanu początkowego następnego diagramu przejść użyjemy instruk cji case. Na rysunku 3.15 znajduje się implementacja w języku C. Zmienne s t a n i p o c z ą t k o w y oznaczają stan aktualny i stan początkowy diagramu przejść. Nume ry stanów użyte w kodzie odpowiadają diagramom z rys. 3.12-3.14.
int stan - 0, początkowy = 0; int wartosc_leksykalna; /* do przekazywania drugiego składnika Ieksemu */ int porażka () { przedni = poczatek„Ieksemu; switch (początkowy) { 9; break; case 0: początkowy 12; break; początkowy case 9: 20; break; początkowy 12 case 25; break; 20 początkowy case powrót(); break; 25 case /* błąd kompilatora */ default } return start; Rys. 3.15. Kod w C znajdujący następny stan początkowy 1
W bardziej wydajnej implementacji programu funkcja daj_znak() powinna być zadeklarowana jako
inline.
Fragment kodu dla danego stanu znajduje krawędź odpowiadającą wczytanemu zna kowi i wykonuje przejście do fragmentu kodu dla stanu wskazywanego przez tę krawędź. Na rysunku 3.16 przedstawiono ten kod dla stanu 0. Jest to modyfikacja kodu z przykładu 3.10 — uwzględniono znaki białe — oraz kod dla diagramów przejść z rys. 3.13 i 3.14. Zwróćmy uwagę, że konstrukcja wh i 1 e (1) instr powtarza wykonanie instr, aż do wystąpienia instrukcji r e t u r n . Z tego powodu, że język C nie p o z w a l a n a jednoczesne zwracanie Ieksemu i wartości atrybutu, funkcje doda j ~ id {) i doda j _ l i c z b e {) muszą ustawiać pewną zmienną globalną na odpowiednią wartość atrybutu odpowiadającą pozycji w tablicy symboli dla id i liczba. Jeśli implementacja języka nie ma instrukcji case, możemy stworzyć tablicę dla każ dego stanu, której indeksem są znaki wejściowe. Jeśli stanl jest taką tablicą, to stani[c] jest wskaźnikiem do fragmentu kodu, który musi zostać wykonany, gdy aktualnie wczyta nym znakiem jest c. Taki kod zwykle kończy się instrukcją skoku do kodu dla następnego stanu. Tablica dla stanu s jest traktowana jako pośrednia tablica przekazywania sterowania dla s.
3.5
Język do specyfikacji analizatorów leksykalnych
Istnieje kilka narzędzi służących do konstrukcji analizatorów leksykalnych na podstawie specjalnych notacji bazujących na wyrażeniach regularnych. Zdążyliśmy już poznać uży cie wyrażeń regularnych do specyfikacji wzorców symboli leksykalnych. Zanim zacznie my rozważać algorytmy kompilacji wyrażeń regularnych w celu stworzenia programów dopasowujących wzorce, przedstawimy przykład narzędzia, które stosuje takie algorytmy. W tym podrozdziale opisaliśmy narzędzie zwane Lex, które jest bardzo często uży wane do specyfikacji analizatorów leksykalnych dla wielu różnych języków. Narzędzie to nazywamy kompilatorem Leksa, a specyfikację jego wejścia — językiem Leksa. Omawia jąc istniejące narzędzie, pokażemy, jak opis wzorców przy użyciu wyrażeń regularnych może być łączony z akcjami, które analizator leksykalny powinien wykonać, służącymi, na przykład, do tworzenia wpisu w tablicy symboli. Specyfikacje podobne do Leksa mogą być przydatne nawet, jeśli nie ma dostępu do kompilatora Leksa, ponieważ mogą one zostać ręcznie przekształcone na działający program przy użyciu diagramów przejść z poprzedniego punktu. Lex jest zwykle używany w sposób przedstawiony na rys. 3.17. Na początku w pliku l e x . 1 tworzy się specyfikację analizatora leksykalnego w języku Leksa. Następnie plik ten jest przepuszczany przez kompilator Leksa, który w wyniku tego tworzy program w C l e x . y y . c. Program l e x . y y . c zawiera reprezentację w postaci tablicy diagra mów przejść stworzonych na podstawie wyrażeń regularnych z pliku l e x . 1 oraz stan dardowe procedury używające tej tablicy do rozpoznawania leksemów. Akcje związane z wyrażeniami regularnymi w l e x . l zostają bezpośrednio przeniesione w odpowied nie miejsca do pliku l e x . y y . c . Na koniec, program l e x . y y . c jest kompilowany
symbol nastepny_symbol()
{ while (1) { switch (stan) { case 0: c = nastepny_znak (); /* c jest bieża.cym znakiem */ if
} r
else if (c == '< ) stan = 1; else if (c == '=') stan = 5; else if (c == '>') stan = 6; else stan = porażka (); break; .../* przypadki 1-8 */ case 9: c - następny-znak (); if (isletter(c)) stan = 10; else stan = porażka (); brak; case 10: c = nastepny_znak (); if (isletter (c)) stan = 10; else if (isdigit(c)) stan = 10; else stan = 11; break; case 11: cofnij (1); dodaj_id(); return ( dajsymbol{) ) .../* przypadki 12-24 */ case 25: c = nastepny_znak (); if (isdigit(c)) stan = 26; else stan ~ porażka (); break; case 26: c = następny^znak (); if (isdigit(c)) stan = 26; else stan = 27; break; case 27: cofnij (1); dodaj_liczbę (); return ( LICZBA ) ;
} } } Rys. 3.16. Kod w C dla analizatora leksykalnego
Program źródłowy w języku Lex lex.l
Kompilator Leksa
lex.yy.c
lex.yy.c
Kompilator C
a. out
Strumień wejściowy
a. out
Sekwencja symboli leksykalnych
Rys.
3.17.
T w o r z e n i e analizatora l e k s y k a l n e g o przy u ż y c i u L e k s a
do pliku wykonywalnego a . o u t , który jest już działającym analizatorem leksykalnym przekształcającym wejście w ciąg symboli leksykalnych. Specyfikacja dla L e k s a Program dla Leksa składa się z trzech części: deklaracje o o
reguły translacji %% o o
dodatkowe procedury Część pierwsza zawiera deklaracje zmiennych, literałów (literał jest identyfikatorem za deklarowanym do reprezentacji stałej) i definicji regularnych. Definicje regularne są in strukcjami podobnymi do tych z p. 3.3; są używane jako składniki wyrażeń regularnych pojawiających się w regułach translacji. Zasady translacji (część druga) są instrukcjami o postaci p p
{ akcja { akcja
p
{ akcja
x
2
n
x
2
n
} } }
W wyrażeniu tym p są wyrażeniami regularnymi, a akcja są fragmentami programu opi sującymi akcje, które analizator leksykalny powinien wykonać, gdy odpowiednie wzorce p zostaną dopasowane do Ieksemu. W Leksie akcje są zapisywane w C, jednak mogą być zapisane w każdym języku implementacji. Trzecia część zawiera wszystkie dodatkowe procedury wymagane przez akcje. Pro cedury te można również umieścić w oddzielnym pliku i kompilować niezależnie od samego analizatora leksykalnego. Analizator leksykalny stworzony przez Leksa współdziała z analizatorem składnio wym w następujący sposób. Po wywołaniu przez analizator składniowy, analizator leki
i
i
sykalny zaczyna wczytywać po kolei pozostałe znaki z wejścia, aż znajdzie najdłuż szy przedrostek wejścia pasujący d o jednego ze wzorców p Następnie wykonuje akcję akcja,}, która typowo przekazuje sterowanie z powrotem do analizatora składniowego. W przypadku wczytania znaków białych i komentarzy analizator leksykalny nie przekazu j e żadnych wartości analizatorowi składniowemu, a powraca do wyszukiwania kolejnego Ieksemu. Analizator leksykalny jako rezultat wywołania przekazuje analizatorowi składnio wemu pojedynczą wielkość, czyli symbol leksykalny. D o przekazania wartości atrybutu, zawierającej informację o leksemie, można użyć zmiennej globalnej yylval. r
P r z y k ł a d 3.11. Na rysunku 3.18 przedstawiono program w Leksie rozpoznający symbo le leksykalne z rys. 3.10 i zwracający znaleziony symbol. Przyglądając się temu kodowi, poznamy wiele ważnych cech Leksa. W części deklaracji znajdują się deklaracje literałów używanych w regułach transla cji . Deklaracje te są zapisane między specjalnymi znacznikami %{ oraz %}. Wszystko, co pojawia się między tymi znacznikami, jest bezpośrednio kopiowane do pliku analizatora leksykalnego lex.yy.c i nie jest traktowane ani jako definicje regularne, ani reguły translacji. W ten sam sposób są traktowane wszystkie procedury w części trzeciej. Na rysunku 3.18 w trzeciej części są dwie procedury, dodaj_id () i doda j_ liczbę (), używane w regułach translacji. Procedury te są kopiowane bez zmian do pliku lex. y y . c . W części deklaracji znajdują się również definicje regularne. Każda z nich składa się z nazwy i wyrażenia regularnego przypisywanego tej nazwie. Przykładowo, pierwsza przypisuje nazwie o g r a n i c z n i k klasę znaków [\t\n], czyli jeden z symboli: spacja, znak tabulacji (oznaczony \t) lub koniec linii (oznaczony \n). Druga definiuje znaki białe bz, czyli ciąg składający się z co najmniej jednego znaku ogranicznika. Zauważmy, że słowo ogranicznik, w Leksie, musi być zawarte w nawiasach klamrowych, aby było odróżnialne od napisu jedenastoliterowego ogranicznik. W definicji nazwy litera wykorzystywana jest klasa znaków. Skrót [ A - Z a - z ] oznacza jedną literę z wielkich liter od A do Z lub z małych od a do z. Piąta defi nicja (dla id) używa nawiasów klamrowych, które są metasymbolami w Leksie i, jak wcześniej, służą d o grupowania. Podobnie, kreska pionowa oznacza sumę logiczną. Ostatnia definicja regularna dla liczba zawiera dodatkowo inne metasymbole. Znak ? oznacza, jak wcześniej, jednokrotne wystąpienie elementu lub jego brak. Od wrotny ukośnik jest używany — gdy następny znak jest metasymbolem — d o przy wrócenia jego naturalnego znaczenia. Znak kropki dziesiętnej w definicji liczba jest wyrażany przez \ . , ponieważ sama kropka w Leksie i w wielu innych programach w systemie UNIX reprezentuje klasę wszystkich znaków oprócz znaku nowego wiersza. W klasie znaków [ + \ - ] przed minusem znajduje się znak odwrotnego ukośnika, po nieważ samodzielny minus jest używany d o oznaczania zakresu znaków, j a k na przykład w [A-Z] . 1
2
!
Program lex.yy.c często jest używany jako podprocedura analizatora składniowego generowanego przez program Yacc, czyli generator analizatorów składniowych omówiony w rozdz. 4. W tym przypadku deklaracje literałów mogą zostać dostarczone przez analizator składniowy i potem skompilowane razem z plikiem
2
W rzeczywistości Lex zinterpretuje poprawnie klasę znaków [ + - ] bez znaku odwrotnego ukośnika, ponieważ
lex . yy . c. minus znajdujący się na końcu nie może reprezentować zakresu.
/* definie je literałów LT, LE, EQ, NE, GT, GE, IF, THEN, ELSE, ID, LICZBA, OPREL */
/* definicje regularne */ ogranicznik [ \t\n] bz {ogranieznik}+ litera [A-Za-z] cyfra [0-9] id {litera}({litera}|{cyfra}) * liczba {cyfra}+(\.{cyfra}+)?(E[+\-]?{cyfra}+)? %%
{bz} if then else {id} {liczba} "<" "<=" "<>" ">" ">="
{/* brak akcji, brak powrotu */} {return(IF);} {return(THEN);} {return(ELSE);} {yylval = dodaj_id(); return (ID);} {yylval = dodaj_liczbe(); return(LICZBA);} {yylval = LT; return(OPREL);} {yylval - LE; return(OPREL);} {yylval = EQ; return(OPREL);} {yylval = NE; return(OPREL);} {yylval = GT; return(OPREL);} {yylval - GE; return(OPREL);}
g, o • o
doda j_id () { /* procedura dodaje do tablicy symboli leksem wskazywany przez wskaźnik yytext, którego długość jest równa yyleng, oraz zwraca wskaźnik do niego do tablicy symboli */
} dodaj_liczbe() { /* podobna procedura dodająca leksem będący liczbą */
}
Rys. 3.18. Program w Leksie dla symboli leksykalnych z rys. 3.10
Istnieje też inna możliwość nadania naturalnego znaczenia znakom, nawet jeśli są one metasymbolami — przez otoczenie ich cudzysłowami. Przykład takiej konwencji przedstawiliśmy, omawiając reguły translacji dla wszystkich sześciu operatorów relacyj nych . Rozważymy teraz reguły translacji znajdujące się w części występującej po pierw szych znakach %%. Pierwsza reguła oznacza, że po napotkaniu b z , czyli maksymalnej sekwencji odstępów, tabulacji i końców wierszy, nie wykonuje się żadnej akcji. W szcze gólności, nie wraca się do analizatora składniowego. Przypomnijmy, że analizator leksy kalny jest tak zbudowany, że rozpoznaje kolejne symbole leksykalne do chwili wywołania akcji zawierającej instrukcję return. Druga reguła oznacza, że po znalezieniu if jest zwracany symbol IF, który jest li terałem reprezentującym pewną liczbę całkowitą, przypisaną w analizatorze składniowym symbolowi leksykalnemu if. Dwie kolejne reguły, dla słów kluczowych then i else, są analogiczne. Akcja reguły dla id zawiera dwie instrukcje. Pierwsza przypisuje zmiennej yylval wartość zwracaną przez funkcję d o d a j _ i d . Definicja tej funkcji znajduje się w trze ciej części specyfikacji. Definicja zmiennej yylval pojawia się w pliku wynikowym Leksa lex . yy. c i jest dostępna z analizatora składniowego. Zadaniem yylval jest przechowywanie zwracanej wartości leksykalnej, która nie może być zwracana przez return (ID), ponieważ instrukcja ta może zwracać tylko klasę symbolu leksykalnego. Nie przedstawiliśmy zawartości funkcji doda j_ id. Jednak można przyjąć, że szuka ona w tablicy symboli Ieksemu pasującego d o wzorca dla id. Lex w trzeciej części specyfikacji umożliwia dostęp do aktualnego Ieksemu przez zmienne yytext i y y l e n g . Zmienna yytext odpowiada zmiennej, która wcześniej została nazwana początek-Ieksemu, czyli wskaźnikowi pierwszego znaku Ieksemu. y y l e n g jest z kolei liczbą całkowitą oznaczającą długość Ieksemu. Jeśli funkcja doda j_ id nie znajdzie, na przykład, identyfikatora w tablicy symboli, to może stworzyć dla niego nowy wpis. D o tablicy znaków zostanie wtedy skopiowane y y l e n g znaków, zaczynając od yytext i kończąc znacznikiem końca napisu z p. 2.7. Nowy wpis w tablicy symboli będzie wskazywał początek tej kopii. Liczby są traktowane w podobny sposób przez następną regułę. Ostatnie sześć re guł wykorzystuje zmienną yylval do przekazania kodu operatora relacyjnego, gdyż w każdym przypadku instrukcja return zwraca kod symbolu leksykalnego relop. Załóżmy, że analizator leksykalny, powstały ze specyfikacji z rys. 3.18, otrzymuje na wejściu sekwencję dwóch znaków tabulacji, liter if i odstępu. Dwie tabulacje są najdłuższym prefiksem wejścia pasującym do pojedynczego wzorca, w tym przypadku do b z . Akcja dla b z nic nie robi, więc analizator leksykalny przesuwa wskaźnik początku Ieksemu (yytext) na znak i i zaczyna szukać kolejnego symbolu leksykalnego. Następnym symbolem, który zostanie dopasowany, jest if. Zauważmy, że oba wzor ce i f oraz {id} pasują do tego Ieksemu i nie istnieje wzorzec pasujący do dłuższego napisu. Wzorzec if na rys. 3.18 występuje przed wzorcem dla identyfikatorów, więc wybierane jest słowo kluczowe if. Zwykle ta strategia rozstrzygania niejasności umoż1
1
Zrobiliśmy to, ponieważ znaki < i > są w Leksie metasymbolami. Otaczają one nazwy „stanów", umożliwiają cych zmianę stanu po napotkaniu pewnych symboli leksykalnych, jak komentarze czy napisy w cudzysłowach, które muszą być traktowane inaczej niż zwykły tekst. Nie ma potrzeby umieszczania znaku równości w cu dzysłowach, ale nie jest to też zabronione.
liwia łatwe wprowadzanie zarezerwowanych słów kluczowych przez wpisanie ich przed wzorcem dla identyfikatorów. Prześledźmy inny przykład. Załóżmy, że na wejściu znajdują się znaki <=. Chociaż do pierwszego znaku wejścia pasuje wzorzec <, to nie jest on najdłuższym dopasowaniem wzorca do prefiksu wejścia. Zatem strategia Leksa, polegająca na wybieraniu najdłuższe go prefiksu wejścia, umożliwia łatwe usunięcie konfliktów między < a <= i spodziewany wybór <= na następny symbol leksykalny. •
Operator kontekstu Jak przekonaliśmy się z podrozdziału 3.1, analizatory leksykalne dla niektórych kon strukcji w językach programowania muszą sprawdzać znaki znajdujące się za aktualnym leksemem zanim wyznaczą rodzaj symbolu leksykalnego. Przypomnijmy przykład dwóch instrukcji w Fortranie
DO 5 I - 1.2 5 DO 5 I = 1,25 Odstępy w Fortranie nie mają znaczenia, oprócz komentarzy i ciągów Holleritha, możemy więc założyć, że wszystkie możliwe odstępy są usuwane przed rozpoczęciem analizy leksykalnej. Powyższe instrukcje dla analizatora leksykalnego są widoczne jako
D05I=1.25 D05I=1,25 W pierwszej instrukcji, zanim nie wczytamy kropki dziesiętnej, nie możemy stwierdzić, że DO jest częścią identyfikatora D051. Natomiast w następnej instrukcji, DO jest słowem kluczowym. W Leksie istnieją wzorce o postaci r /r , gdzie r i r są wyrażeniami regularnymi. Napis pasujący do tego wzorca musi pasować do r , a napis występujący bezpośrednio p o nim musi pasować do r . Wyrażenie regularne r , występujące po operatorze / , oznacza prawy kontekst dopasowania. Przykładowo, specyfikacja dla Leksa d o rozpoznawania słowa kluczowego DO w powyższym kontekście ma postać l
2
{
2
{
2
2
DO/ ({litera} | {cyfra}) *= ({litera} | {cyfra}) *, Analizator leksykalny dla powyższej specyfikacji będzie szukał w buforze wejściowym sekwencji liter i cyfr, znaku równości, dalszych liter i cyfr oraz znaku przecinka, aby upewnić się, że nie jest to instrukcja przypisania. Dopasowany leksem będzie składał się jedynie ze znaków D i 0, które znajdują się przed / . Po skutecznym dopasowaniu wzorca zmienna yytext będzie wskazywała D, natomiast yyleng = 2. Zauważmy, że ten wzorzec kontekstowy rozpozna DO jako słowo kluczowe, gdy po nim występują bezwartościowe znaki, jak Z4 = 6Q. Jednak wzorzec ten nie rozpozna DO będącego częścią jakiegoś identyfikatora.
P r z y k ł a d 3.12. Operatora kontekstowego można użyć do innego trudnego problemu analizy leksykalnej w Fortranie — odróżniania słów kluczowych od identyfikatorów. Na przykład wejście
IF(I, J) = 3 jest całkowicie poprawnym przypisaniem w Fortranie, a nie instrukcją if. Jednym ze sposobów specyfikacji słowa kluczowego IF w Leksie jest zdefiniowanie jego możliwych prawych kontekstów przy użyciu operatora / . Najprostszą postacią instrukcji if jest IF
( warunek
)
instrukcja
W Fortranie 77 wprowadzono inną postać instrukcji if IF
( warunek ) THEN blok- then
ELSE blok- else
END IF Zauważmy, że każda instrukcja w Fortranie nie ma etykiety, zaczyna się od litery, i że po każdym prawym nawiasie używanym do wyboru indeksu tablicy lub grupowania ar gumentów musi występować symbol operatora, jak =, +, przecinek, inny prawy nawias lub koniec instrukcji. Za takim prawym nawiasem nie może występować litera. W tym przypadku, aby upewnić się, że IF jest słowem kluczowym, a nie nazwą tablicy, należy znaleźć prawy nawias, za którym przed znakiem końca wiersza występuje litera (za kładamy, że znak kontynuacji Fortranu anuluje znak końca poprzedniego wiersza). Taki wzorzec dla słowa kluczowego IF można zapisać jako
IF / \( .*\) {litera} Kropka oznacza „dowolny znak oprócz znaku końca wiersza", a znaki odwrotnego uko śnika przed nawiasami nakazują, aby Lex traktował j e jako zwykłe znaki nawiasów, a nie jako metasymbole do grupowania wyrażeń regularnych (patrz przykład 3.10). • Inna metoda radzenia sobie z problemem instrukcji if w Fortranie polega na tym, aby po wczytaniu IF ( sprawdzić, czy IF nie jest zadeklarowane jako tablica. Wyszu kiwanie pełnego wzorca przedstawionego powyżej odbywa się tylko w tym przypadku. Takie testy powodują trudniejszą implementację analizatora leksykalnego ze specyfika cji dla Leksa i mogą nawet zwiększać czas działania w przypadku długich programów. Aby przekonać się, czy takie testy należy zrobić, program symulujący diagramy przejść wykonuje częste sprawdzenia. Trzeba zwrócić uwagę na to, że rozbiór programu w For tranie na symbole leksykalne jest na tyle nieregularnym zadaniem, że często łatwiej jest napisać analizator leksykalny od początku w konwencjonalnym języku programowania, niż używać automatycznego generatora analizatorów leksykalnych.
3.6
Automaty skończone
Program rozpoznający dany język wczytuje z wejścia ciąg znaków x i zwraca „tak", jeśli ten ciąg jest sekwencją w tym języku, oraz „nie" w przeciwnym przypadku. Wyrażenie regularne może zostać skompilowane do takiego programu przez stworzenie uogólnio nego diagramu przejść, zwanego automatem skończonym. Automat skończony może być
deterministyczny lub niedeterministyczny. „Niedeterministyczny" oznacza, że z danego stanu może istnieć wiele przejść dla tego samego symbolu wejściowego. Zarówno deterministyczne i niedeterministyczne automaty skończone są w stanie właściwie rozpoznawać zbiory regularne. Należy jednak przyjąć kompromis między zło żonością czasową a pamięciową. Programy oparte na automatach deterministycznych mogą działać szybciej niż oparte na niedeterministycznych; wymagają jednak znacz nie więcej pamięci. W następnym podrozdziale przedstawiliśmy metody przekształcenia wyrażeń regularnych na oba rodzaje automatów skończonych. Konwersja na niedetermi nistyczny automat jest bardziej bezpośrednia, więc ten rodzaj automatów omówiliśmy jako pierwszy. Przykłady w tym i następnym podrozdziale dotyczą głównie języka opisanego wy rażeniem regularnym (a\b) * abb, składającego się ze zbioru wszystkich napisów utwo rzonych ze znaków a i b, kończącym się abb. Podobne języki pojawiają się w praktyce. Przykładowo, wyrażenie regularne dla wszystkich nazw plików kończących się na . o ma postać ( . | o | c) *. o, gdzie c oznacza dowolny znak oprócz kropki i o. Innym przykła dem są komentarze w C, rozpoczynające się znakami / * , po których występują dowolne znaki zakończone * / , a dodatkowym założeniem jest to, że żaden prefiks właściwy nie kończy się * / .
Niedeterministyczne automaty skończone Niedeterministyczny
automat skończony
(w skrócie NAS) jest matematycznym modelem
składającym się z: 1) 2) 3) 4) 5)
zbioru stanów S, zbioru symboli wejściowych £ (alfabetu symboli wejściowych), funkcji przejścia ruch przyporządkowującej parom stan-symbol zbiór stanów, stanu s , który jest nazywany stanem początkowym, zbioru stanów F, nazywanych stanami akceptującymi (lub końcowymi). 0
Niedeterministyczny automat skończony można przedstawić graficznie jako skierowany i etykietowany graf, zwany grafem przejść, w którym węzły są stanami, a etykietowane krawędzie reprezentują funkcję przejścia. Graf ten wygląda j a k diagram przejść, ale ten sam znak może stanowić etykietę dwóch lub więcej przejść wychodzących z jednego stanu, a krawędzie, oprócz symboli wejściowych, mogą zostać oznaczone symbolem e. Graf przejść dla N A S rozpoznającego język (a\b)*abb znajduje się na rys. 3.19. Zbiorem stanów tego automatu jest {0, 1, 2, 3 } , a alfabetem symboli wejściowych jest
Rys. 3.19. Przykład niedeterministycznego automatu skończonego
{a, b}. Stan 0 jest stanem początkowym, a stan 3 jest stanem akceptującym i jest zazna czony podwójnym okręgiem. Opisując N A S , używamy grafu przejść. W przypadku programu, funkcja przejść — jak się przekonamy — może zostać zaimplementowana kilkoma różnymi sposoba mi. Najprostszą implementacją jest tabela przejść, w której każdy wiersz oznacza stan, a każda kolumna — symbol wejściowy lub e. Komórka dla wiersza / oraz symbolu a jest zbiorem stanów (a dokładniej wskaźnikiem do zbioru stanów), do których można dotrzeć, gdy jest się w stanie i, a na wejściu znajduje się symbol a. Tabela przejść dla NAS z rys. 3.19 jest przedstawiona na rys. 3.20.
S Y M B O L WEJŚCIOWY STAN
0 1 2
a
b
{0,1}
{0} {2} {3}
Rys. 3.20. Tabela przejść dla automatu skończonego z rys. 3.19 Zaletą reprezentacji tablicy przejść jest możliwość szybkiego dostępu do przejścia z danego stanu dla danego znaku wejściowego. Wadą jest duża zajętość pamięci w przy padku, gdy alfabet wejściowy jest duży, a większość przejść prowadzi do zbiorów pu stych. Reprezentacja oparta na liście sąsiedztwa umożliwia zastosowanie bardziej zwartej implementacji, ale dostęp do tej funkcji jest wolniejszy. Oczywiste powinno być, że prze kształcenie jednej reprezentacji automatu skończonego na drugą jest łatwe. Niedeterministyczny automat skończony akceptuje napis wejściowy x wtedy i tylko wtedy, gdy w grafie przejść istnieje ścieżka prowadząca od stanu początkowego do stanu akceptującego, taka, że etykiety kolejnych stanów na tej ścieżce tworzą napis x. NAS z rysunku 3.19 akceptuje ciągi wejściowe abb, aabb, babb, aaabb, • • • Przykładowy ciąg aabb jest akceptowany przez ścieżkę ze stanu 0, następnie przez krawędź oznaczoną a z powrotem do stanu 0, a potem do stanów 1, 2 i 3 po krawędziach odpowiednio a, b i b. Ścieżka może być reprezentowana jako sekwencja przejść stanów zwanych ruchami. Poniższy diagram przedstawia ruchy do zaakceptowania napisu aabb:
Do stanu akceptującego może prowadzić więcej niż jedna ścieżka. Zauważmy, że dla ciągu wejściowego aabb można wykonać również kilka innych sekwencji ruchów, ale żadna z nich nie prowadzi do stanu akceptującego. Przykładowo, inna sekwencja ruchów dla aabb powraca ciągle do nieakceptującego stanu 0:
Język definiowany przez NAS jest zbiorem ciągów wejściowych, które on akceptuje. Nie jest trudno wykazać, że NAS z rys. 3.19 akceptuje (a\b) * abb.
Przykład 3.13. Na rysunku 3.21 przedstawiono NAS rozpoznający aa * \bb *. Ciąg aaa jest akceptowany przez przejście przez stany 0, 1, 2, 2 i 2. Etykiety znajdujące się na krawędziach to e, a, a i a, które po połączeniu dają aaa. Zauważmy, że e „znika" po tym połączeniu. ^ a
Rys. 3.21. NAS akceptujący
aa*\bb
Deterministyczne automaty skończone Deterministyczny automat skończony (w skrócie DAS) jest specjalnym przypadkiem niedeterministycznego automatu skończonego, w którym: 1) 2)
żaden stan nie ma e-przejść, czyli nie ma przejść dla wejścia e, dla każdego stanu s i symbolu wejściowego a istnieje co najmniej jedna krawędź z etykietą a wychodząca ze stanu s.
Deterministyczny automat skończony ma co najwyżej jedno przejście z każdego stanu dla ustalonego symbolu. Jeśli przedstawimy funkcję przejścia automatu w postaci tabeli, to każdy wpis w tabeli będzie pojedynczym stanem. Wtedy bardzo łatwo jest sprawdzić, czy DAS akceptuje napis wejściowy, ponieważ istnieje tylko jedna ścieżka od stanu początkowego po etykietach z tego napisu. Z powyższego algorytmu wynika, w jaki sposób można symulować zachowanie DAS na napisie wejściowym. Algorytm 3.1.
Symulacja DAS.
Wejście. Napis wejściowy x kończący się znakiem końca pliku eof. DAS D ze stanem początkowym s i zbiorem stanów akceptujących F. 0
Wyjście. Odpowiedź „tak", jeśli D akceptuje JC; w przeciwnym przypadku „nie". Metoda. Zastosuj algorytm z rys. 3.22 do napisu wejściowego x. Funkcja ruch(s, c) zwraca stan, do którego następuje przejście ze stanu s po pojawieniu się znaku c. Funkcja daj-znak() zwraca następny znak z ciągu wejściowego x. • Przykład 3.14. Na rysunku 3.23 przedstawiono graf przejść automatu deterministycz nego dla tego samego języka (a\b) * abb, który jest akceptowany przez NAS z rys. 3.19. Dla ciągu wejściowego ababb i tego automatu, algorytm 3.1 przechodzi przez stany 0, 1, 2, 1, 2, 3 i zwraca „tak". •
s:— s ; c := daj—znak; while c 7^ eof do s := ruch(s, c); c := zna/: end; if s e F then return „tak" else return „nie"; 0
Rys. 3.22. Symulacja DAS
a
a
Rys. 3.23. DAS akceptujący
(a\b)*abb
P r z e k s z t a ł c a n i e NAS n a DAS Zauważmy, że NAS z rysunku 3.19 ma dwa przejścia ze stanu 0 dla wejścia a, które prowadzą do stanów 0 i 1. Podobnie NAS z rysunku 3.21 zawiera dwa przejścia dla e ze stanu 0. Nie pokazaliśmy tego na rysunku, ale sytuacja, w której można wybrać przej ścia po e, może powodować niejednoznaczności. Sytuacje, w których funkcja przejścia ma wiele wartości, utrudniają symulację NAS w programie komputerowym. Definicja akceptacji polega jedynie na zapewnieniu istnienia ścieżki ze stanu początkowego do stanu akceptującego. Ale jeśli istnieje wiele ścieżek, które tworzą ten sam napis, może istnieć potrzeba rozważenia wszystkich możliwych ścieżek przed znalezieniem tej, któ ra prowadzi do stanu akceptującego, lub przed przekonaniem się, że taka ścieżka nie istnieje. Przedstawimy teraz algorytm konstrukcji DAS z NAS zyk. Ten algorytm, zwany często konstrukcją podzbiorów, jest NAS programem komputerowym. Bardzo podobny algorytm w konstrukcji analizatorów składniowych LR omówionych w
rozpoznający ten sam ję użyteczny w symulowaniu odgrywa podstawową rolę następnym rozdziale.
W tabeli przejścia NAS każdy wpis jest zbiorem stanów, podczas gdy wpisy dla DAS są pojedynczymi stanami. Ogólnie, metoda przekształcania NAS na DAS polega na tym, że każdy stan DAS odpowiada zbiorowi stanów NAS. Stany DAS śledzą wszystkie możliwe stany NAS, w których może się on znajdować po wczytaniu kolejnego symbolu wejściowego. Innymi słowy, po wczytaniu wejścia fl a *"fl/i DAS znajduje się w sta nie reprezentującym podzbiór T stanów NAS osiągalnych z jego stanu początkowego po ścieżkach etykietowanych symbolami a a • • -a . Liczba stanów DAS może być wykład nicza względem stanów NAS, ale ten najgorszy przypadek zdarza się rzadko. 1
x
2
n
2
A l g o r y t m 3.2.
(Konstrukcja
podzbiorów).
Konstrukcja DAS z N A S .
Wejście. NAS N. Wyjście. DAS D akceptujący ten sam język. Metoda. Algorytm konstruuje tabelę przejść Dprz dla D. Każdy stan DAS jest podzbio rem stanów N A S i Dprz jest konstruowane w ten sposób, że D symuluje „równolegle" wszystkie możliwe ruchy, które może wykonać N dla danego ciągu wejściowego. Użyjemy operacji z rysunku 3.24 do wyliczania zbiorów stanów NAS (s oznacza stan N A S , a T zbiór jego stanów).
OPIS
OPERACJA
e-domknięcie(s)
zbiór stanów N A S osiągalnych ze stanu s po samych e-przejściach
e-domknięcie(T)
zbiór stanów NAS osiągalnych ze stanów z T po samych e-przejściach
ruch(T, a)
zbiór stanów NAS, do których istnieje przejście dla sym bolu wejściowego a z dowolnego stanu z T Rys. 3.24. Operacje na stanach NAS
Przed zobaczeniem pierwszego symbolu, N może być w każdym ze stanów ze zbioru e~domknięcie(s^), gdzie s jest stanem początkowym N. Załóżmy, że T jest zbiorem dokładnie tych stanów, które są osiągalne z s dla danej sekwencji symboli i niech a będzie następnym symbolem wejściowym. Po wczytaniu a, N może wykonać przejście do każdego ze stanów ze zbioru ruch(T, a). Dstany, zbiór stanów D, oraz Dprz, tabelę przejść D, konstruujemy w następujący sposób. Każdy stan D odpowiada zbiorowi stanów N A S , w których może się znaleźć N po wczytaniu pewnej sekwencji symboli wejściowych, zawierającej wszystkie możliwe e-przejścia przed lub po wczytaniu symboli. Stanem początkowym D jest e-domknięcie(s ). Stany i przejścia są dodawane do D przy użyciu algorytmu z rys. 3.25. Stan z D jest akceptujący, gdy jest on zbiorem stanów N A S zawierającym co najmniej jeden stan ak ceptujący z N. 0
0
0
początkowo zbiór Dstany zawiera jedynie elementy z e-domknięcieisą) i wszystkie one są niezaznaczone; while w Dstany istnieje niezaznaczony stan T do begin
zaznacz T; for wszystkie symbole wejściowe a do begin U e-domknięcie(ruch(T, a)); if U g Dstany then
dodaj U jako niezaznaczony stan do Dstany; Dprz[T, a] := U end end Rys. 3.25. Konstrukcja podzbioru
Obliczanie e-domknięcia(T) jest typowym zadaniem poszukiwania węzłów danego grafu osiągalnych z danego zbioru węzłów. W tym przypadku stany z T są danym zbiorem węzłów, a graf składa się tylko z krawędzi z etykietami e z NAS. Prosty algorytm obliczający e-domknięcie(T) korzysta ze stosu do trzymania stanów, których krawędzie nie zostały sprawdzone na e-przejścia. Procedura taka jest przedstawiona na rys. 3.26. • umieść na stosie wszystkie stany T; inicjalizuj t-domknięcie(T) \- T\ while stos nie jest pusty do begin pobierz ze stosu f; for wszystkie krawędzie z t do u oznaczone e do if u j£ e-domknięcie{T) then begin dodaj u do e-domknięcie(T)\ umieść u na stosie end end Rys. 3.26. Obliczanie e-domknięcia
Przykład 3.15. Na rysunku 3.27 przedstawiono NAS N akceptujący język (a\b) * abb. (Ten automat jest jednym z omówionych w następnym podrozdziale; powstaje przez mechaniczne przetłumaczenie wyrażenia regularnego). Zastosujmy algorytm 3.2 do N. Stanem początkowym równoważnego DAS jest e-domknięcie(0), które jest równe A — = { 0 , 1 , 2 , 4 , 7 } , ponieważ są to stany osiągalne ze stanu 0 przez ścieżkę, której wszystkie krawędzie są oznaczone symbolami e. Zauważmy, że ścieżka może nie mieć w ogóle krawędzi, więc stan 0 może zostać osiągnięty z samego siebie taką ścieżką.
e
e Rys. 3.27. NAS N dla
(a\b)*abb
Alfabet symboli wejściowych stanowi w tym przypadku {a,b}. z rys. 3.25 należy zaznaczyć A i obliczyć e-domkniecie(ruch(A, a))
Według algorytmu
Najpierw obliczamy ruch(A,a), zbiór stanów W mających przejścia dla a z elementów z A. Ze stanów 0, 1, 2, 4 i 7 jedynie 2 i 7 mają takie przejścia do 3 i 8, więc 6-domknięcie(nłch({0,1,2,4,7},a))
- e-do mknie cie({3,8})
= {1,2,3,4,6,7,8}
Nazwijmy ten zbiór B. Zatem Dprz[A a] = 5 . Ze stanów z A, jedynie 4 ma przejście dla b do 5, więc DAS ma przejście dla Z? z A :
do C = e-domknięcie({5})
= {1,2,4,5,6,7}
Zatem Dprz[A, b] = C. Jeśli kontynuujemy ten proces z niezaznaczonymi zbiorami B i C, w końcu osiągnie my stan, w którym wszystkie stany DAS są zaznaczone. Jest to oczywiste, skoro istnieje „tylko" 2 różnych podzbiorów zbioru 11 stanów, a każdy zbiór raz zaznaczony jest zaznaczony do końca. W tym przypadku konstruuje się 5 różnych zbiorów stanów: 1 1
A = {0,1,2,4,7} *={1,2,3,4,6,7,8} C={1,2,4,5,6,7}
Z> = { 1 , 2 , 4 , 5 , 6 , 7 , 9 } £-{1,2,4,5,6,7,10}
Stan A jest stanem początkowym, a E jest jedynym stanem akceptującym. Pełna tabela przejść Dprz jest przedstawiona na rys. 3.28.
S Y M B O L WEJŚCIOWY STAN
A B C D E
a
b
B B B B B
C D C E C
Rys. 3.28. Tabela przejść Dprz dla DAS Na rysunku 3.29 widzimy graf przejść dla tego DAS. Należy zauważyć, że DAS z rys. 3.23 również akceptuje (a\b) * abb, a ma o jeden stan mniej. Kwestię minimalizacji liczby stanów DAS omówiliśmy w p. 3.9. • b
a a
Rys. 3.29. Rezultat zastosowania konstrukcji podzbiorów do automatu z rys. 3.27
3.7
Od wyrażeń regularnych do NAS
Istnieje wiele strategii budowy programów rozpoznających wyrażenia regularne, a każda z nich ma zalety i wady. Jedną ze strategii, używaną w programach do edycji tekstów, jest konstrukcja NAS z wyrażenia regularnego i następnie symulowanie zachowania NAS na napisie wejściowym przy użyciu algorytmów 3.3 i 3.4. Jeśli ważna jest szybkość dzia łania w czasie uruchamiania, można przekształcić NAS na DAS, używając konstrukcji podzbiorów omówionych powyżej. W podrozdziale 3.9 przedstawiliśmy alternatywną im plementację DAS na podstawie wyrażenia regularnego, w którym NAS nie jest konstru owany wprost; zakończyliśmy go dyskusją na temat złożoności czasowej i pamięciowej implementacji programów rozpoznających, bazujących na NAS i DAS.
K o n s t r u k c j a NAS z w y r a ż e n i a r e g u l a r n e g o Pokażemy teraz, w jaki sposób można skonstruować NAS z wyrażenia regularnego. Al gorytm ten istnieje w wielu odmianach, my przedstawiamy prostą wersję, łatwą do im plementacji. Algorytm jest sterowany składnią i używa składniowej struktury wyrażeń regularnych w procesie konstrukcji. Przypadki w algorytmie odpowiadają przypadkom w definicji wyrażenia regularnego (patrz p . 3.3). Najpierw omówiliśmy konstrukcję au tomatów rozpoznających e i poszczególne symbole alfabetu. Następnie opisaliśmy, jak konstruować automaty dla wyrażeń będących sumą, złączeniem lub domknięciem. Na przykład, dla wyrażenia r\s konstruujemy indukcyjnie NAS na podstawie dwóch NAS — dla r i s. W trakcie konstruowania, każdy krok wprowadza co najwyżej dwa nowe stany, więc wynikowy NAS dla danego wyrażenia regularnego ma co najwyżej dwukrotnie tyle stanów, ile w wyrażeniu regularnym znajduje się symboli i operatorów.
Algorytm 3.3.
(Konstrukcja
Thompsona).
Konstrukcja NAS z wyrażenia regularnego.
Wejście. Wyrażenie regularne r nad alfabetem E. Wyjście. N A S N akceptujący język L{r). Metoda. Najpierw analizujemy wyrażenie ze względu na podział na podwyrażenia. Na stępnie, używając przedstawionych niżej reguł 1. i 2., konstruujemy automaty dla każdego z symboli podstawowych występujących w r (są to e oraz symbole alfabetu). Symbole podstawowe odpowiadają punktom 1. i 2. definicji wyrażenia regularnego. Ważne jest zrozumienie, że jeśli symbol a pojawia się kilka razy w r, to dla każdego wystąpienia jest konstruowany oddzielny NAS. Następnie, na podstawie struktury składniowej wyrażenia r, łączymy indukcyjnie otrzymane NAS, używając reguły 3., aż do otrzymania automatu dla całego wyrażenia. Każdy pośredni NAS stworzony w tej konstrukcji odpowiada podwyrażeniu r i ma kilka ważnych własności: dokładnie jeden stan końcowy, żadna krawędź nie trafia do stanu początkowego i żadna krawędź nie wychodzi ze stanu końcowego.
1.
Dla e skonstruuj NAS: start
2.
Stan i jest stanem początkowym, a / jest stanem akceptującym. Oczywiste jest, że ten NAS rozpoznaje {e}. Dla a € Z skonstruuj NAS:
3.
Stan * ponownie jest stanem początkowym, a / stanem akceptującym. Automat ten rozpoznaje { a } . Załóżmy, że N(s) i N(t) są NAS dla wyrażeń regularnych s i t. a)
Dla wyrażenia regularnego s\t skonstruuj następujący złożony NAS
N(s\t):
start
Stan z jest stanem początkowym, a / stanem akceptującym. W automacie wyni kowym istnieje e-przejście z / do stanów początkowych N(s) i N(t). Ze stanów akceptujących N(s) i N(t) dodawane jest e-przejście do nowego stanu akceptu jącego / . Stany początkowe i końcowe automatów N(s) i N(t) nie są stanami początkowymi lub końcowymi automatu N(s\t). Zauważmy, że każda ścieżka z i do / może przejść albo przez N(s), albo przez N(t). Z tego wynika, że ten automat rozpoznaje L(s)\JL(t). b) Dla wyrażenia regularnego st skonstruuj następujący złożony NAS N(st):
Stan początkowy N(s) staje się stanem początkowym złożonego NAS, a stan akceptujący N(t) staje się stanem akceptującym nowego NAS. Stan akceptujący N(s) jest łączony ze stanem początkowym N(t). Oznacza to, że wszystkie przej ścia ze stanu początkowego N(t) stają się przejściami ze stanu akceptującego N(s). Nowo powstały połączony stan przestaje być stanem początkowym i ak ceptującym w nowym automacie. Ścieżka z i do / musi najpierw przejść przez N(s)> a potem przez N(t), by etykietami na tej ścieżce były napisy z L(s)L(t).
c)
Skoro żadna krawędź nie trafia do stanu początkowego N ( t ) i nie wychodzi ze stanu akceptującego N ( s ) , więc nie będzie możliwa ścieżka z i do / przecho dząca z N ( t ) z powrotem do N ( s ) . Zatem złożony NAS rozpoznaje L ( s ) L ( t ) . Dla wyrażenia regularnego skonstruuj następujący złożony NAS N ( s * ) :
€
Stan i jest stanem początkowym, / - stanem akceptującym. W wynikowym NAS możemy przejść bezpośrednio z i do / , wzdłuż krawędzi z etykietą e. Odpowiada to sytuacji e G (L(j))*. Drugą możliwością jest dotarcie z / do / , przechodząc jeden raz lub więcej przez automat N ( s ) . Oczywiste jest więc, że powyższy NAS rozpoznaje ( L ( s ) ) * . d) Dla wyrażenia regularnego w nawiasach (s), automatem wynikowym jest ten sam N(s). Za każdym razem, gdy tworzymy nowy stan, nadajemy mu nową nazwę. W ten spo sób żadne dwa stany jakichkolwiek składników NAS nie będą miały takiej samej nazwy. Nawet, jeśli ten sam symbol pojawia się w r kilka razy, za każdym razem tworzymy dla niego oddzielny NAS mający inaczej nazwane stany. • Można sprawdzić, że każdy krok konstrukcji w algorytmie 3.3 tworzy N A S rozpo znający poprawny język. Dodatkowo, konstrukcja ta tworzy NAS N ( r ) o następujących własnościach: 1.
2.
3.
N ( r ) ma co najwyżej dwa razy tyle stanów, co r zawiera symboli i operatorów. Wy nika to z faktu, że w trakcie każdego kroku konstrukcji tworzone były co najwyżej dwa nowe stany. N ( r ) ma dokładnie jeden stan początkowy i jeden akceptujący. Ze stanu akceptu jącego nie wychodzą żadne krawędzie. Ta własność jest zachowana dla każdego automatu składowego. Każdy stan automatu N ( r ) ma albo jedną wychodzącą krawędź dla symbolu z Z, albo co najwyżej dwie krawędzie wychodzące dla e-przejść.
P r z y k ł a d 3.16. Zastosujmy algorytm 3.3 do konstrukcji N ( r ) z wyrażenia regularnego r = (a\b) *abb. Na rysunku 3.30 widzimy drzewo wyprowadzenia dla /-, które jest ana logiczne do drzew wyprowadzeń dla wyrażeń arytmetycznych z p. 2.2. Dla składowej r akceptującej a, konstruujemy następujący NAS: v
10
r
n
r
%
\
I
6
'6
(
'3
)
I
X
D
\
I
I
a
b
Rys. 3.30. Rozkład wyrażenia (a\b) * abb Dla r
7
konstruujemy start
Możemy teraz połączyć N(r )
<2>
i tf(r ), używając reguły dla sumy zbiorów, aby otrzymać
x
2
NAS dla r — r \r 3
x
2
NAS dla ( r ) jest taki sam, jak dla r . NAS dla ( r ) * jest zatem następujący: 3
3
3
NAS dla r = a jest fi
start
Aby otrzymać automat dla r r , łączymy stany 7 i 7', oznaczając nowy stan 7 5
6
e
Kontynuując dalej ten proces, otrzymujemy NAS dla r, j — (a\b) * abb, który został przed stawiony na rys. 3.27. • Symulowanie N A S p r z y użyciu dwóch stosów Przedstawimy teraz algorytm, który dla danego NAS N skonstruowanego przez algorytm 3.3 i dla ciągu wejściowego x stwierdza, czy N akceptuje x. Algorytm wczytuje kolejne znaki z wejścia i oblicza całkowity zbiór stanów N, w których może się on znaleźć po wczytaniu kolejnych prefiksów wejścia. Algorytm ten korzysta ze specjalnych właściwo ści NAS stworzonych przez algorytm 3.3 do wydajnego obliczania zbiorów niedeterministycznych stanów. Implementacja tego algorytmu ma czas działania proporcjonalny do \N\ x gdzie \N\ jest liczbą stanów N, a \x\ jest długością x.
A l g o r y t m 3.4.
Symulacja NAS.
Wejście. NAS N skonstruowany przez algorytm 3.3 i ciąg wejściowy x. Zakładamy, że x jest zakończony znakiem końca pliku eof. Stanem początkowym N jest s , a zbiorem stanów akceptujących jest F. Q
Wyjście. Odpowiedź „tak", jeśli N akceptuje x, w przeciwnym przypadku „nie". Metoda. Zastosuj algorytm przedstawiony na rys. 3.31 do ciągu wejściowego x. Algorytm ten wykonuje w czasie działania konstrukcję podzbiorów. Przejście z aktualnego zbioru stanów S do następnego zbioru stanów jest obliczane w dwóch krokach. W pierwszym jest obliczany zbiór ruch(S, a), czyli zbiór wszystkich stanów, które można osiągnąć S := e-domknięcie({s })\ a :~ daj-znak; while a ^ eof do begin S := e-domknięcie(ruch(S, a)); a := daj-znak; end if SHF^0 then return „tak"; else return „nie"; 0
Rys. 3.31. Symulowanie NAS z algorytmu 3.3
z S przy przejściu dla a (aktualnie wczytanego znaku). Drugi krok polega na e-domknięcia zbioru ruch(S, a), czyli wszystkich stanów, które można osiągnąć przy dowolnej liczbie e-przejść. Algorytm wykorzystuje funkcję daj^znak do nia kolejnych znaków z JC. Po wczytaniu wszystkich znaków x, algorytm zwraca stan akceptujący znajduje się w zbiorze aktualnych stanów S\ w przeciwnym zwraca „nie".
obliczeniu z ruch(S, a) wczytywa „tak", jeśli przypadku •
Algorytm 3.4 może być wydajnie zaimplementowany przy użyciu dwóch stosów i wektora bitów indeksowanego stanami automatu. Pierwszy stos jest używany do utrzy mania aktualnego zbioru niedeterministycznych stanów, drugi stos jest używany do ob liczania następnego zbioru stanów niedeterministycznych. Do obliczenia e-domknięcia można użyć algorytmu z rys. 3.26. Wektor bitowy jest używany do określenia w sta łym czasie, czy stan niedeterministyczny znajduje się j u ż na stosie i wtedy nie trzeba dodawać go ponownie. Po obliczeniu następnego zbioru stanów na drugim stosie, role stosów są zamieniane. Ponieważ każdy niedeterministyczny stan ma co najwyżej dwie wychodzące krawędzie, każdy stan może powodować pojawienie się co najwyżej dwóch nowych stanów przy pojedynczym przejściu. Niech \N\ oznacza liczbę stanów N. Skoro na stosie znajduje się co najwyżej \N\ stanów, więc obliczenie następnego zbioru stanów na podstawie aktualnego wymaga czasu proporcjonalnego do \N\. Zatem, całkowity czas działania potrzebny do symulacji zachowania N dla wejścia x jest proporcjonalny do \N\ x |*|. Przykład 3.17. Niech N będzie NAS z rys. 3.27 i niech x będzie napisem składa jącym się z pojedynczego znaku a. Początkowym stanem jest e-domknięcie({0}) = = { 0 , 1 , 2 , 4 , 7 } . Dla symbolu wejściowego a istnieje przejście ze stanu 2 do 3 i z 7 do 8. Zatem T = { 3 , 8 } . Obliczając e-domknięcie T, otrzymujemy stany { 1 , 2 , 3, 4, 6, 7, 8 } . Skoro żaden z tych niedeterministycznych stanów nie jest akceptujący, więc algorytm zwraca „nie". Zauważmy, że algorytm 3.4 wykonuje konstrukcję podzbiorów w czasie działania. Porównajmy, na przykład, powyższe przejścia ze stanami DAS z rys. 3.29 skonstruowa nego z NAS z rys. 3.27. Stan początkowy i następny stan dla symbolu wejściowego a odpowiada stanom A i B automatu deterministycznego. • Kompromis między wymaganiami czasowymi a pamięciowymi Dla wyrażenia regularnego r i napisu wejściowego x mamy teraz dwie metody spraw dzania, czy x 6 L(r). Jedną jest użycie algorytmu 3.3, konstruującego NAS N z r. Tę konstrukcję można wykonać w czasie 0(\r\) gdzie \r\ jest długością r. N ma co najwyżej 2|r| stanów i co najwyżej dwa przejścia z każdego stanu, zatem tabela przejść dla N może być przechowana w obszarze pamięci 0(\r\). Możemy następnie użyć algorytmu 3.4 do określenia, czy N akceptuje x w czasie 0(\r\ x |JC|). Zatem, używając tej metody, można stwierdzić, czy x należy do L{r) w czasie proporcjonalnym do ilocznynu długości r i x. To podejście jest wykorzystywane w wielu edytorach tekstu do wyszukiwania wzorców wyrażeń regularnych, gdy napis x nie jest bardzo długi. Drugą metodą jest konstrukcja DAS z wyrażenia regularnego r przy zastosowaniu konstrukcji Thompsona i potem do otrzymanego NAS — konstrukcji podzbiorów, czyli y
algorytmu 3.2. (Implementacje pomijającą tworzenie wprost pośredniego NAS przedsta wiliśmy w p. 3.9). Implementując funkcję przejścia za pomocą tabeli, można wykorzystać algorytm 3.1 do symulowania DAS dla wejścia x w czasie proporcjonalnym do długości x, niezależnym od liczby stanów DAS. Ta metoda często jest wykorzystywana w programach dopasowywania wzorców przeszukujących pliki tekstowe według wyrażeń regularnych. Po skonstruowaniu automatu skończonego, proces wyszukiwania może działać bardzo szybko, więc sposób ten jest wydajny, gdy napis wejściowy jest bardzo długi. Istnieją jednak takie wyrażenia regularne, dla których najmniejszy DAS ma wykład niczą liczbę stanów względem rozmiaru wyrażenia regularnego. Na przykład, dla wyrażenia regularnego (a\b) *a(a\b)(a\b) - • • (a\b), zawierającego na końcu n— 1 podwyrażeń (a\b), nie istnieje DAS mający mniej niż 2 stanów. To wyrażenie re gularne oznacza wszystkie napisy złożone z a i b, w których /i-tym znakiem od pra wej jest a. Nie jest trudno udowodnić, że każdy DAS dla tego wyrażenia musi pamię tać n ostatnio wczytanych znaków. W przeciwnym przypadku zwracałby błędne odpo wiedzi. Oczywiste jest, że do pamiętania wszystkich możliwych n-elementowych cią gów a i b potrzeba co najmniej 2 stanów. Na szczęście wyrażenia takie nie pojawiają się często w analizie leksykalnej. Istnieją jednak zastosowania, w których takie wyrażenia są wykorzystywane. n
n
Trzecią możliwością jest użycie DAS, lecz z uniknięciem konstrukcji całej tabeli przejść. Stosowana jest tutaj technika „leniwego obliczania funkcji przejścia". Wartości funkcji przejścia są obliczane w czasie działania, ale przejście z konkretnego stanu dla danego znaku jest wyznaczane dopiero wtedy, gdy jest rzeczywiście potrzebne. Obliczone wartości funkcji przejścia są trzymane w pamięci pomocniczej. Za każdym razem, gdy przejście ma zostać wykonane, pamięć ta jest sprawdzana. Jeśli nie zawiera tego przejścia, to jest ono wyznaczane i dodawane do niej. Jeśli pamięć pomocnicza zapełnia się, to mogą z niej zostać usunięte poprzednio obliczone przejścia, aby zwolnić miejsce dla nowych przejść. Na rysunku 3.32 podsumowano wymagania pamięciowe i czasowe najgorszego przy padku dla algorytmów rozpoznających, czy napis wejściowy x należy do języka wyzna czonego przez wyrażenie regularne r, dla algorytmów opartych o automaty skończone deterministyczne i niedeterministyczne. Technika „leniwa" łączy wymagania pamięcio we metody bazującej na NAS z wymaganiami czasowymi metody korzystającej z DAS. Jej wymagania pamięciowe są proporcjonalne do rozmiaru wyrażenia regularnego plus rozmiar pamięci pomocnicznej, a obserwowany czas działania jest prawie taki sam, jak algorytmu opartego na DAS. W niektórych zastosowaniach technika „leniwa" jest znacz nie szybsza od metody bazującej na DAS, ponieważ nie traci się czasu na obliczanie przejść stanów, które nie są używane.
AUTOMAT
PAMIĘĆ
CZAS
NAS DAS
0(\r\) 0(2l'1)
0(\r\ x \x\) 0(\x\)
Rys. 3.32. Wymagania pamięciowe i czasowe potrzebne do rozpoznania wyrażenia regularnego
3.8
Projekt generatora analizatorów leksykalnych
W tym podrozdziale omówiliśmy projekt narzędzia programistycznego umożliwiającego automatyczną konstrukcję analizatorów leksykalnych na podstawie specyfikacji w języ ku Lex. Przedstawiliśmy kilka metod, jednak żadna z nich nie jest dokładnie taka, jak użyta w poleceniu Lex z systemu UNIX. Programy służące do konstrukcji analizatorów leksykalnych nazwaliśmy kompilatorami Leksa. Załóżmy, że specyfikacja analizatora leksykalnego ma postać p
{ akcja
p
{ akcja
p
{ akcjCln }
x
2
n
x
2
} }
gdzie, podobnie jak w p. 3.5, każdy wzorzec p jest wyrażeniem regularnym, a akcja akcja.} jest fragmentem programu wywoływanym, gdy wyrażenie p pasuje do Ieksemu na wejściu. {
t
Naszym celem jest skonstruowanie programu wyszukującego leksemy w buforze wejściowym. Jeśli zostaje dopasowany więcej niż jeden wzorzec, program powinien wy brać wzorzec dla najdłuższego Ieksemu. Jeśli kilka wzorców pasuje do najdłuższego Ieksemu, to wybierany jest ten, który w specyfikacji został wpisany jako pierwszy. Naturalnym modelem, na podstawie którego można zbudować analizator leksykal ny, jest automat skończony. Model używany w analizatorach wygenerowanych przez kompilator Leksa jest przedstawiony na rys. 3.33(b). Dwa wskaźniki: początku Iekse mu i przedni, wskazują bufor wejściowy, podobnie jak w p. 3.2. Kompilator Leksa — na podstawie wyrażeń regularnych ze specyfikacji dla Leksa — konstruuje tabelę przejść dla automatu skończonego. Analizator leksykalny zawiera symulator automatu skończo nego, używający tabelę przejść do wyszukiwania wzorców wyrażeń regularnych w bufo rze wejściowym. W dalszej części tego podrozdziału omówiliśmy implementacje kompilatora Leksa bazujące na automatach niedeterministycznych i deterministycznych. Przekonamy się, że tabela przejść NAS dla wzorców wyrażeń regularnych może być znacznie mniejsza niż dla automatu deterministycznego. Jednak zaletą DAS jest szybsze rozpoznawanie wzorców niż w przypadku NAS.
Dopasowywanie wzorców bazujące n a N A S Jedną z metod jest konstruowanie tabeli przejść dla niedeterrninistycznego automatu skończonego N dla złożonego wzorca P]\Po\" \PnMożna to wykonać, tworząc najpierw automaty N(p ) dla każdego wzorca p . przy użyciu algorytmu 3.3, a nas tępnie dodając nowy stan początkowy s i łącząc go ze wszystkimi stanami począt kowymi N(p ) za pomocą t-przejść. Graf dla takiego automatu przedstawiono na rys. 3.34. Do symulacji tego NAS można wykorzystać trochę zmodyfikowany algorytm 3.4. Modyfikacja ta zapewnia, że połączony NAS rozpoznaje najdłuższy prefiks wejścia pasuw
i
0
i
Specyfikacja dla Leksa
Tabela przejść
^ Kompilator Leksa (a) Kompilator Leksa
leksem
Bufor wejściowy
(b) Schemat analizatora leksykalnego Rys. 3.33. Model kompilatora Leksa
Rys. 3.34. NAS skonstruowany ze specyfikacji dla Leksa
jacy do któregoś ze wzorców. W połączonym NAS dla każdego wzorca p jest odrębny stan akceptujący. W trakcie symulacji NAS przy użyciu algorytmu 3.4 konstruujemy sekwencję zbiorów stanów, w których połączony NAS może się znaleźć po wczytaniu kolejnych znaków. Nawet, jeśli znajdziemy zbiór stanów zawierający stan akceptujący, to — aby znaleźć najdłuższe dopasowanie — musimy kontynuować symulację NAS aż osiągnie ona tzw. zakończenie, czyli zbiór stanów, z których nie ma przejścia dla aktual nego symbolu wejściowego. {
Domyślamy się, że specyfikacja dla Leksa jest tak zaprojektowana, że poprawny program źródłowy nie może całkowicie wypełnić bufora wejściowego przed osiągnięciem zakończenia przez NAS. Przykładowo, każdy kompilator nakłada pewne ograniczenia na długość identyfikatorów, a naruszenia tych ograniczeń zostają wykryte w momencie przepełnienia bufora wejściowego lub nawet wcześniej. Poprawne dopasowanie wzorca można znaleźć, wykonując dwie modyfikacje algo rytmu 3.4. Po pierwsze, gdy do aktualnego zbioru stanów jest dodawany stan akceptujący, zapisywana musi być aktualna pozycja i wzorzec p odpowiadający temu stanowi akcep tującemu. Po drugie, aktualny zbiór stanów zawiera już stan akceptujący, to zapisywany jest jedynie wzorzec pojawiający się wcześniej w specyfikacji dla Leksa. Po osiągnięciu zakończenia, wskaźnik przedni cofany jest do pozycji, na której pojawiło się ostatnie dopasowanie. Dopasowany wzorzec identyfikuje wtedy znaleziony symbol leksykalny, a dopasowanym leksemem jest napis między wskaźnikami początku Ieksemu a wskaźni kiem przednim. Zwykle specyfikacja dla Leksa jest tak pisana, że zawsze jakiś wzorzec, być może wzorzec błędu, zostaje dopasowany. Jeśli jednak nie zostaje dopasowany żaden wzorzec, to powstaje sytuacja, przed którą nie ma zabezpieczenia. Analizator leksykalny powinien wywołać w takim przypadku standardową procedurę obsługi błędów. i
P r z y k ł a d 3.18.
Prosty przykład ilustruje powyższe pomysły. Załóżmy, że mamy na
stępujący program w Leksie składający się z trzech wyrażeń regularnych i bez definicji regularnych: a abb a*b+
{ } /* akcje zostały tutaj pominięte * / { } {}
Trzy powyższe symbole leksykalne są rozpoznawane przez automaty z rys. 3.35(a). Trzeci automat jest trochę uproszczoną wersją automatu wygenerowanego przez algo rytm 3.3. Automaty z rys. 3.35(a) mogą zostać połączone, za pomocą metody opisanej powyżej, w pojedynczy N A S A' przedstawiony na rys. 3.35(b). Rozważmy zachowanie się automatu N na napisie wejściowym aaba, używając zmo dyfikowanego algorytmu 3.4. Na rysunku 3.36 przedstawiono zbiór stanów i dopasowane wzorce po wczytywaniu kolejnych znaków z wejściowego napisu aaba. Zbiorem stanów początkowych na tym rysunku jest {0, 1, 3, 7 } . Każdy ze stanów 1, 3 i 7 ma przejścia dla znaku a do stanu odpowiednio 2, 4 i 7. Ponieważ stan 2 jest stanem akceptującym dla pierwszego wzorca, więc zapamiętujemy, że pierwszy wzorzec został dopasowany po wczytaniu pierwszego a. Ze stanu 7 istnieje jednak przejście do stanu 7 dla drugiego znaku z wejścia, więc proces dopasowywania musi być kontynuowany. Dla symbolu wejściowego b wykonywa ne jest przejście ze stanu 7 do 8. Stan 8 jest stanem akceptującym dla trzeciego wzorca. Po osiągnięciu stanu 8 nie ma możliwości przejścia dla znaku wejściowego a, więc pro ces się kończy. Ostatnie dopasowanie nastąpiło po wczytaniu trzeciego znaku, algorytm informuje więc, że trzeci wzorzec został dopasowany do Ieksemu aab. • Rola akcji akcja związanej ze wzorcem p w specyfikacji dla Leksa jest następująca. Po dopasowaniu wzorca p analizator leksykalny wykonuje akcję akcja . Zauważmy, że {
t
i
t
3.8
125
PROJEKT GENERATORA ANALIZATORÓW LEKSYKALNYCH start start
start
/"~^v^
b
/y—^
Ą
(a) NAS połączony dla a, abb i a*b
(b) Połączony NAS Rys. 3.35. NAS rozpoznający trzy różne wzorce
Pi
Pl
brak
Rys. 3.36. Sekwencja zbiorów stanów w trakcie przetwarzania wejścia aaba
akcja} jest wykonywana dopiero po znalezieniu najdłuższego dopasowania, a nie po prostu po natrafieniu na akceptujący stan dla wzorca p . }
DAS dla a n a l i z a t o r ó w leksykalnych Inną metodą konstrukcji analizatorów leksykalnych ze specyfikacji dla Leksa jest wy korzystanie DAS do dopasowywania wzorców. Jedyną różnicą jest potrzeba upewnienia się, że znaleziono poprawne dopasowanie. Ta sytuacja jest analogiczna do właśnie opi sanej zmodyfikowanej symulacji NAS. Przy przechodzeniu z NAS do DAS przy użyciu algorytmu 3.2 konstrukcji podzbiorów, kilka stanów akceptujących może pojawić się w pewnym podzbiorze stanów niedeterministycznych. W takiej sytuacji wyższy priorytet
ma ten stan akceptujący, dla którego wzorzec znajduje się wcześniej w specyfikacji dla Leksa. Podobnie jak w symulacji NAS, proces powinien być tak długo kontynuowany, aż dotrze się do stanu, który dla aktualnego symbolu nie ma stanu następnego (czyli następ nym stanem jest 0 ) . Dopasowany leksem można znaleźć na podstawie pozycji z czasu ostatniego wejścia automatu do stanu akceptującego.
P r z y k ł a d 3.19. Jeśli przekształcimy NAS z rys. 3.35 na DAS, otrzymamy tabelę przejść jak na rys. 3.37 (nazwy stanów DAS odzwierciedlają zawartości zbiorów odpowiednich stanów NAS). Ostatnia kolumna na rysunku 3.37 oznacza jeden ze wzorców rozpoznany po przejściu do odpowiedniego stanu DAS. Na przykład, ze stanów NAS 2, 4 i 7 jedynie 2 jest akceptujący. Jest to stan automatu dla wyrażenia a z rys 3.35(a). Zatem automat w stanie 247 rozpoznaje wzorzec a.
S Y M B O L WEJŚCIOWY STAN
0137 247 8 7 58 68
a
b
247 7
8 58 8 8 68 8
_ 7 — -
W Z O R Z E C ZGŁOSZONY
brak a a*b brak a*b+ abb +
Rys. 3.37. Tabela przejść dla DAS
Zauważmy, że napis abb jest dopasowywany przez dwa wzorce: abb i a * roz poznawane przez N A S w stanach 6 i 8. Stan 68 DAS z ostatniego wiersza tabeli przejść zawiera zatem dwa akceptujące stany NAS. Zauważmy, że abb pojawia się przed a*b w regułach translacji ze specyfikacji dla Leksa, zatem zwracanym dopasowaniem dla tego stanu jest abb. Dla napisu wejściowego aaba DAS przechodzi przez stany odpowiadające stanom NAS z rys. 3.36. Rozważmy drugi przykład — napis wejściowy aba. DAS z rysunku 3.37 rozpoczyna w stanie 0137. Dla pierwszego symbolu wejściowego a przechodzi do stanu 247. Następnie dla b przechodzi do stanu 58, a dla kolejnego a nie istnieje następny stan. Doszliśmy więc do zakończenia, przechodząc przez stany DAS 0137, 247 i 58. Ostatni z nich zawiera stan akceptujący 8 NAS z rys. 3.35(a). Zatem DAS zwraca wzorzec a * fr w stanie 58 i wybiera na leksem ab, będący prefiksem wejścia, prowadzącym do stanu 58. • +
+
Implementacja operatora kontekstowego Przypomnijmy z podrozdziału 3.4, że operator kontekstowy / jest potrzebny w sytuacjach, w których wzorzec dla pewnego symbolu leksykalnego musi opisywać prawy kontekst dla aktualnego Ieksemu. Podczas przekształcania wzorca zawierającego / na NAS, można traktować / jak e, tak więc nie trzeba wyszukiwać / na wejściu. Jednak, w momencie
rozpoznania napisu w buforze wejściowym przy użyciu tego wyrażenia regularnego, koniec Ieksemu nie jest pozycją z przejścia przez stan akceptujący NAS. Jest to raczej ostatnie wystąpienie stanu NAS mającego przejście dla (wyobrażonego) / . P r z y k ł a d 3.20. NAS rozpoznający wzorzec dla IF z przykładu 3.12 jest przedstawiony na rys. 3.38. Stan 6 oznacza obecność słowa kluczowego IF. Jednak, aby znaleźć symbol leksykalny IF, musimy cofnąć się do ostatniego wystąpienia stanu 2. •
dowolny
Rys. 3.38. NAS rozpoznający słowo kluczowe IF z Fortranu
3.9
Optymalizacja dopasowywania wzorców bazujących na DAS
W tym podrozdziale przedstawiliśmy trzy algorytmy używane do implementacji i opty malizacji programów rozpoznających wzorce, skonstruowanych na podstawie wyrażeń regularnych. Pierwszy algorytm jest odpowiedni do użycia w kompilatorach Leksa, ponie waż konstruuje DAS bezpośrednio z wyrażenia regularnego, bez konstruowania w trakcie pośredniego NAS. Drugi algorytm minimalizuje liczbę stanów dowolnego DAS, więc może być użyty do zmniejszenia rozmiaru stanów każdego programu dopasowywania wzorców bazują cego na DAS. Algorytm ten jest wydajny i jego złożoność czasowa jest rzędu O(nlogn), gdzie n jest liczbą stanów DAS. Trzeci algorytm może zostać użyty do stworzenia szyb kich i zwartych reprezentacji tabeli przejść DAS zamiast bezpośrednio tablicy dwuwy miarowej. Ważne stany N A S Stan NAS nazywamy ważnym, jeśli ma on wychodzące przejście nie będące e-przejściem. Konstrukcja podzbiorów z rys. 3.25 wykorzystuje jedynie ważne stany z podzbioru T w trakcie wyznaczania e-domknięcie(ruch(T,a)), czyli zbioru stanów osiągalnych z T dla symbolu wejściowego a. Zbiór ruch{s,d) jest niepusty tylko wtedy, gdy stan s jest ważny. Podczas konstrukcji, dwa podzbiory mogą być połączone w jeden wtedy, gdy zawierają te same stany ważne i albo oba zawierają, albo żaden nie zawiera stanów akceptujących NAS. Konstruując podzbiory na podstawie NAS otrzymanego z wyrażenia regularnego za pomocą algorytmu 3.3, możemy skorzystać ze specjalnych właściwości tego NAS w celu połączenia dwóch konstrukcji. Złożona konstrukcja wiąże ważne stany NAS z symbolami wyrażenia regularnego. Konstrukcja Thompsona buduje ważny stan tylko dla symbolu
z alfabetu występującego w wyrażeniu regularnym. Na przykład, dla (a\b) * abb, ważne stany będą konstruowane dla każdego a i b. Oprócz tego, wynikowy NAS ma dokładnie jeden stan akceptujący, ale stan ten nie jest ważny, gdyż nie wychodzą z niego żadne przejścia. Dodając na koniec wyrażenia regularnego r unikalny znacznik końca #, dodajemy do stanu akceptującego r przejście dla #. W ten sposób ten stan staje się stanem ważnym NAS dla r#. Innymi słowy, używając rozszerzonego wyrażenia regularnego możemy — w trakcie konstrukcji podzbiorów — zapomnieć o stanach akceptujących. Po zakończeniu konstrukcji, każdy stan DAS z przejściem dla # musi być stanem akceptującym. Rozszerzone wyrażenie regularne jest reprezentowane za pomocą drzewa składnio wego z liśćmi dla symboli podstawowych i operatorami w węzłach wewnętrznych. Węzły wewnętrzne, w zależności od występującego w nich operatora, będziemy nazywać: wę złem złączenia, sumy i domknięcia, dla — odpowiednio — złączenia i operatorów | oraz *. Na rysunku 3.39(a) przedstawiono drzewo składniowe dla rozszerzonego wyra żenia regularnego z węzłami złączenia oznaczonymi kropkami. Drzewo składniowe dla wyrażenia regularnego można skonstruować w ten sam sposób, co drzewo składniowe dla wyrażeń arytmetycznych (patrz rozdz. 2). Liście w drzewie składniowym dla wyrażenia regularnego są oznaczone symbolami alfabetu lub e. Do każdego liścia nie oznaczonego e zostaje przyporządkowana unikalna liczba całkowita oznaczająca pozycję liścia i jego symbolu. Powtarzany symbol może mieć więc wiele pozycji. Pozycje na rys. 3.39(a) opisano pod symbolami. Ponumerowane stany z NAS z rys. 3.39(c) odpowiadają pozycjom liści w drzewie składniowym z rys. 3.39(a). To, że te stany są stanami ważnymi NAS, nie jest zbiegiem okoliczności. Stany nieważne na rys. 3.39(c) opisano wielkimi literami alfabetu. DAS z rysunku 3.39(b) można otrzymać z NAS z rys. 3.39(c), stosując konstrukcję podzbiorów i identyfikując podzbiory zawierające te same stany ważne. Ta identyfikacja powoduje powstanie o jeden stan mniej, w porównaniu z automatem z rys. 3.29.
Od wyrażenia regularnego do DAS W tym podrozdziale omówiliśmy skonstruowanie DAS bezpośrednio z rozszerzone go wyrażenia regularnego (r)#. Należy rozpocząć od konstrukcji drzewa składniowe go T dla (r)# i obliczenia czterech funkcji: zawiera-pusty, pierwsze^poz, ostatnie-poz i następne-poz, przechodząc przez drzewo T. Funkcje zawiera-pusty, pierwsze^poz i ostatnie-poz są zdefiniowane dla węzłów w drzewie składniowym i są używane do obliczania następne^poz, która jest zdefiniowana dla zbioru pozycji. Przypominając równoważność między ważnymi stanami NAS i pozycjami liści drze wa składniowego dla wyrażenia regularnego, można połączyć konstrukcję NAS z budową DAS, którego stany będą odpowiadać zbiorom pozycji w drzewie. e-Przejścia z N A S re prezentują pewną, dość skomplikowaną strukturę pozycji. Szczególnie kodują informację dotyczącą tego, kiedy jedna pozycja może występować po innej. Inaczej mówiąc, każdy symbol z napisu wejściowego dla DAS może być dopasowany przez pewne pozycje. Sym bol wejściowy c może zostać dopasowany jedynie przez pozycje, w których znajduje się c, ale nie każda pozycja z c musi pasować do konkretnego pojawienia się c w strumieniu wejściowym.
Pojęcie dopasowania pozycji do symbolu wejściowego zdefiniujemy przy użyciu funkcji następne-poz na pozycjach drzewa składniowego. Jeśli / jest pozycją, to następne-poz(i) jest zbiorem pozycji j takich, że istnieje napis wejściowy • • - o / - • dla którego i odpowiada temu wystąpieniu c, a j temu wystąpieniu d. P r z y k ł a d 3.21, Na rysunku 3.39(a) funkcja następne-poz(l) = { 1 , 2 , 3 } . Wyjaśnienie jest następujące: wczytanie a z pozycji 1 oznacza wczytanie wyrażenia a\b z domknięcia (a\b)*. Następna pozycja może pochodzić z kolejnego wystąpienia a\b (stąd w zbiorze wynikowym 1 i 2) albo z dalszej części wyrażenia znajdującego się za (a\b)* (stąd pozycja 3). • 4
Do obliczenia funkcji następne-poz potrzebna jest znajomość pozycji, którym od powiada pierwszy lub ostatni symbol napisu wygenerowanego przez dane podwyrażenie
wyrażenia regularnego. (Informacja tego typu wykorzystywana była nieformalnie w przy kładzie 3.21). Jeśli r* jest takim podwyrażeniem, to każda pozycja, która może znaleźć się na początku r, jest następną dla ostatniej pozycji z r. Podobnie, jeśli rs jest pod wyrażeniem, to dla każdej ostatniej pozycji z r, następną pozycją jest każda pierwsza pozycja z s. W każdym węźle n drzewa składniowego dla wyrażenia regularnego, funkcja pierwsze-poz(n) jest zdefiniowana jako zbiór pozycji, które mogą odpowiadać pierwszemu sym bolowi z napisu wygenerowanego z podwyrażenia z węzła n. Podobnie ostatnie-poz jest zdefiniowana jako zbiór pozycji, które mogą odpowiadać ostatniemu symbolowi z te go napisu. Na przykład, jeśli n jest korzeniem całego drzewa z rys. 3.39, to pierwsze~poz{n) — { 1 , 2 , 3 } , a ostatnie-poz(n) = { 6 } . Podamy teraz algorytm obliczania tych funkcji. Do obliczenia pierwsze-poz i ostatnie-poz potrzebna jest informacja, które z węzłów są korzeniami dla podwyrażeń generujących języki zawierające napis pusty. Dla takich węzłów funkcja zawiera-pusty przyjmuje wartość logiczną prawda, dla po zostałych — fałsz. Możemy teraz podać reguły obliczania funkcji zawiera-pusty, pierwsze^poz, ostat nie-poz i następne-pozPierwsze trzy funkcje można obliczyć za pomocą reguły dla pojedynczego symbolu podstawowego i trzech reguł indukcyjnych umożliwiających ob liczanie wartości funkcji w drzewie składniowym od liści do korzenia. Poszczególne reguły indukcyjne odpowiadają operatorom sumy, złączenia i domknięcia. Reguły dla zawiera-pusty i pierwsze^poz są przedstawione na rys. 3.40. Reguły dla ostatnie-poz (n) różnią się od tych dla pierwsze-poz (n) jedynie zamianą miejscami c i c i dlatego nie zostały pokazane. Pierwsza reguła dla zawiera-pusty oznacza, że jeśli n jest liściem z etykietą e, to zawiera-pusty (n) jest z pewnością prawdą. Druga reguła oznacza, że jeśli n jest liściem o etykiecie będącej symbolem alfabetu, to zawiera-pusty(n) jest fałszem. W tym x
zawiera-
Węzeł n n jest liściem z etykietą e n jest liściem z etykietą pozycja /
2
pierwsze-poz{n)
pusty(n)
true
0
false
0}
zawiera-pusty(c ) or zawiera-pusty(c ) }
pierwsze-poz(c ) }
U
pierwsze-poz{c ) 2
2
n C*j
© n
©
zawiera-pusty(c j) and zawiera-pusty(c ) 2
if zawiera-pusty(c ) then pierwsze-poz(c ) U pierwsze-poz(c ) else pierwsze-poz(c ) x
x
2
{
f*j pierwsze-poz(c )
true
Rys. 3.40. Reguły obliczania zawiera-pusty
x
i
pierwsze-poz
przypadku liść oznacza pojedynczy symbol wejściowy i nie może generować e. Ostatnia reguła dla zawiera-pusty oznacza, że jeśli n jest węzłem domknięcia z węzłem potomnym c , to zawiera-pusty (ri) jest prawdą, ponieważ domknięcie generuje język zawierający e. Innym przykładem jest reguła czwarta dla pierwsze-poz, oznaczająca, że jeśli n jest węzłem złączenia z lewym dzieckiem c i prawym dzieckiem c i jeśli zawiera-pusty (c ) jest prawdą, to x
x
2
x
pierwsze-poz{n) = pierwsze-poz{c ) U pierwszepoz{c ) W przeciwnym razie, pierwsze~poz{n) = pierwsze-poz(c ). Reguła ta stwierdza, że jeśli w wyrażeniu rs, r może generować e, to pierwsze pozycje s mogą „być widoczne przez" r i są wtedy również pierwszymi pozycjami rs. W przeciwnym razie, pierwszymi pozycjami rs są tylko pierwsze pozycje r. Uzasadnienia dla pozostałych reguł dla zawiera-pusty i pierwsze-poz są analogiczne. Funkcja następne-poz(i) opisuje, które pozycje mogą występować bezpośrednio za pozycją i w drzewie składniowym. Dwie poniższe reguły definiują wszystkie możliwości. x
2
x
1.
Jeśli n jest węzłem złączenia z lewym dzieckiem c i prawym c , / należy do zbioru ostatnie-poz(c ), to wszystkie pozycje z pierwsze-poz(c ) znajdują się w następne-poz(i). Jeśli n jest węzłem domknięcia i i należy do zbioru ostatnie-poz(n), to wszystkie pozycje z pierwsze-poz(n) znajdują się w następne-poz(i)x
x
2.
2
2
Jeśli pierwsze-poz i ostatnie-poz zostały policzone w każdym węźle, to następne-poz dla każdej pozycji mogą zostać obliczone w trakcie przejścia w głąb drzewa składnio wego. P r z y k ł a d 3.22. Na rysunku 3.41 przedstawiono wartości funkcji pierwsze-poz (po lewej stronie węzła n) i ostatnie-poz (po prawej) we wszystkich węzłach drzewa z rys. 3.39(a). Przykładowo, pierwsze-poz dla pierwszego z lewej węzła oznaczone go a stanowi zbiór {!}, ponieważ pozycją tego węzła jest 1. Podobnie, pierwsze-poz {1,2,3}
.
{6}
{1,2,3} • {5} {1,2,3} . {4} {1,2,3} .
{3}
{1,2} * {1,2}
{6} # {6} {5} b {5}
{4} b {4} {3} a {3}
{1,2} | {1,2} {1} a {1}
{2} b {2}
Rys. 3.41. Funkcje pierwsze-poz
i ostatnie-poz dla węzłów drzewa składniowego dla (a\b) * abb#
dla drugiego węzła jest { 2 } , skoro ten liść odpowiada pozycji 2. Według reguły trzeciej z rys. 3.40, pierwsze-poz ich rodzica jest równe { 1 , 2 } . Węzeł dla * jest jedynym węzłem, dla którego zawiera,-pusty jest prawdą. Zatem, według czwartej reguły, pierwsze-poz dla rodzica tego węzła (reprezentującego wyra żenie (a\b)*a) jest sumą zbiorów {1,2} i {3}, będących zbiorami pierwsze-poz dla lewego i prawego dziecka. Natomiast, według analogicznej reguły dla ostatnie-poz — skoro dla liścia z pozycji 3 zawiera-pusty jest fałszem — to ostatnie-poz dla rodzica węzła domknięcia zawiera jedynie 3. Policzmy teraz następne-poz z dołu do góry dla wszystkich węzłów drzewa skła dniowego z rys. 3.41. W węźle domknięcia dodajemy 1 i 2 do następne-poz(l) i do następne^poz{2) według reguły 2. Dla rodzica węzła domknięcia dodajemy 3 do następne-poz(\) i następne-poz(2) według reguły 1. W następnym węźle złączenia, używając reguły 1., dodajemy 4 do następne-poz(3). Na następnych dwóch węzłach złączenia do dajemy 5 do następne-poz(4) i 6 do następne-poz(5), używając tej samej reguły, i otrzy mujemy w pełni skonstruowaną funkcję następne-poz. Funkcja ta jest przedstawiona na rys. 3.42.
WĘZEŁ
1 2 3 4 5 6
następne-poz {1, 2, 3} {1, 2, 3} {4} {5} {6}
Rys. 3.42. Funkcja następne-poz Funkcja następne-poz może zostać przedstawiona za pomocą grafu skierowanego, którego każdy węzeł odpowiada pojedynczej pozycji. W grafie istnieje krawędź z węzła i do 7 , jeśli j należy do następne-poz{i). Na rysunku 3.43 przedstawiono ten graf dla następne-poz z rys. 3.42.
• © — 0 — ©
Rys. 3.43. Graf skierowany dla funkcji
następne-poz
Warto zauważyć, że ten graf może stać się NAS bez e przejść dla wyrażenia regu larnego, jeśli: 1)
wszystkie pozycje z pierwsze-poz
korzenia staną się stanami początkowymi,
2) 3)
każda krawędź (/, j) będzie miała etykietę będącą symbolem z pozycji j , pozycja dla # stanie się jedynym stanem akceptującym.
Nie powinno być teraz niespodzianką stwierdzenie, że graf dla następne-poz można prze kształcić na DAS przy użyciu konstrukcji podzbiorów. Pełną konstrukcję można przepro wadzić na samych pozycjach przy użyciu poniższego algorytmu. • A l g o r y t m 3.5.
Konstrukcja DAS z wyrażenia regularnego r.
Wejście. Wyrażenie regularne r. Wyjście. DAS D rozpoznający
L(r).
Metoda. 1. 2. 3.
Skonstruuj drzewo składniowe dla rozszerzonego wyrażenia regularnego w któ rym # jest unikalnym znacznikiem końca dołączonym do (r). Skonstruuj funkcje zawiera-pusty, pierwsze-poz, ostatnie-poz i następne-poz, wy konując przechodzenie drzewa T w głąb. Skonstruuj Dstany, zbiór stanów D oraz Dprz, tabelę przejść dla D, przy użyciu pro cedury z rys. 3.44. Stany z Dstany są zbiorami pozycji. Początkowo wszystkie stany są „niezaznaczone". Stan staje się „zaznaczony" przed momentem rozważania wy chodzących z niego przejść. Stanem początkowymi) staje siępierwsze-poz(korzeń), a stanami akceptującymi — wszystkie stany zawierające pozycję związaną ze znacz nikiem #. •
P r z y k ł a d 3.23. Skonstruujmy DAS dla wyrażenia regularnego (a\b)*abb. Drzewo składniowe dla ((a\b) * abb)# jest przedstawione na rys. 3.39(a). Funkcja zawiera-pusty jest prawdą tylko dla węzła *. Funkcje pierwsze-poz i ostatnie-poz są przedstawione na rys. 3.41, a następne-poz na rys. 3.42. Z rysunku 3.41 wiemy, że pierwsze-poz dla korzenia jest równe { 1 , 2, 3 } . Niech zbiór ten będzie oznaczony A. Rozważmy symbol a. Symbolowi temu odpowiadają po zycje 1 i 3, więc niech B = następne-poz(l) U następne-poz(3) = {1, 2, 3, 4 } . Skoro ten zbiór pojawił się po raz pierwszy, przypiszmy Dprz[A, a] := B. początkowo jedynymi stanami niezaznaczonymi w Dstany jest pierwsze-poz(korzeń), gdzie korzeń jest korzeniem drzewa składniowego dla (r)#; while w Dstany znajduje się stan niezaznaczony T do begin zaznacz T\ for a in symbole wejściowe do begin niech U będzie zbiorem pozycji z następne-poz(p) dla tych p z T, dla których na pozycji p jest a; ifU^0 and U g Dstany then dodaj U do Dstany jako stan niezaznaczony; Dprz[T a] :=U end end t
Rys. 3.44. Konstrukcja DAS
Rozważając na wejściu b, zauważmy, że z pozycji w A tylko 2 odpowiada więc należy rozważyć zbiór następne ~poz{2) — { 1 , 2, 3 } . Skoro ten zbiór zdążył się już pojawić, więc nie jest dodawany do Dstany, ale dodawane jest przejście Dprz[A, b] :~ A. Zajmiemy się teraz zbiorem B — {1, 2, 3, 4 } . Kontynuując algorytm, otrzymujemy stany i przejścia, jak na rys. 3.39(b). •
Minimalizacja liczby s t a n ó w DAS Ważnym wnioskiem teoretycznym jest stwierdzenie, że dla każdego zbioru regularnego istnieje rozpoznający go DAS o minimalnej liczbie stanów, który jest unikalny, jeśli nie uwzględnia się nazw stanów. Wyjaśnimy teraz, jak można skonstruować minimalny DAS, maksymalnie redukując liczbę stanów danego DAS, bez zmiany rozpoznawanego języka. Załóżmy, że M jest DAS ze stanami ze zbioru S i alfabetem wejściowym E. Załóżmy również, że każdy stan m a przejście dla każdego symbolu. Jeśli tak nie jest, można dodać nowy „martwy" stan d, z przejściami z d do d dla wszystkich symboli oraz z s do d dla symbolu a, dla wszystkich stanów s i symboli a, dla których nie ma przejścia z s. Dany napis w odróżnia stan s od t i, jeśli zaczyna działanie DAS M w stanie s, po wczytaniu wejścia w, to automat zakończy działanie w stanie akceptującym, a zaczy nając w stanie t — nieakceptującym, albo odwrotnie. Przykładowo, e odróżnia każdy akceptujący stan od każdego nieakceptującego, a w DAS z rys. 3.29 stany A i B są odróżnialne dla wejścia bb, ponieważ z A dochodzi się do stanu nieakceptującego C, a z B do akceptującego E. Nasz algorytm minimalizacji liczby stanów DAS polega na znajdowaniu wszystkich grup stanów, które są odróżnialne dla pewnych napisów wejściowych. Każda grupa sta nów, które nie mogą być odróżnione, jest następnie zamieniana w pojedynczy stan. Dzia łanie algorytmu polega na utrzymywaniu i poprawianiu podziału zbioru stanów. Każda grupa stanów w danym podziale składa się ze stanów, które nie zostały jeszcze odróż nione, a każda para stanów pochodzących z różnych grup jest odróżniona dla pewnego napisu wejściowego. Początkowo, podział składa się z dwóch grup: stanów akceptujących i stanów nieakceptujących. Podstawowy krok algorytmu polega na wybraniu pewnej grupy stanów, np. A ~ {s , 5 , . . . ,s }, i pewnego symbolu wejściowego a, oraz sprawdzeniu, do jakich stanów można przejść z s ,... ,s dla symbolu a. Jeśli te przejścia prowadzą d o stanów, które należą do więcej niż jednej grupy z aktualnego podziału, to zbiór A musi zostać podzielony tak, aby przejścia z różnych podzbiorów A prowadziły do różnych grup obec nego podziału. Załóżmy na przykład, że s i s dla symbolu wejściowego a prowadzą do stanów t i t , a t i t znajdują się w różnych grupach podziału. Zatem zbiór A musi zostać podzielony na co najmniej dwa podzbiory, tak aby s i 5 trafiały do różnych podzbiorów. Zauważmy, że t i r są odróżnialne dla pewnego napisu w, więc s i s są odróżnialne dla napisu aw. x
k
2
{
k
{
x
2
{
2
0
x
x
?
9
x
2
Proces podziału grup jest powtarzany, aż nie będzie można już podzielić żadnej grupy. Na razie wykazaliśmy, dlaczego stany należy dzielić na odrębne grupy, które są odróżnialne, nie udowodniliśmy jednak, dlaczego stany z jednej grupy po zakończeniu algorytmu będą nieodróżnialne dla każdego napisu wejściowego. Tak jest faktycznie, lecz dowód tego pozostawiamy Czytelnikowi zainteresowanemu teorią (patrz, na przykład, Hopcroft i Ullman [1979]). Czytelnikowi pozostawiamy również udowodnienie, że DAS
skonstruowany przez branie po jednym stanie z każdej grupy i odrzucanie stanów mart wych i nieosiągalnych ze stanu początkowego m a co najwyżej tyle stanów, ile dowolny automat akceptujący ten sam język. A l g o r y t m 3.6.
Minimalizacja liczby stanów DAS.
Wejście. DAS M ze zbiorem stanów 5, zbiorem symboli wejściowych £ , przejścia zdefi niowane dla wszystkich stanów i symboli, stan początkowy s , zbiór stanów akceptują cych F. 0
!
Wyjście. DAS M rozpoznający ten sam język, co M i mający minimalną liczbę stanów. Metoda. 1.
Skonstruuj początkowy podział IT zbioru stanów na dwie grupy: stany akceptujące F i nieakceptujące S — F. Zastosuj procedurę z rys. 3.45 d o TI w celu skonstruowania nowego podziału n n o w y . Jeśli n y — n , niech n = IT, przejdź do kroku 4. W przeciwnym przypadku,
2. 3.
k o ń c o w
n 0 W
przypisz IT : = n
n
o
w
y
i przejdź do 2.
Wybierz z każdej grupy podziału n pojedynczy stan jako reprezentanta dla tej grupy. Reprezentanci będą stanami DAS M . Niech s będzie reprezentantem. Załóżmy, że dla wejścia a istnieje przejście z s do t. Niech r będzie reprezentantem dla grupy zawierającej t (tir mogą być tym samym stanem). Wtedy M' ma przejście z s do r dla symbolu a. Niech stanem początkowym M' będzie reprezentant grupy zawierającej stan początkowy s dla M i niech stanami akceptującymi M' będą reprezentanci należący do F. Zauważmy, że każda grupa z n ma albo same stany z F, albo żadnych stanów z F.
4.
k o ń c o w y
l
0
k o i
5.
c o w y
Jeśli M' jest stanem martwym, czyli nieakceptującym stanem d, który dla wszystkich symboli m a przejścia jedynie d o siebie, to d jest usuwany z M . Z M' należy usunąć również stany, które nie są osiągalne ze stanu początkowego. Wszystkie przejścia do d z innych stanów stają się niezdefiniowane. • !
foreach grupa G w IT do begin
podziel G na takie podgrupy, że dwa stany s i / znajdują się w tej samej podgrupie wtedy i tylko wtedy, gdy dla wszystkich symboli wejściowych a, stany s i / mają przejścia dla a do stanów z tej samej grupy z IT, /* w najgorszym przypadku podgrupą będzie pojedynczy stan */ zastąp G w n l l o w y zbiorem wszystkich utworzonych podgrup end
Rys. 3.45. Konstrukcja n
nowy
P r z y k ł a d 3.24. Rozważmy DAS przedstawiony na rys. 3.29. Początkowy podział IT składa się z dwóch grup: stanu akceptującego (E) i stanów nieakceptujących (ABCD). Aby skonstruować n n o w y , algorytm z rys. 3.45 najpierw rozważa ( £ ) . Skoro grupa ta składa się z pojedynczego stanu, nie może zostać podzielona, zatem jest umieszczana w II owy. Algorytm następnie rozważa grupę (ABCD). Dla wejścia a, każdy z tych stanów n
ma przejście do B, więc na razie dla a nie trzeba dzielić tej grupy. Jednak dla wejścia b, stany A, B i C mają przejścia do grupy (ABCD), a D do E, czyli do innej grupy. Zatem grupa (ABCD) z n w y niusi zostać podzielona na (ABC) i D. n n o w y składa się z (ABC)(D)(E). W następnym przejściu algorytmu z rys. 3.45 ponownie nie ma podziału dla wejścia a. Dla b jednak należy podzielić grupę (ABC) na dwie grupy (AC) (B), ponieważ dla b, A i C mają przejście do C, a B do Z), czyli członka innej grupy niż C. Zatem nową wartością n jest (AC)(B)(D)(E). W kolejnym przejściu algorytmu z rys. 3.45 nie można podzielić żadnej z grup składających się z pojedynczego stanu. Jedyną możliwością jest podział (AC). Jednak dla symbolu a z obu stanów A i C istnieje przejście do stanu B, a dla b do stanu C. Zatem, po tym przebiegu n y = n. n n o w y jest zatem (AC)(B)(D)(E). Jeśli na reprezentanta (AC) wybierzemy A, a reprezentantami pozostałych grup zo staną B, D i E, to otrzymamy zredukowany automat, którego tabela przejść jest przed stawiona na rys. 3.46. Stan A jest stanem początkowym, a stan E jest jedynym stanem akceptującym. n 0
n 0 W
S Y M B O L WEJŚCIOWY STAN
A B D E
a
b
B B B B
A D E A
Rys. 3.46. Tabela przejść dla zredukowanego DAS
Stan E w zredukowanym automacie ma przejście do stanu A dla wejścia b, ponieważ A jest reprezentantem grupy dla C, a dla b w oryginalnym automacie istnieje przejście z E do C. Podobna zmiana pojawia się w przypadku stanu A i wejścia b. Wszystkie pozostałe przejścia są takie same, jak na rys. 3.29. Na rysunku 3.46 nie istnieje stan martwy i wszystkie stany są osiągalne ze stanu początkowego A. • Minimalizacja s t a n ó w w a n a l i z a t o r a c h leksykalnych Chcąc zastosować procedurę minimalizacji stanów do DAS skonstruowanego w p. 3.7, algorytm 3.5 należy zainicjować takim podziałem, żeby do różnych grup trafiały stany oznaczające różne symbole leksykalne. P r z y k ł a d 3.25. W przypadku DAS z rys. 3.37, początkowy podział zgrupuje 0137 z 7, skoro oba te stany nie oznaczają rozpoznania symbolu, a 8 zostanie zgrupowana z 58, skoro oba oznaczają symbol leksykalny a*b . Inne stany będą się znajdowały w grupach jednoelementowych. Natychmiast odkryjemy, że 0137 i 7 należą do innych grup, ponieważ przejścia z nich dla symbolu a prowadzą do różnych grup. Podobnie 8 i 58 nie należą do tej samej grupy z powodu przejść b do różnych grup. Zatem DAS z rys. 3.37 jest automatem o minimalnej liczbie stanów. • +
Metody kompresji tabeli przejść Jak już wiemy, istnieje wiele sposobów implementacji funkcji przejść dla automatu skoń czonego. Proces analizy leksykalnej zajmuje dużą część czasu kompilacji, ponieważ jest to jedyny proces czytający wszystkie znaki z wejścia. Zatem analiza leksykalna powin na minimalizować liczbę operacji, które są wykonywane dla pojedynczego znaku. Jeśli w implementacji analizatora leksykalnego jest używany DAS, to przydatna jest wydajna implementacja funkcji przejść. Dwuwymiarowa tablica, indeksowana stanami i znakami, zapewnia najszybszy dostęp, lecz wymaga również dużej ilości pamięci (na przykład, 100 stanów na 128 symboli wejściowych). Bardziej zwartą, lecz wolniejszą reprezentacją, jest powiązana lista przechowująca przejścia z każdego stanu, z „domyślnym" przejściem na końcu listy. Przejście najczęściej pojawiające się jest oczywiście wybrane na przejście domyślne. Istnieje jeszcze bardziej wyrafinowana implementacja łącząca szybki dostęp do re prezentacji tablicowej ze zwartością struktury listowej. Użyjemy struktury składającej się z czterech tablic indeksowanych numerami stanów, jak na rys. 3.47 . Tablica bazowy jest używana do wyznaczenia bazowego położenia wpisów dla każdego stanu, zapisanych w tablicach następny i sprawdzenie. Tablica domyślne jest używana do wyznaczenia alternatywnego położenia bazowego, w przypadku gdy wpisy z aktualnego położenia bazowego nie dają się zastosować. 1
domyślne
bazowy
następny
sprawdzenie
r
t
i
r
s
i
a
Rys. 3.47. Struktura danych do reprezentacji tabeli przejść
Obliczanie następnystan(s, a), przejście ze stanu s dla symbolu a, zaczynamy od sprawdzenia pary tablic następny i sprawdzenie. W szczególności, znajdujemy wpisy dla stanów s w położeniu / — Z^zowy^] + a , gdzie znak a jest traktowany jako liczba cał kowita. Za następny stan dla s i symbolu a bierzemy następny[l], jeśli sprawdzęnie[l] = = s. W przypadku sprawdzęnie[l] ^ s wyznaczamy ą = domyślny[s] i powtarzamy proce durę rekurencyjnie, używając ą zamiast s. Procedura ta jest następująca:
W praktyce pojawi się jeszcze jedna tablica indeksowana przez s, zawierająca wzorzec (o ile jest) dopa sowywany przy wchodzeniu do stanu s. Informacja ta jest otrzymywana ze stanów NAS tworzących stan s DAS.
procedurę następny stan(s, a); if sprawdzęnie[bazowy[s] +a]= s then return następny[bazowy[s] + a] else return następny _stan(domyślny[s\, a) Celem użycia struktury z rys. 3.47 jest spowodowanie, aby tablice następny i spraw dzenie były krótkie, do czego wykorzystujemy podobieństwo stanów. Przykładowo, stan q, standardowy dla stanu s, może być stanem oznaczającym, że „pracujemy nad identyfi katorem", tak jak stan 10 z rys. 3.13. Załóżmy, że trafiamy do s po zobaczeniu th, czyli prefiksu słowa kluczowego then, jak również identyfikatora. Dla znaku wejściowego e musimy przejść do specjalnego stanu pamiętającego, że widzieliśmy the, ale dla innych znaków stan s zachowuje się jak stan ą. Zatem ustawiamy sprawdzenie[bazowy[s] + e] na s i następny [bazowy [s] + e] na stan dla the. Wybór wartości dla tablicy bazowy, wykorzystujący wszystkie elementy tablic na stępny i sprawdzenie, może nie być możliwy. Z praktyki wiemy jednak, że prosta strategia ustawiania wartości w tablicy bazowy na najmniejsze wartości, tak aby nie było konflik tów między wpisami, działa całkiem dobrze i wykorzystuje niewiele pamięci (najmniej, jak to możliwe). Możemy skrócić tablicę sprawdzenie do tabeli indeksowanej stanami, jeśli DAS ma własność powodującą, że wszystkie przejścia prowadzące do każdego stanu t dotyczą tego samego symbolu a. Aby zaimplementować ten schemat, ustawiamy sprawdzenie[t] — — a i zastępujemy tekst z wiersza 2 procedury następny stan wierszem if sprawdzenie[następny[bazowy[s]
+a]\ — a then
ĆWICZENIA 3.1 Jaki jest wejściowy alfabet dla każdego z poniższych języków: a) Pascal, b) c) d) e)
C, Fortran 77, Ada, Lisp?
3.2 Jakie są konwencje użycia odstępów w każdym z języków z ćwiczenia 3.1? 3.3 Zidentyfikuj leksemy tworzące symbole leksykalne w poniższych programach. Po daj rozsądne wartości atrybutów dla tych symboli. a) Pascal
function { zwraca begin if i else end;
max { i, j : integer ) : iinteger większą z liczb i oraz j } > j then max max := j
b) C int max ( i, j ) int i, j ; /* zwraca większą z liczb i oraz
j */
{ return i>j?i : j ;
} c) Fortran 77 C
FUNCTION MAX { I, J ) ZWRACA W I Ę K S Z Ą Z LICZB I ORAZ J IF {I .GT. J) THEN MAX - I ELSE MAX = J END IF RETURN
3.4 Napisz program dla funkcji da j_ znak () z p. 3.4 przy użyciu schematu buforo wania z wartownikami z p. 3.2. 3.5 Ile dla napisu o długości n istnieje: a) prefiksów, b) sufiksów, c) podciągów spójnych, d) prefiksów właściwych, e) podciągów? *3.6 Opisz języki, jakie tworzą poniższe wyrażenia regularne: a) 0 ( 0 | 1 ) * 0 b) ( ( c | 0 ) l * ) * c) ( 0 | 1 ) * 0 ( 0 ) 1 ) ( 0 | 1 ) d) 0 * 1 0 * 1 0 * 10* e)
(00|11)*((01|10)(00|11)*(01|10)(00|11)*)*
*3.7 Zapisz definicje regularne dla następujących języków: a) wszystkie napisy złożone z liter zawierają kolejno pięć samogłosek, b) wszystkie napisy złożone z liter ustawionych w rosnącym porządku leksykograficznym, c) komentarze składają się z napisów otoczonych / * oraz * / , bez * / między nimi, ewentualnie z cudzysłowem " oraz " wewnątrz, *d) wszystkie napisy złożone z nie powtarzających się cyfr, e) wszystkie napisy złożone z cyfr z co najwyżej jedną powtarzającą się cyfrą, f) wszystkie napisy złożone z 0 i 1, z parzystą liczbą 0 i nieparzystą liczbą 1, g) zbiór ruchów w szachach, jak p—kA lub kbpxqn, h) wszystkie napisy z 0 i 1, nie zawierają spójnego podciągu 0 1 1 , i) wszystkie napisy z 0 i 1, nie zawierają podciągu 0 1 1 .
3.8 Wyspecyfikuj postać leksykalną stałych liczbowych dla języków z ćwiczenia 3.1. 3.9 Wyspecyfikuj postać leksykalną identyfikatorów i słów kluczowych dla języków z ćwiczenia 3.1. 3.10 Konstrukcje wyrażeń regularnych w Leksie są przedstawione na rys. 3.48 w ko lejności malejących priorytetów. W tabeli c oznacza dowolny pojedynczy znak, r — wyrażenie regularne, s — napis.
WYRAŻENIE
P A S U J E D O NIEGO
PRZYKŁAD
a
c
dowolny znak c nie będący operatorem
V
znak c
•
napis s każdy znak oprócz nowego wiersza
A
\*
początek wiersza
" if if » a.*b A
abc
$
koniec wiersza
abc$
[s]
dowolny znak w s
[abc]
dowolny znak spoza s
[ abc]
A
[ s] /-*
zero lub więcej r
a*
r+
jedno lub więcej r
a+
rl
zero lub jedno r
a?
od m do n razy r
a{l,5}
r, i potem r
ab a|b
r{m,
n}
rr x
2
2
1
>*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
2
m
s
143
ĆWICZENIA
odpowiadają odpowiednio napisowi pustemu i pełnemu słowu kluczowemu. Drze wem trie dla słowa kluczowego jest ababaa
Zdefiniujmy funkcję porażki f dla każdego stanu oprócz stanu początkowego. Za łóżmy, że stany s i t reprezentują prefiksy u i v słowa kluczowego. Zatem, f(s) = t wtedy i tylko wtedy, gdy v jest najdłuższym sufiksem właściwym u, który jest również prefiksem słowa kluczowego. Funkcja porażki / dla powyższego drzewa trie jest następująca: s f(s)
3 4 1 2
1 2 0 0
5 6 3 1
Przykładowo, stany 3 i 1 reprezentują prefiksy aba i a słowa kluczowego ababaa. Funkcja / ( 3 ) — 1, ponieważ a jest najdłuższym sufiksem właściwym aba, który jest prefiksem słowa kluczowego. a) Skonstruuj funkcję porażki dla słowa kluczowego
abababaab.
*b) Niech stanami w drzewie trie będą 0, 1 , . . . , m ze stanem początkowym 0. Wykaż, że algorytm z rys. 3.50 poprawnie oblicza funkcję porażki. *c) Wykaż, że instrukcja przypisania t : = f(t)
w wewnętrznej pętli w algorytmie
z rys. 3.50 jest wykonywana co najwyżej m razy. *d) Udowodnij, że algorytm działa w czasie
0(m).
/ * obliczanie funkcji porażki / dla b • • -b */ t:=0;/(l):=0; for s : = 1 to m — 1 do begin while t > 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(/?,
*3.29 Najkrótszym powtarzanym prefiksem dla napisu s nazwiemy najkrótszy prefiks u napisu s, taki, że s = u dla pewnego ^ 1. Przykładowo, a£> jest najkrótszym powtarzanym prefiksem abababab, a aba jest najkrótszym powtarzanym prefiksem aba. Skonstruuj algorytm znajdujący najkrótszy powtarzany prefiks dla napisu s w czasie Wskazówka: Użyj funkcji porażki z ćwiczenia 3.26. 3.30 Napis Fibonacciego jest zdefiniowany w następujący sposób: k
s —b x
s ~ s _i$ --? k
k
k
dla k > 2.
Przykładowo, s = a£>, s — aba i s — abaab. 3
a) Jaka jest długość
Ą
5
sl tl
**b) Jaki jest najkrótszy okres
sl n
c) Skonstruuj funkcję porażki dla s . *d) Wykaż, przy użyciu indukcji, że funkcja porażki dla s może zostać wyrażona wzorem f(j) = j — \s _ \, gdzie k spełnia \s \ ^ j+ 1 < dla 1 ^ j ^ 6
n
k
{
k
e) Zastosuj algorytm KMP do wyznaczenia, czy s jest spójnym podciągiem s 6
v
f) Skonstruuj DAS dla wyrażenia regularnego . * $ . *. **g) Jaka jest maksymalna liczba poszczególnych wywołań funkcji porażki w algo rytmie KMP, użytym do sprawdzenia, czy s jest spójnym podciągiem s ? 6
k
k+x
3.31 Drzewo trie i funkcja porażki z ćwiczenia 3.26 mogą zostać tak rozszerzone, by uwzględniały zbiór słów kluczowych. Każdy stan w drzewie trie odpowiada prefik sowi jednego lub więcej słów kluczowych. Stan początkowy odpowiada napisowi
pustemu, a stan odpowiadający pełnemu słowu kluczowemu jest stanem akceptu jącym. Dodatkowe stany mogą stać się stanami akceptującymi w trakcie obliczania funkcji porażki. Przykładowy diagram przejść dla zbioru słów kluczowych {he, she, his, hers} jest przedstawiony na rys. 3.52.
Rys. 3.52. Drzewo trie dla słów kluczowych {he, she, his, hers}
Dla drzewa trie zdefiniujemy funkcję przejścia g przypisującej parze stan-symbol taki stan, że g(s, bj+\) — jeśli stan s odpowiada prefiksowi b ---b• pewnego słowa kluczowego, a s' odpowiada prefiksowi b ---bjbj Jeśli s jest stanem początkowym, określamy g(s , a) — s dla wszystkich symboli wejściowych a, które nie są początkowym symbolem żadnego słowa kluczowego. Następnie, dla każdego przejścia niezdefiniowanego przypisujemy g(s, a) — porażka. Zauważmy, że ze stanu początkowego nie ma przejść porażka. x
l
Q
+v
Q
Q
Załóżmy, że stany s i t reprezentują prefiksy u i v dla pewnego słowa kluczowe go. Następnie, zdefiniujmy f(s) — t wtedy i tylko wtedy, gdy v jest najdłuższym sufiksem właściwym w, który jest również prefiksem pewnego słowa kluczowego. Funkcja porażki / dla powyższego diagramu przejść jest następująca: s
m
i
2
3 4
0
0 0
5 6
1 2
0
7
8 9
3 0
3
Przykładowo, stany 4 i 1 reprezentują prefiksy sh i h. Funkcja / ( 4 ) — 1, ponieważ h jest najdłuższym sufiksem właściwym sh, który jest prefiksem pewnego słowa kluczowego. Funkcja porażki / może zostać obliczona dla stanów w kolejności rosnącej ich głębokości przy użyciu algorytmu z rys. 3.53. Głębokość stanu jest jego odległością od stanu początkowego.
foreach stan s głębokości 1 do ;
f( ) := *o foreach głębokość d ^ 1 do foreach stan s głębokości d i znak a, takie, że g(s , a) — s' do begin s--=f(s ); while g(s, a) ~ porażka do s := f(s)\ f(s') -g(s, a); end s
d
d
d
Rys. 3.53. Algorytm obliczający funkcję porażki dla drzewa trie dla słów kluczowych
Zauważmy, że skoro g{s c) = porażka dla każdego znaku c, pętla while z rys. 3.53 na pewno się zakończy. Po przypisaniu f(s') do g(r, a), jeśli g(r, a) jest stanem końcowym, również stanie się stanem końcowym, jeśli jeszcze nie był. 0l
a) Skonstruuj funkcję porażki dla zbioru słów kluczowych {aaa, abaaa,
ababaaa}.
*b) Wykaż, że algorytm z rys. 3.53 poprawnie oblicza funkcję porażki. *c) Wykaż, że funkcja porażki może zostać policzona w czasie proporcjonalnym do sumy długości słów kluczowych. 3.32 Niech g będzie funkcją przejścia, a / funkcją porażki z ćwiczenia 3.31 dla słów kluczowych K — {y , y->, . . . , y }. Algorytm AC z rys. 3.54 wykorzystuje funkcje g i / do sprawdzenia, czy napis ci ---a zawiera spójny podciąg, który jest słowem kluczowym. Stan s jest stanem początkowym diagramu przejść dla K, a F jest zbiorem stanów końcowych. x
k
l
k
0
/* czy a • • a zawiera słowo kluczowe jako spójny podciąg */ x
n
s:=s ; for i':— 1 to n do begin while g(s, a ) — porażka do s — f(s)\ Q
{
if s e F then return „tak" end; return „nie"
Rys. 3.54. Algorytm AC
a) Zastosuj algorytm AC do napisu wejściowego ushers przy użyciu przejść i funkcji porażki z ćwiczenia 3.31. *b) Udowodnij, że algorytm AC zwraca „tak" wtedy i tylko wtedy, gdy pewne słowo kluczowe y- jest spójnym podciągiem a ---a . *c) Wykaż, że algorytm AC wykonuje co najwyżej 2n przejść między stanami w trakcie przetwarzania napisu długości n. *d) Wykaż, że diagram przejść i funkcja porażki dla zbioru słów kluczowych k {y,, y , y } może skonstruować DAS z co najwyżej £|y,| + l stanami t
2
x
n
k
w czasie liniowym dla wyrażenia regularnego . *(y, \y \ • • • \y ) • *. 2
k
e) Zmodyfikuj algorytm AC tak, aby wypisywał każde słowo kluczowe znalezione w danym napisie. 3.33 Używając algorytmu z ćwiczenia 3.32, skonstruuj analizator leksykalny dla słów kluczowych z Pascala. 3.34 Niech NWP(JC, y), najdłuższy wspólny podciąg dwóch napisów x i y, będzie pod ciągiem x i y, nie krótszym niż każdy taki podciąg. Przykładowo, tie jest naj dłuższym wspólnym podciągiem słów striped i tiger. Zdefiniujmy d(x, y), czyli odległość między x i y, jako minimalną liczbę wstawień i usunięć znaków potrzebnych do przekształcenia x na y, np. ^(striped,tiger) = 6.
147
ĆWICZENIA PROGRAMISTYCZNE
a) Wykaż, że dla dowolnych napisów x i y, odległość między x i y oraz dłu gość najdłuższego wspólnego podciągu spełniają równanie d(x, y) = |x| -f \y\ — - ( 2 * | N W P ( j c , y)|).
*b) Zapisz algorytm pobierający dwa napisy x i y, wyszukujący najdłuższy wspólny podciąg x i y. 3.35 Zdefiniujmy e(x, y), odległość edycji między napisami x i y, jako minimalną licz bę wstawień, usunięć i zamian znaków, które są wymagane do przekształcenia x w y. Niech x — a •••a \ y = b -'-b . Odległość e(x, y) można obliczyć za pomocą algortymu programowania dynamicznego przy użyciu tablicy odległości d[0..m, 0..n], w której d[i, j] jest odległością edycji między a ---a a b ---b-. Algorytmu z rys. 3.55 można użyć do obliczenia macierzy d. Funkcja zam jest kosztem zamiany znaków: zam{a bj) — 0, jeśli a — b^ w przeciwnym przypadku — zam(a bj) = 1. x
m
x
n
l
0
i
x
i
c
for / := 0 to m do d[i 0] := i\ for j := 0 to n do d[0, j) := j ; for z := 0 to m do for j' := 0 to n do y
D[i, j ] : = mm(d[i-1,
7 - 1] H-zainJ^, / ? ) , ;
+ d[i,
7-1] +
D
Rys. 3.55. Algorytm obliczający odległość edycji między dwoma napisami
a) Jaki jest związek między metryką odległości z ćwiczenia 3.34 a odległością edycji? b) Użyj algorytmu z rys. 3.55 do obliczenia odległości edycji między i
ababb
babaaa.
c) Skonstruuj algorytm wyznaczający minimalną sekwencję transformacji edycyj nych wymaganych do przekształcenia x na y. 3.36 Podaj algorytm, który dla napisu wejściowego x i wyrażenia regularnego r tworzy napis y z języka L(r) taki, że d(x, y) jest najmniejsze możliwe, gdzie d jest funkcją odległości z ćwiczenia 3.34.
ĆWICZENIA
PROGRAMISTYCZNE
P3.1 Napisz w Pascalu lub w C analizator leksykalny dla symboli
leksykalnych
z rys. 3.10. P3.2 Napisz specyfikację dla symboli leksykalnych z Pascala i na jej podstawie skon struuj diagramy przejść. Użyj tych diagramów do implementacji analizatora lek sykalnego dla Pascala w językach takich jak C lub Pascal. P3.3 Dokończ program w Leksie z rys. 3.18. Porównaj rozmiar i szybkość analizatora leksykalnego utworzonego przez Leksa i programu z ćwiczenia P 3 . 1 . P3.4 Napisz specyfikację dla Leksa dla symboli leksykalnych z Pascala, po czym użyj kompilatora Leksa do konstrukcji analizatora leksykalnego dla Pascala.
P3.5 Napisz program pobierający z wejścia wyrażenie regularne oraz nazwę pliku i wy pisującego wszystkie wiersze pliku, które zawierają podciąg spójny pasujący do wyrażenia regularnego. P3.6 Dodaj do programu z rys. 3.18 metody obsługi błędów umożliwiające kontynuację wczytywania leksemów w przypadku pojawienia się błędów. P3.7 Napisz analizator leksykalny na podstawie DAS z ćwiczenia 3.18 i porównaj go z analizatorami leksykalnymi z ćwiczeń P3.1 i P3.3. P3.8 Skonstruuj narzędzie generujące analizator leksykalny w oparciu na opisie symboli leksykalnych utworzonych za pomocą wyrażeń regularnych. UWAGI B I B L I O G R A F I C Z N E Restrykcje, nakładane na języki ze względów leksykalnych, często wynikają ze środowi ska, w którym dany język powstał. Kiedy w 1954 roku został zaprojektowany Fortran, najbardziej powszechnym nośnikiem wejściowym była karta perforowana. Odstępy w For tranie często były pomijane z tego powodu, że osoby piszące ręcznie na dziurkarkach klawiaturowych myliły liczby odstępów (Backus [1981]). Algol 58 oddzielał reprezen tację sprzętową od języka wzorcowego. Był to kompromis, przyjęty po tym, jak jeden z członków zespołu projektującego język nalegał: „Nie! Nie będę nigdy używał znaku kropki jako kropki dziesiętnej". (Wegstein [1981]). Knuth [1973a] przedstawił dodatkowe techniki buforowania wejścia. Feldman [1979b] omówił trudności praktycznego rozpoznawania symboli leksykalnych w Fortranie 77. Wyrażenia regularne były najpierw badane przez Kleene'a [1956], który interesował się opisem zdarzeń reprezentowanych przez model automatów skończonych o niespokoj nym działaniu (McCulloch i Pitts [1943]). Minimalizacja automatów skończonych była najpierw badana przez Huffmana [1954] i Moore'a [1956]. Równoważność automatów deterministycznych i niedeterministycznych pod względem rozpoznawania języków zo stała pokazana przez Rabina i Scotta [1959]. McNaughton i Yamada [1960] opisali algo rytm konstrukcji DAS bezpośrednio z wyrażenia regularnego. Więcej informacji o teorii wyrażeń regularnych można znaleźć w książce Hopcrofta i Ullmana [1979]. Szybko zdano sobie sprawę, że narzędzia do budowy analizatorów leksykalnych z wyrażeń regularnych będą użyteczne w implementacji kompilatorów. Johnson i inni [1968] omówili wczesny system tego typu. Lex, język omówiony w tym rozdziale, został stworzony przez Leska [1975] i wielokrotnie był używany do konstrukcji analizatorów leksykalnych w programach dla systemu UNIX. Schemat zwartej implementacji z p. 3.9 dla tabeli przejść został wymyślony przez S. C. Johnsona i użyty przez niego w im plementacji generatora analizatorów składniowych Yacc (Johnson [1975]). Inne metody kompresji tabeli omówili i ocenili Dencker, Durre i Heuft [1984]. Problem reprezentacji zwartej dla tabel przejść był badany teoretycznie dla opty malnych ustawień przez Tarjana i Yao [1979] oraz Fredmana, Komlósa i Szemerediego [1984]. Cormack, Horspool i Kaiserswerth [1985], opierając się na tych pracach, przed stawili idealny algorytm mieszający. Wyrażenia regularne i automaty skończone były stosowane nie tylko w kompilato rach. Wiele edytorów tekstowych używa wyrażeń regularnych do wyszukiwania. T h o m p son [1968], na przykład, opisuje konstrukcję NAS z wyrażenia regularnego (algorytm 3.3) w kontekście edytora tekstów QED. System UNIX ma trzy programy ogólnego
UWAGI BIBLIOGRAFICZNE
149
przeznaczenia służące do wyszukiwania wzorców: g r e p , e g r e p i f g r e p . Program g r e p nie pozwala na sumę i nawiasy grupujące w wyrażeniach regularnych, ale umoż liwia zastosowanie pewnej postaci ograniczonych odwołań wstecznych, podobnych do tych w Snobolu. Program g r e p używa algorytmów 3.3 i 3.4 do wyszukiwania wzorców. Wyrażenia regularne w e g r e p są podobne do tych z Leksa, oprócz iteracji i operatora kontekstowego. Program e g r e p używa DAS z leniwą konstrukcją stanów do wyszuki wania wzorców, jak w p. 3.7; f g r e p wyszukuje wzorce składające się ze zbioru słów kluczowych, używając algorytmu Aho i Corasicka [1975], który przedstawiono w ćwi czeniach 3.31 i 3.32. Aho [1980] omówił względną wydajność tych programów. Wyrażenia regularne często były wykorzystywane w dokumentacyjnych systemach informacyjnych, w językach zapytań do baz danych i w językach do przetwarzania pli ków, jak AWK (Aho, Kernighan i Weinberger [1979]). Jarvis [1976] używał wyrażeń regularnych do opisu wad obwodów drukowanych. Cherry [1982] używał algorytmu do pasowywania słów kluczowych (z ćwiczenia 3.32) do wyszukiwania złego stylu w ręko pisach. Algorytm dopasowywania wzorców z ćwiczeń 3.26 i 3.27 pochodzi od Knutha, Morrisa i Pratta [1977]. Artykuł ten zawiera również interesującą dyskusję na temat okresów w napisach. Inny wydajny algorytm dopasowywania napisów został wynaleziony przez Boyera i Moore'a [1977]. Pokazali oni, że dopasowywanie spójnego podciągu może być wyznaczone bez zbadania wszystkich znaków napisu docelowego. Harrison [1971] pokazał, że funkcje mieszające mogą być użyte do efektywnego wyszukiwania wzorców. Pojęcie najdłuższego wspólnego podciągu, omówionego w ćwiczeniu 3.34, zostało wykorzystane w programie d i f f z systemu UNIX, służącym do porównywania pli ków (Hunt i Mcllroy [1976]). Wydajny praktyczny algorytm obliczania najdłuższych wspólnych podciągów został opisany przez Hunta i Szymańskiego [1977]. Algorytm ob liczający minimalną odległość edycji z ćwiczenia 3.35 pochodzi od Wagnera i Fischera [1974]. U Wagnera [1974] znajduje się rozwiązanie ćwiczenia 3.36. Sankoff i Kruskal [1983] przedstawili fascynującą dyskusję na temat szerokiego zastosowania algorytmów rozpoznawania minimalnej odległości: od badań wzorców w sekwencjach genetycznych do problemów w przetwarzaniu mowy.
ROZDZIAŁ
4 Analiza składniowa
Dla każdego języka programowania istnieją zasady określające strukturę składniową pro gramów. W Pascalu, na przykład, program składa się z bloków, blok z instrukcji, in strukcje z wyrażeń, wyrażenia z symboli leksykalnych itd. Składnia konstrukcji języków programowania może być opisana gramatykami bezkontekstowymi lub za pomocą no tacji B N F (postać Backusa-Naura), wprowadzonymi już w p. 2.2. Gramatyki są bardzo użyteczne w projektowaniu języków i pisaniu kompilatorów. • •
•
•
Gramatyki umożliwiają stworzenie precyzyjnej i jednocześnie łatwej do zrozumienia specyfikacji składni języka programowania. Z gramatyk niektórych klas można automatycznie wygenerować wydajne analizato ry składniowe, sprawdzające czy program źródłowy jest poprawny pod względem składniowym. Dodatkową zaletą jest to, że podczas procesu konstrukcji analizatora składniowego można ujawnić niejednoznaczności składniowe oraz inne trudne do analizy konstrukcje, które mogą nie zostać wykryte w początkowej fazie projekto wania języka i jego kompilatora. Poprawnie zaprojektowana gramatyka wymusza odpowiednią strukturę języka pro gramowania, użyteczną przy translacji programu źródłowego do poprawnego kodu wynikowego i przy wykrywaniu błędów. Istnieją narzędzia przekształcające opisy ttanslacji bazujące na gramatykach do działających programów. Języki ewoluują w czasie, zyskując nowe konstrukcje i dodatkowe zadania. Te nowe konstrukcje można dodać do języka w prostszy sposób, jeśli obecna implementacja bazuje na opisie gramatyki tego języka.
Większa część tego rozdziału dotyczy metod analizy składniowej typowych dla kom pilatorów. Najpierw przedstawiliśmy pojęcia podstawowe, następnie metody odpowiednie do ręcznej implementacji i, w końcu, algorytmy używane w narzędziach automatycznych. Ponieważ programy mogą mieć błędy składniowe, rozszerzyliśmy metody analizy, tak aby mogły poradzić sobie z najczęściej występującymi błędami.
15J
4.1 ROLA ANALIZATORA SKŁADNIOWEGO
4.1
Rola analizatora składniowego
W omawianym modelu kompilatora analizator składniowy otrzymuje ciąg symboli lek sykalnych od analizatora leksykalnego (rys. 4.1) i sprawdza, czy ciąg ten może zostać wygenerowany przez gramatykę dla języka źródłowego. Analizator składniowy powinien również zgłaszać, w sposób zrozumiały, wszystkie błędy składniowe. Powinien także radzić sobie z najczęściej pojawiającymi się błędami, tak aby mógł przetworzyć resztę swoich danych wejściowych.
Program źródłowy
Symbol leksykalny Analizator składniowy
Analizator leksykalny Daj następny symbol
Pozostała Reprezentacja Drzewo część przodu wyprowa kompilatora pośrednia dzenia
Tablica symboli Rys. 4.1. U m i e j s c o w i e n i e analizatora s k ł a d n i o w e g o w m o d e l u kompilatora
Istnieją trzy główne typy analizatorów składniowych dla gramatyk. Za pomocą uniwersalnych metod analizy składniowej, jak algorytmy Cocke'a-Youngera-Kasamiego i Earleya, można zanalizować każdą gramatykę (patrz p. Uwagi bibliograficzne). Metody te jednak są zbyt mało wydajne do zastosowania w kompilatorach. Metody najczęściej używane w kompilatorach zostały sklasyfikowane jako zstępujące lub wstępujące. Jak wskazują ich nazwy, analizatory zstępujące tworzą drzewa wyprowadzeń od góry (ko rzenia) do dołu (liści), natomiast analizatory wstępujące zaczynają od liści i kończą w korzeniu. W obu analizatorach wejście jest przeglądane od lewej do prawej strony, po jednym symbolu. Najbardziej wydajne metody zstępujące i wstępujące działają tylko dla podklas gra matyk. Jednak niektóre z tych podklas, jak gramatyki LL i LR, są na tyle uniwersalne, że umożliwiają opisanie większości konstrukcji składniowych w językach programowa nia. Analizatory składniowe implementowane ręcznie najczęściej używają gramatyk LL. Przykładem jest metoda z p. 2.4. Analizatory dla szerszej klasy gramatyk LR są zwykle konstruowane przez automatyczne narzędzia. W tym rozdziale założyliśmy, że wyjście analizatora składniowego stanowi pew na reprezentacja drzewa składniowego dla strumienia symboli leksykalnych wygenero wanych przez analizator leksykalny. W praktyce, istnieje wiele zadań, które mogą być wykonywane podczas analizy składniowej, np. zbieranie do tablicy symboli informacji o różnych symbolach leksykalnych, sprawdzanie typów, inne elementy analizy seman tycznej lub generacja kodu pośredniego (patrz rozdz. 2). Na rysunku 4.1 wszystkie te działania umieściliśmy w bloku „pozostała część przodu kompilatora", a omówiliśmy je dokładnie w następnych trzech rozdziałach.
W pozostałej części tego rozdziału omówiliśmy błędy składniowe i ogólne strategie radzenia sobie z nimi. Dwie z tych strategii, nazywane trybem paniki oraz odzyskiwaniem kontroli na poziomie frazy, omówiliśmy dokładniej razem z poszczególnymi metodami analizy składniowej. Implementacja konkretnej strategii zależy od oceny osoby piszącej kompilator, dlatego podaliśmy tylko wskazówki dotyczące każdej metody.
O b s ł u g a błędów składniowych Gdyby kompilator musiał przetwarzać jedynie poprawne programy, jego projekt i imple mentacja bardzo by się uprościły. Jednak często programiści piszą niepoprawne programy i dobry kompilator powinien pomóc programiście w zidentyfikowaniu i zlokalizowaniu błędu. Zastanawiające jest to, że chociaż błędy są bardzo powszechne, to niewiele języ ków zostało tak zaprojektowanych, że z założenia potrafią j e obsługiwać. Nasza cywili zacja byłaby zupełnie inna, gdyby językom mówionym stawiać takie same wymagania składniowe, jak językom komputerowym. Większość specyfikacji języków programowa nia nie opisuje sposobu, w jaki kompilator powinien odpowiadać na błędy. W związku z tym, ustalenie tego pozostaje w gestii projektanta konkretnego kompilatora. Zaplano wanie obsługi błędów na samym początku upraszcza strukturę kompilatora i poprawia jego odpowiedź na ewentualne błędy. Programy, jak wiadomo, mogą zawierać błędy na wielu poziomach. Błędy mogą być: • • • •
leksykalne, np. błędnie wpisany identyfikator, słowo kluczowe lub operator, składniowe, np. wyrażenie arytmetyczne z niewyważonymi nawiasami, semantyczne, np. zastosowanie operatora do niekompatybilnego argumentu, logiczne, np. wywołanie rekurencyjne w nieskończonej pętli.
Zwykle większa część detekcji błędów i metod radzenia sobie z nimi znajduje się wewnątrz fazy analizy składniowej. Jednym z powodów jest to, że wiele błędów z natu ry jest składniowych lub pojawia się, gdy strumień symboli leksykalnych przychodzący z analizatora leksykalnego nie odpowiada regułom gramatycznym definiującym język programowania. Innym powodem jest precyzja nowoczesnych metod analizy składnio wej, ponieważ wykrywanie w nich obecności błędów składniowych jest bardzo wydaj ne. Dokładne wykrycie błędu semantycznego lub logicznego w czasie kompilacji jest znacznie bardziej skomplikowanym zadaniem. W tym podrozdziale przedstawiliśmy kil ka podstawowych technik obchodzenia się z błędami składniowymi. Ich implementację omówiliśmy w połączeniu z poszczególnymi metodami analizy składniowej zaprezento wanymi w tym rozdziale. Obsługa błędów w analizatorze składniowym ma proste do wyrażenia cele: •
Obecność błędów zgłaszać w sposób jasny i dokładny.
•
Szybko powracać do analizy składniowej reszty programu, tak aby móc wykryć
•
kolejne błędy. Nie spowalniać znacząco przetwarzania poprawnych programów.
Efektywna realizacja tych celów jest trudnym wyzwaniem. Na szczęście, zwykłe błędy nie są skomplikowane i stosunkowo prosty mechanizm ich obsługi najczęściej wystarcza. Zdarza się jednak, że błąd pojawia się na dużo wcze śniejszej pozycji niż ta, na której jest wykrywany, i dokładna ocena błędu może być
153
4.1 ROLA ANALIZATORA SKŁADNIOWEGO
trudna. W skomplikowanych przypadkach moduł obsługi błędów może starać się zgad nąć, co programista miał na myśli, pisząc program. Dzięki kilku metodom analizy składniowej, jak metody LL i LR, można wykryć pojawienie się błędu bardzo wcześnie. Mają one własność prefiksu żywotnego, oznacza jącą, że błąd jest wykrywany w chwili, gdy zauważą, że wczytany prefiks wejścia nie może być prefiksem żadnego napisu w tym języku.
P r z y k ł a d 4.1. Aby dobrze poznać rodzaje błędów taktycznie się pojawiających, przyj rzyjmy się błędom, które Ripley i Druseikis [1978] znaleźli w próbce programów stu denckich napisanych w Pascalu. Odkryli, że błędy nie pojawiają się często. Poprawnych składniowo i semantycznie było 6 0 % kompilowanych programów, a w pozostałych 4 0 % błędów nie było wiele. W 80% instrukcji, w których występowały, był to pojedynczy błąd, a w 13% były dwa. Poza tym, większość błędów była trywialna i 9 0 % dotyczyło pojedynczego symbolu leksykalnego. Wiele z tych błędów można łatwo zakwalifikować: 6 0 % to błędy związane ze zna kami przestankowymi, 20% to błędne użycia operatorów i ich argumentów, 15% to błędy słów kluczowych, a pozostałe 5% to błędy innego typu. Większość błędów pierwszego rodzaju to niepoprawne użycie średnika. Konkretny przykład pochodzi z programu w Pascalu. (1) (2) (3)
program drukmax(input, var x, y: integer;
(4) (5) (6) (7) (8) (9)
function max(i:integer; j:integer) {zwraca maksimum liczb i oraz j} begin if i > j then max := i else max := j end;
(10) (11) (12) (13)
output);
: integer;
begin readln (x, y) ; writeln (max (x, y) ) end.
Częstym błędem interpunkcyjnym jest użycie przecinka zamiast średnika w liście ar gumentów w deklaracji funkcji (np. użycie przecinka w miejscu pierwszego średnika w wierszu (4)). Innym błędem jest opuszczanie obowiązkowego średnika na końcu wier sza (np. na końcu wiersza (4)). Jeszcze innym jest dodanie średnika na końcu wiersza przed else (np. na końcu wiersza (7)). Jednym z powodów tak powszechnych błędów związanych ze średnikiem może być to, że w różnych językach średniki są używane w inny sposób. W Pascalu średnik od dziela instrukcje, a w PL/I i C kończy instrukcje. Z niektórych badań wynika, że druga konwencja powoduje mniej błędów (Gannon i Horning [1975]).
Typowym przykładem błędu związanego z operatorem jest ominięcie dwukropka w symbolu : = . Błędy w pisowni słów kluczowych pojawiają się rzadko, a typowym przykładem może być opuszczenie litery i we writeln. Wiele kompilatorów Pascala nie ma problemów z obsługą błędów polegających na najczęściej pojawiających się wstawieniach, usunięciach i zmianach elementów. W rze czywistości kilka kompilatorów Pascala może poprawnie skompilować powyższe progra my z pospolitymi błędami interpunkcji i operatorów. Kompilatory te wyświetlą jedynie ostrzeżenie o błędzie, wskazujące jego miejsce. Istnieje jednak inny powszechny rodzaj błędu, znacznie trudniejszy do naprawienia. Jest to brak begin lub end (np. brak wiersza (9)). Większość kompilatorów nie będzie nawet próbowała naprawiać takich błędów. • W jaki sposób moduł obsługi błędów powinien zgłaszać obecność błędu? Powinien, przynajmniej zgłosić miejsce w programie źródłowym, w którym błąd został wykryty. Istnieje duża szansa, że ten błąd pojawił się kilka symboli leksykalnych wcześniej. Po wszechną strategią wielu kompilatorów jest wypisanie wiersza zawierającego błąd wraz ze wskazaniem pozycji, na której błąd został wykryty. Jeśli istnieje rozsądne prawdopodo bieństwo, jaki to jest błąd, kompilator powinien również wypisać zrozumiały komunikat diagnostyczny, np. „brak średnika na tej pozycji". W jaki sposób analizator składniowy powinien odzyskać kontrolę po wykryciu błę du? Jak się przekonamy, istnieje wiele ogólnych strategii i żadna z nich nie jest zdecydo wanie lepsza od pozostałych. W większości przypadków analizator składniowy nie powi nien przerywać analizy po napotkaniu pierwszego błędu, ponieważ dalsze przetwarzanie może wykryć następne błędy. Zwykle analizator składniowy zawiera pewien mechanizm odzyskiwania kontroli, polegający na powrocie d o stanu, w którym przetwarzanie wejścia może być kontynuowane, mając nadzieję, że poprawne wejście może zostać poprawnie zanalizowane przez kompilator. Nieodpowiednia metoda odzyskiwania kontroli może wprowadzać irytującą lawi nę fałszywych błędów, nie popełnionych przez programistę, lecz powstałych w wyniku zmian wprowadzonych przez analizator w trakcie odzyskiwania kontroli. W podobny sposób, odzyskiwanie kontroli podczas analizy składniowej może spowodować wpro wadzenie błędów semantycznych, które zostaną później wykryte w trakcie analizy se mantycznej lub generacji kodu. Przykładowo, po napotkaniu błędu analizator składnio wy może ominąć deklarację pewnej zmiennej, powiedzmy bum. G d y później w wyra żeniach pojawia się bum, program jest poprawny składniowo. Ponieważ jednak nie ma wpisu w tablicy symboli dla bum, zostanie wygenerowany komunikat „bum nie jest zdefiniowane". Zachowawczą strategią jest blokowanie komunikatów o błędach pojawiających się w strumieniu wejściowym zbyt blisko siebie. Po znalezieniu jednego z błędów składnio wych, kompilator powinien poprawnie zanalizować kilka symboli leksykalnych, zanim zacznie wyświetlać kolejne komunikaty o błędach. W niektórych przypadkach błędów może być tak wiele, że kompilator nie będzie mógł rozsądnie kontynuować przetwarzania. (W jaki sposób, na przykład, kompilator Pascala powinien reagować na program źródło wy w Fortranie?). Wydaje się, że strategia odzyskiwania kontroli po wystąpieniu błędu powinna być kompromisem, uwzględniającym rodzaje błędów mogących się pojawić i to, czy można j e rozsądnie przetworzyć.
Jak już wspomnieliśmy, niektóre kompilatory próbują naprawiać błędy i zgady wać, co programista chciał napisać. Przykładem jest kompilator PL/C (Conway i Wilcox [1973]). Pomijając krótkie programy pisane przez studentów, rozbudowane poprawianie błędów nie jest najczęściej efektywne ze względu na koszty. W rzeczywistości, najważ niejsze stają się obliczenia interaktywne i dobre środowiska programistyczne, dlatego istnieje trend w kierunku mechanizmów zapewniających prostą obsługę błędów.
Strategie odzyskiwania kontroli Istnieje wiele ogólnych strategii, które analizator składniowy może wykorzystać, aby odzyskać kontrolę po wystąpieniu błędu. Chociaż żadna strategia nie okazała się uni wersalna, kilka z metod ma szerokie zastosowanie. Poniżej przedstawiamy następujące strategie: • • • •
tryb paniki, poziom frazy, produkcje dla błędów, korektę globalną.
Tryb paniki. Jest to najprostsza w implementacji strategia i może być użyta w więk szości metod analizy składniowej. Po natrafieniu na błąd, analizator usuwa symbole wej ściowe po kolei, aż natrafi na symbol ze zbioru wskazanych synchronizacyjnych symboli leksykalnych. Symbole synchronizacyjne są zwykle ogranicznikami, których rola w pro gramie źródłowym jest oczywista, jak na przykład średnik czy end. Projektant kom pilatora musi oczywiście wybrać właściwe symbole synchronizacyjne dla konkretnego języka. Mimo że korekta w trybie paniki często pomija dużą liczbę symboli wejściowych bez sprawdzania ich wpływu na wystąpienie innych błędów, jej zaletą jest prostota i — w przeciwieństwie do innych metod, które omówiliśmy poniżej — nic może się ona zapętlić. Jeśli w pojedynczej instrukcji kilka błędów pojawia się rzadko, metoda ta może być odpowiednia. Poziom frazy. Po odkryciu błędu, analizator składniowy może wykonać lokalne po prawki na pozostałej części wejścia, czyli zamienić prefiks pozostałej części wejścia na ciąg znaków umożliwiający kontynuację analizy. Typową poprawką jest zamiana prze cinka na średnik, usunięcie niepotrzebnego średnika lub wstawienie brakującego. Wybór poprawek lokalnych pozostawia się projektantowi kompilatora. Musimy oczywiście być ostrożni w wyborze zmian nie powodujących pętli nieskończonych, a które mogłyby się zdarzyć, gdyby przed aktualnym symbolem zawsze coś było dodawane. Ten typ zamiany może poprawić dowolny ciąg znaków wejściowych i może być wykorzystany w kilku kompilatorach naprawiających błędy. Strategię tę zastosowano naj pierw w analizatorach zstępujących. Jej główną wadą jest trudność, jaką ma w sytuacjach, w których właściwy błąd pojawia się przed miejscem, w którym zosta! wykryty. Produkcje dla błędów. Jeśli wiemy, gdzie pojawiają się najczęstsze błędy, może my ręcznie rozszerzyć gramatykę o produkcje generujące błędne konstrukcje. Następnie tworzymy analizator składniowy w oparciu na tej rozszerzonej gramatyce. Jeśli „błęd na" produkcja jest wykorzystywana w trakcie działania analizatora składniowego, należy wygenerować odpowiedni komunikat diagnostyczny o rozpoznaniu na wejściu błędnej konstrukcji.
Korekta globalna. W najdoskonalszym przypadku kompilator powinien dokonać możliwie jak najmniej zmian w ciągu znaków wejściowych. Istnieją algorytmy pozwala jące wybrać najkrótszą sekwencję zmian dla otrzymania korekty o minimalnym koszcie globalnym. Dla danego niepoprawnego ciągu znaków wejściowych x i gramatyki G al gorytmy te znajdują drzewo wyprowadzenia dla podobnego ciągu y, takiego, że liczba wstawień, usunięć i zmian symboli leksykalnych potrzebna do przekształcenia x na y jest tak mała, jak to tylko możliwe. Niestety, metody te są zbyt kosztowne czasowo i pamięciowo w implementacji, pozostają więc jedynie w rozważaniach teoretycznych. Należy również zauważyć, że najbliższy (ze względu na odległość będącą kosztem przekształcenia) poprawny program nie zawsze jest tym, o który chodziło programiście. Pomimo to, korekta o minimalnym koszcie stanowi kryterium oceny technik odzyskiwa nia kontroli po usunięciu błędów. Korekta taka została wykorzystana do wyszukiwania optymalnych zmian dla odzyskiwania kontroli na poziomie fraz.
4.2
Gramatyki bezkontekstowe
Wiele konstrukcji w jeżykach programowania ma strukturę rekurencyjną, którą można zdefiniować, używając gramatyk bezkontekstowych. Instrukcję warunkową, na przykład, możemy zdefiniować za pomocą reguły jeśli S| i S są instrukcjami, a E jest wyrażeniem, to „if E then S else 5 " jest instrukcją 2
{
(4 1)
2
Ta postać instrukcji warunkowej nie może zostać wyspecyfikowana przy użyciu notacji wyrażeń regularnych. Z rozdziału 3 dowiedzieliśmy się, że wyrażenia regularne mo gą specyfikować strukturę leksykalną symboli leksykalnych. Natomiast użycie zmiennej składniowej instr do opisu klasy instrukcji oraz wyr do opisu klasy wyrażeń pozwala czytelnie wyrazić regułę (4.1) przy użyciu produkcji gramatyki instr —» if wyr then instr else instr
(4.2)
W tym podrozdziale przypomnieliśmy definicję gramatyk bezkontekstowych i wpro wadziliśmy terminologię związaną z analizą składniową. Gramatyka bezkontekstowa (w skrócie gramatyka, patrz p. 2.2) składa się z terminali, nieterminali, symbolu starto wego i produkcji. 1.
2.
Terminale są symbolami podstawowymi, z których są tworzone napisy. Słowo „sym bol leksykalny" jest synonimem „terminala", gdy mówi się o gramatykach dla j ę zyków programowania. We wzorze (4.2) każde ze słów kluczowych if, then i else jest terminalem. Nieterminale są zmiennymi składniowymi opisującymi zbiory napisów. We wzo rze (4.2) instr i wyr są nieterminalami. Nieterminale definiują zbiory napisów, po magające zdefiniować język generowany przez gramatykę. Narzucają one językowi strukturę hierarchiczną, która jest przydatna zarówno w analizie składniowej, jak i w translacji.
3. 4.
Jeden symbol nieterminalny w gramatyce jest wyróżniony jako symbol startowy, a zbiór napisów, które on definiuje, jest językiem definiowanym przez tę gramatykę. Produkcje gramatyki specyfikują sposób, w jaki terminale i nieterminale mogą być łączone w napisy. Każda produkcja składa się z nieterminala, a następnie strzałki (czasem zamiast strzałki może występować symbol ::=) i napisu złożonego z ter minali i nieterminali.
P r z y k ł a d 4.2. Gramatyka z poniższymi produkcjami definiuje proste wyrażenia aryt metyczne. wyr —» wyr op wyr wyr ~ł (wyr) wyr —¥ — wyr wyr —» id op -t + op —> — op * op / op -> t W tej gramatyce symbolami terminalnymi są id + - * / T ( ) Symbolami nieterminalnymi są wyr i op, a wyr jest również symbolem startowym.
•
Konwencje n o t a c y j n e Chcąc uniknąć wyrażeń „to są terminale", „to są nieterminale" itd., przyjmiemy nastę pujące konwencje notacyjne dla gramatyk, których będziemy odtąd używać w książce. 1. Te symbole są terminalami: i) małe litery z początku alfabetu, jak a, b, c, ii) symbole operatorów, jak + , — itp., iii) symbole przestankowe, jak nawiasy, przecinek itp., iv) cyfry 0 , 1 , . . . , 9 , v) napisy czcionką półgrubą, jak id lub if. 2.
Te symbole są nieterminalami: i) duże litery z początku alfabetu, jak A , B , C, ii) litera S, która — gdy się pojawia — jest zwykle symbolem startowym, iii) napisy kursywą i małymi literami, jak wyr lub instr.
3. 4. 5.
Duże litery z końca alfabetu, jak X, Y, Z, reprezentują symbole gramatyki, czyli terminale i nieterminale. Małe litery z końca alfabetu, zwłaszcza u v, . . . , z, reprezentują ciągi terminali. Małe litery greckie, jak na przykład a, fi, y, reprezentują ciągi symboli gramatyki. Zatem ogólną produkcję można zapisać jako A —>• a, co oznacza, że na lewo od y
strzałki (po lewej stronie produkcji) znajduje się pojedynczy nieterminal A, a na prawo od strzałki (po prawej stronie produkcji) znajduje się ciąg symboli a . Jeśli A —> a j , A —> a , . . . , A —• a są produkcjami mającymi A po lewej stronie (nazwiemy je A-produkcjami), możemy zapisać A —> a \a^\ ...\a , apC^,...,^ nazywamy alternatywami dla A .
6.
0
k
x
7.
k
Jeśli nie stwierdzono inaczej, lewa strona pierwszej produkcji jest symbolem star towym.
P r z y k ł a d 4.3.
Używając tych skrótów, gramatykę dla przykładu 4.2 możemy zwięźle
zapisać E ^ E A E \ ( E ) \ - E \ \ ć l A -> + | - i • | / | T Według przyjętej konwencji notacyjnej, E i A są nieterminalami, a E jest dodatkowo symbolem startowym. Pozostałe symbole są terminalami. •
Wyprowadzenia Na proces definiowania języka za pomocą gramatyki można patrzeć w różny sposób. W podrozdziale 2.2 ocenialiśmy go jako proces budowy drzew wyprowadzeń. Można również traktować go jako samo wyprowadzenie, co może być przydatne. Sposób ten dostarcza precyzyjnego opisu konstrukcji drzewa wyprowadzenia. Przede wszystkim jed nak produkcji używa się jako reguł przepisywania, w których nieterminal z lewej jest zamieniany na napis z prawej strony produkcji. Rozważymy, na przykład, następującą gramatykę dla wyrażeń arytmetycznych, w któ rych wyrażenie jest reprezentowane przez E: E
E + E | £
* E \(E
)I -
| id
E
(4.3)
Produkcja E ~> - E oznacza, że jeśli przed wyrażeniem znajduje się minus, to całość również jest wyrażeniem. Tej produkcji można użyć do wygenerowania wyrażeń bardziej złożonych z wyrażeń prostych, wskutek zamiany dowolnego wystąpienia E wystąpieniem — E. W najprostszym przypadku możemy zamienić pojedyncze E wystąpieniem — E. Tę akcję możemy zapisać jako E => - E Czyta się ją: „— E jest wyprowadzalny z
Produkcja E —>• (E) oznacza, że symbol
E — w każdym ciągu symboli gramatyk — może być zamieniony na (E), na przykład, E * £ = > ( £ ) * £ lub E * E ^ E * (E). W celu otrzymania ciągu zamian możemy użyć pojedynczego E i wielokrotnie stosować produkcje w dowolnej kolejności. Na przykład E ^ ~ E ^
- ( £ ) => - ( i d )
Taka sekwencja zamian jest nazywana wyprowadzeniem
—(id) z E. Wyprowadzenie to
stanowi dowód, że jednym z przypadków wyrażenia jest napis —(id). W bardziej abstrakcyjnym ujęciu możemy napisać, że aAp =>- ccyfi, jeśli A ~> y jest produkcją, a a i j3 są dowolnymi ciągami symboli gramatyki. Jeśli a =>• => • • • => <X , mówimy, że z a wyprowadza się cx„. Symbol oznacza ,,bezx
n
x
pośrednio wyprowadzalny". Często musimy nazwać sytuację ,,wyprowadzalny". Do tego celu można użyć symbolu Zatem, 1)
a ==>• ot dla dowolnego ciągu a oraz
2)
jeśli a 4* /3 i j3 => y, to a 4- y.
Podobnie, do zapisu wyrażenia „wyprowadzalny w co najmniej jednym kroku" używa się symbolu 4>. Dla danej gramatyki G z symbolem startowym S relacja =5- może zostać użyta do zdefiniowania języka L(G), czyli języka generowanego przez G. Ciągi z L ( G ) mogą zawierać tylko symbole terminalne z G . Ciąg terminali w jest w L(G) wtedy i tylko wtedy, gdy S => w. Ciąg w jest nazywany zdaniem w G . Język, który może zostać wygenerowany przez gramatykę, nazywa się językiem, bezkontekstowym. Jeśli dwie gramatyki generują ten sam język, to gramatyki te są równoważne. Jeśli S =5> a , gdzie a może zawierać nieterminale, to a jest nazywana formą niową gramatyki G . Zdanie jest formą zdaniową bez symboli nieterminalnych. Przykład 4.4. prowadzenie E ^ ~ E ^
zda
Napis —(id-f-id) jest zdaniem w gramatyce (4.3), ponieważ istnieje wy
-(£)
-{E + E)
-(id + E)
- ( i d + id)
(4.4)
Napisy E, —E, —(£"), , — (id + id) pojawiające się w powyższym wyprowadzeniu są formami zdaniowymi tej gramatyki. Zapis E =4> —(id + id) oznacza, że —(id + id) może zostać wyprowadzony z E. Za pomocą indukcji względem długości wyprowadzenia można wykazać, że każde zdanie gramatyki (4.3) jest wyrażeniem arytmetycznym zawierającym operatory dwuargumentowe + i *, jednoargumentowy —, nawiasy i argument id. Podobnie, za pomocą indukcji względem długości wyrażenia arytmetycznego można wykazać, że wszystkie takie wyrażenia mogą zostać wygenerowane przez tę gramatykę. Zatem gramatyka (4.3) generuje zbiór wszystkich wyrażeń arytmetycznych zawierających dwuargumentowe + i *, jednoargumentowy —, nawiasy i argument id. • W każdym kroku wyprowadzenia należy dokonać dwóch wyborów. Po pierwsze, trzeba wybrać, który nieterminal ma być zastąpiony, a po drugie, którą z produkcji dla tego nieterminala wybrać. Przykładowo, wyprowadzenie (4.4) z przykładu 4.4 może powstać z —(E + E) w następujący sposób: -(E
+ E) = > - ( E + id)
- ( i d + id)
(4.5)
Każdy nieterminal z (4.5) jest zamieniany na tę samą prawą stronę, tak jak w przykładzie 4.4, lecz kolejność tych zamian jest różna. Do zrozumienia sposobu działania analizatorów składniowych, w każdym kroku mu simy rozważać jedynie zamianę pierwszego nieterminala z lewej strony formy zdaniowej. Wyprowadzenia takie nazywane są lewostronnymi. Jeśli a => j3 w kroku, w którym za mieniany jest skrajnie lewy nieterminal z a , wyprowadzenie takie zapisujemy a=>p. Skoro wyprowadzenie (4.4) jest lewostronne, można przepisać je jako
£ ^ - £ r 4 > - ( £ ) ^ - ( £ + £ ) = > - ( i d + £ ) = > - ( i d + id) Używając tych konwencji, każdy krok wyprowadzenia lewostronnego można zapisać jako wAy^> w8y, gdzie w składa się z samych symboli terminalnych, A —> 8 jest zastosowaną produkcją, a yjest ciągiem symboli gramatyki. Aby podkreślić fakt, że j3 jest wyprowadzalne z a za pomocą wyprowadzenia lewostronnego, zapisuje się a = > / 3 . Jeśli S = > a , to mówi się, że a jest lewostronną formą zdaniową danej gramatyki. Analogiczne definicje dotyczą wyprowadzeń prawostronnych, w których w każdym kroku zamieniany jest skrajnie prawy nieterminal. Prawostronne wyprowadzenia są cza sami nazywane wyprowadzeniami kanonicznymi. Wyprowadzenia i drzewa wyprowadzeń Drzewo wyprowadzenia może być traktowane jako graficzna reprezentacja wyprowadze nia, w której pomija się kolejność zamian. Przypomnijmy z podrozdziału 2.2, że każdy węzeł wewnętrzny drzewa wyprowadzenia jest oznaczony przez pewien nieterminal A, a dzieci tego węzła, od lewej do prawej, oznacza się kolejnymi symbolami z prawej strony produkcji, za pomocą której A zostało zamienione w wyprowadzeniu. Liście w drzewie wyprowadzenia są oznaczone przez nieterminale i terminale, a czytane od lewej do pra wej tworzą formę zdaniową, zwaną plonem lub granicą tego drzewa. Na przykład, drzewo wyprowadzenia dla - (id + id), wynikające z wyprowadzenia (4.4), jest przedstawione na rys. 4.2. Związek między wyprowadzeniami i drzewami wyprowadzeń można zauważyć, roz ważając dowolne wyprowadzenie a cu a „ , gdzie cx jest pojedynczym nie terminalem A . Dla każdej formy zdaniowej a- w wyprowadzeniu konstruujemy drze wo wyprowadzenia. Proces jest indukcją po /. Dla i — 1 formą zdaniową jest = A, a jej drzewem jest pojedynczy węzeł oznaczony A. W kroku indukcyjnym załóżmy, że posiadamy drzewo dla a _ = X X - • -X . (Przypominamy ustaloną konwencję: każ de X może być terminalem albo nieterminalem). Załóżmy, że a jest wyprowadzone z poprzez zamianę nieterminala X- na j3 ~Y Y -Y . Oznacza to, że w z-tym kro ku wyprowadzenia produkcja X- —> f3 jest zastosowana do cx _ w celu wyprowadzenia {
i
l
{
{
2
k
f
t
]
2
r
i
a
i
= X X ---X _ l5X --X . l
2
j
[
J+r
k
[
'
Aby wykonać ten krok wyprowadzenia, wyszukujemy jf-ty liść od lewej w aktualnym drzewie wyprowadzenia. Liść ten jest oznaczony symbolem X-. Dodajemy do tego węzła
(
E
)
E
+
E
id
id
Rys. 4.2. Drzewo wyprowadzenia dla —(id + id'
r dzieci, oznaczonych od lewej Y , 7 , . . . , Y . Specjalnym przypadkiem jest r — 0, czyli j3 = e, wtedy j-ty liść otrzymuje dziecko oznaczone e. }
9
r
Przykład 4.5. Rozważmy wyprowadzenie (4.4). Sekwencja drzew wyprowadzeń skon struowanych z tego wyprowadzenia jest przedstawiona na rys. 4.3. W pierwszym kroku wyprowadzenia £ — E. Aby wykonać ten krok, do korzenia E drzewa początkowego dodajemy dwójkę dzieci, oznaczonych — oraz E. W drugim kroku wyprowadzenia — E ^ — ( £ ) . Podobnie dodajemy troje dzieci, oznaczonych (, £ , i ), do liścia z drugiego poddrzewa oznaczonego E. W ten sposób otrzymujemy trzecie drzewo wyprowadzenia dla — ( £ ) . Kontynuując proces, jako szóste drzewo otrzymujemy całe drzewo wyprowadzenia. • E
E /
N
N
E /
I
( E /
E
=S>
N
/ I
/
(
E /
£
I
N
E N
/
)
+
I
(
N
£
E N
E /
£
)
E N
E /
N
E
I
/
)
(
N
+
/
£
£
I
N
E I
+
) N
£
l
i
I
id
id
id
Rys. 4.3. Tworzenie drzewa wyprowadzenia z wyprowadzenia (4.4) Jak wspomnieliśmy, kolejność zamiany symboli w formach zdaniowych w drzewie wyprowadzenia jest ignorowana. Jeśli wyprowadzenie (4.4), na przykład, byłoby konty nuowane jak w wierszu (4.5), to ostateczne drzewo wyprowadzenia byłoby identyczne jak na rys. 4.3. Te zmiany w kolejności stosowania produkcji można wyeliminować, rozwa żając jedynie wyprowadzenia lewostronne (lub prawostronne). Nie trudno zauważyć, że każdemu drzewu wyprowadzenia odpowiada unikalne wyprowadzenie lewostronne albo prawostronne. Korzystając z tego, analizę składniową będziemy przeprowadzać, tworząc lewostronne albo prawostronne wyprowadzenie. Oczywiście zamiast samego wyprowa dzenia możemy tworzyć jedynie drzewo wyprowadzenia. Jednak nie można zakładać, że każde zdanie ma tylko jedno drzewo wyprowadzenia i po jednym wyprowadzeniu lewostronnym i prawostronnym. Przykład 4.6. Rozważmy gramatykę wyrażeń arytmetycznych (4.3). Zdanie id + id * id ma dwa różne wyprowadzenia lewostronne E
E +E id + E id + £ * £ id + id * £ id 4- id * id
E
E * E
=4> £ + £ * £ => id + £ * £ id + id * £ id + id * id
Drzewa dla tych wyprowadzeń są przedstawione na rys. 4.4.
•
Zauważmy, że drzewo wyprowadzenia z rys. 4.4(a) odpowiada zwykłemu prioryte towi operatorów + oraz *, podczas gdy drzewo z rys. 4.4(b) — odwrotnemu. Przyjęto, że operator * ma wyższy priorytet niż + , co znaczy, że wyrażenie a + b * c obliczamy jak a + (b * c), a nie (a + b) * c.
i
l
id
id (a)
Rys, 4.4.
i
i
id
id (b)
D w a d r z e w a w y p r o w a d z e ń dla id + id * id
Niejednoznaczność Gramatyka, w której dane zdanie ma więcej niż jedno drzewo wyprowadzenia, nazywa na jest niejednoznaczną. Inaczej mówiąc, gramatyka jest niejednoznaczna, jeśli to samo zdanie ma w niej więcej niż jedno wyprowadzenie lewostronne lub więcej niż jedno prawostronne. Dla niektórych typów analizatorów składniowych jest pożądane, aby gra matyka była jednoznaczna. Jeśli nie byłaby, nie można by wyznaczyć jedynego drzewa wyprowadzenia dla zdania. Rozważyliśmy również zastosowania pewnych metod, w któ rych mogą być wykorzystywane szczególne gramatyki niejednoznaczne wraz z regułami usuwania niejednoznaczności. Reguły te powodują odrzucenie niepożądanych drzew wy prowadzeń i pozostawienie dla konkretnego zdania pojedynczego drzewa.
4.3
Tworzenie gramatyki
Gramatyki umożliwiają opisanie większości, ale nie wszystkich, składni języków pro gramowania. Niewielka część analizy składniowej jest przeprowadzana przez analizator leksykalny, który ze znaków wejściowych produkuje sekwencję symboli leksykalnych. Niektóre z ograniczeń nakładanych na wejście, jak na przykład wymaganie, żeby iden tyfikatory były zadeklarowane przed użyciem, nie mogą zostać opisane przez gramatykę bezkontekstową. Zatem, sekwencje symboli leksykalnych akceptowane przez analizator składniowy są nadzbiorem języka programowania. Aby zapewnić zgodność języka z za sadami, które nie są sprawdzane przez analizator składniowy, kolejne fazy muszą badać wyjście analizatora składniowego (patrz rozdz. 6). W tym podrozdziale rozważyliśmy podział zadań między analizatorem leksykalnym a składniowym. Ponieważ każda metoda analizy składniowej może wykorzystywać gra matyki jedynie konkretnego typu, początkowa gramatyka zwykle musi zostać przepisana, aby można było zastosować wybraną metodę. Gramatyki odpowiednie dla wyrażeń można konstruować, podobnie jak w p . 2.2, wykorzystując informacje o łączności i priorytetach
operatorów. Opisaliśmy również transformacje użyteczne przy przepisywaniu gramatyk do postaci odpowiedniej do analizy metodą zstępującą; przedstawiliśmy także niektóre konstrukcje programistyczne, których nie można opisać żadną gramatykę. Wyrażenia regularne a gramatyki bezkontekstowe Każda konstrukcja, która może być opisana przez wyrażenie regularne, może być również opisana przez gramatykę. Przykładowo, wyrażenie regularne (a\b) * abb oraz gramatyka
A aA | bA Aj ->• bA Q
0
0
| aA
x
2
A
-> bA
2
3
A -> 3
e
opisują ten sam język, zbiór napisów złożonych z a \ b kończących się abb. Niedeterministyczny automat skończony (NAS) może zostać przekształcony na gra matykę generującą ten sam język. Powyższa gramatyka została skonstruowana z NAS z rys. 3.23 przy użyciu następującej konstrukcji. Dla każdego stanu i z NAS utwórz sym bol nieterminalny A Jeśli stan i ma przejście do stanu j dla symbolu a, dodaj produkcję A —>• aAj. Jeśli przejście z i do j odbywa się dla e, należy dodać produkcję A- —> A-. Jeśli / jest stanem akceptującym, dodaj A —> e. Jeśli / jest stanem startowym, to A musi być symbolem startowym gramatyki. Skoro każdy zbiór regularny jest językiem bezkontekstowym, możemy zadać sen sowne pytanie: „dlaczego używa się wyrażeń regularnych do definicji składni leksykalnej języka?" Istnieje kilka powodów: r
}
i
1. 2. 3. 4.
i
Reguły leksykalne języka są często całkiem proste i do ich opisania nie potrzeba tak potężnych notacji, jak gramatyki. Wyrażenia regularne umożliwiają zwykle bardziej zwartą i prostszą do zrozumienia notację dla symboli leksykalnych niż gramatyki. Z wyrażeń regularnych można automatycznie skonstruować analizatory leksykalne bardziej wydajne niż dowolne gramatyki. Rozdzielenie struktury składniowej języka na części leksykalną i nieleksykalną umożliwia wygodne zmodularyzowanie przodu kompilatora na dwa składniki, które będzie łatwiej napisać ze względu na mniejszy rozmiar.
Nie ma sztywnych wytycznych określających, co należy umieszczać w regułach lek sykalnych, a co w składniowych. Wyrażenia regularne są najbardziej użyteczne do opisu struktury konstrukcji leksykalnych, takich jak np. identyfikatory, stałe słowa kluczowe. Natomiast gramatyki są bardziej użyteczne do opisywania struktur zagnieżdżonych, np.: zrównoważonych nawiasów, dopasowanych begin i end, odpowiadających sobie if, then i else. Jak już wspomnieliśmy, tych zagnieżdżonych struktur nie można opisać wyraże niami regularnymi. Weryfikacja języka wygenerowanego przez gramatykę Chociaż projektanci kompilatorów rzadko robią to dla całej gramatyki języka programo wania, ważne jest, aby móc uzasadnić, dlaczego dany zbiór produkcji generuje konkret-
ny język. Kłopotliwe konstrukcje można zbadać, pisząc zwartą abstrakcyjną gramatykę i przyglądając się językowi przez nią generowanemu. Poniżej skonstruujemy taką grama tykę dla wyrażeń warunkowych. Dowód, że gramatyka G generuje język L składa się z dwóch części: najpierw musimy wykazać, że każdy napis generowany przez G należy do L oraz że każdy łańcuch należący do L może zostać wygenerowany przez G. Przykład 4.7.
Rozważmy gramatykę
S - > (S)S\e
(4.6)
Choć na pierwszy rzut oka nie jest to oczywiste, gramatyka ta geneneruje wszystkie napi sy złożone ze zrównoważonych nawiasów i tylko takie napisy. Aby się o tym przekonać, wykażemy najpierw, że każde zdanie wyprowadzalne z S jest zrównoważone, i następ nie, że każdy zrównoważony napis jest wyprowadzalny z S. Aby pokazać, że każdy napis wyprowadzony z S jest zrównoważony, użyjemy indukcji względem liczby kroków wy prowadzenia. Aby wykazać poprawność warunku początkowego, należy zauważyć, że jedynym napisem złożonym z terminali wyprowadzalnym z S w jednym kroku jest napis pusty, który oczywiście jest zrównoważony. Załóżmy teraz, że wszystkie wyprowadzenia z mniej niż n krokami tworzą zrówno ważone zdania. Rozważmy lewostronne wyprowadzenie z dokładnie n krokami. Wypro wadzenie takie musi mieć postać S
(S)S 4> {x)S =5> (x)y
Wyprowadzenia dla x i y z S zajmują mniej niż n kroków, więc z założenia indukcyjnego x i y są zrównoważone. Zatem, również napis (x)y musi być zrównoważony. Wykazaliśmy więc, że każdy napis wyprowadzalny z S jest zrównoważony. Teraz należy udowodnić, że każdy zrównoważony napis jest wyprowadzalny z S. Użyjemy indukcji względem długości napisu. Prawdziwość warunku początkowego wynika z tego, że napis pusty jest wyprowadzalny z S. Załóżmy, że każdy zrównoważony napis długości mniejszej niż 2n jest wypro wadzalny z S i rozważmy zrównoważony napis w długości 2n, n ^ 1. Oczywiście w rozpoczyna się od lewego nawiasu. Niech (x) będzie najkrótszym prefiksem w ma jącym równą liczbę lewych i prawych nawiasów; w można zatem zapisać jako (x)y, gdzie x i y są zrównoważone. Ponieważ x i y mają długość mniejszą niż 2n, są z założenia indukcyjnego wyprowadzalne z S. Możemy więc znaleźć wyprowadzenie o postaci S^(S)S^(x)S^{x)y z którego otrzymujemy, że w = (x)y jest wyprowadzalne z S.
•
Wyeliminowanie niejednoznaczności Czasami gramatykę niejednoznaczną można przepisać tak, aby wyeliminować niejed noznaczność. Wykażemy to na przykładzie usunięcia niejednoznaczności z gramatyki z „wiszącym else"
instr —> if wyr then instr | if wyr then instr else instr I inna
(4.7)
„inna" oznacza wszystkie inne instrukcje. Zgodnie z tą gramatyką, dla złożonej instrukcji warunkowej if £j then S ełse if £ x
2
then S else S 2
3
drzewo wyprowadzenia jest jak na rys. 4.5. Gramatyka (4.7) jest niejednoznaczna, po nieważ napis if £ , then if £
2
then 5 , else S
(4.8)
2
ma dwa możliwe drzewa wyprowadzenia, jak na rys. 4.6.
instr
if
wyr Z
then
instr
A
Z
El
else
i/isfr
A
Sy if
wyr
then
instr
else
włs&*
Rys. 4.5. Drzewo wyprowadzenia dla instrukcji warunkowej
instr
if
wyr Z
then
instr
A
if
wyr Z
then
A
instr Z
else
A
i/w/r Z
A
instr
if
wyr Z
then
wwfr
else
instr Z
A
A
s
2
if
wyr Z
A
then
wwfr Z
A
Rys. 4.6. Dwa drzewa wyprowadzenia dla niejednoznacznej instrukcji
We wszystkich językach programowania z instrukcjami warunkowymi mającymi taką postać jest preferowane pierwsze drzewo wyprowadzenia. Ogólną zasadą jest „do pasowanie każdego else do najbliższego poprzedniego, niedopasowanego jeszcze then". Ta zasada może zostać włączona bezpośrednio do gramatyki. Przykładową gramatykę (4.7) można przepisać na poniższą gramatykę jednoznaczną. Pomysł polega na tym, że instrukcja między then i else musi być „dopasowana", to znaczy, nie może kończyć się niedopasowanym then, z którym występuje jeszcze jakaś instrukcja. Słowo kluczowe else będzie musiało wtedy zostać dopasowane do niedopasowanego jeszcze then. Instrukcja dopasowana jest albo instrukcją if-then-else nie zawierającą niedopasowanych instrukcji, albo jest innym rodzajem instrukcji niewarunkowej. Zatem, można użyć gramatyki instr —> dopas-instr | niedopas-instr dopasśnstr -» if wyr then dopaś-instr | inna niedop as-instr —> if wyr then instr | if wyr then dopas^instr
else
dopas-instr
else
niedopas-instr
Ta gramatyka generuje taki sam zbiór napisów jak (4.7), ale pozwala tylko na jedno wyprowadzenie napisu (4.8), takie, które związuje każde else z najbliższym poprzednim niedopasowanym then.
Eliminacja lewostronnej rekurencji Gramatyka jest lewostronnie rekurencyjna, jeśli ma nieterminal A taki, że istnieje wy prowadzenie A 4 > Aa dla pewnego napisu a . Metody zstępujące nie dają się zasto sować do gramatyk lewostronnie rekurencyjnych, potrzebna jest więc odpowiednia transformacja usuwająca lewostronną rekurencję. W podrozdziale 2.4 zajmowaliśmy się prostą lewostronną rekurencją z pojedynczą produkcją o postaci A —> Aa. Teraz zba damy przypadek ogólny. Wykazaliśmy już (patrz p. 2.4), jak lewostronnie rekurencyjna para produkcji A —> Aa \ j3 może być zastąpiona produkcjami, które nie są lewostronnie rekurencyjne
A PA' A' -> aA'\e Produkcje te generują z A dokładnie ten sam zbiór napisów, co w oryginalnej gramatyce. Reguła ta jest wystarczająca dla wielu gramatyk.
Przykład 4.8.
Rozważmy następującą gramatykę dla wyrażeń arytmetycznych:
E -» E + T | T T T * F \F F - > ( £ ) | id Po eliminacji bezpośredniej lewostronnej dukcji dla E i T otrzymujemy
(4.10)
rekurencji
(produkcji o postaci A -> Aa) z pro
E ~> TE' E -> +TE' 1
|e
T -> FT
(4.11)
V *FT' | e F -» ( £ ) | id
•
Niezależnie od liczby produkcji dla A, bezpośrednią lewostronną rekurencję moż na wyeliminować przy użyciu następującej techniki. Najpierw grupujemy produkcje dla A A
->
Aa
| Ao^
x
\ P
| - • • | Aa
m
l
\ P
2
\ ' " \ P n
Żaden z napisów P nie może zaczynać się od A. Następnie zamieniamy te produkcje na następujące: i
A
-+p A'\p A'\...\P„A' x
!
A' -> a A x
| o^A'
2
| • • • |
a A' m
| t
Nieterminal A generuje te same napisy co wcześniej, ale nie jest już lewostronnie rekurencyjny. Ta procedura eliminuje każdą bezpośrednią rekurencję lewostronną z pro dukcji A i A' (przy założeniu, że żadne a- nie jest e), ale nie eliminuje lewostronnej rekurencji dla wyprowadzeń w co najmniej dwóch krokach. Rozważmy, na przykład, gramatykę S ^ Aa \ b A^Ac\Sd\e
K
}
Nieterminal S jest lewostronnie rekurencyjny, ponieważ S Aa => Sda, ale nie jest bez pośrednio lewostronnie rekurencyjny. Znajdujący się poniżej algorytm 4.1 służy do systematycznej eliminacji lewostron nej rekurencji z gramatyki. Działa on, gdy gramatyka nie zawiera cykli (wyprowadzeń 0 postaci A =5> A) lub e-produkcji (produkcji o postaci A —> e). Zarówno cykle, jak 1 e-produkcje można w sposób systematyczny wyeliminować z gramatyki (patrz ćwi czenia 4.20 i 4.22). Algorytm 4.1.
Eliminacja lewostronnej rekurencji.
Wejście. Gramatyka G bez cykli i e-produkcji. Wyjście. Równoważna gramatyka bez lewostronnej rekurencji. Metoda. Zastosuj algorytm z rys. 4.7 do gramatyki G. Zauważmy, że wynikowa gramatyka lewostronnie nierekurencyjna może mieć e-produkcje. • Przyczyną działania procedury z rys. 4.7 jest to, że po (i — l)-szej iteracji zewnętrz nej pętli for w kroku (2), każda produkcja o postaci A —> A a, gdzie k < i, spełnia warunek / > k. W wyniku tego, w następnej iteracji pętla wewnętrzna (po j) zwiększa stopniowo dolne ograniczenie na m w każdej produkcji o postaci A -> A a , aż spełnio ne będzie założenie m ^ i. Następnie, eliminacja bezpośredniej lewostronnej rekurencji w produkcjach dla A wymusza, aby m było większe niż i. k
{
i
i
m
1.
Ustaw nieterminale w pewnym porządku Aj, A , . . •, A .
2.
for i' := 1 to n do begin for 7 : = 1 to / — I do begin każdą produkcję o postaci A- —»A -y zamień na produkcje o postaci A —• Ą y | 5 y | • • • | 5 y, gdzie A - —y 5j | 5 | • • • | S są wszystkimi aktualnymi produkcjami dla A-\ end wyeliminuj rekurencję bezpośrednią z produkcji dla A • end
2
i
2
n
2
t
k
Rys. 4.7. Algorytm eliminujący lewostronną rekurencję z gramatyki P r z y k ł a d 4.9. Zastosujmy tę procedurę do gramatyki (4.12). Ze względów technicznych nie ma gwarancji — z powodu e-produkcji — że algorytm 4.1 zadziała, jednak w tym przypadku okazuje się, że A —> e jest nieszkodliwa. Porządkujemy nieterminale S, A. W produkcjach dla S nie ma lewostronnej reku rencji, więc nic nie dzieje się w kroku 2. dla Dla i = 2 podstawiamy produkcje dla S do A —>• i otrzymujemy następujące produkcje dla A: A —• Ac | Aad | fa/ | e Po eliminacji bezpośredniej lewostronnej rekurencji z produkcji dla A otrzymujemy na stępującą gramatykę: S ^ Aa\ b A -> M A ' | A' ;
A' -> cA! I a
•
F a k t o r y z a c j a lewostronna Faktoryzacja lewostronna jest przekształceniem gramatyki przydatnym przy tworzeniu gramatyki dla przewidującego analizatora składniowego. Podstawowy pomysł polega na tym, że kiedy nie jest jasne, którą z dwóch produkcji powinno się wybrać do rozwinięcia nieterminala A, można przepisać te produkcje, a decyzję o ich wyborze odłożyć do chwili aż zobaczymy dużo danych wejściowych. Na przykład, dla dwóch produkcji instr —> if wyr then instr else instr | if wyr t h e n instr jeśli na wejściu jest symbol leksykalny if, nie można od razu stwierdzić, którą produkcję wybrać do rozwinięcia instr. Zazwyczaj, jeśli A —• afi \ a / 3 , gdy wejście rozpoczyna się od niepustego napisu wyprowadzonego z a , to nie wiadomo, czy A rozwijać na a/5 , czy a j 3 . Jednak decyzję tę można odłożyć na później, rozwijając A na aA' i następnie — po wczytaniu napisu wyprowadzonego z a — rozwinąć A' na j5 lub j3 . Innymi słowy, po faktoryzacji lewostronnej oryginalne produkcje zostaną przekształcone na x
2
{
2
x
A A'
aA'
- >j S , |
/3
2
2
Algorytm 4.2.
Lewostronna faktoryzacja gramatyki.
Wejście. Gramatyka G. Wyjście. Równoważna gramatyka po lewostronnej faktoryzacji. Metoda. Znajdź dla każdego nieterminala A najdłuższy prefiks a wspólny dla co najmniej dwóch prawych stron. W przypadku a / e , czyli jeśli istnieje nieuproszczony wspólny prefiks, zamień wszystkie produkcje A -» afi | a j 3 | ••• | Gtf5 | y, gdzie / reprezentuje wszystkie pozostałe napisy nie zaczynające się od a , na x
A
2
n
aA' | y
a ' - • A l k
I - I
A.
A' jest wprowadzonym dodatkowym nieterminalem. Powyższą transformację trzeba stosować tyle razy, aż żadne dwie prawe strony produkcji nie będą miały wspólnego prefiksu. • Przykład 4.10. /
Następująca gramatyka odpowiada problemowi „wiszącego else"
iWtl | iWtleł
\a
Symbole i,tie odpowiadają if, then i else, a W i / oznaczają „wyrażenie" i „instrukcję". Po faktoryzacji lewostronnej gramatyka przyjmuje postać 1
/
-> W tli
V
-> el | e
|a (4.14)
W -> b Dla symbolu wejściowego i można zatem rozwinąć / w iWtll', poczekać na wczytanie iWtI> i dopiero wtedy zadecydować, czy S' powinno zostać rozwinięte w eS, czy w e. Oczywiście obie gramatyki (4.13) i (4.14) są niejednoznaczne i dla symbolu wejścio wego e nie będzie wiadomo, którą produkcję dla S' należy wybrać. W przykładzie 4.19 omówiliśmy, jak poradzić sobie z tym problemem. •
Konstrukcje języków nie będących bezkontekstowymi Stwierdzenie, że niektóre języki nie mogą zostać wygenerowane przez jakiekolwiek gra matyki, nie powinno być zaskoczeniem. Faktycznie, niektóre konstrukcje składniowe wie lu języków programowania nie mogą zostać opisane przy użyciu samych gramatyk. W tym podrozdziale przedstawiliśmy kilka takich konstrukcji i pojawiające się trudności, uży wając prostych języków abstrakcyjnych. Przykład 4.11. Rozważmy abstrakcyjny język L = {n>nv|w> mający postać (a|fc)*}. Język Lj składa się ze wszystkich słów złożonych z dwóch identycznych napisów a i b odseparowanych symbolem c, np. aabcaab. Można udowodnić, że ten język nie jest bezkontekstowy. Problemem dla tego języka jest sprawdzanie, czy identyfikatory zostały zadeklarowane przed ich użyciem. Pierwsze w z wcw reprezentuje deklarację identyfi katora w, a drugie w — jego użycie. Brak bezkontekstowości języka L, bezpośrednio 3
implikuje brak bezkontekstowości języków programowania, jak Algol i Pascal, które wy magają deklaracji identyfikatorów przed ich pierwszym użyciem i pozwalają na użycie identyfikatorów dowolnej długości (udowodnienie tego nie mieści się w tematyce tej książki). Z tego powodu, gramatyka składni Algola lub Pascala nie specyfikuje znaków w identyfikatorach, ale wszystkie identyfikatory są reprezentowane w gramatyce sym bolami leksykalnymi, takimi jak id. W kompilatorach dla takich języków, sprawdzanie, czy identyfikatory są używane po ich zadeklarowaniu jest przeprowadzane w fazie analizy semantycznej. • n
m
tł
m
Przykład 4.12. Język L = {a b c d \n ^ 1 oraz m ^ 1} nie jest bezkontekstowy. Język L
n
n
n
m
m
instr —> cali id ( lista- wyr ) lista^ wyr —>• lista^ wyr , wyr | vvy/* Należy oczywiście dodać odpowiednie produkcje dla wyr. Sprawdzanie, czy liczba parametrów aktualnych jest poprawna, jest wykonywane w trakcie fazy analizy seman tycznej . • n
n
n
Przykład 4.13. Język L = {a b c \ n ^ 0} nie jest bezkontekstowy. Zawiera te napisy z L(a * b * c * ) , które mają różną liczbę symboli a, b i c. Problem dla języka L jest następujący. Tekst składany na maszynie zecerskiej ma kursywę tam, gdzie tekst przygotowany na drukarce ma podkreślenia. Podczas konwersji z pliku przeznaczonego do drukowania na drukarce wierszowej do tekstu przeznaczonego do drukowania na maszynie zecerskiej należy zamienić wszystkie słowa podkreślone na kursywę. Słowo podkreślone jest ciągiem liter, za którymi występuje taka sama liczba znaków cofania kursora i taka sama liczba znaków podkreślenia. Jeśli a będzie oznaczało dowolną literę, b znak cofania kursora, a c znak podkreślenia, to język L będzie reprezentował słowa podkreślone. Wynika z tego, że w ten sposób nie da się użyć gramatyki do opisania podkreślonych słów. Jeżeli jednak słowa podkreślone będą reprezentowane sekwencjami trójek znaków litera-cofnięcie-podkreślenie, to można j e opisać przy użyciu wyrażenia regularnego (abc)*. • 3
3
3
Warto zauważyć, że języki bardzo podobne do L , L i L są bezkontekstowe. Na przykład, bezkontekstowy jest język L\ = {wcw \w mający postać (a\b)*}, gdzie w {
R
2
3
R
oznacza odwróconą kolejność symboli w w. Jest generowany przez gramatykę S
aSa | bSb | c
n
m
m
n
Język L = [ a b c d \ n ^ 1 oraz m ^ 1} jest bezkontekstowy i jego gramatyką jest 2
A -> M c | &c n
n
m
Również język lĄ = { a b c d tyką jest S —¥
m
\ n ^ 1 oraz m ^ 1} jest bezkontekstowy, a jego grama
AB
A
aA6 | ab
B
cBd\
cd
W końcu, L = { a " ^ |n ^ 1} jest bezkontekstowy z gramatyką 3
5 —» (35/? | a/? Warto zauważyć, że L jest podstawowym przykładem języka, którego nie można opisać wyrażeniem regularnym. Aby się o tym przekonać, załóżmy, że L został zdefiniowany przez pewne wyrażenie regularne. Załóżmy równoważnie, że można skonstruować DAS D akceptujący L ! D musi mieć skończoną liczbę stanów k. Rozważmy sekwencję stanów 5 , S S , • • s , przez które przechodzi automat po wczytaniu e, a, a a , ..., a , czyli s jest stanem, do którego automat wejdzie po wczytaniu i symboli a. Ponieważ D ma tylko k różnych stanów, więc co najmniej dwa stany z sekwencji s , s , s ,. •s będą tymi samymi stanami. Niech, na przykład, s — Sj. Ze stanu s sekwencja i symboli b doprowadza do stanu akceptującego / , ponieważ a b należy do L' . Ale ze stanu początkowego s istnieje również ścieżka przechodząca przez s prowadząca do f i oznaczona jak na rys. 4.8. Zatem D akceptuje również ciąg a'b , który nie znajduje się w języku L , co przeczy założeniu, że L jest językiem akceptowanym przez D . 3
3
y
k
0
V
k
2
i
Q
{
2
k
t
i
l
Ł
3
0
iy
l
3
3
J
Ś c i e ż k ao z n a c z o n aa ~ l
/ Ś c i e ż k ao z n a c z o n ab
1
Ś c i e ż k a o z n a c z o n a a \^
so)
. •.
*4f
••• l
J
l
Rys. 4.8. D A S D akceptujący a'b oraz a b
Potocznie mówi się, że „automat skończony nie może przechować licznika", co zna czy, że automat skończony nie może akceptować języków, takich jak L , które wymagają przechowywania liczby a przed zobaczeniem b. Podobnie można stwierdzić, że „grama tyka może przechować liczbę dwóch elementów, ale nie trzech", ponieważ za pomocą gramatyki daje się zdefiniować L , ale nie L 3
3
y
4.4
Analiza zstępująca
W tym podrozdziale opisaliśmy podstawowe pojęcia związane ze zstępującą analizą skła dniową oraz budowanie wydajnych, nienawracąjących analizatorów zstępujących, nazy wanych analizatorami przewidującymi. Zdefiniowaliśmy klasę gramatyk LL(1), z których można automatycznie budować analizatory przewidujące. Oprócz sformalizowania opisu analizatorów przewidujących z p. 2.4, rozważyliśmy nierekurencyjne analizatory przewi dujące i omówiliśmy obsługę błędów. Analizatory wstępujące opisaliśmy w p . 4 . 5 - 4 . 7 .
M e t o d a zejść r e k u r e n c y j n y c h Na analizę zstępującą można patrzyć jak na próbę znalezienia lewostronnego wyprowa dzenia dla napisu wejściowego. Jednocześnie, można ją traktować jak próbę zbudowania drzewa wyprowadzenia dla wejścia, zaczynając od korzenia drzewa i tworząc wierzchoł ki w porządku preorder. W podrozdziale 2.4 opisaliśmy specjalny przypadek metody zejść rekurencyjnych, nazywany analizą przewidującą, który nie wymaga nawracania. Teraz rozważymy bardziej ogólną metodę analizy zstępującej, nazywaną metodą zejść rekurencyjnych, w której nawracanie, czyli wielokrotne przeglądanie wejścia, może być konieczne. Okazuje się jednak, że analizatory z nawracaniem nie są popularne. Jedną z przyczyn jest to, że nawracanie rzadko jest potrzebne przy analizie konstrukcji języka programowania. W sytuacjach, takich jak analiza składniowa języka naturalnego, nawra canie nie jest bardzo wydajne i preferowane są metody korzystające z tablic, takie jak algorytm programowania dynamicznego z ćwiczenia 4.63 albo metoda Earleya [1970]. Aho i Ullman [1972b] podają opis ogólnych metod analizy składniowej. Nawracanie jest konieczne w poniższym przykładzie, w którym przedstawimy me todę śledzenia wejścia podczas nawracania. P r z y k ł a d 4.14.
Rozważmy gramatykę
f^l (4.15) A -> ab \ a ' oraz napis wejściowy w — cad. Aby zstępująco zbudować drzewo wyprowadzenia dla tego napisu, musimy na początku stworzyć drzewo składające się z pojedynczego wierz chołka nazwanego 5. Wskaźnik wejścia wskazuje c, pierwszy symbol w. Korzystamy wówczas z pierwszej produkcji dla S, aby powiększyć drzewo, i otrzymujemy drzewo jak na rys. 4.9(a).
Rys. 4.9. Kroki w analizie zstępującej
Liść po lewej stronie, oznaczony c, pasuje do pierwszego symbolu w, przesuwamy więc wskaźnik wejścia do a, kolejnego symbolu w, i zaczynamy rozpatrywać następny liść, oznaczony A. Możemy wtedy rozwinąć A, używając pierwszej alternatywy, i otrzy mujemy drzewo jak na rys. 4.9(b). Drugi symbol z wejścia pasuje, więc przesuwamy wskaźnik wejścia do d, trzeciego symbolu wejściowego, i porównujemy d z kolejnym wierzchołkiem, oznaczonym b. Ponieważ b nie pasuje do d, ogłaszamy niepowodze nie i wracamy do A, aby zobaczyć, czy są inne alternatywy dla A, których jeszcze nie wypróbowaliśmy, a które mogą okazać się właściwe. Po powrocie do A musimy ustawić wskaźnik wejścia na pozycję 2, czyli pozycję, w której był wtedy, gdy po raz pierwszy weszliśmy do A. Oznacza to, że procedura dla A (analogiczna do procedury dla nieterminali z rys. 2.17) musi zapamiętać wskaźnik wejścia w zmiennej lokalnej. Możemy teraz wypróbować drugą alternatywę dla A, co doprowadzi nas do drzewa jak na rys. 4.9(c). Liść a pasuje do drugiego symbolu w, a liść d pasuje do trzeciego. Ponieważ zbudowaliśmy drzewo wyprowadzenia dla w, zatrzymujemy się i ogłaszamy poprawne zakończenie analizy składniowej. • Gramatyka z lewostronną rekurencją może spowodować, że analizator zbudowany metodą zejść rekurencyjnych, nawet taki z nawrotami, wejdzie w pętlę nieskończoną. Gdy będziemy próbowali rozwinąć A, możemy w pewnej chwili zauważyć, że robimy to ponownie, mimo że nie przetworzyliśmy żadnego wejścia.
Analizatory przewidujące W wielu przypadkach, uważnie zapisując gramatykę, usuwając z niej lewostronną re kurencję oraz wykonując lewostronną faktoryzację na wynikowej gramatyce, uda się nam otrzymać gramatykę, z której może korzystać analizator napisany metodą zejść re kurencyjnych, nie potrzebujący nawrotów. Taki analizator to analizator przewidujący, opisany w p. 2.4. Aby zbudować analizator przewidujący, mając aktualny symbol wej ściowy a oraz nieterminal A, który mamy rozwinąć, musimy wiedzieć, która alternaty wa produkcji A —> a • • - \<x jest jedyną, z której można wyprowadzić tekst rozpo czynający się od a. Oznacza to, że musimy umieć wykryć właściwą alternatywę poprzez sprawdzenie tylko pierwszego symbolu, który jest z niej wyprowadzany. Konstrukcje sterujące przepływem w większości języków programowania, mające wyróżniające je słowa kluczowe, zazwyczaj można w ten sposób wykryć. Na przykład, jeśli mamy dane produkcje x
n
instr —> if wyr then instr else instr | while wyr do instr | begin lista^instr end to słowa kluczowe if, while i begin wskażą nam, która alternatywa jest jedyną mającą szansę zadziałać, jeśli szukamy instrukcji. Diagramy przejść dla analizatorów przewidujących W podrozdziale 2.4 opisaliśmy implementację analizatora przewidującego z użyciem pro cedur rekurencyjnych, np. takich, jak na rys. 2.17. Tak jak stworzyliśmy diagram przejść
(patrz p. 3.4), który był wygodnym planem lub schematem analizatora leksykalnego, tak samo możemy stworzyć diagram przejść, który będzie planem analizatora przewidu jącego. Kilka różnic między diagramem przejść dla analizatora leksykalnego a przewidu jącego analizatora składniowego jest od razu widocznych. W przypadku tego drugiego mamy jeden diagram dla każdego nieterminala. Etykietami krawędzi są symbole leksy kalne i nieterminale. Przejście po nieterminalu A jest wywołaniem procedury dla A. Korzystając z gramatyki, można zbudować diagram przejść analizatora przewidują cego. Należy najpierw usunąć lewostronną rekurencję, a następnie wykonać lewostronną faktoryzację gramatyki. Później, dla każdego nieterminala A trzeba zrobić, co następuje: 1. 2.
Stworzyć stany początkowy i końcowy. Dla każdej produkcji A —> X X - • -X stworzyć ścieżkę od stanu początkowego do końcowego, z krawędziami etykietowanymi X , X , ... , X . l
2
n
x
2
n
Analizator przewidujący, korzystający z diagramów przejść, rozpoczyna działanie w stanie początkowym dla symbolu startowego. Jeśli po wykonaniu pewnej pracy jest w stanie s, z krawędzią etykietowaną a prowadzącą do stanu t i następnym symbolem wejściowym jest a, to analizator przesuwa wskaźnik wejścia o jedną pozycję w prawo i przechodzi do stanu t. Jeżeli jednak krawędź jest etykietowana nieterminalem A, to analizator przechodzi do stanu startowego dla A, nie przesuwając wskaźnika wejścia. Jeśli kiedykolwiek dojdzie do stanu końcowego dla A, to od razu przechodzi do stanu t, w rezultacie „odczytując" A z wejścia w czasie przechodzenia od stanu s do t. W końcu, jeśli jest krawędź od s do t z etykietą e, to analizator przechodzi ze stanu s do / bez przesuwania wskaźnika wejścia. Program analizatora przewidującego opartego na diagramie przejść próbuje dopa sować terminale do wejścia i — gdy musi przejść po krawędzi etykietowanej przez nie terminal — wykonuje potencjalnie rekurencyjne wywołanie procedury. Nierekurencyjną implementację można otrzymać poprzez odkładanie stanu s na stos przed przejściem na zewnątrz s po krawędzi etykietowanej nieterminalem oraz zdejmowanie elementu ze sto su po dojściu do stanu końcowego dla nieterminala. W dalszej części rozdziału bardziej szczegółowo opiszemy implementację diagramów przejść. Powyższa metoda działa, jeśli dostarczony diagram przejść jest deterministyczny, czyli gdy jest tylko jedno możliwe przejście z danego stanu przy danym wejściu. Jeśli istnieje niejednoznaczność, czasem możemy usunąć ją ad hoc, tak jak w przykładzie 4.15. Gdy nie możemy wyeliminować niedeterminizmu, to nie możemy zbudować analizatora przewidującego, ale jeżeli nie znajdziemy lepszej metody analizy składniowej, możemy zbudować analizator metodą zejść rekurencyjnych, korzystający z nawracania w celu systematycznego zbadania wszystkich możliwości. P r z y k ł a d 4.15. Na rysunku 4.10 przedstawiono zestaw diagramów przejść dla grama tyki (4.11). Jedyne niejednoznaczności dotyczą tego, czy wybrać, czy nie, przejście po krawędzi etykietowanej e. Jeśli zinterpretujemy krawędzie wychodzące z początkowego stanu E' jako nakazujące wykonanie przejścia po + , gdy jest on następnym symbolem na wejściu, i wykonanie przejścia po e w przeciwnym przypadku, oraz postąpimy analogicz nie dla stanu T', to usuniemy niejednoznaczności i będziemy mogli napisać analizator przewidujący dla gramatyki (4.11). •
E': ( 3
r : (10)——*{}})
F:
r
Kł3)"
(14
id Rys. 4.10.
D i a g r a m y przejść dla g r a m a t y k i ( 4 . 1 1 )
Diagramy przejść można upraszczać, podstawiając diagramy; takie podstawienia są podobne do przekształceń gramatyk omówionych w p. 2.5. Na rysunku 4.11 (a), na przykład, wywołanie E' przez siebie zostało zastąpione skokiem do początku diagramu dla E'.
±-0 (a)
(b)
E:
0
(c)
(d) Rys.
4.11.
U p r o s z c z o n e d i a g r a m y przejść
f
Na rysunku 4.1 l(b) przedstawiono równoważny diagram dla E . Możemy podsta wić diagram dla E' zamiast przejścia po krawędzi etykietowanej E' w diagramie dla E z rys. 4.10, otrzymując diagram jak na rys. 4.1 l(c). W końcu zauważamy, że pierwszy
i trzeci wierzchołek na tym rysunku są równoważne i łączymy je. Wynik (rys. 4.11(0)), jest powtórzony jako pierwszy diagram na rys. 4.12. Możemy stosować takie same techni ki do diagramów dla T i V. Kompletny zestaw diagramów wynikowych jest przedstawio ny na rys. 4.12. Implementacja w C takiego analizatora przewidującego działa o 2 0 - 2 5 % szybciej niż implementacja w C analizatora opartego na diagramach z rys. 4.10.
id R y s . 4.12.
U p r o s z c z o n e d i a g r a m y przejść dla w y r a ż e ń a r y t m e t y c z n y c h
Nierekurencyjna analiza składniowa z przewidywaniem Możliwe jest zbudowanie nierekurencyjnego analizatora przewidującego poprzez jawne utrzymywanie stosu, zamiast korzystania z niego niejawnie, poprzez wywołania rekuren cyjne. Kluczowym problemem podczas analizy przewidującej jest wybór produkcji, którą stosujemy do nieterminala. Nierekurencyjny analizator z rys. 4.13 wybiera produkcję do zastosowania, korzystając z tablicy analizatora składniowego. Poniżej sprawdzimy, jak takie tablice mogą być konstruowane bezpośrednio z niektórych gramatyk.
WEJŚCIE
STOS
X Y Z $
a
+
b
$
Program analizatora przewidującego
WYJŚCIE
Tablica analizatora składniowego M R y s . 4.13.
M o d e l n i e r e k u r e n c y j n e g o p r z e w i d u j ą c e g o analizatora l e k s y k a l n e g o
Analizator przewidujący sterowany tablicami składa się z bufora wejściowego, sto su, tablicy analizatora składniowego i strumienia wyjściowego. Bufor wejściowy zawie ra ciąg symboli do zanalizowania, zakończony symbolem $, używanym jako znacz nik prawego końca tekstu do zaznaczania końca ciągu wejściowego. Na stosie są przechowywane symbole gramatyki, z $ na dole, oznaczającym koniec stosu. Począt kowo na stosie jest tylko startowy symbol gramatyki, leżący nad $. Tablica analizatora jest dwuwymiarową tablicą A/[A,a], gdzie A jest nieterminalem, a a jest terminalem bądź symbolem $. Analizator jest sterowany przez program, który zachowuje się następująco: roz waża X, symbol na wierzchołku stosu, oraz a, aktualny symbol na wejściu. Te dwa symbole determinują czynność wykonywaną przez analizator. Istnieją trzy możliwości. 1. 2. 3.
Jeśli X — a — $, to analizator zatrzymuje się i ogłasza poprawne zakończenie analizy składniowej. Jeśli X = a ^ $, to analizator zdejmuje X ze stosu i przesuwa wskaźnik wejścia do następnego symbolu wejściowego. Jeśli X jest nieterminalem, to program sprawdza wartość M[X,a] w tablicy ana lizatora M. Tą wartością będzie albo X-produkcja gramatyki, albo wartość błąd. Jeśli, na przykład, M[X a] = {X -» UVW}, to analizator zastępuje X z wierzchoł ka stosu przez WVU (z U na wierzchołku). Przyjmijmy, że wyjściem analizatora jest wypisanie użytej produkcji; można w tym miejscu wykonać dowolny kod. Jeśli M[X,a) — błąd, analizator wywołuje procedurę obsługi błędu. :
Zachowanie analizatora może być opisane terminami jego konfiguracji, ślają zawartość stosu i pozostające wejście.
A l g o r y t m 4.3.
które okre
Nierekurencyjna analiza przewidująca.
Wejście. Napis w oraz tablica analizatora M dla gramatyki G. Wyjście. Jeśli w jest w L(G), to lewostronne wyprowadzenie w; w przeciwnym przypadku — informacja o błędzie. Metoda. Początkowo analizator jest w konfiguracji, w której na stosie jest $S, z S, star towym symbolem G, na wierzchołku, a w buforze wejściowym jest w$. Program, który korzysta z tablicy analizatora przewidującego M do zbudowania wyprowadzenia dla wej ścia, jest pokazany na rys. 4.14. •
P r z y k ł a d 4.16. Rozważmy gramatykę (4.11) z przykładu 4.8. Tablica analizatora prze widującego dla tej gramatyki jest pokazana na rys. 4.15. Puste miejsca to pozycje błędne, na niepustych są produkcje, za pomocą których należy rozwijać nieterminal z wierzchołka stosu. Przypominamy, że nie opisaliśmy jeszcze, skąd biorą się te wartości, ale wkrótce to zrobimy. Przy wejściu id + id * id analizator wykona ciąg czynności przedstawiony na rys. 4.16. Wskaźnik wejścia wskazuje skrajnie lewy symbol ciągu z kolumny W E J Ś C I E . Je śli uważnie przyjrzymy się działaniom analizatora, zauważymy, że śledzi on lewostronne wyprowadzenie dla wejścia, czyli wypisuje produkcje dla lewostronnego wyprowadzę-
n i e c h ip w s k a z u j e p i e r w s z y s y m b o l w $ ;
repeat m e c h X b ę d z i e s y m b o l e m z w i e r z c h o ł k a s t o s u , a a s y m b o l e m w s k a z y w a n y m p r z e z ip;
if X j e s t t e r m i n a l e m a l b o $ then if X = a then zdejmij X z e stosu i p r z e s u ń ip w p r z ó d
else
else błądC) /* X j e s t n i e t e r m i n a l e m */ if M[X,a] =X ^Y Y ---Y then begin l
2
k
zdejmij X z e stosu; p o ł ó ż y yY _ ,. k
k
• •Y
l
>
na s t o s i e , z Yy na w i e r z c h o ł k u ;
l
w y p i s z produkcję X —• Y Y • • • Y {
2
k
end else błądC) until X = $ /* stos jest pusty */ Rys. 4.14.
P r o g r a m analizatora p r z e w i d u j ą c e g o
S Y M B O L WEJŚCIOWY
NIETER MINAL
E E' T T' F
*
+
id E^TE'
)
(
E^TE' £' - ł e
E' -+ +TE'
r
T -> FT' T'^e
-> F 7 " 7"-+e
7" - > * F 7 "
F->id
F->(£)
Rys. 4.15. Tablica analizatora M dla gramatyki ( 4 . 1 1 )
STOS
$E $E'T $E'T'F $E'T'id $E'T' $E' $E'T+ $E'T %E'T'F $£'7'id
%E'T' %E'T'F* %E'T'F $F'T'id
%E'T' $£' $ Rys. 4.16.
WEJŚCIE
id + id + id + id + + + +
id id id id id id id id id id
* id$ * id$ * id$ * id$ * id$ * id$ * id$ * id$ * id$ * id$ * id$ id$ id$ id$ $ $ $
WYJŚCIE
E -> TE' T ^FT' F -+id r -¥ e -> + T £ ' r
F r
->id r -» * f t ' F -+id r -+ e E'
D z i a ł a n i e analizatora p r z e w i d u j ą c e g o na w e j ś c i u id + id * id
$
nia. Symbole wejściowe, które zostały już wczytane, z wymienionymi po nich symbolami gramatyki ze stosu (od wierzchołka do dna), tworzą w wyprowadzeniu lewostronne formy zdaniowe. •
FIRST i F O L L O W Podczas działania, analizator przewidujący korzysta z dwóch funkcji związanych z gra matyką G. Te funkcje, FIRST i FOLLOW, pozwalają, gdy jest to możliwe, wypełnić pozycje w tablicy analizatora przewidującego dla G. Zbiory symboli zwracane przez funkcję FOLLOW mogą być używane również jako symbole synchronizacyjne podczas odzyskiwania kontroli w trybie paniki. Jeśli a jest dowolnym ciągiem symboli z gramatyki, niech FIRST(a)
będzie zbio
rem terminali, od których zaczynają się ciągi wyprowadzalne z a . Jeśli a 4> e, to e także jest w F I R S T ( a ) . Zdefiniujmy FOLLOW(A), dla nieterminala A, jako zbiór terminali a, które mogą wystąpić bezpośrednio na prawo od A w pewnej formie zdaniowej, czyli jako zbiór terminali a, takich, dla których istnieje wyprowadzenie o postaci S aAa/5 dla jakichś a i j3. Zauważmy, że w trakcie wyprowadzania mogły istnieć symbole pomiędzy A i a, ale jeśli takie były, to został z nich wyprowadzony e, a one zniknęły. Jeśli A m o ż e być skrajnym prawym symbolem w jakiejś formie zdaniowej, to $ jest w FOLLOW(A). Aby obliczyć FIRST(X) dla wszystkich symboli z gramatyki X, należy działać zgod nie z poniższymi regułami, aż do żadnego ze zbiorów FIRST nie będzie już można dodać żadnych terminali ani e. 1. 2. 3.
Jeśli X jest terminalem, to F I R S T ^ O jest równe {X}. Jeśli X —¥ e jest produkcją, to należy dodać e do FIRST(X). Jeśli X jest nieterminalem i X -> Y Y - • -Y jest produkcją, to a trzeba umieścić X
2
k
w FIRST(X), jeżeli istnieje takie z, że a jest w F I R S T ( ^ ) , a e jest we wszystkich F I R S T ^ ) , . . . , F I R S T ( r ; _ ) , to znaczy gdy Y ---Y _ ^e. 1
x
i
Jeśli e jest w F I R S T ( F )
x
/
dla wszystkich j = 1, 2 , . . . , fc, to do FIRST(X) należy dodać e. Przykładowo, wszyst kie symbole z FIRST(y ) są z pewnością w FIRST(X). Jeśli z Y nie da się wypro 2
x
wadzić e, to do FIRST(X) nie dodajemy nic więcej, ale jeżeli Y => e, to dodajemy x
F I R S T ( r ) i tak dalej. 2
Możemy teraz obliczyć FIRST dla dowolnego ciągu X X • -X w następujący spo sób: dodajemy do FIRST(XjX • --X ) wszystkie różne od e symbole z FIRST(Xj). Jeżeli e jest w FIRST(Xj), to dodajmy wszystkie różne od e symbole z FIRST(X ), a jeśli e jest w F I R S T C ^ ) i FIRST(X ), to dodajemy symbole różne od e z FIRST(X ) i tak dalej. Na końcu dodajmy e do F I R S T ^ j A ^ • • -X ) jeśli, dla wszystkich /, FIRST(X-) zawiera e. Aby obliczyć FOLLOW(A) dla nieterminali A, należy stosować poniższe reguły, aż do żadnego ze zbiorów FOLLOW nie będzie można już nic dodać. X
2
2
n
n
2
2
3
n
1.
W FOLLOW(S), gdzie S jest symbolem startowym, trzeba umieścić $, znacznik
2.
Jeśli mamy produkcję A —> aBj3, to wszystkie symbole z FTRST(/3), z wyjątkiem e, należy umieścić w FOLLOW(5).
prawego końca wejścia.
3.
Jeśli mamy produkcję A —• aB albo produkcję A —• aBf3, gdzie FIRST(/3) zawiera e (tj. J3 4> e), to wszystkie symbole z FOLLOW(A) są w FOLLOW(F).
Przykład 4.17.
Rozpatrzmy ponownie gramatykę (4.11):
F -> TE' E' -> - + F F ' | e r -> F T ' T' -+ * F F ' | e F - » ( F ) | id Wówczas FIRST(F) = F I R S T ( F ) = F I R S T ( F ) = {(, id} FIRST(F') = { + , e} F I R S T ( 7 ' ) = {*, e} F O L L O W ( F ) = F O L L O W ( F ' ) = {), $} F O L L O W ( F ) = F O L L O W ( F ' ) = { + , ), $ } F O L L O W ( F ) - { + , *, ), $ } Na przykład, id i lewy nawias są dodawane do F I R S T ( F ) według reguły 3. z definicji FIRST, z i = 1 w obu przypadkach, bo FIRST (id) = { i d } oraz FIRST('(') = {(} według reguły 1. Następnie, według reguły 3. z i = 1, produkcja T —> FT' implikuje, że id i lewy nawias są również w FIRST(F). Kolejnym przykładem może być to, że e jest w FIRST(F') według reguły 2. Aby obliczyć zbiory FOLLOW, umieszczamy $ w F O L L O W ( F ) zgodnie z regu łą 1. Zgodnie z regułą 2., zastosowaną do produkcji F —> ( F ) , prawy nawias również jest w F O L L O W ( F ) . Zgodnie z regułą 3., zastosowaną do produkcji F -> TE , $ i prawy nawias są w F O L L O W ( F ' ) . Ponieważ F ' 4> e, są one również w F O L L O W ( F ) . Ostat nim przykładem zastosowania reguł dla F O L L O W będzie użycie reguły 2. do produkcji F —>• TE', która powoduje, że wszystko oprócz e z FIRST(F') musi być umieszczone w FOLLOW(F). Widzieliśmy już, że $ jest w F O L L O W ( F ) . • 1
Budowa tablic analizatorów przewidujących D o budowy tablicy analizatora przewidującego dla gramatyki G można użyć algorytmu opisanego poniżej. Zasada jego działania jest następująca. Przypuśćmy, że A -» a jest produkcją i a jest w F I R S T ( a ) . Wtedy analizator rozwija A, używając a , jeśli aktual nym symbolem wejściowym jest a. Problem pojawia się tylko wtedy, gdy a — e lub a =4> e. W takim przypadku, jeśli aktualny symbol wejściowy jest w FOLLOW(A) lub jeśli odczytano $ z wejścia i $ jest w FOLLOW(A), musimy jeszcze raz rozwinąć A, używając a. Algorytm 4.4.
Budowa tablicy analizatora przewidującego.
Wejście. Gramatyka G. Wyjście. Tablica analizatora M.
Metoda. 1. 2. 3.
4.
Dla każdej produkcji A —• a z gramatyki wykonaj kroki 2. i 3. Dla każdego nieterminala a z F I R S T ( a ) dodaj A —> a do Af[A, a]. Jeśli e jest w F I R S T ( a ) , dodaj A 4 a do M[A, b] dla każdego terminala b z FOLLOW(A). Jeśli e jest w F I R S T ( a ) oraz $ jest w FOLLOW(A), dodaj A a do M[A, $]. Wstaw b ł ą d na każdą niezdefiniowaną pozycję M. •
P r z y k ł a d 4.18. Zastosujmy algorytm 4.4 do gramatyki (4.11). Ponieważ FIRST (TE') — = FIRST(T) = {(, i d } , produkcja E -> TE' jest dopisywana do M[E, (] i do M[E, id]. Produkcja E' —> +TE' powoduje dopisanie E' -> + 7 F ' do M[E', + ] . Produkcja £ ' -> e powoduje dopisanie £ ' -> e do M [ £ ' , )] i do M[E\ $], gdyż F O L L O W ( £ ' ) = = {), $}• Tablicę analizatora zbudowaną przez algorytm 4.4 dla gramatyki (4.11) przedstawi liśmy na rys. 4.15. • Gramatyki LL(1) Algorytm 4.4 można zastosować do dowolnej gramatyki G, aby otrzymać tablicę anali zatora M. Jednakże dla niektórych gramatyk, M może mieć pewne pozycje, na których jest wiele wartości. Przykładowo, jeśli G ma lewostronną rekurencję albo jest niejedno znaczna, to w M będzie co najmniej jedna pozycja z wieloma wartościami.
P r z y k ł a d 4.19.
Przyjrzyjmy się ponownie gramatyce (4.13) z przykładu 4.10
S -> iEtSS' | a S' -> eS | e E -> b Tablica analizatora dla tej gramatyki jest pokazana na rys. 4.17.
NIETER MINAL
S Y M B O L WEJŚCIOWY
a
b
e
S Sf E
i S -> iEtSS'
t
$
S*->€
S' ->eS E^b
Rys. 4.17. Tablica analizatora M dla gramatyki (4.13) Na pozycji M[S\ e) jest jednocześnie S' -> eS i S' -+ e, gdyż FOLLOW(S') = — {e, $ } . Ta gramatyka jest niejednoznaczna i niejednoznaczność tę można zauważyć przy wyborze produkcji do zastosowania, gdy na wejściu jest e (else). Możemy ominąć tę niejednoznaczność, wybierając S -» eS. Taki wybór odpowiada przyłączaniu else do najbliższego poprzedzającego then. Zauważmy, że wybór S -> e spowodowałby, że e nif
f
gdy nie byłoby umieszczane na stosie ani usuwane z wejścia, więc byłby on z pewnością niepoprawny.
•
Gramatyka, dla której tablica analizatora nie ma pozycji z wieloma wartościami, jest nazywana gramatyką LL(1). Pierwsze „L" w LL(1) oznacza przeglądanie wejścia od lewej do prawej, drugie „L" - tworzenie lewostronnego wyprowadzenia, a „ 1 " - używanie do podejmowania decyzji jednego symbolu bieżącego w każdym kroku. Można wykazać, że dla dowolnej gramatyki LL(1), G, algorytm 4.4 generuje tablicę analizatora, która pozwoli wyprowadzić wszystkie i wyłącznie zdania z G. Gramatyki LL(1) mają kilka szczególnych własności. Żadna gramatyka niejedno znaczna albo z lewostronną rekurencją nie może być LL(1). Można również wykazać, że gramatyka G jest LL(1) wtedy i tylko wtedy, gdy dla A —> a \ fi, dwóch różnych, dowolnych produkcji z G, spełnione są następujące warunki: 1.
Dla każdego terminala a, z a i j3 nie daje się jednocześnie wyprowadzić ciągu rozpoczynającego się od a.
2.
Co najwyżej z jednego z a i j8 daje się wyprowadzić pusty ciąg.
3.
Jeśli p Ą- e, to z a nie można wyprowadzić żadnego ciągu rozpoczynającego się od terminala z FOLLOW(A).
Oczywiste jest, że gramatyka (4.11) dla wyrażeń arytmetycznych jest LL(1), a gramatyka (4.13), modelująca instrukcje if-then-else, nie jest. Pozostaje pytanie, co należy zrobić, gdy w tablicy analizatora są pozycje z wieloma wartościami. Jednym ze sposobów jest przekształcenie gramatyki przez usunięcie lewo stronnej rekurencji oraz, gdy jest to możliwe, wykonanie lewostronnej faktoryzacji, mając nadzieję, że uzyskamy gramatykę, dla której tablica analizatora nie będzie miała pozycji z wieloma wartościami. Niestety, są pewne gramatyki, dla których żadne przekształcenia nie pozwolą otrzymać gramatyki LL(1). Przykładem może być gramatyka (4.13). Język dla tej gramatyki nie może być opisany żadną gramatyką LL(1). Jak wiemy, możemy mimo to analizować (4.13) z użyciem analizatora przewidującego, arbitralnie narzucając M[S', e] — {S —> eS}. Niestety, nie ma uniwersalnych reguł, dzięki którym wiele war tości z danej pozycji można zastąpić jedną bez zmrany języka rozpoznawanego przez analizator. Głównym problemem przy używaniu analizy przewidującej jest zapisywanie grama tyki języka źródłowego tak, aby analizator przewidujący mógł być zbudowany z grama tyki. Chociaż usuwanie lewostronnej rekurencji oraz lewostronna faktoryzacja są proste do wykonania, powodują one, że wynikowa gramatyka jest skomplikowana i trudno ją zastosować do translacji. Aby ominąć część tych kłopotów, analizator w kompilatorze korzysta zazwyczaj z analizy przewidującej dla konstrukcji sterujących oraz z metody pierwszeństwa operatorów (opisanej w p. 4.6) dla wyrażeń. Jeżeli jednak mamy gene rator analizatorów LR, taki jak opisany w p. 4.9, automatycznie otrzymujemy wszystkie korzyści wynikające z analizy przewidującej i metody pierwszeństwa operatorów. r
Odzyskiwanie kontroli po wystąpieniu błędu w analizie przewidującej Stos nierekurencyjnego analizatora przewidującego jawnie przedstawia terminale i nieter minale, które analizator zamierza dopasować do pozostającego wejścia. Będziemy więc
w poniższym opisie odwoływać się do symboli ze stosu analizatora. Podczas analizy przewidującej błąd jest wykrywany, gdy terminal na wierzchołku stosu nie pasuje do kolejnego symbolu wejściowego, bądź gdy: na wierzchołku stosu jest nieterminal A, a jest kolejnym symbolem wejściowym, a tablica analizatora nie ma żadnej wartości na pozycji M[A, a]*. Odzyskiwanie kontroli w trybie paniki opiera się na pomijaniu symboli wejściowych aż do natrafienia na symbol leksykalny, który jest elementem wybranego zbioru symboli synchronizacyjnych. Efektywność tej metody zależy od wyboru zbioru synchronizacyj nego. Zbiory powinny być wybierane tak, aby analizator szybko obsługiwał błędy, które często występują w praktyce. Pewne heurystyki, które można stosować, to: 1.
2.
3.
4.
5.
W zbiorze synchronizacyjnym dla nieterminala A możemy umieścić wszystkie sym bole z FOLLOW(A). Jeśli będziemy pomijać symbole aż do napotkania elementu z FOLLOW(A) i zdejmiemy A ze stosu, to, zapewne, analiza będzie mogła być kontynuowana. Użycie samego zbioru FOLLOW(A) jako zbioru synchronizacyjnego dla A nie jest wystarczające. Na przykład, jeśli średniki kończą instrukcje, tak jak w języku C, to słowa kluczowe, które rozpoczynają instrukcje, mogą nie znaleźć się w zbiorze FOLLOW nieterminala tworzącego wyrażenia. Pominięcie średnika po przypisaniu może wówczas skutkować pominięciem słowa kluczowego rozpoczynającego kolejną instrukcję. Często istnieje hierarchiczna struktura konstrukcji języka: np. wyrażenia występują w instrukcjach, które są w blokach itd. Symbole rozpoczynające konstruk cje znajdujące się wyżej w hierarchii możemy dodać do zbioru synchronizacyjnego konstrukcji leżących niższej. Przykładowo, możemy dodać słowa kluczowe, któ re rozpoczynają instrukcje, do zbiorów synchronizacyjnych nieterminali tworzących wyrażenia. Jeśli dodamy symbole z FIRST(A) do zbioru synchronizacyjnego nieterminala A, to umożliwimy wznowienie analizy zgodnie z A, jeśli symbol z FIRST(A) pojawi się na wejściu. Jeśli nieterminal może wygenerować ciąg pusty, to produkcja, z której wyprowadza się e, może być użyta jako domyślna. Taka czynność może opóźnić wykrycie błę du, ale nie może spowodować jego niezauważenia. To podejście zmniejsza liczbę nieterminali, które trzeba rozważać podczas odzyskiwania kontroli. Jeśli terminal na wierzchołku stosu nie może być dopasowany, prostym sposobem jest zdjęcie tego terminala, wypisanie informacji mówiącej, że terminal został wsta wiony, i kontynuowanie analizy. Takie podejście traktuje zbiory synchronizacyjne symbolu jako składające się ze wszystkich innych symboli.
P r z y k ł a d 4.20. Wykorzystanie symboli z FOLLOW i FIRST jako symboli synchroni zacyjnych działa dość dobrze, gdy wyrażenia są analizowane zgodnie z gramatyką (4.11). Tablica analizatora dla tej gramatyki z rys. 4.15 jest powtórzona na rys. 4.18, z ,,synch" oznaczającym symbole synchronizacyjne pobrane ze zbioru FOLLOW rozpatrywanego nieterminala. Zbiory FOLLOW dla nieterminali bierzemy z przykładu 4.17. Z tablicy z rysunku 4.18 będziemy korzystać w sposób następujący. Jeśli analizator sprawdza pozycję A/[A, a] i stwierdza, że nie ma na niej wartości, to pomija symbol a.
* Zgodnie z algorytmem 4.4. na takich pozycjach są wartości
błąd
(przyp. tłum.).
Jeśli wartością jest synch, to nieterminal z wierzchołka stosu jest zdejmowany i podej mowana jest próba kontynuowania analizy. Jeśli symbol na wierzchołku stosu nie pasuje do symbolu wejściowego, to — j a k wspomnieliśmy powyżej — zdejmujemy symbol ze stosu. Przy nieprawidłowym wejściu ) i d * + i d analizator i mechanizm odzyskiwania kon troli z rys. 4.18 zachowuje się jak ten z rys. 4.19. • Powyższy opis odzyskiwania kontroli w trybie paniki nie odnosi się do ważnej kwe stii komunikatów o błędach. Zazwyczaj, komunikaty o błędach muszą zostać dostarczone przez projektanta kompilatora. Odzyskiwanie kontroli na poziomie frazy. Odzyskiwanie kontroli na poziomie frazy jest implementowane przez wypełnienie pustych pozycji w tablicy analizatora przewidu jącego wskaźnikami procedur obsługi błędów. Te procedury mogą zmieniać, dodawać lub usuwać symbole z wejścia i wypisywać odpowiednie komunikaty o błędach. Mogą one również zdejmować elementy ze stosu. Nie jest jasne, czy powinniśmy dopuścić do zmiany symboli na stosie lub do wstawiania nowych elementów na stos, gdyż kroki wyS Y M B O L WEJŚCIOWY
NIETER
id
MINAL E
£->
TE' E' ->
E' T
r F
*
+
T-+FT'
~>id
)
$
synch
synch
E' - > e
£'->€
+TE'
synch
synch
7"-*e
7"->e
synch
synch
T^ET'
synch
T'->e F
( E —> TE'
T'
*FT' synch
synch
F->(2?)
Rys. 4.18. S y m b o l e s y n c h r o n i z a c y j n e d o d a n e d o tablicy analizatora z r y s . 4 . 1 5
STOS $E $E $E'T $E'T'F
$£'T'id $E'T' $E'T'F* $E'T'F %E'T'
$£' $E'T + $E'T $E'T'F
$£'7'id $E'T'
$£' $
WEJŚCIE
WYJŚCIE
) i d *+ i d $ i d *+ i d $ i d *+ i d $ i d *- ł - i d $ i d *+ i d $ + id$ + id$ + id$ + id$ + id$ + id$ id$ id$ id$
błąd, p o m i ń ) i d jest w F I R S T ( £ )
błąd, M[F, + ] = s y n c h F zostało zdjęte
$ $ $
Rys. 4.19. A n a l i z a i o d z y s k i w a n i e kontroli p o w y s t ą p i e n i u b ł ę d u w y k o n y w a n e p r z e z analizator p r z e w i d u j ą c y
konywane przez analizator mogą nie odpowiadać wyprowadzeniu jakiegokolwiek słowa z języka. W każdym razie musimy się upewnić, że nie ma możliwości wejścia w pętlę nieskończoną. Wykazanie, że każda akcja odzyskiwania kontroli po wystąpieniu błę du skutkuje zużyciem symbolu wejściowego (albo zmniejszeniem wysokości stosu, jeśli osiągnięto koniec wejścia) jest dobrą metodą zabezpieczenia się przed takimi pętlami.
4.5
Analiza wstępująca
W tym podrozdziale wprowadziliśmy ogólną metodę analizy składniowej wstępującej, znaną jako analiza redukująca (ang. shift-reduce). Prostym do zaimplementowania rodza jem analizy redukującej, czyli metodą pierwszeństwa operatorów, zajęliśmy się w p. 4.6. Ogólniejszy rodzaj analizy redukującej, nazywany analizą LR, opisaliśmy w p. 4.7. Ana liza LR jest używana w wielu automatycznych generatorach analizatorów składniowych. Analiza redukująca buduje drzewo wyprowadzenia dla wejścia, zaczynając od liści (od dołu) i przechodząc w górę, aż do korzenia (do góry). Możemy o tym myśleć jak o „redukcji" ciągu w do startowego symbolu gramatyki. W każdym kroku redukcji pewien podciąg pasujący do prawej strony produkcji jest zastępowany symbolem z lewej strony tej produkcji i, jeśli podciąg jest poprawnie wybierany w każdym kroku, to śledzimy odwrotność prawostronnego wyprowadzenia. Przykład 4.21.
Rozważmy gramatykę
S -» aABe A Abc\b B -> d Zdanie abbcde można zredukować do S w następujących krokach: abbcde aAbcde aAde aABe S Przeglądamy abbcde, szukając podciągu, który pasuje do prawej strony jakiejś produkcji. Podciągi b i d spełniają ten warunek. Wybierzmy b, które jest z lewej strony, i zastąpmy je A, lewą stroną produkcji A —» b\ otrzymamy więc ciąg aAbcde. Następnie, podciągi Abc, b i d pasują do prawej strony jakiejś produkcji. Chociaż b jest podciągiem położo nym najbardziej na lewo, który pasuje do prawej strony jakiejś produkcji, my wybierzemy zastąpienie podciągu Abc przez A, lewą stronę produkcji A -> Abc. Otrzymamy wtedy aAde. Wówczas, zastępując d przez B, lewą stronę produkcji B —)• d otrzymamy aABe. Możemy teraz zastąpić cały ciąg przez S. Zatem, za pomocą czterech kolejnych redukcji możemy zredukować abbcde do S. Te redukcje, w istocie, śledzą następujące prawostron ne wyprowadzenie od końca: t
S =3- aABe aAde aAbcde abbcde rm rm r/n rm
•
Uchwyty
Nieformalnie, „uchwyt" ciągu to podciąg, który pasuje do prawej strony produkcji i któ rego redukcja do nieterminala po lewej stronie produkcji reprezentuje jeden krok wzdłuż odwrotności prawostronnego wyprowadzenia. W wielu przypadkach skrajny lewy pod ciąg /3, który pasuje do prawej strony jakiejś produkcji A —> fl, nie jest uchwytem, bo redukcja według produkcji A —>• /3 daje ciąg, który nie może być zredukowany do symbolu startowego. W przykładzie 4.21, gdybyśmy zastąpili b przez A w drugim ciągu aAbcde, otrzymalibyśmy ciąg, który później nie mógłby być zredukowany do S. Z tego powodu musimy podać dokładniejszą definicję uchwytu. Formalnie uchwyt prawostronnej formy zdaniowej y to produkcja A -» j3 i pozycja w y, na której znajduje się ciąg symboli /3, który należy zastąpić przez A, aby otrzymać poprzednią prawostronną formę zdaniową w prawostronnym wyprowadzeniu y. Czyli, jeśli S 4> aAw a/3 w, to A —> /3 na pozycji po a jest uchwytem a/3 w. Ciąg symborm
rm
li w po prawej stronie uchwytu zawiera tylko symbole terminalne. Podkreślmy, że może istnieć więcej niż jeden uchwyt, bo gramatyka może być niejednoznaczna i może ist nieć więcej niż jedno prawostronne wyprowadzenie a/3 w. Natomiast, jeżeli gramatyka jest jednoznaczna, to każda prawostronna forma zdaniowa z tej gramatyki ma dokładnie jeden uchwyt. W powyższym przykładzie abbcde jest prawostronną formą zdaniową, której uchwy tem jest A —> b na pozycji 2. Podobnie, aAbcde jest prawostronną formą zdaniową, której uchytem jest A —> Abc na pozycji 2. Czasem mówimy „podciąg j6 jest uchwytem a/3 w", jeśli pozycja /3 i produkcja A -» /3, o których myślimy, nie budzą wątpliwości. Na rysunku 4.20 przedstawiono uchwyt A —»/3 w drzewie wyprowadzenia prawo stronnej formy zdaniowej a/3 w. Uchwyt reprezentuje skrajnie lewe pełne poddrzewo składające się z wierzchołka i wszystkich jego potomków. Na tym rysunku A jest najniż szym skrajnie lewym wierzchołkiem wewnętrznym, ze wszystkimi dziećmi w drzewie. O redukcji j3 do A w a/3w można myśleć jak o „odcinaniu uchwytu", czyli usuwaniu dzieci A z drzewa wyprowadzenia.
Przykład 4.22.
Rozważmy następującą gramatykę:
(1) (2) (3)
E^E
+ E E-*E*E E -+ (E)
(4)
E -> id
( 4 , 1 0 j
i prawostronne wyprowadzenie E
E + E rm =^ E + E * E rm E + E * id. r/n
—-
=4> £ + id-, * idi J
rm
—id, + i d + id. rm *• Dla wygody nadaliśmy indeksy identyfikatorom id, a podkreślenia wskazują uchwyty ko lejnych prawostronnych form zdaniowych. Na przykład idj jest uchwytem prawostronnej formy zdaniowej idj + i d * i d , gdyż id jest prawą stroną produkcji E —> id, a zastąpie nie idj przez E tworzy poprzednią prawostronną formę zdaniową E + i d * i d . Zauważ my, że ciąg symboli na prawo od uchwytu składa się z samych symboli terminalnych. 0
D
2
3
2
3
Ponieważ gramatyka (4.16) jest niejednoznaczna, istnieje inne prawostronne wypro wadzenie tego samego tekstu E £ * E rm E * id. rm —=> E + E * id. rm E + i d * id. rm —9
id, + id. -I-id. rm —Przyjrzyjmy się prawostronnej formie zdaniowej E + E * i d . W tym wyprowadzeniu E + E jest uchwytem E + E * i d , ale we wcześniejszym wyprowadzeniu uchwytem tej prawostronnej formy zdaniowej jest i d . Dwa prawostronne wyprowadzenia w tym przykładzie są podobne do dwóch wypro wadzeń lewostronnych Przycinanie uchwytówz przykładu 4.6. Pierwsze wyprowadzenie daje operacji * wyższy priorytet niż operacji + , a w drugim wyższy priorytet ma + . • Odwrotność prawostronnego wyprowadzenia można otrzymać przez „przycinanie uchwy tów". Zaczniemy od ciągu terminali w, który chcemy przeanalizować. Jeśli w jest zda niem z używanej gramatyki, to w = y„, gdzie y jest n-tą prawostronną formą zdaniową w pewnym, jeszcze nieznanym, prawostronnym wyprowadzeniu 3
3
3
n
rm
rm ~ rm
rm
rm
Aby odtworzyć to wyprowadzenie od końca, wyszukujemy uchwyt j3„ w y i zastępu jemy /3„ lewą stroną pewnej produkcji A -+ j3 , otrzymując (n — l)-szą prawostronną n
n
rt
formę zdaniową Y ~\- Wprawdzie nie wiemy jeszcze, skąd brać uchwyty, ale już wkrótce poznamy metody ich wyszukiwania. Następnie powtarzamy ten proces, czyli wyszukujemy uchwyt p _ w 7 _, i redu kujemy ten uchwyt tak, aby otrzymać prawostronną formę Y _->. Jeśli powtarzając takie czynności, dojdziemy do prawostronnej formy zdaniowej składającej się tylko z symbolu startowego S, to zatrzymujemy się i ogłaszamy pomyślne zakończenie analizy. Zapisany od końca ciąg produkcji, których używaliśmy w redukcjach, jest prawostronnym wypro wadzeniem dla ciągu symboli z wejścia. n
n
l
fl
n
P r z y k ł a d 4.23. Rozważmy gramatykę (4.16) z przykładu 4.22 i ciąg symboli wejścio wych idj + i d * i d Sekwencja redukcji (pokazana na rys. 4.21) redukuje i d + i d * i d do symbolu startowego E. Czytelnik powinien zauważyć, że sekwencja prawostronnych form zdaniowych w tym przykładzie jest zapisaną od końca sekwencją z pierwszego prawostronnego wyprowadzenia w przykładzie 4.22. • 2
v
l
UCHWYT
PRAWOSTRONNA F O R M A ZDANIOWA
id +id *id E 4- i d * i d E+E * id t
2
2
3
E+E
*E E+E E
3 3
id id id
1 2 3
E #E E+E
2
3
PRODUKCJA UŻYWANA W REDUKCJI
E- ^ i d E -+ id E- ^ i d E- H> E * E E -^E + E
Rys. 4.21. R e d u k c j e w y k o n a n e p r z e z analizator redukujący
I m p l e m e n t a c j a stosowa analizy r e d u k u j ą c e j Są dwa problemy, które musimy rozwiązać, jeśli chcemy analizować teksty, używając przycinania uchwytów. Pierwszym jest znalezienie podciągu, który trzeba zredukować, w prawostronnej formie zdaniowej, a drugim - wybranie jednej z produkcji, gdy jest więcej niż jedna produkcja z tym podciągiem po prawej stronie. Zanim zaczniemy roz wiązywać te problemy, rozważmy, jakich rodzajów struktur danych można używać w ana lizatorze redukującym. Wygodną metodą implementacji analizatora redukującego jest użycie stosu do pa miętania symboli gramatyki oraz bufora wejściowego do przechowywania tekstu w, który chcemy przeanalizować. Używamy $ do zaznaczenia dna stosu oraz prawego końca wej ścia. Początkowo stos jest pusty, a na wejściu jest napis w STOS $
WEJŚCIE w$
Analizator przesuwa zero lub więcej symboli z wejścia na stos, aż na wierzchołku stosu będzie uchwyt j3. Analizator redukuje wtedy fi do lewej strony odpowiedniej produkcji. Powtarza on ten cykl aż do wystąpienia błędu albo do czasu, gdy na stosie będzie symbol startowy, a wejście będzie puste
STOS
WEJŚCIE
$5
$
Po osiągnięciu takiej konfiguracji, analizator zatrzymuje się i ogłasza pomyślne zakoń czenie analizy.
P r z y k ł a d 4.24. Prześledźmy kolejne akcje, które analizator redukujący może wyko nać podczas analizowania wejścia idj + i d * i d zgodnie z gramatyką (4.16), używając pierwszego wyprowadzenia z przykładu 4.22. Sekwencja tych operacji jest pokazana na rys. 4.22. Zauważmy, że ponieważ gramatyka (4.16) ma dwa prawostronne wyprowadze nia dla takiego wejścia, to istnieje inna sekwencja operacji, które mógłby wykonywać analizator. • 0
STOS (i)
(2) (3) (4) (5) (6) (7) (8) (9) (10) (H)
$ $idj $E $E + $E + id $E + E $E + E * $£ + £ * id $£ + £ * £ $E + E $E
Rys. 4.22.
3
WEJŚCIE
idj + i d + id + id id
2
3
2
2
0
0
* * * * * *
OPERACJA
id $ id $ id $ id $ id $ id $ id $ 3
przesunięcie
3
redukcja z g o d n i e z E -^ i d
3
przesunięcie
3
przesunięcie
3
redukcja z g o d n i e z E -^ i d
3
przesunięcie
3
przesunięcie
"$
redukcja z g o d n i e zE
$ $ $
~^ i d
redukcja z g o d n i e z E -•»
E *E
redukcja z g o d n i e z E akceptowanie wejścia
Konfiguracje analizatora r e d u k u j ą c e g o dla w e j ś c i a
id, + i d
2
* id
Chociaż głównymi operacjami analizatora są przesunięcie i redukcja, naprawdę ist nieją cztery akcje, które analizator redukujący może wykonać: przesunięcie, redukcja, akceptowanie i błąd. 1. 2.
3. 4.
Operacja przesunięcie powoduje wstawienie kolejnego symbolu z wejścia na wierz chołek stosu. Podczas redukcji analizator wie, że prawy koniec uchwytu jest na wierzchołku stosu. Musi on znaleźć na stosie lewy koniec uchwytu i zdecydować, którym nieterminalem zastąpić uchwyt. Operacja akceptowanie oznacza pomyślne zakończenie analizy. Operacja błąd oznacza, że wystąpił błąd składniowy i analizator wywołuje procedurę obsługi błędu.
Ważny fakt uzasadnia używanie stosu w analizie redukującej: uchwyt zawsze znaj duje się na wierzchołku stosu, a nigdy w jego środku. Fakt ten staje się oczywisty, gdy rozważymy dwa kolejne kroki w dowolnym wyprowadzeniu prawostronnym. Te kroki mogą być następujące:
(1)
S
aAz 4> ajiByz 4> ajiyyz rm
(2)
rm
rm
S 4> aBxAz =^ aBxyz ==> ayxyz
rm rm rm W przypadku (1) A jest zastępowane napisem f3By, a następnie skrajnie prawy nietermi nal B jest zastępowany y. W przypadku (2) znowu A jest zastępowane pierwsze, ale tym razem prawa strona jest ciągiem y samych terminali. Kolejny skrajnie prawy nieter minal B będzie gdzieś po lewej stronie y. Rozważmy przypadek (1), zaczynając od końca, gdy analizator właśnie osiągnął konfigurację STOS
$a/3y
WEJŚCIE
yz$
Analizator redukuje uchwyt y do B i osiąga konfigurację STOS
$a/3£
WEJŚCIE
yzS
Ponieważ B jest skrajnym prawym nieterminalem w ajiByz, to prawy koniec uchwytu ajiByz nie może znajdować się wewnątrz stosu. Analizator może więc przesunąć ciąg y na stos i osiągnąć konfigurację STOS
WEJŚCIE
$af3By
z$
w której fiBy jest uchwytem i jest redukowane do A. W przypadku (2), w konfiguracji STOS
%ay
WEJŚCIE
xyz%
uchwyt y jest na wierzchołku stosu. Po zredukowaniu uchwytu y d o B, analizator może przesunąć ciąg xy, aby umieścić kolejny uchwyt y na wierzchołku stosu STOS
$aBxy
WEJŚCIE
zS
Wówczas analizator redukuje y do A. W obu przypadkach analizator musiał przesunąć zero lub więcej symboli, aby po wykonaniu redukcji kolejny uchwyt znalazł się na stosie. Nigdy nie musiał zagłębiać się w stosie w celu znalezienia uchwytu. Właśnie ta cecha przycinania uchwytów czyni stos strukturą danych szczególnie przydatną d o implementacji analizatorów redukujących. Ciągle nie wyjaśniliśmy, j a k wybierać akcje, aby analizator redukujący działał poprawnie. Metoda pierwszeństwa operatorów i analizatory LR, które wkrótce opiszemy, to dwie techniki takiego wyboru. Prefiksy żywotne Prefiksy prawostronnych form zdaniowych, które mogą wystąpić na stosie analizatora redukującego, są nazywane prefiksami żywotnymi. W innej definicji prefiksu żywotnego jest on określony jako prefiks prawostronnej formy zdaniowej, który kończy się przed
prawym końcem skrajnego prawego uchwytu tej prawostronnej formy zdaniowej. Zgodnie z tą definicją do końca prefiksu żywotnego zawsze można dodać terminale i otrzymać prawostronną formę zdaniową. W związku z tym, dopóki fragment wejścia widziany do danej chwili może zostać zredukowany do prefiksu żywotnego, wejście nie ma błędów. Konflikty podczas analizy redukującej Istnieją gramatyki bezkontekstowe, do których nie można stosować analizy redukującej. Dowolny analizator redukujący dla takich gramatyk może osiągnąć konfigurację, w której mimo znajomości całej zawartości stosu i następnego symbolu wejściowego, nie może zdecydować, czy wykonać przesunięcie czy redukcję (konflikt przesunięcie/redukcja), albo nie może zdecydować, którą z wielu możliwych redukcji wykonać (konflikt re dukcja/redukcja). Przedstawimy teraz kilka przykładów konstrukcji składniowych, które powodują powstawanie takich gramatyk. Formalnie, gramatyki te nie są w klasie LR(fc), zdefiniowanej w p. 4.7; mówimy o nich jako o gramatykach nie-LR. Litera k w LR(k) oznacza liczbę podglądanych symboli z wejścia. Gramatyki używane w kompilatorach zazwyczaj należą do klasy LR(1) i podglądają jeden symbol. Przykład 4.25. Niejednoznaczna gramatyka nie może być LR. Rozważmy jako przy kład gramatykę dla „wiszącego else" (4.7), z p. 4.3 instr —> if wyr then instr \ if wyr then instr else instr | other Jeśli nasz analizator redukujący jest w konfiguracji STOS
• • • if wyr then instr
WEJŚCIE
else • • • $
to nie możemy powiedzieć, czy if wyr then instr jest uchwytem, niezależnie od tego, co jest na stosie poniżej. Mamy więc konflikt przesunięcie/redukcja. Zależnie od tego, co jest po else na wejściu, poprawne może być zredukowanie if wyr then instr do instr bądź przesunięcie else i odczytanie kolejnej instr kończącej alternatywę if wyr then instr el se instr. Nie możemy więc w tym przypadku zdecydować, czy przesunąć, czy zreduko wać, więc gramatyka nie jest LR(1). Mówiąc bardziej ogólnie, żadna niejednoznaczna gramatyka, a ta z pewnością jest niejednoznaczna, nie może być LR(k) dla żadnego k. Powinniśmy jednak wspomnieć, że analiza redukująca może być łatwo przystosowa na do analizowania pewnych niejednoznacznych gramatyk, takich jak opisana wcześniej gramatyka dla if-then-else. Gdy budujemy taki analizator dla gramatyki zawierającej dwie powyższe produkcje, to będzie występował konflikt przesunięcie/redukcja: przy else będzie można wykonać przesunięcie albo redukcję z użyciem produkcji instr —> if wyr then instr. Jeśli rozwiążemy ten konflikt na korzyść przesunięcia, to analizator będzie się zachowywał naturalnie. Analizatory dla takich niejednoznacznych gramatyk są opisane w p. 4.8. • Innym częstym powodem, dla którego gramatyki nie są LR, jest to, iż mimo że wiemy, że mamy uchwyt, to zawartość stosu i kolejny symbol z wejścia nie wystarczają
do zdecydowania, której produkcji trzeba użyć do redukcji. W następnym przykładzie pokazaliśmy taką sytuację. Przykład 4.26. Przypuśćmy, że mamy analizator leksykalny, który zwraca symbol id dla wszystkich identyfikatorów, niezależnie od tego, do czego są one używane. Przypu śćmy ponadto, że w naszym języku procedury wywołuje się przez podanie ich nazwy, z parametrami w nawiasach, oraz że do tablic odwołuje się w ten sam sposób. Ponie waż przekształcenia indeksów w odwołaniach do tablic oraz parametrów w wywołaniach procedur są różne, więc chcemy używać różnych produkcji do wygenerowania list aktu alnych argumentów oraz indeksów. Nasza gramatyka może więc mieć (między innymi) takie produkcje, jak: (1) (2) (3) (4) (5) (6) (7) (8) (9)
instr instr lista^arg lista-arg argument wyr wyr lista^wyr lista-wyr
-» id(lista-arg) —> wyr := wyr —> lista-arg , argument -» argument —> id —> id(lista-wyr) -> id —>• lista-Wyr , wyr —>• wyr
Instrukcja rozpoczynająca się od A(I, J) dla analizatora byłaby ciągiem symboli id(id, id). Po przesunięciu trzech pierwszych symboli na stos analizator redukujący byłby w konfiguracji STOS
id( id
WEJŚCIE
, id )
Oczywiste jest, że id na wierzchołku stosu musi zostać zredukowane, ale z użyciem której produkcji? Prawidłowym wyborem jest produkcja (5), jeśli A jest procedurą, oraz (7), jeśli A jest tablicą. Na stosie nie ma informacji o typie A; pochodząca z deklaracji A informacja jest w tablicy symboli. Rozwiązaniem może być zmiana symbolu id w produkcji ( 1 ) na procid oraz użycie bardziej wyrafinowanego analizatora leksykalnego, który zwraca symbol procid, jeśli od czyta on identyfikator, będący nazwą procedury. Aby móc to zrobić, analizator leksykalny musiałby odwoływać się do tablicy symboli przed zwróceniem symbolu. Jeśli wykonamy taką modyfikację, to przy przetwarzaniu A {I, J) analizator mógłby być w jednej z dwóch konfiguracji STOS
WEJŚCIE
• procid( id
, id ) •
albo w konfiguracji pokazanej wcześniej. W pierwszym przypadku wybieramy redukcję według produkcji (5), w drugim — według produkcji (7). Zauważmy, że trzeci od góry symbol na stosie determinuje wybór używanej produkcji, pomimo że on sam nie jest w tej redukcji używany. Analizatory redukujące do sterowania analizą mogą wykorzystywać informacje znajdujące się w głębi stosu. •
4.6
Metoda pierwszeństwa operatorów
Największa klasa gramatyk, dla których można zbudować analizatory redukujące - gra matyki L R - jest opisana w p. 4.7. Dla niewielkiej, lecz ważnej klasy gramatyk możemy łatwo ręcznie budować wydajne analizatory redukujące. Te gramatyki cechuje (między innymi) to, że prawa strona jakiejkolwiek produkcji nie jest równa e ani nie zawiera dwóch sąsiadujących nieterminali. Gramatykę mającą drugą z tych własności nazywamy gramatyką operatorową. P r z y k ł a d 4.27.
Następująca gramatyka dla wyrażeń:
E -> EAE | (E) | -E | id A -> + | - | * | / | T nie jest gramatyką operatorową, gdyż po prawej stronie, w EAE, są dwa (a nawet trzy) kolejne nieterminale. Jeżeli jednak za A podstawimy każdą z jego alternatyw, otrzymamy następującą gramatykę operatorową: E^E
+ E | E-E
| E * E \ E/E
\ E T E \ (E) | - E | id
(4.17)
Opiszemy teraz łatwą w implementacji technikę analizy, nazywaną metodą pierw szeństwa operatorów*. Technika ta została najpierw opisana jako manipulacje na sym bolach, bez żadnych odwołań do ukrytej gramatyki. W istocie, gdy skończymy budować analizator dla gramatyki pisany metodą pierwszeństwa operatorów, możemy ignorować gramatykę, używając nieterminali na stosie tylko do zajmowania miejsca dla atrybutów związanych z nieterminalami. Jako ogólna technika, metoda pierwszeństwa operatorów ma kilka wad. Trudno jest, na przykład, obsługiwać symbol, taki jak znak minus, który ma dwa różne priorytety (w zależności od tego czy ma jeden, czy dwa argumenty). Co gorsza, ponieważ związek między analizowaną gramatyką a analizatorem napisanym metodą pierwszeństwa ope ratorów jest słaby, nie można być zawsze pewnym, że analizator akceptuje właśnie ten żądany język. I w końcu, metodą pierwszeństwa operatorów można analizować tylko małą klasę gramatyk. M i m o tych wad, ze względu na prostotę, napisano wiele kompilatorów używających metody pierwszeństwa operatorów do analizy wyrażeń. Często takie analizatory używa ją metody zejść rekurencyjnych, opisanej w p. 4.4, do analizy instrukcji. Analizatory działające metodą pierwszeństwa operatorów były używane nawet do całych języków. W metodzie pierwszeństwa operatorów między pewnymi parami terminali definiu jemy trzy rozłączne relacje priorytetów: < = oraz • > . Te relacje priorytetów kierują wyborem uchwytów i mają następujące znaczenie:
RELACJA
ZNACZENIE
a <-b a ~ b a-> b
a ma mniejszy priorytet niż b a ma taki sam priorytet jak b a ma wyższy priorytet niż b
* Właściwie metoda powinna nazywać się metodą priorytetów operatorów, ale ze względu na to, że pojęcie metody pierwszeństwa operatorów pojawiało się już w literaturze, używamy tej nazwy (przyp. tłum.).
Musimy ostrzec Czytelnika, iż — mimo że te relacje mogą wyglądać podobnie do relacji arytmetycznych „mniejszy", „równy" i „większy niż" — relacje priorytetów mają całkiem inne własności. Możemy mieć na przykład, a <-b oraz a-> b dla tego samego języka albo też dla pewnej pary terminali a i b może nie zachodzić ani a < b, a = /?, ani a • > b. Istnieją dwie powszechnie stosowane metody sprawdzania, jakie relacje priorytetów powinny zachodzić między parami terminali. Pierwsza opisywana przez nas metoda jest intuicyjna i opiera się na tradycyjnych pojęciach łączności i priorytetów operatorów. Przykładowo, jeśli * ma wyższy priorytet niż -f, to ustalamy + < • * oraz * - > -f. Jak się przekonamy, to podejście usuwa niejednoznaczność z gramatyki (4.17) i pozwala nam napisać dla niej analizator metodą pierwszeństwa operatorów (chociaż jednoargumentowy minus stwarza problemy). Druga metoda wyboru relacji priorytetów operatorów polega na zbudowaniu jed noznacznej gramatyki dla języka, takiej, że drzewa wyprowadzenia budowane przy jej użyciu będą oddawać właściwą łączność i priorytety operatorów. W przypadku wyrażeń nie jest to trudne zadanie; składnia wyrażeń z p. 2.2 może być przykładem. Dla kolejne go często spotykanego źródła niejednoznaczności, „wiszącego else", modelem może być gramatyka (4.9). Gdy dysponujemy już jednoznaczną gramatyką, korzystamy z mecha nicznego sposobu, aby używając jej, skonstruować relacje priorytetów. Relacje te mogą nie być rozłączne i mogą pozwalać na analizowanie innych języków niż generowane przez początkową gramatykę, ale przy standardowych wyrażeniach arytmetycznych w praktyce nie spotyka się wielu problemów. Nie będziemy tu zajmować się tą metodą; jest ona opisana w książce Aho i Ullmana [1972b].
Używanie relacji priorytetów operatorów Zadaniem relacji priorytetów jest oddzielenie uchwytów prawostronnych form zdanio wych, z < • oznaczającym lewy kraniec, = występującym w środku uchwytu i • > na pra wym krańcu. Dokładniej, przyjmijmy, że mamy prawostronną formę zdaniową gramatyki operatorowej. Z faktu, że po prawej stronie produkcji nie ma sąsiadujących nieterminali wynika, że nie ma ich też w prawostronnej formie zdaniowej. Możemy więc prawostron ną formę zdaniową zapisać jako P a f5 - • -a j5 , gdzie każde z j3- jest albo e (pustym ciągiem), albo pojedynczym nieterminalem, a każde z a jest pojedynczym terminalem. 0
l
{
n
n
z
i
Załóżmy, że między a i zachodzi dokładnie jedna z relacji < = , • > . Ponadto, będziemy używali $ do oznaczania krańca ciągu oraz ustalimy, że $ < -b oraz b- > $ dla wszystkich symboli terminalnych b. Przypuśćmy teraz, że usuwamy nieterminale z ciągu i umieszczamy symbol właściwej relacji, < •, = lub • > , między każdą parą terminali i między krańcowymi terminalami i znakami $ oznaczającymi krańce ciągu. Załóżmy, na przykład, że początkowo mamy prawostronną formę zdaniową id + id * id oraz relacje priorytetów przedstawione na rys. 4.23. Relacje te są częścią tych, które wybralibyśmy do analizy z użyciem gramatyki (4.17). {
Ciąg z wstawionymi relacjami priorytetów wygląda następująco: $ < id > + < id > * < id > $
(4.18)
Przykładowo, między lewym $ a id wstawiono < bo w wierszu oznaczonym $ i ko lumnie oznaczonej id jest < •. Uchwyt można znaleźć w następujący sposób:
id id
+ #
$
< < <
+
$
•>
>
>
•>
<•
•>
•>
•>
•>
<
<
Rys. 4.23. Relacje priorytetów operatorów 1. 2. 3.
Przeglądamy ciąg od lewej strony aż do napotkania pierwszego • > . W ciągu (4.18) następuje to między pierwszym id a + . Następnie przechodzimy do tyłu (w lewo) po kolejnych ^ , aż do napotkania < •. W ciągu (4.18) cofamy się do $. Uchwyt składa się ze wszystkich symboli leżących między pierwszym • > z prawej a < • znalezionym w kroku 2. z lewej strony, włączając wszystkie zawarte lub ota czające nieterminale. (Dołączenie otaczających nieterminali jest konieczne, aby nie spowodować sąsiadowania dwóch nieterminali w prawostronnej formie zdaniowej). W ciągu (4.18) uchwytem jest pierwsze id.
Jeśli korzystamy z gramatyki (4.17), zredukujemy id do E. Otrzymamy wówczas prawostronną formę zdaniową E + id * id. Po analogicznym zredukowaniu dwóch po zostałych id do E otrzymamy prawostronną formę zdaniową E + E * E. Zajmijmy się teraz ciągiem $ + *$ otrzymanym przez skasowanie nieterminali. Po wstawieniu relacji priorytetów mamy $<•+<•*•>$
co oznacza, że lewy koniec uchwytu jest między + a *, a prawy koniec między * a $. Takie relacje priorytetów wskazują, że w prawostronnej formie zdaniowej uchwytem jest E * E. Jak widać, E otaczające * są w tym uchwycie. Ponieważ nieterminale nie wpływają na analizę, nie musimy się przejmować ich odróżnianiem. Na stosie analizatora redukującego możemy przechowywać znacznik „nie terminal", oznaczający miejsce zajęte dla wartości atrybutów. Po przeczytaniu powyższego opisu można odnieść wrażenie, że w każdym kroku, aby znaleźć uchwyt, należy przejrzeć całą prawostronną formę zdaniową. Nie będzie to prawdą, gdy użyjemy stosu do przechowywania już obejrzanych symboli wejściowych i skorzystamy z relacji priorytetów do sterowania działaniem analizatora redukującego. Jeśli między terminalem na wierzchołku stosu a kolejnym symbolem z wejścia zachodzi < • lub = , analizator wykonuje przesunięcie; oznacza to, że nie znalazł jeszcze uchwytu. Jeśli zachodzi • > , to wykonywana jest redukcja. W tym momencie analizator znalazł prawy kraniec uchwytu, a relacji priorytetów można użyć do znalezienia na stosie lewego krańca. Jeśli między parą terminali nie zachodzi żadna z relacji (co na rys. 4.23 jest za znaczone jako pusta pozycja), to wykryto błąd składniowy i trzeba wywołać procedurę obsługi błędu, opisaną w dalszej części rozdziału. Powyższe rozważania formalizujemy w następującym algorytmie. Algorytm 4.5.
Algorytm analizy metodą pierwszeństwa operatorów.
Wejście. Tekst wejściowy w i tablica relacji priorytetów.
Wyjście. Jeśli w jest poprawny — szkieletowe drzewo wyprowadzenia, z zajmującym miejsce nieterminalem E etykietującym wszystkie węzły wewnętrzne; w przeciwnym przypadku — informacje o błędzie. Metoda. Początkowo stos zawiera $, a bufor wejściowy ciąg w$. Aby przeprowadzić analizę, wywołujemy program z rys. 4.24. • (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)
zainicjuj ip tak, aby wskazywało pierwszy symbol w$; repeat forever if $ jest na wierzchołku stosu oraz ip wskazuje $ then return else begin niech a będzie symbolem z wierzchołka stosu a b - symbolem wskazywanym przez ip; if a < -b lub a = b then begin odłóż b na stos; przesuń ip do następnego symbolu wejściowego; end; else if a> b then /* redukcja */ repeat zdejmij element ze stosu until szczytowy element ze stosu jest w relacji < • z poprzednio zdjętym terminalem else błądO end Rys. 4.24. Algorytm analizy metodą pierwszeństwa operatorów
Wyznaczanie relacji priorytetów z łączności i priorytetów operatorów Oczywiście, zawsze możemy dowolnie ustalić relacje priorytetów operatorów i mieć na dzieję, że korzystający z nich analizator używający metody pierwszeństwa operatorów będzie działał poprawnie. W przypadku języka wyrażeń arytmetycznych, takiego jak ge nerowany przez gramatykę (4.17), d o uzyskania właściwych relacji priorytetów możemy użyć opisanej poniżej heurystyki. Zauważmy, że gramatyka (4.17) jest niejednoznacz na, więc prawostronna forma zdaniowa może mieć wiele uchwytów. Nasze reguły są zaprojektowane tak, żeby wybierały „właściwe" uchwyty, zgodnie z podanymi regułami łączności i priorytetami dla dwuargumentowych operatorów. 1.
2.
r z
m
m
z e
Jeśli operator 6 ma wyższy priorytet niż 6 , P y j y y > 0\ * > 0 oraz 6 <0 Jeśli, przykładowo, priorytet * jest wyższy niż + , przyjmijmy, że * • > -F oraz + < •*. Te relacje gwarantują, że w wyrażeniach E + E * E + E, centralne E * E jest uchwytem, który będzie zredukowany jako pierwszy. Jeśli B i 6 są operatorami o tym samym priorytecie (lub jest to jeden operator), przyjmijmy, że 9 -> 0 oraz Q > 0 jeśli te operatory są łączne lewostronnie, bądź 6 <0 oraz 9 < • 0 ] , jeśli są łączne prawostronnie. Jeśli, na przykład, + i są lewostronnie łączne, to przyjmijmy + - > + , + • > - , - • > - oraz — • > -F. Jeśli operator T jest łączny prawostronnie, to przyjmijmy T < • T. Te relacje dają pewność, X
x
2
2
x
X
2
2
2
2
[y
2
2
V
3.
że w E — E + E wybranym uchwytem będzie E — E, &w E t E T E wybrane zostanie drugie E T E. Przyjmijmy, że 0 < -id, id- > 0, 9 < • (, ( < • 0, ) • > 0, 9 • > ) , 9 • > $ oraz $ < • 9 dla wszystkich operatorów 0. Ponadto, niech ( = ) (<•( ( < id
$ <- ( id > $ id > )
$ < id )•>$ ) > )
Te reguły zapewniają zredukowanie id i (E) do E. Ponadto, $ będący znacznikiem zarówno lewego, jak i prawego krańca, powoduje — tak długo, jak jest to możliwe — wyszukiwanie kolejnych uchwytów między $. Przykład 4.28. Na rysunku 4.25 przedstawiono relacje priorytetów operatorów dla gra matyki (4.17), przyjmując że: 1) 2) 3)
operator T ma najwyższy priorytet i jest prawostonnie łączny, * oraz / mają drugi w kolejności priorytet i są lewostronnie łączne, -f oraz — mają najniższy priorytet i są lewostronnie łączne.
(Puste miejsca oznaczają elementy „błąd"). Czytelnik powinien sprawdzić informacje z tej tablicy, chwilowo ignorując problemy z jednoargumentowym minusem. D o b r y m testem może być wyrażenie id * (id T id) — id/id. •
+ -
*
/ r id (
) $
+
-
> > > > > >
•> > > > > >
<
<
>
> <
* < <
1 < <
> > > >
> > > >
<
> <
<
T < < < < <
id <• < <
(
)
$
-> > •>
> > >
<
< < <• <• <•
<
<
> <•
> <
>
•>
> >
•>
> >
= •>
>
<
Rys. 4.25. Relacje priorytetów operatorów Obsługa operatorów jednoargumentowych Jeśli mamy operator jednoargumentowy, taki jak -I (negacja logiczna), który nie jest jed nocześnie operatorem dwuargumentowym, możemy dołączyć go do powyższego sche matu tworzenia relacji priorytetów operatorów. Zakładając, że -I jest prefiksowym opera torem jednoargumentowym, przyjmujemy 9 < • -T dla wszystkich operatorów 0, zarówno jedno-, jak i dwuargumentowych. Ponadto, jeśli -< ma wyższy priorytet niż 0, to przyjmu jemy, że -I • > 0, a jeśli nie, to przyjmujemy -> < • 0. Na przykład, jeśli -> ma wyższy prio rytet niż &, a & jest lewostronnie łączny, według tych reguł odczytalibyśmy ESC~IESLE jako (E&(->E))8cE. Reguły dla postfiksowych operatorów jednoargumentowych są ana logiczne.
Sytuacja zmienia się, gdy mamy do czynienia z operatorem, takim jak znak mi nus, —, który jest zarówno prefiksowym operatorem jednoargumentowym, j a k i infiksowym operatorem dwuargumentowym. Nawet jeśli jedno- i dwuargumentowemu minusowi nadamy ten sam priorytet, relacje z rys. 4.24 nie pozwolą poprawnie przeanalizować wej ścia, takiego jak i d * — id. Najlepszym podejściem w takim przypadku jest zmiana anali zatora leksykalnego, tak aby rozróżniał minus jednoargumentowy od dwuargumentowego i zwracał dla nich różne symbole. Niestety, analizator leksykalny nie może podglądać symboli w celu ich odróżniania; musi on pamiętać poprzedni symbol. W Fortranie, na przykład, minus jest jednoargumentowy, jeśli poprzednim symbolem był operator, lewy nawias, przecinek albo symbol przypisania.
Funkcje priorytetów Kompilatory używające analizatorów działających metodą pierwszeństwa operatorów nie muszą przechowywać tablic opisujących relacje priorytetów. W większości przypadków tablicę tę można zakodować przy użyciu dwóch funkcji priorytetów, f i g, odwzoro wujących symbole terminalne w liczby całkowite. Próbujemy dobrać / i g tak, aby dla symboli a i b było: 1) 2) 3)
f{a) f(a) f(a)
g{b),
gdy ab.
Można więc odczytać relacje priorytetów a i b, porównując ze sobą f(a) i g{b). Musimy jednak zauważyć, że tracimy informacje o błędach zapisane w tablicy priorytetów, gdyż — niezależnie od wartości f{a) i g(b) — jeden z powyższych warunków zawsze będzie zachodzić. Zazwyczaj utrata możliwości wykrywania błędów nie jest na tyle ważna, żeby rezygnować z użycia funkcji priorytetów, jeśli jest to możliwe; błędy ciągle mogą być wykrywane, gdy chcemy wykonać redukcję, a nie możemy znaleźć uchwytu. Nie wszystkie tablice dla relacji priorytetów mają odpowiadające im funkcje prio rytetów, ale w praktyce zazwyczaj takie funkcje daje się znaleźć. Przykład 4.29.
Tablicy priorytetów z rys. 4.25 odpowiada następująca para funkcji:
/ 8
+
-
*
/
T
(
)
id
$
2 1
2 1
4 3
4 3
4 5
0 5
6 0
6 5
0 0
Mamy, na przykład, * < • id oraz / ( * ) < g(id). Zauważmy, że chociaż / ( i d ) > g(id) sugeruje, że id • > id, to id nie jest w żadnej z relacji z id. Inne elementy „błąd" z rys. 4.25 są analogicznie zastępowane przez którąś z relacji priorytetów. Prosta metoda wyznaczania funkcji priorytetów dla tablicy, jeśli takie funkcje ist nieją, jest następująca. Algorytm 4.6.
Wyznaczanie funkcji priorytetów.
Wejście. Tablica priorytetów operatorów.
Wyjście. Funkcje priorytetów reprezentujące wejściową tablicę albo informacja, że one nie istnieją. Metoda. 1. 2.
Dla każdego a, będącego terminalem lub symbolem $, stwórz symbole f i g Podziel uzyskane symbole na tak wiele grup, jak jest to możliwe, i w taki sposób, ° y fa i Ą były tej samej grupie, jeśli a± b. Zauważmy, że możemy umieścić symbole w tej samej grupie, nawet jeśli nie są one w relacji ==. Jeśli, na przykład, a = b oraz c == b, to f i f będą w tej samej grupie, co g . Jeśli, dodatkowo, c = d, fa 8d bC^ą J J g P i > rnimo że wcale nie musi zachodzić a^d. Stwórz graf skierowany, którego wierzchołkami są grupy znalezione w punkcie 2. Dla dowolnego a i /?, jeśli a <-b dodaj do grafu krawędź z grupy, w której jest g do grupy z f . Jeśli a-> b, dodaj krawędź od grupy z f do grupy z Widać, że krawędź bądź ścieżka z f do g oznacza, że f(a) musi być większe niż g(b)\ ścieżka od g do f oznacza, że g(b) musi być większe niż f(a). Jeśli w grafie z punktu 3. są cykle, to funkcje priorytetów nie istnieją. Jeśli cykli nie ma, to niech f(a) będzie długością najdłuższej ścieżki zaczynającej się w grupie z f \ niech g(a) będzie długością najdłuższej ścieżki o początku w grupie z g . • a
a
w
a
t 0
3.
a
1
w
te
s a m e
c
b
ru
e
t
hy
a
a
a
h
4.
fr
a
a
a
P r z y k ł a d 4.30. Rozważmy tablicę z rys. 4.23. Nie ma tam relacji ==, więc każdy symbol tworzy oddzielną grupę. Na rysunku 4.26 przedstawiono graf zbudowany przy użyciu algorytmu 4.6.
Rys. 4.26. Graf reprezentujący funkcje priorytetów
Nie ma w nim cykli, więc funkcje priorytetów istnieją. Ponieważ z Ą i g nie wychodzą żadne krawędzie, to / ( $ ) = = 0. Najdłuższa ścieżka z g ma długość 1, więc g(+) = 1. Istnieje ścieżka z g do / * do do / do g do / , więc g(id) = 5. Funkcje priorytetów odczytane z grafu to: $
+
[d
/ g
+
+
*
id
$
2 1
4 3
4 5
0 0
+
$
Obsługa błędów w analizatorach działających metodą pierwszeństwa operatorów Istnieją dwa miejsca, w których analizator działający metodą pierwszeństwa operatorów może wykryć błędy składniowe: 1.
Jeśli terminal na wierzchołku stosu i aktualny symbol wejściowy nie są ze sobą 1
w żadnej z relacji priorytetów . 2.
Jeśli znaleziono uchwyt, ale nie ma produkcji, dla której ten uchwyt byłby prawą stroną.
Przypomnijmy, że algorytm analizy metodą pierwszeństwa operatorów (algorytm 4.5) zdaje się redukować uchwyty złożone tylko z terminali. Jednakże, mimo że nietermi nale są traktowane anonimowo, to na stosie analizatora dla każdego z nich jest zajęte miejsce. Czyli, gdy w powyższym punkcie 2. mówimy o uchwycie pasującym do prawej strony produkcji, oznacza to, że terminale do siebie pasują i pozycje zajmowane przez nieterminale są takie same. Powinniśmy zauważyć, że nie ma innych miejsc, oprócz wymienionych powyżej 1. i 2., w których możemy wykrywać błędy. Gdy przeglądamy stos w dół, aby znaleźć lewy kraniec uchwytu w krokach (10) — (12) z rys. 4.24, tj. w algorytmie analizy metodą pierwszeństwa operatorów, musimy napotkać relację < •, gdyż $ oznaczający dno stosu jest w relacji < • z każdym symbolem, który może wystąpić na stosie bezpośrednio nad nim. Ponadto, nigdy nie dopuszczamy do tego, aby symbole, które nie są w relacji < • lub ± , sąsiadowały ze sobą na stosie (rys. 4.24). Wobec tego, kroki (10)-(12) muszą zakończyć się redukcją. Sam fakt, że znaleźliśmy na stosie ciąg symboli a< - b = b = • • * = b , nie oznacza, że b b ---b jest ciągiem terminali po prawej stronie jakiejś produkcji. Nie sprawdza liśmy tego warunku w algorytmie z rys. 4.24, ale możemy to robić, a nawet musimy, jeśli chcemy z redukcjami związać reguły semantyczne. Mamy więc możliwość wykry wania błędów w algorytmie z rys. 4.24 z krokami (10)—(12) zmodyfikowanymi tak, aby sprawdzać, która z produkcji jest uchwytem podczas redukcji. x
l
2
n
k
k
Obsługa błędów podczas
redukcji
Możemy podzielić procedurę wykrywania i obsługi błędów na kilka części. Pierwsza z nich obsługuje błędy typu 2. Procedura ta może, na przykład, zdejmować symbole ze stosu, tak jak kroki (10)—(12) z rys. 4.24. Ponieważ nie ma produkcji, zgodnie z którą można by wykonać redukcję, więc nie jest wykonywana żadna akcja semantyczna; za miast tego wypisywany jest komunikat diagnostyczny. Aby stwierdzić, jak powinien on brzmieć, procedura obsługująca przypadek 2. musi zdecydować, do której produkcji „jest podobna" prawa strona zdejmowana ze stosu. Załóżmy, na przykład, że zdejmujemy abc i nie ma prawej strony produkcji składającej się za,b,c oraz zera lub więcej nieterminali. Możemy wówczas sprawdzić, czy usunięcie któregoś z a, b lub c daje poprawną prawą stronę (z pominiętymi nieterminalami). Przykładowo, jeśli mamy prawą stronę aEcE, to możemy wypisać komunikat
1
Jeśli kompilator używa funkcji priorytetów do reprezentowania tablic priorytetów, to źródło wykrywania błędów może być niedostępne.
nieprawidłowy
s y m b o l b w w i e r s z u (wiersz zawierający b)
Możemy również rozważać zmianę bądź wstawienie terminala. Jeśli prawą stroną byłby napis abEdc, moglibyśmy wypisać p o m i n i ę t e d w w i e r s z u (wiersz zawierający c) Może się również okazać, że prawa strona jest właściwym ciągiem terminali, ale z inaczej położonymi nieterminalami. Jeśli, na przykład, abc jest zdejmowane ze stosu bez zawartych lub otaczających nieterminali i abc nie jest prawą stroną, ale aEbc jest, możemy wypisać p o m i n i ę t e E w w i e r s z u (wiersz zawierający b) gdzie E oznacza odpowiednią kategorię składniową reprezentowaną przez nieterminal E. Jeśli, na przykład, a, b i c to operatory, możemy powiedzieć „wyrażenie"; jeśli a jest słowem kluczowym, takim jak if, możemy powiedzieć „warunek". Trudność znalezienia właściwego komunikatu, gdy nie znaleziono odpowiedniej pra wej strony, zależy od tego, czy istnieje skończenie, czy nieskończenie wiele ciągów, które mogą być zdejmowane ze stosu w wierszach (10)—(12) z rys. 4.24. W każdym takim ciągu, bb sąsiednie symbole muszą być w relacji = , czyli musi być b = b = • • • = b . Jeśli z tablicy priorytetów operatorów wynika, że istnieje tylko skończona liczba ciągów terminali powiązanych ze sobą relacją = , to możemy rozpatrywać te ciągi indywidual nie. Dla każdego takiego ciągu x możemy wcześniej wyznaczyć najbliższą mu poprawną prawą stronę y i wypisywać komunikat mówiący, że znaleziono x, a oczekiwano y. x
2
x
2
k
Łatwo jest wyznaczyć wszystkie ciągi, które mogą być zdejmowane ze stosu w kro kach (10)—(12) z rys. 4.24. Są one widoczne w grafie skierowanym, którego wierzchołki reprezentują terminale, a krawędź od a do i? istnieje wtedy i tylko wtedy, gdy a = b. Wówczas możliwe ciągi składają się z etykiet wierzchołków leżących na ścieżkach w tym grafie. Ścieżki mogą składać się z jednego wierzchołka. Jednakże, aby ścieżkę b b ---b można było zdjąć przy jakimś wejściu, musi być symbol a (może nim być $) taki, że a<-b \ b nazwijmy początkowym. Ponadto, musi istnieć symbol c (znowu może nim być $) taki, że b > c; b nazwijmy końcowym. Tylko, gdy takie symbole istnieją, może wystąpić redukcja, w wyniku której ze stosu zdejmiemy b b ---b . Jeśli w grafie istnieje ścieżka od wierzchołka początkowego do końcowego zawierająca cykl, to istnieje nie skończenie wiele ciągów, które mogą zostać zdjęte ze stosu; w przeciwnym przypadku ciągów jest skończenie wiele. x
x
2
k
x
k
k
x
Przykład 4.31.
2
k
Przyjrzyjmy się ponownie gramatyce (4.17)
E ^> E + E \ E-E
\ E * E \ E/E
| E T E | (E) | - E | id
Tablicę priorytetów dla tej gramatyki przedstawiono na rys. 4.25, a graf dla niej na rys. 4.27. Jest w nim tylko jedna krawędź, bo jedyną parą związaną relacją = jest le wy i prawy nawias. Wszystkie wierzchołki, oprócz prawego nawiasu, są początkowe, i wszystkie, oprócz lewego, są końcowe. Wobec tego jedyne ścieżki od wierzchołka po czątkowego do końcowego to ścieżki + , — , * , / , id oraz T długości 1 oraz ścieżka od ( d o ) długości 2. Jest ich, oczywiście, skończenie wiele i każda z nich odpowiada terminalom
z prawej strony jakiejś produkcji z gramatyki. W związku z tym, procedura wyszukująca błędy musi tylko sprawdzić, czy między redukowanymi terminalami są w odpowiednich miejscach znaczniki nieterminali. Konkretnie, procedura robi to, co następuje: 1.
Jeśli redukowany jest -f, —, *, / lub T, sprawdza, że nieterminale są po obu stronach. Jeśli nie, to wypisuje komunikat
brakuje 2.
argumentów
Jeśli redukowany jest id, sprawdza, czy po obu stronach nie ma nieterminali. Jeśli jakiś jest, to ostrzega
brakuje 3.
oczekiwanych
oczekiwanego
operatora
Jeśli redukowane są ( ), sprawdza, czy między nawiasami jest nieterminal. Jeśli nie, to może informować
n i e ma w y r a ż e n i a m i ę d z y
nawiasami
Ponadto procedura musi sprawdzić, że nie ma nieterminala po lewej lub prawej stronie nawiasów. Jeśli jest, procedura wypisuje komunikat taki jak w 2. •
0
O
0
O
©
®
0 — 0 Rys. 4.27. Graf dla tablicy priorytetów z rys. 4.25 Jeśli istnieje nieskończenie wiele ciągów, które można zdejmować, komunikatów o błędzie nie można opracować dla każdego ciągu indywidualnie. Możemy używać ogól nej procedury, aby sprawdzić, czy jakaś prawa strona produkcji jest podobna (powiedzmy, w odległości 1 lub 2, gdzie odległość jest mierzona liczbą symboli, a nie znaków — wsta wianych, zmienianych bądź usuwanych) do zdejmowanego ciągu i, jeśli tak, to wypisywać konkretny komunikat przy założeniu, że właśnie tej produkcji oczekiwano. Jeśli żadna produkcja nie jest podobna do zdejmowanego ciągu, możemy wypisać ogólny komunikat, mówiący, że „coś jest źle w aktualnie rozpatrywanym wierszu". Obsługa błędów
przesunięcie/redukcja
Musimy teraz opisać drugą metodę, za pomocą której analizator działający metodą pierw szeństwa operatorów może wykrywać błędy. Gdy zaglądamy do tablicy priorytetów, aby zdecydować, czy wykonujemy przesunięcie czy redukcję (wiersze (6) i (9) na rys. 4.24), może się okazać, że symbole z wierzchołka stosu i pierwszy symbol z wejścia nie są ze sobą w żadnej relacji. Przypuśćmy, na przykład, że a i Z? to dwa symbole z wierzchołka stosu (b jest na wierzchu), c i d to dwa kolejne symbole wejściowe oraz, że b i c nie są w żadnej relacji priorytetów. Aby odzyskać kontrolę, musimy zmodyfikować stos, wej ście albo to i to. Możemy zmieniać symbole, wstawiać symbole na stos i do wejścia lub kasować symbole z wejścia i stosu. Jeśli wstawiamy lub zmieniamy, to musimy uważać,
żeby nie wpaść w pętlę nieskończoną, w której, na przykład, ciągle wstawialibyśmy sym bole na początek wejścia, nie mogąc zredukować ani przesunąć żadnego z wstawionych symboli. Jednym ze sposobów, który z pewnością nie pozwala tworzyć pętli nieskończonych, jest zapewnienie, że po odzyskaniu kontroli aktualny symbol z wejścia może być prze sunięty (jeśli aktualnym symbolem jest $, mamy pewność, że nie dodamy symboli do wejścia, a stos w końcu się zmniejszy). Przykładowo, gdy ab jest na stosie, a cd na wejściu, to jeśli a ^ - c . to możemy zdjąć b ze stosu. Inną możliwością jest usunięcie c z wejścia, jeśli b^d. Trzecia możliwość to znalezienie takiego symbolu e, że b ^ • e $C • c, i wstawienie e przed c na wejściu. Bardziej ogólnie, możemy wstawić taki ciąg symboli, że 1
b
e
^
'
\
e
^
"
2
e
^
'
' " '
^
"
n ^ '
c
jeśli nie możemy znaleźć pojedynczego symbolu, który moglibyśmy wstawić. Wybór konkretnej akcji powinien być zgodny z intuicją projektanta kompilatora dotyczącą tego, jaki błąd jest najbardziej prawdopodobny w każdym z przypadków. Dla każdego pustego miejsca w tablicy priorytetów musimy podać procedurę obsługi błędów; ta sama procedura może być używana w wielu miejscach. Wówczas — gdy analizator sprawdza wpis dla a i b w kroku (6) z rys. 4.24, a a i b nie są w żadnej z relacji priorytetów — znajduje on wskaźnik procedury obsługi dla tego błędu. P r z y k ł a d 4.32. Rozważmy ponownie tablicę priorytetów z rys. 4.25. Na rysunku 4.28 widzimy wiersze i kolumny tej tablicy, w których było co najmniej jedno puste miejsce i te puste miejsca wypełniliśmy nazwami procedur obsługi błędów.
id id
e3
( e3
(
<
<
)
e3
e3
<
<
$
)
>
$
> e4
>
>
e2
el
Rys. 4.28. Tablica priorytetów operatorów z informacjami o obsłudze błędów
Najważniejsze części tych procedur obsługi błędu to: e l : /* wywoływana, gdy nie ma całego wyrażenia */ wstaw id do wejścia wypisz komunikat: „ n i e ma o c z e k i w a n e g o a r g u m e n t u " e2: /* wywoływana, gdy wyrażenie zaczyna się od prawego nawiasu */ usuń ) z wejścia wypisz komunikat: „ n i e o c z e k i w a n y p r a w y n a w i a s " e3: /* wywoływana, gdy po id lub ) jest id lub ( */ wstaw + do wejścia wypisz komunikat: „ n i e ma o c z e k i w a n e g o 1
Przez „
=".
operatora"
e4: /* wywoływana, gdy wyrażenie kończy się lewym nawiasem */ zdejmij ( ze stosu wypisz komunikat: „nie ma oczekiwanego prawego nawiasu" Sprawdźmy, jak ten mechanizm obsługi błędów obsłuży błędne wejście id + ). Początkowe akcje wykonywane przez analizator to przesunięcie id, zredukowanie go do E (ponownie używamy E do oznaczenia anonimowych nieterminali na stosie) i, następnie, przesunięcie + . Otrzymujemy konfigurację STOS
WEJŚCIE
$£+
)$
Ponieważ + • > ), powinniśmy wykonać redukcję, a uchwytem jest + . Procedura wyszu kująca błędy dla redukcji musi sprawdzić, czy po lewej i prawej stronie plusa jest E. Ponieważ z jednej strony nie ma, wypisuje komunikat
nie ma oczekiwanego argumentu i, mimo to, wykonuje redukcję. Mamy teraz konfigurację STOS
$E
WEJŚCIE
)$
$ i ) nie są w żadnej z relacji, a wpisem z rys. 4.28 dla tej pary symboli jest e2. Procedura e2 powoduje wypisanie komunikatu
nieoczekiwany prawy nawias oraz usuwa prawy nawias z wejścia. Zostajemy z końcową konfiguracją analizatora STOS
$E
4.7
WEJŚCIE
$
Analizatory LR
W tym podrozdziale przedstawiliśmy wydajną metodę analizy wstępującej, którą można stosować do analizy szerokiej klasy gramatyk bezkontekstowych. Metoda ta jest nazywa na analizą LR(/:); „L" oznacza przeglądanie wejścia od lewej do prawej, „R" pochodzi od angielskiego słowa rightmost i oznacza budowę wyprowadzenia prawostronnego od końca, a k oznacza liczbę symboli podglądanych podczas podejmowania decyzji w trak cie analizy. Gdy pomijamy (k), przyjmujemy, że k jest równe 1. Analizatory LR są interesujące z wielu powodów: • •
Można zbudować analizatory L R do prawie wszystkich konstrukcji z języków pro gramowania, dla których można zapisać gramatykę bezkontekstową. Metoda LR jest obecnie najbardziej ogólną, nienawracającą metodą analizy redu kującej, ale analizatory działające tą metodą można zaimplemetować tak wydajnie, jak działające innymi metodami redukującymi.
• •
Klasa gramatyk — które można analizować, używając metody LR — jest właściwym nadzbiorem klasy gramatyk, które można analizować analizatorami przewidującymi. Analizator LR może wykrywać błędy tak wcześnie, jak jest to możliwe, podczas przeglądania wejścia od lewej do prawej strony.
Główną wadą tej metody jest to, że ręczne pisanie analizatora jej używającego do gramatyki dla przeciętnego języka programowania jest zbyt czasochłonne. Potrzebne jest specjalne narzędzie — generator analizatorów LR. Na szczęście, dostępnych jest wiele takich generatorów, a budowę i obsługę jednego z nich, o nazwie Yacc, omówiliśmy w p. 4.9. Korzystając z takiego generatora, można napisać gramatykę bezkontekstową i pozwolić generatorowi automatycznie stworzyć analizator dla tej gramatyki. Jeśli gra matyka jest niejednoznaczna albo są w niej konstrukcje, które trudno jest analizować podczas przeglądania wejścia od lewej do prawej, to generator analizatorów zauważy taką sytuację i poinformuje o niej projektanta kompilatora. Po opisaniu działania analizatora LR przedstawiamy trzy techniki konstruowania tablic LR dla gramatyk. Pierwsza metoda, nazywana prostym LR (lub, od angielskiego skrótu, SLR), jest najłatwiejsza w implementacji, ale najsłabsza z prezentowanych. Dla niektórych gramatyk, dla których pozostałe metody zadziałają, ta może nie dać rezultatu. Druga metoda, nazywana kanonicznym LR, jest najskuteczniejsza i najdroższa. Trzecia, nazywana podglądającym L R (lub, od angielskiego skrótu, LALR), jest średnia zarówno pod względem możliwości, jak i kosztów. Metoda LALR działa dla większości gramatyk języków programowania i, z pewnym wysiłkiem, może być wydajnie zaimplementowa na. W dalszej części rozdziału rozważamy pewne techniki zmniejszania rozmiaru tablic analizatorów LR.
A l g o r y t m analizy L R Schemat analizatora LR przedstawiony jest na rys. 4.29. Składa się on z wejścia, wyjścia, stosu, programu sterującego i tablicy analizatora, mającej dwie części (akcji i przejść). Program sterujący jest taki sam dla wszystkich analizatorów LR; tylko tablice ana lizatora są różne w różnych analizatorach. Program analizatora wczytuje pojedyncze symbole z bufora wejściowego. Używa on stosu do zapamiętywania ciągu o postaci
WEJŚCIE
n
$
Program analizatora LR
STOS
'm-l L
m-1
akcja
przejście
Rys. 4.29. Model analizatora LR
WYJŚCIE
• • -X s , z s na wierzchołku. Każde X jest symbolem z gramatyki, a s jest symbolem nazywanym stanem. Każdy symbol stanu podsumowuje informacje zawarte na stosie pod nim, a kombinacja symbolu stanu z wierzchołka stosu i aktualnego symbolu wejściowego jest używana do indeksowania tablicy analizatora oraz do podejmowania decyzji o przesunięciu lub redukcji. W działającym analizatorze symbole z gramaty ki nie muszą być przechowywane na stosie; my jednak, aby lepiej wyjaśnić działanie analizatorów LR, będziemy je tam zawsze umieszczać.
SQX S X S ]
X
2
m m
2
m
t
I
Tablica analizatora składa się z dwóch części: funkcji wyznaczającej akcje, akcja, i funkcji wyznaczającej przejścia, przejście. Program sterujący analizatora LR zachowuje się następująco: sprawdza s — stan leżący na wierzchołku stosu oraz a — aktualny symbol na wejściu. Następnie odczytuje wartość akcja[s ,a-\ w tablicy akcji analizatora dla stanu s i wejścia a , która może być jedną z poniższych: m
i
m
m
1) 2) 3) 4)
(
przesuń s, gdzie s jest stanem, redukuj zgodnie z produkcją A —> j3, akceptuj, błąd.
Funkcja przejście bierze j a k o argumenty stan i symbol z gramatyki, a zwraca stan. Przeko namy się, że funkcja przejście dla tablicy analizatora zbudowanej z gramatyki g przy uży ciu metody SLR, kanonicznej L R albo L A L R jest funkcją przejścia deterministycznego automatu skończonego, który rozpoznaje żywotne prefiksy G. Przypomnijmy, że żywot ne prefiksy G to te prefiksy prawostronnych form zdaniowych, które mogą wystąpić na stosie analizatora redukującego, gdyż nie wychodzą one poza skrajny prawy uchwyt. Początkowym stanem takiego DAS jest stan leżący początkowo na wierzchołku stosu analizatora LR. Konfiguracją
analizatora L R nazywamy parę, której pierwszym elementem jest za
wartość stosu, a drugim — niewykorzystane wejście
Ta konfiguracja przedstawia prawosUonną formę zdaniową W
XX X
2
• • X aa^ m
l
l
• • -a
n
właściwie tak samo, jak w analizatorze redukującym — tylko obecność stanów na stosie jest nowością. Kolejny ruch analizatora jest wyznaczany przez odczytanie a , aktualnego symbolu t
wejściowego, i s
m
— stanu na wierzchołku stosu, a następnie sprawdzenie wartości
w tablicy akcji analizatora akcja[s,
n:
a ]. Konfiguracje, które mogą wystąpić po każdym t
z czterech typów akcji, to: 1.
Jeśli akcja[s , m
— przesuń s, analizator wykonuje przesunięcie, przechodząc do
konfiguracji
Analizator wstawił na stos aktualny symbol wejściowy a i następny stan s, który jest pobierany z akcja[s , a-]; staje się aktualnym symbolem wejściowym. t
m
1
2.
Jeśli akcja[s , a ] = redukuj według A - » j 3 , to analizator wykonuje redukcję, prze chodząc do konfiguracji m
t
S
S
'''
S
( 0^\ \^2 2
m
- r
s
m - A
s
i
a
• • • a %)
a
i
n
i
gdzie s = przejście[s _ ,A] a r jest długością jS — prawej strony produkcji. Ana lizator zdejmuje ze stosu 2r symboli (r symboli stanu i r symboli gramatyki), od krywając stan s _ . Następnie wstawia na stos A — lewą stronę użytej produkcji i s — wartość przejśćie[s _ A]. Aktualny symbol wejściowy w wyniku redukcji nie jest zmieniany. Dla analizatorów LR, które będziemy budowali, ciąg symboli gramatyki zdjętych ze stosu, X _ zawsze będzie pasował do j8 — prawej strony produkcji używanej do redukcji. Wyjście analizatora LR jest generowane po wykonaniu redukcji przez wykonanie akcji semantycznej związanej z redukowaną produkcją. Chwilowo przyjmiemy, że wyjściem jest tylko wypisanie używanej produkcji. Jeśli akcja[s = akceptuj, analiza jest zakończona. Jeśli akcja[s , a j = błąd, analizator wykrył błąd i wywołuje procedurę obsługi błędu. m
m
r
t
r
m
n
m
3. 4.
X
r+l
m:
m
Algorytm analizy LR jest opisany poniżej. Wszystkie analizatory LR zachowują się w taki sposób; jedyną różnicą między różnymi analizatorami LR są informacje w częściach akcja i przejście tablic analizatorów. A l g o r y t m 4.7.
Algorytm analizy LR.
Wejście. Ciąg wejściowy w i tablica analizatora LR z funkcjami akcja i przejście gramatyki G.
dla
Wyjście. Jeśli w jest w L(G) — wstępujące wyprowadzenie dla w; w przeciwnym przy padku — informacja o błędzie. Metoda. Początkowo na stosie analizatora jest s , czyli stan początkowy, a na wejściu jest w$. Analizator wykonuje program z rys. 4.30, aż do napotkania akcji akceptuj lub akcji błąd. • Q
P r z y k ł a d 4.33. Na rysunku 4.31 przedstawiono funkcje akcji i przejść analizatora dla następującej gramatyki opisującej wyrażenia arytmetyczne z dwuargumentowymi opera torami, takimi jak + i * :
(1) (2) (3) (4) (5) (6)
E E T T F F
^ E + T ->• T -+T * F -> F (E) -> id
Akcje kodujemy następująco: 1) 2) 3) 4)
si oznacza przesunięcie i wstawiany na stos stan /, rj oznacza redukcję według produkcji o numerze j akc oznacza akceptuj, puste miejsce oznacza błąd.
t
zainicjuj ip tak, aby w s k a z y w a ł o p i e r w s z y s y m b o l w $ ;
repeat forever begin n i e c h s b ę d z i e s y m b o l e m z w i e r z c h o ł k a stosu i a s y m b o l e m w s k a z y w a n y m p r z e z ip;
if akcja[s, a] — p r z e s u ń s' then begin w s t a w A , a n a s t ę p n i e s' na w i e r z c h o ł e k stosu; przesuń ip d o n a s t ę p n e g o s y m b o l u w e j ś c i o w e g o
end else if akcja[s, a] = redukuj w e d ł u g A —> /3 then begin zdejmij z e stosu 2 * | / ? | symboli; n i e c h s' b ę d z i e s t a n e m , który z n a l a z ł s i ę na w i e r z c h o ł k u stosu; wstaw A i
przejście[s\ A] na w i e r z c h o ł e k stosu;
w y p i s z p r o d u k c j ę A —> p
end else if akcjals, a] ~ akceptuj then return else
błądC)
end Rys. 4.30. A l g o r y t m a n a l i z y LR
Zauważmy, że wartości przejście[s> a] dla terminala a szukamy w części tablicy nazwanej akcja, w polu związanym z akcją przesunięcia dla wejścia a w stanie s. Z części tablicy nazwanej przejście odczytujemy wartości przejście[s, A] dla nieterminali A. Pamiętajmy również, że nie wiemy jeszcze, jak dobraliśmy wartości w tablicy 4 . 3 1 ; tym problemem zajmiemy się wkrótce.
przejście
akcja STAN 0
id
+
i
s6 r2 r4
3
)
$
r2
r2
r4
r4
r4
ró
r6
s4 r6
E
T
F
1
2
3
8
2
3
akc s7
s5
5
( s4
s5
2 4
*
r6
6
s5
s4
7
s5
s4
9
3 10
sil
8
só
9
rl
s7
rl
rl
10
r3
r3
r3
r3
11
r5
r5
r5
r5
R y s . 4.31. Tablica analizatora dla gramatyki o p i s u j ą c e j w y r a ż e n i a
Dla wejścia id * id + id na rysunku 4.32 przedstawione są kolejne zawartości stosu i wejścia. Na przykład, w wierszu (1) analizator LR jest w stanie 0, z id jako pierw szym symbolem wejściowym. Akcją w wierszu 0 i kolumnie id w części akcja tablicy
z rys. 4.31 jest s5, oznaczająca przesunięcie i wstawienie na stos stanu 5. To widać w wierszu (2), gdzie symbol id i symbol stanu 5 zostały wstawione na stos, a id zostało usunięte z wejścia. Następnie * zostaje aktualnym symbolem wejściowym, a akcją w stanie 5 przy wejściu * jest redukcja według produkcji F -¥ id. Ze stosu zdejmowane są dwa symbole (jeden symbol stanu i jeden symbol z gramatyki). Odkrywany jest wówczas stan 0. Ponieważ w części przejście dla stanu 0 i symbolu F wartością jest 3, na stos wstawiamy F i 3. Dochodzimy do konfiguracji z wiersza (3). Wszystkie kolejne konfiguracje są wyznaczane analogicznie. •
STOS
WEJŚCIE
(i)
0
(2) (3)
Oid OF
(4)
0 7 2
(5)
T T 0 T OT 0 E 0 E 0E 0 E 0E 0 E
(6) (7) (8) (9) (10) (11) (12) (13) (34)
0
0
id * * * *
5 3
2 2 2 2
* 7 * 7 * 7
id F
5 10
1 1+6 1+6 1+6 1+6 1
id 5 F 3 T9
id + id$ id + id$ id + id$ id + id$ id + id$ + id$ + id$ + id$ + id$ id$ $ $ $ $
AKCJA przesunięcie redukcja z g o d n i e z F —• id redukcja z g o d n i e z T --)•
F
przesunięcie przesunięcie redukcja z g o d n i e z F -» id redukcja z g o d n i e z T —> T * F redukcja zgodnie z £
-•»
T
przesunięcie przesunięcie redukcja z g o d n i e z F -^id redukcja z g o d n i e z T -
E -¥ E + T akceptowanie
Rys. 4.32. R u c h y analizatora L R przy w e j ś c i u id * id + id
Gramatyki LR Jak budujemy tablicę analizatora LR dla podanej gramatyki? Gramatyka, dla której mo żemy zbudować tablicę, nazywana jest gramatyką LR. Istnieją gramatyki bezkontekstowe, które nie są LR, ale zazwyczaj można ich unikać w typowych konstrukcjach z języków programowania. Intuicyjnie, aby gramatyka była LR, wystarcza, aby analizator reduku jący działający od lewej do prawej mógł rozpoznawać uchwyty, gdy pojawiają się one na wierzchołku stosu. Analizator LR nie musi przeglądać całego stosu, aby dowiedzieć się, że na jego wierzchołku jest uchwyt. Wszystkie potrzebne informacje są zawarte w symbolu stanu na wierzchołku stosu. Istotnym faktem jest to, że jeśli można rozpoznać uchwyt, znając tylko symbole gramatyki na stosie, to istnieje automat skończony, który może — czy tając symbole gramatyki ze stosu od dołu do góry — wyznaczyć, który uchwyt jest na wierzchołku stosu lub stwierdzić, że takiego uchwytu nie ma. Funkcja przejścia z tablic analizatorów L R jest właściwie takim automatem skończonym. Automat ten nie musi jednak przeglądać stosu przy każdym ruchu. Symbol stanu leżący na wierzchołku sto su jest stanem, w którym automat skończony rozpoznający uchwyty znajdowałby się po przeczytaniu od dołu do góry symboli gramatyki leżących na stosie. Wobec tego, anali-
zator LR może, posługując się stanem leżącym na wierzchołku stosu, poznać wszystkie potrzebne informacje o zawartości stosu. Innym źródłem informacji, z którego analizator LR może korzystać przy po dejmowaniu decyzji o przesunięciu lub redukcji, jest k kolejnych symboli wejścio wych. W praktyce interesujące są przypadki k — 0 i k — 1, w związku z tym dalej zajmiemy się tylko analizatorami LR dla k ^ 1. Na przykład, tablica z rys. 4.31 korzys ta z jednego podglądanego symbolu z wejścia. Gramatyka, którą można analizować ana lizatorami LR podglądającymi co najwyżej k symboli wejściowych, nazywana jest gra matyką LR(k). Między gramatykami LL i LR jest zasadnicza różnica. Aby gramatyka była LR(k), musimy móc rozpoznać wystąpienie prawej strony produkcji po zobaczeniu wszystkie go, co z tej produkcji zostało wyprowadzone i po podejrzeniu k symboli z wejścia. Ograniczenie to jest dużo słabsze niż dla gramatyk LL(&), w których musimy umieć rozpoznać użycie produkcji zobaczywszy tylko k pierwszych symboli wyprowadzonych z jej prawej strony. Widać więc, że gramatyki LR mogą opisywać więcej języków, niż gramatyki LL. Tworzenie tablic analizatorów SLR Wyjaśnimy teraz, jak z gramatyki zbudować tablicę analizatora LR. Przedstawimy trzy metody, o różnej sile i łatwości implementacji. Pierwsza, nazywana „prostym LR" (uży wamy też angielskiego skrótu SLR), jest najsłabszą z tych metod, ze względu na liczbę gramatyk, dla których działa, ale jest najprostszą w implementacji. O tablicach budo wanych tą metodą mówimy, że są tablicami SLR, a o analizatorach używających tablic SLR, że są analizatorami SLR. Gramatyki, dla których można zbudować analizator SLR, nazywamy gramatykami SLR. Pozostałe dwie metody różnią się od metody SLR tym, że korzystają z informacji uzyskanych z podglądania symboli, tak więc metoda SLR jest dobrym punktem startowym do nauki analizy LR. Sytuacją LR(0) (w skrócie sytuacją) gramatyki G nazywamy produkcję G z kropką w jakimś miejscu jej prawej strony. Z produkcji A -> XYZ, na przykład, otrzymujemy cztery sytuacje A XYZ A -» XYZ A -> XYZ A -+ XYZZ produkcji A —>• e mamy tylko jedną sytuację, A -> Sytuację możemy reprezentować parą liczb, z których pierwsza reprezentuje numer produkcji, a druga pozycję kropki. In tuicyjnie, sytuacja wskazuje, jaki fragment produkcji już widzieliśmy w danym punkcie procesu wyprowadzania. Przykładowo, pierwsza z powyższych sytuacji oznacza, że spo dziewamy się na wejściu znaleźć ciąg wyprowadzalny z XYZ. Druga sytuacja oznacza, że odczytaliśmy już ciąg wyprowadzalny z X i teraz oczekujemy ciągu wyprowadzalnego z YZ. Ważne w metodzie SLR jest to, że rozpoczynamy od konstruowania z gramatyki deterministycznego automatu skończonego, rozpoznającego prefiksy żywotne. Sytuacje łączymy w zbiory, z których powstają stany analizatora SLR. Sytuacje można traktować
jak stany niedeterministycznego automatu skończonego rozpoznającego prefiksy żywotne, a „łączenie w zbiory" jest w istocie budową podzbiorów opisaną w p. 3.6. Pewna rodzina zbiorów sytuacji LR(0), którą będziemy nazywać kanoniczną rodziną LR(0) dla gramatyki, zapewnia podstawę do konstrukcji analizatorów SLR. Aby wyzna czyć kanoniczną rodzinę LR(0) dla gramatyki, definiujemy wzbogaconą gramatykę oraz dwie funkcje, domknięcie i przejście. Jeśli G jest gramatyką o symbolu startowym S, to G', wzbogaconą gramatykę G, definiujemy jako G z nowym symbolem startowym S' i produkcją S —> S. Tę nową produkcję startową wstawiamy po to, by analizator zauważył, kiedy należy skończyć analizę. Oznacza to, że wejście jest akceptowane wtedy i tylko wtedy, gdy analizator ma właśnie zredukować S' —>• S. f
Operacja
domknięcia
Jeśli / jest zbiorem sytuacji gramatyki G, to domknięcie(I) manych z / przy zastosowaniu dwóch reguł: 1. 2.
jest zbiorem sytuacji otrzy
Początkowo, każda sytuacja z / jest dodawana do domknięcie(I). Jeśli A a- £/3 jest w domknięcie(I), a f i - ł y jest produkcją, to — jeśli nie zrobiliśmy tego wcześniej — do / dodajemy sytuację B -y. Stosujemy tę regułę aż do domknięcie(I) nie będziemy mogli dodać żadnych nowych elementów.
Intuicyjnie, gdy A —>• a-Bp jest w domknięcie(I), to znaczy, że w pewnej chwili podczas prowadzenia analizy oczekujemy, iż na wejściu może być podciąg wyprowadzalny z Bp. Jeśli mamy produkcję B -> y, to oczekujemy również, że w tej samej chwili na wejściu może być podciąg wyprowadzalny z y. Z tego powodu do domknięcie(I) dołączamy B H> -y. P r z y k ł a d 4.34. £ ' -» E T -> F ->
Rozważmy wzbogaconą gramatykę dla wyrażeń:
£ E + T \T T * F |F (E)\ id
Jeśli / jest jednoelementowym zbiorem { [ £ ' ->• •£]}, to domknięcie(I) sytuacje: 1
E E E T T F F
( 4 - 1 9 )
zawiera następujące
-> -E -E + T -> T -+ -T * F -> -F •(£) -> id
Sytuacja £ ' - » - £ jest umieszczana w domknięcie(I) zgodnie z regułą 1. Ponieważ E jest bezpośrednio za kropką, więc zgodnie z regułą 2. dodajemy £-produkcje z kropkami po lewej stronie, czyli £ - > • • £ + T i £ -> T. Następnie, dlatego że T jest bezpośrednio
po kropce, dodajemy T —• T * F i T —>• F. Teraz F jest po prawej stronie kropki, co powoduje dodanie F —>• •(£) i F —> -id. Używając reguł 1. i 2. do domknięcie(I) nie można dodać żadnych innych sytuacji. • Funkcja domknięcie może być obliczana tak, jak pokazano na rys. 4.33. Wygodną metodą implementacji funkcji domknięcie jest utrzymywanie tablicy dodane zmiennych booleowskich, indeksowanej nieterminalami z G, takiej, że dodane[B] jest nadawana war tość t r u e wtedy, gdy dodajemy sytuacje B —> y dla każdej Z?-produkcji B —• y. function domknięcie{I)\ begin J := /; repeat for każdej sytuacji A —• a i?/3 z 7 oraz każdej produkcji B y z G takiej, ż& B -y nie jest w 7 do dodaj fi -y do 7; until do 7 nie można dodać nowych sytuacji; return J end Rys. 4.33. Obliczanie domknięcia
Warto zauważyć, że jeśli jedna B-produkcja jest dodawana do domknięcia I z krop ką na lewym krańcu, to analogicznie są dodawane wszystkie ^-produkcje. W pewnych okolicznościach nie trzeba nawet zapamiętywać wszystkich sytuacji o postaci B -> *y do danych do I przez domknięcie. Wystarczy wówczas pamiętać listę nieterminali B, których produkcje dodano w ten sposób. Okazuje się, że możemy podzielić każdy z interesujących nas zbiorów sytuacji na dwie klasy sytuacji: 1.
Jądro zbioru
2.
lewym krańcu oraz sytuacja startowa S' -» S. Sytuacje spoza jądra, czyli sytuacje, które mają kropkę na lewym krańcu.
sytuacji,
którym są wszystkie sytuacje, których kropka nie jest na
Co więcej, każdy zbiór sytuacji, który nas interesuje, jest tworzony przez domknięcie jądra zbioru sytuacji; sytuacje, które dodajemy podczas domykania nie mogą, oczywiś cie, należeć do jądra. Możemy więc pamiętać zbiory sytuacji, które nas interesują, ko rzystając z bardzo małej ilości pamięci, jeżeli zdecydujemy się na odrzucenie wszystkich sytuacji spoza jądra, wiedząc, że mogą one zostać odtworzone przy użyciu operacji domknięcia.
Operacja
przejścia
Drugą wygodną funkcją jest przejście(I, X), gdzie / jest zbiorem sytuacji, a X jest sym bolem z gramatyki. przejście(I, X) jest definiowane jako domknięcie zbioru wszystkich sytuacji [A ->• aX • fi] takich, że [A -> a -X/i] jest w /. Intuicyjnie, jeśli / jest zbiorem sytuacji, które są możliwe dla pewnego prefiksu żywotnego y, to przejście(I, X) jest zbiorem sytuacji, które są możliwe dla prefiksu żywotnego yX.
P r z y k ł a d 4.35. to przejście(/,+) E ->
Jeśli I jest zbiorem dwóch sytuacji, { [ £ ' - > £ • ] oraz [ £ - > £ • + 7 ] } , składa się z
E + T
F * F 7 -» F F -> - ( £ ) F -> id r
Obliczyliśmy przejście(I +), wyszukując w / sytuacje, które miały + bezpośrednio po prawej stronie kropki. E' -» E- nie jest taką sytuacją, ale E —> E • +T jest. Przesuwamy kropkę za -f, otrzymując {E —> E + -T}, i obliczamy domknięcie tego zbioru. i
Konstruowanie
zbiorów
sytuacji
Możemy teraz podać algorytm budowania C, kanonicznej rodziny zbiorów sytuacji LR(0) dla wzbogaconej gramatyki G'; algorytm ten jest przedstawiony na rys. 4.34. procedurę sytuacje(G')\ begin C := {domknięcie^? -> -5]})}; repeat for każdego zbioru sytuacji / z C i każdego symbolu X z gramatyki takiego, że przejście (I,X) nie jest puste i nie jest w C do dodaj przejście(I,X) do C until do C nie można dodać żadnego zbioru sytuacji end Rys. 4.34. Konstruowanie zbiorów sytuacji
P r z y k ł a d 4.36. Kanoniczna rodzina zbiorów sytuacji LR(0) dla gramatyki (4.19) z przy kładu 4.34 jest pokazana na rys. 4.35. Funkcja przejście dla tego zbioru sytuacji, przedsta wiona jako diagram przejść deterministycznego automatu skończonego D , jest pokazana na rys. 4.36. • Jeśli każdy ze stanów D z rys. 4.36 jest stanem końcowym, a 7 jest stanem star towym, to D rozpoznaje wszystkie i wyłącznie żywotne prefiksy dla gramatyki (4.19). Nie jest to przypadek. Dla każdej gramatyki G funkcja przejście dla kanonicznej ro dziny zbiorów sytuacji definiuje deterministyczny automat skończony, który rozpoznaje żywotne prefiksy dla G. W istocie, można sobie wyobrazić niedeterministyczny automat skończony N, którego stanami są pojedyncze sytuacje. Od stanów o postaci A —• a -Xf5 do A -> aX • jS istnieją przejścia etykietowane X, a od stanów o postaci A ^ a-BP do B —> y — przejścia etykietowane e. domknięcie(I) dla zbioru sytuacji / (stanów N) to dokład nie e-domknięcie zbioru stanów NAS zdefiniowane w p. 3.6. Wobec tego przejście{l,X) zwraca przejście z / po symbolu X w DAS otrzymanym z N poprzez konstrukcję pod zbiorów. Z takiego punktu widzenia procedura sytuacje(G') z rys. 4.34 jest konstrukcją podzbiorów zastosowaną do NAS N otrzymanego z G \ tak jak to opisaliśmy. 0
F -> id
4> = £' -+• £ £ -> £ ->
•£ +
£
V
•7
7
- > •7 *
7
->
£
7 7
£
:
i
- 7 *
-£
7
-(£)
£ -> •(£)
-7 £
- > •£
£ ->• id
£ -> id 7
- > £ +
£' £• £ -> £ +
7
7 *
• £
£ -> •(£)
7
£ -> id
I: 2
£ ->
7-
7
- > 7 -
7
- >
*
£ -» (£•)
£
£
- >£
+
7
£
- > £ +
7-
£• / : 9
£ -ł (•£) £ + £ £ -> • 7
7
7
£
•7
*
:
'io
7
7 - *
£
7
7 * £ •
£ -> (£)7 - > •£ hi£ -> • ( £ ) £ -> id (4.19) Rys. 4.35. Kanoniczna rodzina LR(0) dgramatyki la
Możliwe sytuacje. Mówimy, że sytuacja A —> /3j • /3 jest możliwa dla prefiksu żywotnego 2
aj3,, jeśli istnieje wyprowadzenie 5' =k aAw =5> a/3, S w. Zazwyczaj, sytuacja będzie 7
1
r/w
rm
możliwa dla wielu prefiksów żywotnych. Fakt, że sytuacja A —• fi • j3 jest możliwa dla ajSp mówi wiele o tym, czy powinniśmy wykonać przesunięcie, czy redukcję, gdy na stosie analizatora znajdziemy a / 3 W szczególności, jeśli /3 / e, to sugeruje nam, że jeszcze nie przesunęliśmy uchwytu na stos, więc wybieramy przesunięcie. Jeśli /3 = e, to wydaje się, że A —> j8 jest uchwytem i że możemy redukować przy użyciu tej produkcji. Oczywiście, dwie możliwe sytuacje mogą nam mówić o tym samym żywotnym prefiksie różne rzeczy. Niektóre z tych konfliktów mogą zostać rozwiązane poprzez podglądanie kolejnego symbolu na wejściu, inne można rozwiązać metodami opisanymi w następnym podrozdziale, ale nie możemy zakładać, że wszystkie konflikty w akcjach analizatora mogą zostać rozwiązane, jeśli używamy metody LR do konstruowania tablicy analizatora dla danej gramatyki. Możemy łatwo wyliczyć zbiór możliwych sytuacji dla każdego prefiksu żywotne go, który może wystąpić na stosie analizatora LR. Faktycznie, z głównego twierdzenia w teorii analizatorów LR wynika, że zbiór możliwych sytuacji dla prefiksu żywotnego y to właśnie zbiór sytuacji osiąganych ze stanu startowego wzdłuż ścieżki etykietowanej y w DAS zbudowanym z kanonicznej rodziny zbiorów sytuacji, z przejściami danymi przez przejście. W istocie, zbiór możliwych sytuacji zawiera wszystkie użyteczne informacje, które można uzyskać ze stosu. Nie udowodnimy tego twierdzenia, ale przedstawimy przykład jego działania. l
r
2
2
2
1
P r z y k ł a d 4.37. Ponownie przyjrzyjmy się gramatyce (4.19), której zbiory sytuacji i funkcja przejdź są pokazane na rys. 4.35 i 4.36. Jasne jest, że ciąg E + T * jest prefiksem żywotnym dla (4.19). Po przeczytaniu E -f T *, automat z rys. 4.36 będzie w stanie / . Stan ten zawiera sytuacje 7
7 -» 7 * F -> •(£) F id
F
które są wszystkimi możliwymi sytuacjami dla E + 7 * . Aby się o tym przekonać, przyj rzyjmy się następującym trzem wyprowadzeniom prawostronnym: f
E => E =>E + T E+ T * F
E'
E =>F + 7 E + T * F E + T * (E)
E' => E =>E + T =ź E + T * F =»£ + 7 * i d
Z pierwszego wyprowadzenia wynika, że dla prefiksu żywotnego E 4- T * jest możliwe 7 — ^ 7 * F , z d r u g i e g o — że F —• •(£), a z trzeciego — że możliwe jest F —> -id. Można wykazać, że nie ma innych sytuacji możliwych dla E + 7 *, a dowód pozostawiamy jako ćwiczenie dla zainteresowanego Czytelnika. • Tablice analizatorów
SLR
Poniżej opisaliśmy, jak z deterministycznego automatu skończonego rozpoznającego pre fiksy żywotne skonstruować części akcja i przejście tablicy analizatora SLR. Nasz algo-
rytm nie będzie generował jednoznacznie zdefiniowanych tablic analizatorów dla wszyst kich gramatyk, ale będzie działał dla wielu gramatyk języków programowania. Mamy gramatykę G, którą wzbogacamy, otrzymując G', a z G' konstruujemy C, kanoniczną rodzinę zbiorów sytuacji dla G'. Z C, korzystając z przedstawionego poniżej algorytmu, budujemy akcja funkcję akcji analizatora i przejście — funkcję przejść. Aby algorytm za działał, potrzebujemy znać FOLLOW(A) dla każdego nieterminala A z gramatyki (patrz p. 4.4). A l g o r y t m 4.8,
Konstruowanie tablicy analizatora SLR.
Wejście. Wzbogacona gramatyka G'. Wyjście. Części akcja i przejście
tablicy analizatora SLR dla gramatyki G'.
Metoda. 1. 2.
Zbuduj C — {/ , / j , . . . j / / i } , rodzinę zbiorów sytuacji LR(0) dla G'. Stan i budujemy z I . Akcje analizatora dla stanu / wyznaczamy następująco: 0
t
a)
jeśli [A -» a-afi] jest w I- oraz przejście{l a) =Ij, to elementowi akcja[i, a] nadajemy wartość „przesuń a musi być terminalem, b) jeśli [A —>• a-] jest w /•, to elementowi akcja[i, a] nadajemy wartość „redukuj według A - » a " dla wszystkich a z FOLLOW(A); A nie może być równe S\ c) jeśli [5" —> S-] jest w /-, to elementowi akcja[i, $] nadajemy wartość „akceptuj". 0
Jeżeli wskutek stosowania powyższych reguł tworzymy sprzeczne akcje, mówimy, że gramatyka nie jest S L R ( l ) . W takiej sytuacji algorytm nie generuje analizatora. 3.
Wartości przejście
dla stanu / są tworzone dla wszystkich nieterminali A zgodnie
4.
Wszystkie pozycje tablicy, którym w krokach 2. i 3. nie nadaliśmy wartości, ozna
z regułą: jeśli przejście(l
0
A) = / ., to przejście[i,
A] = j .
czamy jako błędne. 5.
Stan startowy analizatora to stan zbudowany ze zbioru sytuacji, do którego należała sytuacja [S' -> -S). •
Tablica analizatora, składająca się z wyznaczonych za pomocą algorytmu 4.8 funkcji akcji analizatora i przejść, jest nazywana tablicą SLR(I) dla G. Analizator LR używający tablicy S L R ( l ) dla G jest nazywany analizatorem S L R ( l ) dla G, a gramatyka, dla której istnieje tablica analizatora S L R ( l ) , jest nazywana gramatyką S L R ( l ) . Zazwyczaj opusz czamy (1) po SLR, gdyż nie będziemy się zajmować analizatorami, które podglądają więcej niż jeden symbol. P r z y k ł a d 4.38. Zbudujmy tablicę SLR dla gramatyki (4.19). Kanoniczną rodzinę zbio rów sytuacji LR(0) dla (4.19) pokazaliśmy na rys. 4.35. Najpierw rozważmy zbiór sytu acji / 0
£' E E T T
-> -> -> -> -¥
•£ -E + T T T * F F
F -+ •(£) F -> id Sytuacja F — r •(£") powoduje powstanie wpisu a&c/a[0, (] = przesuń 4, sytuacja F —> id — wpisu afcc/a[0, id] — przesuń 5. Pozostałe sytuacje z 7 nie generują żadnych akcji. Rozpatrzmy teraz I 0
x
f
E EE -> E- +T Pierwsza sytuacja daje akcja[\, stępnie oglądamy I
$] = akceptuj, druga daje akcja[\,
+ ] — przesuń 6. Na
2
E -» T¬ T -r 7 - * F Ponieważ F O L L O W ( F ) = {$, + , ) } , pierwsza sytuacja daje akcja[2, $] = = akcja[2, )] = redukuj według E -> 7\ Druga sytuacja daje akcja[2, *] Kontynuując takie działania otrzymamy tablicę akcji analizatora i tablicę pokazaliśmy na rys. 4.31, na którym numery produkcji w redukcjach są kolejność w pierwotnej gramatyce (4.18), czyli E -¥ E + T ma numer numer 2 itd.
akcja[2, +] = = przesuń 7. przejść, które takie, jak ich 1, E -> T — •
P r z y k ł a d 4.39. Każda gramatyka S L R ( l ) jest jednoznaczna, ale istnieje wiele gramatyk jednoznacznych, które nie są S L R ( l ) . Rozważmy gramatykę z produkcjami S -> S -> L -> L-¥ R ->
L = R R * R id L
(4.20)
O L i / ? możemy myśleć, że odpowiadają, odpowiednio, /-wartościom i r-wartościom, a * jest operatorem oznaczającym „zawartość" . Kanoniczna rodzina zbiorów sytuacji LR(0) dla gramatyki (4.20) jest pokazana na rys. 4.37. Przyjrzyjmy się zbiorowi sytuacji 7 . Pierwsza sytuacja z tego zbioru powoduje, że akcja[2, = ] jest równe „przesuń 6". Ponieważ FOLLOW(/?) zawiera = (aby sprawdzić dlaczego, można rozważyć S L = R *R = R), druga sytuacja nadaje akcja[2, =] wartość „redukuj według R -» L". Widać, że pozycja akcja[2, —] jest wielokrotnie de finiowana. Ponieważ w akcja[2, = ] jest jednocześnie przesunięcie i redukcja, stan 2 ma konflikt przesunięcie/redukcja dla symbolu wejściowego = . Gramatyka (4.20) jest jednoznaczna. Powstaje konflikt przesunięcie/redukcja, gdyż metoda SLR nie jest wystarczająco silna, aby pamiętać lewy kontekst w celu podejmo wania decyzji, jaką akcję powinien wykonać dla wejścia = . analizator, który odczytał ciąg redukowalny do L. Metoda kanoniczna i metoda LALR, opisane dalej, zadziałają dla większej rodziny gramatyk, włączając gramatykę (4.20). Powinniśmy jednak zaznaczyć, że istnieją jednoznaczne gramatyki, dla których wszystkie metody budowy analizatorów 1
2
1
Tak jak w podrozdziale 2.8, /-wartość wyznacza lokację, a /--wartość jest wartością, którą można zapamiętać w lokacji.
S L= R s -* R L --> • * R L -* id R -•» L
A
:
s'
-+
s
-*
L -> id
V
L -->
S' -•> SS -* L=R R -•> I-
S^L=R R -» L L L -* id
V
*
fl-
/? -•> L-
S -* L = RS -•> R 7 : 4
L R L L
—> * •/? ^ L -t • * R -> id
Rys. 4.37. Kanoniczna rodzina LR(0) dla gramatyki (4.20)
LR stworzą tablicę analizatora z konfliktami akcji. Szczęśliwie, zastosowanie związane z językami programowania takich gramatyk zazwyczaj nie jest konieczne. • Tworzenie kanonicznych tablic analizatorów LR Przedstawiamy teraz najbardziej ogólną metodę tworzenia tablic analizatorów LR dla gramatyk. Przypomnijmy, że w metodzie SLR w stanie i wykonujemy redukcję zgodnie z A - y a , jeśli zbiór sytuacji l zawiera sytuację [A —» a-] oraz a jest w FOLLOW(A). Jednakże, w niektórych sytuacjach, gdy stan i znajduje się na wierzchołku stosu, żywotny prefiks na stosie jest taki, że w prawostronnej formie zdaniowej po fi A nie może występować a. Wówczas redukcja według A -> a nie jest poprawna dla wejścia a. t
Przykład 4.40. Rozważmy ponownie przykład 4.39, w którym w stanie 2 mamy sy tuację R —> L-, odpowiadającą powyższemu A a , natomiast a może być znakiem = , który jest w FOLLOW(7?). Wtedy analizator SLR będzie chciał wykonać redukcję we dług R -> L w stanie 2, z = jako następnym symbolem wejściowym (będzie również chciał wykonać przesunięcie, gdyż w stanie 2 jest sytuacja S —ł L- = R). W gramatyce z przykładu 4.39 nie ma jednak prawostronnej formy zdaniowej, która zaczynałaby się od / ? = • • • . W związku z tym, w stanie 2, który jest stanem odpowiadającym wyłącznie żywotnemu prefiksowi L, analizator nie powinien chcieć redukować L do R. • Możliwe jest przechowywanie w stanie większej ilości informacji, co pozwoli wy kluczyć niektóre z nieprawidłowych redukcji zgodnych z A —> (x. Poprzez podział stanów, gdy jest to konieczne, możemy spowodować, że każdy stan analizatora LR będzie wska zywał, które z symboli mogą występować po uchwycie a , dla którego jest możliwa redukcja do A. Te dodatkowe informacje wprowadzamy do stanów, redefiniując sytuacje tak, aby zawierały symbol terminalny jako drugą składową. Ogólną postacią sytuacji jest teraz
[A -» a • /3, a], gdzie A -> a/3 jest produkcją, natomiast a jest terminalem bądź wskaźni kiem prawego krańca, $. Taki obiekt nazywamy sytuacją LR(I). 1 odnosi się do długości drugiej składowej, nazywanej podglądem sytuacji . W sytuacjach o postaci [A -» a • j3, a], gdzie j8 jest różne od e, podglądane symbole nie mają znaczenia, ale w sytuacjach o po staci [A - » a-, a] redukcja jest proponowana tylko wtedy, gdy kolejnym symbolem wej ściowym jest a. Musimy więc wykonać redukcję według A ->• a tylko dla tych symboli wejściowych a, dla których [A a-, A] jest sytuacją LR(1) w stanie leżącym na wierz chołku stosu. Zbiór takich a zawsze będzie podzbiorem FOLLOW(A), ale — j a k wynika z przykładu 4.40 — może on być podzbiorem właściwym. Formalnie, mówimy, że sytuacja LR(1) [A a / 3 , a] jest możliwa dla prefiksu żywotnego y, jeśli istnieje wyprowadzenie S =3- ó*Aw => Saftw, gdzie 1
rm
1) 2)
rm
y=5a, a jest pierwszym symbolem w albo w jest równe e, natomiast a jest równe $.
P r z y k ł a d 4.41.
Rozpatrzmy gramatykę
S ^ BB B -+ aB\b Istnieje prawostronne wyprowadzenie S 4> aaBab =J> aaaBab. Zgodnie z powyższą defirm rm nicją, sytuacja [B - » a B, a] jest możliwa dla prefiksu żywotnego y = a a a , z S = aa, A = B, w — ab, a = a oraz /3 = B. Istnieje również prawostronne wyprowadzenie S =>• itai? => BaaB. Z tego wyprowarm rm dzenia wynika, że sytuacja [B - » a $] jest możliwa dla prefiksu żywotnego Baa. • J
1
Metoda budowy rodziny zbiorów możliwych sytuacji LR(1) jest właściwie taka sama, jak metoda, za pomocą której budowaliśmy kanoniczną rodzinę zbiorów sytuacji LR(0). Musimy tylko zmodyfikować procedury domknięcie i przejście. Chcąc ocenić nową definicję operacji domknięcie, rozpatrzmy sytuację o postaci [A —> a B/5, a] ze zbioru sytuacji możliwych dla pewnego prefiksu żywotnego y. Istnie je wówczas prawostronne wyprowadzenie S 8Aax =$> 8ocBBax, dla którego y = Set. rm rm Przypuśćmy, że z /3<xc można wyprowadzić ciąg by. Wówczas dla każdej produkcji o po staci B —> r? dla pewnego rj mamy wyprowadzenie S =>• yBby rm
Jt]by. Stąd [B —> -rj, 6]
rm
jest możliwa dla y. Zauważmy, że Z? może być pierwszym terminalem wyprowadzonym z /3, albo w wyprowadzeniu jiax 4> &y z j3 jest wyprowadzany e, i wówczas b może być równe a. Podsumowując obie możliwości, mówimy, że b może być dowolnym termina lem z FIRST(/3ax), gdzie FIRST jest funkcją z p. 4.4. Widać, że x nie może zawierać pierwszego terminala z by, czyli FIRST(/3ax) = FIRST(/3a). Opiszemy teraz, jak tworzyć zbiory sytuacji LR(1). Algorytm 4.9.
Konstruowanie zbiorów sytuacji LR(1).
Wejście. Wzbogacona gramatyka G'. 1
Oczywiście, możliwe jest podglądanie większej liczby symboli, ale my nie będziemy tego rozważać.
Wyjście. Zbiory sytuacji LR(1), które są zbiorami sytuacji możliwych dla jednego lub więcej prefiksów żywotnych G . f
Metoda. Procedury domknięcie i przejście oraz główną procedurę sytuacje, budowy zbiorów sytuacji, przedstawiono na rys. 4.38.
służące do •
function domknięcie(I)\ begin repeat for każdej sytuacji [A —> a Z?j8, a] z I, każdej produkcji B —v y z G' i każdego terminala b z FIRST(/3«) takiego, że [B —> y, b] nie jest w / do dodaj [B —> -y, b] do /; until do / nie można dodać nowych sytuacji; return / end; function przejście(I, X)\ begin niech J będzie zbiorem sytuacji [A —> aX /3, a] takich, że [A -» a -Xj3, a] jest w /; return domknięcie(J) end; l
procedurę sytuacje{G )\ begin C := { ^ ^ / V c ^ ( { f 5 -5, $]})}; repeat for każdego zbioru sytuacji / z C i każdego symbolu X z gramatyki takiego, że przejście(l ,X) jest niepuste i nie ma go w C do dodaj przejście(l,X) do C until do C nie będzie można dodać nowych zbiorów end /
Rys. 4.38. Konstruowanie zbiorów sytuacji LR(1) dla gramatyki G'
P r z y k ł a d 4.42.
Rozważmy następującą wzbogaconą gramatykę:
S' ~ł S S - r CC C
(4.21) cC\d f
Zaczynamy od obliczenia domknięcia {[S -5, $]}. Aby to zrobić, w procedurze do mknięcie dopasowujemy sytuację [S -> S, $] do sytuacji [A -» a - 5 / 3 , a]. Otrzymujemy A = S', a = e, B = S, fi = e oraz a = $. Funkcja domknięcie mówi nam, że należy dodać [B -» -y, Z?] dla każdej produkcji £ —» y oraz terminala 6 z FIRST(j3a). Patrząc na roz patrywaną gramatykę, B —> y, to S —> CC, a ponieważ /3 jest równe e , a a równe $, więc b może być tylko równe $. W związku z tym dodajemy [S -> -CC, $]. f
Kontynuujemy obliczanie domknięcia, dodając wszystkie sytuacje [C - » -y, b] dla b z FIRST(C$). Dopasowując [S -> CC, $] do [A a-BP, a] otrzymujemy A = 5, a = e, B = C, P — C oraz a = $. Ponieważ z C nie da się wyprowadzić tekstu pustego, więc FIRST(C$) = FIRST(C). Ponieważ FIRST(C) zawiera terminale c i d, dodajemy sytuacje [C —> -c C, c], [C —» -c C, 6?], [C —> -d, c] i [C —> -d, d]. W żadnej z tych nowych sytuacji nie ma nieterminala bezpośrednio na prawo od kropki, obliczyliśmy więc nasz pierwszy zbiór sytuacji LR(1). Startowym zbiorem sytuacji jest t
7 : 0
S* -> -5, $ - 4 CC, $ C —> -c C, c/d C —> •o', C/Ć/ s
Dla wygody ominęliśmy nawiasy kwadratowe i użyliśmy zapisu [C —> -c C, c/d] jako skrótowego zapisu dwóch sytuacji [C -> -c C, c] i [C —> *c C, c?]. Obliczamy teraz przejście(I ,X) dla różnych wartości X. Dla X = S musimy do mknąć sytuację [S' —> S-, $]. Nie można już nic dodać, bo kropka jest na prawym krańcu produkcji. Mamy więc kolejny zbiór sytuacji Q
I\ x
S'
$
Dla X = C domykamy [S —> C • C, $]. Dodajemy C-produkcje z $ na drugiej pozycji i nie możemy już dodać nic więcej. Mamy: I : 2
S -» C - C , $ C - f -c C, $ c
$ 7
Niech teraz X = c. Musimy domknąć {[C~> c - C , c/o ]}. Dodajemy C-produkcje z c/d na drugiej pozycji i otrzymujemy 7 : 3
C —• c - C , c/d C ~¥ c C, c / d C —>• -J, c / J
W końcu, dla X — d otrzymujemy następujący zbiór sytuacji: 7 : 4
C-*d-,
c/d
Zakończyliśmy wyznaczanie przejścia dla I . Z I nie dostajemy nowych zbiorów, ale 7 ma przejścia dla C, c i d. Z C mamy zbiór Q
x
2
7 : 5
S-+CC-, $
który nie potrzebuje domknięcia. Dla c bierzemy domknięcie {[C —)• c • C, $]}, otrzymując 7 : 6
C -> c • C, $ C -> -c C, $ C • d, $
Można zauważyć, że 7 różni się od 7 tylko elementami na drugiej pozycji w parach. Przekonamy się, że często się zdarza, iż zbiory sytuacji LR(1) dla gramatyki mają takie same elementy na pierwszych pozycjach, a różne tylko na drugich. Gdy budujemy rodzinę 6
3
zbiorów sytuacji LR(0) dla tej samej gramatyki, każdy ze zbiorów sytuacji LR(0) będzie odpowiadał zbiorowi elementów z pierwszej pozycji jednego lub więcej zbiorów sytuacji LR(1). Tę własność opiszemy dokładniej, omawiając analizę LALR. Kontynuując obliczanie funkcji przejście dla 7 , przejście(I , d) jest równe 7
/ :
C->
7
$
Przechodząc teraz do / , przejścia 3
a przejście{I
v
7 : 8
7 dla c i d to — odpowiednio — 7 i 7 , 3
5
6
I : g
4
c/J
7 i 7 nie tworzą nowych przejść, przejścia 4
3
C) to
C->c
a przejście(I ,
2
l dla c i J to — odpowiednio — 7 i 7 , 6
6
?
C) to
C -> c C •, $
Pozostałe zbiory sytuacji nie generują nowych przejść. Na rysunku 4.39 widzimy dziesięć zbiorów sytuacji razem z ich funkcjami przejście.
•
Przedstawiamy teraz reguły, przy zastosowaniu których budujemy funkcje przejść i akcji analizatora ze zbiorów sytuacji LR(1). Funkcje akcji i przejść przedstawiamy, tak jak poprzednio, w postaci tablic. Jedyną różnicą są wartości w tych tablicach.
A l g o r y t m 4.10.
Konstruowanie kanonicznych tablic analizatorów LR.
Wejście. Wzbogacona gramatyka G'. Wyjście. Funkcje akcja i przejście
z kanonicznej tablicy analizatora L R dla G'.
Metoda. r
1.
Zbuduj C = {/ , 7 j , . . . , 7„}, rodzinę zbiorów sytuacji LR(1) dla G .
2.
Stan i analizatora jest tworzony z l
0
r
Akcje analizatora dla stanu i są wyznaczane
jak następuje: a) jeśli [A —>• a - a / 3 , b\ jest w /• oraz przejście(I
ti
a) = 7 - , to niech akcja[i,
a]
będzie równa „przesuń / ' ; a musi być terminalem, b) jeśli [A -» a-, a] jest w 7- i A ^ 5', to nadaj akcja[i, a] wartość „redukuj według A -> a", c) jeśli [S' —>
$] jest w 7-, to nadaj akcja[i, $] wartość „akceptuj". (
Jeśli stosowanie powyższych reguł powoduje powstawanie konfliktów, mówimy, że gra matyka nie jest LR(1) i że algorytm nie zadziałał. 3.
Wartości przejście to przejście[i,
4.
dla stanu i są wyznaczane następująco: jeśli przejście(l^
A) = Ij,
A] = j .
Wszystkie pozycje tablicy, którym w krokach 2. i 3. nie nadaliśmy wartości, ozna czamy jako błędne.
5.
Stan startowy analizatora to stan zbudowany ze zbioru sytuacji, do którego należała ;
sytuacja [5 -> -S, $].
•
Rys. 4.39. Graf funkcji przejście dla gramatyki (4.21)
Tablica powstała z funkcji akcji analizatora i funkcji przejść utworzonych za pomo cą algorytmu 4.10 jest nazywana tablicą kanoniczną analizatora LR(1). Analizator LR używający tej tablicy jest nazywany analizatorem kanonicznym L R ( ł ) . Jeśli funkcja akcji analizatora nie ma wielokrotnie zdefiniowanych pozycji, to dana gramatyka jest nazywana gramatyką LR(1). Tak jak poprzednio, jeśli (1) wynika z kontekstu, jest ono pomijane.
P r z y k ł a d 4.43. Tablica kanoniczna analizatora dla gramatyki (4.21) jest przedstawiona na rys. 4.40. Produkcje 1, 2 i 3 to S -> CC, C -» c C oraz C->d. •
przejście
akcja STAN 0
c
d
s3
s4
C
1
2
2
s6
s7
5
3
s3
s4
8
4
r3
r3
só
s7
rl
5 6 8
9 r3
7 r2
9
Każda kanoniczny Gramatyka czyli mniej
S
akc
i
R y s . 4.40.
$
r2 r2
K a n o n i c z n a tablica analizatora dla gramatyki ( 4 . 2 1 )
gramatyka SLR( 1) jest gramatyką LR( 1), ale dla gramatyki SLR( 1) analizator LR może mieć więcej stanów niż analizator SLR dla tej samej gramatyki. z poprzednich przykładów jest SLR i ma analizator SLR o siedmiu stanach, niż dziesięciu w analizatorze z rys. 4.40.
Tworzenie tablic analizatorów LALR Wprowadzamy teraz ostatnią ze wspomnianych na początku p. 4.7 metod konstrukcji analizatorów, technikę L A L R (podglądające-LR, ang. lookahead'-LR). Metoda ta jest często stosowana, bo tablice uzyskiwane przy jej zastosowaniu są znacznie mniejsze niż tablice LR(1), a najczęściej spotykane konstrukcje składniowe z języków programowania mogą być wygodnie wyrażone przy użyciu gramatyki LALR. To stwierdzenie jest prawie prawdziwe również dla gramatyk SLR, ale istnieje pewna liczba konstrukcji, których nie można wygodnie obsłużyć metodą SLR (m.in. te z przykładu 4.39). Porównując rozmiary analizatorów, tablice SLR i LALR dla gramatyki zawsze ma ją taką samą liczbę stanów; dla języka podobnego do Pascala jest to kilkaset stanów. Kanoniczna tablica L R dla języka tej samej wielkości miałaby przeciętnie kilka tysięcy stanów. Dużo łatwiej i bardziej ekonomicznie jest więc budować tablice SLR i L A L R zamiast kanonicznych tablic LR. Jako wprowadzenie rozpatrzmy raz jeszcze gramatykę (4.21), której zbiór sytuacji LR(1) pokazaliśmy na rys. 4.39. Rozpatrzmy parę podobnie wyglądających stanów, np. 7 i 7 . Każdy z nich ma tylko sytuacje z pierwszą składową C -> d-. W 7 podglądanym symbolem jest c lub d, w 7 może to być tylko $. Aby dostrzec różnicę ról, jakie odgrywają 7 i 7 w analizatorze, zauważmy, że gramatyka (4.21) generuje zbiór regularny c * dc * d. Podczas czytania z wejścia cc-• -cdcc-• -cd analizator przesuwa na stos pierwszą grupę c oraz następujące po nich J , i po odczytaniu d wchodzi do stanu 4. Następnie jest wykonywana redukcja według C -> <7, pod warunkiem, że następnym symbolem jest c lub d. Wymaganie, aby c lub d było kolejnym symbolem, jest rozsądne, gdyż są to jedyne symbole, które mogą rozpo czynać napis c * d. Jeśli $ występuje po pierwszym d, mamy wejście typu ccd, które nie należy do języka i stan 4 poprawnie zgłasza błąd, jeśli $ jest kolejnym symbolem wejściowym. 4
7
4
7
4
7
Analizator wchodzi do stanu 7 po przeczytaniu drugiego d. Wówczas musi on na wejściu widzieć $ albo napis, który początkowo był na wejściu, i nie miał posta ci c * dc * d. Sensownie jest więc, aby w stanie 7 redukować C -> d przy wejściu $ i zgłaszać błąd przy wejściu c lub d. Zastąpmy teraz 7 i 7 przez 7 , sumę 7 i 7 , złożoną ze zbioru trzech sytuacji reprezentowanych przez [C - ł d-, c/d/%]. przejścia dla d do 7 lub 7 z 7 , 7 , 7 i 7 wchodzą teraz do 7 . Akcją w stanie 7 dla dowolnego symbolu na wejściu jest redukcja. Zmieniony analizator zachowuje się właściwie tak, jak oryginalny, chociaż może on zredukować d do C w okolicznościach, w których pierwotny analizator zgłosiłby błąd, na przykład dla wejść rodzaju ccd lub cdcdc. Błąd ten zostanie później znaleziony; co więcej, zostanie on znaleziony zanim przesuniemy jakikolwiek inny symbol. Uogólniając, możemy zajmować się zbiorami sytuacji LR(1) mającymi to samo jądro, tj. zbiór elementów z pierwszych pozycji, i możemy te zbiory łączyć w jeden zbiór sytuacji. Przykładowo, na rysunku 4.39, 7 i 7 tworzą taką parę zbiorów, z ją drem {C -» d-}. Podobnie, 7 i 7 tworzą kolejną parę, o jądrze {C -> c - C , C - » -c C, C ->• -a }. Jest jeszcze jedna para, 7 z 7 , mająca jądro {C -> c C-}. Podkreślmy, że — w ogólności — jądro jest zbiorem sytuacji LR(0) dla rozpatrywanej gramatyki i że w gramatyce LR(1) mogą być więcej niż dwa zbiory sytuacji o tym samym jądrze. 4
7
47
4
7
4
47
7
0
2
3
6
47
4
3
7
6
1
8
9
Ponieważ jądro przejścieil, X) zależy tylko od jądra 7, zbiory przejście łączonych zbiorów mogą również być połączone. Funkcje akcja są modyfikowane tak, aby oddawały różne od błędu akcje wszystkich łączonych zbiorów sytuacji. Przypuśćmy, że mamy gramatykę LR(1), czyli taką, której zbiory sytuacji LR(1) nie mają konfliktów akcji analizatora. Jeśli zastąpimy wszystkie stany mające to samo jądro ich sumą, możliwe jest, że suma ta będzie miała konflikt. Jest to jednak mało prawdo podobne z następującego powodu: załóżmy, że w sumie jest konflikt przy podglądanym symbolu a, bo istnieje sytuacja [A -» a-, a], w której powinniśmy wykonać redukcję według A ~> a , oraz sytuacja [B -> /3 -ay, b], w której powinniśmy wykonać przesu nięcie. Wówczas jeden ze zbiorów sytuacji, z których powstała suma, zawiera sytuację [A —> a •, a], a ponieważ jądra wszystkich tych stanów są takie same, musi również zawie rać sytuację [B - ł j3 • ay, c] dla pewnego c. Ale wtedy stan ten miałby taki sam konflikt przesunięcie/redukcja dla a i gramatyka nie byłaby LR(1), co zakładaliśmy. Widać więc, że łączenie stanów o takich samych jądrach nie może spowodować powstania konfliktu przesunięcie/redukcja, którego nie było w jednym z pierwotnych stanów, bo przesunięcia zależą tylko od jądra, a nie od podglądanego symbolu. Łączenie może jednak tworzyć konflikt redukcja/redukcja, co pokazujemy w następ nym przykładzie. P r z y k ł a d 4.44.
Rozpatrzmy gramatykę
S* -> S S -> aAd | bBd | aBe \ bAe A -» c B -+ c która generuje cztery napisy: acd, ace, bcd i bce. Tworząc zbiory sytuacji, Czytelnik mo że sprawdzić, że gramatyka ta jest LR(1). Gdy to zrobimy, otrzymamy zbiór sytuacji {[A —r c-, d], [B c-, e}} możliwych dla prefiksu żywotnego ac oraz {[A -¥ c-, e),
[B —¥ c-, d]} możliwych dla bc. Żaden z tych zbiorów nie wywołuje konfliktów, a ich jądra są takie same. Jednakże ich suma A - » c-, d/e B -» c-, d / e powoduje konflikt redukcja/redukcja, ponieważ redukcje zgodnie zA—> c i B —> c są jednocześnie wywoływane dla wejść d i e. • Jesteśmy teraz gotowi do przedstawienia pierwszego z dwóch algorytmów budowy tablic LALR. Przede wszystkim tworzymy zbiory sytuacji LR(1) i, jeśli nie wprowadzi to konfliktów, łączymy zbiory o równych jądrach. Następnie budujemy tablicę analizatora z rodziny połączonych zbiorów sytuacji. Metoda, którą opiszemy, służy głównie do defi niowania gramatyk L A L R ( ł ) . Tworzenie całej rodziny zbiorów sytuacji LR(1) wymaga zbyt dużo pamięci i czasu, aby było użyteczne. Algorytm 4.11.
Prosta, ale „pamięciożerna" metoda budowania tablic LALR.
Wejście. Wzbogacona gramatyka G'. Wyjście. Funkcje akcja i przejście
z tablicy analizatora LALR dla G'.
Metoda. 1. 2. 3.
Zbuduj C = {/ / j , . . . , I }, rodzinę zbiorów sytuacji LR(1) dla G'. Dla każdego jądra zbiorów sytuacji LR(1) znajdź wszystkie zbiory o tym jądrze i zastąp te zbiory ich sumą. Niech C = { 7 , 7 . . . , J } będzie rodziną powstałych zbiorów sytuacji LR(1). Akcje analizatora dla stanu i są tworzone z J w ten sam sposób, co w algorytmie 4.10. Jeśli powstaje konflikt akcji analizatora, to algorytm nie tworzy analizatora, a o gramatyce mówimy, że nie jest L A L R ( l ) . Tablica przejście jest konstruowana następująco: jeśli J jest jednym lub sumą kilku zbiorów sytuacji LR(1), tj. J = I U 7 U • • • U7^, to jądra przejście(I X ) , przejście (7 , X ) , przejście(I , X) są takie same, gdyż I 7 , . . . , 7^ mają takie same jądra. Niech K będzie sumą wszystkich zbiorów sytuacji o takim samym jądrze, co przejście(J , X ) . Wtedy przejście(J, X) — K. • 0)
0
n
l 3
m
i
4.
x
2
2
Xl
k
v
2
x
Tablicę tworzoną przez algorytm 4.13 nazywamy tablicą analizatora LALR dla G. Jeśli nie ma konfliktów, to rozpatrywana gramatyka jest określana jako gramatyka LALR( 1). Rodzinę zbiorów sytuacji powstałą w kroku 3. nazywamy rodziną LALR(l). P r z y k ł a d 4.45. Rozpatrzmy ponownie gramatykę (4.21), dla której graf funkcji przej ście przedstawiliśmy na rys. 4.39. Tak jak wspominaliśmy, istnieją trzy pary zbiorów sytuacji, które możemy połączyć. 7 oraz 7 zastępujemy ich sumą 3
7
36
:
C -> c - C , c/d/% C -> -c C, c/d/% C -> -d c/d/% y
7 i 7 zastępujemy ich sumą 4
7
7
47
:
C->d-,
c/d/%
6
[B —¥ c-, d]} możliwych dla bc. Żaden z tych zbiorów nie wywołuje konfliktów, a ich jądra są takie same. Jednakże ich suma A - » c-, d/e B -» c-, d / e powoduje konflikt redukcja/redukcja, ponieważ redukcje zgodnie zA—> c i B —> c są jednocześnie wywoływane dla wejść d i e. • Jesteśmy teraz gotowi do przedstawienia pierwszego z dwóch algorytmów budowy tablic LALR. Przede wszystkim tworzymy zbiory sytuacji LR(1) i, jeśli nie wprowadzi to konfliktów, łączymy zbiory o równych jądrach. Następnie budujemy tablicę analizatora z rodziny połączonych zbiorów sytuacji. Metoda, którą opiszemy, służy głównie do defi niowania gramatyk L A L R ( ł ) . Tworzenie całej rodziny zbiorów sytuacji LR(1) wymaga zbyt dużo pamięci i czasu, aby było użyteczne. Algorytm 4.11.
Prosta, ale „pamięciożerna" metoda budowania tablic LALR.
Wejście. Wzbogacona gramatyka G'. Wyjście. Funkcje akcja i przejście
z tablicy analizatora LALR dla G'.
Metoda. 1. 2. 3.
Zbuduj C = {/ / j , . . . , I }, rodzinę zbiorów sytuacji LR(1) dla G'. Dla każdego jądra zbiorów sytuacji LR(1) znajdź wszystkie zbiory o tym jądrze i zastąp te zbiory ich sumą. Niech C = { 7 , J ,...> J } będzie rodziną powstałych zbiorów sytuacji LR(1). Akcje analizatora dla stanu i są tworzone z J w ten sam sposób, co w algorytmie 4.10. Jeśli powstaje konflikt akcji analizatora, to algorytm nie tworzy analizatora, a o gramatyce mówimy, że nie jest L A L R ( l ) . Tablica przejście jest konstruowana następująco: jeśli J jest jednym lub sumą kilku zbiorów sytuacji LR(1), tj. J = I U 7 U • • • U7^, to jądra przejście(I X ) , przejście (7 , X ) , przejście(I , X) są takie same, gdyż I 7 , . . . , 7^ mają takie same jądra. Niech K będzie sumą wszystkich zbiorów sytuacji o takim samym jądrze, co przejście(J , X ) . Wtedy przejście(J, X) — K. • 0)
0
n
x
m
i
4.
x
2
2
Xl
k
v
2
x
Tablicę tworzoną przez algorytm 4.13 nazywamy tablicą analizatora LALR dla G. Jeśli nie ma konfliktów, to rozpatrywana gramatyka jest określana jako gramatyka LALR( 1). Rodzinę zbiorów sytuacji powstałą w kroku 3. nazywamy rodziną LALR(l). P r z y k ł a d 4.45. Rozpatrzmy ponownie gramatykę (4.21), dla której graf funkcji przej ście przedstawiliśmy na rys. 4.39. Tak jak wspominaliśmy, istnieją trzy pary zbiorów sytuacji, które możemy połączyć. 7 oraz 7 zastępujemy ich sumą 3
7
36
:
C -> c - C , c/d/% C -» -c C, c/d/% C -> -d c/d/% y
7 i 7 zastępujemy ich sumą 4
7
7
47
:
C->d-,
c/d/%
6
a 7 i 7 zastępujemy ich sumą 8
9
7
:
89
C —> c
c/d/%
Funkcje a£c/a i przejście pokazano na rys. 4 . 4 1 .
z tablicy L A L R dla skomprymowanych zbiorów sytuacji
akcja STAN 0
c
d
s36
s47
l
przejście $
S
C
1
2
akc
2
s36
s47
5
36
s36
s47
89
47
r3
r3
r2
r2
5
r3 rl
89
r2
Rys. 4.41. Tablica analizatora L A L R dla gramatyki ( 4 . 2 1 )
Rozpatrzmy przejście{I^, C), aby zrozumieć sposób wyznaczania przejście. W pier wotnym zbiorze sytuacji LR(1), przejście^, C) = 7 , a 7 jest teraz częścią 7 , czyli przejście(7 , C) = 7 . Do tego samego wniosku doszlibyśmy, rozpatrując 7 , drugą część 7 . Tam przejście(I , C) = 7 , a 7 jest teraz częścią 7 . Jako kolejny przykład weźmy przejście(I i c), element, który jest wykonywany po przesunięciu w 7 przy wejściu c. W pierwotnych zbiorach sytuacji LR(1) przejście(I , c) = 7 . Ponieważ teraz 7 jest czę ścią 7 , przejście(I c) jest równe 7 . Dlatego element na rys. 4.41 dla stanu 2 i wejścia c jest równy s36, co oznacza przesuń i wstaw stan 36 na stos. • 8
36
8
36
6
6
9
9
89
2
2
2
36
89
89
2:
6
6
36
W chwili, gdy na wejściu jest napis z języka c * dc * d, analizator LR z rys. 4.40 i analizator L A L R z rys. 4.41 wykonają dokładnie taki sam ciąg przesunięć i redukcji, chociaż nazwy stanów na stosie mogą być różne; tzn. jeśli analizator L R wkłada na stos 7 albo 7 , analizator LALR położy na stosie stan 7 . Związek ten jest zawsze prawdziwy dla gramatyk LALR. Analizatory LR i LALR będą wykonywały taką samą pracę, gdy wejście będzie poprawne. 3
6
36
Jeżeli jednak wejście nie będzie poprawne, analizator LALR może wykonywać pew ne redukcje po zgłoszeniu błędu przez analizator LR, ale wtedy analizator L A L R nigdy nie przesunie kolejnego symbolu. Przykładowo, dla wejścia ccd, po którym jest $, ana lizator LR z rys. 4.34 odłoży na stosie 0 c3 c3
dA
i w stanie 4 odkryje błąd, bo $ jest następnym symbolem na wejściu, a w stanie 4 na pozycji $ jest błąd. Analizator LALR z rys. 4.41 wykona analogiczne ruchy, odkładając na stos 0 c 36 c 36 d 47 Ale stan 47 na pozycji $ ma akcję redukuj według C—td. Analizator L A L R zmieni więc swój stos na 0 c 36 c 36 C 89
Teraz akcją w stanie 89 przy wejściu $ jest redukcja według C —> c C. Nowy stos to 0 c 36 C 89 i wywoływana jest podobna redukcja, co skutkuje stosem 0 C 2 Ostatecznie, w stanie 2 na pozycji $ jest błąd, który jest wówczas wykrywany.
•
Wydajne tworzenie tablic analizatorów LALR Istnieje kilka modyfikacji, które możemy wprowadzić do algorytmu 4 . 1 1 , chcąc uniknąć budowania całej rodziny zbiorów sytuacji LR(1) w trakcie tworzenia tablicy analizatora L A L R ( l ) . Po pierwsze, możemy reprezentować zbiór sytuacji / , używając jego jądra, czyli tych sytuacji, które albo są startową sytuacją [S -» $], albo mają kropkę w miejscu innym niż początek prawej strony. Po drugie, możemy wyznaczyć akcje analizatora generowane przez 7, używając tylko jądra. Dowolna sytuacja wywołująca redukcję według A-^r a będzie w jądrze, chyba że a = e. Redukcja zgodnie z A —• e jest wywoływana przy wejściu a wtedy i tylko wtedy, gdy w jądrze jest sytuacja [B —>• y • C<5, b] taka, że C Ą> Arj dla pewnego rj oraz że a jest f
rm
w FIRST(rj56). Zbiór nieterminali A, takich że C 4> Ar/, może być wcześniej obliczony rm
dla każdego nieterminala C. Akcje przesunięcia generowane przez / mogą być wyznaczone z jądra 7 w nastę pujący sposób: przy wejściu a wykonujemy przesunięcie, jeśli w jądrze jest sytuacja [B —> Y • C 5 , b], gdzie C ax jest wyprowadzeniem, w którym ostatni krok nie używa rm e-produkcji. Zbiór takich a również może być wcześniej obliczony dla każdego C. Oto jak obliczyć funkcję przejść dla /, korzystając tylko z jądra /: jeśli [B -> y - X 5 , b] jest w jądrze / , to [B -» yX • 5 , b) jest w jądrze przejścieil, X). Sy tuacja [A —> X • j3, a] również jest w jądrze przejście(I, X ) , jeśli w jądrze I jest sytuacja [B -> y-C5, b] i C 4- Arj dla jakiegoś Ti. Jeśli dla każdej pary nieterminali C i A obrm
liczymy wcześniej, czy C
Arj dla pewnego Tj, to obliczanie zbiorów sytuacji z jądra rm
jest tylko trochę mniej efektywne niż z domkniętych zbiorów sytuacji. Aby wyliczyć zbiory sytuacji L A L R ( l ) dla gramatyki wzbogaconej G', zaczynamy od jądra startowego zbioru sytuacji 7 , S' —> -S. Następnie obliczamy jądra funkcji przejść z 7 , tak jak opisaliśmy powyżej. Kontynuujemy obliczanie funkcji przejść dla każdego nowo otrzymanego jądra, aż do czasu, gdy będziemy mieli jądra całej rodziny zbiorów sytuacji LR(0). 0
0
Przykład 4.46. f
S
Rozpatrzmy raz jeszcze gramatykę wzbogaconą
S
S L —R | R L -> *R | id R L Jądra zbiorów sytuacji LR(0) dla tej gramatyki przedstawiamy na rys. 4.42.
•
h
:
S'
- > 5-
S R
- > L-
L-
V
5
=7?
V
-+
id L=-R
L ~+
* /?•
R -•>
L-
/ :
S -> 7?L
4.42.
Jądra z b i o r ó w sytuacji L R ( 0 ) dla g r a m a t y k i ( 4 . 2 0 )
4
Rys.
L -*
Sf -> S
V
7 : 9
S -*
L^R-
Następnie rozszerzamy jądra przez dołączenie do każdej sytuacji LR(0) odpo wiednich symboli podglądanych (drugich pozycji). Aby zobaczyć, jak symbole podglądane przechodzą ze zbioru sytuacji 7 do przejście(I, X), rozpatrzmy B -> y • CS — sytuację LR(0) z jądra I. Załóżmy, że C =>- Ar/ dla jakiegoś r/ (być może C~A oraz r/ = e) rm
i że A —>• Xj3 jest produkcją. Wówczas sytuacja LR(0) A -> X • )3 jest w przejście(I, X). Przypuśćmy teraz, że nie obliczamy sytuacji LR(0), ale sytuacje LR(1), i że [B ->y-CS, b] jest w zbiorze 7. W takim przypadku, dla jakich wartości a, [A ->• X • j3, a] będzie w przejście(I, X ) ? Z pewnością, jeśli jakieś a jest w FIRST(rj<5), to z wyprowa dzenia C =$> A T J wynika, że [A —>• X • 6 , a] musi być w przejścieil, X ) . Wtedy wartość b rm
jest bez znaczenia i mówimy, że a, jako podglądany symbol dla A —> X • /3, jest gene rowany odruchowo. Zgodnie z definicją, $ jest generowany odruchowo jako podglądany symbol dla sytuacji S' —> -S w startowym zbiorze sytuacji. Jest jeszcze inne źródło symboli podglądanych dla sytuacji A -> X • f3. Jeśli TjS e, to [A —)-Xj3j b] również będzie w przejście(I, X ) . Wtedy mówimy, że symbole podglądane przechodzą z B —>• y-C5 do
Wyznaczanie symboli podglądanych.
Wejście. Jądro K zbioru I sytuacji LR(0) i symbol X z gramatyki. Wyjście. Symbole podglądane generowane odruchowo przez sytuacje z / dla sytuacji z jądra przejście(I, X) oraz sytuacje z 7, z których symbole podglądane przechodzą do sytuacji z jądra przejście(I, X). Metoda. Algorytm jest podany na rys. 4.43. Używa on fikcyjnego symbolu podglądanego # do wykrywania sytuacji, w których symbole podglądane przechodzą. • Zastanówmy się teraz, jak szukać symboli podglądanych związanych z sytuacjami w jądrach zbiorów sytuacji LR(0). Po pierwsze, wiemy, że $ jest symbolem podglądanym dla S' —» S w startowym zbiorze sytuacji LR(0). Algorytm 4.12 podaje nam wszystkie odruchowo generowane symbole podglądane. Po wyliczeniu wszystkich tych symboli musimy pozwolić im przechodzić aż do czasu, gdy dalsze przechodzenie nie będzie
możliwe. Istnieje wiele różnych sposobów i wszystkie, w pewnym sensie, pamiętają „no we" symbole podglądane, które wcześniej „przeszły" do sytuacji, ale jeszcze z niej „nie wyszły". Następny algorytm opisuje jedną z technik propagacji symboli podglądanych do wszystkich sytuacji. for każdej sytuacji B —> y- 3 z K do begin f := domknięcie ({[B -» y- 8, #]}); if [A -» a-XP, a] jest w J\ podczas gdy a nie jest równe # then podglądany symbol a jest generowany odruchowo dla sytuacji A -+ aX • P w przejście{I, X); if [A -> cxXP, #] jest w / then podglądane symbole propagują z [B —> y- 8 w / do A orAT • /? w przejście(I, X) end Rys. 4.43. Odkrywanie propagowanych i odruchowych podglądanych symboli A l g o r y t m 4.13.
Wydajne obliczanie jąder rodzin zbiorów sytuacji L A L R ( l ) . f
Wejście. Gramatyka wzbogacona G . Wyjście. Jądra rodziny zbiorów sytuacji L A L R ( l ) dla G'. Metoda. 1. 2.
Używając opisanej powyżej metody, zbuduj jądra dla zbiorów sytuacji LR(0) dla G. Zastosuj algorytm 4.12 do jądra każdego zbioru sytuacji LR(0) i symbolu X z gra matyki, aby wyznaczyć, które symbole podglądane są odruchowo generowane dla sytuacji z jądra przejście(I, X) i z których sytuacji w I symbole podglądane prze chodzą do sytuacji z jądra przejście (I X). Zainicjuj tablicę, która dla każdej sytuacji z jąder wszystkich zbiorów sytuacji podaje związane z nią symbole podglądane. Początkowo, każda sytuacja ma związane ze sobą wyłącznie te symbole podglądane, o których w kroku 2. stwierdziliśmy, że są generowane odruchowo. Powtarzaj przejścia po sytuacjach z jąder wszystkich zbiorów. Gdy odwiedzamy sytuację /, używając informacji zebranych w kroku 2., sprawdzamy, do których sytuacji z jąder i propaguje swoje symbole podglądane. Aktualny zbiór symboli podglądanych dla / jest dodawany do zbiorów związanych z każdą z sytuacji, do których i powoduje przejście swoich symboli podglądanych. Powtarzamy przejścia po sytuacjach z jąder aż do chwili, w której nowe symbole podglądane nie mogą już przechodzić. • }
3.
4.
P r z y k ł a d 4.47. Zbudujmy jądra sytuacji L A L R ( l ) dla gramatyki z poprzedniego przy kładu. Jądra sytuacji LR(0) przedstawiono na rys. 4.42. Gdy zastosujemy algorytm 4.12 do jądra zbioru sytuacji, / , obliczymy domkniecie{{[S' - » -5, #]}), które jest równe n
S -> -5, #
S -4 = 5 ->•/?, #
#
L ->•*/?,#/ = L - 4 -id, # / = /? - 4 -L, # Dwie sytuacje z tego domknięcia powodują odruchowe generowanie symboli podgląda nych. Sytuacja [£,—••*/?, —] powoduje odruchową generację symbolu podglądanego = dla sytuacji L—>*-Rz jądra 7 , a sytuacja [L - 4 -id, = ] powoduje odruchową generację = w sytuacji L —• id z jądra 7 . 4
5
Drogi przechodzenia symboli podglądanych wśród sytuacji z jąder wyznaczone przez krok 2. algorytmu 4.13 są podsumowane na rys. 4.44. Przykładowo, przejścia z 7 dla symboli S, L, R, * oraz id to, odpowiednio, 7 7 , 7 , 7 oraz 7 . Dla 7 obliczyliśmy tylko domknięcie pojedynczej sytuacji z jądra, [S* - 4 -5, #]. Wobec tego S* - 4 -5* powoduje przejście swoich symboli podglądanych do każdej sytuacji z jąder l do 7 . 0
p
2
3
4
5
0
x
Z V
/ : 4
DO l : S' -4 SS -4 L- — R
5* -4 •S
7
h h h h
7? LS -4 RL - j - * -7? L -4 id
5 -> L-^R
h
5 -> L=R
* R
h h h h
L -4 * -7? I -4 id L -» * R> 7? -4L-
S ^ L—-R
h h h h
L -4 * -7? L -4 * -7? 7? -4L5-4l = 7?-
L
5
Rys. 4.44. Propagacja podglądanych symboli Na rysunku 4.45 pokazaliśmy kroki 3. i 4. algorytmu 4.13. Kolumna oznaczona POCZĄTKOWO przedstawia odruchowo wygenerowane symbole podglądane dla każdej sytuacji z jąder. Przy pierwszym przejściu symbol podglądany $ przechodzi z 5' - 4 S w 7 do sześciu sytuacji wymienionych na rys. 4.44. Symbol podglądany — przechodzi z L 4 * • 7? w 7 do sytuacji L - 4 * 7?- w 7 i 7? - 4 L- w 7 . Przechodzi również do siebie i do L - 4 id- w 7 , ale te symbole podglądane są już obecne. W drugim i trzecim przebiegu jedynym nowym propagowanym symbolem podglądanym jest $, odkryty dla następników 7 i 7 w przebiegu 2 i następnika 7 w przebiegu 3. Żadne nowe symbole nie przechodzą w przebiegu 4, więc ostateczny zbiór symboli podglądanych jest przedstawiony w skrajnie prawej kolumnie na rys. 4.45. Q
4
7
8
5
2
4
6
Okazuje się, że konflikt przesunięcie/redukcja znaleziony w przykładzie 4.39, gdy używaliśmy metody SLR, zniknął, gdy używamy metody LALR. Stało się tak dlatego, że jedyny symbol podglądany $ jest związany z 7? —> L- z 7 , nie ma więc konfliktu 2
z akcją analizatora, którą jest przesunięcie przy wejściu - , generowane przez sytuację
P O D G L Ą D A N E SYMBOLE ZBIÓR
SYTUACJA POCZĄTKOWO
PRZEBIEG I
PRZEBIEG 2
PRZEBIEG 3
$
$
$
$
5' ->S-
$
$
$
hI:
S -> L=R /? -> L-
$ $
$ $
$ $
h-
5 -» R
$
$
$
S' -> •S
V
2
I:
L ->
* R
= /$
= /$
= /$
h-
L ->•
id
= /$
= /$
= /$
V
S -+ L— R
$
$
= /$
= /$
= /$
= /$
Ą
L ->•
V-
* R-
=
-> L-
$
5 -> L = RRys. 4.45. Obliczanie podglądanych symboli Zmniejszanie tablic analizatorów L R
Gramatyka typowego języka programowania, o 50 do 100 terminalach i 100 produkcjach, może mieć tablicę analizatora L A L R o kilkuset stanach. Funkcja akcji bez trudu może mieć 2 0 0 0 0 pozycji, a każda z nich wymaga co najmniej 8 bitów pamięci. Wynika z tego, że kodowanie bardziej wydajne niż tablica dwuwymiarowa jest ważne. Krótko opiszemy kilka technik, które były stosowane do kompresowania pól akcji i przejść z tablicy analizatora LR. W pożytecznej technice zmniejszania tablicy akcji wykorzystuje się fakt, że zazwy czaj wiele wierszy w takiej tablicy jest identycznych. Przykładowo, na rysunku 4.40 stany 0 i 3 mają identyczne elementy w tablicy akcji, tak jak 2 i 6. Możemy więc zaoszczędzić sporo pamięci, przy niewielkim nakładzie czasu, jeśli zrobimy wskaźnik dla każdego stanu prowadzący do tablicy jednowymiarowej. Wskaźniki dla stanów z takimi samymi akcjami wskazują tę samą pozycję. Aby wydobyć informacje z tej tablicy, przypisujemy każdemu terminalowi liczbę od zera do o jeden mniejszą niż liczba terminali i używamy tej liczby jako przesunięcia od wartości wskaźnika dla każdego ze stanów. W danym stanie akcja analizatora dla /-tego terminala będzie znaleziona i pozycji za miejscem wskazywanym przez wskaźnik dla tego stanu. Dalszą oszczędność miejsca można osiągnąć kosztem otrzymania nieco wolniejsze go analizatora (co, zazwyczaj, uznawane jest za rozsądne, działanie analizatora typu LR zajmuje bowiem tylko małą część całkowitego czasu kompilacji) poprzez tworzenie listy akcji dla każdego stanu. Lista składa się z par (symbol terminalny, akcja). Najczęściej
używana w danym stanie akcja może zostać umieszczona na końcu listy, a w miejsce terminala można w parach wstawić symbol „dowolny", oznaczający, że jeśli aktualny symbol wejściowy nie został do tego momentu znaleziony na liście, powinniśmy wy konać tę akcję niezależnie od symbolu wejściowego. Co więcej, elementy błąd można bezpiecznie zastąpić redukcjami, co może ujednolicić niektóre wiersze. Błędy zostaną wykryte później, przed wykonaniem przesunięcia. P r z y k ł a d 4.48. Przyjrzyjmy się tablicy analizatora z rys. 4.31. Zauważmy, że akcje dla stanów 0, 4, 6 i 7 są zgodne. Możemy przedstawiać j e wszystkie, korzystając z listy SYMBOL
AKCJA
id ( dowolny
s5 s4 błąd
Stan 1 ma podobną listę + $ dowolny
só akc błąd
W stanie 2 możemy zastąpić elementy błąd przez r2, tak aby redukcja według pro dukcji 2 zachodziła dla dowolnego wejścia oprócz *. Listą dla stanu 2 jest więc * dowolny
s7 r2
W stanie 3 są tylko elementy błąd oraz r4. Pierwsze możemy zastąpić drugimi i wtedy lista dla stanu 3 składa się tylko z jednej pary (dowolny, r4). Stany 5, 10 i 11 mogą zos tać potraktowane podobnie. Listą dla stanu 8 jest + ) dowolny
s6 sil błąd
* dowolny
s7 rl
a dla stanu 9
•
Tablicę przejście również moglibyśmy zakodować, używając listy, ale tu wydajniej sze wydaje się tworzenie listy par dla każdego nieterminala A. Każda para na liście dla A ma postać {obecny stan, następny-.stan), wskazując przejście[obecnystan,
A] =
następnystan
Technika ta jest wygodna, bo w dowolnej, ustalonej kolumnie tablicy przejście zazwyczaj jest niewiele stanów. Powodem jest to, że przejście dla nieterminala A może być jedynie stanem wyprowadzalnym ze zbioru sytuacji, w którym niektóre sytuacje mają A bezpo średnio na lewo od kropki. Żaden zbiór nie ma sytuacji z X i Y bezpośrednio na lewo
od kropki, jeśli X ^ Y. Wobec tego, każdy ze stanów występuje w co najwyżej jednej kolumnie tablicy przejście. Aby uzyskać większą oszczędność miejsca, zauważmy, że elementy błąd w tablicy przejście nie są nigdy sprawdzane. Możemy więc zastąpić każdy element błąd najczę ściej występującym innym elementem z jego kolumny. Ten element zostaje domyślnym; jest reprezentowany na liście dla każdej kolumny przez parę z „dowolny" w miejscu o b e c n e g o - stanu.
P r z y k ł a d 4.49. Rozważmy ponownie rysunek 4.31. Kolumna dla F ma element 10 dla stanu 7, a wszystkie pozostałe elementy są równe 3 lub błąd. Możemy zastąpić błąd przez 3 i dla kolumny F stworzyć listę obecnystan
nastpnystan
1 dowolny
10 3
Podobnie, odpowiednią listą dla kolumny T jest 6 dowolny
9 2
Dla kolumny E jako domyślne możemy wybrać 1 łub 8; w obu przypadkach potrzebne są dwa elementy. Na przykład, dla kolumny E możemy stworzyć listę 4
dowolny
8
• 1
Jeśli Czytelnik doda liczbę elementów na listach stworzonych w tym i w poprzednim przykładzie oraz doda wskaźniki ze stanów do list akcji i z nieterminali do list następnych stanów, nie będzie oszołomiony oszczędnością miejsca w porównaniu z implementacją tablicową z rys. 4 . 3 1 . Nie powinniśmy jednak dać się zwieść temu małemu przykłado wi. Dla gramatyk spotykanych w praktyce pamięć potrzebna w reprezentacji listowej to zazwyczaj mniej niż dziesięć procent pamięci potrzebnej w reprezentacji tablicowej. Powinniśmy również dodać, że opisywane w p. 3.9 metody kompresji tablic dla automatów skończonych mogą być również stosowane przy reprezentowaniu tablic ana lizatorów LR. Zastosowanie tych metod jest omówione w ćwiczeniach.
4.8
Używanie gramatyk niejednoznacznych
Z twierdzenia wynika, że żadna gramatyka niejednoznaczna nie może być L R i w związ ku z tym nie jest w żadnej z klas gramatyk omawianych w poprzednim podrozdziale. Jak przekonamy się teraz, pewne typy gramatyk niejednoznacznych okazują się jednak przydatne w specyfikacji i implementacji języków. Dla konstrukcji języka, takich jak wyrażenia, użycie gramatyk niejednoznacznych pozwala stworzyć krótsze, bardziej na turalne specyfikacje niż użycie dowolnej, równoważnej gramatyki jednoznacznej. Innym
zastosowaniem gramatyk niejednoznacznych jest separowanie często występujących kon strukcji języka w celu optymalizacji specjalnych przypadków. Korzystając z gramatyki niejednoznacznej, możemy specyfikować specjalne przypadki poprzez uważne dodawanie nowych produkcji do gramatyki. Powinniśmy podkreślić, że chociaż gramatyki, których używamy, są niejednoznacz ne, to we wszystkich przypadkach podajemy reguły ujednoznaczniające, które powodują, że dla każdego zdania istnieje tylko jedno drzewo wyprowadzenia. Dzięki temu, specyfi kacja języka pozostaje jednoznaczna. Zaznaczmy również, że niejednoznaczne konstruk cje powinny być używane oszczędnie i ściśle kontrolowane; w innym przypadku nie ma gwarancji, jaki język jest rozpoznawany przez analizator.
Korzystanie z priorytetów i łączności do rozstrzygania konfliktów akcji analizatora Rozważmy wyrażenia w językach programowania. Następująca gramatyka dla wyrażeń arytmetycznych z operatorami + oraz *: E^E
(4.22)
+ E | E * E | ( £ ) | id
jest niejednoznaczna, gdyż nie opisuje łączności ani priorytetów operatorów + oraz *. Gramatyka jednoznaczna E -4 E + T \ T T -> T * F | F F -> ( £ ) | id
(4.23)
generuje ten sam język, ale nadaje + mniejszy priorytet niż *, i powoduje, że oba ope ratory są lewostronnie łączne. Istnieją dwa powody, dla których możemy chcieć używać gramatyki (4.22) zamiast (4.23). Po pierwsze, jak się przekonamy, bez trudu możemy zmieniać łączność i priorytety operatorów + i * bez zmiany produkcji gramatyki (4.22) czy liczby stanów w analizatorze wynikowym. Po drugie, analizator dla (4.23) będzie spędzał znaczącą ilość czasu, redukując produkcje E -4 T i T -¥ F, których jedyną funk cją jest wymuszanie łączności i priorytetów. Analizator dla (4.22) nie będzie tracił czasu na redukowanie tych pojedynczych produkcji (tak są one nazywane). l
Zbiory sytuacji LR(0) dla (4.22) wzbogacone o produkcję E -> E pokazano na rys. 4.46. Ponieważ gramatyka (4.22) jest niejednoznaczna, więc podczas próby stwo rzenia tablicy L R ze zbiorów sytuacji powstaną konflikty akcji analizatora. Stanie się to w stanach odpowiadających zbiorom sytuacji 7 oraz 7 . Przypuśćmy, że do budowy tablicy analizatora używamy metody SLR. Konflikt powstający w 7 między redukcją według E -» E + E i przesunięciem dla + oraz * nie może zostać rozstrzygnięty, gdyż + oraz * są w F O L L O W ( £ ) . Wobec tego dla wejścia + albo * powinny być wywoływane dwie akcje. Podobny konflikt, między redukcją zgodnie z i przesunięciem dla + oraz * powstaje w 7 . Faktycznie, każda z naszych metod budowy tablicy LR stworzy te konflikty. 7
g
7
8
Możemy jednak rozstrzygnąć te problemy, korzystając z informacji o priorytetach i łączności dla + oraz *. Rozpatrzmy wejście id + id * id, dla którego analizator, opar ty na rys. 4.46, po przetworzeniu id + id przejdzie do stanu 7; analizator ten będzie w szczególności w konfiguracji
E'
-ł £
E E E E
-ł £ + E -* -E * E -» id
E' E E
-* £• -* £ • + £ -- > • £ • * £
E E E E E
-->(£) -* •£ + £ -^ •£ * £ -+ - ( E ) -» id
E --j. id / : 4
E E E E E
-4 £ + £ -•» •£ + £ --» £ * E -+ •(£) --» id
V
£ £ £ £ £
-» £ * • £ -> •£ + £ -* •£ * £ -M E ) -» id
£
-* ( £ • ) -- > £ • + £ -•» £ • * £
£ £
£ -•» £ + £• £->£•+£ £
-•+ £ • * £
£ - > £ * £ • £->£•+£ £ -- > £ • * £ £ --.(£)•
Rys. 4.46. Zbiory sytuacji LR(0) dla wzbogaconej gramatyki (4.22)
STOS
0 £ 1 + 4 E 7
WEJŚCIE
*id$
Przyjmując, że * ma wyższy priorytet niż + , wiemy, że analizator powinien prze sunąć * na stos, przygotowując się do redukcji * i id otaczających * do wyrażenia. Tak zachowałby się przedstawiony na rys. 4.31 analizator S L R dla takiego samego języka oraz analizator napisany metodą pierwszeństwa operatorów. Natomiast, gdyby -f miał wyższy priorytet niż *, analizator zredukowałby E + E do £ . Wobec tego względny priorytet + oraz * jednoznacznie określa, jak rozstrzygnąć konflikt ze stanu 7, dokonując wyboru między redukcją według £ - + £ + E a przesunięciem *. Jeśli na wejściu był napis id + id + id, to analizator również doszedłby do konfiguracji, w której po przetworzeniu wejścia id + id na stosie byłoby 0 £ 1 + 4 £ 7 . Dla wejścia + w stanie 7 znowu mamy konflikt przesunięcie/redukcja. Teraz jednak o rozstrzygnięciu konfliktu decyduje łączność operatora + . Jeśli + jest lewostronnie łączny, to właściwą akcją jest redukcja £ - > £ + £ , czyli id otaczające pierwszy + muszą być połączone jako pierwsze. Znowu wybór ten zgadza się z tym, co zrobiłby analizator S L R lub analizator napisany metodą pierwszeństwa operatorów dla gramatyki z przykładu 4.34. Podsumowując: jeżeli + jest lewostronnie łączny, akcją w stanie 7 dla wejścia + powinna być redukcja £—)•£ + £ , a jeżeli * ma wyższy priorytet niż + , akcją w stanie 7 dla wejścia * powinno być przesunięcie. Podobnie, przyjmując, że * jest lewostronnie łączna i ma priorytet wyższy niż + , możemy uzasadniać, że w stanie 8, który pojawia się na wierzchołku stosu tylko wtedy, gdy najbliższymi wierzchołkowi stosu symbolami
z gramatyki są E * E, powinniśmy wykonywać redukcję E —>• E * E zarówno dla *, jak i dla + . W przypadku + na wejściu jest to spowodowane wyższym priorytetem *, a w przypadku * uzasadnieniem jest lewostronna łączność *. Kontynuując to rozumowanie, otrzymujemy tablicę analizatora L R przedstawioną na rys. 4.47. Produkcje 1-4 to, odpowiednio, E ^ E + E, E E (E) i E —¥ id. Interesujący jest fakt, że podobna tablica analizatora powstałaby poprzez wyeliminowanie redukcji pojedynczych produkcji E - » T i T —> F z pokazanej na rys. 4.31 tablicy S L R dla gramatyki (4.23). Gramatyki niejednoznaczne, takie jak (4.22), mogą być podobnie obsługiwane przez analizatory L A L R i kanoniczne analizatory L R .
przejście
akcja STAN 0
id
5
s5
r4
r4
(
)
$
E 1
s2 akc
6
s2
s3
3 4
s4
s3
l 2
+
*
r4
s3
s2
s3
s2
r4 8 8
6
s4
s5
s9
7
rl
s5
rl
rl
8
r2
r2
r2
r2
9
r3
r3
r3
r3
Rys. 4.47, Tablica analizatora dla gramatyki ( 4 . 2 2 )
Niejednoznaczność „wiszącego else" Ponownie rozpatrzmy następującą gramatykę dla instrukcji warunkowych: instr -> if wyr then instr else instr | if wyr then instr | inne Tak jak zauważyliśmy w podrozdziale 4.3, gramatyka ta jest niejednoznaczna, bo wystę puje w niej niejednoznaczność „wiszącego else". Aby uprościć tę dyskusję, rozważmy abstrakcję powyższej gramatyki, w której i oznacza if wyr then, e oznacza else, a a oznacza „wszystkie inne produkcje". Możemy wtedy zapisać gramatykę, ze wzbogacają cą produkcją 5' -+ S, następująco: S
S
' ~^ S -+ iSeS | iS | a
(4.24)
Zbiory sytuacji L R ( 0 ) dla gramatyki (4.24) są przedstawione na rys. 4.48. Niejedno znaczność w (4.24) powoduje powstanie konfliktu przesunięcie/redukcja w stanie I . Dla S -+ iS-eS powinniśmy wykonać przesunięcie e, a — ponieważ F O L L O W ( S ) = {e,$} — dla sytuacji S —> iS- przy wejściu e powinniśmy wykonać redukcję S -> iS. Tłumacząc z powrotem na język if • then • • else, gdy na stosie będziemy mieli Ą
if wyr then instr
•S s -> •iSeS s ~> •iS s -> •a
h'
5 ->
/ :
5 -> /S • eS
4
S -> iSS -> iSe • S S -> •iSeS 5 -> •iS •a
5' ->S> 5 5 5 5 5
-> i • SeS iS
-> •iSeS -> •iS -> •a
s -> iSeS-
V
Rys. 4.48. Stany LR(0) dla wzbogaconej gramatyki (4.24)
a else jako pierwszy symbol wejściowy, to czy powinniśmy przesunąć else na stos (czy li przesunąć e), czy zredukować if wyr then instr do instr (czyli wykonać redukcję S -> iS)l Odpowiedź brzmi: powinniśmy przesunąć else, gdyż jest ono „związane" z po przednim then. W języku gramatyki (4.24) e na wejściu, oznaczające else, może być tylko częścią prawej strony zaczynającej się od iS na wierzchołku stosu. Jeśli następujące po e wejście nie może być wyprowadzone z 5, kończąc prawą stronę iSeS, to można wykazać, że nie istnieje inne wyprowadzenie. Dochodzimy do wniosku, że konflikt przesunięcie/redukcja w / powinien być roz strzygnięty na korzyść przesunięcia dla wejścia e. Tablica analizatora SLR zbudowana ze zbiorów sytuacji z rys. 4.48, używająca tego rozstrzygnięcia konfliktu akcji analiza tora w / przy wejściu e, jest pokazana na rys. 4.49. Produkcje 1 do 3 to, odpowiednio, S -¥ iSeS, S ->• iS oraz S -> a. 4
4
STAN 0 1 2 3 4 5 6
przejście
akcja i
e
s2
a
$
S
1
s3 akc
s2
4
s3 r3 r2
r3 s5
6
s3
s2 rl
rl
Rys. 4.49. Tablica analizatora LR dla abstrakcyjnej gramatyki z „wiszącym else" Przykładowo, dla wejścia iiaea analizator wykonuje ruchy przedstawione na rys. 4.50, odpowiadające właściwemu rozstrzygnięciu konfliktu związanego z „wiszącym else". W wierszu 5. stan 4 wybiera wykonanie przesunięcia dla wejścia e, a w wierszu 9. stan 4 wybiera wykonanie redukcji S —> iS dla wejścia $. Dla porównania, jeśli nie moglibyśmy używać gramatyki niejednoznacznej do opi su instrukcji warunkowych, to bylibyśmy zmuszeni do używania bardziej niewygodnej gramatyki, podobnej do (4.9).
STOS
(1) 0 (2) 07 2 (3) 0i li 1 (4) 0i li la 3 (5) 0/ li IS 4 (6) 0/ li IS 4e 5 (7) Oi li IS 4e 5a 3 (8) 0/ li IS 4e 5S 6 (9) 0/ 25 4 (10) OS 1
WEJŚCIE
iiaea% iaea$ aea% ea% ea% a% $ $ $ $
Rys. 4.50. Akcje analizatora wykonywane dla wejścia iiaea Niejednoznaczności powodowane przez produkcje dla specjalnych przypadków Nasz ostatni przykład użyteczności gramatyk niejednoznacznych dotyczy dodatkowej pro dukcji do opisania specjalnego przypadku konstrukcji składniowej opisywanej bardziej ogólnie przez resztę gramatyki. Dodanie tej nowej produkcji powoduje powstanie kon fliktu akcji analizatora. Często konflikt ten możemy rozstrzygnąć z dobrym skutkiem poprzez wprowadzenie reguły ujednoznaczniającej, która mówi, że redukcję należy wy konywać z użyciem produkcji dla specjalnego przypadku. Akcja semantyczna związana z dodatkową produkcją pozwala wówczas traktować specjalny przypadek przy użyciu specyficznych mechanizmów. Interesujące zastosowanie produkcji dla specjalnych przypadków przedstawili Kernighan i Cherry[1975] w preprocesorze służącym do składania równań, EQN, który był używany w trakcie składania tej książki*. W preprocesorze EQN składnia wyrażeń matematycznych jest opisana przez gramatykę, która używa operatora indeksu dolnego, sub, oraz operatora indeksu górnego, sup, tak jak pokazaliśmy we fragmencie gramaty ki (4.25). Nawiasy klamrowe są używane do ograniczania wyrażeń złożonych, a c jest używane jako symbol reprezentujący dowolny napis (1)
E -> E sub E sup E
(2)
E -> E sub E
(3)
E -» E sup E
(4)
E
(5)
E
(4.25)
{£} c
Gramatyka (4.25) jest niejednoznaczna z kilku powodów. Nie opisuje łączności ani priorytetów operatorów sub i sup. Nawet jeśli rozstrzygniemy problemy wyni kające z łączności i priorytetów sub i sup, na przykład decydując, że oba operato ry mają równy priorytet i są prawostronnie łączne, gramatyka ciągle będzie niejed noznaczna. Dzieje się tak dlatego, że produkcja (1) opisuje specjalny przypa dek wyrażeń generowanych przez produkcje (2) i (3), tj. wyrażenia o postaci E sub E sup E. Powodem tego specjalnego traktowania jest to, że wielu użyt kowników wolałoby składać wyrażenie typu a sub i sup 2 jako aj, a nie jako a?. Aby * Dotyczy to oryginału książki (przyp. tłum.).
E Q N produkował takie specjalne wyjście, wystarczyło dodać produkcję dla specjalnego przypadku. W celu sprawdzenia, jak taka niejednoznaczność może być obsługiwana w środo wisku LR, zbudujmy analizator SLR dla gramatyki (4.25). Zbiory sytuacji LR(0) dla tej gramatyki są przedstawione na rys. 4.51. W tej rodzinie dla trzech zbiorów sytuacji
E' £ E E
£
V
£' -> E E —>• •£ sub E sup E £—>••£ sub E E -¥ -E sup E E •{£} E -> c
£ • sub £ sup £
£ -4 £ • sub £ £ —• £ • sup £
£
V"
-> £• —> £ • sub E sup E -> E • sub E -> E • sup E
£ -> £ • sub £ sup £ £ -> £ sub £ • sup £ £—)•£• sub £ £ -» £ sub £• £ —» £ • sup £ £ £ £ £
E {•£} E —> •£ sub E sup E E ~> -E sub E £ -> •£ sup £
{£•}
-» £ • sub £ sup £ -> £ • sub £ —> E • sup £ -> £ sup £•
£ -> {£}•
£ -> •{£} £ -> -c
V £ ~> c-
E
£ sub • £ sup £ £ -)• £ sub £ E -» •£ sub £ sup £ £ •£ sub £ £ —• •£ sup £
-» £ sub £ sup • £ ->• £ sup • £ -> •£ sub £ sup £ -> •£ sub £ —>• •£ sup £
£ -> •{£} £ ->
£ £ £ £ £
£ -> •{£} £
£ £ £ £ £
-c
£ —y E sup • £ £ •£ sub £ sup £ £ - > • • £ sub £ £ —» •£ sup £ £ -* •{£}
c
—• £ • sub £ sup £ —• £ sub £ sup £• —> £ • sub £ —> £ • sup £ —>• £ sup £•
£ —> -c Rys. 4.51. Zbiory sytuacji LR(0) dla gramatyki (4.25)
istnieją konflikty akcji analizatora. W / , / i / są konflikty przesunięcie/redukcja dla symboli sub i sup, gdyż łączność i priorytety tych operatorów nie zostały wyspecyfi kowane. Rozstrzygamy te konflikty, stwierdzając, że sub i sup mają równe priorytety i są prawostronnie łączne. W związku z tym, w każdym z przypadków preferowane jest przesunięcie. 7
g
n
W /
u
między produkcjami
E -> E sub E sup E E ^
E sup £
jest również konflikt redukcja/redukcja dla wejść } i $. Stan / będzie na wierzchołku stosu wtedy, gdy widzieliśmy wejście, które zostało zredukowane do E sub E sup E na stosie. Jeśli konflikt redukcja/redukcja rozstrzygniemy na korzyść produkcji (1), napis o postaci E sub E sup E będziemy traktować jak specjalny przypadek. Używając tych reguł ujednoznaczniania, otrzymujemy tablicę analizatora SLR z rys. 4.52. n
przejście
akcja STAN
0 i 2 3 4 5 6 7 8 9 10 11
sub
sup
{
}
s2 s4
s5
r5
r5
s4
s5
E 1
s3 r5
s2 s2 s5 slO s5 r4
$ akc
s2
s4 s4 s4 r4
c s3
6 r5
r3 s3 s9 r2 r3 r4
s2
7 8 r2 r3 r4 11
s3 rl
rl
Rys. 4.52. Tablica analizatora dla gramatyki (4.25) Pisanie gramatyk jednoznacznych, które wyróżniają specjalny przypadek konstrukcji składniowej, jest bardzo trudne. Aby sprawdzić, jak trudne, Czytelnik może spróbować skonstruować gramatykę równoważną gramatyce (4.25), która będzie jednoznaczna i bę dzie wyróżniała wyrażenia o postaci E sub E sup E.
Obsługa błędów w analizie LR Analizator LR wykrywa błąd, gdy odczytuje wartość z tablicy akcji analizatora, a tą war tością jest błąd. Błędy nie są nigdy wykrywane podczas odczytywania wartości z tablicy przejść. W przeciwieństwie do analizatora napisanego metodą pierwszeństwa operatorów, analizator LR zgłosi błąd od razu, gdy nie będzie poprawnej kontynuacji dla wczytanej już części wejścia. Analizator kanoniczny LR przed zgłoszeniem błędu nigdy nie wykona ani jednej redukcji. Analizatory SLR i LALR mogą wykonać pewną liczbę redukcji, ale nigdy nie przesuną na stos błędnego symbolu wejściowego. W analizie L R odzyskiwanie kontroli w trybie paniki możemy zaimplementować następująco: przeglądamy stos w dół aż do napotkania stanu s z przejściem dla pewnego nieterminala A. Następnie wyrzucamy zero lub więcej symboli wejściowych, aż znajdzie my symbol a, który legalnie może występować po A. Analizator odkłada wtedy na stos stan przejście[s, A] i wznawia zwykłą analizę. Może istnieć więcej niż jeden kandydat na
nieterminal A. Zazwyczaj będą to nieterminale reprezentujące duże fragmenty programu, takie jak wyrażenie, instrukcja lub blok. Jeśli, na przykład, A jest nieterminalem instr, to a może być średnikiem lub symbolem end. Ta metoda odzyskiwania kontroli próbuje wyizolować zdanie zawierające błąd skła dniowy. Analizator stwierdza, że ciąg wyprowadzalny z A zawiera błąd. Część tego ciągu została już obsłużona, a wynikiem tego przetwarzania jest ciąg symboli na wierzchołku stosu. Pozostała część ciągu wyprowadzałnego z A jest ciągle na wejściu, więc analizator próbuje pominąć pozostałą część ciągu, szukając na wejściu symbolu, który popraw nie może występować po A. Usuwając ze stosu stany, pomijając wejście i wstawiając przejście[s, A] na stos, analizator udaje, że znalazł wystąpienie A i wznawia zwykłą analizę. Odzyskiwanie kontroli na poziomie frazy implementuje się, sprawdzając każdą po zycję z błędem w tablicy analizatora LR i wybierając, na podstawie znajomości języka, najbardziej prawdopodobną pomyłkę programisty, która prowadzi do wykrycia danego błędu. Można wówczas skonstruować właściwą procedurę odzyskiwania kontroli; zapew ne wierzchołek stosu i/lub początkowe symbole z wejścia mogą zostać zmodyfikowane w sposób, który zostanie uznany za właściwy dla każdej pozycji błędu. W porównaniu z analizatorami napisanymi metodą pierwszeństwa operatorów, projekt konkretnej procedury obsługi błędów dla analizatora L R jest stosunkowo prosty. W szczególności, nie musimy przejmować się niepoprawnymi redukcjami; każda redukcja wykonywana przez analizator L R jest z pewnością poprawna. Możemy więc wypełnić wszystkie puste miejsca w tablicy akcji wskaźnikami procedur obsłu gi błędu, które wykonają odpowiednie operacje wybrane przez projektanta kompilatora. Operacje te mogą być, m.in., wstawianiem lub usuwaniem symboli ze stosu i/lub wejścia albo zmianą bądź transpozycją symboli wejściowych, dokładnie tak samo, jak w przy padku analizatorów pisanych metodą pierwszeństwa operatorów. Tak jak tam, musimy dokonywać takich wyborów, które nie pozwolą wejść analizatorowi LR w pętlę nieskoń czoną. Strategia zapewniająca, że co najmniej jeden symbol wejściowy będzie usunięty lub kiedyś przesunięty, albo że stos będzie się zmniejszał po osiągnięciu końca wejścia, jest tu wystarczająca. Należy unikać zdejmowania ze stosu stanu, który leży na nieterminalu, gdyż taka modyfikacja usuwa ze stosu konstrukcję, która została już poprawnie przeanalizowana.
P r z y k ł a d 4.50.
Kolejny raz zajmijmy się gramatyką dla wyrażeń E->
E + E \ E * E
\ (E)
\ id
Na rysunku 4.53 przedstawiono tablicę analizatora LR z rys. 4.47, zmodyfikowaną do wykrywania i obsługi błędów. Zmieniliśmy każdy stan, w którym wywoływana jest jakaś redukcja dla pewnych symboli wejściowych poprzez zastąpienie elementów błąd z tego stanu redukcjami. Zmiana ta skutkuje opóźnieniem wykrywania błędów o czas wykonania jednej lub kilku redukcji, ale błędy i tak są wykrywane przed wykonaniem dowolnego przesunięcia. Pozostałe puste miejsca z rys. 4.47 zostały zastąpione odwołaniami do procedur obsługi błędów. Procedury obsługi błędów są podane niżej. Podobieństwo tych operacji i błędów przez nie obsługiwanych do operacji z przykładu 4.32 (dla analizatora napisanego me-
przejście
akcja STAN
0 i 2 3 4 5 6 7 8 9
id
+
*
s3 e3 s3 r4 s3 s3 e3 rl r2 r3
el s4 el r4 el el s4 rl r2 r3
el s5 el r4 el el s5 s5 r2 r3
( s2 e3 s2 r4 s2 s2 e3 rl r2 r3
) e2 e2 e2 r4 e2 e2 s9 rl r2 r3
$
E
el akc el r4 el el e4 rl r2 r3
1 6 7 8
Rys. 4.53. Tablica analizatora LR z procedurami obsługi błędów
todą pierwszeństwa operatorów) powinno być widoczne. Przypadek e l z analizatora L R w analizatorze napisanym metodą pierwszeństwa operatorów jest jednak często obsługi wany przez redukcję. e l : /* Ta procedura jest wywoływana ze stanów 0, 2, 4 i 5, a we wszystkich z nich oczekujemy na początek argumentu, czyli id albo lewego nawiasu. Zamiast tego znaleźliśmy -f-, * albo koniec wejścia */ wstaw sztuczne id na stos i przykryj j e stanem 3 (przejściem ze stanów 0, 2, 4 i 5 dla i d ) wypisz komunikat „zaginiony argument" e2: /* Ta procedura jest wywoływana ze stanów 0, 1, 2, 4 i 5 po znalezieniu prawego nawiasu */ 1
usuń prawy nawias z wejścia wypisz komunikat „nieoczekiwany prawy nawias" e3: /* Ta procedura jest wywoływana ze stanów 1 i 6, gdy oczekiwany jest operator, a znaleziony jest id albo lewy nawias */ wstaw + na stos i przykryj go stanem 4 wypisz komunikat „oczekiwany operator" e4: /* Ta procedura jest wywoływana ze stanu 6 po znalezieniu końca wejścia. W stanie 6 oczekujemy operatora albo prawego nawiasu */ wstaw prawy nawias na stos i przykryj go stanem 9 wypisz komunikat „oczekiwany prawy nawias" Inne niż opisane w przykładzie 4.32, kolejne konfiguracje analizatora przy niepo prawnym wejściu id + ) pokazano na rys. 4.54. •
Przypomnijmy, że, w praktyce, symboli z gramatyki nie umieszcza się na stosie. Wygodnie jest jednak wyobrażać sobie, że one tam są, aby pamiętać o symbolach, które te stany „reprezentują".
WEJŚCIE
STOS
0 0 id 0E 0E 0£
id+)$ +)$ +)$ )$ $
3 1 1+4 1+4
0 E 1 +4 id 3
$
0 E 1+4 E 1 0E 1
$ $
KOMUNIKAT O
BŁĘDZIE I DZIAŁANIE
„nieoczekiwany prawy nawias" e2 usuwa prawy nawias „zaginiony argument" el wstawia id 3 na stos
Rys. 4.54. Kroki analizy i obsługi błędów wykonywane przez analizator LR
4.9
Generatory analizatorów
W tym podrozdziale omówiliśmy korzystanie z generatora analizatorów, w celu ułatwienia budowy przodu kompilatora. Jako przykład posłużył nam generator analizatorów L A L R o nazwie Yacc, implementuje on bowiem wiele z opisanych w poprzednich dwóch pod rozdziałach pojęć i jest powszechnie dostępny. Yacc jest skrótem od angielskiej nazwy yet another compiler-compiler, czyli „jeszcze jeden kompilator-kompilatorów", co oddaje popularność generatorów analizatorów na początku lat siedemdziesiątych, gdy powstała pierwsza wersja Yacca napisana przez S . C . Johnsona. Yacc jest dostępny w systemach UNIX* i był używany przy pisaniu setek kompilatorów.
Generator analizatorów Yacc Za pomocą Yacca można zbudować translator w sposób pokazany na rys. 4.55. Najpierw przygotowujemy plik, powiedzmy t r a n s l a t o r . y, zawierający specyfikację translatora dla Yacca. Polecenie w systemie UNIX yacc t r a n s l a t o r . y korzystając z metody L A L R naszkicowanej w algorytmie 4 . 1 3 , przekształci plik t r a n s l a t o r . y w program w języku C , nazwany y . t a b . c . Program y . t a b . c jest reprezentacją analizatora LALR napisaną w C , razem z innymi procedurami w języku C , które użytkownik mógł przygotować. Tablica analizatora LALR jest zmniejszana tak, jak to opisaliśmy w p. 4.7. Kompilując za pomocą polecenia cc y . t a b . c
-ly
y . t a b . c razem z biblioteką l y , która zawiera program analizatora LR, otrzymamy program wynikowy a . o u t , który wykonuje translację opisaną przez pierwotną specyfi kację dla Yacca . Jeśli potrzebne są inne procedury, mogą one zostać skompilowane lub załadowane razem z y . t a b . c , tak jak w przypadku innych programów w języku C . 1
* W wielu systemach, nie tylko UN1X, jest dostępny generator bison, który zastępuje program yacc; akceptuje on pliki wejściowe Yacca (przyp. tłum.). Nazwa ly jest zależna od systemu. 1
Specyfikacja w języku Yacca translator.y
Kompilator Yacc
y.tab.c
y.tab . c
Kompilator C
a. out
Wejście
a. out
Wyjście
Rys. 4.55. Tworzenie translatora za pomocą Yacca
Źródłowy program dla Yacca składa się z trzech części deklaracje o, o. "o "o
reguły
translacji
9- So o
pomocnicze
procedury
w C
P r z y k ł a d 4 . 5 1 . Aby przygotować program źródłowy dla Yacca, rozpatrzmy prosty kal kulator, który wczytuje wyrażenie arytmetyczne, wylicza jego wartość i ją drukuje. Kal kulator ten zbudujemy, zaczynając od następującej gramatyki dla wyrażeń arytmetycz nych: E -> E + T | T T ->T * F \F F -¥ (E) \ cyfra Symbol cyfra jest pojedynczą cyfrą od 0 do 9. Program kalkulatora dla Yacca stworzony na podstawie tej gramatyki jest przedstawiony na rys. 4.56. • Deklaracje. W części przeznaczonej na deklaracje programu dla Yacca są dwie opcjonalne sekcje. W pierwszej z nich umieszczamy zwykłe deklaracje w języku C, ograniczone % { oraz % } . Umieszczamy tu deklaracje wszystkich zmiennych tymczaso wych używanych przez reguły translacji lub procedury z drugiej albo trzeciej części. Na rysunku 4.56 sekcja ta zawiera tylko instrukcję preprocesora #include
nakazującą preprocesorowi języka C włączyć standardowy plik nagłówkowy < c t y p e . h > , który zawiera predykat i s d i g i t . W tej samej części pliku umieszczamy deklaracje symboli leksykalnych z gramatyki. Na rysunku 4.56 instrukcja %token
CYFRA
deklaruje, że C Y F R A jest symbolem leksykalnym. Symbole zadeklarowane w tej sekcji mogą być używane w drugiej i trzeciej części specyfikacji.
%{ #include %} %token CYFRA
w y r
, ,
{ print ("%d\n», S D ; }
X n
wiersz wyr | term
czynnik
wyr '+' term term
{ $$ = $1 + $3; }
' |
term '*' czynnik czynnik
{ $$ = $1 * $3; }
: |
' ( ' wyr ') CYFRA
{ $$ - $2; }
yylex{) { int c; c = getchar (); if (isdigit
} return c;
} Rys. 4.56. Specyfikacja prostego kalkulatora w Yaccu Reguły translacji. W części specyfikacji znajdującej się po pierwszej parze %% umieszczamy reguły translacji. Każda reguła składa się z produkcji gramatyki i skoja rzonej z nią akcji semantycznej. Zbiór produkcji, które zapisywaliśmy < lewa s t r o n a > ^ < alt 1 > | < alt 2 > | - - - | < alt n > w Yaccu zostałby zapisany jako
strona>
: I
1> 2>
{akcja {akcja
semantyczna semantyczna
1} 2}
|
{akcja
semantyczna
n}
W produkcjach Yacca pojedynczy znak w apostrofach, ' c ' , jest traktowany jako sym bol terminalny c , a ciąg liter i cyfr, bez apostrofów i nie zadeklarowany jako symbol leksykalny, jest traktowany jako nieterminal. Alternatywy w prawych stronach mogą być oddzielone pionową kreską, a za lewą stroną i jej alternatywami oraz akcjami seman tycznymi wstawiany jest średnik. Pierwsza lewa strona to symbol początkowy.
Akcja semantyczna Yacca jest sekwencją instrukcji w języku C. W akcji seman tycznej symbol $ $ odwołuje się do wartości atrybutu skojarzonej z nieterminalem po lewej stronie, podczas gdy $ i odwołuje się do wartości skojarzonej z /-tym symbolem gramatyki (terminalem bądź nieterminalem) p o prawej stronie. Akcja semantyczna jest wykonywana zawsze, gdy redukujemy według związanej z nią produkcji, więc zazwy czaj akcja semantyczna wyznacza wartość $ $ zależną od $ i . W specyfikacji dla Yacca napisaliśmy dwie ^-produkcje E^rE
+T |T
i związane z nimi akcje semantyczne jako
wyr
: |
wyr '+' term term
{ $$ = $1 + $3; }
Zauważmy, że nieterminal term w pierwszej produkcji jest trzecim symbolem gramatyki po prawej stronie, podczas gdy ' + ' jest drugim. Akcja semantyczna związana z pierwszą produkcją dodaje wartości wyr i term z prawej strony i zapisuje wynik jako wartość nie terminala w y r z lewej strony. Ominęliśmy akcję semantyczną dla drugiej produkcji, gdyż kopiowanie wartości jest akcją domyślną dla produkcji z jednym symbolem gramatyki po prawej stronie; { $$ = $1; } jest domyślną akcją semantyczną. Zauważmy, że do specyfikacji dodaliśmy nową produkcję startową wiersz
:
wyr ' \ n '
{ printf("%d\n",
$1); }
Produkcja ta mówi, że wejściem dla kalkulatora jest wyrażenie, po którym następuje znak końca wiersza. Akcja semantyczna związana z tą produkcją wypisuje dziesiętną wartość wyrażenia i znak końca wiersza. Pomocnicze procedury w C. Trzecia część specyfikacji dla Yacca składa się z pomocni czych procedur w języku C. Musimy dostarczyć analizator leksykalny o nazwie yylex (). Inne procedury, takie jak procedury obsługi błędów, mogą być tu umieszczone w razie potrzeby. Analizator leksykalny yylex () zwraca parę składającą się z symbolu leksykalnego i związanej z nim wartości atrybutu. Jeśli zwracany jest symbol taki j a k CYFRA, to musi on być wcześniej zadeklarowany w pierwszej sekcji specyfikacji dla Yacca. Wartość atrybutu związanego z symbolem leksykalnym jest dostarczana analizatorowi poprzez zadeklarowaną w Yaccu zmienną yylval. Analizator leksykalny z rysunku 4.56 jest bardzo surowy. Wczytuje po jednym znaku z wejścia, używając funkcji g e t c h a r () z języka C. Jeśli znak jest cyfrą, wartość cyfry jest zapamiętywana w zmiennej yylval, a zwracany jest symbol CYFRA. W innym przypadku odczytany znak jest zwracany jako symbol.
Używanie gramatyk niejednoznacznych w Yaccu Zmodyfikujmy teraz specyfikację dla Yacca tak, aby program kalkulatora był bardziej użyteczny. Po pierwsze, pozwolimy kalkulatorowi obliczać ciągi wyrażeń, po jednym w wierszu. Pozwolimy również umieszczać puste wiersze między wyrażeniami. Robimy to, zmieniając pierwszą regułę na
:
wiersze
w i e r s z e
w i e r s z e
wyr
\n'
{printf("%g\n",
$ 2 ) ;
}
' \ n '
W Yaccu pusta alternatywa, taka jak trzeci wiersz powyżej, oznacza e. Po drugie, zwiększymy klasę wyrażeń tak, aby zawierała liczby zamiast pojedyn czych cyfr i operatory -h, — (dwuargumentowy i jednoargumentowy), * oraz / . Najprost szą metodą opisu takiej klasy wyrażeń jest użycie gramatyki niejednoznacznej E^E
+ E\E~E
\ E*E \ E/E \ (E) | - E \ liczba
Odpowiednia specyfikacja dla Yacca jest przedstawiona n a rys. 4.57. %{ #include #include <stdio.h> #define YYSTYPE double
/* stos Yacca będzie przechowywał wartości double */
%} %token LICZBA %left '+' %left '*' '/' %right UMINUS
wiersze
wiersze wyr '\n' wiersze '\n' /* */
{ printf("%g\n", $2);}
e
wyr
wyr '-' wyr ' *' wyr '/' wyr wyr ') ' wyr LICZBA
wyr wyr wyr wyr ' ('
{ $$ = $1 + $3; } { $$ = $1 - $3; } { $$ = $1 * $3; } { $$ = $1 / $3; } { $$ = $2; } %prec UMINUS { $$ = - $2; }
yylex() { int c; while ( ( c = getchar() ) — ' if ( (c '.') II (isdigit(c)) ungetc (c, stdin); scanf ("%lf", &yylval); return LICZBA;
);
} return c; Rys. 4.57. Specyfikacja Yacca dla bardziej złożonego kalkulatora
Ponieważ gramatyka w specyfikacji z rys. 4.57 jest niejednoznaczna, algorytm bu dowy tablicy LALR wygeneruje konflikty akcji analizatora. Yacc zgłosi liczbę pow stałych konfliktów akcji. Opis zbiorów sytuacji i konfliktów akcji można otrzymać po wywołaniu Yacca z parametrem - v . Parametr ten powoduje utworzenie dodat kowego pliku y.output, w którym są zawarte jądra stanów sytuacji analizatora, opis konfliktów akcji powstałych w trakcie działania algorytmu LALR i czytelna reprezentacja tablicy analizatora LR, z której wynika sposób rozstrzygnięcia konfliktów. Zawsze, gdy Yacc zgłasza, że wystąpiły konflikty akcji analizatora, mądrze jest utworzyć i przejrzeć plik y . output, żeby poznać te konflikty i sprawdzić, czy zostały prawidłowo rozstrzy gnięte. Jeżeli nie zarządzimy inaczej, Yacc będzie rozstrzygał wszystkie konflikty, korzy stając z dwóch reguł: 1.
2.
Konflikt redukcja/redukcja jest rozstrzygany przez wybór produkcji, która w pliku specyfikacji występuje jako pierwsza. W związku z tym, aby otrzymać właściwe roz strzygnięcie w programie do składania wyrażeń (patrz (4.25)), wystarczy produkcję (1) umieścić przed produkcją (3). Konflikt przesunięcie/redukcja jest rozwiązywany na korzyść przesunięcia. Reguła ta pozwala poprawnie rozstrzygać konflikty powstające z niejednoznaczności „wi szącego else".
Ponieważ te domyślne reguły nie zawsze są tym, czego oczekuje autor kompilatora, Yacc dostarcza ogólnego mechanizmu rozstrzygania konfliktów przesunięcie/redukcja. W części dla deklaracji symbolom terminalnym możemy przypisać łączność i priorytety. Deklaracja
%left '+' '-' powoduje, że + oraz - mają ten sam priorytet i są lewostronnie łączne. Możemy zade klarować operator prawostronnie łączny, pisząc A
%right ' ' oraz możemy spowodować, żeby operatora nie można było wiązać (tj. żeby dwa wystą pienia operatora nie mogły być powiązane), pisząc
łnonassoc ' <' Priorytet symboli leksykalnych jest ustalany przez kolejność, w której występują one w sekcji deklaracji, począwszy od najniższego. Symbole w tej samej deklaracji mają taki sam priorytet. Wobec tego deklaracja
%right UMINUS z rys. 4.57 nadaje symbolowi UMINUS priorytet wyższy niż priorytety pięciu poprzedza jących go symboli terminalnych. Yacc rozwiązuje konflikty przesunięcie/redukcja poprzez dołączenie informacji o prio rytecie i łączności do każdej produkcji i każdego terminala zaangażowanego w ten kon flikt. Jeśli musi on wybrać między przesunięciem symbolu wejściowego a i redukcją według A - » a , Yacc redukuje, jeśli priorytet produkcji jest wyższy niż priorytet a, albo
jeśli priorytety są takie same, a wiązaniem produkcji jest left. W innych przypadkach wybierane jest przesunięcie. W normalnej sytuacji priorytetem produkcji jest priorytet jej skrajnie prawego ter minala. W większości przypadków jest to rozsądna decyzja. Przykładowo, mając dane produkcje E^E +E \E * E wolelibyśmy redukować według E —• E + E przy podglądanym symbolu + , gdyż + po prawej stronie m a taki sam priorytet jak podglądany symbol, ale jest lewostronnie łączny. Przy podglądanym symbolu * wolelibyśmy wykonać przesunięcie, bo podglądany symbol ma priorytet wyższy niż + w produkcji. W sytuacjach, w których skrajnie prawy terminal nie m a priorytetu odpowiedniego dla produkcji, możemy wymusić priorytet poprzez dodanie do produkcji znacznika %prec
Priorytet i łączność produkcji będą wówczas takie same, jak te dla terminala, o którym zakładamy, że zosta! zdefiniowany w sekcji deklaracji. Yacc nie zgłasza konfliktów prze sunięcie/redukcja, które zostały rozstrzygnięte przy użyciu tych mechanizmów łączności i priorytetów. „Terminal" może być wypełniaczem, tak jak UMINUS z rys. 4.57; ten terminal nie jest zwracany przez analizator leksykalny, ale jest zadeklarowany wyłącznie po to, by można było zdefiniować priorytet produkcji. Na rysunku 4.57 deklaracja
%right UMINUS przypisuje symbolowi UMINUS priorytet wyższy niż ten dla * oraz / . W części dla reguł translacji znacznik
%prec UMINUS na końcu produkcji
wyr : '-' wyr nadaje jednoargumentowemu minusowi w tej produkcji wyższy priorytet niż jakiemukol wiek innemu operatorowi. Tworzenie analizatorów leksykalnych za pomocą Leksa Lex został tak zaprojektowany, aby produkował analizatory leksykalne, które mogą być używane z Yaccem. Biblioteka Leksa, 11, dostarcza procedurę sterującą o nazwie yylex ( ) , taką, jakiej Yacc żąda od analizatora leksykalnego. Jeśli używamy Leksa do skonstruowania analizatora leksykalnego, zastępujemy procedurę yylex() w trzeciej części specyfikacji dla Yacca instrukcją
#include "lex.yy.c" i nakazujemy każdej akcji Leksa zwracać terminal znany Yaccowi. Dzięki użyciu in strukcji #include "lex.yy.c", procedura yylex m a dostęp do nazw symboli Yacca, ponieważ wyjście Leksa jest kompilowane jako część wyjściowego pliku Yac ca, y . tab . c.
W systemie UNIX, jeśli specyfikacja dla Leksa jest w pliku pierwszy.1, a spe cyfikacja dla Yacca w pliku drugi . y, możemy napisać
lex pierwszy.1 yacc drugi.y cc y.tab.c -ly -11 aby otrzymać żądany translator. Specyfikacja dla Leksa z rys. 4.58 może być używana zamiast analizatora leksy kalnego z rys. 4.57. Ostatnim wzorcem jest \ n | ponieważ . w Leksie pasuje do dowolnego znaku z wyjątkiem nowego wiersza.
liczba
[0-9]+\.?I[0-9]*\.[0-9]+
9. 9. "o o
[ ] {liczba}
{ /* pomiń znaki białe */ { sscanf(yytext, "%lf", &yylval);
\n| .
{ return yytext[0] ; }
return LICZBA; }
Rys. 4.58. Specyfikacja dla Leksa procedury yylex () z rys. 4.57
O b s ł u g a b ł ę d ó w w Yaccu W Yaccu obsługa błędów jest realizowana przy użyciu pewnego rodzaju specjalnych produkcji dla błędów. To użytkownik decyduje, które „główne" nieterminale będą miały związaną ze sobą obsługę błędów. Typowy wybór to pewien podzbiór zbioru nieterminali generujących wyrażenia, instrukcje, bloki i procedury. Użytkownik może następnie dodać do gramatyki produkcje o postaci A errora, gdzie A jest głównym nieterminalem, a a jest ciągiem symboli z gramatyki, być może pustym; error jest zastrzeżonym słowem Yacca. Yacc z takiej specyfikacji wygeneruje analizator, traktując produkcje dla błędów tak, jak zwykłe produkcje. Gdy jednak analizator stworzony przez Yacca napotka błąd, traktuje stany, któ rych zbiory sytuacji zawierają produkcje dla błędów, w specjalny sposób. Po wys tąpieniu błędu, Yacc zdejmuje symbole ze swojego stosu, aż napotka najwyższy stan na stosie, dla którego zbiór sytuacji zawiera sytuację o postaci A —> • error a. Analiza tor „przesuwa" wtedy fikcyjny symbol error na stos, tak jakby zobaczył symbol error na wejściu. Jeżeli a jest równe e, redukcja do A następuje natychmiast i wywoływana jest ak cja semantyczna związana z produkcją A —> error (która może być procedurą odzyski wania kontroli napisaną przez użytkownika). Analizator następnie pomija symbole wejściowe aż do znalezienia symbolu, po odczytaniu którego może wznowić normalne działanie. Jeśli a nie jest pusty, Yacc pomija symbole z wejścia, szukając ciągu, który może zostać zredukowany do a . Jeśli a składa się wyłącznie z terminali, to Yacc wyszukuje taki ciąg terminali na wejściu i „redukuje" j e , przesuwając na stos. W tym momencie na wierzchołku stosu analizatora będzie error a. Analizator zredukuje error a do A i wznowi normalne działanie.
Na przykład produkcja dla błędu o postaci instr -¥
error ;
nakaże analizatorowi, by po wystąpieniu błędu pomijał symbole aż do symbolu tuż po pierwszym napotkanym średniku i aby przyjął, że właśnie znalazł instrukcję. Procedury semantyczne dla tej produkcji nie muszą zmieniać wejścia, ale mogą na przykład wypisać komunikat i nadać jakiejś zmiennej wartość, która spowoduje, że nie będzie generowany kod wynikowy. P r z y k ł a d 4.52. Na rysunku 4.59 przedstawiono specyfikację kalkulatora dla Yacca z rys. 4.57 z produkcją dla błędu wiersze
: error
'\n'
Ta produkcja powoduje, że p o znalezieniu błędu w wierszu wejścia kalkulator wstrzy muje normalną analizę. Po napotkaniu błędu analizator kalkulatora zaczyna zdejmować
%{ #include #include <stdio.h> #define YYSTYPE double /* stos Yacca będzie przechowywał wartości double */ %} %token LICZBA %left '+' '-' %left '*' ' /' %right UMINUS o. o.
wiersze ; | | |
wiersze wyr '\n' { print{"%g\n", $ 2 ) ; } wiersze '\n' /* pusta */ error '\n' { yyerror("wpisz ponownie ostatni wiersz:" yyerror; }
wyr
wyr ' +' wyr wyr r _/ wyr wyr f * ' wyr wyr ' / ' wyr ' (' wyr ' ) ' t _' wyr %prec LICZBA
{ $$ = { $$ = { $$ = { $$ = { $$ = UMINUS
$1 + $3; $1 - $3; $1 * $3; $1 / $3; $2; } { $$ -
} } } }
lex yy- c' Rys. 4.59. Kalkulator z obsługą błędów
symbole ze stosu aż znajdzie stan, który ma akcję przesunięcia dla symbolu error. Stan 0 jest takim stanem (w tym przykładzie jest to jedyny taki stan), ponieważ jedną z jego sytuacji jest w i e r s z e —> • error ' \ n ' Ponadto stan 0 leży zawsze na dnie stosu. Analizator przesuwa symbol error na stos, a następnie pomija symbole z wejścia, aż znajdzie znak końca wiersza. Wtedy analiza tor przesuwa znak końca wiersza na stos, redukuje error ' \ n ' do wiersze i wypisuje komunikat diagnotyczny „wpisz ponownie ostatni wiersz". Specjalna procedura Yacca y y e r r o k przestawia analizator na jego zwykły tryb działania. •
ĆWICZENIA 4.1 Rozpatrzmy gramatykę S -> ( L ) | a L^r L,S\ S a) Jakie są terminale, nieterminale i co jest symbolem startowym? b) Znajdź drzewa wyprowadzenia dla następujących zdań: i) (a, a) ii) (a, (a, a)) iii) (a, ((a, a ) , (a,
a)))
c) Zbuduj lewostronne wyprowadzenie dla każdego ze zdań w b). d) Zbuduj prawostronne wyprowadzenie dla każdego ze zdań w b). *e) Jaki język jest generowany przez tę gramatykę? 4.2 Rozważmy gramatykę S -» aSbS | bSaS \e a) Budując dwa różne drzewa lewostronnego wyprowadzenia dla zdania wykaż, że gramatyka ta jest niejednoznaczna. b) Zbuduj odpowiadające wyprowadzenia prawostronne dla abab. c) Zbuduj odpowiadające drzewa wyprowadzenia dla abab. *d) Jaki język jest generowany przez tę gramatykę? 4.3 Rozważmy gramatykę bwyr —> bwyr or bterm \ bterm bterm —> bterm and bczynnik \ bczynnik bczynnik —• not bczynnik \ (bwyr) | true | false a) Zbuduj drzewo wyprowadzenia dla zdania not (true or false). b) Pokaż, że gramatyka ta generuje wszystkie wyrażenia logiczne. *c) Czy gramatyka ta jest niejednoznaczna? Dlaczego? 4.4 Rozważmy gramatykę R->R
'|' R | RR | R* | (R) | a | b
abab,
Zauważmy, że pierwsza pionowa kreska jest symbolem operacji „lub", a nie sepa ratorem alternatyw. a) Wykaż, że gramatyka ta generuje wszystkie wyrażenia regularne nad symbolami a i b. b) Wykaż, że gramatyka ta jest niejednoznaczna. *c) Zbuduj równoważną gramatykę jednoznaczną, taką, która nadaje operatorom *, konkatenacji i | priorytety i łączność opisaną w p. 3.3. d) Zbuduj drzewa wyprowadzenia dla obu gramatyk i zdania a\b*c. 4.5 Następującą gramatykę dla instrukcji if-then-else proponujemy użyć do ominięcia niejednoznaczności „wiszącego else": instr —> if wyr then instr | dop_instr dop^instr -¥ if wyr then dop^inst | inne
else instr
Wykaż, że ta gramatyka i tak jest niejednoznaczna. *4.6 Spróbuj zaprojektować gramatyki dla poniższych języków. Które z nich są językami regularnymi? a) zbiór wszystkich napisów złożonych z 0 i 1, takich, że bezpośrednio po każdym 0 jest co najmniej jedno 1, b) napisy z 0 i 1 z równą liczbą 0 i 1, c) napisy z 0 i 1 o różnej liczbie 0 i 1, d) napisy z 0 i 1, w których nie ma podciągu 0 1 1 , e) napisy z 0 i 1 o postaci xy, gdzie x ^ y, f) napisy z 0 i 1 o postaci xx. 4.7 Zbuduj gramatykę dla wyrażeń z każdego z następujących języków: a) Pascal, b) C, c) Fortran 77, d) Ada, e) Lisp. 4.8 Zbuduj jednoznaczną gramatykę dla instrukcji z każdego z języków wymienionych w ćwiczeniu 4.7. 4.9 W prawych stronach produkcji gramatyki możemy używać operatorów podobnych do operatorów dla języków regularnych. Nawiasów kwadratowych można używać do zapisania opcjonalnej części produkcji. Przykładowo, moglibyśmy napisać instr —• if wyr then instr[e\se
instr]
aby opisać opcjonalną część else. Uogólniając, A —> a[j3]y jest równoważne dwóm produkcjom: A
aj3y i A -> a y .
Nawiasy klamrowe mogą być używane do opisywania frazy, która może być po wtórzona zero lub więcej razy. Na przykład
instr —y begin instr{\ instr}
end
opisuje listę oddzielonych średnikami instr zawartych między begin a end. Uogól niając, A ->• a{f5}y jest równoważne A - » aBy i i? j3Z? | e. W pewnym sensie [j3] oznacza wyrażenie regularne /3 | e, a {j3} oznacza j6*. Notację tę możemy uogólnić tak, aby po prawej stronie produkcji móc umieszczać dowolne wyrażenie regularne zbudowane z symboli gramatyki. a) Zmodyfikuj powyższą w-srr-produkcję tak, aby po prawej stronie produkcji była lista instr zakończona średnikiem. b) Podaj zbiór produkcji gramatyki bezkontekstowej generujący taki sam zbiór napisów, co A —• B * a(C\D). c) Pokaż, jak zastąpić dowolną produkcję A r, gdzie r jest wyrażeniem regular nym, skończonym zbiorem produkcji bezkontekstowych. 4.10
Poniższa gramatyka generuje deklaracje pojedynczego identyfikatora instr lista-opcji opcja typ przecinek dokładność podstawa
—y declare id lista- opcji —» lista-opcji opcja \ e —>• typ | przecinek | dokładność -> real | compIex -> fixed | floating —• single | double -» binary | decimal
\ podstawa
a) Pokaż, jak można uogólnić tę gramatykę, aby pozwalała używać n opcji 1 ^ i ^ n, z których każda może być równa a- lub b
A
h
v
b) Powyższa gramatyka pozwala tworzyć nadmiarowe bądź sprzeczne deklaracje, takie jak declare
zap
real
fixed
real
floating
Moglibyśmy nalegać, aby składnia języka zabraniała takich deklaracji. Wów czas otrzymalibyśmy skończony zbiór poprawnych składniowo ciągów symboli leksykalnych. Oczywiście takie legalne deklaracje tworzą język bezkontekstowy, a nawet zbiór regularny. Napisz gramatykę dla deklaracji z n opcjami, z których każda może wystąpić co najwyżej jeden raz. **c) Pokaż, że gramatyka z punktu b) musi mieć co najmniej 2 symboli. n
d) Czego z punktu c) dowiadujemy się o realności wymuszania nienadmiarowości i niesprzeczności opcji deklaracji przez definicję składni języka? 4.11
a) Usuń lewostronną rekurencję z gramatyki z ćwiczenia 4 . 1 . b) Zbuduj analizator przewidujący dla gramatyki z punktu a). Przedstaw zacho wanie analizatora dla zdań z ćwiczenia 4.1 b).
4.12 4.13
Zbuduj metodą zejść rekurencyjnych analizator z nawrotami dla gramatyki z ćwi czenia 4.2. Czy potrafisz zbudować analizator przewidujący dla tej gramatyki? Gramatyka S -» aSa
I aa
generuje wszystkie napisy złożone z a o parzystej długości z wyjątkiem napisu pustego. a) Napisz metodą zejść rekurencyjnych z nawrotami analizator dla tej gramatyki, który najpierw próbuje aSa, a potem aa. Pokaż, że procedura dla S zadziała dla 2, 4 lub 8 a, ale nie działa dla 6 a. *b) Jaki język jest rozpoznawany przez Twój analizator? 4.14 Zbuduj analizator przewidujący dla gramatyki z ćwiczenia 4.3. 4.15 Zbuduj analizator przewidujący dla gramatyki jednoznacznej dla wyrażeń regular nych z ćwiczenia 4.4. *4.16 Wykaż, że gramatyka z lewostronną rekurencją nie może być LL(1). *4.17 Wykaż, że gramatyka LL(1) nie może być niejednoznaczna. 4.18 Wykaż, że gramatyka bez e-produkcji, w której każda alternatywa zaczyna się od innego terminala, zawsze jest LL(1). 4.19 Symbol X z gramatyki jest bezużyteczny,
jeśli nie ma wyprowadzenia o postaci
S 4 > wXy = 5 > wxy, czyli gdy X nigdy nie może wystąpić w wyprowadzeniu które gokolwiek zdania. *a) Napisz algorytm eliminujący wszystkie produkcje zawierające bezużyteczne symbole z gramatyki. b) Zastosuj swój algorytm do gramatyki S -¥ 0 \ A A AB B -¥ 1 4.20 Mówimy, że gramatyka jest e-wolna, jeśli nie ma e-produkcji albo istnieje dokładnie jedna e-produkcja, S -> e, i wtedy symbol startowy S nie występuje po prawej stronie którejkolwiek produkcji. a) Napisz algorytm przekształcający daną gramatykę w równoważną gramatykę e-wolną. Podpowiedz. Najpierw wyznacz wszystkie nieterminale, które mogą generować napis pusty. b) Zastosuj swój algorytm do gramatyki z ćwiczenia 4.2. 4.21 Produkcja pojedyncza
to taka, która po prawej stronie ma pojedynczy nieterminal.
a) Napisz algorytm przekształcający daną gramatykę w równoważną gramatykę bez pojedynczych produkcji. b) Zastosuj swój algorytm do gramatyki dla wyrażeń (4.10). 4.22 Gramatyka wolna od cykli nie ma wyprowadzeń o postaci A ^ A dla jakiegokol wiek nieterminala A. a) Napisz algorytm przekształcający daną gramatykę w równoważną gramatykę wolną od cykli. b) Zastosuj swój algorytm do gramatyki S —> SS \ (S) | e. 4.23 a) Używając gramatyki z ćwiczenia 4 . 1 , zbuduj prawostronne wyprowadzenie dla (a, (a, a)) i pokaż uchwyt każdej prawostronnej formy zdaniowej.
b) Przedstaw kroki wykonywane przez analizator redukujący odpowiadające pra wostronnemu wyprowadzeniu z punktu a). c) Przedstaw kroki we wstępującej konstrukcji drzewa wyprowadzenia podczas redukującej analizy z punktu b). 4.24 Na rysunku 4.60 przedstawiono relacje priorytetów operatorów dla gramatyki z ćwiczenia 4 . 1 . Korzystając z tych relacji priorytetów, przeanalizuj zdania z ćwi czenia 4.1 b). a
(
a
(
> <
»
< <
< <
>
$ >
<
<
)
$
)
> >
> >
>
Rys. 4.60. Relacje priorytetów operatorów dla gramatyki z ćwiczenia 4.1
4.25 Znajdź funkcje priorytetów operatorów dla tablicy z rys. 4.60. 4.26 Istnieje mechaniczna metoda tworzenia relacji priorytetów operatorów z grama tyki operatorowej, włączając te z wieloma różnymi nieterminalami. Zdefiniujmy początkowe(A) dla nieterminala A jako zbiór terminali A takich, że a jest skraj nym lewym terminalem w pewnym napisie wyprowadzalnym z A, oraz zdefiniujmy końcowe(A) jako zbiór terminali, które mogą być skrajnymi prawymi terminalami w którymś z napisów wyprowadzalnych z A. Wówczas dla terminali a i b mówimy, że a = b, jeśli istnieje prawa strona o postaci aafiby, gdzie fi jest albo puste, albo jest pojedynczym nieterminalem, a a i y są dowolne. Mówimy, że a<-b, jeśli ist nieje prawa strona o postaci aaAfł i b jest w początkowe(A), oraz, że a-> b, jeśli istnieje prawa strona o postaci (xAbj5 i a jest w końcowe(A). W obu przypadkach a i P są dowolnymi napisami. Ponadto, $ < b dla b z początkowe(S), gdzie S jest symbolem startowym i a-> $ dla a z końcowe(S). a) Dla gramatyki z ćwiczenia 4.1 oblicz początkowe
i foncow dla 5 i T.
b) Sprawdź, że relacje priorytetów z rys. 4.60 są wyprowadzone z tej gramatyki. 4.27 Stwórz relacje priorytetów operatorów dla następujących gramatyk: a) gramatyki z ćwiczenia 4.2, b) gramatyki z ćwiczenia 4.3, c) gramatyki dla wyrażeń (4.10). 4.28 Napisz analizator dla wyrażeń regularnych metodą pierwszeństwa operatorów. 4.29 O gramatyce mówimy, że jest (jednoznacznie odwracalną) gramatyką pierwszeń stwa operatorów, jeśli jest gramatyką operatorową, w której nie ma dwóch prawych stron o takim samym układzie nieterminali, i stosując metodę z ćwiczenia 4.26, ustalamy, że dowolna para elementów jest w co najwyżej jednej relacji. Które z gramatyk z ćwiczenia 4.27 są gramatykami pierwszeństwa operatorów?
4.30 O gramatyce mówimy, że jest w postaci normalnej Greibach (GNF, z ang. Greibach normal form), jeśli jest e - w o l n a i każda z produkcji (z wyjątkiem S —> e, jeśli taka istnieje) m a postać A -> aa, gdzie a jest terminalem, a a jest ciągiem nieterminali, być może pustym. **a) Napisz algorytm przekształcający daną gramatykę w równoważną gramatykę o postaci normalnej Greibach. b) Zastosuj swój algorytm do gramatyki dla wyrażeń (4.10). *4.31 Wykaż, że każda gramatyka może zostać przekształcona w równoważną gramatykę operatorową. Podpowiedz. Najpierw przekształć gramatykę do postaci normalnej Greibach. *4.32 Wykaż, że każdą gramatykę można przekształcić w gramatykę operatorową, w któ rej każda produkcja ma jedną z postaci A ->• aBcC
A
aBb
A-*aB
A^r a
Jeśli e jest w języku, to S -» e też jest produkcją. 4.33 Rozważmy niejednoznaczną gramatykę: S -> AS | b A->SA\a a) Zbuduj rodzinę zbiorów sytuacji LR(0) dla tej gramatyki. b) Zbuduj N A S , w którym każdy stan jest sytuacją LR(0) z punktu a). Wykaż, że graf przejść kanonicznej rodziny sytuacji LR(0) dla tej gramatyki jest taki sam, jak DAS zbudowany z NAS przy użyciu konstrukcji podzbiorów. c) Zbuduj tablicę analizatora SLR, korzystając z algorytmu 4.8. d) Przedstaw wszystkie ruchy dozwolone przez tablicę z punktu c) dla wejścia abab. e) Zbuduj kanoniczną tablicę analizatora. f) Zbuduj tablicę analizatora LALR, używając algorytmu 4.11. g) Zbuduj tablicę analizatora LALR, używając algorytmu 4.13. 4.34 Zbuduj tablicę analizatora SLR dla gramatyki z ćwiczenia 4.3. 435
Rozważmy następującą gramatykę: E -> E + T | T T T F \ F F -¥ F * | a \ b a) Zbuduj tablicę analizatora SLR dla tej gramatyki. b) Zbuduj tablicę analizatora LALR.
4.36 Skompresuj tablice zbudowane w ćwiczeniach 4.33, 4.34 i 4.35, zgodnie z meto dami z p. 4.7. 4.37 a) Wykaż, że następująca gramatyka: S -> AaAb | BbBa A ->e B € jest LL(1), ale nie S L R ( l ) . **b) Wykaż, że każda gramatyka LL(1) jest gramatyką LR(1).
*4.38 Wykaż, że gramatyka LR(1) nie może być niejednoznaczna. 4.39 Wykaż, że następująca gramatyka: S A
Aa | bAc | dc | bda d
jest L A L R ( l ) , ale nie S L R ( l ) . 4.40 Wykaż, że następująca gramatyka: S -¥ Aa | bAc | Bc \ bBa A-> d B d jest LR(1), ale nie L A L R ( l ) . *4.41 Rozpatrzmy rodzinę gramatyk G
n
S -> A b A -» ajAi \aj i
1< i 1 ^ i, j ^ n
i
i
zdefiniowaną
oraz
2
j ^ i n
2
a) Wykaż, że G m a 2n — n produkcji i 2 +n + n zbiorów sytuacji LR(0). Co ten wynik mówi o możliwej wielkości tablic analizatorów LR w stosunku do wielkości gramatyki? n
b) Czy G
n
jestSLR(l)?
c) Czy G jest L A L R ( l ) ? n
4.42 Napisz algorytm obliczający dla każdego nieterminala A z gramatyki zbiór nieter minali B takich, że A =^ Ba dla pewnego ciągu a symboli z gramatyki. 4.43 Napisz algorytm wyznaczający dla każdego nieterminala A z gramatyki zbiór ter minali a takich, że A aw dla pewnego ciągu terminali w, gdzie ostatni krok wyprowadzenia nie używa e-produkcji. 4.44 Zbuduj tablicę analizatora SLR dla gramatyki z ćwiczenia 4.4. Rozstrzygnij wszyst kie konflikty akcji analizatora tak, aby wyrażenia regularne były normalnie wy prowadzane. 4.45 Zbuduj analizator SLR dla gramatyki (4.7) z „wiszącym else", traktując wyr jako terminal. Rozstrzygnij konflikty akcji w zwykły sposób. 4.46 a) Zbuduj tablicę analizatora SLR dla gramatyki E ^ E sub R | E sup E \ {E} \ c R -» E sup E | E Rozstrzygnij konflikty akcji analizatora, aby wyrażenia były wyprowadzane tak, jak w analizatorze LR z rys. 4.52. b) Czy każdy konflikt redukcja/redukcja generowany podczas budowy tablicy ana lizatora LR można przekształcić w konflikt przesunięcie/redukcja poprzez prze kształcenie gramatyki? *4.47 Zbuduj gramatykę LR równoważną gramatyce do składu wyrażeń arytmetycznych (4.25) tak, żeby wyrażenia o postaci E sub E sup E były wyróżnione jako spe cjalny przypadek.
4.48 Rozważmy następującą gramatykę niejednoznaczną dla n dwuargumentowych ope ratorów inriksowych: E -> E 6 E | E 6 E | • • • | E S E \ (E) | id X
n
2
Przyjmij, że wszystkie operatory są lewostronnie łączne i że B m a priorytet wyższy niż Oj, jeśli i > j . l
a) Zbuduj zbiory sytuacji SLR dla tej gramatyki. Jak wiele zbiorów powstaje w zależności od n? b) Zbuduj tablicę analizatora SLR dla tej gramatyki i skompresuj ją, używając listowej reprezentacji z p. 4.7. Jaka jest sumaryczna długość wszystkich list używanych w tej reprezentacji, w zależności od n? c) Ilu kroków wymaga wyprowadzenie id 9 id 0- id? t
*4.49 Powtórz ćwiczenie 4.48 dla gramatyki jednoznacznej \ ~* \ i 2 I 2 E ~ y % ^2 3 J 3 E
E
0
E
E
E
E
E
2
E
n
-> E
n
6 E n
n+X
|
E
n+X
Co o relatywnej wydajności analizatorów równoważnych gramatyk jednoznacznych i niejednoznacznych mówią Twoje odpowiedzi na pytania z ćwiczeń 4.48 i 4.49? A co o relatywnej wydajności budowania analizatorów? 4.50 Napisz program Yacca, który jako wejście będzie otrzymywał wyrażenie arytme tyczne, a na wyjściu będzie produkował odpowiadające mu wyrażenie postfiksowe. 4.51 Napisz program „kalkulatora" w Yaccu, który będzie obliczał wartości wyrażeń logicznych. 4.52 Napisz program w Yaccu, który jako wejście przyjmie wyrażenie regularne, a jako wyjście wypisze jego drzewo wyprowadzenia. 4.53 Prześledź ruchy, które wykonałyby analizatory: przewidujący, napisany metodą pierwszeństwa operatorów, oraz L R z ćwiczeń 4.20, 4.32 i 4.50 dla następujących niepoprawnych wejść: a) ( i d - h ( * id )
b) * + id ) + ( i d * *4.54 Zbuduj metodą pierwszeństwa operatorów i LR analizatory poprawiające błędy dla następującej gramatyki: instr
if e then instr | if e then instr else instr | while e d o instr | begin lista end
I
s
lista —> lista ; instr I instr
*4.55 Gramatykę z ćwiczenia 4.54 można przerobić do postaci LL poprzez zastąpienie produkcji dla lista przez lista -> instr^ lista' lista' ; instr | e Zbuduj analizator przewidujący poprawiający błędy dla poprawionej gramatyki. 4.56 Przedstaw zachowanie swoich analizatorów z ćwiczeń 4.54 i 4.55 dla niepopraw nych wejść a) if e then s ; if e then s end b) while e do begin s ; if e then s ; end 4.57 Napisz analizatory: przewidujący, metodą pierwszeństwa operatorów i LR z odzy skiwaniem kontroli w trybie paniki dla gramatyk z ćwiczeń 4.54 i 4.55, używając średnika i end jako symboli synchronizujących. Przedstaw zachowanie swoich ana lizatorów dla niepoprawnych wejść z ćwiczenia 4.56. 4.58 W podrozdziale 4.6 zaproponowaliśmy opartą na grafach metodę wyznaczania zbioru ciągów, które mogą zostać zdjęte ze stosu w kroku redukcji analizatora napisanego metodą pierwszeństwa operatorów. *a) Podaj algorytm wyznaczania wyrażenia regularnego opisującego wszystkie takie ciągi. b) Podaj algorytm wyznaczania, czy zbiór takich ciągów jest skończony, czy nie, i jeśli tak, to wypisujący wszystkie ciągi. c) Zastosuj swoje algorytmy z punktów a) i b) do gramatyki z ćwiczenia 4.54. **4.59 Omawiając analizatory z rys. 4.18, 4.28 i 4.53 poprawiające błędy, twierdziliś my, że dowolne poprawianie błędu musi zakończyć się usunięciem co najmniej jednego symbolu z wejścia bądź zmniejszeniem stosu, jeśli osiągnięto koniec wejścia. Jednakże nie wszystkie wybrane przez nas metody poprawiania powo dowały natychmiastowe usuwanie symbolu z wejścia. Czy możesz udowodnić, że pętle nieskończone nie są możliwe dla analizatorów z rys. 4.18, 4.28 i 4.53? Podpowiedz. Warto zauważyć, że w analizatorze napisanym metodą pierw szeństwa operatorów kolejne terminale na stosie są ze sobą w relacji ^ •, nawet jeśli wystąpiły błędy. Dla analizatorów LR stos zawiera prefiks żywotny, nawet w obecności błędów. **4.60 Podaj algorytm wyznaczania nieosiągalnych pozycji w tablicach analizatorów prze widujących, napisanych metodą pierwszeństwa operatorów i LR. 4.61 Analizator LR z rysunku 4.53 obsługuje cztery sytuacje, w których najwyższym stanem jest 4 lub 5 (co zdarza się, gdy na wierzchołku stosu jest, odpowiednio, + lub *), a następnym symbolem z wejścia jest -f lub *, w dokładnie ten sam sposób: wywołując procedurę e l , która pomiędzy nie wstawia id. Łatwo możemy sobie wyobrazić analizator LR do wyrażeń z pełnym zestawem operatorów arytmetycz nych zachowujący się w taki sam sposób: wstawiający id pomiędzy sąsiadujące operatory. W pewnych językach (takich jak PL/1 lub C, ale nie Fortran ani Pascal) rozsądnie byłoby traktować w specjalny sposób przypadek, w którym na wierz chołku stosu jest / , a * jest na wejściu. Dlaczego? Jakie mogło by być rozsądne zachowanie procedury poprawiającej błędy?
4.62 Mówimy, że gramatyka jest o postaci normalnej Chomsky'ego, i każda nie-e-produkcja ma postać A —>• BC lub A -> a.
jeśli jest e-wolna
*a) Podaj algorytm przekształcający gramatykę w równoważną jej gramatykę o po staci normalnej Chomsky'ego. b) Zastosuj swój algorytm d o gramatyki dla wyrażeń (4.10). 4.63 Mając daną gramatykę G o postaci normalnej Chomsky'ego i tekst wejściowy w = a a ---a , napisz algorytm sprawdzający, czy w jest w L(G). Podpowiedz. Użyj programowania dynamicznego do wypełnienia tablicy T o rozmiarze n x n, w której T[i, j] ~ {A \ A 4> afl - -aj}. Tekst w z wejścia jest w L(G) wtedy i tylko wtedy, gdy S jest w 7 [ 1 , n]. *4.64 a) Mając daną gramatykę G o postaci normalnej Chomsky'ego, pokaż jak do gra matyki dodać produkcje dla błędów polegających na pojedynczym wstawieniu, usunięciu lub mutacji, aby powiększona gramatyka generowała wszystkie moż liwe ciągi symboli. b) Zmodyfikuj algorytm analizatora z ćwiczenia 4.63 tak, aby po otrzymaniu do wolnego napisu w znajdował on wyprowadzenie w o najmniejszej liczbie pro dukcji związanych z błędami. 4.65 Napisz w Yaccu analizator dla wyrażeń arytmetycznych używający mechanizmów obsługi błędów z przykładu 4.50. x
2
n
i+x
UWAGI B I B L I O G R A F I C Z N E W bardzo ważnym raporcie o Algolu 60 (Naur [1963]) użyto Postaci Backusa-Naura (BNF) do definiowania składni dużego języka programowania. Równoważność B N F i gramatyk bezkontekstowych została szybko zauważona, a teoria języków formalnych w latach 60. przyciągała wiele uwagi. Hopcroft i Ullman [1979] opisali podstawy tej dziedziny. Metody analizy zostały znacząco usystematyzowane po opracowaniu gramatyk bez kontekstowych. Wymyślono różne ogólne techniki pozwalające na analizę dowolnej gra matyki bezkontekstowej. Jedną z najwcześniejszych jest algorytm programowania dyna micznego, zasugerowany w ćwiczeniu 4.63, który niezależnie odkryli J. Cocke [1970], Younger [1967] i Kasami [1965], W swoim doktoracie Earley [1970] również opracował uniwersalny algorytm analizy dla wszystkich gramatyk bezkontekstowych; A h o i Ullman [1972b i 1973a] opisali szczegółowo te i inne metody. W kompilatorach stosowano wiele różnych metod analizy. Sheridan [1959] opisał metodę analizy używaną w pierwszym kompilatorze Fortranu, który wprowadzał dodat kowe nawiasy wokół argumentów, aby móc analizować wyrażenia. Pojęcie priorytetów operatorów i użycie funkcji priorytetów pochodzi od Floyda [1963]. W latach 60. za proponowano dużą liczbę wstępujących metod analizy. Niektóre z nich to pierwszeństwo proste (Wirth i Weber [1966]), ograniczony kontekst (Floyd [1964], Graham [1963]), strategia mieszanych priorytetów (McKeeman, Horning i Wortman [1970) i słabe pierw szeństwo (Ichbiah i Morse [1970]). Metoda zejść rekurencyjnych i analizatory przewidujące są powszechnie używa ne. Ze względu na elastyczność metoda zejść rekurencyjnych była stosowana w wielu
wczesnych systemach pisania kompilatorów, takich j a k META (Schorre [1964]) i T M G (McClure [1965]). Rozwiązanie ćwiczenia 4.13 można znaleźć u Birmana i Ullmana [1973], razem z opisem teorii tej metody analizy. Pratt [1973] proponował zstępującą metodę opartą na priorytetach operatorów. Gramatyki L L były badane przez Lewisa i Stearnsa [1968], a ich własności opisali Rosenkrantz i Stearns [1970]. Analizatory przewidujące były dogłębnie badane przez Knutha [ 1 9 7 l a ] . Lewis, Rosenkrantz i Stearns [1976] opisali wykorzystanie analizato rów przewidujących w kompilatorach. Algorytmy przekształcania gramatyki do postaci LL(1) są przedstawione u Fostera [1968], Wooda [1969], Stearnsa [1971] oraz Soisalona-Soininena i Ukkonena [1979]. Gramatyki L R i ich analizatory zaproponował Knuth [1965], który opisał budo wę kanonicznych tablic analizatorów LR. Metoda L R nie była uważana za praktycz ną aż do czasu, gdy Korenjak [1969] pokazał, że za jej pomocą można tworzyć analizatory, rozsądnych rozmiarów, do gramatyk języków programowania. Gdy DeRemer [1969, 1971] wymyślił metody SLR i LALR, które są prostsze niż Korenjaka, technika LR została główną metodą wykorzystywaną przez generatory ana lizatorów. Dziś generatory analizatorów LR są powszechnie spotykane w środowiskach budowy kompilatorów. Wiele badań poświęcono budowie analizatorów LR. Wykorzystywanie niejedno znacznych gramatyk L R pochodzi od Aho, Johnsona i Ullmana [1975] oraz Earleya [1975a]. Usuwanie redukcji według pojedynczych produkcji opisali Anderson, Eve i Horning [1973], Aho i Ullman [1973b], Demers [1975], Backhouse [1976], Joliat [1976], Pager [1977b], Soisalon-Soininen [1980] oraz Tokuda [1981]. Techniki wyznaczania zbiorów symboli podglądanych L A L R ( l ) zaproponowali LaLonde [1971], Anderson, Eve i Horning [1973], Pager [1977a], Kristensen i Madsen [1981], DeRemer i Pennello [1982] oraz Park, Choe i Chang [1985], którzy dostarczyli również pewnych eksperymentalnych porównań. Aho i Johnson [1974] przedstawili ogólny przegląd analizy LR i opisali pewne algorytmy stosowane w generatorze analizatorów Yacc, włącznie z użyciem produkcji do obsługi błędów. A h o i Ullman [1972b i 1973a] dokładnie opisali analizę L R i jej teoretyczne podstawy. Wymyślono wiele technik obsługi błędów w analizatorach. Przegląd metod obsługi błędów znajduje się u Ciesingera [1979] i Sippu'a [1981]. Irons [1963] zaproponował metodę obsługi błędów składniowych opartą na gramatyce. Produkcje dla błędów zosta ły zastosowane przez Wirtha [1968] do obsługi błędów w kompilatorze PL360. Leinius [1970] zaproponował strategię odzyskiwania kontroli na poziomie frazy. Aho i Peterson [1972] opisali obsługę błędów globalnie najmniejszym kosztem poprzez wykorzystanie produkcji dla błędów w połączeniu z ogólnymi algorytmami analizy dla gramatyk bez kontekstowych. Mauney i Fischer [1982] rozszerzyli te pomysły do lokalnie najtańszego poprawiania dla analizatorów L L i LR, korzystając z technik analizy Grahama, Harrisona i Ruzzo [1980]. Graham i Rhodes [1975] zajęli się obsługą błędów w analizatorach napisanych metodami pierwszeństwa. Horning [1976] opisał cechy, które powinny mieć dobre komunikaty o błędach. Sippu i Soisalon-Soininen [1983] porównali wydajność metod obsługi błędów w Helsinki Language Processor (Raiha i in. [1983]) z techniką „ruchu do przodu" Pennello i De-
Remera [1978], techniką obsługi błędów w analizatorach LR Grahama, Haleya i Joya [1979] oraz techniką „globalnego kontekstu" Pai i Kieburtza [1980]. Poprawianie błędów podczas analizy opisali Conway i Maxwell [1963], Moulton i Muller [1967], Conway i Wilcox [1973], Levy [1975], Tai [1978] oraz Róhrich [1980]. U Aho i Petersona [1972] można znaleźć rozwiązanie ćwiczenia 4.63.
Translacja sterowana składnią
W tym rozdziale rozwinęliśmy zagadnienia omówione w p. 2.3, czyli translację języ ków bazującą na gramatykach bezkontekstowych. Z konstrukcjami języków programo wania wiąże się pewną informację przez dołączenie atrybutów do symboli gramatyki reprezentujących te konstrukcje. Wartości tych atrybutów są obliczane za pomocą reguł semantycznych związanych z produkcjami gramatyki. Istnieją dwie notacje służące do łączenia reguł semantycznych z produkcjami: de finicje sterowane składnią i schematy translacji. Definicje sterowane składnią są wysokopoziomową specyfikacją translacji. Ukrywają one wiele detali implementacyjnych i zwalniają użytkownika z bezpośredniego określania kolejności, w jakiej jest wyko nywana translacja. Schematy translacji natomiast wskazują kolejność obliczania reguł sematycznych i w związku z tym niektóre detale implementacyjne są w nich widoczne. Obie notacje wykorzystaliśmy do określania kontroli semantycznej (rozdz. 6), szczególnie do 'wyznaczania typów, oraz do generacji kodu pośredniego (rozdz. 8). Ogólnie, aby użyć definicji sterowanych składnią lub schematów translacji, należy zanalizować strumień symboli leksykalnych, tworząc drzewo wyprowadzenia i następnie przejść to drzewo, tak aby policzyć reguły semantyczne w jego węzłach (rys. 5.1). Obli czanie tych reguł może polegać na generacji kodu, zapisie informacji w tablicy symboli, wyświetlaniu komunikatów o błędach lub innym działaniu. Translację strumienia symboli leksykalnych otrzymuje się, obliczając reguły semantyczne.
Napis wejściowy
^
Drzewo wyprowadzenia
^
Graf zależności
^
Kolejność obliczeń reguł semantycznych
Rys. 5.1. Ogólny schemat translacji sterowanej składnią
Implementacja nie musi dosłownie odpowiadać schematowi z rys. 5.1. Pewne szcze gólne przypadki definicji sterowanych składnią mogą być zaimplementowane jako po jedynczy przebieg wykonujący wszystkie obliczenia w trakcie analizy składniowej, bez konstruowania wprost drzewa wyprowadzenia lub grafu zawierającego zależności między atrybutami. Implementacja z pojedynczym przebiegiem jest ważna ze względu na wydaj ność — krótki czas kompilacji, dlatego ten rozdział dotyczy zwłaszcza takich przypadków
szczególnych. Za pomocą jednej z ważnych podklas, zwanej definicją „L-atrybutowaną", można wykonać prawie wszystkie możliwe translacje, nie wymagające bezpośredniej konstrukcji drzewa wyprowadzenia.
5.1
Definicje sterowane składnią
Definicja sterowana składnią jest uogólnieniem gramatyki bezkontekstowej, w której z każdym symbolem jest związany pewien zbiór atrybutów. Zbiór ten jest podzielony na dwa podzbiory: atrybuty syntezowane i dziedziczone. Jeśli węzeł w drzewie wypro wadzenia dla symbolu gramatyki traktujemy jak rekord zawierający pola przechowujące informacje, to atrybut odpowiada nazwie poła rekordu. Atrybut może reprezentować dowolne wielkości: napis, liczbę, typ, adres pamięci itp. Wartość atrybutu w węźle drzewa wyprowadzenia jest zdefiniowana przez regułę se mantyczną związaną z produkcją użytą dla tego węzła. Wartość atrybutu syntezowanego jest obliczana z wartości atrybutów w dzieciach tego węzła z drzewa wyprowadzenia, natomiast wartość atrybutu dziedziczonego — na podstawie atrybutów w sąsiadach i ro dzicu węzła. Reguły semantyczne ustanawiają między atrybutami zależności, które mogą być reprezentowane za pomocą grafu. Z takiego grafu zależności wyprowadza się kolejność obliczeń reguł semantycznych. Wartości atrybutów w węzłach drzewa wyprowadzenia dla danego napisu wejściowego definiowane są w trakcie obliczania reguł semantycznych. Reguła semantyczna może mieć także efekty uboczne, na przykład wypisanie wartości lub zmiana wartości zmiennej globalnej. Implementacja nie musi oczywiście konstruować wprost drzewa wyprowadzenia lub grafu zależności, wystarczy, że będzie produkować taki sam wynik dla każdego napisu wejściowego. Drzewo wyprowadzenia z widocznymi wartościami atrybutów w każdym węźle na zywa się drzewem wyprowadzenia z przypisami, natomiast proces obliczania wartości atrybutów w węzłach — opisywaniem lub dekoracją drzewa wyprowadzenia. Postać definicji sterowanej składnią W definicji sterowanej składnią z każdą produkcją gramatyki A-> a jest związany zbiór reguł semantycznych o postaci b := f{c c , • • . , c ), gdzie / jest funkcją, oraz v
2
k
1)
b jest atrybutem syntezowanym symbolu A, a c , , c , . . . , c są atrybutami do symboli
2)
z produkcji, albo b jest atrybutem dziedziczonym jednego z symboli gramatyki z prawej strony pro dukcji, a c , , c , • • •, c są atrybutami symboli z produkcji.
2
2
k
k
W obu przypadkach mówimy, że atrybut b zależy od atrybutów c , c , . . . , c . Gramatyka atrybutowana jest definicją sterowaną składnią, w której funkcje z reguł semantycznych nie mają efektów ubocznych. Funkcje z reguł semantycznych często zapisujemy jako wyrażenia. Czasami jedy nym celem reguł semantycznych w definicji sterowanej składnią jest tworzenie efektów ubocznych. Reguły semantyczne tego typu są zapisywane jako wywołania procedur lub x
2
k
fragmenty programu. Można j e traktować jak reguły definiujące wartości sztucznych atry butów syntezowanych dla nieterminala z lewej strony produkcji (sztuczny atrybut i znak przypisania w regule semantycznej nie jest pokazywany). Przykład 5.1. Definicja sterowana składnią z rys. 5.2 jest przeznaczona dla programu kalkulatora stołowego. Ta definicja związuje wartość całkowitą atrybutu syntezowanego, nazywanego wart, z każdym z nieterminali W, S i C. Dla każdej produkcji dla W, S i C reguła semantyczna oblicza wartość atrybutu wart dla nieterminala z lewej strony produkcji z wartości wart dla nieterminali z prawej strony.
PRODUKCJA
w - > w + s w -> s S-+S +C s -> c x
{
C-> (W)
C —>• cyfra
REGUŁY SEMANTYCZNE
print(W .wart) W.wart : = W\. wart 4- S.wart W.wart := S.wart S.wart:— S .wart + C.wart S.wart := C.wart C.wart := W. wart C.wart := cyfra.lekswart x
Rys. 5.2. D e f i n i c j a s t e r o w a n a s k ł a d n i ą dla p r o s t e g o kalkulatora s t o ł o w e g o
Symbol leksykalny cyfra ma atrybut syntezowany lekswart, którego wartość do starcza analizator leksykalny. Reguła związana z produkcją L -ł W n dla nieterminala startowego L jest po prostu procedurą wypisującą wartość wyrażenia arytmetycznego wy generowanego przez W; tę regułę można traktować jako definicję sztucznego atrybutu dla nieterminala L. Specyfikacja dla Yacca dla tego kalkulatora, w celu pokazania translacji w trakcie analizy LR, została przedstawiona na rys. 4.56. • W definicji sterowanej składnią zakłada się, że terminale mają tylko atrybuty syn tezowane, ponieważ definicja ta nie zawiera żadnych reguł semantycznych dla terminali. Wartości atrybutów terminali są zwykle dostarczone przez analizator leksykalny (patrz p. 3.1). Ponadto, jeśli nie zostanie stwierdzone inaczej, zakłada się, że symbol startowy nie ma atrybutów dziedziczonych.
Atrybuty syntezowane Atrybuty syntezowane są powszechnie stosowane w praktyce. Definicja sterowana skład nią używająca jedynie atrybutów syntezowanych jest nazywana definicją S-atrybutowaną. Drzewo składniowe dla definicji S-atrybutowanej może zostać oznaczone przypisami przez obliczanie reguł semantycznych dla atrybutów w każdym węźle przechodząc od liści do korzenia. W podrozdziale 5.3 omówiliśmy sposób przystosowania generatora analizatorów LR do mechanicznej implementacji definicji S-atrybutowanej opartej na gramatyce LR. Przykład 5.2. Definicja S-atrybutowana z przykładu 5.1 specyfikuje kalkulator stoło wy wyświetlający wartość wyrażenia po wczytaniu zawierającego go wiersza kończące-
L
I W.wart=
^
n
19
+
W.wart = 15
S.wart ~ 4
I S.wart = 15
'
&uwf=3
I*
l C.wart = 4
^
i C. wart = 5
cyfra, lekswart = 4
i
I
cyfra, lekswart
Cwarf = 3
= 5
I cyfra, lekswart
= 3
Rys. 5.3. Drzewo wyprowadzenia z przypisami dla 3*5+4n
go się znakiem końca wiersza n. Wyrażenie to składa się z cyfr, nawiasów, operatorów + oraz *. Przykładowo, dla wyrażenia 3*5 + 4 zakończonego znakiem nowego wiersza program wyświetla wartość 19. Na rysunku 5.3 widać drzewo wyprowadzenia z przypi sami dla wejścia 3*5 + 4n. Wynik wypisywany w korzeniu drzewa jest wartością W.wart z pierwszego dziecka korzenia. W celu obliczenia wartości atrybutów rozważmy węzeł najbardziej lewy z do łu, odpowiadający użyciu produkcji C - ł cyfra. Odpowiadająca jej reguła semantyczna C.wart := cyfra.lekswart ustawia na 3 wartość atrybutu C.wart w tym węźle, ponieważ wartością cyfra.lekswart w dziecku tego węzła jest 3. Podobnie w rodzicu tego węzła, atrybut S.wart m a wartość 3. Rozważmy węzeł dla produkcji S —> S*C. Wartość atrybutu S.wart
w tym węźle jest
zdefiniowana przez PRODUKCJA
S —> 5, *C
R E G U Ł A SEMANTYCZNA
S.wart
:= 5, .wart
x
Gdy stosowana jest reguła semantyczna w tym węźle, S wart v
tość 3, a C.wart
z prawego dziecka ma wartość 5. Zatem S.wart
C.wart
z lewego dziecka ma war w tym węźle otrzymuje
wartość 15. Reguła związana z produkcją dla nieterminala startowego L —> W n drukuje wartość wyrażenia wygenerowanego przez W.
Atrybuty dziedziczone Wartość atrybutu dziedziczonego w węźle drzewa wyprowadzenia jest zdefiniowana na podstawie atrybutów z rodzica oraz z sąsiadów tego węzła. Atrybuty dziedziczone przy dają się do wyrażania zależności konstrukcji programistycznych od kontekstu, w jakim się pojawiają. Na przykład, atrybut dziedziczony może zostać użyty do wyznaczenia, czy potrzebny jest adres, czy wartość identyfikatora, gdy pojawia się on po lewej albo po pra-
wej stronie przypisania. Chociaż definicję sterowaną składnią zawsze można przepisać, tak aby używała tylko atrybutów syntezowanych, często bardziej naturalne jest użycie w niej atrybutów dziedziczonych. W poniższym przykładzie atrybut dziedziczony rozprowadza informację o typach do poszczególnych identyfikatorów w deklaracji. Przykład 5.3. Deklaracja generowana przez nieterminal D w definicji sterowanej skład nią z rys. 5.4 zawiera słowa kluczowe int lub real oraz występujące po nich listy identy fikatorów. Nieterminal T ma atrybut syntezowany typ, którego wartość jest wyznaczana na podstawie słowa kluczowego w deklaracji. Reguła semantyczna L.dz : = T.typ zwią zana z produkcją D^tTL ustawia atrybut dziedziczony L.dz na typ deklaracji. Następne reguły — przy użyciu L.dz — przeprowadzają tę wartość w dół drzewa wyprowadzenia. Reguły związane z produkcjami dla L wywołują procedurę dodajtyp do dodania typu dla każdego identyfikatora do jego wpisu w tablicy symboli (wskazywanego przez atrybut wpis).
PRODUKCJA
REGUŁY SEMANTYCZNE
D-+TL
L.dz '.= T.typ
jT-Mnt
T.typ \— integer
T^real
T.typ := real
L -» Lj , if
L .dz'.= L.dz dodajtyp(id.wpis,
L.dz)
dodajtyp(id.wpis,
L.dz)
L->id
x
Rys. 5.4. Definicja sterowana składnią z atrybutem dziedziczonym L.dz
Na rysunku 5.5 przedstawiono drzewo wyprowadzenia z przypisami dla zdania real id! , i d , i d . Wartość L.dz w trzech węzłach L stanowi typ dla trzech identyfikatorów i d p i d , i d . Wartości te są wyznaczone po obliczeniu wartości atrybutu T.typ w lewym dziecku korzenia i następnie — przechodząc w dół drzewa — wartości L.dz w trzech 2
2
3
3
D
L.dz = real
,
id
2
Rys. 5.5. Drzewo wyprowadzenia z atrybutem dziedziczonym dz w każdym węźle dla L
węzłach dla L w prawym poddrzewie korzenia. W każdym węźle dla L zostanie wywołana również procedura dodajtyp ustawiająca w tablicy symboli typ real dla identyfikatora z prawego dziecka tego węzła. •
Grafy zależności Jeśli atrybut b w węźle drzewa wyprowadzenia zależy od atrybutu c, to reguła dla b w tym węźle musi zostać wyliczona po regule semantycznej definiującej c. Wzajemne zależ ności między atrybutami syntezowanymi i dziedziczonymi w węzłach drzewa wy prowadzenia mogą zostać przedstawione za pomocą grafu skierowanego zwanego grafem zależności. Przed skonstruowaniem grafu zależności dla drzewa wyprowadzenia każda reguła semantyczna jest zapisywana w postaci b := f(c c , . . . , c ). Dla reguł semantycznych składających się z wywołania procedury wprowadzany jest sztuczny atrybut syntezowany b. Graf zależności ma węzły dla wszystkich atrybutów oraz krawędzie z każdego b do każdego c takie, że atrybut b zależy od atrybutu c. Konstrukcję grafu zależności dla drzewa wyprowadzenia dokładniej przedstawia poniższy algorytm. v
2
k
for każdy węzeł n w drzewie wyprowadzenia do for każdy atrybut a symbolu gramatyki z n do utwórz węzeł w grafie zależności dla a; for każdy węzeł n w drzewie wyprowadzenia do for każda reguła semantyczna b := f{c , c , - • •, c ) związana z produkcją użytą w n do for / := 1 to k do skonstruuj krawędź z węzła dla c do węzła dla b; x
2
k
(
Załóżmy na przykład, że A.a : — f(X.x,Y.y) jest regułą semantyczną dla produkcji A - » X Y . Reguła ta definiuje atrybut syntezowany A.a zależny od atrybutów X.x i Y.y. Jeśli ta produkcja jest używana w drzewie wyprowadzenia, to w grafie zależności znajdą się trzy węzły: A.a, X.x i Y.y oraz krawędzie: z A.a do X.x, ponieważ A.a zależy do X.x, i z A.a do Y.y, ponieważ A.a zależy od Y.y. Jeśli produkcja A - 4 XY miałaby związaną z nią regułę semantyczną X.i := := g(A.a,Y.y), to istniałyby krawędzie z X.i do A.a oraz do Y.y, ponieważ X.i zależy zarówno od A.a, j a k i od Y.y.
P r z y k ł a d 5.4. Jeśli poniższa produkcja jest używana w drzewie wyprowadzenia, to do grafu zależności są dodawane krawędzie przedstawione na rys. 5.6. PRODUKCJA
W ->W +W X
2
R E G U Ł A SEMANTYCZNA
W.wart := W .wart + W .wart x
2
Trzy węzły w grafie zależności, oznaczone znakiem •, reprezentują atrybuty syntezowane W.wart, W .wart i W .wart z odpowiednich węzłów drzewa wyprowadzenia. Krawędź z W.wart do W wart oznacza, że W.wart zależy od W .wart, a krawędź z W.wart do W .wart — że W.wart zależy również od W .wart. Linie przerywane oznaczają drzewo wyprowadzenia i nie są częścią grafu zależności. • x
2
v
2
x
2
Rys. 5.6. W.wart j e s t
W^.wart
syntezowane z
oraz
W .wart 2
P r z y k ł a d 5.5. Na rysunku 5.7 widać graf zależności dla drzewa wyprowadzenia z rys. 5.5. Węzły w grafie zależności są oznaczone liczbami, które będą używane do ich numeracji. Z węzła 4 dla T.typ istnieje krawędź do węzła 5, dla L.dz, ponieważ atrybut dziedziczony L.dz zależy od atrybutu T.typ z reguły L.dz '•= T.typ dla produkcji D —> TL. Pojawiają się dwie krawędzie prowadzące do węzłów 7 i 9, ponieważ L .dz zależy od L.dz w regule semantycznej L dz := L.dz dla produkcji L ->• L , id. Każda z reguł semantycznych dodajtyp(id.wpis, L.dz) związana z produkcjami dla L powodu j e utworzenie sztucznego atrybutu. Węzły 6, 9 i 10 są tworzone dla tych sztucznych atrybutów. • x
v
x
D
dz
id! Rys.
1
L
8
1 wpis
5.7. G r a f z a l e ż n o ś c i dla d r z e w a s k ł a d n i o w e g o z rys. 5.5
Kolejność obliczeń Porządek topologiczny w acyklicznym grafie skierowanym jest uporządkowaniem węzłów grafu m , m , . . -, m , dla którego wszystkie krawędzie grafu prowadzą od wcześniejszych węzłów do późniejszych w tym porządku. Inaczej mówiąc, jeśli m- —> rrij jest krawędzią z m do m •, to w tym porządku m występuje przed m . Każdy porządek topologiczny w grafie zależności dostarcza prawidłowej kolej ności obliczeń reguł semantycznych związanych z węzłami drzewa wyprowadzenia. W porządku topologicznym atrybuty zależne c c , . . . , c z reguły semantycznej b : = / ( c p c , . . . , c ) muszą być dostępne w węźle przed rozpoczęciem obliczania funkcji / . Translacja specyfikowana przez definicję sterowaną składnią może zostać sprecy zowana w następujący sposób. Gramatyka, na której ta definicja bazuje, jest używana x
2
k
t
i
}
v
2
k
2
k
do konstrukcji drzewa wyprowadzenia dla wejścia. Graf zależności jest konstruowany w sposób omówiony powyżej. Z porządku topologicznego w grafie zależności otrzymu jemy kolejność obliczeń dla reguł semantycznych. Obliczenie reguł semantycznych w tej kolejności daje translację napisu wejściowego.
P r z y k ł a d 5.6. Każda z krawędzi w grafie zależności z rys. 5.7 prowadzi z węzła o niż szej numeracji do węzła o wyższej numeracji. Zatem porządek topologiczny w grafie zależności można otrzymać, zapisując węzły w porządku przypisanych im numerów. Ko rzystając z tego porządku, otrzymujemy następujący program. Niech a będzie atrybutem związanym z węzłem o numerze n w grafie zależności n
a : = real; 4
:
dodaj typ(id .wpis,
a );
dodajtyp(id >wpis, a a ; dodaj typ(id . wpis,
a );
3
2
9
5
7
7
{
a )\ 9
Obliczając te reguły semantyczne, zapisujemy typ real we wpisach w tablicy symboli dla każdego identyfikatora. • Istnieje kilka metod obliczania reguł semantycznych: 1.
2.
3.
Metody bazujące na drzewach wyprowadzenia. W czasie kompilacji metody te wyznaczają kolejność obliczeń na podstawie porządku topologicznego z grafu zależności dla drzewa wyprowadzenia dla każdego wejścia. Metody te nie za działają jedynie wtedy, gdy graf zależności dla rozważanego drzewa wyprowadzenia ma cykl. Metody bazujące na regułach. W czasie konstrukcji kompilatora reguły semantyczne związane z produkcjami są analizowane ręcznie lub przy użyciu wyspecjalizowanych narzędzi. Dla każdej produkcji porządek obliczeń atrybutów z nią związanych jest wyznaczany w trakcie konstrukcji kompilatora. Metody bez pamięci (ang. oblivious). Kolejność obliczeń jest ustalana bez roz ważania reguł semantycznych. Przykładowo, jeśli translacja odbywa się pod czas analizy składniowej, to porządek obliczeń jest narzucany przez samą meto dę analizy składniowej, niezależnie od reguł semantycznych. Taka kolejność obliczeń ogranicza klasę definicji sterowanych składnią, które mogą zostać zaim plementowane.
Metody bazujące na regułach i metody bez pamięci nie konstruują wprost grafu zależności w trakcie kompilacji, więc mogą być bardziej wydajne pod względem czasu kompilacji i wykorzystania pamięci. Definicje sterowane składnią są nazywane cyklicznymi, jeśli graf zależności dla pewnego drzewa wyprowadzenia, generowanego przez jej gramatykę, ma cykl. W pod rozdziale 5.10 omówiliśmy sposób sprawdzenia, czy definicja sterowana składnią jest cykliczna.
5.2
Konstrukcja drzew składniowych
W tym podrozdziale omówiliśmy użycie definicji sterowanych składnią do specyfikacji konstrucji drzew składniowych i innych graficznych reprezentacji konstrukcji językowych. Wykorzystanie drzew składniowych jako reprezentacji pośrednich umożliwia od dzielenie translacji od analizy składniowej. Procedury translacji wywoływane w trakcie analizy składniowej mają dwa rodzaje ograniczeń. Po pierwsze, gramatyka odpowiednia do analizy składniowej nie musi odpowiadać naturalnej strukturze hierarchicznej kon strukcji języka. Na przykład gramatyka dla Fortranu może traktować podprogram jako, po prostu, listę instrukcji. Jednak analiza tego podprogramu może być łatwiejsza, jeśli użyjemy reprezentacji odzwierciedlającej zagnieżdżenia pętli DO. Po drugie, metoda ana lizy składniowej narzuca kolejność, w której są rozważane węzły drzewa wyprowadzenia. Ta kolejność może nie odpowiadać kolejności, w której informacja o konstrukcjach staje się dostępna. Z tego powodu kompilatory C zwykle konstruują drzewa składniowe dla deklaracji. Drzewa składniowe (Abstrakcyjne) drzewo składniowe jest skondensowaną postacią drzewa wyprowadzenia przydatną do reprezentacji konstrukcji języka. Produkcja S —> if B then S else S w drze wie wyprowadzenia może pojawić się jako {
2
if-then-else B
S\
S
2
W drzewie składniowym operatory i słowa kluczowe nie pojawiają się jako liście, ale raczej są związane z węzłami wewnętrznymi, które byłyby rodzicami tych liści w drzewie wyprowadzenia. Innym uproszczeniem w drzewach składniowych jest to, że łańcuchy pojedynczych produkcji mogą być zwinięte. Drzewo wyprowadzenia z rys. 5.3 przyjmuje następującą postać drzewa składniowego: +
3
5
Translacja sterowana składnią może bazować na drzewach składniowych lub drze wach wyprowadzeń. W obu przypadkach podejście jest takie samo i polega na umiesz czeniu atrybutów w węzłach drzewa składniowego. Konstrukcja drzew składniowych dla wyrażeń Konstrukcja drzew składniowych dla wyrażenia jest podobna do translacji wyrażenia do notacji postfiksowej. Poddrzewa dla podwyrażeń są konstruowane przez stworzenie węzła dla każdego operatora i argumentu. Dziećmi węzła dla operatora są korzenie poddrzew reprezentujących podwyrażenia będące argumentami tego operatora.
Każdy węzeł w drzewie składniowym może zostać zaimplementowany jako rekord z kilkoma polami. W węźle dla operatora jedno pole identyfikuje operator, a pozostałe pola zawierają wskaźniki d o węzłów argumentów tego operatora. Operator często jest nazywany etykietą tego węzła. Jeśli drzewo składniowe jest używane do translacji, to jego węzły mogą zawierać dodatkowe pola przechowujące wartości (lub wskaźniki do wartości) atrybutów związanych z tym węzłem. W tym podrozdziale użyliśmy następują cych funkcji do stworzenia węzłów w drzewie składniowym dla wyrażeń z operatorami dwuargumentowymi; każda funkcja zwraca wskaźnik do nowoutworzonego węzła: 1)
twwęzeł(op,
lewy, prawy)
tworzy węzeł operatora z etykietą op i dwoma polami
2)
zawierającymi wskaźniki do lewy i prawy, twliść(id, wpis) tworzy węzeł identyfikatora z etykietą id i polem zawierającym wpis, czyli wskaźnik do wpisu w tablicy symboli dla tego identyfikatora,
3)
fw/iic(liczba, wart) tworzy węzeł dla liczby z etykietą liczba i polem zawierającym wart, czyli wartość tej liczby.
Przykład 5.7. Następujący ciąg wywołań funkcji tworzy drzewo składniowe z rys. 5.8 dla wyrażenia a - 4 + c . W poniższych wywołaniach p , /> >*-> P5 3 wskaźnikami do węzłów, a wpisa i wpisc są wskaźnikami do wpisów w tablicy symboli dla identyfikatorów s
{
a
2
1 c. (1) (2) (3)
= twliść(id, wpisa); Pi P = fvv/wc(liczba, 4 ) ; p pY P = twwęzeł('-', 2
v
3
(4)
p := twliść(l&,
(5)
p :=
Ą
5
ftvwfzrf('+',
wpisc); p
p );
v
4
2
Drzewo jest konstruowane metodą wstępującą. Wywołania funkcji twliść(id, wpisa) i rw//ic(liczba, 4) tworzą liście dla a i 4. Wskaźniki do tych węzłów są zapamiętywane przy użyciu p i p Wywołanie twwęzeł( -', p P ) konstruuje następnie wewnętrzny węzeł mający jako dzieci liście dla a i 4. Po następnych dwóch krokach p wskazuje na korzeń. (
x
v
v
2
5
Do wpisu dla a
Rys. 5.8. Drzewo składniowe dla a-4+c Definicja sterowana składnią do konstrukcji drzew składniowych Na rysunku 5.9 przedstawiono definicję S-atrybutowaną do konstrukcji drzewa składnio wego dla wyrażenia zawierającego operatory + i - . Gramatyka, na której bazuje, wyko rzystywana jest do uszeregowania wywołań funkcji do konstrukcji drzewa, czyli twwęzeł
i twliść. Atrybut syntezowany wwsk dla W i S przechowuje wskaźniki zwracane przez wywołania funkcji.
PRODUKCJA w-+w
+ S
x
W ->w - S W ->S S-> (W) S -> id S -¥ liczba x
Rys.
5.9.
REGUŁY SEMANTYCZNE W.wwsk := twwęzeł ('+', W . wwsk, S.wwsk) W.wwsk \— twwęzeł ('-', W .wwsk, S.wwsk) W. wwsk:— S.wwsk S.wwsk := W.wwsk S. wwsk := twliść(id, id.wpis) S.wwsk : = nv/tfć*(liczba, liczba.wart) x
x
D e f i n i c j e s t e r o w a n e s k ł a d n i ą d o konstrukcji d r z e w a s k ł a d n i o w e g o d l a w y r a ż e n i a
Przykład 5.8. Drzewo wyprowadzenia z przypisami wykorzystane do konstrukcji drze wa składniowego dla wyrażenia a - 4 + c jest przedstawione na rys. 5.10 (linie przerywa ne). Węzły w drzewie wyprowadzenia oznaczone nieterminalami W i S używają atrybutu syntezowanego wwsk do utrzymania wskaźnika do węzła drzewa składniowego dla wy rażenia reprezentowanego przez ten nieterminal.
Do wpisu dla c Do wpisu dla a Rys.
5.10.
Konstrukcja drzewa składniowego dla
a-4+c
Reguły semantyczne związane z produkcjami S ->• id i S —• liczba definiują atrybut S.wwsk jako wskaźnik do nowego liścia dla identyfikatora lub liczby. Atrybuty id.wpis i liczba.wart są wartościami leksykalnymi, o których zakłada się, że są dostarczane przez analizator leksykalny podczas zwracania symboli leksykalnych id i liczba. Na rysunku 5.10, gdy wyrażenie W jest pojedynczym składnikiem, czyli stosowana była produkcja W —> S, atrybut W.wwsk otrzymuje wartość S.wwsk. Gdy wywoływana jest reguła semantyczna W.wwsk := twwęzeł('- , W .wwsk S.wwsk) związana z produkcją W -> W -S, wskaźniki W .wwsk, S.wwsk mają wartości a i 4 ustawione przez poprzednie reguły semantyczne. l
Y
{
J
Interpretując rysunek 5.10, warto zauważyć, że drzewo niższe, utworzone z rekor dów, jest „prawdziwym*' drzewem składniowym będącym wyjściem, podczas gdy drzewo wyższe (z linii punktowanych) jest drzewem wyprowadzenia, które może istnieć jedynie na rysunku. W następnym podrozdziale pokazaliśmy, jak definicja S-atrybutowana może być łatwo zaimplementowana, aby używała stosu wstępującego analizatora składniowe go do trzymania wartości atrybutów. Właściwe — przy takiej implementacji — funkcje tworzenia węzłów są wywoływane w tej samej kolejności co w przykładzie 5.7. • Grafy skierowane acykliczne dla wyrażeń Graf skierowany acykliczny (nazywany dag*) dla wyrażenia utożsamia w nim wspólne podwyrażenia. Podobnie jak drzewo składniowe, dag ma węzeł dla każdego podwyrażenia. Węzeł wewnętrzny reprezentuje operator, a jego dzieci reprezentują argumenty tego operatora. Różnica polega na tym, że węzeł w dag reprezentujący wspólne podwyrażenie może mieć więcej niż jednego „rodzica" w drzewie składniowym. W drzewie składniowym wspólne podwyrażenia są reprezentowane jako powielone poddrzewa. Na rysunku 5.11 przedstawiono dag dla wyrażenia a
+ a *
( b - c )
+
( b - c )
* d
Liść dla a ma dwóch rodziców, ponieważ a jest wspólnym podwyrażeniem a i a* ( b - c ) . Podobnie, oba wystąpienia wspólnego podwyrażenia b - c są reprezentowane przez po jedynczy węzeł i węzeł ten, podobnie, ma dwóch rodziców.
+
b
c
Rys. 5.11. Dag dla wyrażenia a+a* ( b - c ) + ( b - c ) *d
Definicja sterowana składnią z rys. 5.9 skonstruuje dag zamiast drzewa składniowe go, jeśli zostaną zmodyfikowane operacje do konstrukcji węzłów. Jeśli funkcja konstrukcji węzła będzie najpierw sprawdzać, czy identyczny węzeł nie został j u ż wcześniej utwo rzony, otrzymamy graf dag. Na przykład, przed konstrukcją nowego węzła z etykietą op i polami lewy i prawy, funkcja twwęzeł(op, lewy, prawy) może sprawdzić, czy taki wę zeł nie został już wcześniej skonstruowany. Jeśli tak, to twwęzeł(op, lewy, prawy) może zwrócić wskaźnik na poprzednio skonstruowany węzeł. Funkcje konstrukcji liści twliść mogą zachowywać się podobnie.
* Z ang. directed
acyclic graph (przyp. tłum.).
P r z y k ł a d 5.9. Sekwencja instrukcji z rys. 5.12 konstruuje dag z rys. 5.11, przy założe niu, że twwęzeł i twliść tworzą węzły tylko wtedy, gdy to konieczne, a kiedy jest to tylko możliwe zwracają wskaźniki do istniejących węzłów z poprawną etykietą i dziećmi. Na rysunku 5.12 wartości a, b, c i d są wskaźnikami do tablicy symboli na wpisy dla a, b , c i d. (1) (2) (3) (4) (5) (6) (7)
P2
= twliść (id, a); = twliść (id, a);
P3
= twliść (id,
P\
P4
Pi Pe Pl
= = = =
b)\
twliść (id, c); twwęzeł ('-', p , twwęzeł ('*', p , twwęzeł ('-', p j , 3
2
p ); p ); p ); 4
s
(8) (9) (10) (11) (12) (13)
Pio
:= twliść (id, b); := twliść (id, i ) ; := twwęzeł ('-', p ,
Pu
:= fw/iść (id, d);
P& P9
Pl2
Pu
g
:= twwęzeł := twwęzeł
('*', ('+',
p Pil) p , P12); 1 0
7
6
Rys. 5.12. Instrukcje konstruowania d a g z r y s . 5.11
Gdy wywołanie twliść(\&, a) powtarza się w wierszu 2, zwracany jest węzeł skon struowany w poprzednim wywołaniu twliść(\d, a), a więc p ~ p . Podobnie, węzły zwracane w wierszach 8 i 9 są takie same, jak zwracane w wierszach 3 i 4. Zatem węzeł zwracany w wierszu 10 musi być ten sam, jak konstruowany przez wywołanie twwęzeł w wierszu 5. • x
2
W wielu zastosowaniach węzły są implementowane jako rekordy przechowywane w tablicy, tak j a k n a rys. 5.13. Każdy rekord na tym rysunku m a pole z etykietą wy znaczającą rodzaj węzła. D o tego węzła można odwoływać się, używając jego indeksu lub pozycji w tablicy. Indeks całkowity węzła, tradycyjnie, jest nazywany numerem. Przy użyciu numerów, na przykład, możemy stwierdzić, że węzeł 3 ma etykietę +, jego lewe dziecko jest węzłem 1, a prawe węzłem 2. Następującego algorytmu można użyć do utworzenia węzłów dla reprezentacji wyrażenia za pomocą daga.
PRZYPISANIE
i:=i+10
DAG
REPREZENTACJA
id liczba
\
+
10
Do wpisu dla i
10 1 1
2 3
Rys. 5.13. W ę z ł y w d a g u dla i:=i+l0 u m i e s z c z o n e w tablicy A l g o r t y m 5.1. Metoda konstrukcji węzła w grafie dag, korzystająca z numerów. Za łóżmy, że węzły są przechowywane w tablicy, j a k na rys. 5.13, oraz że odwołanie do każdego węzła dokonuje się po jego numerze. Niech sygnaturą węzła operatora będzie trójka (op, /, r) składająca się z etykiety op, lewego dziecka / i prawego dziecka r. Wejście. Etykieta op, węzły / i r. Wyjście. Węzeł z sygnaturą (op, /, r ) .
Metoda. Przejrzyj tablicę w poszukiwaniu węzła m z etykietą op, lewym dzieckiem / i prawym r. Jeśli taki węzeł zostanie znaleziony, to zwróć m, jeśli nie, nowy węzeł n z etykietą op, lewym dzieckiem / i prawym r, po czym zwróć n. Oczywisty sposób ustalenia, czy węzeł m znajduje się już w tablicy, polega na utrzymaniu wszystkich utworzonych do tej pory węzłów na liście i sprawdzaniu, czy każdy węzeł z listy ma potrzebną sygnaturę. Poszukiwanie m może stać się bardziej wydajne przy użyciu k list, zwanych kubełkami, i funkcji mieszającej h do wyznaczenia, który kubełek ma być przeszukiwany . Funkcja mieszająca h oblicza numer kubełka na podstawie wartości op, l i r. Dla takich samych argumentów funkcja ta zwraca zawsze ten sam numer kubełka. Jeśli m nie znajduje się w kubełku h(opJ,r), to nowy węzeł n jest tworzony i umieszczany w tym kubełku, tak aby kolejne wyszukiwania znalazły go tam. Kilka sygnatur może mieć równe kody mieszające i trafiać do tego samego kubełka, lecz w praktyce oczekujemy, że każdy kubełek zawiera małą liczbę węzłów. Każdy kubełek może zostać zaimplementowany jako lista jednokierunkowa, jak na rys. 5.14. Każdy element na liście reprezentuje węzeł. Nagłówki kubełków, zawierające wskaźniki do pierwszych elementów list, są przchowywane w tablicy. Numer kubełka, otrzymywany z obliczenia h(op,l,r), jest indeksem do tej tablicy. 1
Elementy listy reprezentujące węzły Tablica nagłówków kubełków, indeksowana kodem mieszającym
25
20
Rys. 5.14. Struktura danych do przeszukiwania kubełków Algorytm 5.1 można przystosować dla węzłów, które nie są umieszczane kolejno w tablicy. W wielu kompilatorach węzły są umieszczane w miarę potrzeby, aby uniknąć tworzenia tablicy, która może przez większość czasu przechowywać zbyt wiele węzłów, a przez część mieć za mało węzłów. W takim przypadku nie możemy założyć, że węzły znajdują się w pamięci sekwencyjnej, więc trzeba używać wskaźników do odwoływania się do węzłów. Jeśli funkcja mieszająca może zostać użyta do obliczenia numeru kubełka z etykiety i dwóch wskaźników do węzłów dzieci, to można również użyć wskaźników do węzłów zamiast ich numerów. W przeciwnym przypadku węzły możemy ponumerować dowolnie i używać tej liczby jako numeru węzła. • Grafów dag można również użyć d o reprezentacji zbiorów wyrażeń, ponieważ dag może mieć więcej niż jeden korzeń. W rozdziałach 9 i 10 obliczenia wykonywane przez ciąg instrukcji przypisania są reprezentowane za pomocą grafów dag. 1
Wystarczy jakakolwiek struktura danych implementująca słownik zaproponowany przez Aho, Hopcrofta i Ull mana [19831. Ważną własnością takiej struktury jest to, że dla danego klucza, czyli etykiety op i dwóch węzłów / i r, można szybko otrzymać węzeł m o sygnaturze (op, l, r) lub stwierdzić, że taki nie istnieje.
5.3
OBLICZENIA WSTĘPUJĄCE DEFINICJI S-ATRYBUTOWANYCH
5.3
279'
Obliczenia wstępujące definicji S-atrybutowanych
Teraz, gdy wiemy już, w jaki sposób translacja może być specyfikowana przy użyciu definicji sterowanych składnią, zajmiemy się implementacją translatorów dla nich. Trans lator dla dowolnej definicji sterowanej składnią może być trudny do skonstruowania. Jednak istnieją duże klasy użytecznych definicji sterowanych składnią, dla których łat wo jest skonstruować translatory. W tym podrozdziale przyjrzymy się jednej kla sie, zwanej definicjami S-atrybutowanymi, czyli mającymi tylko atrybuty syntezowa ne. Następny podrozdział dotyczy implementacji definicji, które mają również atrybuty dziedziczone. Atrybuty syntezowane można obliczyć, stosując wstępujący analizator składniowy, w trakcie wczytywania wejścia. Analizator składniowy może przechowywać wartości atrybutów syntezowanych związanych z symbolami gramatyki na swoim stosie. Gdy tylko jest wykonywana redukcja, wartości nowych atrybutów syntezowanych są ob liczane z atrybutów znajdujących się na stosie dla symboli gramatyki z prawej strony redukowanej produkcji. W tym podrozdziale pokazujemy, jak stos analizatora składnio wego może zostać rozszerzony do przechowywania wartości atrybutów syntezowanych; z p. 5.6 dowiemy się, że taka implementacja jest w stanie obsłużyć również atrybuty dziedziczone. W definicji sterowanej składnią z rys. 5.9, służącej do konstrukcji drzewa skład niowego, pojawiają się jedynie atrybuty syntezowane. Podejście takie może zostać więc zastosowane do konstrukcji drzew składniowych w trakcie analizy wstępującej. Jak przekonamy się w p. 5.5, translacja wyrażeń podczas analizy zstępującej często używa atrybutów dziedziczonych. Omówienie translacji w trakcie analizy składniowej zstępującej odłożymy do czasu zbadania zależności „od lewej do prawej" w następnym podrozdziale.
Atrybuty syntezowane na stosie analizatora składniowego Translator dla definicji S-atrybutowanej można implementować przy użyciu generatora analizatorów LR, takiego jak omówiony w p. 4.9. Z definicji S-atrybutowanej wynika, że generator analizatorów może skonstruować translator obliczający atrybuty w trakcie analizy wejścia. Wstępujący analizator składniowy używa stosu do przechowywania informacji o zanalizowanych poddrzewach. Dodatkowych pól na stosie analizatora można użyć do przechowywania wartości atrybutów syntezowanych. Na rysunku 5.15 widać przykłado wy stos analizatora mającego miejsce do przechowywania pojedynczej wartości atrybutu.
stan X Y wierzch —> Z
wart X.x Y.y Z.z
Rys. 5.15. Stos analizatora składniowego zawierający pola dla atrybutów syntezowanych
W -> S R R
{ R.d:= S.wwsk} { W.wwsk := Rs }
+
S
{
/?!
{
R ~> 5 R
{
R
{
R .d := fww^f(V,fl.rf,S.nwdfc) } x
R.s:=R s} v
{ x
e
flpd
: = n v w f z ^ ( ' - ' , fl.d, S.wwsk) }
R.s: = R s v
}
R.s := /?.
W ) S —> id
{ S. wwsk := W.wwsk } { S.wwsk : = nv//śc(id, id.w/7/5) }
Rys. 5.28.5Przekształcony translacji konstrukcji drzew składniowych —• liczba { schemat S.wwsk := rw/tfć^liczba, liczba.warf) }
W
Do wpisu dla c Do wpisu dla a Rys. 5.29. Użycie atrybutów dziedziczonych podczas konstrukcji drzew składniowych przez akcje skojarzone z produkcjami T id i T -t liczba, jak w przykładzie 5.8. W le w y m skrajnym S, atrybut S.wwsk wskazuje liść dla a. Wskaźnik do węzła dla a jest dziedziczony jako atrybut R.d po prawej stronie W ->• SR. Gdy produkcja R —> — TR jest używana w prawym dziecku korzenia, R.d wska zuje węzeł dla a, a S.wwsk — węzeł dla 4. Węzeł dla a-4 jest konstruowany przy zastosowaniu funkcji twwęzeł do operatora minus i tych wskaźników. Ostatecznie, R.d wskazuje korzeń całego drzewa wyprowadzenia wtedy, gdy jest stosowana produkcja R —»• e. Całe drzewo jest zwracane przez atrybuty s węzłów dla R (nie pokazano na rys. 5.29), aż stanie się wartością W.wwsk. • X
5.5
TRANSLACJA ZSTĘPUJĄCA
291
Projekt translatora przewidującego Poniższy algorytm uogólnia konstrukcję przewidujących analizatorów składniowych im plementujących schemat translacji bazujący na gramatyce dostosowanej do analizy zstę pującej.
Algortym 5.2.
Konstrukcja przewidującego translatora sterowanego składnią.
Wejście. Schemat translacji sterowany składnią oparty na gramatyce dostosowanej d o analizy zstępującej. Wyjście. Kod translatora sterowanego składnią. Metoda. Użyta technika jest modyfikacją konstrukcji przewidującego analizatora skła dniowego z p . 2.4. 1.
2. 3.
Dla każdego nieterminala A skonstruuj funkcję mającą parametr formalny dla każ dego dziedziczonego atrybutu A oraz zwracającą wartości atrybutów syntezowanych A (być może jako rekord łub jako wskaźnik do rekordu z polem na każdy atrybut lub używając, omawiany w p. 7.5, mechanizm wywołania do przekazywania para metrów przez referencję). Dla uproszczenia przyjmiemy, że każdy nieterminal ma tylko jeden atrybut syntezowany. Funkcja dla A m a lokalną zmienną dla każdego atrybutu każdego symbolu gramatyki pojawiającego się po prawej stronie produkcji dla A. Podobnie jak w p . 2.4, o tym, jakiej produkcji należy użyć decyduje kod dla nieter minala A na podstawie aktualnego symbolu wejściowego. Kod związany z każdą produkcją działa następująco. Rozważamy symbole leksy kalne, nieterminale i akcje po prawej stronie produkcji z lewej do prawej strony: i) dla symbolu leksykalnego X z atrybutem syntezowanym x zapamiętaj wartość x w zmiennej X.x\ następnie wykonaj procedurę wczytującą symbol X z wejścia; ii) dla nieterminala B wygeneruj przypisanie c:— B ( b , b ,..., b ) z wywołaniem funkcji po prawej stronie, gdzie b b ,... , b są zmiennymi dla atrybutów dziedziczonych B a c jest zmienną oznaczającą atrybut syntezowany B\ ]
v
2
2
k
k
t
iii) dla akcji skopiuj kod do analizatora składniowego, zamieniając każde odwołanie do atrybutu przez zmienną dla tego atrybutu. • Algorytm 5.2 jest rozszerzany w p. 5.7 do implementacji definicji L-atrybutowanych, przy założeniu, że drzewo wyprowadzenia zostało już skonstruowane. W podrozdziale 5.8 rozważyliśmy sposoby poprawienia translatorów skonstruowanych przy użyciu algo rytmu 5.2. Na przykład, można wyeliminować przypisania o postaci x :~ y, kopiują ce wartości, lub używać pojedynczej zmiennej do trzymania wartości kilku atrybutów. Niektóre z tych poprawek można również wykonać automatycznie przy użyciu metod opisanych w rozdz. 10.
Przykład 5.16. Gramatyka z rys. 5.28 jest LL(1), a więc odpowiednia do zstępującej analizy składniowej. Z atrybutów nieterminali w gramatyce otrzymujemy następujące
typy dla argumentów i rezulatatów dla funkcji dla W, R i S; W i S nie mają atrybutów dziedziczonych, więc nie mają argumentów:
function W : T węzeł_drzewa_ skład; function R(d: T węzeł_drzewa_ skład): function 5 : T w ę z e ł - d r z e w a - s k ł a d ;
T
w ę z e ł - d r z e w a - skład;
Połączymy dwie produkcje dla R z rys. 5.28, aby zmniejszyć rozmiar translatora. Nowe produkcje używają symbolu leksykalnego opdod do reprezentacji + oraz -
R ->• opdod S R R
-> e
{ R .d := twwęzeł (opdod.leksem, { Jł.s:=R s} { R.s := R.d } l
x
R.d, S.wwsk)
}
( (
.
^ }
v
Kod dla R opiera się na procedurze analizy z rys. 5.30. Jeśli symbolem bieżącym jest
opdod, to stosowana jest produkcja R -» opdod 5/?: wywoływana jest procedura wczytaj, pobierająca z wejścia następny symbol leksykalny po opdod, i następnie są wywoływane procedury dla 5 i R. W przeciwnym przypadku procedura ta nic nie robi, naśladując produkcję R e. procedurę R; begin if bieżący — opdod then begin wczytaj(opdod)\ S\ R end else begin / * nic nie rób */ end end; Rys. 5.30. Procedura analizy składniowej dla produkcji
opdod SR |e
function R(d: t węzeł-drzewa„ skład): T węzeł_ drzewa_ skład; var wwsk, dl, sl, s: T węzeł_drzewa_skład; opdodleksem : char; begin if bieżący = opdod then begin / * produkcja R —> opdod SR */ opdodleksem := lekswart; wczytaj{opdod); wwsk := S; dl := twwęzeł(opdodleksem, i, wwsk); sl :=R{dl); s:— sl; end; else s := d /* produkcja dla R -> e */ return s end; Rys. 5.31. Konstrukcja drzew składniowych metodą zejść rekurencyjnych
5.6
293
OBLICZANIE WSTĘPUJĄCE ATRYBUTÓW DZIEDZICZONYCH
Procedura dla R z rysunku 5.31 zawiera kod obliczania atrybutów. Wartość leksy kalna lekswart symbolu opdod jest zapisywana w opdodleksem, z wejścia wczytywany jest symbol, wywoływana jest S i jej rezultat jest zapamiętywany na wwsk. Zmienna dl odpowiada atrybutowi dziedziczonemu R .d a sl atrybutowi syntezowanemu R .s. In strukcja return zwraca wartość s w momencie zakończenia funkcji. Funkcje dla W i S są konstruowane podobnie. • l
5.6
t
{
Obliczanie wstępujące atrybutów dziedziczonych
Metoda implementacji definicji L-atrybutowanych dla wstępującej analizy składniowej jest w stanie obsłużyć wszystkie definicje L-atrybutowane bazujące na gramatykach LL(1). Może również obsłużyć wiele (ale nie wszystkie) definicji L-atrybutowanych ba zujących na gramatykach LR(1). Metoda jest uogólnieniem techniki translacji wstępującej przedstawionej w p. 5.3.
Usuwanie wstawiania akcji ze schematów translacji Wstępująca metoda translacji (patrz p . 5.3) opiera się na wszystkich akcjach translacji znajdujących się na prawym końcu produkcji, natomiast w metodzie analizy przewi dującej (p. 5.5) akcje można umieszczać w różnych miejscach po prawej stronie. Przed omówieniem sposobu używania atrybutów dziedziczonych w metodzie wstępującej wpro wadzimy transformację powodującą, że wszystkie akcje w schemacie translacji pojawiają się na prawych końcach ich produkcji. Transformacja powoduje wstawienie nowych nieterminali znaczników generujących e do gramatyki bazowej. Każda akcja zostaje zastąpiona przez inny nieterminal znacznika M i akcja jest umieszczona na końcu produkcji M —> e. Przykładowo, schemat translacji W -» SR R-> + S {print('+')} R \ - S {print S -> liczba {print(liczba.wart)}
R | e
jest przekształcany przy użyciu nieterminali znaczników M i N na W -» SR R-* + SMR\-SNR\e S —¥ liczba {print(liczba.wart)} M -> e {printf')} N -> e {printf )} 9
Gramatyki w dwóch powyższych schematach translacji akceptują dokładnie ten sam język i rysując drzewa wyprowadzenia z dodatkowymi węzłami dla akcji możemy się przeko nać, że akcje te są wywoływane dokładnie w tej samej kolejności. Akcje w przekształco nym schemacie translacji kończą produkcje, mogą więc być wykonywane bezpośrednio przed redukcją prawej strony w trakcie wstępującej analizy składniowej.
Atrybuty dziedziczone na stosie analizatora składniowego Redukcja prawej strony produkcji A ->• XY w analizatorze wstępującym polega na usunię ciu X i Y z wierzchołka stosu i wstawieniu tam A. Załóżmy, że X ma atrybut syntezowany X.s, który w implementacji z p. 5.3 jest trzymany razem z X na stosie analizatora. Ponieważ wartość X.s znajduje się już na stosie analizatora przed wykonaniem ja kiejkolwiek redukcji w poddrzewie pod K, to może być dziedziczona przez Y. Oznacza to, że jeśli atrybut Y.d jest zdefiniowany przez regułę kopiującą Y.d :=X.s, to wartość X.s może być używana w miejscach, w których znajdują się odwołania do Y.d. Jak się prze konamy, reguły kopiujące odgrywają ważną rolę w obliczaniu atrybutów dziedziczonych podczas analizy wstępującej.
Przykład 5.17. Typ identyfikatora można przekazać za pomocą reguł kopiujących uży wających atrybutów dziedziczonych w sposób przedstawiony na rys. 5.32 (adaptacja rys. 5.7). Najpierw przeanalizujemy ruchy wykonywane przez analizator wstępujący dla wejścia
real p, q, r D
P
Rys. 5.32. W każdym węźle dla L: L.dz := T.typ Następnie pokażemy, jak można uzyskać dostęp do wartości atrybutu T.typ w momencie stosowania produkcji dla L. Schemat translacji, który chcemy zaimplementować, jest następujący: D-+T
{
L T -> int T -> real L -> L , id L id
{ { { { {
x
L.dz := T.typ } T.typ := integer } T.typ := real } L .dz := L.dz } dodajtyp(\d.wpis, L.dz) } dodajtypiid.wpis, L.dz) } x
Jeśli akcje z powyższego schematu translacji zostaną zignorowane, to sekwencja ruchów wykonywana przez analizator składniowy dla wejścia z rys. 5.32 będzie jak na rys. 5.33. Aby rysunek był bardziej czytelny, na stosie są przedstawione odpowiednie
symbole gramatyki zamiast stanu stosu i odpowiednie identyfikatory zamiast symbolu leksykalnego id.
WEJŚCIE real p, q, r
UŻYTA PRODUKCJA
stan
real T
/ -> real
Tp T L
L-Md
/ q, r q/ r T L , /r T L , q ,r 7 L r TL , T L T
f
L
D
L -» L ,
id
L -•» L ,
id
r D- - > T L
Rys. 5.33. Jeżeli redukowane jest L, symbol T znajduje się bezpośrednio poniżej symboli z prawej strony Załóżmy, jak w podrozdziale 5.3, że stos analizatora jest implementowany jako para tablic stan i wart. Jeśli stan[i] oznacza symbol gramatyki X, to warf [z] przechowuje atry but syntezowany X.s. Zawartość tablicy stan jest przedstawiona na rys. 5.33. Zauważmy, że za każdym razem, gdy redukowana jest prawa strona produkcji dla L, to T znajduje się na stosie bezpośrednio poniżej symboli tej prawej strony. Ten fakt można wykorzystać do dostępu do wartości atrybutu T.typ. Implementacja przedstawiona na rys. 5.34 wykorzystuje fakt, że atrybut T.typ znaj duje się w znanym miejscu na stosie wart względem wierzchołka tego stosu. Niech wierzch i nwierzch będą wskaźnikami wierzchołka stosu bezpośrednio przed i po prze prowadzeniu redukcji. Z reguł kopiujących definiujących L.dz wiadomo, że T.typ może zostać użyty zamiast L.dz.
FRAGMENT KODU
PRODUKCJA D - t T L ;
T ->int T -> real L -¥ L , id L->id
wart[nwierzch]
:=
wart[nwierzch] := dodajtyp(wart[wierzch], dodaj typ (wart[wierzch],
integer real wart[wierzch
-3])
wart[wierzch - U )
Rys. 5.34. Wartość T.typ jest używana zamiast L.dz Jeżeli stosujemy produkcję L —> id, to id.wpis znajduje się na wierzchołku stosu wart, a T.typ bezpośrednio poniżej jego. Zatem, dodajtyp(wart[wierzch], wart[wierzch~ 1]) jest odpowiednikiem dodajtyp(\d.wpis, T.typ). Podobnie, skoro prawa strona produkcji L~¥ L , id ma trzy symbole, T.typ pojawia się na wart[wierzch — 3] w trakcie wy konywania redukcji. Reguły kopiowania zawierające L.dz mogą zostać wyeliminowane, ponieważ można użyć wartości T.typ ze stosu. •
Symulacja obliczania atrybutów dziedziczonych Dostęp do wartości atrybutów znajdujących się na stosie analizatora składniowego jest możliwy jedynie wtedy, kiedy gramatyka umożliwia przewidzenie pozycji wartości atry butu.
Przykład 5.18. Rozważmy następujący schemat translacji dla przypadku, w którym nie można przewidzieć pozycji wartości atrybutu: PRODUKCJA
REGUŁY SEMANTYCZNE
S^aAC S^bABC C-J-c
C.d:=A.s Cd:-A.s C.s:=g(C.d)
(
}
C dziedziczy atrybut syntezowany A za pomocą reguły kopiowania. Zauważmy, że na stosie między A i C może się znajdować lub nie symbol B. Gdy wykonywana jest redukcja C -> c, wartość Cd znajduje się albo w wart[wierzch — 1], albo w wart[wierzch - 2], nie jest jednak jasne, który przypadek należy zastosować. Nowy nieterminal znacznika M (rys. 5.35) jest wstawiany bezpośrednio przed C po prawej stronie produkcji (patrz (5.6)). Jeśli analizujemy składnię zgodnie z pro dukcją 5 —> bABMC, to Cd dziedziczy wartość A.s pośrednio przez M.d i M.s. Gdy stosowana jest produkcja M —> e, reguła kopiowania M.s := M.d powoduje, że wartość M.s = M.d = A.s pojawia się bezpośrednio przed częścią stosu wykorzystywaną do ana lizy symbolu C Zatem wartość Cd można znaleźć w wart[wierzch — 1] w czasie sto sowania produkcji C —> c, niezależnie czy stosowana jest pierwsza, czy druga produkcja z poniższej modyfikacji gramatyki (5.6). PRODUKCJA
REGUŁY SEMANTYCZNE
S-^aAC S —> bABMC C^c
Cd:— A.s M.d := A.s; C.s:=g(C.d) M.s:=M.d
Af-»e
Cd:—M.s
d
:
s
€
(b) Produkcja zmodyfikowana
(a) Produkcja oryginalna
Rys. 5.35. Kopiowanie wartości atrybutu przez znacznik M
Nieterminale znaczników mogą być także wykorzystane do symulacji reguł seman tycznych nie będących regułami kopiowania. Rozważmy, na przykład PRODUKCJA
S^aAC
REGUŁY SEMANTYCZNE
Cd:=f(A.s)
(5.7)
Tym razem reguła definiująca Cd nie jest regułą kopiowania, więc wartość Cd nie znajduje się jeszcze na stosie wart. Ten problem można również rozwiązać za pomocą znacznika. PRODUKCJA
REGUŁY SEMANTYCZNE
S -> aABNC N->e
N.d := A.s\Cd :== N.s N.s :=/'(N.d)
(5.8)
Odrębny nieterminal W dziedziczy A.s dzięki regule kopiowania. Jego syntezowany atrybut N.s uzyskuje wartość f(A.s) i następnie Cd dziedziczy tę wartość dzięki regule kopiowania. W trakcie redukcji N —> e, wartość N.d znajduje się w miejscu A.s, czyli w wart[wierzch — 1]. Kiedy dokonywana jest redukcja dla produkcji S -> aANC, wartość Cd jest również znajdowana w wart[wierzch — 1], ponieważ jest to N.s. Właściwie nie trzeba znać w tym momencie Cd. potrzebne jest to podczas redukcji symbolu terminalnego do C, kiedy jego wartość jest umieszczana na stosie razem z N.
P r z y k ł a d 5.19. Trzy nieterminale znaczników L, M i W są używane (rys. 5.36), aby zapewnić, że wartość atrybutu dziedziczonego B.rp pojawia się na znanej pozycji na stosie analizatora, gdy redukowane jest poddrzewo dla B. Oryginalną gramatykę atrybutowaną przedstawiono na rys. 5.22, a jej przeznaczenie do formatowania tekstu opisano w przykładzie 5.13. Inicjowanie jest wykonywane przy użyciu L. Produkcją dla S na rys. 5.36 jest S -¥ LB, więc L pozostanie na stosie, gdy poddrzewo poniżej B jest redukowane. Wartość 10 atrybutu dziedziczonego B.rp = L.s jest wstawiana na stosie analizatora składniowego za pomocą reguły L.s := 10 związanej z produkcją L ->• e.
R E G U Ł A SEMANTYCZNA
PRODUKCJA
s -> LB
B.rp := L.s S.wys := B.wys
L -> e B -¥
L.s := 10 B MB X
B rp := B.rp M.d := B.rp B .rp := M.s
2
v
2
B.wys := max(B .wySi x
B -+
B wA>NB x
2
B .wys) 2
:= B.rp N.d := B.rp B .rp := N.s B .rp x
2
B.wys := przemieść(B .wys, x
B -> tekst M
e
N -> e
B .wys) 2
B.wys := tekst/z x B.rp
M.s :=M.d N.s :=
zmniejsz(N.d)
Rys. 5.36. W s z y s t k i e atrybuty d z i e d z i c z o n e są u s t a w i a n e z a p o m o c ą r e g u ł k o p i o w a n i a
Znacznik M w B -> B M B odgrywa podobną rolę do tej, jaką ma M na rys. 5.35. Powoduje on, że wartość B.rp pojawi się na stosie bezpośrednio poniżej B . W produkcji B —> B s n b N B nieterminal N jest używany tak, jak w (5.8). Nieterminal A^, dzięki regule kopiowania N.d :=B.rp, dziedziczy wartość atrybutu, od którego zależy B .rp, oraz syntetyzuje wartość B .ps wskutek reguły N . s :— zmniejsz(N.d). W konsekwencji (pozostawiamy to do przemyślenia), wartość B.rp znajduje się zawsze poniżej prawej strony, gdy następuje redukcja do B. Fragmenty kodu implementujące definicję sterowaną składnią z rys. 5.36 są przed stawione na rys. 5.37. Wszystkie atrybuty dziedziczone na rys. 5.36 są ustawiane przy użyciu reguł kopiowania, więc w implementacji ich wartości można otrzymać, śledząc ich pozycje na stosie wart. Jak w poprzednich przykładach, wierzch i nwierzch wskazują wierzchołek stosu przed i po redukcji. • X
2
2
l
2
2
2
F R A G M E N T KODU
PRODUKCJA
S L B B B M N
-» LB e -> B MB -» B sxxhNB -» tekst -> e -> e X
x
2
2
wart[nwierzch] := wart[wierzch] wart[nwierzch]10 wart[nwierzch] := max(wart[wierzch - 2 ] , wart[wierzch]) wart[nwierzch] :=przemi>ić(wart[wierzch-3], wart[wierzch]) wart[nwierzch] := wart[wierzch] x wart[wierzch - 1 ] wart[nwierzch]wart[wierzch — 1] wart[nwierzch] := zmniejsz(wart[wierzch - 1 ] )
Rys. 5.37. Implementacja definicji sterowanej składnią z rys. 5.36
Systematyczne wprowadzanie znaczników, jak w modyfikacjach (5.6) i (5.7), umoż liwia obliczanie definicji L-atrybutowanych podczas analizy LR. Dla każdego znacznika jest tylko jedna produkcja, więc po ich dodaniu, gramatyka pozostaje gramatyką LL(1). Każda gramatyka LL(1) jest również gramatyką LR(1), więc nie pojawią się żadne kon flikty po dodaniu znaczników do gramatyki LL(1). Niestety, nie można tego powiedzieć o wszystkich gramatykach LR(1) — po dodaniu znaczników do niektórych gramatyk LR(1) mogą pojawić się konflikty. Pomysły z poprzednich przykładów można wykorzystać do stworzenia następującego algorytmu. A l g o r t y m 5.3.
Wstępująca analiza składniowa i translacja z atrybutami dziedziczonymi.
Wejście. Definicja L-atrybutowana oparta na gramatyce LL(1). Wyjście.
Analizator składniowy obliczający wartości wszystkich atrybutów na swoim
stosie. Metoda. Załóżmy dla uproszczenia, że każdy nieterminal A m a jeden atrybut dziedziczony A.d, a każdy symbol gramatyki X — atrybut syntezowany X.s. Jeśli X jest terminalem, to jego atrybut syntezowany jest faktycznie wartością leksykalną zwracaną razem z X przez analizator leksykalny. Ta wartość leksykalna pojawia się na stosie w tablicy wart, jak w poprzednich przykładach.
Dla każdej produkcji A —>X ---X wprowadźmy n nowych nieterminali marke rów A / j , . . . ,M i zamieńmy produkcję na A M X ••-M X . Atrybut syntezowany Xj.s zostanie wstawiony na stos analizatora do tablicy wart, na pozycję związaną z Xj. Atrybut dziedziczony Xj.d, jeśli taki istnieje, pojawi się w tej samej tablicy, ale jako związany z Mj. Niezmiennik powoduje, co jest bardzo ważne, że jeśli w trakcie analizy atrybut dzie dziczony A.d istnieje, to znajduje się on w tablicy wart na pozycji odpowiadającej M , . Jak założyliśmy, symbol startowy nie m a atrybutu dziedziczonego, nie m a więc problemu z przypadkiem, gdy A jest symbolem startowym. Jednak nawet gdyby istniał taki atrybut dziedziczony, mógłby zostać umieszczony poniżej początku stosu. Istnienie niezmien nika można wykazać za pomocą prostej indukcji względem liczby kroków w analizie wstępującej. Należy zwracać uwagę na fakt, że atrybuty dziedziczone są związane z nieterminalami znaczników Mj, a atrybut X-.d jest obliczany dla M- przed rozpoczęciem redukcji do Xj. X
n
l
n
X
{
n
n
Przyjęliśmy, że podczas analizy wstępującej można obliczyć atrybuty; rozważmy dwa przypadki. Po pierwsze, jeśli przeprowadzimy redukcję do nieterminala znacznika M-, wiemy, do której produkcji A —> M X • • -M X ten znacznik należy. Znane są zatem p o zycje wszystkich atrybutów wymaganych do obliczenia atrybutu dziedziczonego Xj.d. Atrybut A.d znajduje się w wart[wierzch — 2j + 2], X d w wart[wierzch — 2j -f- 3], X .s w wart[wierzch — 2j4-4] itd. Zatem, możemy obliczyć X-.d i przechować go w wart[wierzch + 1], która p o redukcji staje się nowym wierzchołkiem stosu. Zauważ my, że informacja, iż gramatyka jest LL(1), jest ważna, ponieważ w przeciwnym razie możemy nie mieć pewności, d o jakiego nieterminala powinno się zredukować e, a wtedy nie można by było ustawić właściwych atrybutów lub nawet nie byłoby wiadomo, którą produkcję wybrać. Prosimy Czytelnika o przyjęcie na wiarę, albo samodzielne prze prowadzenie dowodu, że każda gramatyka LL(1) ze znacznikami jest wciąż gramatyką LR(1). {
{
n
n
y
x
Drugi przypadek pojawia się, gdy jest przeprowadzana redukcja do symbolu nie bę dącego znacznikiem, na przykład produkcja A —»• M X • • • M X . Wtedy musimy policzyć jedynie atrybut syntezowany A.s (zauważmy, że atrybut A.d jest j u ż policzony i znajduje się na stosie bezpośrednio poniżej pozycji, na którą jest wpisywane A). Atrybuty p o trzebne do obliczenia A.s są wprost dostępne na znanych pozycjach na stosie, pozycjach symboli Xj podczas redukcji. X
{
n
n
Następujące uproszczenia redukują liczbę znaczników. Drugie uproszczenie powo duje uniknięcie konfliktów analizy dla gramatyk lewostronnie rekurencyjnych. 1.
2.
Jeśli Xj nie ma atrybutu dziedziczonego, trzeba użyć znacznika Mj. Oczywiście, przewidywane pozycje atrybutów na stosie zmienią się, jeśli M . zostanie ominięte, ale taka zmiana może być dołączona do analizatora składniowego. Jeśli X .d istnieje, ale jest obliczany za pomocą reguły kopiowania X .d := A.d, to można ominąć M , ponieważ wiadomo z niezmiennika, że A.d znajduje się j u ż we właściwym miejscu na stosie, bezpośrednio poniżej X . Ta wartość może również służyć jako X .d. • y
{
x
x
x
Chociaż wstawienie M przed X upraszcza rozważania o nieterminalach znaczników, ma ono jednak efekt uboczny: wprowadza sprzeczności analizy składniowej do gramatyki lewostronnie rekurencyjnej. Patrz: przy kład 5.21. Wkrótce pokażemy, że A/, można wyeliminować. ]
l
Zamiana atrybutów dziedziczonych na syntezowane Czasami można uniknąć użycia atrybutów dziedziczonych dzięki zmianie gramatyki. Przykładowo, deklaracja w Pascalu może składać się z listy identyfikatorów, za którymi występuje typ, np. m , n : i n t e g e r . Gramatyka dla takich deklaracji może zawierać produkcje D ~> L : T T —> integer | char L -> L , id | id Ponieważ niektóre indentyfikatory są generowane przez L, a typ nie znajduje się w poddrzewie dla L, nie można związać typu z identyfikatorem, używając tylko atrybutów syntezowanych. Tak naprawdę, nieterminal L w pierwszej produkcji dziedziczy typ z T po swojej prawej stronie, więc gramatyka nie jest L-atrybutowana i translacje bazujące na tym nie mogą być wykonywane w trakcie analizy składniowej. Rozwiązaniem tego problemu jest takie przekształcenie struktury gramatyki, aby typ był ostatnim elementem listy identyfikatorów D idL L -» , id L | : T T -> integer | char Teraz ten typ może zostać przeniesiony za pomocą atrybutu syntezowanego L.łyp. W związku z tym, że każdy identyfikator jest generowany przez L, ich typ może być wprowadzony do tablicy symboli. Trudne definicje sterowane składnią Algorytm 5.3, implementacji atrybutów dziedziczonych podczas analizy wstępującej, można rozszerzyć na pewne, ale nie wszystkie, gramatyki LR. Definicja L-atrybutowana z rys. 5.38 bazuje na prostej gramatyce LR(1), ale nie może zostać zaimplementowana w tej postaci podczas analizy LR. Nieterminal L w L —> e dziedziczy liczbę jedynek ge nerowaną przez S. Produkcja L —> e jest pierwsza, którą analizator wstępujący zredukuje, więc translator w tym momencie nie będzie znał liczby jedynek na wejściu.
PRODUKCJA
L^L
X
1
REGUŁY SEMANTYCZNE
L.licznik := 0 L^.licznik := L.licznikĄ-1 print (L .licznik) x
Rys. 5.38. Trudna definicja sterowana składnią
5.7
Obliczanie rekurencyjne
Funkcje rekurencyjne obliczające atrybuty w trakcie przechodzenia drzewa wyprowadze nia mogą być konstruowane wg definicji sterowanej składnią przy użyciu uogólnionych technik translacji przewidującej (patrz p . 5.5). Takie funkcje pozwalają na implementację
tych definicji sterowanych składnią, które nie mogą być implementowane bezpośrednio w trakcie analizy składniowej. W tym podrozdziale opisaliśmy związanie pojedynczej funkcji translacji z każdym nieterminalem. Funkcja ta odwiedza dzieci węzła dla tego nieterminala w pewnej kolejności ustalonej przez produkcję w tym węźle. Nie jest ko nieczne odwiedzanie w kolejności od lewej do prawej. W podrozdziale 5.10 wyjaśniliśmy, jak osiągnąć efekt translacji w trakcie więcej niż jednego przebiegu przy związaniu wielu procedur z nieterminalami. Przechodzenie z lewej do prawej Z algorytmu 5.2 wiemy, jak mogą być implementowane definicje L-atrybutowane, ba zujące na gramatyce LL(1), za pomocą konstrukcji funkcji rekurencyjnej analizującej i dokonującej translacji każdego nieterminala. Wszystkie definicje sterowane składnią L-atrybutowane mogą być implementowane przy użyciu podobnej funkcji rekurencyjnej wywoływanej dla węzła dla tego nieterminala w poprzednio skonstruowanym drzewie wyprowadzenia. Biorąc pod uwagę produkcję w tym węźle, stwierdzamy, że funkcja może wyznaczyć, jakie on m a dzieci. Funkcja dla nieterminala A pobiera węzeł i war tości atrybutów dziedziczonych dla A jako argumenty i zwraca jako rezultaty wartości atrybutów syntezowanych A. Szczegóły konstrukcji są takie same jak w algorytmie 5.2, ze zmienionym kro kiem 2., w którym funkcja dla nieterminala decyduje — na podstawie aktualnego symbolu wejściowego — której produkcji powinno się użyć. Funkcja stosuje instrukcję wyboru (case) do wyznaczenia produkcji używanej w tym węźle. Poniżej podajemy przykład ilustrujący tę metodę. Przykład 5,20. Rozważmy definicję sterowaną składnią wyznaczania szerokości i wy sokości bloków z rys. 5.22. Nieterminal B ma atrybut dziedziczony rp i syntezowany wys. Przy użyciu algorytmu 5.2, zmodyfikowanego w sposób opisany powyżej, skonstruujemy funkcję dla B przedstawioną na rys. 5.39. Funkcja B pobiera jako argumenty węzeł n i wartość odpowiadającą B.rp dla tego węzła, a zwraca wartość odpowiadającą B.wys w tym węźle. Instrukcja wyboru zawiera dla każdej produkcji z B po lewej stronie fragment kodu obliczającego wartość funkcji. Kod odpowiadający każdej z produkcji symuluje reguły semantyczne związane z produk cją. Kolejność, w jakiej są stosowane poszczególne reguły, musi być taka, by atrybuty dziedziczone nieterminali były obliczane przed wywołaniem funkcji dla tego nietermi nala. W kodzie odpowiadającym produkcji B —> Z?sub£, zmienne rp, rp] i rp2 prze chowują wartości atrybutów B.rp, B .rp i B .rp. Podobnie wys, wysl i wys2 trzymają wartości B.wys, B .wys i B .wys. Funkcja dziecko(m, i) służy do odwoływania się do i-tego dziecka węzła m. Skoro B jest etykietą trzeciego dziecka węzła n, wartość B .wys jest wyznaczana przez wywołanie funkcji B(dziecko(n, 3), rp2). • {
x
2
2
2
2
Inne kolejności przechodzenia Kiedy drzewo wyprowadzenia jest dostępne wprost, dzieci węzła mogą być odwiedzane w dowolnej kolejności. Rozważmy definicję nie będącą L-atrybutowaną z przykładu 5.21.
function B(m, rp); var rplj rp2, wysl, wys2; begin case produkcja w węźle n of 'B^B B' : rpl := rp; wysl := B(dziecko(n 1), psi); rp2 '.= rp; wys2 := B(dziecko(n 2), ps2); return max(wysl, wys2); 'B~>B sub B' : rpl := rp; wysl := B(dziecko(n /), psi); rp2 := zmniejsz(rp); wys2 := B(dziecko(n, 3), ps2); return przemieść(wysl, wys2); 'B -> tekst': return rp x tekst.w; default: błąd end end; X
2
)
y
X
2
y
Rys. 5.39. Funkcja dla nieterminala B z rys. 5.22
W translacji specyfikowanej przez tę definicję dzieci węzła dla jednej produkcji należy odwiedzać od lewej do prawej, natomiast dzieci węzła dla innej produkcji — od prawej do lewej. Ten abstrakcyjny przykład ilustruje siłę użycia wzajemnie rekurencyjnych funkcji ob liczania atrybutów w węzłach drzewa wyprowadzenia. Funkcje nie muszą zależeć od ko lejności tworzenia węzłów drzewa wyprowadzenia. Podstawową zasadą obliczeń w trakcie przechodzenia drzewa jest to, że atrybuty dziedziczone węzła muszą być obliczone przed wejściem do tego węzła, a atrybuty syntezowane po ostatecznym opuszczeniu węzła.
Przykład 5.21. Każdy z nieterminali z rys. 5.40 ma atrybut dziedziczony d i atrybut syntezowany s. Grafy zależności dla dwóch produkcji również przedstawiono na tym rysunku. Reguły związane z A —> LM powodują pojawienie się zależności z lewej do prawej, a reguły związane z A -> QR — zależności od prawej do lewej. Funkcja dla nieterminala A jest przedstawiona na rys. 5.41 (zakładamy, że można skonstruować funkcje dla nieterminali L, M, Q i R). Nazwy zmiennych z rys. 5.41 po chodzą pd nieterminala i jego atrybutu, na przykład li i Is są zmiennymi oznaczającymi L.i i L.s. Kod odpowiadający produkcji A -> LM jest konstruowany tak, jak w przykładzie 5.20. Innymi słowy, wyznaczamy atrybut dziedziczony L, wywołujemy funkcję dla L do wyznaczenia atrybutu syntetyzowanego L i powtarzamy proces dla M. Kod odpo wiadający A -» QR odwiedza poddrzewo dla R przed odwiedzeniem poddrzewa dla Q. W przeciwym przypadku, kod dla dwóch produkcji jest bardzo podobny. •
PRODUKCJA
REGUŁY SEMANTYCZNE
A —• LM
L.d:=Ł{A.d) M.d
:=
m(L.s)
A.s:=f(M.s) R.d
A - > Q R
:=
Q.d
r(A.d) :=q(R.s)
A.s:=f(Q.s)
d
d
i
A
s
s
d
d
M
s
d
Q
A
s
s
d
R
s
Rys. 5.40. Produkcje i reguły semantyczne dla nieterminala A
function A ( n ad); begin case produkcja w węźle n of 'A -» L M'\ / * kolejność od lewej do prawej * / y
ld:=l{ad); Is := L(dziecko(n, md:=
ms := M(dziecko(n,
return f
A - > Q M': rd :=
md);
/ * kolejność od prawej do lewej */ r(ad); 2),
rd);
:=q(rs);
qs := Q(dziecko{n,
return
2),
f(ms);
rs := R(dziecko(n, ąd
1 ) , Id);
m(łs);
1 ) , qd);
f{qs);
default: błąd
end end; Rys. 5.41. Zależności z rys. 5.40 wyznaczają kolejność odwiedzin dzieci
5.8
Pamięć przeznaczona na wartości atrybutów w czasie kompilacji
Rozważmy przydział pamięci dla wartości atrybutów w trakcie kompilacji. Wykorzy stamy informację z grafu zależności dla drzewa wyprowadzenia, więc podejście to jest przeznaczone dla metod analizy, które kolejność obliczeń wyznaczają z grafu zależno-
ści. W następnym podrozdziale rozważyliśmy przypadek, w którym kolejność obliczeń można przewidzieć z góry, więc pamięć dla atrybutów można ustalić jednokrotnie, gdy jest konstruowany kompilator. Dla danego porządku obliczeń atrybutów, niekoniecznie odpowiadającego przeszu kiwaniu w głąb, czas istnienia atrybutu zaczyna się, gdy atrybut jest obliczany po raz pierwszy, a kończy, gdy wszystkie atrybuty, które od niego zależą, zostaną obliczone. Można oszczędzić pamięć dla atrybutów, przydzielając ją jedynie na czas istnienia. Techniki omówione w tym podrozdziale można zastosować do każdej kolejności obliczeń; aby to podkreślić, rozważmy następującą definicję sterowaną składnią, która nie jest L-atrybutowaną, służącą do przekazywania informacji o typie do identyfikatorów w deklaracji. Przykład 5.22. Definicja sterowana składnią z rys. 5.42 jest rozwinięciem tej z rys. 5.4; zezwala na deklaracje o postaci real
c [ 1 2 ] [31] ;
int
x[3],
(5.9)
y[5];
(5.10)
Drzewo wyprowadzenia dla (5.10) jest przedstawione na rys. 5.43(a) liniami przery wanymi. Znaczenie liczb przy węzłach jest omówione w następnym przykładzie. Jak w przykładzie 5.3, typ otrzymany z T jest dziedziczony przez L i przekazywany w dół do identyfikatora w deklaracji. Krawędź z T.typ do L.dz pokazuje zależność L.dz od T.typ. Definicja sterowana składnią z rys. 5.42 nie jest L-atrybutowaną, ponieważ I .dz zależy od liczba.wwf, a liczba znajduje się po prawej stronie I w I I [ liczba ]. • x
x
REGUŁY
PRODUKCJA
TL
D->
L.dz —T.typ
r->int
T.typ := integer
T ->• real
T.typ := real
L~^L
L .dz := L.dz l.dz —L.dz
X
, I
x
L->L
l.dz--L.dz
I - > / j [ liczba ]
l .dz '.— tablica(\iczbsi.wart, l.dz)
/-*id
dodaj typ(id.wpis, l.dz)
X
x
Rys. 5.42. Przekazywanie typu identyfikatora w deklaracji
Przydzielanie pamięci na atrybuty w czasie kompilacji Załóżmy, że dana jest sekwencja rejestrów do trzymania wartości atrybutów. Dla uprosz czenia zakładamy, że każdy rejestr może trzymać każdą wartość atrybutu. Jeśli atrybuty mają różne typy, można zgrupować atrybuty zajmujące taką samą ilość pamięci i roz ważać każdą z tych grup niezależnie. D o wyznaczenia rejestrów, w których wartości atrybutów są umieszczane, opieramy się na informacji o czasie ich istnienia.
P r z y k ł a d 5.23. Załóżmy, że atrybuty są obliczane w kolejności liczb ze skonstruowa nego w poprzednim przykładzie grafu zależności z rys. 5 . 4 3 . Czas istnienia każdego węzła zaczyna się, gdy jego atrybut jest obliczany, a kończy, gdy jego atrybut jest uży wany po raz ostatni. Przykładowo, czas istnienia węzła 1 kończy się, gdy obliczany jest węzeł 2, ponieważ węzeł 2 jest jedynym węzłem zależnym od 1. Czas istnienia węzła 2 kończy się, gdy obliczany jest 6. • 1
D
x (a) Graf zależności dla drzewa wyprowadzenia
1
2
3
4
»• 5
6
»• 7
9
8
(b) Węzły w kolejności obliczeń z (a) Rys. 5.43. Wyznaczanie czasu istnienia wartości atrybutów
Metoda obliczania atrybutów używająca najmniejszej możliwej ilości rejestrów jest przedstawiona na rys. 5.44. Rozważmy węzły w grafie zależności D dla drzewa wyprowa dzenia w kolejności, w której mają być obliczone. Początkowo mamy pulę rejestrów r r , . . . Jeśli atrybut Z? jest zdefiniowany przez regułę semantyczną Z? : = f ( c c , . . . , c ), to czas istnienia jednej lub więcej wartości z c , c , . . . , c może zakończyć się po ob liczeniu b. Rejestry trzymające te atrybuty są zwracane do puli po obliczeniu b. Jeśli tylko jest to możliwe, b jest obliczane w rejestrze, który przechowywał jedną z wartości Cj, c , . • • , c . p
{ i
2
x
2
k
k
2
k
2
Rejestry używane podczas obliczeń dla grafu zależności z rys. 5.43 są przedstawione na rys. 5.45. Zaczynamy obliczanie węzła 1 w rejestrze r . Czas istnienia węzła 1 kończy się, gdy obliczany jest 2, więc węzeł 2 może być obliczany w r . Węzeł 3 dostaje nowy rejestr r , ponieważ węzeł 6 będzie potrzebował wartości z węzła 2. x
x
2
W grafie zależności z rys. 5.43 nie pokazano węzłów związanych z regułą semantyczną dodajtyp(id.wpis, ponieważ dla atrybutów sztucznych nie jest rezerwowana pamięć. Zauważmy jednak, że ta reguła semantyczna nie może być obliczona, jeśli nie ma jeszcze wartości atrybutu l.dz. Algorytm określający ten fakt musi posługiwać się grafem zależności zawierającym węzły dla tej reguły semantycznej.
l.dz),
for each w ę z e ł m € {m , m , . . . , m } do begin for each w ę z e ł n, k t ó r e g o c z a s istnienia k o ń c z y x
N
2
się przed obliczeniem m
do
z a z n a c z rejestr d l a n;
if
p e w i e n rejestr r j e s t z a z n a c z o n y
then begin
o d z n a c z r; o b l i c z m w rejestrze r; z w r ó ć z a z n a c z o n e rejestry d o puli
end else / *
ż a d e n rejestr n i e z o s t a ł z a z n a c z o n y * /
o b l i c z m w rejestrze z puli; / * w t y m miejscu należy umieścić akcje używające wartości m
if
czas istnienia
m
zakończył się
*/
then
z w r ó ć rejestr dla m d o puli
end Rys. 5.44.
r,
r
r
x
Rys.
2
5.45.
P r z y p i s a n i e w a r t o ś c i a t r y b u t ó w d o rejestrów
r
z
r
2
r
x
r
x
Rejestry u ż y w a n e d l a w a r t o ś c i atrybutów z rys.
r
r
2
x
5.43
Uniknięcie kopiowania Możemy ulepszyć metodę przedstawioną na rys. 5.44, traktując reguły kopiowania jako specjalny przypadek. Reguła kopiowania ma postać b : = c, więc jeśli wartość c jest w rejestrze r, to wartość b pojawia się j u ż w rejestrze r. Liczba atrybutów zdefiniowanych regułami kopiowania może być znacząca, więc nie chcielibyśmy dokonywać kopiowań wprost. Zbiór węzłów mających tę samą wartość tworzy klasę równoważności. Metodę z ry sunku. 5.44 można zmodyfikować w taki sposób, aby mogła przechowywać wartość klasy równoważności w rejestrze. Gdy rozważany jest węzeł m, to sprawdzamy, czy jest on zdefiniowany przez regułę kopiowania. Jeśli jest, to j e g o wartość musi się j u ż znaj dować w rejestrze i m może znaleźć się w klasie równoważności dla wartości z tego rejestru. Ponadto, rejestr jest zwracany do puli jedynie po zakończeniu czasu istnienia wszyskich węzłów z wartościami w tym rejestrze. Przykład 5.24. Graf zależności z rysunku 5.46 jest zaadaptowany z rys. 5.43, z zazna czeniem reguł kopiowania znakiem równości. Z definicji sterowanej składnią z rys. 5.42 wynika, że typ wyznaczony w węźle 1 jest kopiowany do każdego elementu na liście identyfikatorów, więc węzły 2, 3, 6 i 7 z rys. 5.43 są kopiami 1. Skoro węzły 2 i 3 są kopiami 1, ich wartości są pobierane z rejestru r z rys. 5.46. Zauważmy, że czas istnienia węzła 3 kończy się, gdy jest obliczany węzeł 5, ale rejestr trzymający wartość węzła 3 nie jest zwracany do puli, gdyż czas istnienia węzła 2 w tej klasie równoważności jeszcze się nie zakończył. x
Rys. 5.46. Używane rejestry z zaznaczonymi regułami kopiowania
Następujący kod pokazuje, jak deklaracje (5.10) z przykładu 5.22 mogą zostać prze tworzone przez kompilator: r
:— integer;
r
: = 5,
x
2
2 ••
/ * oblicza węzeł 4 * /
tablica(r ,
{
*/
2
: = 3;
r
:~ tablica(r ,
/ * oblicza węzeł 8 * / r );
2
dodajtyp(x,
/ * typ y
r );
r
2
r );
2
dodajtyp(y, 2
/ * oblicza węzły 1, 2, 3, 6, 7 * /
x
/ * typ x
*/
r ); 2
Użyte powyżej x i y wskazują wpisy w tablicy symboli dla x i y, a procedura dodajtyp musi być wywołana w odpowiednich momentach w celu dodania typów x i y do ich wpisów w tablicy symboli. •
5.9
Przypisanie pamięci w trakcie konstrukcji kompilatora
Chociaż możliwe jest przechowywanie wszystkich wartości atrybutów na pojedynczym stosie podczas przechodzenia, czasami można uniknąć robienia kopii, używając wielu stosów. Zazwyczaj, jeśli zależności między atrybutami utrudniają umieszczenie pewnych wartości atrybutów na stosie, można zapisać j e w węzłach jawnie skonstruowanego drze wa składniowego. Z podrozdziałów 5.3 i 5.6 wiemy już, w jaki sposób można użyć stosu do przecho wywania wartości atrybutów w trakcie wstępującej analizy składniowej. Stos jest również używany niejawnie przez analizator stosujący metodę zejść rekurencyjnych do śledzenia wywołań procedur. Ta kwestia jest omówiona w rozdz. 7. Użycie stosu może być połączone z innymi technikami w celu zaoszczędzenia pa mięci. Akcje wypisujące wynik przy użyciu print, używane w schematach translacji omówionych w rozdz. 2 emitują do pliku wyjściowego, kiedy to tylko możliwe, atry buty zawierające napisy. Podczas konstrukcji drzew składniowych w p. 5.2 wskaźniki przekazywaliśmy do węzłów, a nie do całych poddrzew. Zwykle, możemy zaoszczędzić miejsce, przekazując — zamiast dużych obiektów — j e d y n i e wskaźniki do nich. Techniki te zastosowano w przykładach 5.27 i 5.28.
Przewidywanie czasu istnienia na podstawie gramatyki Kiedy kolejność obliczeń atrybutów wynika z pewnego przechodzenia drzewa wyprowa dzenia, czas istnienia atrybutów można przewidzieć w czasie konstrukcji kompilatora. Załóżmy, na przykład, że dzieci są odwiedzane od lewej d o prawej podczas przechodze nia w głąb, jak w p . 5.4. Zaczynając w węźle dla produkcji A —>• BC, odwiedzane jest drzewo dla B, następnie dla C i wtedy zwracany jest węzeł dla A, Rodzic A nie może odwoływać się do atrybutów B ani C, więc ich czas istnienia musi się zakończyć, gdy wraca się do A. Zauważmy, że te obserwacje bazują na produkcji A —> BC i kolejności odwiedzin węzłów dla tych nieterminali. Nie trzeba nic wiedzieć o poddrzewach B i C. Dla dowolnej kolejności obliczeń, jeśli czas istnienia atrybutu c jest zawarty w b, to wartość c może być przechowywana na stosie powyżej b. W tym przypadku b i c nie muszą być atrybutami tego samego nieterminala. Dla produkcji A —> BC stos może zostać użyty podczas przechodzenia w głąb w następujący sposób. Należy zacząć w węźle dla A. Atrybuty dziedziczone A znajdują się j u ż na stosie. Następnie trzeba obliczyć i umieścić na stosie wartości atrybutów dziedziczonych B. Te artybuty pozostają na stosie w trakcie przechodzenia poddrzewa B, natomiast atrybuty syntezowane B znajdujące się powyżej ich są zwracane. Proces ten jest powtarzany dla C, czyli na stosie umieszcza się atrybuty dziedziczone, przechodzi j e g o poddrzewo i po wraca się z jego atrybutami syntezowanymi na wierzchołku stosu. Oznaczając atrybuty X dziedziczone i syntezowane odpowiednio D(X) i S(X), stos zawiera teraz D(A),
D(fl), S(B), D ( C ) , S(C)
(5.11)
Wszystkie wartości atrybutów potrzebne do obliczenia atrybutów syntezowanych A znaj dują się na stosie, można więc powrócić do A ze stosem zawierającym D(A), S(A) Zauważmy, że liczba (i przypuszczalnie rozmiar) dziedziczonych i syntezowanych atrybutów dla symbolu gramatyki jest stała. Zatem, na każdym kroku powyższego pro cesu wiemy, j a k głęboko w stos trzeba sięgnąć, aby znaleźć atrybut.
Przykład 5.25. Załóżmy, że wartości atrybutów dla translacji składającej teksty (patrz rys. 5.22) są trzymane na stosie w sposób omówiony powyżej. Początkowy węzeł pro dukcji B BB z B.rp na wierzchołku stosu, zawartość stosu przed i po wejściu do węzłów są przedstawione na rys. 5.47, odpowiednio po lewej i prawej stronie węzła. Jak zwykle stosy rosną w dół. Zauważmy, że bezpośrednio przed odwiedzeniem po raz pierwszy węzła dla nieter minala B, jego atrybut rp znajduje się na wierzchołku stosu. Bezpośrednio po ostatniej wizycie, czyli, gdy wędruje się w górę z tego węzła, jego atrybuty wys i rp znajdują się na dwóch górnych pozycjach na stosie. • {
2
Gdy atrybut b jest zdefiniowany regułą kopiowania b := c, a wartość c znajduje się na wierzchołku stosu dla atrybutów, może nie być konieczne włożenie na stos kopii c. Istnieją również inne możliwości eliminowania reguł kopiujących, jeśli więcej niż jeden stos jest używany do przechowywania wartości. W następnym przykładzie będziemy
B.rp
B.rp
B.wys
B
B.rp
B rp
Byfp
B .rp
B,.rp
B,
B.rp
B.rp
B.rp
x
v
\B wys\
\B wys\
v
v
B .rp
B .rp
2
2
\B .wys\ 2
Rys. 5.47. Zawartości stosu przed i po odwiedzeniu węzłów
używać oddzielnych stosów dla atrybutów syntezowanych i dziedziczonych. Z porównania z przykładem 5.25 wynika, że reguły kopiowania mogą zostać wyeliminowane, jeśli zostaną użyte oddzielne stosy.
P r z y k ł a d 5.26. Dla definicji sterowanej składnią (patrz rys. 5.22) załóżmy, że używamy oddzielnych stosów dla atrybutu dziedziczonego rp i dla atrybutu syntezowanego wys. Stosy są utrzymywane w ten sposób, że B.rp jest na wierzchołku stosu rp bezpośrednio przed odwiedzeniem B po raz pierwszy i po odwiedzeniu B po raz ostatni. B.wys będzie na wierzchołku stosu wys bezpośrednio po ostatnim odwiedzeniu B. Dla odrębnych stosów możemy skorzystać z reguł kopiowania B rp := B.rp oraz B .rp := B.rp, związanych z B ~> B B . Jak przedstawiono na rys. 5.48, nie musimy B .rp umieszczać na stosie, ponieważ ta wartość jest już na wierzchołku jako B.rp. v
2
X
2
x
B.rp
B.rp
Bi
B.rp
B
B.rp
By.wys
B.rp
B.wys
B\. wys
B«
B.rp
B\.wys B .wys 2
Rys. 5.48. Używanie oddzielnych stosów dla atrybutów rp i wys
Schemat translacji bazujący na definicji sterowanej składnią z rys. 5.22 jest przed stawiony na rys. 5.49. Operacja push(v,s) umieszcza wartość v na stosie s, a pop(s) zdejmuje wartość z wierzchołka stosu s; top(s) służy do odwoływania się do elementu z wierzchołka stosu s. • Następny przykład łączy użycie stosu dla wartości atrybutów z akcjami do genero wania kodu.
{ push(\0,
S ->
rp) }
B B^B
X
B
{
2
w2top(wys)\pop{wys)\ wl := top(wys);pop(wys)\ push{max(w\,w2) wys) } y
B -> B sub B x
{ push(zmniejsz(top(rp)),rp) } {pop(rp)\ w2 : = top{wys)\pop{wys)\ w l : = top(wys);pop(wys); push(przemieść(w\ w2),wys) }
2
}
B
tekst
{ push{tekstw
x top(rp),wys) }
Rys. 5.49. Schemat translacji operujący na stosach rp i wys Przykład 5.27. Rozważmy teraz techniki implementacji definicji sterowanej składnią specyfikującej generacje kodu pośredniego. Wartość wyrażenia logicznego W and F jest fałszem, jeśli W jest fałszem. W języku C podwyrażenie F nie musi być obliczone, jeśli W jest fałszem. Obliczanie takich wyrażeń logicznych jest omówione w p . 8.4. Wyrażenia logiczne z definicji sterowanej składnią z rys. 5.50 są tworzone z identyfi katorów i operatora and. Każde wyrażenie W dziedziczy dwie etykiety W.true i W.false zaznaczające, że należy do nich wejść, jeśli W jest odpowiednio prawdą albo fałszem. Załóżmy, że W —• W and W . Jeśli obliczenie W będzie fałszem, to wykona nie programu przejdzie do miejsca wskazywanego przez dziedziczoną etykietę W.false. W przeciwnym przypadku, gdy W będzie prawdą, to zostanie wykonany kod obli czający W . Nowa etykieta generowana przez funkcję nowaetykieta zaznacza, począ tek kodu dla W . Poszczególne instrukcje są tworzone przy użyciu gen. Dalsza dysku sja dotycząca związku obliczeń wyrażeń logicznych i kodu pośredniego znajduje się w p. 8.4. Definicja sterowana składnią z rys. 5.50 jest L-atrybutowana, więc można skonstru ować dla niej schemat translacji. Schemat translacji z rys. 5.51 używa procedury emit do generowania instrukcji przyrostowo. Na rysunku widać również akcje ustawiające wartości atrybutów dziedziczonych, wstawione przed odpowiednim symbolem gramatyki (patrz p. 5.4). x
2
x
x
2
2
REGUŁY SEMANTYCZNE
PRODUKCJA W-¥W
X
and W
2
W .true W .false W .true W false W.kod x
x
2
2
W.kod
nowaetykieta W false W.true W.false W .kod || gen('\abe\'W .true) || W .kod x
x
f
2
1
gen('if id.pozycja 'goto W.true)\\ gen( goto' W.false)
Rys. 5.50. Częściowe obliczanie wyrażeń logicznych
W —y
{ W .true :— nowaetykieta; W .falset W.false } x
x
W and x
{ £?w7('label' W .true); W .true := W. true; W false := W.false } x
2
2
w
2
W -¥ id
1
{ emitCif id.pozycja'goto 'W.true); emit('goto' W.false) }
Rys. 5.51. Generowanie kodu dla wyrażeń logicznych
Schemat translacji z rys. 5.52 jest rozwinięciem schematu z rys. 5.49 i używa dwóch stosów do przechowywania wartości atrybutów dziedziczonych W.true i W.false. Jak w przykładzie 5.26, reguły kopiowania nie zmieniają stosów. D o implementacji reguły W .true := nowaetykieta, zanim W zostanie odwiedzone, nowa etykieta jest umieszczana na stosie dla true. Czas istnienia etykiety kończy się razem z akcją emir ('label' top(true)), odpowiadającą emit('\abe\' W .true), więc po tej akcji następuje zdjęcie wartości ze stosu. Stos dla false nie zmienia się w tym przykładzie, lecz jest on potrzebny, jeśli oprócz and zezwolimy na operator or. • x
x
x
W ->
{ push(nowaetykieta, true) } and
{ emif('label' top(true)); pop(łrue) }
w
2
W -» id
{ emit('if' id.pozycja''goto top(true)); emit('goto' top(false)) }
Rys. 5.52. Generowanie kodu dla wyrażeń logicznych
Rozłączne czasy istnienia Pojedynczy rejestr jest specjalnym przypadkiem stosu. Jeśli po każdej operacji push stępuje operacja pop, to w danej chwili na stosie może istnieć co najwyżej jeden elem Możemy wtedy użyć rejestru zamiast stosu. Jeśli czasy istnienia dwóch atrybutów są łączne, ich wartości mogą być trzymane w tym samym rejestrze.
Przykład 5.28. Definicja sterowana składnią z rys. 5.53 konstruuje drzewa składniowe dla wyrażeń przypominających listy, o operatorach mających ten sam priorytet. Żądamy, aby czas istnienia każdego atrybutu R kończył się, gdy atrybut zależny od niego został policzony. Można wykazać, że dla dowolnego drzewa wyprowadzenia atrybuty R mogą być policzone w tym samym rejestrze r. Poniższe rozważania są typo we dla analizy gramatyk. Indukcja jest przeprowadzana względem rozmiaru poddrzewa podpiętego pod R we fragmencie drzewa wyprowadzenia na rys. 5.54.
R E G U Ł Y SEMANTYCZNE
PRODUKCJA
R.s := S.wwsk W.wwsk := R.s
W^SR R -» opdodStf j
R s := twwęzeł(opdod.leksem, R.d, S.wwsk) R.s := R s v
v
R.s := R.d
R^>€
S.wwsk := twwęzeł (liczba, liczba, wart)
S -> liczba
Rys. 5.53. Przystosowana z rys. 5.28 definicja sterowana składnią
wwsk
wwsk
d
s
Rys. 5.54. Graf zależności dla W -> SR Najmniejsze poddrzewo otrzymuje się, jeśli zastosuje się R e. W tym przypadku jest kopią R.d, więc obie wartości znajdują się w rejestrze r. Dla większego poddrzewa produkcja w korzeniu musi mieć postać R -» opdod5/?j. Czas istnienia R.d kończy się, gdy obliczane jest R d, więc R .d może być obliczony w rejestrze r. Z założenia indukcyjnego wszystkie atrybuty dla egzemplarzy nieterminala R w poddrzewie R mogą być przypisane do tego samego rejestru. W końcu, R.s jest kopią R s, więc ta wartość znajduje się już w r. Według schematu translacji z rys. 5.55 oblicza się atrybuty w gramatyce atrybutowanej z rys 5.53, przy użyciu rejestru r do przechowywania wartości atrybutów R.d i R.s dla wszystkich egzemplarzy nieterminala R. v
x
x
v
W —• S R i? -> opdod S R R -» e 5 -> liczba
{ r := S.wwsk /* r przechowuje teraz wartość R.d */ } { W.wwsk := r / * r zawiera tf.s */ } { r := twwęzeł(opdod.leksem
}
r, S.wwsk) }
{ S.wu>s/: := rww^ze^liczba, liczba.warr) }
Rys. 5.55. Przekształcony schemat translacji do konstrukcji drzew składniowych Rysunek 5.56 zawiera kod implementujący powyższe schematy translacji. Jest on skonstruowany zgodnie z algorytmem 5.2. Nieterminal R nie ma już atrybutów, więc R staje się procedurą, a nie funkcją. Zmienna r jest lokalna w funkcji W, więc możliwe jest wywołanie rekurencyjne W, chociaż dla translacji z rys. 5.55 nie trzeba tego robić. Kod ten można ulepszyć przez eliminację rekurencji końcowej i zamianę pozostałego wywołania R przez treść procedury wynikowej, jak w p. 2.5. •
function W: t węzeł— drzewa_składniowego; var r: t węzeł—drzewa_ składniowego; leksem—opdod: char; procedurę R; begin if bieżący ~ opdod then begin leksem^opdod := lekswart; wczytaj (opdod)', r := twwęzeł(leksem~opdod, r, S); R end end; begin r~S; R return r end; Rys. 5.56. Porównaj procedurę R z kodem z rys. 5.31
5.10
Analiza definicji sterowanych składnią
W podrozdziale 5.7 atrybuty obliczaliśmy podczas przechodzenia drzewa przy użyciu zbioru wzajemnie rekurencyjnych funkcji. Funkcja dla nieterminala odwzorowuje war tości atrybutów dziedziczonych w węźle na wartości atrybutów syntezowanych w tym węźle. Podejście z podrozdziału 5.7 można rozszerzyć do translacji, której nie można wy konywać w trakcie przechodzenia w głąb. Chociaż grupy atrybutów syntezowanych moż na obliczyć pojedynczymi funkcjami, tutaj użyjemy oddzielnych funkcji dla każdego atrybutu syntezowanego każdego nieterminala. Konstrukcja z p. 5.7 dotyczy przypad ku szczególnego, w którym wszystkie atrybuty syntezowane formują pojedynczą grupę. Grupowanie atrybutów jest wyznaczone z zależności wynikających z reguł semantycz nych z definicji sterowanej składnią. Poniższy abstrakcyjny przykład ilustruje konstrukcję rekurencyjnej metody obliczającej. Przykład 5.29. Powodem utworzenia tej definicji sterowanej składnią z rys. 5.57 jest problem, który omówiliśmy w rozdz. 6. „Przeciążony" operator może mieć zbiór możli wych typów. Do wyboru jednego z nich dla każdego podwyrażenia jest używana informa cja kontekstowa. Ten problem można rozwiązać, wykonując przejście wstępujące w celu utworzenia zbioru możliwych typów, a następnie przejście zstępujące w celu zawężenia zbioru do pojedynczego typu. Reguły semantyczne z rysunku 5.57 przedstawiają ten problem. Atrybut syntezowany s reprezentuje zbiór możliwych typów i atrybut dziedziczony d reprezentuje informację kontekstową. Dodatkowy atrybut syntezowany t, który nie może zostać obliczony w tym
samym przebiegu co s, reprezentuje generowany kod lub typ wybrany dla podwyrażenia. Grafy zależności dla produkcji z rys. 5.57 są przedstawione na rys. 5.58. •
REGUŁY SEMANTYCZNE
PRODUKCJA s->w
w
-» w
{
d
w
s
5.57.
=
S.r
=
W.s
w
2
W-Md
Rys.
W.d
g(W.s) W.t W .s)
= /j(W J,
2
r
Wd y
=
fdĄW.d)
W .d 2
=
fd2(W.d)
W.t
= ft(W .t,
E.s
=
W.t
=
w .t)
{
2
id.s h(W.d)
A t r y b u t y s y n t e z o w a n e s i ; n i e m o g ą być o b l i c z o n e j e d n o c z e ś n i e
t
d Rys.
5.58.
w
s
t
d
w
s
t
Grafy z a l e ż n o ś c i d l a produkcji z rys.
i d
s
5.57
R e k u r e n c y j n e obliczanie a t r y b u t ó w Graf zależności dla drzewa wyprowadzenia jest tworzony przez sklejenie mniejszych grafów odpowiadających regułom semantycznym dla produkcji. Graf zależności D dla produkcji p bazuje jedynie na regułach semantycznych dla pojedynczej produkcji, czyli na regułach semantycznych dla atrybutów syntezowanych lewej strony i atrybutów dzie dziczonych symboli z prawej strony. Graf D przedstawia więc jedynie lokalne zależności. Dla przykładu, wszystkie krawędzie w grafie zależności dla W -> W W z rys. 5.58 znaj dują się między egzemplarzami tego samego atrybutu. Z tego grafu zależności nie można stwierdzić, że atrybuty s muszą być obliczone przed wszystkimi pozostałymi atrybutami. p
p
x
2
Po dokładnym przyjrzeniu się grafowi zależności dla drzewa wyprowadzenia z rys. 5.59 można zauważyć, że atrybuty każdego egzemplarza W muszą być policzone w kolejności W.s, W.d, W.t. Zauważmy, że wszystkie atrybuty z rys. 5.59 można obliczyć w trzech przebiegach: w trakcie przejścia wstępującego są obliczane atrybuty s, przejście zstępujące wylicza atrybuty d, a końcowe przejście wstępujące oblicza atrybuty t. W metodzie stosującej obliczenia rekurencyjne, funkcja dla atrybutu syntezowanego pobiera, jako parametry, wartości niektórych atrybutów dziedziczonych. Zwykle, jeśli atrybut syntezowany A.a może zależeć od atrybutu dziedziczonego A.b, to funkcja dla A.a pobiera A.b jako parametr. Zanim przeprowadzimy analizę zależności, rozważymy przykład pokazujący ich użycie.
S
V
d
id
* s
id
*
s
Rys. 5.59. Graf zależności dla drzewa wyprowadzenia Przykład 5.30. Funkcje Ws i Wt z rys. 5.60 zwracają wartości atrybutów syntezowa nych s i t w węźle n oznaczonym W. W funkcji dla nieterminali jest instrukcja case z przypadkami dla wszystkich produkcji (patrz p. 5.7). Kod wykonywany zawsze symu luje reguły semantyczne związane z produkcją z rys. 5.57. Z powyższej dyskusji na temat grafu zależności z rys. 5.59 wynika, że atrybut W.t w węźle drzewa wyprowadzenia może zależeć od W.d. Przekazujemy zatem atrybut dziedziczony d jako parametr do funkcji Wt dla atrybutu t. Atrybut W.s nie zależy od żadnych atrybutów dziedziczonych, więc funkcja Ws nie ma parametrów odpowiadających wartościom atrybutów. • Ściśle acykliczne definicje sterowane składnią Metody stosujące obliczenia rekurencyjne można skonstruować dla klasy definicji ste rowanych składnią, zwanych definicjami „ściśle acyklicznymi". Dla definicji z tej klasy atrybuty w każdym węźle dla nieterminala mogą być obliczone zgodnie z takim samym porządkiem (częściowym). Gdy konstruujemy funkcję dla atrybutu syntezowanego nie terminala, ten porządek jest używany do wyboru atrybutów dziedziczonych, które stają się parametrami funkcji. Podamy definicję tej klasy i wykażemy, że definicja sterowana składnią z rys. 5.57 należy do tej klasy. Następnie przedstawimy algorytm testujący cykliczność i ścisłą acykliczność oraz sposób, w jaki implementacja przykładu 5.30 może zostać rozszerzona na wszystkie definicje ściśle acykliczne. Rozważmy nieterminal A w węźle n w drzewie wyprowadzenia. Graf zależności dla drzewa wyprowadzenia może mieć ścieżki zaczynające się w atrybucie węzła n, prze chodzące przez atrybuty innych węzłów drzewa wyprowadzenia, i kończyć się w innym atrybucie n. Dla naszych potrzeb wystarczy jedynie przyjrzeć się ścieżkom, które nie wy chodzą poza poddrzewo o korzeniu A. Łatwo można zauważyć, że takie ścieżki prowadzą z pewnego atrybutu dziedziczonego A do pewnego atrybutu syntezowanego A. Można oszacować (być może zbyt pesymistycznie) zbiór takich ścieżek, rozważając częściowe porządki atrybutów A.
function Ws(n); begin case produkcja dla węzła n of 'W-*W W ': sl :—Ws(dziecko(n, 1)); s2 := Ws(dziecko(n, 2)); return fs(sl, s2); 'W->id': return id.s; default: błąd end end; X
2
function Wt(n, d); begin case produkcja dla węzła n of dl:=fdl(d); tl :=Wt{dziecko(n d2 :=fd2{d); t2:=Wt{dziecko(n, return ft{tl, t2); 'W id': return h(d); default: błąd end }
1), dl); 2), d2);
end; function Sr(n); begin 5 : = Ws(dziecko(n, :=*(*); t :=Wt(dziecko(n, return r
1)); ł), d);
end; Rys. 5.60. Funkcje dla atrybutów syntezowanych z rys. 5.57
Niech produkcja p zawiera po prawej stronie nieterminale A , A , . . . , A . Niech RAj będzie porządkiem częściowym atrybutów Aj, dla 1 ^ j n . D [RA , / M , . . . , RA ] oznaczamy graf otrzymany przez dodanie w następujący spo sób krawędzi do D \ jeśli w RAj atrybut Aj.b znajduje się przed Aj.c, to dodawana jest krawędź z Aj.b do A ..c. x
p
x
2
2
n
n
p
Definicja sterowana składnią nazywa się ściśle acykliczną, jeśli dla każdego nie terminala A można znaleźć taki porządek częściowy RA na atrybutach A, że dla każdej produkcji p z A po lewej stronie i nieterminalami A A , . . . , A po prawej: v
2
3
1) 2)
D [RA , RA , • • •, RA ] jest acykliczna oraz jeśli istnieje krawędź z atrybutu A.b do A.c w D [RA , atrybut A.fc znajduje się w A.c. P
{
2
n
p
P r z y k ł a d 5.31. Niech p będzie produkcją D znajduje się na środku rys. 5.58. Niech przypadku porządkiem całkowitym) s —> d dwa nieterminale W i W , zatem RW i RW jest przedstawiony na rys. 5.61. p
x
2
{
2
{
RA ,..., 2
# A ] , to w / M rt
W -> W W z rys. 5.57, której graf zależności RW będzie porządkiem częściowym (w tym —> t. Po prawej stronie produkcji p znajdują się są takie same jak RW, a graf D [RW , RW ] {
2
p
x
2
Rys. 5.61. Powiększony graf zależności dla produkcji Jedyne ścieżki między atrybutami związanymi z korzeniem W na rys. 5.61 prowadzą zddot. Ponieważ w RW atrybut i znajduje się przed t, więc warunek (2) jest spełniony. • Dla danej definicji ściśle acyklicznej i porządków częściowych RA dla każdego nieterminala A, funkcja dla atrybutu syntezowanego s symbolu A pobiera następujące argumenty: jeśli w RA atrybut i występuje przed s, to z jest argumentem, a w przeciwnym przypadku nie jest. Test cykliczności Definicję sterowaną składnią nazywa się cykliczną, jeśli graf zależności dla jakiegoś drzewa wyprowadzenia ma cykl. Definicje cykliczne są źle sformułowane i nie mają sen su. Nie ma sposobu na rozpoczęcie obliczania jakiegokolwiek atrybutu wewnątrz cyklu. Obliczanie porządku częściowego powodującego, że definicja jest ściśle acykliczna, jest związane ze sprawdzaniem, czy definicja jest cykliczna. Powinniśmy więc przeprowadzić najpierw test cykliczności. P r z y k ł a d 5.32. W poniższej definicji sterowanej składnią ścieżki między atrybutami A zależą od produkcji. Jeśli zastosowana jest produkcja A —• 1, to A.s zależy od A.d, w prze ciwnym przypadku tak nie jest. W celu uzyskania pełnej informacji o możliwych zależ nościach powinniśmy śledzić zbiory porządków częściowych atrybutów nieterminala.
PRODUKCJA
REGUŁY SEMANTYCZNE
S -> A A-» 1 A -> 2
A.d \— c A.s-f {A.d) A.s:— d
•
Pomysł, na którym opiera się algorytm z rys. 5.62, jest następujący. Porządki czę ściowe są reprezentowane za pomocą skierowanych, acyklicznych grafów. Dla danych grafów dla atrybutów symboli z prawej strony produkcji można wyznaczyć graf dla atry butów z lewej strony w sposób następujący. for symbol gramatyki X do &{X) zawiera pojedynczy graf z atrybutami X i bez krawędzi; repeat zmiana := false; for produkcja p o postaci A —> X X • • -X do begin for dag Gj G &{X ) . ..,G € &(X ) do begin £>:=£>,; for krawędź b c należąca do G-, 1 ^ j ^ k do dodaj do D krawędź między atrybutami b i c symbolu Xy, if D ma cykl then porażka testu cykliczności else begin G := nowy graf z węzłami dla atrybutów A i bez krawędzi; for każda para atrybutów b i c symbolu A do if istnieje ścieżka w D z b do c then dodaj b c do G; if G nie należy jeszcze do &{A) then begin dodaj G d o & ( A ) \ zmiana := true end end end end until zmiana — false X
x t
k
2
k
k
Rys. 5.62. Test cykliczności
Niech grafem zależności dla produkcji p o postaci A X X '--X będzie D . Niech Dj będzie grafem dag dla X- gdzie 1 ^ j ^ . k. Każda krawędź b—tawDj jest tymczasowo dodawana do grafu D dla tej produkcji. Jeśli graf wynikowy ma cykl, to ta definicja sterowana składnią jest cykliczna. W przeciwnym przypadku, ścieżki w wynikowym grafie wyznaczają nowy graf dag na atrybutach lewej strony produkcji i ten graf jest dodawany do ^(A). Czas działania testu cykliczności z rys. 5.62 dla każdego symbolu gramatyki X jest wykładniczy względem liczby grafów w zbiorach ^(X). Istnieją definicje sterowane składnią, które nie mogą zostać przetestowane na cykliczność w czasie wielomianowym. Algorytm z rysunku 5.62 można przekształcić w sposób opisany poniżej do bardziej efektywnego testu, jeśli definicja sterowana składnią jest ściśle niecykliczna. Zamiast utrzymywania rodziny grafów &(X) dla każdego X, informacja z całej rodziny jest łączona i trzymany jest pojedynczy graf F(X). Zauważmy, że wszystkie grafy w &(X) mają takie same węzły dla atrybutów X, ale mogą mieć różne krawędzie. F(X) jest grafem rozpiętym na węzłach dla atrybutów X, który ma krawędź między X.b a X.c, jeśli X
t
p
2
k
p
jakikolwiek graf w &(X) m a tę krawędź. F(X) reprezentuje „oszacowanie najgorszego przypadku" zależności między atrybutami X. W szczególności, jeśli F(X) jest acykliczny, to definicja sterowana składnią też będzie acykliczna. W przeciwnym jednak razie nie musi to być prawdą, czyli jeśli F(X) ma cykl, to definicja sterowana składnią nie musi być cykliczna. Zmodyfikowany test cykliczności konstruuje acykliczne grafy F(X) dla każdego X. Z tych grafów można skonstruować algorytm obliczający daną definicję sterowaną skład nią. Metoda jest uogólnieniem przykładu 5.30. Funkcja dla atrybutu syntezowanego X.s jako argumenty pobiera wszystkie atrybuty dziedziczone, które występują przed s w F (X). Funkcja wywoływana w węźle n wywołuje inne funkcje obliczające potrzebne atrybu ty syntezowane w dzieciach n. Procedury obliczające te atrybuty otrzymują wartości atrybutów, których potrzebują. Ścisła acykliczność jest wymagana po to, aby atrybuty dziedziczone mogły zostać policzone.
ĆWICZENIA 5.1 Dla wyrażenia wejściowego ( 4 * 7 + 1) *2 skonstruuj drzewo wyprowadzenia z przy pisami zgodnie z definicją sterowaną składnią z rys. 5.2. 5.2 Skonstruuj drzewo wyprowadzenia i drzewo składniowe dla wyrażenia ( ( a ) + ( b ) ) zgodnie z: a) definicją sterowaną składnią z rys. 5.9 b) schematem translacji z rys. 5.28. 5.3 Skonstruuj graf dag i zidentyfikuj liczby wartości dla podwyrażeń wyrażenia a + a + ( a + a + a + ( a + a + a + a ) ) , przy założeniu, że + jest łączne lewostronnie. *5.4 Podaj definicję sterowaną składnią do translacji wyrażeń infiksowych do infiksowych bez nadmiernych nawiasów. Przykładowo, ponieważ 4- i * są lewostronnie łączne, ( ( a * ( b + b ) ) * ( d ) ) można zapisać jako a* ( b + c ) *d. 5.5 Podaj definicję sterowaną składnią do odróżniania wyrażeń utworzonych z ope ratorów + i * zastosowanych do zmiennej x i stałych, na przykład, x * ( 3 * x + x * x ) . Załóż, że nie są wykonywane uproszczenia, czyli 3*x staje się po translacji 3 * x + 0 * l . 5.6 Poniższa gramatyka generuje wyrażenia utworzone przy zastosowaniu operatora arytmetycznego + do stałych całkowitych i rzeczywistych. Gdy dodawane są dwie liczby całkowite, to typ wyniku jest całkowity, w przeciwnym przypadku jest rze czywisty.
W -»w + s\s S -> liczba . liczba | liczba a) Podaj definicję sterowaną składnią wyznaczającą typ każdego podwyrażenia. b) Rozszerz definicję sterowaną składnią z punktu a) o translację wyrażeń do notacji postfiksowej (oprócz wyznaczania typów). Użyj jednoargumentowego operatora i n t t o r e a l do przekształcenia wartości całkowitych na odpowiadającą wartość rzeczywistą, tak, aby oba argumenty + w notacji postfiksowej miały ten sam typ.
5.7 Rozszerz definicję sterowaną składnią z rys. 5.22 tak, aby oprócz wysokości śledziła również szerokości bloków. Załóżmy, że terminal tekst ma atrybut syntezowany w zawierający znormalizowaną szerokość tekstu. 5.8 Niech atrybut syntezowany wart zawiera wartość liczby binarnej wygenerowa nej z S za pomocą następującej gramatyki. Na przykład, dla wejścia 101.101, S.wart = 5.625 S -> L.L\L L -> LB\B B -> 0 11 a) Użyj atrybutów syntezowanych do wyznaczenia S.wart. b) Wyznacz S.wart za pomocą definicji sterowanej składnią, w której jedynym atrybutem syntezowanym B jest c, zawierający udział bitu generowanego przez B w wartości wynikowej. Na przykład, wkłady pierwszego i ostatniego bitu w 101.101 d o wartości 5.625 są odpowiednio 4 i 0.125. 5.9 Przepisz gramatykę z definicji sterowanej składnią z przykładu 5.3 tak, aby infor macja o typie mogła być przekazywana jedynie przez atrybuty syntezowane. *5.10 Gdy instrukcje generowane przez poniższą gramatykę są tłumaczone na kod maszy ny abstrakcyjnej, instrukcja break jest tłumaczona na skok do instrukcji znajdującej się bezpośrednio za pętlą instrukcji while. Dla uproszczenia, wyrażenia są repre zentowane przez terminal wyr, a inne rodzaje instrukcji przez terminal inna. Te terminale mają atrybut syntezowany kod, zawierający ich translację. 5 -» | | |
while wyr do begin S end S ; S break inna
Podaj definicję sterowaną składnią tłumaczącą instrukcje na kod maszyny stosowej z p. 2.8. Upewnij się, że instrukcje break w zagnieżdżonych instrukcjach while są tłumaczone poprawnie. 5.11 Wyeliminuj lewostronną rekurencję z definicji sterowanej składnią z ćwiczenia 5.6 a), b). 5.12 Wyrażenia generowane przez następującą gramatykę mogą mieć wewnątrz przypi sania. S -> W W -*W : = W \W + W | (W ) | id Semantyka wyrażeń jest taka, j a k w języku C. Czyli na przykład b : = c jest wyra żeniem przypisującym wartość c d o b . r-Wartość tego wyrażenia jest taka, j a k c . Ponadto, a : = ( b : = c ) przypisuje wartość c do b i następnie d o a. a) Skonstruuj definicję sprawdzającą, czy lewa strona wyrażenia jest 1-wartością. Użyj atrybutu dziedziczonego strona symbolu W wskazującego, czy wyrażenie W pojawia się po lewej, czy po prawej stronie przypisania. b) Rozszerz definicję sterowaną składnią z a) o generowanie, w trakcie sprawdzania wejścia, kodu pośredniego dla maszyny stosowej z p . 2.8.
5.13 Przepisz gramatykę z ćwiczenia 5.12 tak, aby grupowała podwyrażenia : = do prawej, a podwyrażenia + do lewej. a) Skonstruuj schemat translacji symulujący definicję sterowaną składnią z ćwi czenia 5.12b). b) Zmodyfikuj schemat translacji z a) tak, aby emitował przyrostowo kod do pliku wynikowego. 5.14 Podaj schemat translacji sprawdzający, czy ten sam identyfikator nie pojawił się dwa razy na liście identyfikatorów. 5.15 Załóżmy, że deklaracje są generowane przez następującą gramatykę. D -> idL L-> idL\:T T -> integer | real 9
a) Skonstruuj schemat translacji wpisujący do tablicy symboli typ każdego iden tyfikatora, jak w przykładzie 5.3. b) Skonstruuj translator przewidujący ze schematu translacji z a). 5.16 Następująca gramatyka jest wersją jednoznaczną gramatyki z rys. 5.22. Nawiasy klamrowe { } są używane tylko do grupowania bloków i są eliminowane podczas translacji. S L B F
-> L -¥ LB\B -¥ BsubF\F -» {L}\tekst
a) Dostosuj definicję sterowaną składnią z rys. 5.22 do wykorzystywania powyż szej gramatyki. b) Przekształć definicję sterowaną składnią z a) na schemat translacji. *5.17 Rozszerz transformację eliminacji lewostronnej rekurencji z p. 5.5 tak, aby uwzględ niała dla nieterminala A z (5.2): a) atrybuty dziedziczone zdefiniowane regułami kopiowania, b) atrybuty dziedziczone. 5.18 Wyeliminuj lewostronną rekurencję ze schematu translacji z ćwiczenia 5.16b). *5.19 Załóżmy, że mamy L-atrybutowaną definicję, dla której gramatyka jest albo LL(1), albo taka, dla której można usunąć niejednoznaczności i skonstruować przewidu jący analizator składniowy. Pokaż, że atrybuty dziedziczone i syntezowane można trzymać na stosie analizatora zstępującego sterowanego tabelą analizy przewidują cej. *5.20 Udowodnij, że dodanie unikalnych nieterminali znaczników w dowolnych miej scach w gramatyce LL(1) powoduje powstanie gramatyki, która jest LR(1). 5.21 Rozważmy następującą modyfikację gramatyki LR(1) L-t Lb\a: L -» M -»
MLb\a
e
a) Jaką kolejność produkcji w drzewie wyprowadzenia zastosowałby analizator wstępujący dla napisu wejściowego
abbbl
*b) Pokaż, że zmodyfikowana gramatyka nie jest LR(1). *5.22 Pokaż, że w schemacie translacji bazującym na rys. 5.36 wartość atrybutu dzie dziczonego B.rp zawsze znajduje się bezpośrednio poniżej prawej strony, gdy re dukujemy prawą stronę do B. 5,23 Algorytm 5.3 dla analizy wstępującej i translacji z atrybutami dziedziczonymi używa nieterminali znaczników do przechowywania wartości atrybutów dziedzi czonych w przewidywanych pozycjach na stosie analizatora składniowego. Potrzeb nych byłoby mniej znaczników, gdyby wartości atrybutów były umieszczane na stosie odrębnym od stosu analizatora. a) Przekształć defnicję sterowaną składnią z rys. 5.36 do schematu translacji. b) Tak zmodyfikuj schemat translacji skonstruowany w a), aby wartość atrybutu dziedziczonego rp pojawiała się na odrębnym stosie. Wyeliminuj nieterminal znacznika M w tym procesie. *5.24 Rozważmy translację w trakcie analizy, jak w ćwiczeniu 5.23. S. C. Johnson suge ruje następującą metodę symulacji odrębnego stosu dla atrybutów dziedziczonych, przy użyciu znaczników i zmiennej globalnej dla każdego atrybutu dziedziczonego. W produkcji wartość v jest przenoszona na stos d przez pierwszą akcję i pobierana w następnej: A -¥ a { push(v,d)
} j3 { pop(d)
}
Stos d może być symulowany następującymi produkcjami, używającymi zmiennej globalnej g i nieterminala znacznika M z atrybutem syntezowanym s: A
-> a M P
{g:=M.s}
M —> e { M.s := g; g := v }
a) Zastosuj tę transformację do schematu translacji z ćwiczenia 5.23b). Zamień wszystkie odwołania do wierzchołka oddzielnego stosu na odwołania do zmien nej globalnej. b) Wykaż, że schemat translacji skonstruowany w a) oblicza tę samą wartość dla atrybutu syntezowanego symbolu startowego, co schemat z ćwiczenia 5.23b). 5.25 Użyj podejścia z podrozdziału 5.8 do implementacji wszystkich atrybutów W.strona w schemacie translacji z ćwiczenia 5.12b) za pomocą pojedynczej zmiennej lo gicznej. 5.16 Zmodyfikuj użycie stosu podczas przechodzenia w głąb w przykładzie 5.26 tak, aby wartości na stosie odpowiadały tym trzymanym na stosie analizatora w przykładzie 5.19.
UWAGI B I B L I O G R A F I C Z N E Użycie atrybutów syntezowanych do specyfikacji translacji języka pojawiło się u Ironsa [1961]. Pomysł analizatora składniowego wywołującego akcje semantyczne omówili
Samelson i Bauer [1960] oraz Brooker i Morris [1962]. Grafy zależności, atrybuty dzie dziczone i test ścisłej acykliczności pojawił się u Knutha [1968]; test cykliczności jest w poprawkach do tego artykułu. Rozszerzony przykład w tym artykule wykorzystuje ograniczone efekty uboczne dla globalnych atrybutów umieszczonych w korzeniu drzewa wyprowadzenia. Jeśli atrybuty mogą być funkcjami, atrybuty dziedziczone mogą zostać wyeliminowane. Podobnie jak w semantykach denotacyjnych, z nieterminalem można związać funkcję o argumentach będących atrybutami dziedziczonymi i dającą w wyniku atrybuty syntezowane. Takie obserwacje zamieścił Mayoh [1981]. Jednym z zastosowań, w którym efekty uboczne w regułach semantycznych są niepo żądane, jest edycja sterowana składnią. Załóżmy, że edytor jest generowany z gramatyki atrybutowanej dla języka źródłowego, jak u Repsa [1984], i rozważmy zmianę edycyjną w programie źródłowym, której wynikiem jest skasowanie części drzewa wyprowadze nia dla programu. Tak długo, jak nie ma efektów ubocznych, wartości atrybutów dla zmienionego programu mogą być obliczane przyrostowo. Ershov [1958] użył funkcji mieszających do śledzenia wspólnych podwyrażeń. Definicja gramatyk L-atrybutowanych w artykule Lewisa, Rosenkrantza i Stearnsa [1974] pojawiła się z powodu translacji odbywającej się w trakcie analizy składniowej. Podobne ograniczenia zależności między atrybutami zastosował w każdym z przejść z lewej do prawej w głąb Bochmann [1976]. Gramatyki afiksowe, wprowadzone przez Kostera [1971], są podobne do gramatyk L-atrybutowanych. Ograniczenia na gramatyki L-atrybutowane zaproponowali Koskimies i Raiha [1983] do kontroli dostępu do atrybu tów globalnych. Mechaniczna konstrukcja translatora przewidującego, podobna do tej z algorytmu 5.2, jest opisana przez Bochmanna i Warda [1978]. Wrażenie, że zstępująca analiza skła dniowa pozwala na większą elastyczność w translacji, zostało obalone przez Brogsola [1974], który udowodnił, że schemat translacji bazujący na gramatyce LL(1) może być symulowany przez analizę składniową LR(1). Watt natomiast [1977] wykorzystał nieter minale znaczników w celu zapewnienia, że wartości atrybutów dziedziczonych znajdą się na stosie w trakcie analizy wstępującej. Pozycje po prawych stronach produkcji, w któ rych można bezpiecznie umieścić znaczniki bez utracenia własności LR(1), są rozważane u Purdoma i Browna [1980] (patrz ćwiczenie 5.21). Proste wymaganie, aby atrybuty dzie dziczone były zdefiniowane za pomocą reguł kopiowania, nie wystarcza do zapewnienia, że atrybuty zostaną policzone podczas analizy wstępującej. Warunki dostateczne dla reguł semantycznych zostały podane przez Tarhia [1982]. Charakterystyka stanów analizatora składniowego atrybutów, które można obliczyć w trakcie analizy LR(1), jest przedstawio na u Jonesa i Madsena [1980], Jako przykład translacji, która nie może zostać wykonana w trakcie analizy składniowej, Giegerich i Wilhelm [1978] rozważają generację kodu dla wyrażeń logicznych. Z podrozdziału 8.6 dowiemy się, że do rozwiązania tego pro blemu można użyć poprawiania (ang. backpatching), więc pełen drugi przebieg nie jest potrzebny. Powstało wiele narzędzi służących do implementacji definicji sterowanych składnią, zaczynając od FOLDS (Fang [1972]), jednak tylko kilka było powszechnie używanych. DELTA (Lorho [1977]) konstruowała graf zależności w trakcie kompilacji. Oszczędzało to pamięć dzięki śledzeniu czasu istnienia atrybutów i eliminacji reguł kopiowania. Me tody obliczania atrybutów oparte na drzewach wyprowadzenia są omówione przez Ken n e d y e g o i Ramanathana [1979] oraz Cohena i Harry'ego [1979].
Przegląd metod obliczania atrybutów znajduje się u Engelfrieta [1984]. Towarzyszą cy atrykuł Courcelle'a [1984] zawiera podstawy teoretyczne. HLP, opisany przez Raiha i innych [1983], wykonuje kolejne przejścia w głąb, jak zasugerowali Jazayeri i Walter [1975]. LINGUST, opisany przez Farrowa [1984], również wykonuje kolejne przebiegi. Ganzinger i inni [1982] stwierdzili, że MUG zezwala na wyznaczenie kolejności od wiedzania dzieci węzła na podstawie produkcji dla tego węzła. GAG, według Kastensa, Hutta i Zimmermana [1982] pozwala na wielokrotne odwiedziny dziecka węzła. GAG implementuje klasę uporządkowanych gramatyk atrybutowanych zdefiniowaną przez Ka stensa [1980]. Pomysł powtarzanych odwiedzin pojawia się we wcześniejszym artykule Kennedy'ego i Warrena [1976], w którym przedstawiono metody do obliczania więk szej klasy gramatyk ściśle acyklicznych. Saarinen [1978] opisuje modyfikację metody Kennedyego i Warrena, która oszczędza pamięć, przechowując wartości atrybutów na stosie, jeśli nie są potrzebne podczas późniejszych odwiedzin. Implementacja opisana przez Jourdana [1984] konstruuje rekurencyjne metody obliczające dla tej klasy. Metody rekurencyjne zostały również opisane przez Katayamę [1984]. Całkiem innego podejścia użył Madsen [1980], tworząc NEATS, w którym grafy dag są skonstruowane dla wyrażeń reprezentujących wartości atrybutów. Analiza zależności w czasie konstrukcji kompilatora może zaoszczędzić czas i pa mięć w trakcie kompilacji. Testowanie cykliczności jest typowym problemem analizy. Jazayeri, Ogden i Rounds [1975] dowodzą, że test cykliczności wymaga wykładnicze go czasu względem rozmiaru gramatyki. Techniki poprawiania implementacji testu cy kliczności były rozważane przez Lorho i Paira [1975], Raiha i Saarinena [1982] oraz Deransarta, Jourdana i Lorho [1984]. Naiwne metody obliczające używały dużo pamięci, co doprowadziło do rozwinięcia technik jej oszczędzania. Algorytm przydziału wartości atrybutów do rejestrów z p. 5.8 został opisany w całkiem odmiennym kontekście przez Marilla [1962]. Dowód, że pro blem znalezienia porządku topologicznego w grafach zależności, minimalizujący liczbę rejestrów, jest NP-zupełny, znajduje się u Sethiego [1975]. Analiza w trakcie kompilacji czasów istnienia w wieloprzebiegowych metodach obliczania pojawia się u Raiha [1981] oraz u Jazayeriego i Pozefsky'ego [1981]. Branąuart i inni [1976] wspominają użycie oddzielnych stosów do trzymania syntezowanych i dziedziczonych atrybutów podczas przechodzenia. GAG wykonuje analizę czasu istnienia i umieszcza wartości atrybutów w miarę potrzeby w zmiennych globalnych, na stosach i w węzłach drzewa wyprowadze nia. Porównanie z technikami oszczędzającymi pamięć używanymi w GAG i LINGUST przeprowadzili Farrow i Yellin [1984],
ROZDZIAŁ
Kontrola typów
Kompilator musi sprawdzać, czy program źródłowy przestrzega zasad obowiązujących w języku pod względem syntaktycznym i semantycznym. Taka kontrola, nazywana kon trolą statyczną (dla odróżnienia od kontroli dynamicznej, która następuje podczas wyko nywania programu wynikowego), zapewnia, że określone rodzaje błędów zostaną wykryte i zgłoszone. Oto przykłady kontroli statycznej. 1.
2.
3.
4.
Kontrola typów. Kompilator powinien zgłosić błąd, jeżeli operator został zastoso wany do nieprawidłowego argumentu, na przykład dodawanie do siebie zmiennej tablicowej i zmiennej funkcyjnej. Kontrola przepływu sterowania. Jeżeli instrukcja powoduje opuszczenie konstrukcji przez sterowanie, to musi istnieć jakieś miejsce, do którego to sterowanie zostanie przekazane. Na przykład instrukcja break w C powoduje opuszczenie przez stero wanie najbliższej zamykającej ją pętli while, for lub wyrażenia switch; jeżeli taka zamykająca instrukcja nie istnieje, wystąpi błąd. Kontrola unikalności. Są sytuacje, w których obiekt musi być zdefiniowany tylko raz. Na przykład w Pascalu identyfikator musi być unikalny, etykiety w instrukcji case muszą być różne, a elementy w typie skalarnym nie mogą się powtarzać. Kontrola związana z nazwą. Czasami taka sama nazwa musi się pojawić dwa lub więcej razy. Na przykład w Adzie pętla lub blok mogą mieć nazwę, która pojawia się na ich początku i końcu. Kompilator musi sprawdzić, czy w obu tych miejscach jest użyta ta sama nazwa.
W tym rozdziale skupimy się na kontroli typów. Jak wynika z powyższych przykła dów, większość innych kontroli statycznych jest rutynowa i może być zaimplementowana przy użyciu technik znanych już z poprzedniego rozdziału. Niektóre z nich mogą być połączone z innymi czynnościami. Na przykład, gdy wpiszemy informację o nazwie do tablicy symboli, możemy sprawdzić, czy ta nazwa jest unikalna. Wiele kompilatorów Pascala łączy kontrolę statyczną oraz generację kodu pośredniego z analizą składnio wą. Bardziej skomplikowane konstrukcje, jak te z Ady, powinny jednak mieć oddzielny przebieg sprawdzający poprawność typów, znajdujący się między analizą składniową a generacją kodu pośredniego, jak to pokazano na rys. 6.1.
Strumień symboli— leksykalnych
Generator Analizator Drzewo _ Kontroler Drzewo kodu typów składniowe pośredniego składniowy składniowe >
Rys.
Repre zentacja pośrednia
6.1. P o ł o ż e n i e kontrolera t y p ó w
Kontroler typów sprawdza, czy typ użyty w danym wyrażeniu jest zgodny z tym wynikającym z jego kontekstu. Na przykład w Pascalu wbudowany operator arytmetyczny mod wymaga argumentów typu całkowitego, a więc kontroler typów musi sprawdzić, czy argumenty mod są typu całkowitego. Kontroler typów również powinien sprawdzić, czy dereferencja jest zastosowana do typu wskaźnikowego, czy indeksowanie jest używane tylko dla tablic, czy funkcje zdefiniowane przez użytkownika mają prawidłową liczbę i typ argumentów i tak dalej. Specyfikacje prostego kontrolera typów znajdują się w p. 6.2, natomiast reprezentacja typów oraz rozważania, kiedy dane typy są zgodne — w p. 6.3. Informacje o typach, zebrane przez kontrolera typów, mogą być przydatne podczas generacji kodu. Przykładowo, operator + najczęściej jest stosowany do typów całkowitych lub rzeczywistych, czasem zaś do innych, musimy więc ocenić kontekst, żeby określić, w jakim sensie został użyty. Symbol, który może reprezentować różne operacje w różnych kontekstach, nazywamy „przeciążonym". Przeciążanie może być związane z wymusza niem typów, kiedy kompilator dostarcza operator do konwersji argumentu na typ zgodny z kontekstem. Odwrotnym pojęciem do przeciążania jest „polimorfizm". Treść funkcji polimorficznej może być wykonana z argumentami różnych typów. Algorytm zrównania dla typów funkcji polimorficznych opisaliśmy w p. 6.7.
6.1
Systemy typów
Projektowanie kontrolera typów dla języka jest oparte na informacjach dotyczących syntaktycznych konstrukcji języka, pojęcia typów i zasad przypisywania typów do konstrukcji języka. Poniższe fragmenty raportu Pascala i podręcznika C są przykładowymi informa cjami, od których programista może zacząć pisać kompilator: • •
„Jeśli oba argumenty operacji dodawania, odejmowania lub dzielenia są liczbami całkowitymi, to wynik także jest liczbą całkowitą". „Wynikiem jednoargumentowego operatora & jest wskaźnik obiektu podawany przez argument tego operatora. Jeżeli typem argumentu tego operatora jest to wów czas typem rezultatu jest 'wskaźnik na . . " .
Z powyższych fragmentów można wywnioskować, że z każdym wyrażeniem jest zwią zany typ. Co więcej, typy te mają strukturę; typ „wskaźnik na . . j e s t zbudowny z typu, który wskazuje „ . . . " . Zarówno w Pascalu, jak i w C mamy typy proste i złożone. Typy proste są widziane przez programistę jako niepodzielne, bez żadnej wewnętrznej struktury. W Pascalu pod stawowymi typami są boolean, character, integer oraz real. Podzakresy, takie jak 1 . . 1 0 , oraz typy wyliczeniowe, np.
6.1
327
SYSTEMY TYPÓW
(fioletowy, inclygo, niebieski, zielony, zolty, pomarańczowy, czerwony) mogą być także traktowane jako typy podstawowe. Pascal umożliwia programiście tworze nie typów złożonych z typów prostych oraz z innych typów złożonych, takich jak tab-break lice, rekordy czy zbiory. Wskaźniki i funkcje również mogą być traktowane jako typy złożone.
Wyrażenia określające typy Typ konstrukcji językowych będziemy nazywać „wyrażeniem określającym typ". Niefor malnie, wyrażenie określające typ jest albo typem podstawowym, albo jest utworzone przez zastosowanie operatora — zwanego konstruktorem typów — dla innych wyrażeń typów. Zbiór podstawowych typów i wyrażeń określających typy zależy od języka pro gramowania. W tym rozdziale posługujemy się następującą definicją wyrażeń określających 1.
2.
3.
typy:
Typ podstawowy jest wyrażeniem określającym typ; typem podstawowym jest np.: boolean, char, integer i real. Specjalny typ podstawowy — bląd-typu, sygnalizuje błąd podczas kontroli typów, natomiast typ podstawowy void oznaczający „brak wartości" pozwala na sprawdzenie instrukcji. Skoro wyrażenie określające typ może być nazwane, nazwa typu jest wyrażeniem określającym typ. Przykład użycia nazw typów przedstawiliśmy w punkcie 3c) (patrz poniżej); wyrażenia określające typy zawierające nazwę omówiliśmy w p. 6.3. Konstruktor typu zastosowany do wyrażenia określającego typ jest wyrażeniem okre ślającym typ. Konstruktory zawierają: a)
Tablice. Jeżeli T jest wyrażeniem określającym typ, wówczas array(I, T) jest wyrażeniem określającym typ, oznaczającym typ tablicowy o elementach typu T i zbiorze indeksów / . Zbiór / jest często zbiorem liczb całkowitych. Na przykład deklaracja w Pascalu
var A: array[1..10] of integer; wiąże wyrażenie określające typ array( 1.. 10, integer) z A. b)
Produkcje. Jeżeli T i T są wyrażeniami określającymi typy, to ich iloczyn kartezjański T x T jest wyrażeniem określającym typ. Zakładamy, że x jest operatorem lewostronnym. {
{
c)
2
2
Rekordy. Różnica między rekordem a produkcją polega na tym, że pola rekordu mają nazwy. Konstruktor typu rekordowego będzie więc miał dwa argumenty nazwy pól i ich typy. (Pod względem technicznym nazwy pól powinny być częścią konstruktora typu, ale wygodniej jest przechowywać nazwy pól razem ze związanymi z nimi typami. W rozdziale 8 konstruktor typu rekordowego jest stosowany dla wskaźnika na tablicę symboli, w której są przechowywane nazwy pól). Przykładowo, następujący fragment programu w Pascalu
type
var
wiersz
=
recorcl adres: integer; l e k s e m : a r r a y [ 1 . . 1 5 ] of end; a r r a y [ 1 . . 1 0 1 ] of w i e r s z ;
tabela:
char
deklaruje zmienną o nazwie typu w i e r s z reprezentującą określenie typu record((adres
d)
15,char)))
i zmienna t a b e l a jest tablicą rekordów tego typu. Wskaźniki. Jeżeli T jest wyrażeniem określającym typ, to wskaźnik(T) jest wy rażeniem określającym typ oznaczającym „wskaźnik na obiekt typu T\ Przy kładowo, deklaracja w Pascalu var
e)
x integer) x ( l e k s e m x array(1..
p:
t
wiersz
deklaruje zmienną p typu wskaźnifc(wiersz). Funkcje. W ujęciu matematycznym funkcja przekształca elementy jednego zbio ru — dziedziny, w elementy drugiego zbioru — wartości funkcji. Funkcję w j ę zykach programowania możemy potraktować jako odwzorowanie dziedziny ty pów D w wartości typów R i zapisujemy to D —> R. Przykładowo, wbudowana funkcja Pascala m o d ma dziedzinę typu int x int, czyli parę liczb całkowitych i zbiór wartości typu int (liczba całkowita). Możemy więc powiedzieć, że mod jest typu 1
int x int —» int Innym przykładem jest deklaracja w Pascalu function
f (a,
b:
char)
: T integer;
•••
która oznacza, że dziedzina typu f jest określona jako char x char, a zakres typu jako wskainik(integef). Typ funkcji f jest więc opisany jako wyrażenie określające typ char x char ->
wskaźnik(integer)
Często, ze względów implementacyjnych omówionych w następnym rozdzia le, istnieją ograniczenia typu, który może zwrócić funkcja: np. nie może ona zwrócić tablic ani funkcji. Jednakże istnieją języki, spośród których Lisp jest najbardziej znanym, które pozwalają funkcjom zwracać obiekty dowolnych ty pów, a więc na przykład zdefiniować funkcję g typu {integer -» integer) -» (integer —> integer) Znaczy to, że g jako argument bierze odwzorowanie integer w integer, i g jako rezultat produkuje inną funkcję tego samego typu. 4.
1
Wyrażenia określające typy mogą zawierać zmienne, których wartości są wyraże niami określającymi typy. Typy zmiennych są opisane w p. 6.6.
Zakładamy, że x ma wyższy priorytet niż tym -> jest operatorem prawostronnym.
więc int x int
int jest tym samym co (int x int) -> int. Poza
Wygodnym sposobem prezentacji wyrażeń określających typy jest użycie grafu. Sto sując sposób kierowania składnią omówiony w p. 5.2, możemy skonstruować drzewo lub graf dag dla wyrażeń określających typy, w którym wewnętrzne węzły są konstruktorami typów, liście — podstawowymi typami, nazwami typów i zmiennymi typów (rys. 6.2). Przykłady reprezentacji wyrażeń określających typy, użytych w kompilatorach, podaliśmy w p. 6.3.
char
char
wskaźnik
x
wskaźnik
integer
cchar>
integer
Rys. 6.2. Drzewo i graf dag dla char x char —• wskaznik(integer)
Systemy typów System typów jest zbiorem zasad przyporządkowującym wyrażenia określające typy róż nym częściom programu. Kontroler typów implementuje system typów. Systemy typów, prezentowane w tym rozdziale, są określone w sposób zależny od składni, a więc z ła twością mogą być zaimplementowane przy użyciu technik omówionych w poprzednim rozdziale. Różne systemy typów mogą być używane przez różne kompilatory i procesory tego samego języka. Na przykład w Pascalu typ tablicowy zawiera zbiór indeksów danej ta blicy, a więc funkcja z argumentem tablicowym może być zastosowana tylko do tablic, które mają ten sam zbiór indeksów. Jednakże wiele kompilatorów Pascala dopuszcza stosowanie tablic jako argumentów bez podawania zakresu indeksów. A więc te kom pilatory używają innego systemu typów niż ten z definicji języka Pascal. Podobnie jest w systemie UNIX — polecenie l i n t sprawdza, czy programy w C mają dopuszczalne błędy, używając bardziej dokładnego systemu typów niż sam kompilator C.
Statyczna i dynamiczna kontrola typów Kontrolę typów wykonywaną przez kompilator nazywamy statyczną, natomiast sprawdza nie podczas działania programu wynikowego — sprawdzaniem dynamicznym. Właściwie każde sprawdzenie może być zrobione dynamicznie, jeżeli kod wynikowy zawiera typ elementu wraz z jego wartością. Solidny system typów eliminuje potrzebę dynamicznego sprawdzania błędów typu, ponieważ pozwala określić statycznie, że dane błędy nie mogą wystąpić podczas działania programu. To znaczy, że jeżeli solidny system typów przyporządkuje do części programu inny typ niż błąd-typu, to podczas wykonywania tej części programu wynikowego nie wystąpią błędy typów. Język nazywamy ściśle typowanym, jeżeli jego kompilator może zapewnić, że programy, które zaakceptuje, zostaną wykonane bez błędów typów. W praktyce, niektóre sprawdzenia mogą być zrobione dynamicznie. Przykładowo, jeżeli na początku zadeklarujemy
tabela: array[0..255] i: integer
of
char;
a następnie obliczymy wartość t a b e l a [ i ] , kompilator nie zawsze będzie mógł zagwa rantować, że podczas wykonania wartość i będzie z zakresu od 0 do 255 P o p r a w i a n i e błędów Skoro kontrola typów może znaleźć błędy w programie, bardzo ważne jest, aby kontroler typów mógł wykonać cokolwiek sensownego, gdy wykryje błąd. Kompilator powinien przynajmniej poinformować o rodzaju i miejscu wystąpienia błędu. Pożądane jest, aby kontroler typów odzyskiwał kontrolę po błędzie i aby mógł sprawdzić pozostałe dane wej ściowe. Ponieważ obsługa błędów ma wpływ na zasady sprawdzania poprawności typów, należy ją zaprojektować dla systemu typu zaraz na początku; musi być tak przygotowana, aby radziła sobie z błędami. Włączenie obsługi błędów może powodować, że system typów wykroczy poza wy magane specyfikowanie poprawnych programów. Przykładowo, gdy wystąpi błąd, typ nieprawidłowo zbudowanej części programu może być nieznany. Radzenie sobie z bra kiem takiej informacji wymaga techniki podobnej do stosowanej w językach, które nie potrzebują, aby identyfikatory były zadeklarowane przed użyciem. Zmienne typu, omó wione w p. 6.6, mogą być używane do zapewnienia spójnego użycia niezadeklarowanych lub źle zadeklarowanych identyfikatorów.
6.2
Specyfikacja prostego kontrolera typów
W tym podrozdziale opisaliśmy kontrolera typów dla prostego języka, w którym każdy identyfikator musi być zadeklarowany zanim zostanie użyty. Kontrolera typów możemy przedstawić w postaci schematu translacji, w którym typ danego wyrażenia jest zbu dowany z typów tworzących j e podwyrażeń. Kontroler typów może obsługiwać tablice, wskaźniki, instrukcje i funkcje. Prosty język Gramatyka przedstawiona na rys. 6.3 generuje programy, prezentowane przez nieterminalny symbol P , składający się z sekwencji deklaracji D poprzedzających pojedyncze wyrażenie W. P D ->
D\W D;D\id:T
T —> char | integer | array [ liczba ] of T \ T T W litera | liczba | id | W mod W | W [ W ] | W t Rys. 6.3. Gramatyka języka źródłowego 1
Techniki analizy przepływu danych, podobne do tych z rozdz. 10, mogą być użyte do wnioskowania, czy i jest w danym zakresie w niektórych programach. Jednakże nie ma takiej techniki, dzięki której możemy podjąć prawidłową decyzję we wszystkich przypadkach.
Programem wygenerowanym przez gramatykę z rys. 6.3 jest
key: integer; key mod 1999 Przed omówieniem wyrażeń, zajmijmy się typami w tym języku. Sam język ma dwa podstawowe typy: char i integer; trzecim podstawowym typem jest btąd^typu używany do sygnalizowania błędów. Dla uproszczenia przyjmijmy, że indeksy wszystkich tablic rozpoczynają się od ł. Na przykład
array [256] of char oznacza wyrażenie określające typ array(l..256, char) składające się z konstruktora array, zastosowanego dla podzakresu 1..256, i typu char. Tak jak w Pascalu, przed rostkowy operator T w deklaracji tworzy wskaźnik do typu, a więc
t integer oznacza wyrażenie określające typ wskaźnik(integer), składające się z konstruktora wskaź nik zastosowanego dla typu integer. W schemacie translacji z rys. 6.4 czynnością związaną z produkcją D -> id : T jest zachowanie typu dla identyfikatora w tablicy symboli. Czynność dodajtyp(\d.wpis, T.typ) jest stosowana dla otrzymanego atrybutu wpis wskazującego id w tablicy symboli i określenie typu reprezentowane przez utworzony atrybut typ nieterminalnego T. Jeżeli T generuje char lub integer, wówczas T.typ jest zdefiniowany jako char lub integer. Górną granicę tablicy otrzymuje się z atrybutu val symbolu leksykalnego liczba, która zwraca liczbę całkowitą reprezentowaną przez liczba. Z założenia tablice rozpoczynają się od 1, a więc typ konstruktora array jest stosowany do podzakresu 1..liczba.val i typu danego elementu. Skoro D pojawia się przed W po prawej stronie P - » D ; W, możemy być pewni, że typy wszystkich zadeklarowanych identyfikatorów będą zapamiętane zanim zostanie sprawdzone wyrażenie wygenerowane przez W (patrz rozdz. 5). Faktycznie, jeśli odpo wiednio zmodyfikujemy gramatykę z rys. 6.3, to możemy zaimplementować schemat translacji w tej części podczas analizy składniowej wstępującej lub zstępującej.
P D D T T
D ;W -¥ D \ D -> id : T -» char —> integer T T T - 4 array [ liczba ] of T
{ dodaj typ(id. wpis, T.typ) } { T.typ = char } {T.typ ~ integer } { T.typ = wskaźnik(T typ) } { T.typ = array(l...\icźba.wart, v
T.typ) }
Rys. 6.4. Część schematu translacji, która zapamiętuje typ identyfikatora
Sprawdzanie typów wyrażeń W poniższych zasadach otrzymany atrybut typ dla W oznacza wyrażenie określające typ wyznaczone przez system typów dla wyrażenia wygenerowanego przez W. Według
następujących zasad semantycznych stałe reprezentowane przez symbole leksykalne napis i liczba są typu char i integer, a więc: W -¥ napis W -f liczba
{ W.typ := char } { W.typ := integer }
Funkcji znajdzie) używamy, aby uzyskać typ zachowany w tablicy symboli pod indeksem e. Kiedy identyfikator pojawi się w wyrażeniu, jego zadeklarowany typ jest zapamiętywany i przypisany do atrybutu typ W
-> id
{ W.typ
:= znajdź(\d.wpis)
}
Wyrażenie otrzymane po zastosowaniu operatora mod na dwóch wyrażeniach typu integer ma typ integer; w przeciwnym przypadku j e g o typem jest błąd-typu. Zasadą jest W
W mod W x
2
{ W.typ := if W .typ = integer and W .typ — integer then integer else błąd-typu } x
2
W odwołaniu do tablicy W [W ] wyrażenie W musi być typu całkowitego i wtedy rezultatem jest element typu t uzyskany z typu W array(s, t); nie używamy tutaj zbioru s indeksów tablicy x
2
t
2
x
W -> W [ W ] x
2
{ W.typ : - if W .typ = integer and W .typ = array(s, t) then t else błąd-typu } 2
x
Operator przyrostka T zwraca w wyrażeniach obiekt wskazywany przez j e g o argument. Typ W T jest typem t obiektu wskazywanego przez wskaźnik W W -> W T x
{ W.typ := if W .typ = wskainik(t) else błąd- typu } x
then t
Pozostawiamy Czytelnikowi dodanie produkcji i zasad semantycznych umożliwiają cych użycie w wyrażeniach dodatkowych typów i operacji. Na przykład, aby identyfikato ry mogły być typu boolean, możemy wprowadzić produkcję T boolean do gramatyki z rys. 6.3. Wprowadzenie operatorów porównania, jak < , i operatorów logicznych, jak and, do naszych produkcji dla W umożliwiłoby konstruowanie wyrażeń typu boolean.
Kontrola typu instrukcji Ponieważ konstrukcje językowe, takie jak instrukcje, zazwyczaj nie mają wartości, mo że być do nich przypisany specjalny podstawowy typ void. Jeżeli w instrukcji zostanie wykryty błąd, przypisany do niej zostanie typ błąd-typu. Instrukcje, które rozpatrujemy, to przypisania, warunki i instrukcje while. Sekwencje instrukcji są oddzielone średnikami. Produkcje z rys. 6.5 mogą być połączone z tymi z rys. 6.3, jeżeli zmienimy produkcje dla całego programu na P —> D ; 5. Program będzie się wtedy składał z deklaracji poprzedzających instrukcje; powyższe reguły sprawdzania wyrażeń są wciąż potrzebne, ponieważ instrukcje mogą zawierać wyrażenia.
S -» id := W S -> if W then Sj S -» while U/ do 5 S -> 5j ; 5
t
{ S.typ := if id.typ = W.typ then void else kod- błędu } { S.typ := if W.ry/? = boolean then Spfyp else kod- błędu } { S.fyp := if W.typ = boolean then S .typ else £odL 6Ć£rfw } { S.ryp := if S^OT = wirf and S .typ = wwd then wirf } else kod-błędu } x
7
2
2
Rys. 6.5. Schemat translacji do kontroli typu instrukcji
Zasady kontroli instrukcji są podane na rys. 6.5. Pierwsza zasada sprawdza, czy stro na prawa i lewa instrukcji przypisania mają ten sam t y p . Druga i trzecia zasada określa, że wyrażenie w warunku i instrukcji while musi być typu boolean. Błędy są generowane przez ostatnią zasadę z rys. 6.5, ponieważ sekwencje instrukcji są typu void tylko wtedy, kiedy wszystkie ich podinstrukcje są typu void. W tych zasadach, niedopasowanie typów produkuje typ błąd-typu\ przyjazny kontroler typów powinien oczywiście zgłosić rodzaj i pozycję także tych błędów niedopasowań. 1
Sprawdzanie typów funkcji Wykonanie funkcji dla argumentu może być zapisane jako produkcja W
-> W ( W )
w której wyrażenie jest zastosowaniem jednego wyrażenia do drugiego. Zasady związane z wyrażeniami typów z nieterminalnym T mogą być rozszerzone następującą produkcją i akcją pozwalającą na typy funkcji w deklaracji: T -> r , '
-V
T
2
{T.typ
:= T typ v
-> T .typ 2
}
Strzałka w apostrofach używana jest jako konstruktor funkcji, aby odróżnić ją od strzałki używanej jako metasymbol w produkcji. Zasada kontroli typów funkcji aplikacji jest następująca: W -> W ( W ) 1
2
{ W.typ
: = i f W .typ = s and W .typ = s -> t then t else błąd-typu } 2
x
Z zasady tej wynika, że w wyrażeniu utworzonym przez zastosowanie W do W , typ Wj musi być funkcją M / z typu s wyrażenia W do jakiegoś zakresu typu t\ typ W ( W ) jest r. Wiele problemów związanych ze sprawdzaniem typów w kontekście funkcji może być rozważanych dla powyższej prostej składni (patrz rys. 6.5). Uogólnienie dla funkcji z więcej niż jednym argumentem otrzymuje się, konstruując produkcję typu składającego x
2
2
x
2
Jeżeli wyrażenie może występować także po lewej stronie przypisania, musimy rozróżnić między /-wartościami i r-wartościami. Na przykład, l:-2 jest niepoprawne, ponieważ do stałej 1 nie możemy przypisać wartości.
się z argumentów. Zauważmy, że n argumentów typu T można przyjąć jako pojedynczy argument typu T x • - * x T . Na przykład, możemy napisać n
x
root
:
(real
—• r e a l )
n
x
real
~> r e a l
(6.1)
aby zadeklarować funkcję r o o t , której argumentami są odwzorowanie liczby rzeczywi stej w liczbę rzeczywistą i liczba rzeczywista, która zwraca liczbę rzeczywistą. Składnia w językach podobnych do Pascala dla tej deklaracji to function
root
( function
f (real ) : real; x : real ) : real
Składnia (6.1) oddziela deklarację typu funkcji od nazw jej parametrów.
6.3
Równoważność określeń typów
Zasady kontroli typów przedstawione w poprzednim podrozdziale mają postać: „jeżeli dwa typy wyrażeń są równe, to zwróć dany typ, w przeciwnym razie zwróć typ błąd-typu". Ważne jest więc, aby móc dokładnie zdefiniować, kiedy dane typy są równo ważne. Niejasności mogą powstać, jeśli wyrażeniom określającym typy są nadane nazwy, które później są użyte w wyrażeniach określających typy. Najważniejsze jest, czy nazwa w wyrażeniu określającym typ oznacza siebie, czy też jest skrótem innego wyrażenia określającego typ. Skoro istnieje związek między pojęciem równoważności typów i reprezentacją ty pów, obie te sprawy będziemy omawiać jednocześnie. Kompilatory, aby zwiększyć wy dajność, używają reprezentacji, które pozwalają szybko określić równoważność typów. Pojęcie równoważności typów, zaimplementowane w określonym kompilatorze, może być wyjaśnione za pomocą pojęć równoważności strukturalnej i równoważności przez nazwę, omówionych w tym podrozdziale. Użyliśmy reprezentacji grafowej wyrażeń określają cych typy, z liśćmi jako podstawowymi typami i nazwami typów, wewnętrznymi węzłami jako konstruktorami typów, jak to pokazano na rys. 6.2. Przekonamy się, że rekurencyjne definiowanie typów prowadzi do pętli w grafie typów, jeżeli nazwa jest traktowana jako skrót dla wyrażenia określającego typ.
Strukturalna równoważność wyrażeń określających typy Jeżeli wyrażenia określające typy są budowane z typów podstawowych i konstruktorów, to naturalne pojęcie równoważności typów dwóch wyrażeń określających typy jest równo ważnością strukturalną; to znaczy, że dwa wyrażenia są albo tego samego typu podstawo wego, albo są utworzone przez zastosowanie tego samego konstruktora dla strukturalnie równoważnych typów. Czyli dwa wyrażenia określające typy są strukturalnie równoważne wtedy i tylko Wtedy, gdy są identyczne. Wyrażenie określające typ integer, na przykład, jest równoważne tylko wyrażeniu integer, ponieważ mają one ten sam typ podstawowy. Podobnie wskafnik(integer) jest równoważny tylko wskaźnik(integer), ponieważ oba są utworzone przez zastosowanie tego samego konstruktora wskaźnik dla równoważnych typów. Jeżeli do skonstruowania diagramu reprezentującego określenia typów użyjemy
metody korzystającej z numerów węzłów (patrz algorytm 5.1), to identyczne określenie typów będzie reprezentowane przez ten sam węzeł drzewa. W praktyce, często jest potrzebna modyfikacja pojęcia strukturalnej równoważności, pozwalająca odzwierciedlić rzeczywiste zasady kontroli typów dla języka źródłowego. Na przykład, kiedy tablice są przekazywane jako parametry, możemy nie chcieć włączać zakresu tablicy jako części jej typu. Algorytm testujący równoważność strukturalną (rys. 6.6) może być przystosowany to testowania zmodyfikowanych pojęć równoważności. Zakłada się w nim, że jedynymi konstruktorami typów są tablice, produkcje, wskaźniki i funkcje. Algorytm rekurencyjnie porównuje struktury wyrażeń określających typy bez sprawdzania cykli, można więc go zastosować do reprezentacji w formie drzewa lub grafu dag. Identyczne określenia wyrażające typy nie muszą być reprezentowane przez ten sam węzeł w diagramie. Struk turalna równoważność węzłów w grafach typów z cyklami może zostać przetestowana przy użyciu algorytmu z p. 6.7.
(1)
function srown(s, /): boolean; begin if s i t są tego samego typu podstawowego then return true else if s = array(s , s ) and t = array(t , t ) then return srown(s t ) and srown(s , t ) else if s = s x s and t = t x t then return srown(s t ) and srown(s t) else if s = wskaźnik(s ) and / = wska£nik(t ) then return srown(s , t ) and srown(s , t ) else if s = s -> s and t = t -+ t then return srown(s t ) and srown(s t) else return true end
(2) (3) (4) (5) (6) (7) (8) (9) (10) (11)
x
x
2
x
v
x
Xi
2
2
2
x
2l
2
x
x
2
2
v
x
2i
2
2
x
2
x
x
(12)
x
2
x
2
Rys. 6.6. Testowanie równoważności strukturalnej dwóch wyrażeń określających typy s i t
Zakresy tablic s i t w x
s — array(s , t = array(t x
v
x
s) t) 2
2
są zignorowane, jeżeli test na równoważność tablic w wierszach 4 i 5 z rys. 6.6 zostanie zmieniony na else if s — array(s return srown(s , v
2
s ) and t = array(t t) 2
v
t ) then 2
2
W pewnych przypadkach możemy znaleźć reprezentację wyrażenia określającego typ, która jest znacznie prostsza niż notacja grafowa. W przykładzie 6.1 niektóre infor macje z wyrażenia określającego typ zostały zakodowane jako sekwencja bitów, którą
można interpretować jak pojedynczą liczbę całkowitą. W kodowaniu tym różne liczby całkowite reprezentują strukturalnie różne wyrażenia określające typy. Test na struktural ną równoważność typów może być przyspieszony dzięki początkowemu przetestowaniu równoważności, polegającemu na porównaniu tej całkowitoliczbowej reprezentacji typu, a następnie — jeżeli są one takie same — zastosowaniu algorytmu z rys. 6.6.
P r z y k ł a d 6 . 1 . Kodowanie wyrażeń określających typy pochodzi z kompilatora C napi sanego przez D. M. Ritchiego. Było ono także zastosowane w kompilatorze C opisanym przez Johnsona [1979]. Rozważmy wyrażenie określające typ z następującymi konstruktorami typów dla wskaźników, funkcji i tablic: wskaźnik(t) określa wskaźnik na typ f, f-rez(t) określa funkcję zwracającą obiekt typu t oraz array(t) określa tablicę (nie określonej długości) elementów typu t. Zwróćmy uwagę, że są to uproszczone konstruktory typów dla tablic i funkcji. Liczba elementów tablicy będzie trzymana w innym miejscu, nie jako część konstruktora typu array. Podobnie, jedynym argumentem k o n s t r u k t o r a / , rez jest typ re zultatu funkcji; typy argumentów funkcji będą trzymane w innym miejscu. A więc obiekty ze strukturalnie równoważnymi wyrażeniami określającymi w tym systemie typów mogą nie zdać testu z rys. 6.6 zastosowanego do bardziej złożonego systemu typów. Skoro każdy z konstruktorów jest operatorem jednoargumentowym, wyrażenie okre ślające typ utworzone z zastosowaniem tych operatorów dla typów podstawowych ma bardzo jednolitą strukturę. Przykładami takich wyrażeń są
fwskaźniki/array(wskaźnik(f-
char rez(char) rez(char)) rez(char)))
Każde z powyższych wyrażeń może być przedstawione jako sekwencja bitów, przy użyciu prostego schematu kodowania. Skoro są tylko trzy typy konstruktorów, do ich zakodowa nia wystarczy użyć dwóch bitów KONSTRUKTOR TYPU
wskaźnik array f-rez
KODOWANIE
01 10 11
Podstawowe typy w języku C są kodowane przez Johnsona [1979] przy użyciu czterech bitów; nasze cztery podstawowe typy mogą być zakodowane jako: T Y P PODSTAWOWY
boolean char integer real
KODOWANIE
0000 0001 0010 0011
Ograniczdne wyrażenia określające typy mogą być teraz zakodowane jako sekwen cje bitów. Cztery bity najbardziej z prawej strony kodują podstawowy typ wyrażenia. Przesuwając się od prawej do lewej, kolejne dwa bity oznaczają konstruktor użyty dla
podstawowych typów, kolejne dwa bity oznaczają konstruktor zastosowany dla niego i tak dalej. Na przykład T Y P PODSTAWOWY
char f_rez(char) wskaźnik(f- rez{char)) array {wskainikif-rez(char)))
KODOWANIE
000000 000011 000111 100111
0001 0001 0001 0001
(patrz również ćwiczenie 6.12). Oprócz oszczędzania miejsca, taka reprezentacja śledzi konstruktory, które pojawiają się w danym wyrażeniu określającym typ. Dwie różne sekwencje bitów nie mogą repre zentować takiego samego typu, ponieważ inny jest albo typ podstawowy, albo konstruktor w określeniu typów. Oczywiście różne typy mogą mieć tę samą sekwencję bitów, gdyż rozmiar tablicy i argumenty funkcji nie są w niej reprezentowane. Kodowanie z tego przykładu można tak rozszerzyć, aby zawierało rekordy. Pod czas kodowania każdy rekord należy traktować jak typ podstawowy; oddzielne sekwen cje bitów kodują typ każdego pola rekordu. Równoważność typów w C sprawdziliśmy w przykładzie 6.4. •
Nazwy wyrażeń
określających typy
W niektórych językach typy mogą mieć nadane nazwy. Na przykład we fragmencie programu w Pascalu type var
link nast ostatni P q,
r
= T kom; link; link; t kom; t kom;
(6.2)
identyfikator l i n k jest zadeklarowany jako nazwa dla typu Tkom. Powstaje pytanie: czy zmienne n a s t , o s t a t n i , p , q, r mają identyczne typy? Zaskakujące — odpowiedź zależy od implementacji. Problem powstał, ponieważ w raporcie Pascala nie zdefiniowano pojęcia „identycznych typów". Chcąc pokazać tę sytuację, pozwolimy, aby wyrażenia określające typy miały na zwy i aby te nazwy pojawiały się w wyrażeniach określających typy, gdzie do tej pory występowały tylko typy podstawowe. Na przykład, jeżeli kom jest nazwą wyrażenia okre ślającego typ, to wskaźnik(kom) jest wyrażeniem określającym typ. Na razie załóżmy, że nie ma żadnych zapętlonych definicji wyrażeń określających typy, takich jak definiujące kom jako nazwę wyrażenia określającego typ zawierający kom. Jeżeli w określeniu typu są dozwolone nazwy, powstają dwa pojęcia równoważności określeń typów, zależnie od traktowania nazw. Równoważność nazw postrzega każdą nazwę typu jako oddzielny typ, a więc dwa wyrażenia określające typy są równoważne nazwami wtedy i tylko wtedy, gdy są identyczne. W równoważności strukturalnej nazwy są zamienione przez określenia typów, które definiują, a więc dwa określenia typów są strukturalnie równoważne, jeżeli reprezentują dwa strukturalnie równoważne określenia typów, gdy wszystkie nazwy zostaną j u ż podstawione.
P r z y k ł a d 6.2. Wyrażenia określające typy, które mogą być związane ze zmiennymi w deklaracjach (6.2) są podane w następującej tabeli: ZMIENNA
nast ostatni p q r
OKREŚLENIE TYPU
link link wskaźnik(kom) wskafnik(kom) wskaźnik(kom)
Pod względem równoważności nazw zmienne n a s t i o s t a t n i mają ten sam typ, ponieważ są związane z tym samym wyrażeniem określającym typ. Zmienne p , q i r także mają ten sam typ, ale p i n a s t już nie mają, ponieważ związane z nimi określenia typu są różne. Pod względem równoważności strukturalnej wszystkie (pięć) zmienne mają ten sam typ, ponieważ l i n k jest nazwą dla określenia typu wskaźnik(kom). • Koncepcje równoważności strukturalnej i równoważności nazw są pomocne przy wyjaśnianiu zasad używanych w różnych językach do powiązania — poprzez deklaracje — typów z identyfikatorami. P r z y k ł a d 6.3. W Pascalu powstaje zamieszanie, gdyż wiele implementacji wiąże nie jawną nazwę typu z każdym zadeklarowanym identyfikatorem. Jeżeli deklaracja zawiera wyrażenie określające typ, które nie jest nazwą, tworzona jest nazwa niejawna. Dzieje się tak za każdym razem, gdy określenie typu pojawi się w deklaracji zmiennej. A więc nazwy niejawne są tworzone dla określenia typu w dwóch deklaracjach zawierających p , q i r w (6.2). To znaczy, że deklaracja jest traktowana tak, jakby link np npr nast ostatni P q r
= T kom; = T kom; = T kom; link; link; np; nqr; nqr;
Tutaj wprowadzono nowe nazwy n p i n q r . Ze względu na równoważność nazw, skoro n a s t i o s t a t n i są zadeklarowane z tą samą nazwą typu, traktowane są tak, jakby miały równoważne typy. Podobnie q i r są traktowane jakby miały równoważne typy, ponieważ związane są z nimi takie same niejawne nazwy typów. Jednakże p , q i n a s t nie mają równoważnych typów, ponieważ mają typy z różnymi nazwami. Typowa implementacja do reprezentowania typów konstruuje graf typów. Za każ dym razem, gdy pojawia się konstruktor lub typ podstawowy, tworzony jest nowy węzeł. Za każdym razem, gdy pojawia się nowa nazwa typu, tworzony jest liść, mimo to śle dzimy określenie typu, do którego odnosi się nazwa. W tej reprezentacji dwa określenia typów są równoważne, gdy są reprezentowane przez ten sam węzeł w grafie typów. Na
rysunku 6.7 przedstawiono graf typów dla deklaracji (6.2). Linie kropkowane pokazu ją związek między zmiennymi i węzłami w grafie typów. Zwróćmy uwagę, że nazwa ty pu kom ma trzech rodziców, wszystkich oznaczonych wskaźnik. Równoważne znaki pojawiają się między nazwą typu l i n k a węzłem w grafie typów, do którego się ona odnosi. •
nast
ostatni link
p
= wskaźnik
wskaźnik
q wskaźnik
kom Rys. 6.7. Związek między zmiennymi i węzłami w grafie typów
Cykle w reprezentacji typów Podstawowe struktury danych, jak listy wiązane i drzewa, są często definiowane rekurencyjnie; np. lista wiązana jest albo pusta, albo składa się z komórek ze wskaźnikami na listę wiązaną. Taka struktura danych jest zwykle implementowana przy użyciu rekor dów zawierających wskaźniki na podobne rekordy i nazwy typów mają znaczącą rolę w definiowaniu typów takich rekordów. Rozważmy listę wiązaną komórek, z których każda zawiera jakąś liczbę całkowi tą i wskaźnik kolejnej komórki z listy. Deklaracje w Pascalu nazw typów związanych z połączeniami i komórkami to type
link kom
= T kom; = recorcł info nast end;
: :
integer; link
Zauważ, że nazwa typu l i n k jest zdefiniowana w zależności od kom i że kom jest zdefiniowany w zależności od l i n k , a więc definicje te są rekurencyjne. Jeżeli chcemy wprowadzić cykle do grafu typów, to rekursywnie zdefiniowane na zwy typów można zastąpić. Jeżeli przykładowo wskaźnik(kom) zostanie zastąpiony przez l i n k , to dla kom uzyskamy określenie typu przedstawione na rys. 6.8(a). Używając cy kli jak na rys. 6.8(b), możemy wyeliminować pojęcie kom z części grafu typów poniżej węzła oznaczonego record.
Przykład 6.4. Język C unika cykli w grafach, używając strukturalnej równoważności dla wszystkich typów poza rekordami. W C deklaracja kom wyglądałaby następująco: s t r u c t kom { int info; s t r u c t kom
};
*nast;
kom = record
kom = record
X info
X integer
X nast
wskaźn ik
info
integer
X nast wskazn ik
kom (a)
(b)
Rys. 6.8. Rekursywnie zdefiniowana nazwa typu kom Język C używa raczej słowa kluczowego s t r u c t niż r e c o r d i nazwa kom będzie częścią typu rekordu. W rezultacie C używa acyklicznej reprezentacji z rys. 6.8(a). Język C wymaga, aby nazwy typów były zadeklarowane zanim zostaną użyte, oprócz wyjątku pozwalającego na użycie wskaźników do niezadeklarowanych typów rekordów. Wszystkie potencjalne cykle powstają zatem z powodu wskaźników do rekordów. Skoro nazwa rekordu jest częścią jego typu, testowanie strukturalnej równoważności kończy się, gdy zostanie osiągnięty konstruktor typu. Porównywane typy albo są równoważne, ponieważ mają taką samą nazwę rekordu, albo są nierównoważne. •
6.4
Konwersja typów
Rozważmy wyrażenie x + i , gdzie x jest typu rzeczywistego i i jest typu całkowitego. Skoro reprezentacja liczby rzeczywistej i całkowitej na komputerze jest inna i inne in strukcje maszynowe są używane w operacjach na liczbach rzeczywistych i całkowitych, to kompilator musi dokonać konwersji jednego z argumentów +, aby zapewnić, że oba argumenty będą tego samego typu, gdy wykonywane będzie dodawanie. Z definicji języka wynika, jakie konwersje są wymagane. Kiedy liczba całkowi ta jest przypisywana liczbie rzeczywistej, lub odwrotnie, następuje konwersja do typu znajdującego się po lewej stronie przypisania. W wyrażeniach zazwyczaj konwertuje się liczbę całkowitą w rzeczywistą i dopiero później wykonuje się operacje na wynikowej parze argumentów rzeczywistych. Kontroler typów w kompilatorze może być używany do wstawiania tych operacji konwersji do pośredniej reprezentacji programu źródłowego. Na przykład, przyrostkowa notacja dla x + i może być x i inttoreal real+ Tutaj operator inttoreal konwertuje i z liczby całkowitej na rzeczywistą, a następnie real+ wykonuje rzeczywiste dodawanie na swoich argumentach. Konwersja typów często następuje w innych kontekstach. Symbol, który ma inne znaczenie w zależności od kontekstu, jest nazywany przeciążonym. Przeciążanie omó wiliśmy w następnym podrozdziale, ale wspominamy o nim już tutaj, ponieważ często towarzyszy mu konwersja typów.
Sprowadzanie do zgodności typów Mówi się, że konwersja z jednego typu na inny jest niejawna, jeżeli jest wykonywana automatycznie przez kompilator. Konwersja niejawna, nazywana także sprowadzaniem do zgodności typów, w wielu językach występuje tylko wtedy, gdy nie traci się żadnych informacji, np. liczba całkowita może być przekonwertowana na rzeczywistą, ale nie odwrotnie. Jednakże w praktyce możliwa jest utrata informacji, gdy liczba rzeczywista zajmuje taką samą liczbę bitów, co liczba całkowita. Konwersję nazywa się jawną, gdy programista napisze coś, co ją spowoduje. Z prak tycznych powodów wszystkie konwersje w Adzie są jawne. Konwersje jawne są dla kon trolera typów tym, czym zastosowania funkcji, a więc nie przedstawiają żadnego nowego problemu. W Pascalu, na przykład, wbudowana funkcja o r d przyporządkowuje znakowi liczbę całkowitą, a c h r wykonuje odwrotne przyporządkowanie: liczbie całkowitej znak, a więc te konwersje są jawne. Natomiast w C następuje sprawdzenie zgodności typów (to znaczy konwersja niejawna) dla znaków ASCII zamienianych na liczby całkowite w zakresie od 0 do 12 7 w wyrażeniach arytmetycznych. Przykład 6.5. Rozważmy wyrażenie utworzone z zastosowaniem arytmetycznego ope ratora op dla stałych i identyfikatorów, tak jak w gramatyce z rys. 6.9. Załóżmy, że istnieją dwa typy: rzeczywisty i całkowity, i — jeżeli jest to potrzebne — to liczby całkowite są przekształcane na rzeczywiste. Atrybut typ nieterminala W może być albo liczbą całkowitą, albo rzeczywistą. Reguły kontroli typów są przedstawione na rys. 6.9. Podobnie jak w p . 6.2, funkcja znajdzie) zwraca typ zapisany w tablicy symboli pod indeksem e. • PRODUKCJA
w -> liczba w -> liczba . liczba w
-> id
w
">
Wy OP
W
2
R E G U Ł A SEMANTYCZNA
W.typ W.typ W.typ W.typ
:= := := :=
integer real znajdi(\6\.wpis) if Wy .typ = integer and W .typ = integer then integer else if Wy .typ = integer and W .typ = real then real else if Wy .typ = real and W .typ — integer then real else if Wy .typ = real and W Jyp = real then real else błąd-typu 2
2
2
2
Rys. 6.9. Reguły kontroli typów dla wymuszeń z liczby całkowitej na rzeczywistą Konwersje niejawne stałych mogą być robione podczas kompilacji, wydłużając nie kiedy czas działania badanego programu. W następujących fragmentach kodu, X jest tablicą liczb rzeczywistych, która została zainicjowana samymi jedynkami. Używając kompilatora Pascala, Bentley [1982] zmierzył, że wykonanie for
I: = l
to
N do X [ I ] : = 1
zajęło 48.4N mikrosekund, podczas gdy wykonanie for
I:-l
to
N do
X[I]:=1.0
zajęło 5.4N mikrosekund. Oba fragmenty przypisują wartość jeden elementom z tabli cy liczb rzeczywistych. Jednakże kod wygenerowany (przez kompilator) dla pierwsze go fragmentu zawierał wywołania funkcji z biblioteki przetwarzania (ang. run-time) do przekształcania reprezentacji całkowitej 1 w reprezentację rzeczywistą. Skoro podczas kompilacji wiadomo, że X jest tablicą liczb rzeczywistych, bardziej sumienny kompilator przekształciłby 1 w 1.0 j u ż podczas kompilacji.
6.5
Przeciążanie funkcji i operatorów
Symbol, który m a różne znaczenia w zależności od kontekstu, nazywamy przeciążonym. W matematyce operator + jest przeciążony, ponieważ + w A + B ma różne znaczenie, w zależności od tego, czy A i B są liczbami całkowitymi, liczbami rzeczywistymi, liczba mi zespolonymi, czy macierzami. W Adzie przeciążone są nawiasy ( ) ; wyrażenie A ( I ) może być I - t y m elementem tablicy A, wywołaniem funkcji A z argumentem I albo jawną konwersją wyrażenia I na typ A. Przeciążenie jest rozwiązane, gdy znaczenie wystąpienia przeciążonego sym bolu jest określone. Na przykład, jeżeli + oznacza dodawanie liczb całkowitych albo rzeczywistych, to dwa wystąpienia + w x + ( i + j ) mogą oznaczać inne for my dodawania, zależnie od typów x, i oraz j . Rozwiązanie przeciążania jest czasem określane jako identyfikator operatora, ponieważ określa, jaką operację oznacza symbol operatora. W większości języków operatory arytmetyczne są przeciążone. Jednakże przecią żenie, na przykład, operatora + może zostać rozwiązane tylko p o przyjrzeniu się argu mentom operatora. Analiza przypadków w celu podjęcia decyzji, czy użyć całkowitą, czy rzeczywistą wersję +, jest podobna do tej w regułach semantycznych dla W —> W op W z rys. 6.9, gdzie typ W jest określony przez możliwe typy W i W . }
x
2
Zbiór możliwych typów dla podwyrażenia Nie zawsze można zdecydować o przeciążeniu funkcji tylko po przyjrzeniu się jej argumentom, o czym świadczy następny przykład, w którym nie pojedynczy typ, lecz pojedyncze podwyrażenie może mieć zbiór możliwych typów. W Adzie, z kon tekstu muszą wynikać wystarczające informacje, aby móc zawęzić ten wybór do jednego typu.
Przykład 6.6.
W Adzie jednym ze standardowych (to znaczy wbudowanych) interpre tacji operatora * jest funkcja, która parze liczb całkowitych przyporządkowuje liczbę całkowitą. Operator może być przeciążony przez dodanie deklaracji, jak na przykład te function function
"*" "*"
( i, ( x,
j y
: integer : complex
) return ) return
complex; complex;
2
Po powyższych deklaracjach możliwe typy dla * zawierają integer x integer ~~¥ integer integer x integer complex complex x complex —> complex Załóżmy, że jedynym możliwym typem dla 2, 3 i 5 jest liczba całkowita. Z po wyższych deklaracji wynika, że podwyrażenie 3*5 — w zależności od kontekstu — j e s t albo typu całkowitego, albo zespolonego. Jeżeli całym wyrażeniem jest 2 * ( 3 * 5 ) , to 3*5 musi być typu całkowitego, ponieważ * jako argumenty ma albo parę liczb całko witych, albo parę liczb zespolonych. Z drugiej strony 3*5 musi być typu zespolonego, jeżeli całe wyrażenie to ( 3 * 5 ) *z i z jest zadeklarowane jako liczba zespolona. • W podrozdziale 6.2 założyliśmy, że każde wyrażenie ma unikalny typ, a więc reguła kontroli typów dla wywołania funkcji jest następująca: W -> W ( W ) {
{ W.typ : = if W .typ = s and W .typ = s -»t t h e n f else błąd-typu }
2
2
2
Uogólnienie tej reguły dla zbiorów typów przedstawiono na rys. 6.10. Jedyną operacją na rys. 6.10 jest wywołanie funkcji; reguły kontroli innych operatorów w wyrażeniach są podobne. Przeciążony identyfikator może mieć kilka deklaracji, z czego wnioskujemy, że jedna pozycja w tablicy symboli może zawierać zbiór możliwych typów; ten zbiór jest zwracany przez funkcję znajdź. Symbol startowy W' generuje kompletne wyrażenie. Jego rola jest wyjaśniona poniżej.
R E G U Ł A SEMANTYCZNA
PRODUKCJA 1
w -> w W -> id
w* -> w ( w ) x
2
W'.typy := W.typy W .typy :=• znąjdź(id.wpis) W .typy := { t | istnieje s w W .typy takie, że s -> / jest w W .typy } 2
x
Rys. 6.10. Określanie zbioru możliwych typów wyrażenia
Z trzeciej reguły z rysunku 6.10 wynika, że jeżeli s jest jednym z typów W i jeden z typów W może odwzorowywać s w f, to t jest jednym z typów W (W ). Niedopa sowanie typów podczas zastosowania funkcji powoduje, że zbiór W.typy staje się pusty, co jest warunkiem, którego będziemy tymczasowo używać do sygnalizowania błędów typów. 2
{
{
2
P r z y k ł a d 6.7. Oprócz zilustrowania specyfikacji z rys. 6.10 przykład ten sugeruje, jak użyte podejście przenosi się na inne konstrukcje. Rozważmy wyrażenie 3 * 5 . Niech deklaracja operatora * będzie taka, jak w przykładzie 6.6, czyli * może odwzorować parę liczb całkowitych w liczbę całkowitą lub liczbę zespoloną, w zależności od kontekstu. Zbiór możliwych typów dla podwyrażenia 3*5 jest pokazany na rys. 6.11, gdzie i i c są odpowiednio skrótami od integer i complex.
W: {i,c} W: {i} I
3: {/}
*: i*i->c,
{ixi->i,
5: {i} cxc->c}
Rys. 6.11. Zbiór możliwych typów dla wyrażenia 3*5 Załóżmy także, że jedynym możliwym typem dla 3 i 5 jest integer. Operator * jest wówczas stosowany dla pary liczb całkowitych. Jeżeli potraktujemy tę parę liczb całkowitych jako jednostkę, to jej typ jest określony jako integer x integer. Istnieją dwie funkcje w zbiorze typów dla *, których argumentami są dwie liczby całkowite: jedna zwraca liczbę całkowitą, podczas gdy druga liczbę zespoloną, a więc korzeń może być albo typu całkowitego, albo zespolonego. • Zawężanie zbioru możliwych typów W Adzie całe wyrażenie musi mieć unikalny typ. Mając unikalny typ z kontekstu, może my zawęzić wybór typów dla każdego podwyrażenia. Jeżeli jednak nie uzyskamy unikal nego typu dla każdego podwyrażenia, to dla całego wyrażenia generowany jest błąd typu. Przed przejściem od wyrażenia do podwyrażeń, przyjrzymy się zbiorowi W.typy skonstruowanemu według reguł z rys. 6.10. Wykażemy, że każdy typ t z W. typy jest typem możliwym; czyli może zostać wybrany spośród przeciążonych typów identyfikatorów z W w taki sposób, że typem W staje się t. Własność jest spełniona dla identyfikatorów przez ich deklarację, ponieważ każdy element id.typy jest możliwy. Dla kroku indukcyjnego przyjmijmy, że t jest w W.typy, gdzie W to W ( W ). Z reguł określających zastosowania funkcji (rys. 6.10), dla niektórych typów s, s musi należeć do W .typy i typ s —> t musi należeć do W .typy. Przez indukcję, s i s —> t są możliwymi typami odpowiednio dla W i W Z tego wynika, że t jest możliwym typem dla W. Może być kilka sposobów znajdowania możliwych typów. Rozważmy wyrażenie f ( x ) , gdzie f może być typu a -» c lub b —t c, a x może być typu a lub b. Wówczas f ( x ) jest typu c, a x jest typu a lub b. Definicję sterowaną składnią z rys. 6.12 uzyskano z definicji z rys. 6.10 przez dodanie reguł semantycznych do określenia dziedziczonego atrybutu unikalny dla W. Utworzony atrybut kod omówiono poniżej. Skoro całe wyrażenie jest generowane przez W', chcemy żeby W.typy było zbio rem zawierającym pojedynczy typ t. Ten pojedynczy typ jest dziedziczony jako wartość W.unikalny. Ponownie typ podstawowy błąd-typu sygnalizuje błąd. Jeżeli funkcja W (W ) zwraca typ t, to możemy znaleźć typ s, który jest możliwy dla argumentu W ; w tym samym czasie s -> t jest możliwym typem dla funkcji. Zbiór S z reguły semantycznej dla W ( W ) z rys. 6.12 jest używany do sprawdzania, czy istnieje unikalny typ s mający taką własność. Definicja sterowana składnią z rys. 6.12 może być zaimplementowana przez wyko nanie dwóch przejść w głąb drzewa składniowego w poszukiwaniu wyrażenia. Podczas pierwszego przejścia jest tworzony wstępująco atrybut typy. Podczas drugiego przejścia atrybut unikalny jest przekazywany zstępująco i jeśli wrócimy z węzła, może zostać utworzony atrybut kod. W praktyce, kontroler typów może po prostu dołączyć unikalny x
2
2
x
2
v
x
2
2
x
2
typ do każdego węzła drzewa składniowego. Na rysunku 6.12 wygenerowaliśmy notację przyrostkową, aby pokazać, jak można wytworzyć kod pośredni. W notacji przyrostkowej każdy identyfikator i instancja operatora zastosuj musi mieć dołączony typ przy użyciu funkcji gen.
ZASADY
PRODUKCJA
W -»
W .typy : = W.typy W.unikalny := if W'.typy = {t} then t else błąd-typu W.kod : = W.kod
W
w
-> id
w
->w
x
SEMANTYCZNE
1
W.typy :— znajdź(\d.wpis) W.kod :— gen(iń.leksem ' : ' W.unikalny) ( w
2
W.typy := {s | istnieje s w 1
)
W .typy 2
taki, że s —> s' jest w W .typy } x
t := W.unikalny S :— {s | s 6 W .typy and s -> f 6 Wj .rypy } W .unikalny := if 5 = { 5 } then 5 else błąd-typu W .unikalny := if S = { 5 } then 5 —*• t else błąd-typu 2
2
x
W.kod := W .kod x
|| W .fow/ || ge/zCzastosuj' ' : ' W.unikalny) 2
Rys. 6.12. Zawężanie zbioru typów dla wyrażenia
6.6
Funkcje polimorficzne
Zwykła procedura pozwala, aby wyrażenia znajdujące się w jej treści mogły być wy konywane z argumentami o określonych typach; za każdym razem, gdy wywoływana jest funkcja polimorficzna, wyrażenia w jej treści mogą być wykonywane z argumentami różnych typów. Pojęcie „polimorficzny" może być także zastosowane w odniesieniu do kodu, który może być wykonany z argumentami różnych typów, a więc możemy mówić zarówno o polimorficznych funkcjach, jak i operatorach. Wbudowane operatory do indeksowania tablic, stosowania funkcji i manipulowania wskaźnikami są zazwyczaj polimorficzne, ponieważ ich użycie nie jest ograniczone do szczególnego rodzaju tablic, funkcji czy wskaźników. W podręczniku C, na przykład, znajdujemy następujące stwierdzenia na temat wskaźnikowego operatora &: „Jeżeli typ argumentu j e s t t o typem rezultatu jest 'wskaźnik na ". Skoro każdy typ może być podstawiony w miejsce operator & jest polimorficzny. W Adzie „podstawowe" funkcje są polimorficzne, ale polimorfizm w Adzie jest ogra niczony. Skoro pojęcie „podstawowy" jest także używane w odniesieniu do przeciążonych funkcji i do wymuszania argumentów funkcji, będziemy unikać tego wyrażenia. W tym podrozdziale zajęliśmy się problemem, który powstaje podczas projekto wania kontrolera typów dla języka z funkcjami polimorficznymi. Aby poradzić sobie z polimorfizmem, rozszerzymy nasz zbiór określeń typów o wyrażenia ze zmiennymi ty pów. To włączenie zmiennych typów stwarza pewne problemy algorytmiczne dotyczące równoważności określeń typów.
Dlaczego funkcje polimorficzne? Funkcje polimorficzne są atrakcyjne, ponieważ ułatwiają implementacje algorytmów, któ re operują na strukturach danych, niezależnie od typów elementów w tych strukturach. Przykładowo, wygodniej jest mieć program, który wyznacza długość listy, nie znając typów elementów tej listy. t y p e l i n k ~ t kom; kom = r e c o r d info: nast: end;
integer; link
f u n c t i o n d l u g o s c ( Iwsk : l i n k ) var len : i n t e g e r ; begin l e n := 0; w h i l e l w s k <> n i l do b e g i n l e n := l e n + 1; l w s k := l w s k T . n a s t end; d l u g o s c := l e n end;
:
integer;
Rys. 6.13. Program w Pascalu wyznaczający długość listy Języki, takie jak Pascal, wymagają pełnej specyfikacji typów parametrów funkcji, a więc funkcja określająca długość listy powiązanej liczb całkowitych nie może być zastosowana dla listy liczb rzeczywistych. Kod Pascala z rys. 6.13 jest dla listy liczb całkowitych. Funkcja d l u g o s c przechodzi przez kolejne połączenia z listy, aż osiągnie połączenie n i l . Chociaż funkcja w żaden sposób nie zależy od typu informacji trzy manych w komórkach, Pascal wymaga, aby typ i n f o został opisany przed napisaniem funkcji d l u g o s c . fun d l u g o s c ( l w s k ) = if nuli(lwsk) then 0 else dlugosc(tl(lwsk))
+ 1;
Rys. 6.14. Program w ML wyznaczający długość listy W językach z polimorficznymi funkcjami, np. M L (Milner [1984]), funkcję d l u g o s c można zapisać w sposób pozwalający na stosowanie jej dla każdego rodzaju listy, co pokazano na rys. 6.14. Słowo kluczowe f u n wskazuje, że d l u g o s c jest funkcją reku rencyjna. Funkcje n u l i i t l są predefiniowane: n u l i testuje, czy lista jest pusta, a t l zwraca listę po usunięciu z niej pierwszego elementu. Wykorzystując definicję z rys. 6.14, zastosowanie funkcji d l u g o s c w obu następujących przykładach daje wartość 3:
dlugosc(["pon","wt","śr"]); d l u g o s c ( [ 1 0 , 9, 8] ) ; W pierwszym przykładzie funkcja d l u g o s c jest użyta dla listy, której elementami są ciągi znaków, w drugim zaś — dla listy liczb całkowitych.
Zmienne typu Zmienne reprezentujące wyrażenia określające typy pozwalają na omówienie nieznanych typów. W dalszej części tego podrozdziału będziemy używać greckich liter a , j3, . . . do oznaczenia zmiennych typów w wyrażeniach określających typy. Ważnym zastosowaniem zmiennych typu jest sprawdzanie spójnego użycia identyfi katorów w języku, który nie wymaga zadeklarowania identyfikatorów przed ich użyciem. Zmienna reprezentuje typ niezadeklarowanego identyfikatora. Patrząc na program, może my stwierdzić, czy niezadeklarowany identyfikator został użyty w jednej instrukcji jako liczba całkowita, a w drugiej jako tablica. Takie niespójne użycie może zostać zgłoszone jako błąd. Z drugiej strony, jeżeli zmienna zawsze jest używana jako liczba całkowita, to nie tylko mamy zapewnione spójne użycie, ale możemy jednocześnie wywnioskować, jaki musi być jej typ. Wnioskowanie typów jest problemem oznaczania typu konstrukcji językowej na pod stawie sposobu, w jaki jest używana. Pojęcie to jest często stosowane do problemu wnio skowania typu funkcji na podstawie jej treści.
P r z y k ł a d 6.8. Techniki wnioskowania typów mogą być stosowane w programach na pisanych w takich językach, jak C czy Pascal; pozwalają na uzupełnienie w czasie kom pilacji brakującej informacji o typach. Fragment kodu z rys. 6.15 przedstawia procedurę m l i s t , która ma parametr p będący procedurą. Wszystko co wiemy, patrząc na pierwszy wiersz procedury m l i s t , to to, że p jest procedurą; przede wszystkim jednak nie znamy liczby ani typów argumentów p . Taka niekompletna specyfikacja typu p jest dopuszczalna (patrz podręczniki o językach C i Pascal). Procedura m l i s t stosuje parametr p do każdej komórki z listy wiązanej. Na przy kład p można użyć do inicjowania albo wypisania liczby całkowitej trzymanej w komórce listy. Pomimo że typy argumentów p nie są określone, możemy wywnioskować z użycia p w wyrażeniu p ( l w s k ) , że typem p musi być l i n k —» void Każde wywołanie m l i s t z parametrem procedurą, która jest innego typu, jest błędem. Procedura może być postrzegana jako funkcja, która nie zwraca wartości, więc typem jej wyniku jest void. • Techniki wnioskowania typów i kontroli typów mają ze sobą wiele wspólnego. We wszystkich mamy do czynienia z wyrażeniami, które zawierają zmienne. Rozumowa nie, podobne do tego z poniższego przykładu, jest używane także przez kontrolera typów do wnioskowania typów reprezentowanych przez zmienne.
type link T kom; procedurę mlist ( lwsk : link; procedurę p ) ; begin while lwsk O nil do begin p(lwsk); lwsk := lwskt.nast end end; Rys. 6.15. Procedura mlist z procedurą p jako parametrem Przykład 6.9. Typ może zostać wywnioskowany dla funkcji polimorficznej d e r e f w następującym pseudoprogramie. Funkcja d e r e f działa tak, jak pascalowy operator T powodujący dereferencję wskaźników function deref(p); begin r e t u r n pT end; Kiedy przyjrzymy się pierwszemu wierszowi function
deref(p);
nic nie wiemy o typie p , więc przedstawiamy go, używając zmiennej typu j3. Z definicji, operator przyrostkowy T pobiera wskaźnik do obiektu i zwraca obiekt. Skoro operator T jest użyty n a p w wyrażeniu pT, to p musi być wskaźnikiem obiektu nieznanego typu a , więc wiemy już, że j3 =
wskaźnik(a)
gdzie a jest inną zmienną typu. Następnie, wyrażenie p i jest typu a , więc możemy napisać wyrażenie określające typ dla każdego typu a , wskaźnik(a) —> a dla typu funkcji d e r e f .
(6.3) •
Język z funkcjami polimorficznymi Wszystko, czego dotąd dowiedzieliśmy się o funkcjach polimorficznych, to to, że mogą być wykonywane z argumentami „różnych typów". Dokładne określenie zbioru typów, do których można zastosować funkcję polimorficzną, może być wykonane przy użyciu symbolu V, oznaczającego „dla każdego typu". A zatem V a.wskaźnik(a)
a
(6.4)
to zapis wyrażenia określającego typ z wyrażenia (6.3) dla typu funkcji d e r e f z przy kładu 6.9. Funkcja polimorficzną d l u g o s c (patrz rys. 6.14) pobiera listę elementów dowolnego typu i zwraca liczbę całkowitą, więc jej typ może być zapisany jako
V a.lista(a)
-> integer
(6.5)
W tym wyrażeniu, lista jest konstruktorem typu. Bez symbolu V możemy tylko podawać przykłady możliwych dziedzin i wartości typów dla d l u g o s c lista(integer) lista(lista(char))
—> integer —• integer
Wyrażenia określające typy, j a k (6.5), są najbardziej ogólnymi instrukcjami, którymi możemy określać typ funkcji polimorficznej. Symbol V nazywamy kwantyfikatorem ogólnym, a zmienną typu, do której jest za stosowany, nazywamy związaną z nim. Zmienne związane mogą mieć w dowolnym mo mencie zmienione nazwy pod warunkiem, że wszystkie wystąpienia zmiennej będą miały zmienioną nazwę. A zatem, wyrażenie określające typ V y.wskaźnik(y)
-> y
jest równoważne (6.4). Wyrażenie określające typ z symbolem V można nieformalnie nazwać „typem polimorficznym". Język, którego będziemy używać do sprawdzania funkcji polimorficznych, jest ge nerowany przez gramatykę z rys. 6.16.
P -» D ; W D -> D } D | id : Q Q V zmienna_typu . Q | T T '-V T | TxT | konstruktor_jednoargumentowy { T ) | typ„ podstawowy | zmienna_typu
I (T) W -> W ( W ) | W, W | id Rys. 6.16. Gramatyka dla języka z funkcjami polimorficznymi
Programy, wygenerowane przez gramatykę, składają się z sekwencji deklaracji po przedzających wyrażenie W przeznaczone do sprawdzenia, na przykład d e r e f : V a . wskaźnik(a) —• a ; q : wskainik{wskatnik(integer)) d e r e f ( d e r e f (q) )
;
(6.6)
Skróćmy zapis przez dodanie nieterminala T bezpośrednio generującego wyrażenia okre ślające typy. Konstruktory i x tworzą funkcję i produkują typy. Jednoargumentowe konstruktory, reprezentowane przez konstruktor, jednoargumentowy pozwalają zapisać typy jak wska£nik(integer) i lista(integer). Nawiasy są używane do grupowania typów. Wyrażenia, których typy należy sprawdzić, mają bardzo prostą składnię: mogą być iden tyfikatorami, sekwencjami wyrażeń tworzącymi krotki, albo zastosowaniem funkcji dla argumentu.
Reguły kontroli typów dla funkcji polimorficznych różnią się — z trzech powodów — od tych dla zwykłych funkcji z p. 6.2. Przed omówieniem tych reguł, pokażemy te różnice, rozważając wyrażenie d e r e f ( d e r e f ( q ) ) z programu (6.6). Drzewo skła dniowe dla tego wyrażenia pokazane jest na rys. 6.17. Z każdym węzłem związane są dwie etykiety. Pierwsza oznacza podwyrażenie reprezentowane przez dany węzeł, a dru ga określenie typu przypisane do tego podwyrażenia. Indeksy dolne z i w rozróżniają odpowiednio zewnętrzne i wewnętrzne wystąpienia d e r e f .
zastosuj : a
z
deref : wskaźnik{a ) z
-» a
z
z
zastosuj : a
w
deref : wskaźnik(a ) w
Rys. 6.17.
w
D r z e w o s k ł a d n i o w e dla
-» a
w
q:
wskaźnik{wskaźnik)integer))
deref (deref (q) )
oznaczone etykietą
Różnice reguł dla funkcji polimorficznych i zwykłych to: 1.
Różne wystąpienia funkcji polimorficznej w tym samym wyrażeniu nie muszą mieć argumentów tego samego typu. W wyrażeniu d e r e f ( d e r e f ( q ) ), d e r e f usu wa jeden poziom pośredniego wskazania, a więc d e r e f jest stosowane dla argu mentu innego typu. Implementacja tej własności jest oparta na interpretacji Vcu jako „dla każdego typu a " . Każde wystąpienie d e r e f ma swoją własną ocenę tego, co oznacza zmienna związana a w (6.4). Przypiszmy więc każdemu wystąpieniu d e r e f wyrażenie określające typ utworzone przez zamienienie a z (6.4) na nową zmienną i jednocześnie usuńmy kwanyfikator V. Na rysunku 6.17 w wyrażeniach są użyte nowe zmienne i c ^ , oznaczające odpowiednio zewnętrzne i wewnętrzne wystąpienia d e r e f . Skoro zmienne mogą wystąpić w wyrażeniach określających typy, musimy ponownie przeanalizować pojęcie równoważności typów. Załóżmy, że W typu s —> s' jest zasto sowane do W typu t. Zamiast prostego określenia równoważności typów musimy je unifikować. Unifikacja ta jest zdefinowana poniżej; nieformalnie można stwierdzić, że występuje ona wtedy, kiedy typy s i t mogą być przetworzone na strukturalnie równoważne przez zamianę zmiennych typu w s i t na określenia typu. Przykładowo w wewnętrznym węźle oznaczonym zastosuj na rys. 6.17, równoważność z
w
w
z
2.
x
2
wskaźnik(oCw) =
wskaźnik(wskatnik(integer))
zachodzi, gdy a jest zastąpione przez wskaźnik(integer). Potrzebujemy mechanizmu do zapamiętywania efektów unifikacji dwóch wyrażeń. Zmienna typu może pojawić się w kilku wyrażeniach określających typy. Jeżeli w wyniku unifikacji s i s powstanie zmienna a reprezentująca typ r, to a musi reprezentować t w dalszej części procesu kontroli typów. Na przykład na rys. 6.17, <Xw jest z zakresu typu d e r e f w, a więc możemy jej używać w odniesieniu do typu w
3.
f
d e r e f ^ ( q ) . Unifikacja dziedziny typu d e r e f z typem q skutkuje powstaniem określenia typu w pośrednim węźle nazwanym zastosuj. Inna zmienna typu z rys. 6.17 reprezentuje integer. w
Podstawienia, instancje i unifikacja Informacja o typach reprezentowanych przez zmienne jest sformalizowana przez zdefi niowanie odwzorowania zmiennych typu w określenia typu, nazwanego podstawieniem. Następująca funkcja rekurencyjna podstaw(t) precyzuje pojęcie zastosowania podstawie nia P do zastąpienia wszystkich zmiennych typu w wyrażeniu t. Jak zwykle, użyjemy funkcyjnego konstruktora typu jako konstruktora typu. function podstaw( t : wyr_typ ) : wyr^typ; begin if t jest typem podstawowym then return t else if t jest zmienną then return P(i) else if t jest t ~> t then return podstaw(t ) end x
2
{
->
podstaw(t ) 2
Dla wygody, powstające wyrażenie określające typ będziemy oznaczać P(t), gdy dla argumentu t zostanie zastosowana funkcja podstaw; otrzymane w ten sposób P(t) będziemy nazywać instancją t. Jeżeli podstawienie P nie określa wyrażenia dla zmiennej a , to zakładamy że P(cc) wynosi a; to znaczy, że P jest odwzorowaniem tożsamościowym na taką zmienną.
Przykład 6.10. stancją t:
Poniżej będziemy zapisywać s < r, aby zasygnalizować, że s jest in
wskaźnik(integer) wskaźnik(real) integer —• integer wskaźnik(a)
< wskaźnik(a) < wskatnik(a) < a -+ a < /?
a < p Jednakże określenie typu po lewej stronie nie jest instancją prawej strony, gdyż: integer real podstawienia nie odnoszą się do podstawowych typów integer —> real a —• a sprzeczne podstawienie dla a integer —y a a —> a wszystkie wystąpienia a muszą być zastąpione Dwa określenia typów t i t unifikują się, jeżeli istnieje podstawienie P takie, że P(t ) — P(t ). W praktyce jesteśmy zainteresowani najbardziej ogólnymi unifikatorami, które są podstawieniami narzucającymi jak najmniej ograniczeń na zmienne w wyraże niach. Dokładniej, najbardziej ogólna unifikacja wyrażeń t i t jest podstawieniem P o następujących własnościach: x
{
2
2
{
i)
p(t )=p(t ), l
2
2
•
2)
dla każdego innego podstawienia P', takiego że P'(t ) = P'(f )> podstawienie P' jest instancją P (to znaczy, że dla każdego t, P (t) jest instancją P(t)). x
2
f
Dalej, używając określenia „unifikuje", będziemy myśleć o najbardziej ogólnej unifikacji.
Kontrola funkcji polimorficznych Reguły kontroli wyrażeń wygenerowanych przez gramatykę z rys. 6.16 zapiszemy w grafie reprezentacji typów, wykorzystując następujące operacje: 1)
2)
odświei(t) zamienia zmiennne wiązane w wyrażeniu określającym typ t na nowe zmienne i zwraca wskaźnik do węzła reprezentującego wynikowe określenie typu; każdy symbol V w t jest jednocześnie usunięty; zunifikuj(m, ń) zrównuje określenia typu reprezentowane przez węzły wskazane przez m i n; ma to efekt uboczny w postaci konieczności śledzenia podstawień, które sprawiają, że wyrażenia są równoważne; jeżeli wyrażenia nie da się zunifiko wać, cały proces kontroli typów zawodzi . 1
Pojedyncze liście i węzły pośrednie w grafie typów są skonstruowane przy użyciu operacji twliść i twwęzeł, podobnych do tych z p. 5.2. Dla każdej zmiennej typowej musi istnieć unikalny liść, jednak inne wyrażenia strukturalnie równoważne nie muszą mieć takich unikalnych węzłów. Operacja unifikuj jest oparta na następującym sformułowaniu z teorii grafów ope racji unifikacji i podstawiania. Załóżmy, że węzły grafu m i n reprezentują odpowiednio wyrażenia e i / . Mówimy, że węzły m i n są równoważne po podstawieniu P, jeżeli p ( e ) = P(f). Problem znalezienia najbardziej ogólnej unifikacji P może być rozważany jako problem grupowania węzłów w zbiory, które muszą być równoważne po P. Aby wyrażenia były równoważne, ich korzenie muszą być równoważne. Poza tym, dwa wę zły m i n są równoważne wtedy i tylko wtedy, gdy reprezentują ten sam operator i ich odpowiadające dzieci są równoważne. Algorytm unifikacji pary wyrażeń zamieściliśmy w p. 6.7. Algorytm ten śledzi zbiór węzłów, które są równoważne po poprzedzających podstawieniach. Reguły kontroli typów w wyrażeniach są podane na rys. 6.18. Nie pokazujemy tutaj sposobu, w jaki deklaracje są przetwarzane. Kiedy wyrażenia określające typy wygene rowane przez nieterminale T i Q są sprawdzane, twliść i twwęzeł dodają węzły do grafu typów, wzorując się na konstrukcji grafu z p. 5.2. Kiedy identyfikator jest zadeklarowa ny, typ w deklaracji jest zapamiętywany w tablicy symboli w postaci wskaźnika węzła reprezentującego dany typ. Na rysunku 6.18 wskaźnik ten odnosi się do otrzymanego atrybutu id.typ. Jak j u ż wspomnieliśmy, operacja odśwież usuwa symbol V, kiedy zamie nia zmienne wiązane nowymi zmiennymi. Akcja ta połączona jest z produkcją W —¥ W , W i ustawia W.typ na produkt typów W i W . x
2
x
2
Powodem przerwania procesu kontroli typów jest to, że efekty uboczne niektórych unifikacji można zapisać, zanim zostanie wykryty błąd. Naprawę po błędzie można zaimplementować, jeżeli efekty uboczne operacji unifikuj zostaną wstrzymane do czasu, aż wyrażenie zostanie pomyślnie zunifikowane.
W ->W
X
(W )
{ p twwęzeł(nowa-zmtypu); unifikuj( W typ, twwęzeł( ' , W .typ, p )); W.fyp { W-OT? = fwwczefC x ' , W .typ, W .typ) } { W.ryp = odśwież(\6\.typ) }
2
v
W -> w/j , W W -> id
2
x
2
2
Rys. 6.18. Schemat translacji do sprawdzenia funkcji polimorficznych
Reguła kontroli typów dla wywołania funkcji W —> W ( W ) jest potrzebna, gdy W .typ i W .typ są zmiennymi typu, na przykład, W .typ — a i W .typ — j3. Tutaj W .typ musi być funkcją, która dla jakiegoś nieznanego typu y spełni a = j3 —• y. Na rysunku 6.18 stworzony jest nowy typ zmiennej, odpowiadający y i W typ jest zunifikowany z W .?yp —>• y. Każde wywołanie funkcji nowa-zmtypu zwraca nową zmienną, dla której liść jest tworzony za pomocą twliść, a węzeł reprezentujący funkcje do zunifikowania z W .typ jest tworzony przez twwęzeł. Gdy unifikacja się uda, nowy węzeł reprezentuje typ wynikowy. Reguły z rysunku 6.18 można wyjaśnić, omawiając szczegółowo prosty przykład. Streśćmy działanie algorytmu przez zapisanie wyrażeń określających typy związane z każdym podwyrażeniem, jak na rys. 6.19. Dla każdego zastosowania funkcji, efek tem ubocznym operacji unifikacji może być zapisywanie wyrażeń określających typy dla niektórych zmiennych typu. Taki efekt uboczny pokazano w kolumnie „Podstawienie" na rys. 6.19. x
x
2
2
x
2
x
v
2
x
WYRAŻENIE
PODSTAWIENIE
TYP
q wska£nik(wskainik(integer)) derefw wskaźnik(a ) —> a deref (<3) wska£nik(integer) w
w
w
deref deref (derefw(q)) z
z
wskaźnik(a ) ~> a integer z
a
w
= wska£nik(integer)
z
Oą — integer
Rys. 6.19. Podsumowanie wstępującego wyrażenia określającego typ
P r z y k ł a d 6 . 1 1 . Kontrola typu wyrażenia d e r e f ( d e r e f w ( q ) ) w programie (6.6) odbywa się od liści z dołu do góry. Znowu indeksy dolne z i w rozróżniają wystąpienia d e r e f . Kiedy rozważane jest wyrażenie d e r e f . , odśwież tworzy następujące węzły, używając nowej zmiennej typowej z
->:3 wskaźnik : 2
Liczba przy węźle wskazuje na równoważność klas, do których węzeł należy. Część grafu typów z identyfikatorami drzewa jest przedstawiona poniżej. Liniami kropkowanymi oznaczono węzły 3, 6 i 9, oznaczające odpowiednio d e r e f , d e r e f w i q. z
deref —> '• 3
/ wskaźnik:
\
2
\
deref
z
-» : 6
/
)
\
wskaźnik;
/ cc : 1
5
\
z
q
w
wskaźnik:
9
wskaźnik:
8
|
)
/ a :4
I integer : 7
w
Zastosowanie funkcji d e r e f ^ ( q ) jest sprawdzane przez skonstruowanie węzła n dla funkcji odwzorowującej typ q na nową zmienną typu j3. Ta funkcja daje się unifikować z typem d e r e f reprezentowanym poniżej przez węzeł m. Zanim węzły m i n zostaną zrównane, każdy węzeł ma różny numer. Po zunifikowaniu, równoważne węzły mają te same numery; numery zmienione są podkreślone: w
n -> :_6
—> : 3 wskaźnik:
2
\
m -» : 6 )
a
wskaźnik:
5
/ z'
)
\
1
a
w
wskaźnik:
5
wskaźnik:
8
/
I
:8
integer
fi
: 8
: 7
Zauważmy, że oba węzły a i wskafnik(integer) mają numer 8, czyli a jest zunifiko wane z tym wyrażeniem określającym typ, co przedstawiono na rys 6.19. Później 0^ jest zunifikowane z typem integer. • w
w
Następny przykład wiąże wnioskowanie typów funkcji polimorficznych w M L z za sadami kontroli typów z rys. 6.18. Składnia definicji funkcji w M L jest podana następu jąco: fun i d ( i d j , . . . , id^ ) = W; 0
gdzie i d jest nazwą funkcji, a i d . . . , id^ są jej parametrami. Dla ułatwienia przyjmij my, że składnia wyrażenia W jest taka, jak na rys. 6.16, gdzie jedynymi identyfikatorami w W są nazwy funkcji, ich parametry i wbudowane funkcje. Takie podejście jest więc formalizacją tego, co przedstawiliśmy w przykładzie 6.9, gdzie typ polimorficzny był wnioskowany dla d e r e f . Nowe zmienne typu powstają dla nazwy funkcji i jej parametrów. Wbudowane funkcje mają zwykle polimorficzne typy; każda zmienna typu, która pojawi się w tych typach, jest ograniczona kwantyfikatorami V. Możemy sprawdzić, czy typy wyrażeń i d (idj, . . . , id^) i W pasują. Jeżeli tak, ozna cza to, że wywnioskowaliśmy typ dla nazwy funkcji. Podsumowując, każda zmienna wywnioskowanego typu jest związana z kwantyfikatorem V i tworzy polimorficzny typ dla funkcji. 0
1 (
Q
Przykład 6.12. fun
Przypomnijmy sobie funkcję M L z rys. 6.14, określającą długość listy
dlugosc(lwsk) = if nuli(lwsk) then O else dlugosc(tl(lwsk))
+ 1;
Zmienne typu a i j8 są wprowadzone odpowiednio dla typów d l u g o s c i l w s k . Do wiadujemy się, że typ d l u g o s c ( l w s k ) pasuje do wyrażenia tworzącego treść funkcji i że d l u g o s c musi być typu
dla każdego typu a , lista(a)
—> integer
stąd typ d l u g o s c to V a. lista(a)
-> integer
Na rysunku 6.20 przedstawiliśmy program napisany bardziej szczegółowo, do któ rego można zastosować reguły kontroli typów z rys. 6.18. Deklaracje w programie wiążą nowe zmienne typu a i j3 z d l u g o s c i l w s k oraz jawnie tworzą typy wbudowanych operacji. Warunki zapisujemy, podobnie jak na rys. 6.16, stosując operator polimorficz ny i f dla trzech argumentów, reprezentujących wyrażenie przeznaczone do testowania, część then i część else; z deklaracji wynika, że części then i else mogą mieć dowolny pasujący typ, który jest także typem rezultatu.
dlugosc lwsk if nuli tl 0 1 + sprawdź sprawdź(
y; Va. booleanx a x a —» a; Va. lista(a) boolean; Va. lista(a) -> lista(a); integer} integer; integer x integer ~> Va. a x a -» a ;
integer;
dlugosc(lwsk); if( nuli(lwsk), 0, dlugosc(tl(lwsk)) + 1
Rys. 6.20. Deklaracje poprzedzające wyrażenia przeznaczone do kontroli
Oczywiste jest, że d l u g o s c ( l w s k ) musi mieć ten sam typ co treść funkcji; to sprawdzenie jest zakodowane przy użyciu operatora s p r a w d ź . Użycie s p r a w d ź jest technicznym ułatwieniem, pozwalającym wykonać całą kontrolę przy użyciu podobnego programu jak ten z rys. 6.16. Rezultaty zastosowania reguł kontroli typów z rys. 6.18 dla programu z rys. 6.20 są opisane na rys. 6.21. Nowe zmienne, utworzone jako efekt działania operatora odśwież zastosowanego dla polimorficznych typów wbudowanych operacji, są rozróżniane za po mocą indeksu dolnego a. Dowiadujemy się z wiersza (3), że d l u g o s c musi być funkcją z typu y na typ 8. Wówczas, sprawdzając podwyrażenie n u l i ( l w s k ) , dowiadujemy się z wiersza (6), że y unifikuje się z lista(a ), gdzie a jest nieznanym typem. Wiemy więc, że typ d l u g o s c musi być n
dla każdego typu a , lista(a ) n
n
n
->• 8
W końcu, kiedy w wierszu (15) jest sprawdzone dodawanie (dla jasności możemy pisać + między jego argumentami) 8 jest unifikowane z integer. Kiedy sprawdzanie jest zakończone, zmienna typu a pozostaje typu d l u g o s c . Skoro żadne założenia nie są zrobione na a każdy typ może być za nią podstawiony, gdy użyta jest funkcja. Dlatego zrobimy z niej zmienną związaną i napiszemy n
ni
WYRAŻENIE
WIERSZ
PODSTAWIENIE
TYP
lwsk dlugosc dlugosc(lwsk) lwsk nuli nuli(lwsk) 0 lwsk tl tl (lwsk) dlugosc dlugosc(tl(lwsk)) 1
r (1) (2) P 8 (3) (4) 7 lista(On) —> boolean (5) boolean (6) integer (7) lista(oh) (8) - 4 //sra (a,) listafa) (9) lista(a ) (10) lista(On) -> 5 (11) 8 (12) integer (13) + integer x integer —> integer (14) (15) dlugosc(tl(lwsk) ) +1 integer if boolean x a- x a- -> a,(16) (17) if (•••) integer sprawdź &m tX (18) sprawdź ( • • •integer ) (19) n
p = y-»5
y~
lista(a ) n
ą = a
n
8 = integer a = integer t
x
m
n
n
n
= integer
-> integer
dla typu d l u g o s c .
6.7
m
-> integer dla dlugosc
Rys. 6.21. Wynikanie typu lista(a )
V a . lista(a )
a
•
Algorytm unifikacji
Nieformalnie unifikacja jest problemem wymagającym zadecydowania, czy dwa wyra żenia e i / mogą być doprowadzone do identycznej postaci przez zastąpienia wyrażeń na zmienne w e i / . Testowanie równoważności wyrażeń jest szczególnym przypadkiem unifikacji: jeżeli e i / zawierają stałe, a nie zawierają zmiennych, to e i / zrównują się wtedy i tylko wtedy, gdy są identyczne. Algorytm unifikacji z tego podrozdziału mo że być zastosowany dla grafów z cyklami, a więc może być użyty do przetestowania strukturalnej równoważności typów cyklicznych . Unifikacja ze względu na funkcję P , nazwaną podstawieniem, odwzorowującą zmien ne na wyrażenia, była zdefiniowana w poprzednim podrozdziale. Oznaczamy przez P(e) wyrażenie otrzymane, gdy każda zmienna a z e jest zastąpiona przez P(cc). P jest zrów naniem dla e i f> jeżeli P(e) = P(f). Algorytm 6.1 określa podstawienie, które jest najbardziej ogólną unifikacją pary wyrażeń. 1
W niektórych aplikacjach błędem jest unifikacja zmiennej z wyrażeniem zawierającym tę zmienną. Algorytm 6.1 pozwala na takie podstawienia.
P r z y k ł a d 6.13. Aby przyjrzeć się najbardziej ogólnym unifikacjom, rozważmy dwa wyrażenia określające typy ((aj (Oj
o^) -» a) 4
x listaic^)) x lista(a^))
lista(oQ a 5
Dwie unifikacje P i P' dla tych wyrażeń są następujące: X
s«
S'(x)
«1 «2 «3 «4 «5
Oj
«i «i «i «i
«2 Oj «2
/wrfl(a!)
Te podstawienia odwzorowują e i / następująco: p ( e ) = />(/) = ( ( o j ->• c^) x / ^ ( O j ) ) p {e) -> x Ustala-)
lista{a^
= P'(/) = ((a,
t
/wfa(aj) l
Podstawienie F jest najbardziej ogólną unifikacją e i / . Zauważmy, że P (e) jest instan cją P(e), ponieważ możemy podstawić aj dla obu zmiennych w P(e). Jednak odwrotne stwierdzenie nie jest prawdziwe, ponieważ to samo wyrażenie musi zostać podstawione dla każdego wystąpienia a w P*{e) a więc nie możemy otrzymać P(e) przez podsta wienie za zmienną aj w P'(e). • x
y
Jeżeli wyrażenia do zunifikowania są reprezentowane przez drzewa, liczba węzłów w drzewie dla podstawionego wyrażenia P(e) może zwiększać się wykładniczo dla e i / , nawet jeżeli P jest najbardziej ogólną unifikacją. Jednak taki wzrost nie musi wystąpić, gdy do reprezentacji wyrażeń i podstawień zostaną użyte grafy zamiast drzew. Będziemy implementować sformułowanie unifikacji w teorii grafów, prezentowane również w poprzednim podrozdziale. Problem pojawia się przy grupowaniu, gdyż w zbio rach węzły muszą być równoważne po najbardziej ogólnej unifikacji dwóch wyrażeń. Dwa wyrażenia z przykładu 6.13 są reprezentowane przez dwa węzły, oznaczone —Kl na rys. 6.22. Liczby całkowite przy węzłach oznaczają równoważność klas, do których węzły należą po zrównaniu węzłów z numerem 1. Te równoważności klas mają taką własność, że wszystkie wewnętrzne węzły w klasie dotyczą tego samego operatora. Odpowiadające dzieci węzłów pośrednich w klasie równoważności są również równoważne. ->:1
a: 4 x
a :5 2
->:1
a :4 3
Rys. 6.22. Klasy równoważności po unifikacji
a :5 4
A l g o r y t m 6.1.
Unifikacja dwóch wierzchołków w grafie.
Wejście. Graf i para węzłów m i n do unifikacji. Wyjście. Wartość logiczna prawda, jeżeli wyrażenia reprezentowane przez węzły m i n dają się zunifikować; fałsz w przeciwnym przypadku. Wersję operacji unifikuj, potrzebną ze względu na reguły kontroli typów z rys. 6.18, uzyskujemy, jeżeli funkcja w tym algorytmie zostanie zmieniona tak, aby zwracała błąd zamiast fałsz. Metoda. Węzeł jest reprezentowany przez rekord, jak na rys. 6.23, z polami na dwuargumentowy operator oraz wskaźniki do prawego i lewego dziecka. Zbiór równoważnych węzłów można odczytać przy użyciu pola zbiór. Jeden wę zeł w każdej klasie równoważności jest wybrany jako unikalny reprezentant tej klasy przez ustawienie pola zbiór na wskaźnik nil. Pola zbiór w pozostałych węzłach klasy równoważności będą wskazywać (najprawdopodobniej nie wprost, ale poprzez inne wę zły) reprezentanta. Początkowo każdy węzeł n jest w klasie równoważności sam, z sobą samym jako węzłem -— reprezentantem.
konstruktor
lewy^ ^
zbiór
prawy
fi"
Rys. 6.23. Struktura danych dla węzła function unifikuj(m, n : węzeł) : boolean begin s := znajd£(m)\ t := znajdź(n); if s = t then return true else if s i t są węzłami, które reprezentują ten sam typ podstawowy then return true else if s jest węzłem operatora op z dziećmi s i s and t jest węzłem operatora op z dziećmi ty i t then begin połącz(s, t) return unifikujmy, ty) and unifikuj(s , t ) end else if s lub t reprezentuje zmienną then begin połącz(s, t) return true end else return false /* wewnętrzne węzły z różnymi operatorami nie mogą zostać zunifikowane */ end x
2
2
2
2
Rys. 6.24. Algorytm unifikacji Algorytm unifikacji, przedstawiony na rys. 6.24, wykorzystuje następujące dwie operacje na węzłach:
1) 2)
znajdź(n) zwraca reprezentanta klasy równoważności aktualnie zawierającej węzeł n. połącz(m,n) łączy klasy równoważności zawierające węzły m i n. Jeżeli jeden z re prezentantów klas równoważności m i n jest węzłem bez zmiennej, połącz sprawia, że węzeł ten staje się reprezentantem połączonych klas równoważności; w przeciwnym przypadku połącz wybiera na nowego reprezentanta jednego z poprzednich repre zentantów. Ta asymetria w specyfikacji połącz jest ważna, ponieważ zmienna nie może być użyta jako reprezentant klasy równoważności dla wyrażenia zawierające go konstruktor typu lub typ podstawowy. W przeciwnym razie, dwa nierównoważne wyrażenia mogłyby zostać zunifikowane przez tę zmienną.
Operacja połącz dla zbiorów jest zaimplementowana przez proste zmienianie pola zbiór, reprezentanta klasy równoważności, w taki sposób, że zaczyna on wskazywać reprezen tanta innej klasy. Aby znaleźć klasę równoważności, do której należy węzeł, sprawdzamy, na co wskazują kolejne wskaźniki zbiór, aż znajdziemy reprezentanta (węzeł z polem zbiór o wartości nil). Zauważmy, że algorytm z rys. 6.24 używa raczej s = znajdź(m) i t = znajdź(n), a nie odpowiednio m i n. Reprezentanci s i t są równi, jeżeli m i n należą do tej samej klasy równoważności. Jeżeli s i t reprezentują ten sam typ podstawowy, wywołanie połącz(m, n) zwróci wartość logiczną prawda. Jeżeli s i t są wewnętrznymi węzłami dla dwuargumentowego konstruktora typu, domyślnie łączymy ich klasy równoważności i rekurencyjnie sprawdzamy, czy ich klasy równoważności są równe. Wskutek początkowe go łączenia zmniejszamy liczbę klas równoważności przed rekurencyjnym sprawdzeniem dzieci, więc algorytm kończy działanie. Podstawienie zmiennych za wyrażenia jest zaimplementowane przez dodanie liścia ze zmienną do klasy równoważności zawierającej węzeł dla wyrażenia. Jeżeli m lub n jest liściem ze zmienną, która została dołączona do klasy równoważności zawierającej węzeł reprezentujący wyrażenie z konstruktorem typu lub typem podstawowym, to znajdź zwró ci reprezentanta, który odzwierciedla ten konstruktor typu lub typ podstawowy, a więc zmienna nie może być zunifikowana z dwoma różnymi wyrażeniami. • P r z y k ł a d 6.14, Na rysunku 6.25 pokazaliśmy początkowy graf, dla dwóch wyrażeń z przykładu 6.13, z wszystkimi ponumerowanymi węzłami i w swojej własnej klasie równoważności. Aby obliczyć unifikuj(\, 9), algorytm zauważa, że oba węzły 1 i 9 re prezentują ten sam operator, więc łączy 1 i 9 w jedną klasę równoważności i wywo łuje unifikuj(2, 10) i unifikuj(%, 14). Wynik obliczenia unifikuj{\, 9) jest pokazany na rys. 6.22. •
->:9 x :2
lista : 8
x : 10
a: s
14
lista : 13 a: A
12
Rys. 6.25. Początkowy graf dag z każdym węzłem w swojej własnej klasie równoważności
Jeżeli algorytm 6.1 zwróci wartość logiczną prawda, możemy skonstruować pod stawienie 5, które będzie się zachowywać jak unifikacja. Niech każdy węzeł n z wyni kowego grafu reprezentuje wyrażenie związane ze znajdź(n). Stąd, dla każdej zmiennej a , znajdf(a) zwróci węzeł n, który jest reprezentantem klasy równoważności a. Wy rażeniem reprezentowanym przez n jest P(cc). Przykładem może być rys. 6.22, gdzie reprezentantem dla jest węzeł 4, który reprezentuje a . Reprezentantem dla a jest węzeł 8, który reprezentuje lista(ot^). x
5
P r z y k ł a d 6.15. Algorytm 6.1 może być użyty do testowania równoważności struktu ralnej dwóch wyrażeń określających typy e : real — > e f : real -¥ (real -> f) Grafy typów dla tych wyrażeń są pokazane na rys. 6.26. Dla wygody węzły zostały ponumerowane.
real:6 Rys. 6.26. Graf dla dwóch typów cyklicznych
Możemy wywołać unifikuj(l, 3), aby przetestować strukturalną równoważność tych dwóch wyrażeń. Algorytm łączy węzły 1 i 3 w klasę równoważności i rekurencyjnie wywołuje unifikuj(2, 4) i unifikuj(l, 5). Skoro 2 i 4 reprezentują ten sam typ podstawo wy wywołanie unifikuj(2, 4) zwróci wartość logiczną prawda. Wywołanie unifikuj(l, 5) dodaje 5 do klasy równoważności 1 i 3, oraz rekurencyjnie wywołuje unifikuj(2, 6) i uni fikuj^, 3).
real: 2 Rys. 6.27. Graf typów pokazujący równoważność klas węzłów
Wywołanie unifikuj(2, 6) zwraca wartość logiczną prawda, ponieważ 2 i 6 reprezen tują ten sam typ podstawowy. Drugie wywołanie unifikuj(l, 3) kończy się, ponieważ już mamy połączone węzły 1 i 3 w tę samą klasę równoważności.
Algorytm wówczas się kończy, zwracając wartość logiczną prawda, aby pokazać, że dane określenia typów są istotnie równoważne. Na rysunku 6.27 pokazano wynikowe klasy równoważności węzłów, gdzie węzły z tą samą liczbą całkowitą są w tej samej • klasie równoważności. ĆWICZENIA 6.1 Zapisz wyrażenie określające typy dla następujących typów: a) tablicy wskaźników na liczby rzeczywiste, w której indeksy są od 1 do 100, b) dwuwymiarowej tablicy liczb całkowitych (to znaczy tablicę tablic), której wier sze są indeksowane od 0 do 9, a kolumny od —10 do 10, c) funkcji, których dziedziną są funkcje z liczb całkowitych na wskaźniki na liczby całkowite i których zakresy są rekordami składającymi się z liczby całkowitej i znaku. 6.2 Załóżmy, że mamy następującą deklarację w C: typedef struct i n t a, b ; }
KOM,
{
*WKOM;
KOM k t o [ 1 0 0 ] ; WKOM p a s ( x ,
y)
int
x;
KOM y
{ •••
}
Zapisz wyrażenia określające dla typów k t o i p a s . 6.3 Poniższa gramatyka definiuje listę list literałów. Interpretacja symboli jest taka sa ma, jak ta dla gramatyki z rys. 6.3 z dodaniem typu list, które oznacza następującą listę elementów typu T: P D T W L
-> D ; W -> D ; D | id : T -» list of T | char | integer ( L ) | literał | liczba | id W, L I W
Zapisz reguły translacji podobne do tych z p. 6.2, aby określić typy wyrażeń (W) i list (L). 6.4 Dodaj do gramatyki z ćwiczenia 6.3 produkcję W^nil oznaczającą, że wyrażenie może być listą pustą. Popraw reguły w twojej odpo wiedzi do ćwiczenia 6.2, biorąc pod uwagę fakt, że nil może oznaczać listę pustą elementów dowolnego typu. 6.5 Używając schematu translacji z p. 6.2, wyznacz typy wyrażeń w następujących fragmentach programu. Zaprezentuj typy przy każdym węźle drzewa wyprowadze nia. a) c : c h a r ; i : integer; c mod i mod 3 b) p : T i n t e g e r ; a : a r r a y a[pT]
[10]
of
integer;
c) f: i n t e g e r —> b o o l e a n ; i: integer; j : integer; w h i l e f ( i ) do k := i ; i := j mod i ; j := k
k:
integer;
6.6 Zmodyfikuj schemat translacji dla kontroli wyrażeń w p. 6.2 tak, aby wypisywał opisową wiadomość, gdy zostanie wykryty błąd i kontynuował sprawdzanie, tak, jakby oczekiwany typ się pojawił. 6.7 Przepisz reguły kontroli typów dla wyrażeń z p. 6.2 tak, żeby odnosiły się do węzłów w reprezentacji grafowej wyrażeń określających typy. Przepisane reguły powinny używać struktur danych i operacji wspieranych przez język, taki jak Pascal. Użyj strukturalnej równoważności typów, gdy: a) określenia typów są reprezentowane przez drzewa, tak jak na rys. 6.2, b) graf typów jest grafem dag z unikalnym węzłem dla każdego wyrażenia okre ślającego typ. 6.8 Zmodyfikuj schemat translacji z rys. 6.5 tak, aby obsługiwał następujące przypadki: a) instrukcje mają wartości: wartością przypisania jest wartość wyrażenia po pra wej stronie znaku : =, a wartością instrukcji warunkowej lub pętli while jest wartość treści instrukcji; wartością listy instrukcji jest wartość ostatniej instruk cji z tej listy, b) wyrażenia logiczne: dodaj produkcje dla operatorów logicznych a n d , o r i n o t oraz dla operatorów porównania (np. < ) ; później dodaj odpowiednie schematy translacji wyznaczające typ tych wyrażeń. 6.9 Uogólnij reguły kontroli typów dla funkcji podanych na końcu p . 6.2 tak, aby obsługiwały funkcje n-argumentowe. 6.10 Przyjmij, że nazwy l i n k i kom są zdefiniowane jak w p. 6.3. Które z poniższych wyrażeń są strukturalnie równoważne? Które są równoważne nazwami? i)
link,
ii) wskatnikikom), iii) wskaznik(link), iv) wskaźnik(record((Ln£o
x integer) x ( n a s t x
wskaźnik(kom))))
6.11 Przepisz algorytm do testowania strukturalnej równoważności z rys. 6.6 tak, aby argumenty srown były wskaźnikami do węzłów w grafie dag. 6.12 Rozważ kodowanie ograniczonych wyrażeń określających typy jako sekwencji bi tów z przykładu 6.1. U Johnsona [1979] dwubitowe pola dla konstruktorów po kazują się w odwrotnej kolejności — pole na najbardziej zewnętrzny konstruktor pojawia się po czterech bitach przeznaczonych na typ podstawowy; na przykład: WYRAŻENIE TYPU
char f-rez(char) wskainikifrez(char)) array(wskaźnik(frez(char)))
KODOWANIE
000000 000011 001101 110110
0001 0001 0001 0001
Korzystając z operatorów w C, zapisz kod konstruujący reprezentację array(t) i odwrotnie: przyjmując, że kodowanie jest takie, jak:
zf
a) u Johnsona [1979], b) w przykładzie 6.1. 6.13 Załóżmy, że typ każdego identyfikatora jest podzakresem liczb całkowitych. Dla wyrażeń z operatorami +, - , *, d i v i mod, takich jak w Pascalu, zapisz reguły kontroli typów, które przypisują każdemu podwyrażeniu podzakres, w którym musi znajdować się jego wartość. 6.14 Podaj algorytm testujący równoważności typów C (patrz przykład 6.4). 6.15 Niektóre języki, jak PL/I, wymuszają wartość logiczną na liczbie całkowitej, gdzie prawda jest identyfikowana jako 1 i fałsz jako 0. Przykładowo, 3 < 4 < 5 jest gru powane ( 3 < 4 ) < 5 i ma wartość logiczną „prawda" (lub 1) ponieważ 3 < 4 ma wartość 1 i 1 < 5 jest prawdą. Zapisz zasady translacji dla wyrażeń logicznych, aby dokonywały tego wymuszenia. Użyj instrukcji warunkowych w pośrednim języku do przypisania wartości całkowitych do zmiennych tymczasowych, które reprezen tują wartość wyrażenia logicznego, kiedy jest to potrzebne. 6.16 Uogólnij algorytmy (a) z rys. 6.9 i (b) z rys. 6.12 na wyrażenia z konstruktorami typów array, wskaźnik i iloczyn kartezjański. 6.17 Które z poniższych rekurencyjnych wyrażeń określających typy są równoważne? e l = integer —> e l e 2 = integer -> (integer -» e 2 ) e 3 = integer —> (integer —> e l ) 6.18 Używając zasad z przykładu 6.6, wskaż te z poniższych wyrażeń, które mają uni kalne typy (załóż, że z jest liczbą zespoloną): a) 1*2*3 b) l * ( z * 2 ) c) ( l * z ) * z 6.19 Załóżmy, że pozwolimy na konwersję typów z przykładu 6.6. Jakie warunki należy przyjąć dla a, b i c (liczby całkowite lub zespolone), aby wyrażenie (a*b)*c miało unikalny typ? 6.20 Wyraź, za pomocą zmiennych typu, typy następujących funkcji: a) ref, której argumentem jest obiekt dowolnego typu i która zwraca wskaźnik na ten obiekt, b) której argumentami jest tablica indeksowana liczbami całkowitymi, której ele menty są dowolnego typu i która zwraca tablicę z obiektami wskazywanymi przez elementy danej tablicy. 6.21 Znajdź najbardziej ogólną unifikację wyrażeń określających typy: i) (wskaźnik(a)) ii) J3 x ( y - > 5)
x (j3 ->• y)
Co by było, gdyby S w (ii) zamienić na a ? 6.22 Znajdź najbardziej ogólną unifikację dla każdej pary wyrażeń z następującej listy lub określ, że ono nie istnieje:
a) ax -> (ctj -> a , ) b) array(j5 ) x
c) 7j -> y
-¥ (wskaźnik (j3 ) —• /5 ) x
3
2
d) ^ -+ (Ą -> 5 ) 2
6.23 Rozszerz reguły kontroli poprawności typów z przykładu 6.6 tak, aby uwzględniały rekordy. Użyj następującej dodatkowej składni dla wyrażeń określających typy i wyrażeń: T —> record pola end W ^WAd pola —> pola ; pole \ pole pole -> id : T Jakie ograniczenia nakłada brak nazw typów na typy, które mogą zostać zdefinio wane? *6.24 Rozwiązanie problemu przeciążania z p. 6.5 ma dwie fazy: najpierw ustalany jest zbiór możliwych typów dla każdego podwyrażenia, a następnie jest on zawężany do jednego typu, po czym określany jest unikalny typ dla całego wyrażenia. Jakiej struktury danych byś użył do rozwiązania problemu przeciążania w pojedynczym przejściu wstępującym? **6.25 Rozwiązanie problemu przeciążania staje się trudniejsze, gdy identyfikator dekla racji jest opcjonalny. Precyzując, deklaracje mogą zostać użyte do przeciążania identyfikatorów reprezentujących symbole funkcyjne, ale wszystkie wystąpienia niezadeklarowanych identyfikatorów mają ten sam typ. Wykaż, że problem usta lenia, czy wyrażenie w tym języku ma poprawny typ jest NP-zupełny. Ten pro blem powstaje podczas kontroli typów w eksperymentalnym języku Hope (Burstall, MacQueen i Sannella [1980]). 6.26 Wzorując się na przykładzie 6.12, wywnioskuj polimorficzny typ dla m a p :
map
: V a . V/3. ((a ->• j3) x lista(a)) -*
lista(p)
Definicja M L dla m a p jest następująca: fun
map(f,
1) i f nuli(1) then n i l else cons( f ( h d ( l ) ) ,
map(f,
tl(l)))
Typy identyfikatorów wbudowanych w treść funkcji są następujące: nuli nil cons hd tl
: : : : :
Va. Va. Va. Va. Va.
lista(a) -> boolean; lista(a)\ (a x lista(a)) lista(a)\ lista(a) -» a ; lista(a) -> lista(a);
**6.27 Wykaż, że algorytm unifikacji z p. 6.7 określa najbardziej ogólną unifikację. *6.28 Zmodyfikuj algorytm unifikacji z p. 6.7 tak, żeby nie unifikował zmiennych z wy rażeniami zawierającymi te zmienne.
**6.29 Załóżmy, że wyrażenia są reprezentowane przez drzewa. Znajdź wyrażenia e i / takie, że dla każdej unifikacji P liczba węzłów w P(e) jest wykładnicza względem liczby węzłów w e i / . 6.30 D w a węzły są nazwane przystającymi, jeżeli reprezentują równoważne wyraże nia. Nawet jeżeli żadne dwa węzły w oryginalnym grafie nie są przystające, jest możliwe, że p o unifikacji dwa różne węzły będą przystające, a) Podaj algorytm łączenia klas wzajemnie przystających węzłów w pojedyncze węzły. **b) Rozszerz algorytm z a), aby łączył przystające węzły tak długo, aż żadne dwa węzły nie będą przystające. *6.31 Wyrażenie g ( g ) z wiersza 9 z pełnego programu w C z rys 6.28 jest zastosowniem funkcji do niej samej. Deklaracja z wiersza 3 podaje integer jako typ g, ale typ argumentów g nie jest określony. Spróbuj uruchomić program. Kompilator może wyświetlić ostrzeżenie, ponieważ g jest zadeklarowane jako funkcja w wierszu 3, a nie wskaźnik do funkcji. (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (U) (12) (13) (14) (15)
int n; int f (g) int g() ;
{ int m; m = n; if ( m = else { n =
:
0 ) return 1;
} } main ()
{ n = 5;
} Rys. 6.28. Program w C zawierający zastosowanie funkcji do niej samej
a) Co możesz powiedzieć o typie g ? b) Wykorzystaj reguły kontroli typów dla polimorficznych funkcji z rys. 6.18 do wywnioskowania typu g w następującym programie: m times g times (
: integer ; : integer x integer —» integer ; : a ; m, g ( g ) )
UWAGI B I B L I O G R A F I C Z N E We wczesnych językach, takich jak Fortran i Algol 60, typy podstawowe i konstruktory typów były wystarczająco ograniczone, aby kontrola typów nie była poważnym proble mem. W rezultacie opis sprawdzania typów w ich kompilatorach pojawiał się w dys kusjach o uogólnieniu kodu dla wyrażeń. Sheridan [1959] opisał translacje wyrażeń w oryginalnym kompilatorze Fortrana. Kompilator ten sprawdzał, czy wyrażenie jest ty pu całkowitego, czy rzeczywistego, ale język nie pozwalał na wymuszenia. Backus [1981, s. 54] wspominał: „Myślę, że dlatego, iż nie podobały się nam zasady obowiązujące dla wyrażeń mieszanych, zadecydowaliśmy: 'Wyrzućmy je. Będzie łatwiej.'". W pracy na temat kontroli typów w kompilatorze Algol, Naur [1965] opisał techniki użyte przez kompilator podobne do tych rozważanych w p. 6.2. Pojęcia strukturalizujące dane, takie jak tablice i rekordy, Zuse przewidział już w latach czterdziestych w swojej pracy „Plankalkul", nie miały one jednak większe go znaczenia (Bauer i Wóssner [1972]). Jednym z pierwszych języków programowania, który pozwolił, aby wyrażenia określające typy były konstruowane systematycznie, był Algol 68. Wyrażenia określające typy mogły w nim być definiowane rekurencyjnie i moż na było użyć strukturalnej równoważności typów. Wyraźne rozróżnienie równoważności strukturalnej i przez nazwę można znaleźć w E L I , wybór pozostawiono programiście (Wegbreit [1974]). W krytyce Pascala, Welsh, Sheeringer i Hoare [1977] zwrócili uwagę na to rozróżnienie. Kombinacja sprowadzania do zgodności i przeciążania typów może prowadzić do niejasności: wymuszanie argumentu może prowadzić do przeciążenia przy wykorzystaniu innego algorytmu. Ogranicza więc jedno albo drugie. Takie ograniczające podejście do sprowadzania do zgodności zastosowano w PL/I, gdzie pierwsze założenie projektowe brzmiało: „Cokolwiek zadziała". Jeżeli szczególna kombinacja symboli jest uzasadniona, to będzie ona oficjalnie obowiązująca (Radin i Rogoway [1965]). Kolejność często była narzucana zbiorom typów podstawowych — na przykład Hext [1967] opisał strukturę siatki nałożoną na typy podstawowe w CPL — i niższe typy mogły być przekształcane na wyższe. Rozwiązanie problemu przeciążania w trakcie kompilacji, w językach takich jak APL (Iverson [1962]) i SETL (Schwartz [1973]), może poprawić czas działania progra mów (Bauer i Saal [1974]). Tennenbaum [1974] rozróżniał „przednie" rozwiązanie, które określa zbiór możliwych typów operatora z jego argumentów, i „wsteczne" rozwiązanie, oparte na typie oczekiwanym przez kontekst. Używając siatki typów, Jones i Muchnick [1976] oraz Kapłan i Ullman [1980] rozwiązali problem ograniczeń dla typów, korzy stając z przedniej i wstecznej analizy. Przeciążanie w Adzie może być usunięte przez pojedynczy przedni, a potem pojedynczy wsteczny przebieg, jak w p. 6.5. Ta obserwacja pojawiła się w wielu pracach: Ganzinger i Ripken [1980], Pennello, DeRemer i Meyers [1980], Janas [1980], Persch i inni [1980]. Cormack [1981] przedstawił implementa cję rekurencyjna, a Baker [1982] uniknął jawnego wstecznego przebiegu, przechowując diagram możliwych typów. Wnioskowaniem typów zajął się Curry (Curry i Feys [1958]) w związku z logiką kombinatoryczną i rachunkiem lambda dokonanym przez Churcha [1941]. Dawno j u ż zaobserwowano, że rachunek lambda jest podstawą języków funkcyjnych. Wielokrotnie stosowaliśmy funkcję dla argumentów, aby omówić w tym rozdziale pojęcie kontroli ty-
pów. Funkcje mogą być definiowane i stosowane niezależnie od typów w rachunku lambda i Curry był zainteresowany ich „funkcjonalnym charakterem" oraz ustaleniem, co należy nazywać najbardziej ogólnym polimorficznym typem, składającym się z określenia typów z uniwersalnymi kwantyfikatorami, jak w p . 6.6. Zainspirowany przez Curry'ego, Hindley [1969] zaobserwował, że unifikacja może być użyta do wnioskowania typów. Niezależ nie, Morris [1968a] przypisał typy d o wyrażeń lambda przez ułożenie zbiorów równań i rozwiązanie ich, określając typy związane ze zmiennymi. Nieświadomy pracy Hindleya, Milner [1978] także zaobserwował, że unifikacja może być użyta do rozwiązania zbioru równań i zastosował ten pomysł do wnioskowania typów w języku programowania ML. Zestaw zasad kontroli poprawności typów w M L opisał Cardelli [1984]. To podej ście zostało zastosowane do języka napisanego przez Meertensa [1983]; Suzuki [1981] badał jego zastosowanie w Smalltalku 1976 (Ingalls [1978]). Mitchell [1984] przedstawił możliwości włączenia wymuszeń. Morris [1968a] zaobserwował, że rekurencyjne i cykliczne typy pozwalają na wnio skowanie typów dla wyrażeń zawierających zastosowanie funkcji do siebie samej. Pro gram w C z rysunku 6.28, zawierający zastosowanie funkcji do niej samej, jest inspi rowany programem w Algolu napisanym przez Ledgarda [1971]. Ćwiczenie 6.31 jest zaczerpnięte z pracy MacQueena, Plotkina i Sethiego [1984], gdzie podany jest model semantyczny dla rekurencyjnych typów polimorficznych. Inne podejścia do tego problemu występują u McCrackena [1979] i Cartwrighta [1985]. Reynolds [1985] zbadał system typów ML, teoretyczne wskazówki unikania anomalii dotyczących wymuszeń i przecią żania oraz funkcji polimorficznych wyższego rzędu. Robinson [1965] zajął się unifikacją. Algorytm unifikacji z p. 6.7 można łatwo przy stosować do algorytmów do testowania równoważności (1) automatów skończonych i (2) wiązanej listy z cyklami (Knuth [1973a], p. 2.3.5, ćwiczenie 11). Prawie liniowy algo rytm testujący równoważność automatów skończonych Hopcrofta i Karpa [1971] może być postrzegany jako implementacja szkicu z pracy Knutha [1973a, s. 594]. Sprytne użycie struktur danych, algorytmów liniowych dla acyklicznego przypadku jest przed stawione przez Patersona i Wegmana [1978] oraz Martelliego i Montanariego [1982]. Algorytm znajdujący przystające węzły (patrz ćwiczenie 6.30) pojawił się u Downeya, Sethiego i Tarjana [1980]. Despeyroux [1984] opisał generator sprawdzający typy, który używa dopasowywania wzorca do stworzenia kontrolera typów ze specyfikacji semantyki operacyjnej opartej na regułach wnioskowania.
ROZDZIAŁ
Środowiska przetwarzania
Zanim zajmiemy się generacją kodu, musimy umieć powiązać statyczny kod źródłowy programu z akcjami, wykonywanymi w trakcie działania i implementującymi program. Ta sama nazwa w programie źródłowym może oznaczać różne obiekty danych na maszynie docelowej. W tym rozdziale zbadaliśmy związek między nazwami i obiektami danych. Przydzielanie i zwalnianie pamięci dla obiektów danych jest zarządzane przez pakiet wspomagania przetwarzania, składający się z procedur ładowanych wraz z wygenerowa nym kodem wynikowym. Projekt pakietu wspomagania przetwarzania zależy od seman tyki procedur. Przy użyciu technik omawianych w tym rozdziale można skonstruować pakiety wspomagania dla takich języków, jak Fortran, Pascal i Lisp. Każde działanie procedury jest nazywane aktywacją tej procedury. Jeśli procedu ra jest rekurencyjna, to w danej chwili może istnieć jednocześnie kilka jej aktywacji. Każde wywołanie procedury w Pascalu prowadzi do aktywacji, która może operować na obiektach danych, których pamięć została przydzielona do tej aktywacji. Reprezentacja obiektu danych w trakcie działania wynika z j e g o typu. Często pod stawowe typy danych, j a k znaki, liczby całkowite i rzeczywiste, mogą być reprezentowane przez równoważne obiekty danych na maszynie docelowej. Natomiast obiekty złożone, jak tablice, napisy i struktury, są zwykle przedstawiane jako kolekcje typów podstawo wych; ich struktura jest omówiona w rozdz. 8.
7.1
Język źródłowy
Dla uproszczenia załóżmy, że podobnie j a k w Pascalu, program składa się z procedur. W tym podrozdziale rozróżniamy kod źródłowy procedur od ich aktywacji w czasie działania. Procedury Definicją procedury jest deklaracja, która, w najprostszej postaci, związuje identyfika tor z instrukcją. Identyfikator jest nazwą procedury, a instrukcja treścią procedury. Na przykład, kod Pascala z rys. 7.1 w wierszach 3 - 7 zawiera definicję procedury nazwanej
c z y t a j t a b . Treść tej procedury jest w wierszach 5-7. Procedury zwracające wartości, w wielu językach są nazywane funkcjami, chociaż wygodniej jest odnosić się do nich jak do procedur. Cały program również jest traktowany jak procedura. (1) (2) (3) (4) (5) (6) (7)
program sort(input, output) ; var a : array [0. .10] of integer; procedurę czytajtab; var i : integer; begin for i := 1 to 9 do read(a[i]) end;
(8) (9) (10) (11)
function podział(y, z: integer) : integer; var i, j, x, v: integer; begin ... end
(12) (13) (14) (15) (16) (17) (18) (19) (20)
procedurę ąuicksort(m, n: integer); var i : integer; begin if ( n > m ) then begin i := podział(m, n ) ; ąuicksort(m, i-1); ąuicksort (i + 1, n ) ; end end;
(21) (22) (23) (24) (25)
begin a[0] := -9999; a[10] := 9999; czytajtab; ąuicksort(1,9) end. Rys. 7.1. Program w Pascalu, wczytujący i sortujący liczby całkowite
Jeżeli nazwa procedury pojawia się wewnątrz instrukcji, mówimy, że procedura jest w tym miejscu wywoływana. Wywołanie procedury powoduje wykonanie treści proce dury i jest to podstawowa zasada jej działania. Główny program w wierszach 2 1 - 2 5 z rys. 7.1 wywołuje w wierszu 23 procedurę c z y t a j t a b i następnie, w wierszu 24, wywołuje procedurę ą u i c k s o r t . Zauważmy, że wywołania procedur mogą również nastąpić w wyrażeniach, tak jak w wierszu 16. Niektóre z identyfikatorów, pojawiające się w definicji procedury, są specjalne i na zywamy j e parametrami formalny mi procedury. (W języku C są one nazywane „argumen tami formalnymi", a w Fortranie „argumentami ślepymi"). Identyfikatory m i n w wier szu 12 są parametrami formalnymi procedury ą u i c k s o r t . Argumenty, zwane para metrami aktualnymi, mogą być przekazane do wywoływanej procedury i podstawione w miejsce formalnych w treści procedury. Metody ustalania związku między parame trami aktualnymi i formalnymi omówiliśmy w p . 7.5. Wiersz 18 kodu z rys. 7.1 jest wywołaniem procedury ą u i c k s o r t , z parametrami aktualnymi i + 1 i n.
Drzewa aktywacji Wprowadźmy dwa założenia na temat przepływu sterowania między procedurami w trak cie wykonywania programu: 1. 2.
Wykonywanie programu jest sekwencyjne, czyli składa się z sekwencji kroków, w których może znaleźć się sterowanie programu w trakcie działania. Każde wykonanie procedury rozpoczyna się na początku treści procedury i po za kończeniu powraca do miejsca bezpośrednio za samym wywołaniem. Oznacza to, że przepływ sterowania między procedurami, j a k się wkrótce przekonamy, można przedstawić za pomocą drzew.
Każde wykonanie treści procedury nazywa się aktywacją tej procedury. Czas i s t n i e n i a aktywacji procedury p jest sekwencją kroków między pierwszym a ostatnim krokiem wykonywania treści procedury, włączając w to czas wykonywania procedur wywołanych z p , procedur wywołanych przez te ostatnie itd. Na ogół, termin „czas istnienia" odnosi się do sekwencji kolejnych kroków wykonywanych w trakcie działania programu. W językach, na przykład w Pascalu, sterowanie po każdym wejściu do procedury q z procedury p (w przypadku niewystąpienia błędu krytycznego) ostatecznie musi powró cić do p . Dokładniej, za każdym razem, gdy sterowanie programu przechodzi z aktywacji procedury p do aktywacji procedury q, musi powrócić do tej samej aktywacji dla p . Jeśli a i Z? są aktywacjami procedur, to ich czasy istnienia albo są rozłączne, al bo jeden jest zawarty w drugim. Oznacza to, że jeśli sterowanie wchodzi do b przed opuszczeniem a, to przed opuszczeniem a musi opuścić b. Tę własność zawierania czasów istnienia można zilustrować, wstawiając dwie in strukcje p r i n t do każdej procedury: jedną przed pierwszą instrukcją z treści procedury, a drugą za ostatnią. Pierwsza instrukcja drukuje napis w e j ś c i e oraz nazwę procedury i wartości parametrów aktualnych, druga drukuje napis o p u s z c z e n i e i te same infor macje. Przykładowy wynik wykonania programu z rys. 7.1 ze wstawionymi instrukcjami p r i n t przedstawiono na rys. 7.2. Czas istnienia aktywacji ą u i c k s o r t ( 1 , 9 ) składa się z sekwencji kroków wykonywanych od wypisania w e j ś c i e ą u i c k s o r t ( 1 , 9 ) do o p u s z c z e n i e ą u i c k s o r t ( 1 , 9). Na rysunku 7.2 założyliśmy, że p o d z i a ł ( 1 , 9 ) zwraca liczbę 4. Wykonanie rozpoczęte... wejście czytajtab opuszczenie czytajtab wejście ąuicksort(1, 9) wejście podział(1,9) opuszczenie podział(1,9) wejście ąuicksort(1,3) opuszczenie ąuicksort(1,3) wejście ąuicksort(5, 9) opuszczenie ąuicksort(5, 9) opuszczenie ąuicksort(1, 9) Wykonanie zakończone. Rys. 7.2. Wyjście odzwierciedlające aktywacje procedur z rys. 7.1
Procedura jest r e k u r e n c y j n a , jeśli nowa jej aktywacja może rozpocząć się przed zakończeniem wcześniejszej aktywacji tej samej procedury. Na rysunku 7.2 widać, że sterowanie wchodzi do aktywacji ą u i c k s o r t ( 1 , 9), w wierszu 24, na początku wyko nywania programu, a opuszczają prawie na samym końcu. W międzyczasie, kilkakrotnie następuje kolejne wejście do aktywacji procedury ą u i c k s o r t , więc procedura ta jest rekurencyjna. Rekurencyjna procedura p nie musi wywoływać siebie bezpośrednio: p może wy wołać inną procedurę q, która z kolei może wywołać p w pewnej sekwencji wywołań procedur. Można użyć drzewa, zwanego drzewem a k t y w a c j i , do zobrazowania kolejności, w jakiej sterowanie wchodzi i opuszcza aktywacje. W drzewie aktywacji: 1) 2) 3) 4)
każdy węzeł reprezentuje aktywację procedury, korzeń reprezentuje aktywację programu głównego, węzeł dla a jest rodzicem węzła dla b wtedy i tylko wtedy, gdy sterowanie przechodzi z aktywacji a do b, węzeł dla a znajduje się po lewej stronie węzła dla b wtedy i tylko wtedy, gdy a jest przed b.
Ponieważ każdy węzeł reprezentuje odrębną aktywację, a każda aktywacja odrębny wę zeł, wygodnie jest mówić o sterowaniu znajdującym się w węźle, gdy znajduje się ono w aktywacji reprezentowanej przez ten węzeł. P r z y k ł a d 7.1. Drzewo aktywacji z rys. 7.3 skonstruowano na podstawie wyjścia z rys. 7 . 2 . Aby zaoszczędzić miejsce, na rysunku pokazano jedynie pierwsze litery nazw procedur. Podczas wykonania s o r t u j występuje aktywacja c z y t a j t a b , reprezento wana przez pierwsze dziecko korzenia z etykietą c. Następna aktywacja, reprezentowana przez drugie dziecko korzenia, jest dla procedury ą u i c k s o r t z parametrami aktualny mi 1 i 9. Podczas tej aktywacji, wywołania p o d z i a ł i ą u i c k s o r t z wierszy 16-18 z rys. 7.1 prowadzą do aktywacji p ( l , 9 ) , q ( l , 3 ) i q ( 5 , 9). Zauważmy, że aktywacje q ( l , 3) i q ( 5 , 9) są rekurencyjne i że zaczynają się i kończą przed zakończeniem q(l,9). 1
s
p(l,3)
q(l,0)
q(2,3)
p(2,3)
q(2,l)
p(5,9)
q(3,3)
q(5,5)
q(7,9)
p(7,9)
q(7,7)
q<9,9)
Rys. 7.3. D r z e w o a k t y w a c j i o d p o w i a d a j ą c e w y j ś c i u z rys. 7 . 2 Właściwe wywołania wykonane przez ąuicksort zależą od wyniku działania funkcji podział (patrz dokładny opis algorytmu w: Aho, Hopcroft i Ullman [1983]). Na rysunku 7.3 przedstawiono jedno z moż liwych drzew wywołań. Odpowiada ono wywołaniom z rys. 7.2 i chociaż niektóre wywołania znajdują się głębiej w drzewie wywołań, to nie zostały przedstawione na rys. 7.2.
Stosy sterowania Przepływ sterowania w programie odpowiada przechodzeniu w głąb drzewa aktywacji, polegającemu na rozpoczęciu w korzeniu, odwiedzeniu węzła przed dziećmi i na re kurencyjnych odwiedzinach kolejnych dzieci w kolejności od lewej do prawej. Wynik z rys. 7.2 może zatem zostać odtworzony przez przejście drzewa aktywacji z rys. 7.3, wypisanie w e j ś c i e w momencie wchodzenia po raz pierwszy do węzła dla aktywacji i o p u s z c z e n i e po przejściu całego poddrzewa tego węzła. Można wykorzystać stos, zwany stosem sterowania, do przechowywania istniejących w danej chwili aktywacji dla procedur. Metoda polega na umieszczaniu na stosie węzła dla aktywacji, w momencie jej rozpoczęcia, i zdejmowaniu jej ze stosu w momencie, gdy się kończy. Zawartość stosu sterowania ma związek ze ścieżkami z korzenia drzewa aktywacji. Gdy na wierzchołku stosu sterowania znajduje się węzeł n, stos ten zawiera węzły wzdłuż ścieżki z n do korzenia. Przykład 7.2. Na rysunku 7.4 widać węzły z drzewa aktywacji z rys. 7.3, które zostały osiągnięte, gdy sterowanie weszło do aktywacji reprezentowanej za pomocą q ( 2 , 3). Aktywacje z etykietami r , p ( 1 , 9), p ( 1 , 3) i q ( 1 , 0) zostały wykonane do końca, więc rysunek zawiera prowadzące do nich linie przerywane. Linie ciągłe stanowią ścieżkę z q ( 2 , 3) do korzenia. s c
'
q(l,9)
pU,9)
'
q(l,3) 1
p(l,3)
q(l,0)
\ q(2,3)
Rys. 7.4. Stos sterowania zawiera węzły wzdłuż ścieżki do korzenia W tej chwili stos sterowania zawiera następujące węzły znajdujące się na tej ścieżce do korzenia (w kolejności od liścia do korzenia): s,q(l,9),q(l,3),q(2,3) i nie zawiera żadnych innych.
•
Stosy sterowania można rozszerzyć, aby móc stosować techniki rezerwacji pamięci stosowej, które są wykorzystywane w takich językach, jak Pacal i C. Te techniki omówi liśmy dokładnie w p . 7.3 i 7.4. Zakres deklaracji Deklaracja w języku jest konstrukcją składniową, która związuje informację z nazwą. Deklaracje mogą być jawne, jak we fragmencie programu w Pascalu var
i
:
integer;
lub niejawne. W Fortranie, na przykład, dla każdej zmiennej o nazwie zaczynającej się na I zakłada się, że oznacza liczbę całkowitą. W różnych częściach programu mogą znajdować się niezależne deklaracje tej sa mej nazwy. Zasady widzialności dla danego języka wyznaczają, która deklaracja ma być stosowana do nazwy pojawiającej się w tekście programu. W programie w Pascalu z rys. 7.1 i jest zadeklarowane trzykrotnie, w wierszach 4, 9 i 13, i program wykorzystuje tę nazwę niezależnie w procedurach c z y t a j t a b , p o d z i a ł i ą u i c k s o r t . Deklaracja w wierszu 4 jest stosowana do zmiennej i w wierszu 6. Innymi słowy, dwa wystąpie nia i w wierszu 6 znajdują się w zakresie deklaracji z wiersza 4. Trzy wystąpienia i w wierszach 16-18 znajdują się w zakresie deklaracji z wiersza 13. Fragment programu, którego dana deklaracja dotyczy, jest zakresem tej deklara cji. Gdy deklaracja nazwy znajduje się wewnątrz procedury, a jej zakres zawiera się w procedurze, to nazywana jest ona lokalną, w przeciwnym przypadku — nielokalną. Rozróżnienie między nazwami lokalnymi i nielokalnymi dotyczy wszystkich konstrukcji składniowych, które mogą zawierać deklaracje. Chociaż zakres jest własnością deklaracji nazwy, często wygodnie jest mówić o „za kresie zmiennej x " zamiast „zakresie deklaracji nazwy x, która jest stosowana do tego wystąpienia x " . Wtedy zakresem zmiennej i z wiersza 17 z rys. 7.1 jest treść ą u i c k sort . W trakcie kompilacji, tablica symboli może zostać użyta do znalezienia deklaracji dla konkretnego wystąpienia danej nazwy. Gdy deklaracja jest wczytywana, w tablicy symboli tworzony jest wpis. Tak długo, jak znajdujemy się w zakresie tej deklaracji, jej wpis jest zwracany, gdy ta nazwa jest poszukiwana w tablicy symboli. Tablice symboli omówiliśmy w p. 7.6. 1
Wiązanie n a z w Nawet, jeżeli każda nazwa w programie jest zadeklarowana jednokrotnie, może ona ozna czać różne obiekty danych w czasie działania. Nieformalne określenie „obiekt danych" odpowiada miejscu w pamięci, które może przechowywać wartości. W semantyce języków programowania, określenie środowisko odnosi się do funkcji, która nazwie przyporządkowuje miejsce w pamięci, a określenie stan — funkcji, która z kolei miejscu przypisuje w pamięci wartość tam przechowywaną, jak na rys. 7.5. Uży wając określeń /-wartość i r-wartość z rozdz. 2, środowisko przypisuje nazwie /-wartość, a stan przypisuje /-wartości r-wartość.
Środowisko Nazwa
Stan
Pamięć
Wartość
Rys. 7.5. Dwuetapowe przypisanie wartości do nazw
W większości przypadków terminy nazwa, identyfikator, zmienna, węzeł mogą być używane zamiennie, bez utraty zrozumienia użytych konstrukcji.
Środowiska i stany różnią się — przypisanie zmienia stan, ale nie środowisko. Za łóżmy, na przykład, że adres pamięci 100, związany ze zmienną p i , przechowuje 0. Po przypisaniu p i : = 3 . 1 4 ten sam adres pamięci jest związany z p i , lecz przechowywaną w nim wartością staje się 3 . 1 4 . Gdy środowisko związuje adres pamięci s z nazwą x, mówi się, że x jest związane z s, a sam związek jest wiązaniem x . Określenie „miejsce" pamięci jest symboliczne, ponieważ jeśli x nie jest typem podstawowym, miejsce pamięci s dla x może stanowić wiele słów pamięci. Związywanie jest dynamicznym odpowiednikiem deklaracji (rys. 7.6). Jak się prze konaliśmy, w tym samym czasie może istnieć wiele aktywacji pojedynczej procedury rekurencyjnej. W Pascalu nazwa zmiennej lokalnej jest przypisana do innego miejsca pamięci w każdej aktywacji procedury. Techniki związywania zmiennych lokalnych omó wiliśmy w p . 7.3.
POJĘCIE
STATYCZNE
definicja procedury deklaracja nazwy zakres deklaracji
ODPOWIEDNIK
DYNAMICZNY
aktywacje procedury wiązanie nazwy czas istnienia wiązania
Rys. 7.6. Pojęcia dynamiczne odpowiadające pojęciom statycznym
Pojawiające się pytania Sposób, w jaki kompilator języka organizuje swoją pamięć i wiąże nazwy, w dużym stopniu zależy od odpowiedzi na następujące pytania: 1.
Czy procedury mogą być rekurencyjne?
2.
Co się dzieje z wartościami nazw lokalnych, gdy sterowanie powraca z aktywacji
3. 4. 5. 6. 7. 8.
procedury? Czy procedury mogą odwoływać się do zmiennych nielokalnych? Jak parametry są przekazywane w momencie wywołania procedury? Czy procedury mogą być przekazywane jako parametry? Czy procedury mogą być zwracane jako rezultaty? Czy pamięć może być przydzielana dynamicznie pod kontrolą programu? Czy pamięć musi być zwalniana jawnie?
W dalszej części rozdziału omówiliśmy wpływ odpowiedzi na te pytania na bibliotekę wspomagania przetwarzania (ang. run-time support library) dla języka programowania.
7.2
Organizacja pamięci
Organizacja pamięci w czasie działania programu omówiona w tym podrozdziale może być użyta w takich językach, jak Fortran, Pascal i C.
Podział pamięci w czasie wykonywania Załóżmy, że kompilator otrzymuje blok pamięci od systemu operacyjnego w celu urucho mienia w nim skompilowanego programu. Wiemy już, że pamięć w czasie wykonywania może zostać podzielona na: 1) 2) 3)
wygenerowany kod wynikowy, obiekty danych, odpowiednik stosu sterowania do przechowywania aktywacji procedur.
Rozmiar wygenerowanego kodu jest ustalany w trakcie kompilacji, więc kompilator może umieścić go w obszarze wyznaczonym statycznie, na przykład, na początku pamię ci. Podobnie, rozmiar niektórych obiektów danych może być znany w czasie kompilacji i te obiekty również mogą zostać umieszczone w obszarze inicjowanym statycznie, jak na rys. 7.7. Jednym z powodów rezerwacji pamięci dla jak największej liczby obiek tów danych jest możliwość wkompilowania konkretnych adresów tych obiektów w kod wynikowy. Wszystkie obiekty danych w Fortranie mogą być umieszczane w pamięci statycznie.
Kod Dane statyczne Stos
Sterta Rys. 7.7. Typowy podział pamięci, w czasie wykonywania, na kod i obszary danych
Implementacje takich języków, jak Pascal i C używają rozszerzeń stosu sterowa nia do zarządzania aktywacjami procedur. Gdy wykonywane jest wywołanie procedury, wykonanie aktywacji jest wstrzymywane i informacja o stanie maszyny, j a k wartość licz nika rozkazów i rejestry procesora, jest zapisywana na stosie. Gdy sterowanie powraca z wywołania, aktywacja może zostać wznowiona po przywróceniu właściwych warto ści rejestrów i ustawieniu licznika rozkazów na miejsce bezpośrednio za wywołaniem. Miejsce na obiekty danych, których czasy istnienia są zawarte w czasie istnienia akty wacji, może zostać zarezerwowane na stosie, razem z innymi informacjami związanymi z aktywacją. Tę strategię omówiliśmy w następnym podrozdziale. Odrębny obszar pamięci w czasie wykonywania, zwany stertą, przechowuje wszyst kie pozostałe informacje. Pascal pozwala, aby pamięć dla danych była rezerwowana przez sam program (patrz p. 7.7). Pamięć na te dane jest pobierana ze sterty. Implementacje języków, w których czasy istnienia aktywacji nie mogą być reprezentowane przez drze-
wo aktywacji, mogą używać sterty do przechowywania informacji o tych aktywacjach. Kontrolowany sposób rezerwowania i zwalniania pamięci na stosie jest tańszy od takich operacji na stercie. Rozmiary stosu i sterty mogą zmieniać się w trakcie działania programu, więc na rys. 7.7 umieszczono j e na przeciwnych krańcach pamięci, aby mogły łatwo zwiększać swój rozmiar. Pascal i C potrzebują zarówno stosu sterowania, jak i sterty, ale inne języki nie. Zgodnie z konwencją stosy rosną w dół. Oznacza to, że „wierzchołek" stosu znajduje się na dole strony. Ponieważ adresy pamięci rosną w czasie poruszania się w dół strony, „wzrost w dół" oznacza: w kierunku wyższych adresów. Jeśli wskaźnik wierzchołek oznacza wierzchołek stosu, przesunięcia względem wierzchołka stosu mogą być obliczane przez odjęcie przesunięcia od wierzchołka. Na wielu maszynach obliczenia te mogą być wykonywane wydajnie przy przechowywaniu wartości wierzchołek w rejestrze. Adresy na stosie mogą zatem być reprezentowane jako przesunięcia względem wierzchołka . 1
Rekordy aktywacji Informacja potrzebna do pojedynczego wykonania procedury jest przechowywana w cią głym bloku pamięci, zwanym rekordem aktywacji lub ramką, składającym się z pól, jak na rys. 7.8. Nie wszystkie języki ani kompilatory używają wszystkich tych pól. Często rejestry zawierają tylko niektóre z tych informacji. W językach, takich jak Pascal lub C, rekord aktywacji procedury zwykle jest umieszczany na stosie sterowania, gdy procedura jest wywoływana, i zdejmowany ze stosu, gdy sterowanie powraca z jej wywołania.
Wartość zwracana Parametry aktualne Opcjonalne wiązanie sterowania Opcjonalne wiązanie dostępu Zapamiętany stan procesora Dane lokalne Dane tymczasowe
Rys. 7.8. Ogólny rekord aktywacji
1
Organizując pamięć tak, jak na rys. 7.7, zakłada się, że pamięć w czasie przetwarzania składa się z pojedyn czego ciągłego bloku otrzymanego na początku przetwarzania. To założenie nakłada stałe ograniczenie na sumę rozmiarów stosu i sterty. Jeśli limit jest dostatecznie duży, tak że prawie nigdy nie jest przekraczany, dla wielu programów może być stratą miejsca. Alternatywą może być połączenie w listę obiektów na stosie i na stercie, ale wtedy śledzenie wierzchołka stosu jest mniej wydajne. Ponadto, maszyna docelowa może wymagać innego umieszczenia tych obszarów, na przykład wtedy, gdy zezwala tylko na dodatnie przesunięcia względem adresów z rejestrów.
Przeznaczenia poszczególnych pól w rekordzie aktywacji są następujące: 1. 2. 3.
4.
5. 6.
7.
W polu danych tymczasowych są przechowywane wartości tymczasowe, pojawiające się w trakcie obliczeń wyrażeń. W polu danych lokalnych przechowuje się dane lokalne dla tego wykonania proce dury. Układ tego poła omówiono poniżej. W polu do zapamiętania stanu procesora przechowuje się informacje o nim bezpo średnio sprzed wywołania procedury. To pole zawiera wartości licznika rozkazów, rejestrów procesora, które powinny być przywrócone po powrocie z procedury. Opcjonalne wiązanie dostępu jest używane do odwoływania się do danych nielokal nych przetrzymywanych w innych rekordach aktywacji (patrz p. 7.4). Dla języków, takich jak Fortran, wiązania dostępu nie są potrzebne, ponieważ wszystkie dane nie lokalne są trzymane w stałym miejscu. Wiązania dostępu lub podobny mechanizm, nazywany tablicą „display", są wykorzystywane w Pascalu. Opcjonalne wiązanie sterowania wskazuje rekord aktywacji procedury wywołującej. Pole z parametrami aktualnymi jest używane przez procedurę wywołującą do prze kazania parametrów do wywoływanej procedury. Na rysunku przedstawiono miejsce na parametry aktualne, lecz w praktyce — w celu uzyskania większej wydajności — parametry są przekazywane do rejestrów procesora. Pole dla wartości zwracanej jest wykorzystywane przez wywoływaną procedurę do przekazywania rezultatu do procedury wywołującej. W praktyce, wartość ta jest zwracana w rejestrze, w celu uzyskania większej wydajności.
Rozmiary wszystkich tych pól można wyznaczyć w czasie wywoływania procedury. W rzeczywistości, rozmiary prawie wszystkich pól można wyznaczyć w czasie kompila cji. Wyjątek stanowi procedura mająca tablice lokalne, których wielkość jest wyznaczana przez wartość parametru aktualnego, dostępnego dopiero wtedy, gdy procedura jest wy woływana w trakcie działania programu. W podrozdziale 7.3 omówiliśmy rezerwację pamięci w rekordzie aktywacji dla danych o zmiennej długości.
Czas kompilacji dla układu danych lokalnych Załóżmy, że pamięć w czasie przetwarzania jest zorganizowana w ciągłe bloki bajtów, gdzie bajt jest najmniejszą jednostką adresowalnej pamięci. Na wielu maszynach, bajt jest ośmiobitowy, a pewna ilość bajtów tworzy słowo maszynowe. Obiekty wielobajtowe są przechowywane w kolejnych bajtach i ich adresem jest wtedy adres pierwszego bajtu. Ilość pamięci potrzebna dla danej nazwy jest zależna od jej typu. Podstawowy typ danych, jak znak, liczba całkowita lub rzeczywista, może być przechowywany w całko witej liczbie bajtów. Pamięć dla obiektu złożonego, takiego jak tablica lub rekord, musi być dostatecznie duża, aby mogła przechowywać wszystkie swoje składniki. Pamięć dla obiektów złożonych jest zwykle rezerwowana w pojedynczym ciągłym bloku bajtów, aby dostęp do składników był łatwy. Więcej informacji znajduje się w p. 8.2 i 8.3. Pole dla danych lokalnych jest zbudowane na podstawie deklaracji w procedurze w trakcie kompilacji. Dane o zmiennej długości są umieszczane poza tym polem. Liczba adresów pamięci, które zostały zarezerwowane dla poprzednich deklaracji, jest pamiętana do wyznaczenia względnego adresu pamięci dla zmiennych lokalnych od pewnej pozycji,
na przykład od początku rekordu aktywacji. Ten adres względny, inaczej przesunięcie, jest różnicą między tą ustaloną pozycją a obiektem danych. Układ pamięci dla obiektu danych w dużym stopniu zależy od sposobów adreso wania na maszynie docelowej. Przykładowo, instrukcje dodające liczby całkowite mogą wymagać, aby liczby te były wyrównane, czyli umieszczone w pewnych pozycjach w pa mięci, na przykład pod adresami podzielnymi przez 4. Chociaż tablica dziesięciu znaków potrzebuje tylko 10 bajtów, to kompilator może zarezerwować 12 bajtów i 2 nie będą używane. Powstała po wyrównaniu nie używana przestrzeń jest tzw. wypełnieniem. Gdy pamięć jest cenna, kompilator może upakować dane tak, że wypełnienie się nie pojawi, jednak wtedy może być potrzebne wykonanie dodatkowych instrukcji (w trakcie działa nia), które ustawią upakowane dane w ten sposób, że da się na nich działać tak, jakby były wyrównane poprawnie. P r z y k ł a d 7.3. Na rysunku 7.9 przedstawiono uproszczony układ danych używany w kom pilatorze C dla dwóch maszyn, Maszyny 1 i Maszyny 2. Język C dostarcza trzech roz miarów liczb całkowitych, deklarowanych słowami kluczowymi s h o r t , i n t i l o n g . Z zestawów instrukcji dla obu maszyn wynika, że kompilator dla Maszyny 1 rezerwu j e 16, 32 i 32 bity dla trzech rodzajów liczb całkowitych, podczas gdy kompila tor dla Maszyny 2: 24, 48 i 64 bity. D o porównania maszyn rozmiary na rys. 7.9 są mierzone w bitach, mimo że żadna z maszyn nie umożliwia bezpośredniego adresowania bitów. R O Z M I A R ( w bitach) TYP
W Y R Ó W N A N I E ( w bitach) Maszyna 2
Maszyna 2
Maszyna 1
8
8
8
64
16
24
16
64
32
48
32
64
32
64
32
64
32
64
32
64
64
128
32
64
W s k a ź n i k na z n a k
32
30
32
64
Inne w s k a ź n i k i . . .
32
24
32
64
^8
2*64
32
64
Maszyna 1
int float
a
a
Znaki w tablicach są wyrównywane do 8 bitów.
Rys. 7.9. Układ danych wykorzystywany przez dwa kompilatory C
Pamięć Maszyny 1 jest zorganizowana w bajty składające się z 8 bitów każdy. Mimo że każdy bajt ma adres, jej instrukcje faworyzują krótkie liczby całkowite umieszczone pod adresami parzystymi, a zwykłe liczby całkowite pod adresami podzielnymi przez 4. Kompilator umieszcza pod adresami parzystymi krótkie liczby całkowite nawet wtedy, gdy w wyniku musi przeskoczyć jeden bajt jako wypełnienie. Zatem, cztery bajty skła dające się z 32 bitów mogą zostać zarezerwowane dla pojedynczego znaku i znajdującej się za nim krótkiej liczby całkowitej. Każde słowo w Maszynie 2 składa się z 64 bitów i adresy słów są tworzone z 24 bi tów. Wewnątrz słowa bit można wybrać na 64 sposoby, więc d o zaadresowania poje-
dynczego bitu w słowie potrzeba 6 bitów. Wskaźnik do znaku w Maszynie 2 składa się z 30 bitów, z czego 24 służą do znalezienia konkretnego słowa, a 6 — pozycji znaku wewnątrz słowa. Silne zorientowanie zbioru instrukcji Maszyny 2 na słowa powoduje, że kom pilator rezerwuje za każdym razem całe słowa, nawet jeśli do reprezentacji wszy stkich możliwych wartości danego typu wystarczyłoby mniej bitów, na przykład dla zna ku wystarczyłoby 8 bitów. Wynika z tego, że wyrównanie dla Maszyny 2 dla każ dego typu wynosi 64 bity. Wewnątrz każdego słowa, bity dla każdego typu podstawowe go znajdują się na ustalonych pozycjach. D w a słowa składające się ze 128 bitów zostaną zarezerwowane dla znaku i krótkiej liczby całkowitej w taki sposób, że znak wykorzysta tylko 8 bitów z pierwszego słowa, a krótka liczba całkowita tylko 24 bity z drugiego słowa. •
7.3
Strategie rezerwacji pamięci
W każdym z trzech obszarów danych przedstawionych na rys. 7.7 jest używana inna strategia rezerwacji pamięci: 1. 2. 3.
Statyczna rezerwacja pamięci jest stosowana dla wszystkich obiektów danych zna nych w trakcie kompilacji. Rezerwacja stosowa zarządza pamięcią stosu w czasie działania. Rezerwacja stertowa, w czasie działania programu, w miarę potrzeby rezerwuje i zwalnia pamięć obszaru danych zwanego stertą.
Omówione w tym podrozdziale powyższe strategie rezerwacji pamięci są stosowane do rekordu aktywacji. Opiszemy także dostęp kodu wynikowego do procedury do pamięci przypisanej do nazwy lokalnej. Rezerwacja statyczna W rezerwacji statycznej nazwy są przypisywane do pamięci w trakcie kompilacji pro gramu, więc pakiet wspomagania przetwarzania nie jest do tego potrzebny. Ponieważ te przypisania nie zmieniają się w trakcie działania, za każdym razem, gdy procedura jest aktywowana, jej nazwy są przypisane do tych samych adresów pamięci. Ta własność powoduje, że wartości nazw lokalnych są zachowywane między aktywacjami procedury. Oznacza to, że gdy sterowanie wraca do procedury, wartości zmiennych lokalnych są takie same, j a k były w chwili, gdy sterowanie ostatni raz wyszło z procedury. Na podstawie typu nazwy, jak już wiemy z p . 7.2, kompilator wyznacza ilość pa mięci, jaką należy przypisać do nazwy. Adres tej pamięci składa się z przesunięcia od końca rekordu aktywacji dla procedury. Kompilator musi w końcu zadecydować, gdzie będą się znajdowały rekordy aktywacji względem kodu wynikowego lub innych rekordów. Kiedy ta decyzja zostanie podjęta, pozycja każdego rekordu aktywacji staje się ustalo na. W czasie kompilacji można zatem ustalić adresy, pod którymi kod wynikowy może znaleźć dane, na których operuje; znane są również adresy, pod którymi informacja jest zapisywana podczas wywołania procedury.
Używanie tylko rezerwacji statycznej ma jednak pewne ograniczenia: 1. 2. 3.
Rozmiar obiektu danych i ograniczenia odnośnie do jego położenia muszą być usta lone w czasie kompilacji. Procedury rekurencyjne są ograniczone, ponieważ wszystkie aktywacje procedury używają takiego samego wiązania nazw lokalnych z adresami pamięci. Struktury danych nie mogą być tworzone dynamicznie, ponieważ nie m a mechani zmu na rezerwację pamięci w trakcie działania programu.
Fortran został tak zaprojektowany, aby pozwalał na rezerwację statyczną. Program w Fortranie składa się z programu głównego, podprogramów i funkcji (wszystkie j e bę dziemy nazywać procedurami), jak w programie w Fortranie 77 na rys. 7.10. Układ kodu i rekordów aktywacji dla tego programu, z zastosowaniem organizacji pamięci z rys. 7.7, przedstawiono na rys. 7.11. Wewnątrz rekordu aktywacji dla KONSUM (czytaj „konsumuj" — Fortran nie lubi długich identyfikatorów) znajduje się miejsce dla zmiennych lokal nych BUF, NAST i C. Pamięć przypisana d o BUF trzyma napis złożony z pięćdziesięciu znaków. Następnie znajduje się przestrzeń na wartość całkowitą dla NAST oraz znaku dla C. Fakt zadeklarowania NAST również w PRODUK nie stanowi problemu, ponieważ zmienne lokalne tych dwóch procedur znajdują się w innych rekordach aktywacji.
PROGRAM KONSUM
(1)
CHARACTER * 5 0 BUF
(2) (3)
INTEGER NAST
(4)
CHARACTER C ,
(5)
DATA N A S T
(6)
6
PRODUK
11/,
BUF / '
(7)
BUF (NAST:NAST)
(8)
NAST = NAST +
1
IF
'
(9)
( C
(10)
WRITE
(11)
END
(12) (13)
,NE.
CHARACTER F U N C T I O N
)
GOTO 6
PRODUK()
CHARACTER * 8 0 BUFOR INTEGER NAST
(15)
SAVE BUFOR,
(16)
DATA N A S T IF
NAST /81/
( NAST
(18)
READ
(19)
NAST =
(20)
'
= C
( * , ' (A) ' ) B U F
(14)
(17)
' /
C = PRODUK ( )
.GT. 80
(*, ' (A) ' )
)
THEN
BUFOR
1
END I F
(21)
PRODUK = B U F O R ( N A S T
(22)
NAST -
(23)
END
:
NAST)
NAST+1
Rys. 7.10. Program w Fortranie 77
Ponieważ rozmiary kodu wykonywalnego i rekordów aktywacji są znane w czasie kompilacji, organizacja pamięci inna niż na rys. 7.11 też jest możliwa. Kompilator For tranu może umieścić rekord aktywacji dla procedury razem z jej kodem. W niektórych systemach komputerowych możliwe jest pozostawienie przez kompilator nieokreślonej względnej pozycji rekordów aktywacji i pozwolenie konsolidatorowi połączyć rekordy aktywacji z kodem. P r z y k ł a d 7.4. Program z rysunku 7.10 wykorzystuje fakt, że wartości zmiennych lo kalnych są zachowywane między aktywacjami procedur. Instrukcja SAVE z Fortranu 77 specyfikuje, że wartość zmiennej lokalnej na początku aktywacji musi być taka sama, jak na końcu ostatniej aktywacji. Początkowe wartości tych zmiennych lokalnych mogą być wyspecyfikowane za pomocą instrukcji DATA.
T
Kod dla KONSUM
Kod
Kod dla PRODUK
T CHARACTER*5 0 INTEGER
BUFFER
Rekord aktywacji dla KONSUM
NAST
CHARACTER C
+
DANE STATYSTYCZNE
Rekord aktywacji CHARACTER#8 0 B U F F E R INTEGER
dla PRODUK
NAST
Rys. 7.11. Pamięć statyczna dla identyfikatorów lokalnych w programie w Fortranie 77
Instrukcja w wierszu 18 procedury PRODUK wczytuje na raz wiersz tekstu do bu fora. Procedura ta za każdym razem, gdy jest aktywowana, dostarcza kolejnych znaków. Główny program KONSUM również ma bufor, w którym zbiera znaki aż do otrzymania odstępu. Dla wejścia
hello world znaki zwracane przez aktywacje PRODUK są pokazane na rys. 7.12. Wyjściem programu jest wtedy
hello Bufor, do którego PRODUK wczytuje wiersze, musi przechowywać wartości mię dzy aktywacjami. Instrukcja SAVE z wiersza 15 powoduje, że gdy sterowanie wraca do PRODUK, lokalne zmienne BUFOR i NAST mają te same wartości, które miały, gdy ste-
rowanie ostatni raz wyszło z procedury. Z a pierwszym razem, gdy sterowanie trafia do PRODUK, wartość dla zmiennej lokalnej NAST jest pobierana z instrukcji DATA z wiersza 16. W ten sposób NAST jest inicjowane na 81. D
KONSUM PRODUK h
PRODUK e
PRODUK 1
PRODUK 1
PRODUK o
PRODUK
Rys. 7.12. Znaki zwracane przez aktywacje procedury PRODUK
Rezerwacja stosowa Rezerwacja stosowa jest oparta na idei stosu sterowania. Pamięć jest zorganizowana jako stos, a rekordy aktywacji są umieszczane i zdejmowane ze stosu, gdy aktywacje odpo wiednio zaczynają się i kończą. Pamięć dla zmiennych lokalnych dla każdego wywołania procedury znajduje się w rekordzie aktywacji dla tego wywołania. Zmienne lokalne dla każdego wywołania są zatem przypisywane do nowej pamięci w każdej aktywacji, po nieważ — gdy wywoływana jest procedura — n a stosie jest umieszczany nowy rekord aktywacji. Ponadto, wartości zmiennych lokalnych są usuwane, gdy aktywacja kończy się, czyli wartości te są tracone, ponieważ pamięć dla nich jest usuwana ze stosu razem z rekordem aktywacji. Najpierw opiszemy rezerwację stosową, w której rozmiary rekordów aktywacji są znane w trakcie kompilacji. Sytuacje, w których niepełna informacja o rozmiarach jest dostępna w trakcie kompilacji, są omówione poniżej (s. 386). Załóżmy, że rejestr wierzchołek wskazuje na wierzchołek stosu. Rekord aktywacji może być w czasie działania przydzielany lub zwalniany przez zwiększenie lub zmniej szenie wartości wierzchołek o rozmiar rekordu. Jeśli procedura q m a rekord aktywacji o rozmiarze a, to wierzchołek jest zwiększany o a bezpośrednio przed wykonaniem kodu q. Gdy sterowanie powraca z q, wierzchołek jest zmniejszany o a. P r z y k ł a d 7.5. Na rysunku 7.13 przedstawiono rekordy aktywacji, które są wstawiane i usuwane ze stosu sterowania w trakcie przepływu sterowania przez drzewo aktywacji z rys. 7,3. Linie przerywane w drzewie prowadzą d o aktywacji, które j u ż się zakończyły. Wykonanie zaczyna się od aktywacji procedury s . Gdy sterowanie osiąga pierwsze wy wołanie procedury wewnątrz s , aktywowana jest procedura c , a jej rekord aktywacji jest umieszczany na stosie. G d y sterowanie powraca z tej aktywacji, rekord jest zdejmowany ze stosu i pozostaje na nim tyko rekord aktywacji dla s . Następnie, w trakcie aktywa cji s , sterowanie dociera d o wywołania q z parametrami aktualnymi 1 i 9, na stosie jest rezerwowany rekord aktywacji dla q. W każdej chwili, gdy sterowanie znajduje się w aktywacji, jej rekord znajduje się na wierzchołku stosu. Kilka aktywacji występuje między dwoma ostatnimi sytuacjami z rys. 7.13. Dla ostatniej sytuacji z tego rysunku, aktywacje p ( 1 , 3 ) i q (1, 0) zaczęły się i zakończyły w trakcie czasu istnienia q ( 1 , 3 ) , więc ich rekordy aktywacji pojawiły się oraz zostały usunięte ze stosu, i na wierzchołku stosu pozostał rekord aktywacji dla q (1, 3 ) . •
POZYCJE
R E K O R D Y AKTYWACJI
W D R Z E W I E AKTYWACJI
NA STOSIE
UWAGI
s ramka dla s
s a : array
s s
a : array c jest aktywowana c
c
i : integer
s s
a : array
1 q(l,9)
c
qd,9)
ramka dla c została usunięta i q d, 9) została wstawiona na stos
i : integer
s
1
S c
p(l,9)
s a : array
qd,9)
qd,9)
1
i : integer
qd,3)
qd,3)
1
pd,3) qd,0)
sterowanie właśnie powróciło do q (1, 3)
i : integer
Rys. 7.13. Rezerwacja rekordów aktywacji skierowana w dół stosu
W procedurze w Pascalu adres względny dla danych lokalnych w rekordzie aktywacji może zostać wykonany w sposób omówiony w p. 7.2. Załóżmy, że wierzchołek wskazuje w czasie działania na położenie końca rekordu aktywacji. Adres lokalnej nazwy x w ko dzie wynikowym dla procedury może zatem zostać zapisany jako dx(wierzchołek), czyli dane przypisane do x mogą zostać znalezione pod adresem otrzymanym przez dodanie dx do wartości rejestru wierzchołek. Zauważmy, że adresy mogą zostać opisane w inny sposób, z uwzględnieniem przesunięć względem wartości z innego rekordu c wskazującej ustaloną pozycję w rekordzie aktywacji.
Sekwencje
wywołujące
Wywołania procedur są implementowane przez wygenerowanie tzw. sekwencji wywo łujących. Sekwencja wywołująca rezerwuje miejsce na rekord aktywacji i wprowadza
informację do j e g o pól. Sekwencja powrotu przywraca stan maszyny, tak aby procedura wywołująca mogła działać dalej. Sekwencje wywołujące i rekordy aktywacji różnią się nawet w implementacjach tego samego języka. Kod w sekwencji wywołującej jest często dzielony między proce durę wywołującą a procedurę wywoływaną. Nie ma jednak dokładnego podziału zadań w czasie działania między wywołującego i wywoływanego — język źródłowy, maszyna docelowa i system operacyjny wymagają różnych rozwiązań . Projektując sekwencje wywołujące i rekordy aktywacji należy przestrzegać zasady, aby pola, których rozmiary są j u ż ustalone, były umieszczone w środku (patrz rys. 7.8 — pola z wiązaniem sterowania, dostępu i stanem procesora w ogólnym rekordzie aktywacji występują w środku). Decyzja o tym, czy należy użyć wiązania sterowania i dostępu, jest częścią projektu kompilatora, więc pola te mogą zostać ustalone w trakcie konstrukcji kompilatora. Jeśli dla każdej aktywacji zapamiętywana jest taka sama liczba informacji o stanie procesora, to ten sam fragment kodu może wykonać zapamiętywanie i przywra canie wszystkich aktywacji. Ponadto, w takim przypadku programy, np. uruchomieniowe (ang. debugger), łatwiej będą mogły odkodować informację znajdującą się na stosie, gdy wystąpi błąd. Chociaż rozmiar pól dla zmiennych tymczasowych jest ostatecznie ustalany w czasie kompilacji, to nie musi on być znany dla przodu kompilatora. Uważna generacja kodu lub optymalizacja mogą zredukować liczbę zmiennych tymczasowych potrzebnych w proce durze, więc w trakcie rozważania przodu kompilatora ten rozmiar również nie jest znany. W ogólnym rekordzie aktywacji pole zmiennych tymczasowych zostało umieszczone po polu dla danych lokalnych, więc zmiany jego rozmiaru nie będą wpływały na przesunięcia obiektów danych względem pól w części środkowej rekordu. Ponieważ każde wywołanie ma własne parametry aktualne, wywołujący zwykle oblicza j e i przekazuje do rekordu aktywacji wywoływanego. Metody przekazywania pa rametrów przedstawiliśmy w p. 7.5. Rekord aktywacji wywołującego na stosie sterowania znajduje się bezpośrednio pod rekordem wywoływanego, jak na rys. 7.14. Umieszczanie pól dla parametrów i ewentualnej wartości wyniku bezpośrednio przy rekordzie aktywacji wywołującego m a zaletę. Wywołujący może mieć dostęp do tych pól, używając przesu nięcia względem końca swojego własnego rekordu aktywacji, bez znajomości całego układu rekordu dla wywoływanego. W szczególności, nie ma powodu, aby wywołujący wiedział cokolwiek na temat danych lokalnych, czy zmiennych tymczasowych wywoły wanego. Dzięki takiemu ukrywaniu informacji jest możliwe wywoływanie procedur ze zmienną liczbą argumentów, jak p r i n t f w C, w sposób omówiony poniżej. Języki, jak Pascal, wymagają, aby tablice lokalne w procedurze miały rozmiar znany w czasie kompilacji. Często jednak rozmiar tablicy lokalnej może zależeć od wartości pa rametru przekazywanego do procedury. W takim przypadku rozmiar wszystkich danych lokalnych dla procedury nie może zostać wyznaczony aż procedura nie zostanie wywo łana. Techniki umożliwiające użycie danych o zmiennej długości są omówione w dalszej części tego podrozdziału. 1
1
Jeśli procedura jest wywoływana n razy, to część sekwencji wywołującej dla różnych procedur wywołujących jest generowana n razy. Natomiast część sekwencji w procedurze wywoływanej jest współdzielona przez wszystkie wywołania, a więc jest generowana jednokrotnie. Pożądane jest zatem umieszczanie jak największej części sekwencji wywołującej w procedurze wywoływanej.
Sekwencja wywołania wynika z powyższej dyskusji. Jak pokazano na rys. 7.14, re jestr wierzchu sp wskazuje koniec pola stanu procesora w rekordzie aktywacji. Ta pozycja jest znana wywołującemu, więc może on być odpowiedzialny za ustawianie wierzch-sp przed przekazaniem sterowania do wywoływanego. Kod dla wywoływanego może mieć dostęp do zmiennych tymczasowych i danych lokalnych przy wykorzystaniu przesunięć względem wierzch-sp. Sekwencja wy woły wania jest następująca: 1. 2.
3. 4.
Wywołujący oblicza parametry aktualne. Wywołujący zapisuje adres powrotu i starą wartość wierzch-sp w rekordzie aktywa cji wywoływanego. Wywołujący zwiększa następnie wierzch-sp do pozycji przed stawionej na rys. 7.14, czyli, wierzchsp jest przesuwane za dane lokalne i zmienne tymczasowe wywołującego oraz parametry i pole stanu wywoływanego. Wywoływany zapamiętuje wartości rejestrów i inne informacje o stanie. Wywoływany inicjuje swoje dane lokalne i rozpoczyna wykonywanie.
Parametry i wartość zwracana Wiązanie
Rekord aktywacji wywołującego
sterowania
Wiązania i stan zapamiętany Zmienne tymczasowe i dane lokalne
Odpowiedzialność wywołującego
Parametry i wartość zwracana Wiązanie
Rekord aktywacji wywoływanego
sterowania
Wiązania i stan zapamiętany wierzch _sp
Zmienne tymczasowe i dane lokalne
Rys.
7.14.
Odpowiedzialność wywoływanego
Podział zadań między w y w o ł u j ą c e g o i w y w o ł y w a n e g o
Możliwa sekwencja powrotu jest następująca: 1. 2. 3.
Wywołany umieszcza wartość wyniku obok rekordu aktywacji wywołującego. Przy użyciu informacji z pola stanu, wywołany przywraca wierzchsp i inne rejestry, po czym powraca do kodu wywołującego. Chociaż wierzch-sp został zmniejszony, wywołujący może skopiować zwróco ną wartość do swojego własnego rekordu aktywacji i użyć go do obliczenia wy rażenia.
Powyższe sekwencje wywołujące pozwalają, aby liczba argumentów w procedu rze wywoływanej zależała od każdego wywołania. Zauważmy, że w czasie kompilacji kod wynikowy wywołującego wie, ile argumentów przekazuje do wywoływanego. Stąd wywołujący zna rozmiar pola parametrów. Jednak, kod wynikowy wywoływa nego musi być przygotowany również do obsługi innych wywołań, więc pole paramet rów może zbadać dopiero po wywołaniu. Stosując organizację stosu z rys. 7.14, infor macja opisująca parametry musi zostać umieszczona obok pola stanu, tak aby mógł ją znaleźć wywoływany. Rozważmy dla przykładu funkcję standardowej biblioteki p r i n t f w C. Pierwszy argument p r i n t f specyfikuje rodzaj pozostałych argumen tów, więc jeśli p r i n t f znajdzie pierwszy argument, to również będzie potrafiło znaleźć pozostałe. Dane o zmiennej
długości
Strategia powszechnie stosowana przy posługiwaniu się danymi o zmiennej długości jest przedstawiona na rys. 7.15. Procedura p ma trzy lokalne tablice, a pamięć je zawierająca nie stanowi części rekordu aktywacji dla p. W rekordzie aktywacji znajdują się jedynie wskaźniki do początków poszczególnych tablic. Adresy względne tych wskaźników są znane w czasie kompilacji, więc kod wynikowy może mieć dostęp do elementów tablic za pomocą tych wskaźników.
Wiązanie
sterowania
Wskaźnik do A Wskaźnik do B Wskaźnik do C
Rekord aktywacji dlap
Tablica A Tablica B
Tablice p
Tablica C
wierzch _sp
Wiązanie
sterowania
Rekord aktywacji dla procedury q wywołanej przez p
Tablice q wierzch
Rys. 7.15. Dostęp do dynamicznie rezerwowanych tablic
Na rysunku 7.15 przedstawiono również procedurę q wywołaną przez p . Rekord aktywacji dla q zaczyna się po tablicach procedury p , a zmiennej długości tablice dla q zaczynają się za tym rekordem. Dostęp do danych na stosie jest wykonywany przy użyciu dwóch wskaźników: wierzch i wierzch- sp. Pierwszy z tych znaczników oznacza rzeczywisty wierzchołek stosu i wskazuje pozycję, od której będzie zaczynać się następny rekord aktywacji. Drugi jest używany do znalezienia danych lokalnych. Aby zachować spójność z orga nizacją z rys. 7.14, załóżmy, że wierzchsp wskazuje koniec pola ze stanem procesora. wierzch-sp na rysunku 7.15 wskazuje koniec tego pola w rekordzie aktywacji q. Pole to zawiera, między innymi, wiązanie sterowania do poprzedniej wartości wierzch-sp, gdy sterowanie znajdowało się w aktywacji p . Kod ustalający nową pozycję wierzch i wierzch-sp może być wygenerowany w cza sie kompilacji, na podstawie rozmiarów pól w rekordach aktywacji. Gdy następuje powrót z q, nową wartością wierzch staje się wartość wierzch-sp pomniejszona o długość stanu procesora i pól parametrów w rekordzie aktywacji dla q. Ta długość jest znana w cza sie kompilacji, przynajmniej dla wywołującego. Po ustawieniu wartości wierzch, nowa wartość wierzch- sp może zostać skopiowana z wiązania sterowania dla q. Referencje wiszące Za każdym razem, gdy zwalniana jest pamięć, pojawia się problem referencji wiszących. Referencja wisząca pojawia się wtedy, kiedy istnieje referencja do pamięci, która została zwolniona. Używanie wiszących referencji jest błędem logicznym, ponieważ zgodnie z semantyką większości języków programowania wartość zwolnionego obszaru pamięci jest nieokreślona. Co gorsza, ponieważ ta pamięć może zostać później zarezerwowana ponownie na inne dane, w programie używającym referencji wiszących może pojawić się tajemniczy błąd. P r z y k ł a d 7.6. Procedura w i s z r e f w programie w C z rys. 7.16 zwraca wskaźnik do pamięci przypisanej do lokalnej nazwy i . Wskaźnik jest tworzony przez zastosowanie operatora & do i . Gdy sterowanie powraca do m a i n z w i s z r e f , pamięć na zmienne lokalne jest zwalniana i może zostać wykorzystana do innych celów. Ponieważ p w m a i n odwołuje się do tej pamięci, użycie p jest referencją wiszącą. • Przykład 7.11 (patrz p . 7.7) zawiera zwalnianie pamięci pod kontrolą programu. m a i n ()
{ i n t *p; p = wiszref() ;
} int
•wiszref()
{ i n t i - 23; r e t u r n &i;
} Rys. 7.16. Program w C pozostawiający wskaźnik p do zwolnionej pamięci
Rezerwacja stertowa Omówiona strategia rezerwacji stosowej nie może być stosowana, jeśli co najmniej jeden z poniższych warunków jest prawdziwy. 1. 2.
Wartości zmiennych lokalnych muszą być zachowane, gdy aktywacja się kończy. Aktywacja wywoływana istnieje dłużej niż wywołująca. Ta możliwość nie może pojawić się w językach, w których drzewa aktywacji poprawnie odzwierciedlają przepływ sterowania między procedurami.
W każdym z powyższych przypadków, zwalnianie rekordów aktywacji nie może odbywać się w kolejności: ostatni wchodzi, pierwszy wychodzi, więc pamięć nie może zostać zorganizowana jak stos. Rezerwacja stertowa, w miarę potrzeby, dzieli pamięć ciągłą na rekordy aktywacji i inne obiekty. Części te mogą być zwalniane w dowolnej kolejności, więc po pewnym czasie działania sterta będzie się składać z naprzemiennych obszarów zajętych i wolnych. Różnicę między rezerwacją stertowa a stosową rekordów aktywacji można zaobser wować na rys. 7.17 i 7.13. Rekord dla aktywacji procedury c z rys. 7.17 jest zachowany, gdy ta aktywacja się kończy. Rekord dla nowej aktywacji q ( 1 , 9) nie może więc znaj dować się fizycznie bezpośrednio za rekordem dla s, jak było na rys. 7.13. Teraz — j e ś l i pozostawiony rekord dla c zostanie zwolniony — na stercie, między rekordami aktywacji dla s i q ( 1 , 9), pozostanie obszar wolnej pamięci. Moduł zarządzający stertą może teraz wykorzystać ponownie ten zwolniony obszar.
POZYCJA W DRZEWIE AKTYWACJI
REKORDY AKTYWACJI NA STERCIE
Wiązanie
s y
c
1 q(l,9)
UWAGI
sterowania
c
V — Wiązanie
V
sterowania
pozostający rekord aktywacji dla c
q(l,9) - Wiązanie
sterowania
Rys. 7.17. Rekordy dla istniejących aktywacji nie muszą sąsiadować ze sobą na stercie
Pytanie o wydajne zarządzanie stertą dotyczy częściowo teorii struktur danych. Nie które z tych technik przedstawiliśmy w p. 7.8. Zarządzanie stertą zwykle wymaga pewnej ilości czasu i pamięci. Ze względu na wydajność może być przydatne, aby małe rekordy aktywacji lub rekordy o przewidywalnym rozmiarze traktować jak szczególny przypadek, w następujący sposób:
1. 2.
3.
Dla każdego rozmiaru, którym jesteśmy zainteresowani, należy utrzymywać listę wolnych bloków w tym rozmiarze. Jeśli to możliwe, żądanie bloku wielkości s powinno zostać zaspokojone przez blok rozmiaru s*, gdzie s' jest najmniejszym możliwym rozmiarem bloku, większym lub równym s. Gdy blok jest w końcu zwalniany, to zwracany jest na listę, z której został wzięty. Dla większych bloków pamięci, jest wykorzystywany moduł zarządzający stertą.
Dzięki temu podejściu można osiągnąć szybką rezerwację i zwalanianie małych ilości pamięci, ponieważ pobranie i zwrócenie bloku na listę jest operacją wydajną. Dla więk szych obszarów pamięci spodziewamy się, że obliczenia na nich będą trwały pewien czas, więc czas spędzony przez moduł zarządzający stertą możemy pominąć.
7.4
Dostęp do nazw nielokalnych
Strategie rezerwacji pamięci, omówione w poprzednim podrozdziale, zostaną dostosowa ne do umożliwienia dostępu do nazw nielokalnych. Chociaż dyskusja bazuje na rezerwa cji stosowej rekordów aktywacji, to te same pomysły można zastosować do rezerwacji stertowej. Reguły widzialności języka wyznaczają sposób traktowania odniesień do nazw nielo kalnych. Zwykła reguła, zwana regułą widzialności leksykalnej lub statycznej, wyznacza deklarację, która jest stosowana do nazwy jedynie przez badanie samego kodu tekstu programu. Pascal, C i Ada znajdują się wśród wielu języków, które używają widzialności leksykalnej wraz z dodanym warunkiem „najbliższego zagnieżdżenia", który omówiliśmy poniżej. Alternatywna reguła, zwana regułą widzialności dynamicznej, wyznacza dekla racje stosowane do nazwy w czasie działania, przez rozważanie aktualnych aktywacji. Językami, które stosują tę regułę, są, między innymi, Lisp, APN i Snobol. Rozważania rozpoczniemy od bloków i reguły „najbliższego zagnieżdżenia", następ nie zajmiemy się nazwami nielokalnymi w językach, takich jak C, gdzie widzialność jest leksykalna, wszystkie nielokalne nazwy mogą zostać przypisane do pamięci rezerwowa nej statycznie i niedozwolone jest zagnieżdżanie deklaracji procedur. W jęzkach, jak na przykład Pascal, mających zagnieżdżone procedury i widzialność leksykalną, nazwy przynależne do różnych procedur mogą w danej chwili być częścią śro dowiska. Omówimy dwa sposoby znajdowania rekordów aktywacji zawierających pamięć przypisaną do nielokalnych nazw: wiązania dostępu i tablice display. Na koniec omówimy implementację widzialności dynamicznej.
Bloki Blok jest instrukcją zawierającą deklaracje swoich własnych danych lokalnych. Koncepcja bloku pochodzi z Algola. Blok w C ma postać { deklaracje
instrukcje
}
Charakterystyczna dla bloków jest struktura umożliwiająca zagnieżdżanie. Ogra niczniki określają początek i koniec bloku. C używa nawiasów klamrowych { i } jako
ograniczników, podczas gdy w Algolu tradycyjnie używa się b e g i n i end. Ograniczniki powodują, ż e jeden blok jest niezależny od innych lub jest w innym zagnieżdżony, czyli nie jest możliwe dla dwóch bloków B i B nakładanie się na siebie w taki sposób, że najpierw zaczyna się blok B , potem B , ale najpierw kończy się B a dopiero potem B . Własność zagnieżdżania czasami jest nazywana strukturą blokową. Widzialność deklaracji w języku o strukturze blokowej jest określona przez regułę {
x
2
2
v
2
najbliższego 1. 2.
zagnieżdżenia:
Deklaracja z bloku B jest widzialna w bloku B. Jeśli nazwa x nie została zadeklarowana w bloku B, to wystąpienie x w B znajduje się w zakresie widzialności deklaracji x w bloku B zawierającym blok B, takim że f
i) B' zawiera deklarację x, ii) B' względem zagnieżdżenia jest najbliżej bloku Z?, bliżej niż każdy inny blok z deklaracją x . Każda deklaracja z rysunku 7.18 inicjuje deklarowaną nazwę, przypisując jej numer bloku, w którym ona się znajduje. Widzialność deklaracji b w B nie zawiera B ponie waż b jest zadeklarowana również wSpCO zostało zaznaczone n a rysunku jako B —B . Taka przerwa jest zwana dziurą w widzialności deklaracji. Reguła najbliższego zagnieżdżenia widzialności jest odzwierciedlona w wyniku pro gramu z rys. 7.18. Sterowanie przepływa do bloku z miejsca bezpośrednio przed nim i z bloku d o miejsca bezpośrednio za nim w kodzie źródłowym. Instrukcje p r i n t f są zatem wykonywane w kolejności B > B ^ B i B , czyli w kolejności, w której sterowanie opuszcza bloki. Wartości a i b w tych blokach są następujące: 0
v
0
2
2 0 0 0
X
x
0
1 3 1 0
Struktura blokowa może zostać zaimplementowana przy użyciu rezerwacji stosowej pamięci. Ponieważ zakres widzialności deklaracji nie rozszerza się na zewnątrz bloku, w którym się pojawiła, przestrzeń na zadeklarowaną nazwę może zostać zarezerwowana przy wchodzeniu do bloku i zwolniona przy wychodzeniu sterowania z bloku. To po dejście traktuje blok jak „bezparametrową procedurę", wywoływaną jedynie z miejsca bezpośrednio przed blokiem i powracającą tylko do miejsca bezpośrednio p o bloku. Śro dowisko nielokalne dla bloku może być utrzymywane przy użyciu technik dla procedur opisanych w dalszej części podrozdziału. Zauważmy jednak, że bloki są prostsze niż pro cedury, ponieważ żadne parametry nie są przekazywane, a sterowanie przepływa z bloku i do bloku tak, jak w statycznym kodzie programu . Alternatywna implementacja polega na jednorazowej rezerwacji pamięci dla całej treści procedury. Jeśli wewnątrz procedury znajdują się bloki, wykonywany jest przydział pamięci na bloki potrzebne dla deklaracji wewnątrz tych bloków. Dla bloku B z rys. 7.18 1
0
1
Wyskoczenie z bloku do bloku otaczającego może być zaimplementowane przez zdjęcie ze stosu rekordów aktywacji dla bloków znajdujących się między tymi blokami. Skoki do wewnątrz bloków są dozwolone w niektórych językach. Przed przekazaniem sterowania w ten sposób, rekordy aktywacji należy utworzyć dla bloków pośrednich. Semantyka języka określa, w jaki sposób dane lokalne są inicjowane w tych rekordach aktywacji.
main() { i n t a = 0; i n t b = 0;
DEKLARACJA
int int int int int
{ i n t b = 1;
{ int a : printf B,
a b b a b
= « = = =
0, 0 1, 2, 3
WIDZIALNOŚĆ B
B
0~ 2 B -B 0
{
B -B {
3
B
2
B,
2; b)
%d %d"
Bi
i n t b = 3; p r i n t f ( " % d %d\n", } p r i n t f ( " % d %d\n", } p r i n t f ( " % d %d\n",
a,
a,
a,
b)
b);
b);
Rys. 7.18. Bloki w programie w C można zarezerwować pamięć, jak na rys. 7.19. Indeksy przy zmiennych lokalnych a i b identyfikują bloki, w których są zadeklarowane zmienne lokalne. Zauważmy, że a i b mogą mieć przypisany ten sam obszar pamięci, ponieważ znajdują się w blokach, które nie istnieją w tym samym czasie. 2
aj, b
3
3
Rys. 7.19. Pamięć dla nazw zadeklarowanych na rys. 7.18 W przypadku niewystępowania danych o zmiennej długości, maksymalna ilość pa mięci potrzebnej w trakcie wykonywania bloku może być wyznaczona w czasie kom pilacji. (Dane o zmiennej długości mogą zostać obsłużone przy użyciu wskaźników — jak w p. 7.3). Aby wyznaczyć ilość pamięci, zakładamy, że wszystkie ścieżki sterowania w programie faktycznie mogą zostać wybrane, czyli że zarówno część then i część else dla instrukcji warunkowej mogą zostać wykonane i że wszystkie instrukcje w pętli while mogą zostać osiągnięte.
Widzialność leksykalna bez zagnieżdżonych procedur Reguły dla widzialności lokalnej w C są prostsze niż w Pascalu (omówione w dalszej części punktu), ponieważ definicje procedur w C nie mogą być zagnieżdżane, czyli jedna definicja procedury nie może być zawarta w innej. Program w C (rys. 7.20) składa się
z sekwencji deklaracji zmiennych i procedur (w C są one nazywane funkcjami). Jeśli istnieje nielokalna referencja do nazwy a w pewnej funkcji, to a należy zadeklarować na zewnątrz jakiejkolwiek funkcji. Widzialność deklaracji będącej na zewnątrz funkcji składa się z treści funkcji, która znajduje sie za tą deklaracją, z dziurami, jeśli taka sama nazwa jest deklarowana wewnątrz funkcji. Na rysunku 7.20 nielokalne wystąpienia a w c z y t a j t a b , p o d z i a ł i m a i n odnoszą się do tablicy zadeklarowanej w wierszu 1. (1)
int
(2)
c z y t a j t a b ()
a [ l l ] ;
(3)
int
(4)
ąuicksort(m,n)
int
m,
(5)
m a i n ()
...
}
{
...
podział{y,z) {
...
Rys. 7.20.
a
a
...
int
}
y,
z;
n;
{
{
...
a
...
}
...}
Program w C z nielokalnymi wystąpieniami a
Strategii rezerwacji stosowej dla nazw lokalnych (omówionej w p . 7.3) można użyć bezpośrednio dla języka z widzialnością leksykalną, jak na przykład C, jeżeli nie ma procedur zagnieżdżonych. Pamięć dla wszystkich nazw zadeklarowanych na zewnątrz procedur może zostać zarezerwowana statycznie. Pozycje w tej pamięci są znane w cza sie kompilacji, więc jeśli nazwa jest nielokalna w pewnej treści procedury, używamy wprost adresu wyznaczonego statycznie. Każda inna nazwa musi być lokalna i pocho dzić z aktywacji na wierzchołku stosu. Nazwa taka jest dostępna przy użyciu wskaźnika wierzch. Procedury zagnieżdżone powodują, że ten schemat nie działa, ponieważ zmienna nielokalna może wtedy odnosić się do danych przechowywanych głęboko na stosie, co jest opisane poniżej. Ważną zaletą rezerwacji statycznej dla nazw nielokalnych jest to, że deklarowane procedury mogą być swobodnie przekazywane jako parametry i zwracane jako rezultaty (funkcja w C jest przekazywana przez przekazywanie wskaźnika do niej). Dla widzial ności leksykalnej i bez zagnieżdżania procedur, każda nazwa nielokalna dla jednej pro cedury jest również nielokalna dla wszystkich procedur. Jej statyczny adres może być użyty we wszystkich procedurach, niezależnie od tego, w jaki sposób zostały one akty wowane. Podobnie, jeśli procedury mogą być zwracane jako rezultaty, nazwy nielokalne w zwracanej procedurze odwołują się do pamięci zarezerwowanej dla nich statycznie. Rozważmy, na przykład, program w Pascalu z rys. 7.21. Wszystkie wystąpienia na zwy m, zaznaczone kółkami, znajdują się w zakresie deklaracji w wierszu 2. Ponieważ m jest nielokalna we wszystkich procedurach w programie, jej pamięć może zostać za rezerwowana statycznie. Za każdym razem, gdy procedury f i g są wykonywane, jako dostępu do wartości m mogą używać adresu statycznego. Fakt, że f i g są przekazywane jako parametry, oddziałuje tylko wtedy, gdy są one aktywowane, i nie oddziałuje to na sposób odwoływania się do wartości m. Wyjaśniając dokładniej, wywołanie b ( f ) w wierszu 11 związuje funkcję f z para metrem formalnym h procedury b . Zatem, gdy w wierszu 8, w w r i t e ( h ( 2 ) ), wywo ływana jest funkcja h będąca parametrem formalnym, funkcja f jest aktywowana. Akty wacja f zwraca 2, ponieważ zmienna nielokalna m ma wartość 0 , a parametr formalny n ma wartość 2. Następne wywołanie, b ( g ) , związuje g z h, i tym razem wywołanie h aktywuje g. Wynikiem programu jest 2
0
(1)
program
pas(input,
(2)
var ©
:
(3)
function
(4)
begin
(5) (6)
function
(7) (8)
procedurę
(9) (10) (U) (12)
begin
begin
output);
integer; f (n f
g(n f
:
:= ® :
:= ®
integer) + n
end
integer) * n
end
:
ii nn tt e€ g e r ;
{
f
:
}; integer;
{
g
b(function
h(n
:
write(h(2))
end
{
}; integer) b
integer);
};
begin
© := 0; b(f);
b(g);
writeln
end.
Rys. 7.21. Program w Pascalu z nielokalnymi wystąpieniami m
Widzialność leksykalna z zagnieżdżonymi procedurami Nielokalne wystąpienie nazwy a w procedurze w Pascalu znajduje się w zakresie naj bliższej — pod względem zagnieżdżenia — deklaracji a w statycznym tekście programu. Zagnieżdżanie definicji procedur w programie w Pascalu z rys. 7.22 jest przedsta wione za pomocą wcięć na następującej liście: sortuj czytajtab zamień ąuicksort podział a w wierszu 15 na rys. 7.22 występuje w funkcji p o d z i a ł , która jest zagnieżdżona w procedurze ą u i c k s o r t . Najbliższa, pod względem zagnieżdżenia, deklaracja a znaj duje się w wierszu 2 w procedurze składającej się z całego programu. Regułę najbliższego zagnieżdżenia stosuje się również do nazw procedur. Procedura z a m i e ń , wywoływana w wierszu 17 przez p o d z i a ł , jest nielokalna dla p o d z i a ł . Stosując tę regułę, spraw dzamy najpierw, czy z a m i e ń jest zdefiniowana wewnątrz ą u i c k s o r t ; ponieważ tak nie jest, szukamy jej w głównym programie s o r t u j .
Głębokość
zagnieżdżenia
Pojęcie głębokości zagnieżdżenia procedury jest używane do implementacji widzialno ści leksykalnej. Załóżmy, że nazwa programu głównego znajduje się na głębokości za gnieżdżenia 1. Do głębokości zagnieżdżenia dodaje się 1, jeśli przechodzi się od jednej procedury do procedury bezpośrednio w niej zawartej. Procedura ą u i c k s o r t w wier szu 11 na rys. 7.22 znajduje się na głębokości zagnieżdżenia 2, podczas gdy p o d z i a ł w wierszu 13 znajduje się na głębokości 3. Każde wystąpienie nazwy związujemy z głę bokością zagnieżdżenia procedury, w której została ona zadeklarowana. Wystąpienia a, v i i w wierszach 15-17 w procedurze p o d z i a ł mają zatem głębokości 1, 2 i 3.
(1) (2) (3)
program sortuj(input, output); var a : array [0..10] of integer; x : integer;
(4) (5) (6)
procedurę czyta jtab; var i : integer; begin ... a ... end { czytajtab };
(7) (8) (10)
procedurę zamień(i, j: integer); begin x := a[i]; a[i] a[j]; a [ j ] := x end { zamień } ;
(11) (12)
procedurę ąuicksort(m, n: integer); var k, v : integer;
(9)
(13) (14) (15) (16) (17) (18)
function podział(y, z: integer): integer; var i, j : integer; begin ... a ... ... v ... ... zamień (i, j); ... end { podział } ;
(19)
begin ... end { ąuicksort };
(20)
begin . . . end { sortuj } . Rys. 7.22. Program w Pascalu z zagnieżdżonymi procedurami
Wiązania
dostępu
Bezpośrednią implementację widzialności leksykalnej dla zagnieżdżonych procedur moż na wykonać, dodając wiązania dostępu do każdego rekordu aktywacji. Jeśli procedura p jest zagnieżdżona bezpośrednio wewnątrz q w kodzie źródłowym, to wiązanie dostę pu w rekordzie aktywacji dla p wskazuje wiązanie dostępu w rekordzie dla ostatniej aktywacji q. Sytuacje na stosie sterowania podczas wykonywania programu z rys. 7.22 są przed stawione na rys. 7.23. Ponownie, aby zaoszczędzić miejsce, pokazane są jedynie pierwsze litery nazw procedur. Wiązanie dostępu dla aktywacji s o r t u j jest puste, ponieważ s o r t u j nie znajduje się w żadnej innej procedurze. Wiązanie dostępu dla każdej aktywacji ą u i c k s o r t wskazuje rekord s o r t u j . Zauważmy (rys. 7.23(c)), że wiązanie dostę pu w rekordzie aktywacji dla p o d z i a ł ( 1 , 3 ) wskazuje wiązanie dostępu w rekordzie ostatniej aktywacji ą u i c k s o r t , czyli ą u i c k s o r t ( 1 , 3 ) . Załóżmy, że procedura p na głębokości zagnieżdżenia n odwołuje się do nielokalnej zmiennej a z głębokości n ^ n . Pamięć dla a można znaleźć w następujący sposób: p
a
1.
p
Gdy sterowanie znajduje się w p , rekord aktywacji dla p znajduje się na wierzchołku stosu. Przejdź po n — n wiązaniach dostępu z rekordu z wierzchołka stosu. War tość n — n można obliczyć w czasie kompilacji. Jeśli wiązanie dostępu w jednym rekordzie wskazuje wiązanie dostępu w innym, to przejście po tym wiązaniu może zostać wykonane za pomocą jednej operacji odwołania pośredniego. p
p
a
a
a, x
a, x
a, x
a, x
q(l,9>
q(l,9)
q(l,9)
q(l,9>
Wiązanie dostępu
Wiązanie dostępu
Wiązanie dostępu
Wiązanie dostępu
k, v
k, v
k, v
k,v
q(l,3)
q(l,3)
q(l,3)
Wiązanie dostępu
Wiązanie dostępu
Wiązanie dostępu
k, v
k, v
k, v
(a)
(b)
Pd,3)
P(l,3)
Wiązanie dostępu
Wiązanie dostępu
i, j z(l,3)
(c)
Wiązanie dostępu
Rys. 7.23. Wiązania dostępu służące do znajdowania pamięci dla zmiennych nielokalnych
2.
Po przejściu n — n wiązań osiągamy rekord aktywacji dla procedury, dla której a jest lokalne. Jak j u ż wiemy z poprzedniego podrozdziału, pamięć jej przypisana względem pozycji w rekordzie znajduje się na stałym przesunięciu. W szczególnym przypadku to przesunięcie może być wględem wiązania dostępu. p
a
Stąd, adres nielokalnej zmiennej a w procedurze p jest definiowany przez następującą parę obliczoną w trakcie kompilacji i zapisaną w tablicy symboli: (n
p
— n , przesunięcie wewnątrz rekordu aktywacji zawierającego a ) a
Pierwszy element pary oznacza liczbę wiązań aktywacji, po których należy przejść. Przykładowo, w wierszach 15-16 na rys. 7.22, procedura p o d z i a ł na głębokości zagnieżdżenia 3 odwołuje się do nielokalnych a i v na głębokościach zagnieżdżenia, odpowiednio, 1 i 2. Rekordy aktywacji zawierające te zmienne nielokalne znajdują się po przejściu, po odpowiednio, 3 — 1 = 2 i 3 — 2 = 1 wiązaniach dostępu, zaczynając z rekordu dla p o d z i a ł . Kod ustawiający wiązania dostępu jest częścią sekwencji wywołania. Załóżmy, że procedura p znajdująca się na głębokości zagnieżdżenia n wywołuje procedurę x z głę bokości n . Kod ustawiający wiązanie dostępu w wywoływanej procedurze zależy od tego, czy wywoływana procedura jest zagnieżdżona w wywołującej. p
x
1.
2.
Przypadek n < n . Skoro wywoływana procedura x jest głębiej zagnieżdżona niż p , musi być zadeklarowana wewnątrz p , w innym przypadku nie będzie dostęp na z p . Przypadek ten występuje, gdy z s o r t u j wywoływane jest ą u i c k s o r t (rys. 7.23(a)) i gdy z ą u i c k s o r t wy woły wane jest p o d z i a ł (rys. 7.23(c)). Wte dy wiązanie dostępu w wywoływanej procedurze musi wskazywać wiązanie dostępu w rekordzie aktywacji procedury wywołującej bezpośrednio poniżej stosu. Przypadek n ^ n . Według reguł widzialności procedury na głębokościach zagnież dżenia 1, 2 , . . . , n — 1, zawierające wywoływane i wywołujące procedury, muszą być takie same, jak wtedy, gdy ą u i c k s o r t wywołuje siebie (rys. 7.23(b)) i gdy p o d z i a ł wywołuje z a m i e ń (rys. 7.23(d)). Przechodząc p o n —fl*-f-1 wiązaniach dostępu od wywołującego dochodzimy do ostatniego rekordu aktywacji dla proce dury, która statycznie zawiera obie procedury wywoływaną i wywołującą. Osiągnię te wiązanie dostępu jest jedynym, do którego musi wskazywać wiązanie dostępu w procedurze wywoływanej. Ponownie n — n + 1 może zostać obliczone w czasie kompilacji. p
x
p
x
x
p
p
Parametry
x
procedury
Reguły widzialności leksykalnej są stosowane nawet wtedy, gdy zagnieżdżona procedura jest przekazywana jako parametr. Funkcja f w wierszach 6 - 7 w programie w Pascalu na rys. 7.24 ma nielokalną zmienną m; wszystkie wystąpienia m są oznaczone kółkami. W wierszu 8 procedura c przypisuje m wartość 0 i następnie przekazuje f jako parametr do b . Zauważmy, że widzialność deklaracji m z wiersza 5 nie obejmuje treści procedury b w wierszach 2 - 3 . Instrukcja w r i t e ł n ( h ( 2 ) ) w treści b aktywuje f, ponieważ parametr formalny h odnosi się do f. Czyli, w r i t e ł n wypisze rezultat f ( 2 ) . W jaki sposób ustawiane jest wiązanie dostępu dla aktywacji f ? Odpowiedź jest następująca: procedura zagnieżdżona, która jest przekazywana jako parametr, musi mieć przypisane wiązanie dostępu przekazywane jednocześnie, jak na rys. 7.25. Gdy procedura c przekazuje f, musi wyznaczyć również wiązanie dostępu dla f, w ten sam sposób, (1)
program paramfinput,
(2) (3)
procedurę
(4) (5)
procedurę
begin
var ®
b(function
h(n:integer):
writełn(h(2))
end
{ b
integer);
};
c; :
(6) (7)
function
(8)
b e g i n ©
(9) (10) (U)
output);
begin
integer; f(n f :=
:
integer)
:= © 0;
+ n
b(f)
:
end end
integer;
{ {
f c
}; };
begin c end.
Rys. 7.24. Wiązanie dostępu musi być przekazywane razem z parametrem aktualnym f
param
Rys. 7.25. Parametr aktualny procedury f jest przekazywany razem z wiązaniem dostępu jakby wywoływała f. To wiązanie jest przekazywane razem z f do b . Następnie, gdy f jest aktywowane z wewnątrz b , wiązanie to jest używane do ustalenia wiązania dostępu w rekordzie aktywacji f. Tablice
display
Dostęp do zmiennych nielokalnych, szybszy niż za pomocą wiązań dostępu, można uzy skać, stosując tablicę d wskaźników do rekordów aktywacji, zwaną display. Dzięki tej tablicy pamięć dla zmiennej nielokalnej a na głębokości zagnieżdżenia / może zostać znaleziona w rekordzie aktywacji wskazywanym przez element tablicy d[i]. Załóżmy, że sterowanie znajduje się w aktywacji dla procedury p , będącej na głę bokości zagnieżdżenia j , W takim przypadku, pierwsze j—l elementów tablicy display wskazuje ostatnie aktywacje procedur zawierających leksykalnie procedurę p , a d[j] wskazuje aktywację p . Użycie tej tablicy jest zwykle szybsze niż przechodzenie po wią zaniach dostępu, ponieważ rekord aktywacji przechowujący zmienną nielokalną może zostać znaleziony przez odczyt elementu d i przejście po jednym wskaźniku. Prosty sposób posługiwania się tablicą display polega na dodatkowym wykorzysta niu wiązań dostępu. Aktualizacja tablicy przez przejście ciągu wiązań dostępu musi być wykonywana jako część sekwencji wywołania i powrotu. Gdy przechodzi się przez wią zanie do rekordu aktywacji na głębokości zagnieżdżenia n, element tablicy display d[n] jest przypisywany wskaźnikowi tego rekordu. W rezultacie, display powiela informację z ciągu wiązań dostępu. Powyższy sposób można ulepszyć. Ten przedstawiony na rysunku 7.26 wymaga mniej pracy przy wchodzeniu i wychodzeniu z procedury w zwykłym przypadku, gdy procedury nie są przekazywane jako parametry. Na rysunku 7.26 tablica display jest tabli cą globalną, oddzielną od stosu. Sytuacja z rysunku odnosi się do wykonania programu źródłowego z rys. 7.22. Ponownie pokazano jedynie pierwsze litery nazw procedur.
Na rysunku 7.26(a) widać sytuację bezpośrednio przed rozpoczęciem aktywacji q ( 1 , 3 ) . Skoro q u i c k s o r t znajduje się na głębokości zagnieżdżenia 2, element tabli cy display d[2] jest zmieniany, gdy rozpoczyna się nowa aktywacja q u i c k s o r t . Efekt aktywacji q ( 1 , 3) przedstawiono na rys. 7.26(b), gdzie d[2] wskazuje teraz nowy rekord aktywacji. Stara wartość d[2] jest zapisywana w rekordzie aktywacji . Zapisana wartość będzie później potrzebna do przywrócenia tablicy display do swojego stanu z rys. 7.26(a), gdy sterowanie powróci do aktywacji q ( 1 , 9 ) . Tablica display zmienia się, gdy występuje nowa aktywacja i należy ją przywrócić, gdy sterowanie powraca z tej aktywacji. Reguły widzialności Pascala i innych języków z widzialnością leksykalną umożliwiają uaktualnianie tablicy display za pomocą nastę1
41] 42]
41] d[2] (a)
(b)
qd,9) Zapamiętane d[2]\
qd,9) [Zapamiętane d[2]\ q(l,3 Zapamiętane
Ą2
41] 42] 43] q
Zapamiętane
Ą2]
q(l,3)
q(l,3) Zapamiętane
d[2]
Zapamiętane
d\2
p(l,3)
pd,3) Zapamiętane
Zapamiętane^ 4 2 ]
d[3]
Zapamiętane 4 3 ]
z(l,3) Zapamiętane
421
Rys. 7.26. Zarządzanie tablicą display, gdy procedury nie są przekazywane jako parametry Zauważmy, że dla q ( 1 , 9) zawartość d[2] również została zapamiętana, chociaż nie była używana wcześniej i nie musi być przywracana. Dla wywołań q łatwiej jest zapisywać d[2], niż w czasie działania decydować, czy należy to zrobić.
pujących kroków. Omawiamy jedynie prosty przypadek, w którym nie m a przekazywania procedur jako parametrów (patrz ćwiczenie 7.8). Gdy nowy rekord aktywacji jest two rzony dla procedury na głęokości zagnieżdżenia /, należy: 1) 2)
zapamiętać wartość d[i] w nowym rekordzie aktywacji oraz wartości d[i] przypisać wskaźnik nowego rekordu aktywacji.
Bezpośrednio przed zakończeniem aktywacji, d[i] jest przywracana zapamiętana wartość. Postępowanie to można uzasadnić następująco. Załóżmy, że procedura na głębokości zagnieżdżenia j wywołuje procedurę z głębokości i. Są dwa przypadki, zależne od tego, czy procedura wywoływana jest zagnieżdżona wewnątrz procedury wywołującej w kodzie źródłowym, czy nie, podobnie, jak w dyskusji o wiązaniach dostępu. 1.
2.
Przypadek j < i. Wtedy i = j + 1 i wywoływana procedura jest zagnieżdżona we wnątrz wywołującej. Pierwszych j elementów tablicy display nie trzeba więc zmie niać i wskaźnik nowego rekordu aktywacji wystarczy przypisać do d[i\; przykładami tego są: rys. 7.26(a), gdzie s o r t u j wywołuje ą u i c k s o r t , oraz rys. 7.26(c), gdzie ą u i c k s o r t wywołuje p o d z i a ł . Przypadek j ^ /. Ponownie, procedury na głębokościach 1, 2 , 1 , zawierające procedury wywołującą i wywoływaną, muszą być takie same. W tym przypadku w nowym rekordzie aktywacji jest zapisywana tylko stara wartość d[i] a d[i] jest przypisywany wskaźnik nowego rekordu aktywacji. Display ma teraz poprawne war tości, ponieważ pierwszych i - 1 elementów nie zostało zmienionych. 9
Przykładem dla drugiego przypadku, dla i = j = 2, jest wywołanie rekurencyjne ą u i c k s o r t na rys. 7.26(b). Bardziej interesujący przypadek pojawia się, gdy aktywacja p ( 1 , 3) z głębokości 3 wywołuje z ( 1 , 3) z głębokości 2, a procedurą zawierającą j e obie jest s z głębokości 1, jak na rys. 7.26(d). (Program znajduje się na rys. 7.22). Zauważmy, że gdy wywoływane jest z ( 1 , 3 ) , wartość d[3] przynależna do p ( 1 , 3 ) , znajduje się wciąż w tablicy display, chociaż — gdy sterowanie znajduje się w z — nie ma do niej dostępu. Gdy z wywoła inną procedurę z głębokości 3, ta procedura zapamięta d[3], a potem przy powrocie do z przywróci ją. Można wykazać więc, że każda procedura widzi odpowiedni display dla wszystkich głębokości, aż do swojej własnej. Istnieje kilka miejsc, w których można umieścić display. Jeśli istnieje dostatecznie dużo rejestrów, to display, traktowane jak zwykła tablica, może być zbiorem rejestrów. Zauważmy, że kompilator może wyznaczyć maksymalną długość tej tablicy, która od powiada maksymalnej głębokości zagnieżdżenia procedur w programie. W przeciwnym przypadku, display może znajdować się w pamięci zarezerwowanej statycznie i wtedy wszystkie odwołania do rekordów aktywacji muszą używać pośredniego adresowania przez odpowiedni wskaźnik z tablicy display. Podejście to jest uzasadnione dla maszy ny z adresowaniem pośrednim, chociaż każde odwołanie pośrednie to dodatkowy cykl dostępu do pamięci. Inną możliwością jest przechowywanie tablicy display na stosie sterowania i tworzenie nowej kopii przy każdym wywołaniu procedury. t
Widzialność dynamiczna Przy widzialności dynamicznej nowy rekord aktywacji dziedziczy istniejące związanie nazwy nielokalnej z pamięcią. Nazwa nielokalna a w wywoływanej aktywacji odwołuje
się do tej samej pamięci, co w aktywacji wywołującej. Nowe wiązanie nazwy jest wyko nywane dla procedury zawierającej tę nazwę jako lokalną. Taka nazwa odwołuje się do pamięci w nowym rekordzie aktywacji. Program z rysunku 7.27 jest ilustracją widzialności dynamicznej. Procedura w y p i s z w wierszach 3-4 zapisuje wartość zmiennej nielokalnej r . Przy widzialności leksykanej z Pascala zmienna nielokalna znajduje się w zakresie widzialności deklaracji z wiersza 2, więc wynikiem programu jest 0.250 0.250
0.250 0.250
Jednak przy widzialności dynamicznej wynikiem jest 0.250 0.250
0.125 0.125
Gdy w y p i s z jest wywoływane w wierszach 10-11 w programie głównym, wypi sywane jest 0 . 2 5 0 , ponieważ używana jest zmienna r lokalna w programie głównym. Kiedy jednak w y p i s z jest wywoływane w wierszu 7 z wnętrza m a ł e , wypisywane jest 0 . 1 2 5, ponieważ używana jest zmienna r lokalna w m a l e .
(1) program dynamiczna(input,output); var r : real; (2) (4)
procedurę wypisz; begin write( r : 5:3 ) end;
(5) (6) (7)
procedurę male; var r: real; begin r := 0.125; wypisz end;
(3)
(8) (9) (10) (U) (12)
begin r := 0.25; wypisz; male; writełn; wypisz; male; writełn end.
Rys. 7.27. Wynik zależy od tego, czy użyto widzialność dynamiczną, czy statyczną Poniższe dwa podejścia w implementacji widzialności dynamicznej są podobne do wykorzystywanych przy implementacji widzialności leksykalnej, odpowiednio: wiązań dostępu i tablicy display. 1.
2.
Dostęp głęboki. Ogólnie, widzialność dynamiczna m a znaczenie, jeżeli wiązania do stępu wskazują te same rekordy aktywacji, co wiązania sterowania. W najprostszej implementacji nie wykorzystuje się wiązań dostępu i używa się wiązań sterowania do przeszukiwania stosu, do poszukiwania pierwszego rekordu aktywacji zawierającego wartość tej nazwy nielokalnej. Termin dostęp głęboki pochodzi od tego, że w prze szukiwaniu można dojść „głęboko" w stosie. Głębokość, do której można dotrzeć, zależy od wejścia programu i nie można jej wyznaczyć w czasie kompilacji. Dostęp płytki. Polega on na przechowywaniu obecnej wartości każdej nazwy w pa mięci zarezerwowanej statycznie. Gdy rozpoczyna się nowa aktywacja procedury
p, nazwa lokalna n w p zajmuje pamięć zarezerwowaną statycznie dla n. Poprzed nią wartość n można zapisać w rekordzie aktywacji dla p i należy ją przywrócić podczas kończenia aktywacji p . Oba podejścia mają zalety i wady. Dostęp głęboki pochłania więcej czasu podczas dostępu do zmiennej nielokalnej, ale nie powoduje zwiększenia czasu przy rozpoczynaniu i koń czeniu aktywacji. Natomiast dostęp płytki umożliwia bezpośredni dostęp do zmiennych nielokalnych, lecz potrzebny jest czas do uaktualniania tych wartości przy wchodzeniu i wychodzeniu z aktywacji. Gdy funkcje są przekazywane jako parametry i zwracane jako rezulataty, dostęp głęboki daje bardziej bezpośrednią implementację.
7.5
Przekazywanie parametrów
Zwykłą metodą komunikacji między procedurami jest użycie nazw nielokalnych i para metrów procedury wywoływanej. Zarówno zmienne nielokalne i parametry są używane przez procedurę z rys. 7.28 d o wymiany wartości a [ i ] i a [ j ] . W tym przypadku tablica a jest nielokalna dla procedury z a m i e ń , a i oraz j są parametrami.
(1) (2) (3) (4) (5)
procedurę zamień(i, j: integer); var x : integer; begin x : = a [ i ] ; a [ i ] : = a [ j ] ; a [ j ] :=x end Rys. 7.28. Procedura w Pascalu ze zmiennymi nielokalnymi i parametrami
W tym podrozdziale omówiliśmy kilka najczęściej wykorzystywanych metod zwią zywania parametrów aktualnych z formalnymi. Są to: przekazanie przez wartość, przez referencję, skopiowanie-przywrócenie, przekazywanie przez nazwę i rozwinięcie makro instrukcji. Ważne jest, aby wiedzieć, którą metodę przekazywania parametrów stosuje dany język (lub kompilator), ponieważ wynik programu może zależeć od użytej metody. Skąd tak dużo metod? Różne metody pojawiają się w różnych interpretacjach tego, co reprezentuje dane wyrażenie. W przypisaniu o postaci a[i]
:=
a[j]
wyrażenie a [ j ] reprezentuje wartość, podczas gdy a [ i ] reprezentuje lokację pamięci, w której jest umieszczana wartość a [ j ] . Decyzja, czy należy użyć lokacji, czy wartości reprezentowanych przez wyrażenie, zależy od tego, czy wyrażenie znajduje się po lewej, czy po prawej stronie symbolu przypisania. Jak w rozdziale 2, określenie /-wartość doty czy pamięci reprezentowanej przez wyrażenie, natomiast r-wartość — wartości zawartej w pamięci. Przedrostki /- i r- pochodzą od angielskich słów left i right i oznaczają „lewą" oraz „prawą" stronę przypisania. Różnice między metodami przekazywania parametrów wynikają głównie z tego, że parametr aktualny reprezentuje r-wartość, /-wartość lub napis będący nazwą parametru aktualnego.
Przekazywanie przez wartość Jest to najprostsza metoda przekazywania parametrów. Parametry aktualne są obliczane i ich r-wartości są przekazywane do wywoływanej procedury. Przekazywanie przez war tość jest używane w C, w Pascalu odbywa się to standardowo. Wszystkie programy, które już omówiliśmy w tym rozdziale, były oparte na tej metodzie przekazywania parametrów. Przekazywanie przez wartość można zaimplementować w następujący sposób: 1. 2.
Parametr formalny jest traktowany tak, jak zmienna lokalna, więc pamięć dla para metrów formalnych znajduje się w rekordzie aktywacji procedury wywoływanej. Procedura wywołująca oblicza parametry aktualne i umieszcza ich r-wartości w pa mięci dla parametrów formalnych.
Przekazywanie przez wartość różni się od innych tym, że operacje na parametrach formalnych nie oddziałują na wartości w rekordzie aktywacji procedury wywołującej. Jeśli słowo kluczone v a r w wierszu 3 z rys. 7.29 zostanie pominięte, Pascal przekaże x i y przez wartość do procedury z a m i e ń . Wywołanie z a m i e ń ( a , b ) w wierszu 12 pozostawi w tym przypadku niezmienione wartości a i b . Przy przekazywaniu para metrów przez wartość efekt wywołania z a m i e ń ( a , b ) będzie taki sam, jak sekwencji instrukcji: x y pom x y gdzie x, y zmiennych gdy rekord wpływu na
:= a := b := x y : = pom i pom są lokalne w z a m i e ń . Chociaż te przypisania zmieniają wartości lokalnych x, y i pom, to zmiany te są gubione po powrocie z wywołania, aktywacji dla z a m i e ń jest zwalniany. Wywołanie to nie ma więc żadnego rekord aktywacji wywołującego.
(1) p r o g r a m r e f e r e n c j a ( i n p u t , o u t p u t ) ; (2) v a r a , b : i n t e g e r ; (3) p r o c e d u r ę z a m i e ń ( v a r x , y : i n t e g e r ) ; v a r pom : i n t e g e r ; (4) begin (5) pom := x ; (6) x := y ; (7) y := pom; (8) end; (9) (10) b e g i n a := 1; b := 2 ; (11) zamień(a,b); (12) w r i t e l n C a =' , a ) ; w r i t e l n ( ' b = ' , (13) (14) e n d . Rys. 7.29. Program w Pascalu z procedurą z a m i e ń
Procedura z parametrami przekazywanymi przez wartość może oddziaływać na wy wołującą przez zmienne nielokalne (patrz z a m i e ń z rys. 7.28) albo przez wskaźniki, które są jawnie przekazywane przez wartości. W programie w C z rys. 7.30, x i y są zadeklarowane w wierszu 2 jako wskaźniki liczb całkowitych. Operator & w wywołaniu z a m i e ń (&a, &b) w wierszu 8 powoduje przekazanie do z a m i e ń wskaźników a i b . Wynikiem tego programu jest a
jest
teraz
2,
b
jest
teraz
1
Użycie wskaźników w tym przykładzie podpowiada, jak kompilator używający przeka zywania przez referencję może zamieniać wartości.
(1) (2) (3) (4) (5)
zamień (x, y) int *x, *y; { int pom; pom = *x; *x = *y; *y = pom; }
(6) main () (7) { int a = 1, b = 2 ; (8) zamień ( &a, &b ); (9) printf ("a jest teraz %d, b jest teraz %d\n",a,b); (10)
}
Rys. 7.30. Program w C używający wskaźników przekazywanych przez wartość
Przekazywanie przez referencję Gdy parametry są przekazywane przez referencję (inaczej przekazywanie przez adres lub przez lokację), procedura wywołująca przekazuje wywoływanej wskaźnik miejsca pamięci dla każdego parametru aktualnego. 1. 2.
Jeśli parametr aktualny jest nazwą wyrażenia mającą /-wartość, to przekazywana jest ta /-wartość. Jeśli jednak parametr aktualny jest wyrażeniem, jak a + b lub 2, które nie m a /-wartości, to wartość ta jest obliczana w nowej lokacji i jej adres jest przekazywany.
Referencja do parametru formalnego w procedurze wywoływanej w kodzie wynikowym staje się referencją pośrednią przez wskaźnik przekazany do procedury wywoływanej.
Przykład 7.7. Rozważmy procedurę z a m i e ń z rys. 7.29. Wywołanie z a m i e ń z pa rametrami aktualnymi i oraz a [ i ] , czyli z a m i e ń ( i , a [ i ] ) , będzie miało taki sam skutek, jak wykonanie następujących kroków: 1.
2.
Skopiuj adres (/-wartość) i oraz a [ i ] do rekordu aktywacji wywoływanej proce dury. Przyjmijmy, że a r g l i a r g 2 są lokacjami, pod które następuje to kopiowanie, odpowiadającymi odpowiednio x i y. Ustaw pom na zawartość lokacji wskazywanej przez a r g l (czyli p o m przypisz war tość / , będącą początkową wartością i ) . Ten krok odpowiada przypisaniu pom : = x w wierszu 6 w definicji z a m i e ń . 0
3.
Ustaw zawartość lokacji wskazywanej przez a r g l na wartość lokacji wskazywanej przez a r g 2 , czyli wykonaj przypisanie i : = a [ / ] . Ten krok odpowiada przypi saniu x : = y z wiersza 7 w procedurze z a m i e ń . Ustaw zawartość lokacji wskazywanej przez a r g 2 na wartość pom; odpowiada to przypisaniu a [/ ] : = i . Krok ten odpowiada y : = t e m p . • 0
4.
0
Przekazywanie parametrów przez referencję jest używane w wielu językach. W Pas calu w ten sposób są przekazywane parametry zadeklarowane za pomocą v a r . Tablice są zwykle przekazywane przez referencje.
Metoda skopiuj-przy wróć Pośrednią metodą między przekazywaniem przez wartość a przekazywaniem przez re ferencję jest skopiuj-przywróć (metoda jest również znana jako copy-in copy-out lub wartość-rezultat). 1.
2.
Przed przejściem sterowania do wywoływanej procedury są obliczane para metry aktualne. r-Wartości parametrów aktualnych są dostarczane do procedu ry wywoływanej, tak jak w przekazywaniu przez wartość. Dodatkowo jednak, /-wartości parametrów aktualnych (jeśli j e mają) są obliczane przed wywołaniem procedury. Gdy sterowanie powraca z procedury, aktualne r-wartości parametrów formalnych są kopiowane z powrotem do /-wartości parametrów aktualnych, przy użyciu /-wartości obliczonych przed wywołaniem. Oczywiście, kopiowane są jedynie parametry aktu alne mające /-wartości.
Pierwszy krok „wkopiowuje" wartości parametrów aktualnych do rekordu aktywacji procedury wywoływanej (do pamięci dla parametrów formalnych). Drugi krok „wykopiowuje" ostateczne wartości parametrów formalnych do rekordu aktywacji pro cedury wywołującej (pod /-wartości obliczone z parametrów aktualnych przed wywo łaniem). Zauważmy, że z a m i e ń ( i , a [ i ] ) działa poprawnie przy zastosowaniu metody skopiuj-przywróć, ponieważ lokacja a [ i ] jest obliczana i zabezpieczana przez procedurę wywołującą przed wywołaniem. Zatem, ostateczna wartość parametru formalnego y, który jest wartością początkową i , jest kopiowana pod poprawny adres, nawet gdy adres a [ i ] jest zmieniany w trakcie wywołania (z powodu zmiany wartości i ) . Metoda skopiuj-przywróć jest używana w niektórych implementacjach Fortranu. In ne języki używają jednak przekazywania przez referencję. Różnice między tymi dwoma metodami uwidoczniają się, jeśli wywoływana procedura używa więcej niż jednego spo sobu adresowania pamięci w rekordzie aktywacji wywołującego. Aktywacja tworzona przez wywołanie n i e b e z p ( a ) w wierszu 6 na rys. 7.31 może mieć dostęp d o a jako zmiennej nielokalnej oraz przez parametr formalny x. Przy przekazywaniu parametrów przez referencję, przypisania do obu, x i a, natychmiast oddziałują na a, więc ostateczną wartością a jest 0. Jednak, przy metodzie skopiuj-przywróć wartość 1 parametru aktu alnego a jest kopiowana do formalnego x. Ostateczna wartość parametru x, czyli 2, jest kopiowana do /-wartości a bezpośrednio przed powrotem sterowania, więc ostateczną wartością a będzie 2 .
(1) (2) (3) (4) (5) (6) (7)
program kopiowanie(input,output); var a : integer; procedurę niebezp(var x : integer); begin x := 2 ; a := 0 end; begin a : = 1; niebezp (a) ; writełn (a) end.
Rys. 7.31. W y n i k z m i e n i s i ę , g d y z a m i a s t p r z e k a z y w a n i a p r z e z referencję, u ż y j e s i ę m e t o d y skopiuj-przywróć
Przekazywanie przez nazwę Przekazywanie przez nazwę jest tradycyjnie zdefiniowane przez regułę z Algola: 1.
2.
3.
kopiowania
Procedura jest traktowana jakby była makrem, czyli jej treść jest podstawiana za wywołanie u wywołującego, z parametrami aktualnymi wstawionymi dosłownie w miejsce formalnych. Takie dosłowne podstawienie jest nazywane rozwijaniem makroinstrukcji lub rozwinięciem w miejscu wywołania*. Nazwy lokalne procedury wywoływanej różnią się od nazw w procedurze wywo łującej. Można uważać, że każda zmienna lokalna w procedurze wywoływanej ma systematycznie zmienianą nazwę przed wykonaniem rozwinięcia makra. Parametry aktualne są otaczane nawiasami w celu zachowania ich spójności.
Przykład 7.8. Wywołanie z a m i e ń ( i , a [i ] ) z przykładu 7.7 zostałoby zaimplemen towane, w postaci pom : = i i := a [ i ] a [ i ] : - pom Zatem, przy przekazywaniu przez nazwę, z a m i e ń przypisuje, jak się spodziewamy, i wartość a [i], ale ma niespodziewany rezultat, ustawiając a [ a [ 7 ] ] , zamiast a [ 7 ] , na Vq, gdzie 7 jest początkową wartością i. Zjawisko to występuje, ponieważ lokacja x w przypisaniu x : = t e m p w z m i e ń nie jest obliczana aż do momentu, w którym jest potrzebna, ale wtedy wartość i jest już zmieniona. Poprawnie działająca wersja procedury z a m i e ń najwidoczniej nie może zostać napisana, jeśli wykorzystywane jest przekazywanie parametrów przez nazwę (patrz Fleck [1976]). • Q
0
0
Chociaż przekazanie przez nazwę jest interesujące głównie teoretycznie, podobna ogólnie technika rozwijania procedur w miejscu wywołania jest polecana do zmniejszenia czasu działania programu. Tworzenie aktywacji dla procedury stanowi dodatkowy koszt — trzeba zarezerwować pamięć na rekord aktywacji, zapisać stan procesora, ustawić wiązania i przesłać sterowanie. Jeżeli treść procedury jest krótka, kod sekwencji wywo łania może być większy niż kod samej procedury. Bardziej wydajne może być zatem W języku angielskim używa się określenia in-line expansion
(przyp. tłum.).
użycie rozwijania procedury wewnątrz procedury wywołującej, nawet jeśli rozmiar pro gramu zwiększy się w niewielkim stopniu. W następnym przykładzie rozwijanie procedur zastosowaliśmy do procedury o parametrach przekazywanych przez wartość. P r z y k ł a d 7.9. x
Załóżmy, że parametry funkcji f w przypisaniu
: = f (A)
+
f(B)
są przekazywane przez wartość. Parametry aktualne A i B w tym przykładzie są wyraże niami. Podstawienie wyrażeń A i B za każde wystąpienie parametru formalnego w treści funkcji f prowadzi d o wywołania przez nazwę. Przypomnijmy a [ i ] z ostatniego przy kładu. Nowych zmiennych tymczasowych można użyć do wymuszenia obliczenia parame trów aktualnych przed wykonaniem treści funkcji: tl t
= A;
t
=
2
t
= B; f
( t , ) ;
3
4
= f(t );
X
=
2
t
3
+
t
4
;
Teraz, rozwinięcie funkcji f w miejscu wywołania można zastąpić wszystkie wystąpienia parametru formalnego przez t i t , gdy odpowiednio pierwsze i drugie wy wołanie jest rozwijane . • x
2
1
Zwykła implementacja przekazywania parametrów przez nazwę polega na przeka zaniu do procedury wywoływanej podprocedury bezparametrowej, zwanej thunk, któ ra może obliczać /-wartość albo r-wartość parametru aktualnego. Jak każda procedura przekazywana jako parametr w języku przy użyciu widzialności leksykalnej, thunk jest przekazywana razem z wiązaniem dostępu, wskazującym aktualny rekord aktywacji dla procedury wywołującej.
7.6
Tablice symboli
Kompilator używa tablicy symboli do śledzenia widzialności i związywania informacji z nazwami. Tablica symboli jest przeszukiwana za każdym razem, gdy nazwa jest znajdo wana w kodzie źródłowym. Zmiany w tablicy symboli są dokonywane, gdy znajdowana jest nowa nazwa lub informacja o j u ż istniejącej. Mechanizm obsługi tablicy symboli musi pozwalać na wydajne dodawanie nowych wpisów i wyszukiwanie istniejących. W tym podrozdziale przedstawiliśmy dwa mecha nizmy obsługi tablicy symboli: listę i tablicę mieszającą. Oba są oceniane ze względu na czas potrzebny do dodania n wpisów i wykonania e zapytań. Lista liniowa jest najprost sza w implementacji, ale jej wydajność jest mała dla dużych e i n. Tablice mieszające 1
Istnieje ukryty koszt związany ze zmiennymi tymczasowymi. Mogą one wykorzystywać dodatkową pamięć zarezerwowaną na rekord aktywacji. Jeśli zmienne lokalne w rekordzie aktywacji są inicjowane, to dodatkowe zmienne tymczasowe powodują wydłużenie czasu.
7.6
407
TABLICE SYMBOLI
mają większą wydajność kosztem większej trudności w implementacji i większej zajętości pamięci. Oba mechanizmy mogą zostać przystosowane do uwzględniania reguły najbliższego zagnieżdżenia. Kompilator w trakcie kompilacji powinien — w miarę potrzeby — zwiększać dynamicznie rozmiar tablicy symboli. Jeśli ten rozmiar jest ustalany podczas pisania kompilatora, to musi być dostatecznie duży, aby skompilowanie wszystkich prog ramów źródłowych, które mogą się pojawić, było możliwe. Taki ustalony rozmiar bę dzie jednak prawdopodobnie za duży w większości przypadków, a w niektórych może być za mały. Wpisy w tablicy symboli Każdy wpis w tablicy symboli jest deklaracją nazwy. Format wpisu nie musi być jed norodny, ponieważ informacja zapisywana dla danej nazwy zależy od przeznaczenia tej nazwy. Każdy wpis może zostać zaimplementowany jako rekord składający się z se kwencji kolejnych słów pamięci. Aby tablica symboli była jednorodna, przydatne może być trzymanie niektórych informacji dotyczących nazw na zewnątrz tablicy symboli. W tablicy symboli pamiętany byłby wtedy jedynie wskaźnik do informacji zachowanej w rekordzie. Informacja jest wprowadzana d o tablicy symboli w różnym czasie. Jeśli w ogóle są wprowadzane słowa kluczowe, to wykonywane jest to w trakcie inicjowania. Analizator leksykalny (patrz p . 3.4) wyszukuje w tablicy symboli sekwencji liter i cyfr w celu usta lenia, czy dane słowo kluczowe lub nazwa została już wczytana. Przy takim podejściu słowa kluczowe muszą znajdować się w tablicy symboli przed rozpoczęciem analizy lek sykalnej. Możliwe jest inne podejście, w którym analizator leksykalny wykrywa słowa kluczowe, i wtedy nie muszą się one znajdować w tablicy symboli. Jeśli język nie rezer wuje słów kluczowych, to ważne jest, aby zostały one umieszczone w tablicy symboli razem z uwagą, że mogą zostać użyte jako słowo kluczowe. Sam wpis w tablicy symboli można dodać, gdy przeznaczenie nazwy stanie się ja sne, natomiast wartości atrybutów można ustalić później, gdy informacja o nich będzie dostępna. W niektórych przypadkach wpis może zostać zainicjowany przez analizator leksykalny, od razu po wczytaniu nazwy z wejścia. Częściej zdarza się, że pojedyncza na zwa oznacza kilka różnych obiektów, nawet wewnątrz tego samego bloku lub procedury. Na przykład, deklaracje w C int struct
x; x { float
y,
z;
};
wykorzystują x zarówno jako liczbę całkowitą, jak i oznaczenie struktury z dwoma po lami. W takich przypadkach, analizator leksykalny może przekazać analizatorowi skład niowemu samą nazwę (lub wskaźnik na leksem tworzący tę nazwę) zamiast wskaźni ka d o wpisu w tablicy symboli. Rekord w tablicy symboli jest tworzony wtedy, gdy zna ne jest przeznaczenie składniowe dla nazwy. Z deklaracji (7.1) zostaną utworzone dwa wpisy w tablicy symboli dla x: jeden dla x jako liczby całkowitej i drugi dla x jako struktury. Atrybuty nazwy są wprowadzane w odpowiedzi na deklaracje, które mogą być nie jawne. Etykiety często są identyfikatorami, za którymi występuje dwukropek, więc jedną
z akcji związaną z rozpoznaniem takiego identyfikatora może być zgłoszenie tego fak tu d o tablicy symboli. Podobnie, składnia deklaracji procedury specyfikuje, że pewne identyfikatory są parametrami formalnymi. Znaki składające się na nazwy Istnieje rozróżnienie między symbolem leksykalnym i d dla identyfikatora lub nazwy, leksemem składającym się z ciągu znaków tworzącego nazwę a atrybutami tej nazwy (patrz rozdz. 3). Praca na ciągach znaków może być niewygodna, więc kompilatory często używają pewnej reprezentacji nazwy o ustalonej długości zamiast leksemów. Leksem jest potrzebny, gdy wpis do tablicy symboli jest tworzony po raz pierwszy, i gdy leksem znaleziony na wejściu jest wyszukiwany w tablicy symboli w celu sprawdzenia, czy stanowi nazwę, która j u ż wcześniej się pojawiła. Powszechną reprezentacją nazwy jest wskaźnik d o wpisu w tablicy symboli dla tej nazwy. Jeśli długość nazwy ma małe ograniczenie górne, to znaki tworzące nazwę m o gą być przechowywane we wpisie w tablicy symboli, j a k na rys. 7.32(a). Jeśli nie ist nieje ograniczenie długości nazwy lub jest ono rzadko osiągane, można użyć sche matu pośredniego z rys. 7.32(b). Zamiast rezerwowania w każdym wpisie w tablicy symboli maksymalnej możliwej ilości pamięci na przechowywanie Ieksemu, pamięć można wykorzystać wydajniej, jeśli we wpisie będzie znajdował się jedynie wskaźnik. W rekordzie dla nazwy jest umieszczany wskaźnik do oddzielnej tablicy znaków (tablicy napisów) pozycji pierwszego znaku Ieksemu. Działanie według schematu pośredniego z rys. 7.32(b) pozwala na to, aby rozmiar pola nazwy we wpisie do tablicy symboli był stały. Należy przechowywać całkowity leksem tworzący nazwę, aby każde użycie tej sa mej nazwy mogło być związane z tym samym rekordem w tablicy symboli. Trzeba jednak rozróżniać wystąpienia tego samego Ieksemu w różnych zakresach widzialności dla różnych deklaracji. Informacja o przydziale pamięci Informacja o lokacjach pamięci, które będą przypisane nazwom w trakcie działania, jest przechowywana w tablicy symboli. Rozważmy najpierw nazwy przypisane do pamięci statycznej. Jeśli kod wynikowy jest w asemblerze, zajmowanie się adresami pamięci dla różnych nazw można pozostawić temu językowi. Wszystko, co trzeba zrobić — to, po wygenerowaniu kodu w asemblerze dla programu — przeszukać tablicę symboli i dodać do programu definicje wygenerowane w asemblerze dla każdej nazwy. Jeśli jednak kod maszynowy m a być wygenerowany przez kompilator, to pozycja każdego obiektu danych względem pewnego stałego adresu, jak początek rekordu ak tywacji, musi być ustalona. Ta sama uwaga dotyczy bloku danych załadowanego jako moduł oddzielny od programu. Przykładowo, bloki COMMON w Fortranie są ładowane oddzielnie i pozycje nazw względem początku bloku COMMON, w którym się znajdują, muszą być wyznaczone. Z powodów, które omówiliśmy w p. 7.9, podejście z p. 7.3 nale ży tak zmodyfikować dla Fortranu, aby przesunięcia dla nazw były przydzielone dopiero po wczytaniu wszystkich deklaracji dla procedury i przetworzeniu wszystkich instrukcji
EQUIVALENCE.
Nazwa
(a) W przestrzeni o stałym rozmiarze wewnątrz rekordu
s
c . 1
1
i l i i
o
z
1
r
1 .
1
i
i
t
u
1
i l y i i
1
1
i
1
1
i a i
i i l i i
t
i
i
i
t
1
1
Atrybuty
. 1 i i
1 i
1
i
t
i
1 i
l i
i 1 i b i
i
i
1 a
1
1
1 i
1 i i
1 1 i
Nazwa
Atrybuty
(b) W oddzielnej tablicy Rys. 7.32. Przechowywanie znaków tworzących nazwy
W przypadku nazw, dla których pamięć jest rezerwowana na stosie lub stercie, kompilator nie rezerwuje pamięci wcale, a jedynie rozplanowuje rekord aktywacji dla wszystkich procedur, jak w p . 7.3. Listowe struktury danych dla tablic symboli Najprostszym i najłatwiejszym sposobem implementacji struktury danych dla tablicy sym boli jest lista rekordów, przedstawiona na rys. 7.33. Używać można pojedynczej tablicy, lub równoważnie kilku tablic, do przechowywania nazw i związanych z nimi informacji. Nowe nazwy są dodawane do listy w kolejności ich wczytywania. Pozycja końca tablicy jest zaznaczana wskaźnikiem dostępne, wskazującym miejsce, w które zostanie wpisany rekord dla nowej nazwy. Wyszukiwanie nazwy odbywa się od końca tablicy do począt ku. Gdy nazwa zostaje znaleziona, informację z nią związaną można znaleźć w miejscu występującym bezpośrednio po tej nazwie. Jeśli zostanie osiągnięty początek tablicy bez znalezienia nazwy, ponosi się porażkę — szukana nazwa nie znajduje się w tablicy. Zauważmy, że tworzenie wpisu dla nazwy i wyszukiwanie nazwy z tablicy sym boli są operacjami niezależnymi — możemy potrzebować wykonać tylko jedną z nich. W języku o strukturze blokowej nazwa pojawia się w zakresie widzialności najbliżej zagnieżdżonej deklaracji dla tej nazwy. Tę regułę widzialności można zaimplementować
przy użyciu struktury listowej, tworząc nowy wpis za każdym razem, gdy nazwa jest deklarowana. Nowy wpis jest wykonywany w miejscu wskazywanym przez wskaźnik do stępne, a wskaźnik ten jest zwiększany o rozmiar rekordu w tablicy symboli. Ponieważ wpisy są dodawane po kolei, zaczynając od początku tablicy, ich kolejność jest taka, w ja kiej były tworzone. Przeszukując tablicę w kierunku od dostępne do początku tablicy, ma się pewność znalezienia wpisu ostatnio utworzonego.
id,
id
2
info
2
dostępne Rys. 7.33. Lista rekordów
Jeśli tablica symboli zawiera n nazw, praca potrzebna na dodanie nowej nazwy jest stała, jeśli wstawianie jest wykonywane bez sprawdzania, czy nazwa znajduje się już w tablicy. Jeśli istnienie kilku wpisów dla nazw jest niedozwolone, należy to sprawdzić, przeglądając całą tablicę; praca ta jest proporcjonalna do n. Aby znaleźć dane dla nazwy należy sprawdzić średnio n/2 nazw, więc koszt takiego zapytania jest również proporcjo nalny do n. Zatem, skoro wstawienia i zapytania mają złożoność czasową proporcjonalną do n, to całkowita złożoność wstawienia n nazw i wykonania e zapytań wynosi c o naj wyżej cn(n + e), gdzie c jest stałą reprezentującą czas poświęcony na wykonanie kilku operacji maszynowych. W programie średniego rozmiaru, n i e mogą wynosić odpo wiednio 100 i 1000, więc kilkaset tysięcy operacji maszynowych jest wykonywanych przez operacje zarządzania tablicą symboli. To jeszcze nie jest bolesne, ponieważ może oznaczać mniej niż sekundę czasu, jeżeli jednak n i e zostaną przemnożone przez 10, koszt wzrośnie stukrotnie i czas operacji na tablicy symboli stanie się zbyt duży. Użycie programu profilującego może dostarczyć wartościowych danych o tym, czym zajmuje się kompilator, i czy przypadkiem zbyt dużo czasu nie zużywa na przeszukiwanie list.
Tablice mieszające Istnieje technika przeszukiwania zwana mieszaniem (ang. hashing), która jest zaimple mentowana w wielu kompilatorach. Teraz rozważymy jej prosty wariant, zwany miesza niem otwartym, gdzie „otwarte" oznacza, że nie ma ograniczenia ilości wpisów, które
można dodać do tablicy. Zastosowanie mieszania otwartego daje możliwość wykonania e zapytań na n nazwach w czasie proporcjonalnym do n(n + e)/m dla dowolnie wybranej stałej m. Ponieważ m może być dowolnie duże, aż do n, ta technika jest ogólnie bardziej wydajna niż lista i jest wybierana najczęściej dla tablicy symboli. Jak można się spo dziewać, ilość pamięci zajętej przez strukturę danych zwiększa się wraz z m, należy więc wybrać między złożonością pamięciową a czasową. Podstawowy schemat mieszania przedstawiono na rys. 7.34. Struktura danych składa się z dwóch części: 9
1. 2.
Tablicy mieszającej, będącej stałej długości tablicą wskaźników do poszczególnych wpisów. Wpisy są zorganizowane w m oddzielnych listach jednokierunkowych, zwanych ku bełkami (niektóre kubełki mogą być puste). Każdy rekord w tablicy symboli znaj duje się na dokładnie jednej z tych list. Pamięć dla rekordów można pobierać z tablicy rekordów, co omówiliśmy w następnym podrozdziale. Możliwe jest rów nież użycie metod rezerwacji dynamicznej pamięci z języka implementacji kompila tora do uzyskiwania miejsca dla rekordów, jednak często powoduje to zmniejszenie wydajności.
Tablica nagłówków list indeksowanych wartością funkcji mieszającej 0
Elementy list utworzone dla pokazanych nazw cp
9|
| -|
|
n f i
-1
1
I
sprawdź 20 ost 32
akcja »
ws -
210
Rys. 7.34. Tablica mieszająca o rozmiarze 211
Do wyznaczenia, czy w tablicy symboli istnieje wpis dla napisu s, do s należy zastosować funkcję mieszającą h taką, że h(s) zwraca liczbę całkowitą z zakresu od 0 do m— 1. Jeśli s znajduje się w talicy symboli, to na liście jest numer h(s). Jeśli s nie znajduje się jeszcze w tablicy symboli, jest dodawane przez utworzenie rekordu dla s, który jest dołączany na początek listy o numerze h(s).
Jeśli w tablicy o długości m znajduje się n wpisów, to pojedyncza lista m a średnio n/m wpisów. Wybierając takie m, że n/m jest ograniczone niewielką stałą, na przykład 2, czas dostępu do wpisu w tablicy jest prawie stały. Pamięć zajęta przez tablicę symboli składa się z m słów tablicy mieszającej oraz cn słów wpisów do tablicy, gdzie c jest liczbą słów w pojedynczym wpisie. Zatem pamięć dla tablicy mieszającej zależy jedynie od m, a pramięć na wpisy zależy jedynie od liczby wpisów. Wybór wartości m zależy od przewidywanego zastosowania tablicy symboli. Wy branie m równego kilkaset powinno spowodować, że czas przeszukiwania tablicy symboli będzie mało znaczący w stosunku do całkowitego czasu kompilacji, nawet dla progra mów o pokaźnych rozmiarach. Jednak, jeśli wejście dla kompilatora może być generowane przez inny program, to liczba nazw w programie może znacznie przekroczyć liczbę nazw w programach o tym samym rozmiarze, i wtedy przydatne mogą być tablice o większych rozmiarach. Dużo uwagi poświęcono problemowi zaprojektowania łatwo obliczalnej funkcji mie szającej dla napisów i rozdzielającej równomiernie napisy na m list. Wygodnym sposobem obliczania funkcji mieszającej jest zastosowanie następują cych kroków: 1.
2.
Wyznaczyć dodatnią liczbę całkowitą h ze znaków c c , c w napisie s. Przekształcenie pojedynczego znaku na liczbę jest zwykle możliwe bezpośrednio w języku implementacji. Pascal dostarcza do tego celu funkcję ord, natomiast C automatycznie przekształca znaki na liczby całkowite, jeśli wykonywane są na nich operacje arytmetyczne. Przekształcić liczbę całkowitą h, wyznaczoną powyżej, na numer listy, czyli na liczbę całkowitą od 0 do m — 1. Proste podzielenie h przez m i wzięcie reszty jest wystarczające. Wzięcie reszty działa najlepiej, jeśli m jest liczbą pierwszą, stąd wybór 211 zamiast 2 0 0 na rys. 7.34. v
2
k
Funkcje mieszające uwzględniające wszystkie znaki napisu są trudniejsze do oszu kania niż te, które sprawdzają jedynie kilka znaków na krańcach lub w środku napisu. Pamiętajmy, że jeśli wejście kompilatora zostało wygenerowane przez program, to może mieć stylizowaną postać w celu uniknięcia konfliktów z nazwami, które mogą zostać użyte przez człowieka lub inny program. Ludzie również często dążą do „grupowania nazw", jak np. b a z , n o w y b a z , b a z i . Prostym sposobem obliczania h jest dodanie wartości całkowitych poszczególnych znaków napisu. Lepszym pomysłem jest przemnożenie starej wartości h — przed doda niem następnego znaku — przez stałą a , czyli h = 0, h = OLh _ + c , dla 1 ^ i ^ k, natomiast h — h , gdzie k jest długością napisu. (Przypomnijmy, że wartość mieszająca dająca numer listy jest obliczana jako h m o d m). Proste dodawanie wartości znaków jest przypadkiem a = 1. Zamiast dodawania można także zastosować różnicę symetryczną między c a ah _ Dla liczb całkowitych 32-bitowych, jeśli zostanie wybrane OL = 65599, czyli licz ba pierwsza równa w przybliżeniu 2 , to podczas obliczania cch^ szybko pojawi się przepełnienie. Ponieważ a jest liczbą pierwszą, ignorowanie przepełnienia i trzymanie jedynie dolnych 32 bitów wydaje się być słuszne. Q
k
i
t
v
1 6
t
t
x
y
P. J. Weinberger stworzył kompilator języka C, za pomocą którego przeprowadzono eksperyment oceniający działanie funkcji. Najlepsza — dla wszystkich rozmiarów tablic (patrz rys. 7.36) — okazała się funkcja mieszająca hashpjw z rys. 7.35. Zbiór rozmiarów składał się z pierwszych liczb pierwszych większych niż 100, 200, . . . , 1500. Niewiele gorsza była funkcja, która obliczała h przez przemnożenie starej wartości przez 65599, ignorując przepełnienia i dodając następny znak. Funkcję hashpjw oblicza się, startując od h = 0. Dla każdego znaku c należy przesunąć bity h o cztery pozycje w lewo i dodać c. Jeśli jakikolwiek z czterech najbardziej znaczących bitów h jest 1, trzeba przesunąć te cztery bity w prawo o 24 pozycje, dodać j e przy użyciu operacji różnicy symetrycznej do h i wyzerować cztery najbardziej znaczące bity. (1) (2) (3) (4)
#define PIERWSZA 211 łdefine EOS '\0' int hashpjw (s) char *s;
(5) { (6) (7)
(8) (9)
char *p; unsigned h = 0, g; for ( p = s; *p != EOS; p - p+1 ) { h = (h « 4) + (*p) ;
(10)
if (g = h&0xf0000000) {
(11)
h = h h = h
(12) (13)
A
A
(g » g;
24) ;
}
(14)
}
(15)
return h % PIERWSZA
(16) } Rys. 7.35. Funkcja mieszająca hashpjw zapisana w C
P r z y k ł a d 7.10. Najlepsze wyniki uzyskuje się wtedy, gdy już podczas projektowania funkcji mieszającej uwzględnia się rozmiar tablicy mieszającej i przewidywane wejście. Przykładowo, wartości mieszające dla nazw najczęściej pojawiających się w języku po winny być różne. Jeśli słowa kluczowe są wprowadzone do tablicy symboli, to mogą się znajdować pośród najczęściej używanych nazw, chociaż w jednym z przykładowych programów w C, nazwa i pojawiała się ponad 3 razy, tak często j a k w h i l e . Jednym ze sposobów testowania funkcji mieszającej jest ocena napisów, które trafiają na tę samą listę. Dla danego pliku F , składającego się z n napisów, załóżmy że bj napisów trafia na listę y, dla 0 ^ j ^ m— 1. Miarę określającą równomierność rozłożenia napisów między listy można otrzymać ze wzoru m-l
L»A
+ l)/2
(7.2)
Intuicyjne uzasadnienie tego wyrażenia jest następujące: aby znaleźć pierwszy wpis na liście y, należy spojrzeć na 1 element listy, aby znaleźć drugi — na 2, i tak dalej, aż d o bj, by znaleźć ostatni wpis. Sumą ł, 2 , . . . , bj jest bj(bj + l ) / 2 .
Z ćwiczenia 7.14 wartością obliczaną według (7.2) dla funkcji, która losowo roz mieszcza napisy między kubełkami, jest (n/2m)(n + 2 m - l )
(7.3)
Na rysunku 7.36 przedstawiono stosunek wyrażeń (7.2) do (7.3) dla kilku funkcji mieszających zastosowanych do dziewięciu plików. Plikami tymi były: 1. 2. 3. 4. 5. 6. 7. 8. 9.
50 najczęściej pojawiających się nazw i słów kluczowych w próbce programów w C. Jak 1., ale dla 100 najczęściej pojawiających się nazw i słów kluczowych. Jak 1., ale dla 500 najczęściej pojawiających się nazw i słów kluczowych. 952 zewnętrzne nazwy w jądrze systemu operacyjnego UNIX. 627 nazw w programie w C wygenerowanym przez C++ (Stroustrup [1986]). 915 losowo wygenerowanych napisów. 614 słów z p . 3.1 tej książki. 1201 słów angielskich z dodanymi prefiksami i sufiksami x x x . 300 nazw v l 0 0 , v i 0 1 , . . . , v 3 9 9 .
hashpjw
x 65599 czwórki
xl6
X5
x2
środek
xl
krańce
METODY MIESZANIA
Wydrukowano numery plików Rys. 7.36. Względna wydajność funkcji mieszających dla tablicy o rozmiarze 211 Funkcja hashpjw jest taka, jak na rys. 7.35. Funkcje nazywane x a , gdzie a jest stałą całkowitą, obliczają h m o d m, gdzie h jest otrzymywane iteracyjnie, zaczynając od 0, mnożąc starą wartość przez a i dodając do niej następny znak. Funkcja środek tworzy h ze środkowych czterech znaków napisu, natomiast końce, w celu obliczenia h, dodaje pierwsze trzy i ostatnie trzy znaki do długości. Funkcja czwórki natomiast grupuje każde cztery kolejne znaki w liczbę całkowitą i następnie j e dodaje. •
Reprezentacja informacji o widzialności Wpisy w tablicy symboli odpowiadają deklaracjom nazw. Gdy nazwa, która wystąpiła w pliku źródłowym, jest wyszukiwana w tablicy symboli, to wpis dla odpowiedniej deklaracji dla tej nazwy musi być zwrócony. Reguły widzialności języka źródłowego wyznaczają, która deklaracja jest właściwa. Prostym podejściem jest utrzymywanie oddzielnej tablicy symboli dla każdego za kresu widzialności. W efekcie, tablica symboli dla procedury lub zakresu widzialności jest odpowiednikiem z czasu kompilacji dla rekordu aktywacji. Informacja dla zmiennych nielokalnych procedury jest znajdowana przez przeszukiwanie — według reguł widzial ności języka — tablic symboli dla procedur ją zawierających. Równoważnie, informa cja o zmiennych lokalnych procedury może zostać dołączona do węzła dla procedury w drzewie składniowym dla programu. Przy tym podejściu tablica symboli jest włączona w reprezentację pośrednią wejścia. Regułę najbliższego zagnieżdżenia można zaimplementować przez przystosowanie struktur danych, przedstawionych już w tym podrozdziale. Nazwom lokalnym procedur są przyznawane unikalne liczby. Jeśli język ma strukturę blokową, to bloki są również numerowane. Numer każdej procedury można obliczyć metodą sterowaną składnią z re guł semantycznych rozpoznających początek i koniec każdej procedury. Numer procedury staje się częścią numerów wszystkich nazw lokalnych. Reprezentacją nazwy lokalnej w ta blicy symboli jest para składająca się z numeru nazwy i numeru procedury. (W niektórych projektach, jakie omówiliśmy poniżej, numer procedury nie musi w rzeczywistości wy stępować, ponieważ może być ona wyliczona na podstawie pozycji rekordu w tablicy symboli). Jeżeli wyszukiwana jest nowo wczytana nazwa, to zostanie znaleziona jedynie wte dy, gdy znaki nazwy pasują do znaków wpisu i związany numer w tablicy symboli jest numerem przetwarzanej procedury. Reguły najbliższego zagnieżdżenia mogą zostać zaimplementowane przy użyciu następujących operacji na nazwach: szukaj: wstaw: skasuj:
znajdź ostatnio utworzony wpis utwórz nowy wpis usuń ostatnio utworzony wpis
„Skasowane" wpisy muszą zostać zachowane i są usuwane jedynie z aktywnej tabli cy symboli. W kompilatorze jednoprzebiegowym informacja w tablicy symboli na temat widzialności wewnątrz, na przykład treści procedury, nie jest potrzebna w czasie kompila cji po przetworzeniu treści tej procedury. Jednak w czasie działania te informacje mogą być potrzebne, jeśli jest zaimplementowany jakiś system diagnostyki działania. Wtedy informacja z tablicy symboli musi zostać dodana do wygenerowanego kodu w celu wy korzystania przez konsolidator lub przez system diagnostyki działania. (Zobacz również traktowanie nazw pól w rekordach w p. 8.2 i 8.3). Każda struktura danych omówiona w tym podrozdziale — lista i tablica mieszająca — może być wykorzystywana do powyższych operacji. Opisując listę składającą się z tablicy rekordów, wspominaliśmy, jak można za implementować operację szukaj przez wstawianie wpisów na jednym krańcu, tak aby kolejność wpisów w tablicy była taka sama, jak kolejność wstawiania wpisów. Przeglą danie zaczynające się na końcu, w kierunku początku, znajduje ostatnio dodany wpis
dla nazwy. Podobnie jest z listą jednokierunkową, co pokazano na rys. 7.37. Wskaźnik przód wskazuje ostatnio dodany wpis na listę. Implementacja wstaw zajmuje stały czas, ponieważ nowy wpis jest wstawiany na początek listy. Implementacja szukaj polega na przejrzeniu listy, rozpoczynając od wpisu wskazywanego przez przód i przechodzeniu po dowiązaniach, aż do znalezienia poszukiwanej nazwy, lub do osiągnięcia końca listy. Na rysunku 7.37 wpis dla a zadeklarowany w bloku B , zagnieżdżonym w bloku B , występuje bliżej początku listy niż wpis dla a zadeklarowany w bloku B . 2
Q
0
przód
Rys. 7.37. Ostatni wpis dla a znajduje się bliżej początku listy
Zauważmy, dla operacji skasuj, że wpisy dla deklaracji w procedurze zagnieżdżonej najgłębiej znajdują się na początku listy. Trzymanie numeru procedury z każdym wpisem nie jest zatem potrzebne — j e ś l i wiadomo, który wpis jest pierwszy dla danej procedury, to wszystkie wpisy, aż do tego, mogą być usunięte z aktywnej tablicy symboli, gdy kończy się przetwarzanie zakresu widzialności dla tej procedury. Tablica mieszająca składa się z m list, do których dostęp odbywa się za pomocą tablicy. Ponieważ dana nazwa zawsze jest umieszczana na tej samej liście, oddzielny mi listami można zarządzać, jak na rys. 7.37. Do implementacji operacji skasuj nie trzeba jednak przeszukiwać całej tablicy mieszającej, szukając list zawierających wpisy do skasowania. Można użyć następującego podejścia. Załóżmy, że każdy wpis ma dwa wiązania: 1)
wiązanie mieszające, które dowiązuje wpis do innych wpisów, dla których funkcja
2)
mieszająca zwraca tę samą wartość, wiązanie widzialności, które związuje wszystkie wpisy w tej samej widzialności.
Jeśli wiązanie widzialności zostanie pozostawione bez zmian po usunięciu wpisu z tablicy mieszającej, to łańcuch utworzony za pomocą wiązań widzialności tworzy oddzielną (nieaktywną) tablicę symboli dla konkretnego zakresu widzialności. Usuwanie wpisów z tablicy mieszającej należy wykonywać ostrożnie, ponieważ od działuje ono na wpis poprzedzający na zawierającej go liście. Przypomnijmy, że usuwanie i-tego wpisu polega na przypisaniu wiązaniu dla (i— l ) - g o wpisu wskaźnika ( ( + l)-szego. Używanie tylko wiązań widzialności do znalezienia i-tego wpisu jest zatem niewystarcza jące. Wpis (i — l)-szy można znaleźć, jeśli wiązania mieszające formują cykliczną listę jednokierunkową, w której ostatni wpis wskazuje pierwszy. Innym sposobem jest użycie stosu do śledzenia list zawierających elementy do usunięcia. Znacznik jest umieszczany na stosie, gdy jest wczytywana nowa procedura. Nad znacznikiem znajdują się numery list zawierających wpisy dla nazw zadeklarowanych w tej procedurze. Po zakończeniu przetwarzania procedury, numery list — aż do znacznika — można usunąć ze stosu. Inny schemat omówiliśmy w ćwiczeniu 7.11.
7.7
Mechanizmy dostarczane przez język, służące do dynamicznej rezerwacji pamięci
W tym podrozdziale w zwięzły sposób opisaliśmy środki dostarczane przez niektóre języ ki, służące do dynamicznej rezerwacji pamięci dla danych pod kontrolą programu. Pamięć ta jest zwykle pobierana ze sterty. Miejsce zarezerwowane na dane jest często trzymane aż do jawnego zwolnienia. Rezerwacja pamięci może być albo jawna, albo niejawna. W Pascalu, na przykład, rezerwacja jawna jest dokonywana za pomocą standardowej procedury n e w . Wywołanie n e w ( p ) rezerwuje pamięć dla typu obiektu wskazywanego przez p i p jest przypisywany wskaźnik nowo zarezerwowanego obiektu. W większości implementacji Pascala zwolnienie pamięci jest wykonywane przez wywołanie d i s p o s e . Rezerwacja niejawna występuje przy obliczaniu wyników wyrażeń w pamięci prze znaczonej do trzymania wartości wyrażenia. Przykładowo, Lisp przy użyciu c o n s rezer wuje węzeł na liście, a węzły — do których nie ma żadnego dostępu — są automatycznie odzyskiwane. Snobol pozwala, aby długość napisów zmieniała się w trakcie działania i wówczas zarządza pamięcią potrzebną do trzymania na stercie napisów.
P r z y k ł a d 7.11. Program w Pascalu z rysunku 7.38 tworzy listę jednokierunkową przed stawioną na rys. 7.39 i drukuje liczby całkowite trzymane w jej węzłach. Jego wynikiem jest 76 4 7
3 2 1
Gdy wykonanie programu rozpoczyna się w wierszu 15, pamięć dla wskaźnika p o c z znajduje się w rekordzie aktywacji całego programu. Za każdym razem, gdy sterowanie wykonuje (11)
new(p);
pt.klucz
:= k;
pt.info
i;
wywołanie n e w ( p ) powoduje zarezerwowanie węzła gdzieś na stercie; pT oznacza ten węzeł w przypisaniach w wierszu 11. Z wyniku programu wynika, że zarezerwowane węzły są dostępne, gdy sterowanie powraca do programu głównego z w s t a w . Innymi słowy, węzły rezerwowane za pomocą n e w podczas aktywacji w s t a w są przechowywane, gdy sterowanie powraca z aktywacji do programu głównego. •
D a n e bezużyteczne Dostęp do pamięci zarezerwowanej dynamicznie może zostać utracony. Pamięć, którą program rezerwuje, ale nie może się do niej odwołać, jest bezużyteczna (po angielsku jest nazywana garbage). Załóżmy że między wierszami 16 i 17 na rys. 7.38 znajduje się przypisanie n i l do p o c z t . n a s t (16) (17)
wstaw ( 7 , 1 ) ; wstaw ( 4 , 2 ) ; wstaw (76,3) ; p o c z t . n a s t := n i l ; writełn (poczt.klucz, poczt, info) ;
(1)
program
(2)
type
(3)
tabela(input,output);
link
=
wezel
t
wezel;
=
record
(4)
klucz,
(5)
nast
(6)
info
:
link
:
integer);
end;
(7)
var
(8)
procedurę
pocz
(9)
:
link;
w s t a w (k,
var
(10)
p
:
i
link;
begin
(11)
new (p) ;
pT. k l u c z
:=
(12)
pt.nast
:=
pocz
(13) (14)
integer;
:
pocz;
k;
pt. inf o :=
:=
i ;
p
end; begin
(15)
pocz
(16)
wstaw(7,l);
(17)
writełn(poczt.klucz,
(18)
writełn(poczt.nastt.klucz,
(19)
:=
nil; wstaw(4,2);
wstaw(76,3);
poczt.info); poczt.nastt.info);
writełn(poczt.nastt.nastt.klucz, poczt.nastt.nastt.info)
(20)
end.
Rys. 7.38.
Rezerwacja dynamiczna w ę z ł ó w w Pascalu przy użyciu n e w
pocz
76
nil
Rys. 7 . 3 9 . Lista j e d n o k i e r u n k o w a u t w o r z o n a p r z e z p r o g r a m z rys. 7 . 3 8
Skrajnie lewy węzeł z rys. 7.39 będzie teraz zawierał wskaźnik n i l zamiast wskaźnika środkowego węzła. Gdy wskaźnik środkowego węzła jest tracony, to środkowy i prawy węzeł stają się danymi bezużytecznymi. Lisp wykonuje odzyskiwanie danych bezużytecznych (ang. garbage collectioń), czyli proces omówiony w następnym podrozdziale, który odzyskuje niedostępną pamięć. Pascal i C nie mają możliwości odzyskiwania danych bezużytecznych i to sam program zwalnia niepotrzebną pamięć. W tych językach zwalniana pamięć może być użyta ponownie, ale utracone dane pozostają, aż program się zakończy.
Referencje wiszące Dodatkowe komplikacje pojawiają się przy jawnym zwalnianiu pamięci, ponieważ mogą wystąpić referencje wiszące. Jak wspomnieliśmy w p. 7.3, referencja wisząca pojawia się w chwili odwołania do zwolnionej pamięci. Rozważmy, na przykład, efekt wywołania d i s p o s e ( p o c z t . n a s t ) między wierszami 16 i 17 z rys. 7.38
(16)
wstaw(7,l); wstaw(4,2); wstaw(76,3); dispose(poczt.nast); w r i t e ł n (poczt. klucz, poczt, info) ;
(17)
Wywołanie d i s p o s e zwalnia węzeł następny po wskazywanym przez p o c z , jak po kazano na rys. 7.40. Jednak p o c z t . n a s t nie zostało zmienione, więc jest wiszącym wskaźnikiem zwolnionej pamięci. Referencje wiszące i dane bezużyteczne są pojęciami zależnymi od siebie: refe rencje wiszące pojawiają się, jeżeli zwolnienie pamięci występuje przed zakończeniem korzystania z ostatniej referencji, natomiast dane bezużyteczne występują, jeżeli ostatnia referencja jest tracona przed zwolnieniem pamięci.
pocz
(a) Przed
1
76
nil
pocz
(b)Po
76
3
i i
nil
Rys. 7.40. Utworzenie referencji wiszących i danych bezużytecznych
7.8
Techniki dynamicznej rezerwacji pamięci
Techniki potrzebne do implementacji dynamicznej rezerwacji pamięci zależą od tego, jak pamięć jest zwalniana. Jeśli zwalnianie jest niejawne, biblioteka wspomagania prze twarzania jest odpowiedzialna za wyznaczenie, czy blok pamięci nie jest j u ż więcej niepotrzebny. Jeśli zwalnianie jest wykonywane jawnie przez programistę, to kompilator ma mniej pracy. Na początku rozważymy zwalnianie jawne. Jawna rezerwacja bloków o stałym rozmiarze Najprostsza postać dynamicznej rezerwacji pamięci dotyczy bloków o stałym rozmiarze. Wskutek połączenia bloków w listę, jak na rys. 7.41, rezerwacja i zwalnianie mogą być wykonywane szybko przy małej zajętości pamięci lub nawet bez niej. Załóżmy, że bloki są pobierane z ciągłego obszaru pamięci. Inicjowania tego ob szaru dokonuje się przy użyciu fragmentu bloku jako wiązania do następnego bloku. Wskaźnik dostępne wskazuje pierwszy blok. Rezerwacja polega na wzięciu bloku z listy, a zwolnienie na zwrócenie go na listę. Procedury kompilatora, które zarządzają blokami, nie muszą mieć danych o typie obiektów, które będą trzymane w bloku przez program użytkownika. Każdy blok można traktować jak rekord z wariantami: dla procedur kompilatora stanowiący blok ze wskaź nikiem następnego bloku, a dla programu użytkownika — blok innego typu. Pamięć nie
dostępne
(a)
dostępne
P - H ?
0>)
Rys. 7.41. Zwolniony blok jest dodawany do listy wolnych bloków
jest zatem zajęta, ponieważ program użytkownika może używać całego bloku dla swoich potrzeb. Gdy blok jest zwracany, procedury kompilatora używają części pamięci z bloku do dowiązania go do listy wolnych bloków, jak pokazano na rys. 7.41.
Jawna rezerwacja bloków o dowolnym rozmiarze Gdy bloki są rezerwowane i zwalniane, pamięć może być fragmentaryczna, czyli sterta może składać się z naprzemiennych bloków wolnych i używanych, jak na rys. 7.42.
Wolny
Rys. 7.42. Wolne i używane bloki na stercie
Sytuacja przedstawiona na rysunku 7.42 może wystąpić, gdy program zarezerwuje, na przykład, pięć bloków i następnie zwolni drugi i czwarty. Fragmentacja nie zależy od tego, czy bloki mają stały, czy zmienny rozmiar. Jeśli mają dowolny rozmiar (rys. 7.42), nie można zarezerwować bloku większego niż jakikolwiek z wolnych bloków, nawet gdy pamięć jest ogólnie dostępna. Jedna z metod rezerwacji bloków o dowolnej wielkości jest zwana metodą pierwsze go odpowiedniego. Gdy blok o rozmiarze s jest rezerwowany, poszukiwany jest pierwszy blok o rozmiarze / ^ s, który jest następnie dzielony na blok używany o rozmiarze s i blok wolny o rozmiarze f — s. Zauważmy, że rezerwowanie pamięci zajmuje czas potrzebny na wyszukiwanie wolnego bloku o dostatecznym rozmiarze. Gdy blok jest zwalniany, sprawdza się, czy następny blok jest wolny. Jeśli to możli we, zwalniany blok jest łączony z wolnym blokiem w celu stworzenia większego wolnego bloku i niepowodowania dalszej fragmentacji. Istnieje wiele subtelnych detali rezerwowa nia, zwalniania i utrzymywania wolnych bloków na liście lub listach bloków dostępnych. Między złożonością czasową a pamięciową, i również dostępnością dużych bloków, musi być przyjęty kompromis. Te kwestie dokładnie omówili Knuth [1973a] oraz A h o , Hopcroft i Ullman [1983].
Niejawne zwalnianie pamięci Niejawne zwalnianie pamięci wymaga współdziałania programu użytkownika i biblioteki wspomagania przetwarzania, ponieważ ta ostatnia musi wiedzieć, kiedy blok nie jest już używany. Ta współpraca jest implementowana przez ustalenie formatu bloków pamięci. Załóżmy, że format bloku pamięci jest taki, jak na rys. 7.43.
Opcjonalny rozmiar bloku Opcjonalny licznik odwołań Opcjonalny znacznik Wskaźniki bloków
Informacje użytkownika
Rys. 7.43. Format bloku pamięci Pierwszym problemem jest rozpoznanie granic bloku. Jeśli rozmiar bloku jest usta lony, to można użyć informacji o pozycji. Przykładowo, jeśli blok zajmuje 20 słów, to nowy blok zaczyna się co 20 słów. W przeciwnym przypadku, w niedostępnym obszarze dołączonym do bloku jest przechowywany rozmiar bloku, tak aby można było wyznaczyć początek następnego bloku. Drugim problemem jest rozpoznanie, czy blok jest w użyciu. Załóżmy, że blok jest w użyciu, jeśli program użytkownika może się odwoływać do informacji w bloku. Od wołanie może istnieć przez wskaźnik lub przez sekwencję wskaźników, więc kompilator musi znać pozycję w pamięci wszystkich wskaźników. Zgodnie z formatem z rysun ku 7.43, wskaźniki są trzymane na stałych pozycjach w bloku. Być może ważniejszym założeniem jest, że obszar informacji użytkownika w bloku nie zawiera żadnych wskaź ników. Do niejawnego zwalniania pamięci można zastosować jedno z dwóch podejść. Po niżej znajduje się ich pobieżny opis; dokładniejsze omówienie jest u Aho, Hopcrofta i Ullmana [1983]. 1.
Liczniki odwołań. Należy śledzić liczbę bloków, które bezpośrednio wskazują ak tualny blok. Jeśli licznik wskazuje 0, to blok ten można zwolnić, ponieważ nie da się do niego odwołać. Innymi słowy, blok staje się obszarem bezużytecznym, który można odzyskać. Utrzymywanie liczników odwołań może być kosztowne. Przypi sanie wskaźników p : = q prowadzi do zmian w licznikach odwołań dla bloków wskazywanych przez oba wskaźniki p i q. Licznik dla bloku wskazywanego przez p zmniejsza się o jeden, a dla bloku wskazywanego przez p zwiększa o jeden. Liczniki odwołań są najlepsze, jeśli wskaźniki między blokami nie mogą tworzyć cykli. Na rysunku 7.44, na przykład, żaden z bloków nie jest osiągalny z zewnątrz, więc oba są bezużyteczne, ale każdy ma licznik odwołań równy jeden.
2.
Techniki zamaczania. Alternatywne podejście polega na tymczasowym zatrzymaniu wykonywania programu użytkownika i użyciu zamrożonych wskaźników do wyzna czenia, które bloki są w użyciu. Podejście to wymaga, aby wszystkie wskaźniki na stercie były znane. Intuicyjnie, metoda odpowiada „wlaniu farby" przez wskaźniki na stertę. Każdy blok osiągalny przez farbę jest w użyciu i resztę można zwolnić. Do kładniej, metoda ta polega n a przejściu po stercie i zaznaczeniu wszystkich bloków jako nieużywane. Przechodząc następnie p o wszystkich wskaźnikach, każdy blok, do którego się dotrze, jest zaznaczany jako używany. W końcowym sekwencyjnym przejściu p o stercie są usuwane wszystkie bloki wciąż zaznaczone jako nieużywane.
Rys. 7.44. Bezużyteczne bloki z niezerowym licznikiem odwołań
W przypadku bloków o zmiennym rozmiarze jest jeszcze inna możliwość przesuwa nia używanych bloków pamięci z ich obecnych pozycji . Ten proces, zwany upakowaniem (ang. compaction), przesuwa wszystkie używane bloki n a jeden kraniec sterty, tak, że cała wolna pamięć może zostać zebrana w jeden wielki blok. Upakowanie wymaga również informacji o wskaźnikach wewnątrz bloków, ponieważ — gdy używany blok jest prze mieszczany — wszystkie wskaźniki do niego muszą zostać skorygowane i odzwierciedlać przesunięcie. Jej zaletą jest to, że po takim procesie fragmentacja dostępnej pamięci jest wyeliminowana. 1
7.9
Przydział pamięci w Fortranie
Fortran, j a k już wspomnieliśmy w podrozdziale 7.3, został tak zaprojektowany, aby umoż liwiał statyczny przydział pamięci. Istnieją jednak pewne kwestie, takie j a k sposób trak towania deklaracji COMMON i EQUIVALENCE, które są specyficzne dla Fortranu. Kom pilator Fortranu może utworzyć wiele obszarów danych, czyli bloków pamięci, w których wartości obiektów mogą być przechowywane. W Fortranie istnieje jeden obszar danych dla każdej procedury i p o jednym obszarze danych dla każdego nazwanego wspólnego bloku (COMMON) oraz jeden dla pustego bloku wspólnego, jeśli został użyty. Tablica symboli dla każdej nazwy musi rejestrować obszar danych, d o którego ona przynależy, czyli jej pozycję względem początku obszaru. Kompilator musi ostatecznie zdecydować, gdzie obszary danych mają się znaleźć w stosunku do wykonywalnego kodu i d o siebie, ale wybór ten jest arbitralny, ponieważ obszary danych są niezależne. Kompilator musi obliczyć rozmiary każdego obszaru danych. Dla obszarów danych dla procedur wystarcza pojedynczy licznik, ponieważ ich rozmiary są znane p o przetwo rzeniu każdej procedury. Dla każdego bloku COMMON, podczas przetwarzania wszyst1
Można to zrobić także z blokami o stałym rozmiarze, ale nie ma to żadnych zalet.
kich procedur musi być trzymany rekord, ponieważ każda procedura używająca bloku ma swoją własną informację o rozmiarze bloku. Właściwy rozmiar bloku COMMON jest rozmiarem największego bloku o takiej nazwie spośród łączonych kawałków kodu. Dla każdego obszaru danych kompilator tworzy mapę pamięci, która jest opisem zawartości obszaru. Ta „mapa pamięci" może po prostu składać się ze wskazania (we wpisie w tablicy symboli dla każdej nazwy w tym obszarze) przesunięcia względem p o czątku obszaru. Nie potrzeba prostej odpowiedzi na pytanie: „jakie są wszystkie nazwy w tym obszarze danych?". Jednak, w Fortranie znana jest odpowiedź na pytanie o obszary danych dla procedur, ponieważ wszystkie nazwy zadeklarowane w procedurze, które nie są wspólne (z bloku COMMON) albo równoważne nazwie wspólnej, znajdują się w obsza rze danych procedury. Nazwy wspólne mogą mieć wpisy w tablicy symboli powiązane w łańcuchy, po jednym na każdy blok COMMON, w kolejności ich występowania w bloku. W rzeczywistości, jako że przesunięcia nazw w obszarze danych nie zawsze mogą być wyznaczone przed przetworzeniem całej procedury (tablice w Fortranie zawsze muszą być zadeklarowane przed deklaracją ich wymiarów), potrzebne jest, aby te ciągi nazw wspólnych zostały utworzone. Program w Fortranie składa się z programu głównego, podprogramów i funkcji (wszystkie j e będziemy nazywać procedurami). Każde wystąpienie nazwy m a widzialność składającą się tylko z jednej procedury. D l a każdej procedury można wygenerować kod wynikowy po dotarciu do końca tej procedury. Jeśli tak się uczyni, możliwe jest, że większość informacji z tablicy symboli zostanie usunięta. Wystarczy zachować jedynie te nazwy, które są zewnętrzne dla procedury właśnie przetworzonej. Są to nazwy innych procedur i pochodzące z bloków wspólnych. Nazwy te naprawdę mogą nie być zewnętrzne dla całego kompilowanego programu, ale muszą zostać zachowane, aż d o przetworzenia wszystkich procedur.
Dane w obszarach COMMON Dla każdego bloku tworzy się rekord zawierający pierwszą i ostatnią nazwę dla aktualnej procedury, które zostały zadeklarowane w bloku COMMON. Przy przetwarzaniu deklaracji o postaci
COMMON /BLOKI/ NAZWA1, NAZWA2 kompilator musi wykonać następujące czynności: 1.
W tablicy dla nazw bloków COMMON utworzyć rekord dla bloku BLOKI, jeśli taki jeszcze nie istnieje. 2. We wpisach dla NAZWA1 i NAZWA2 ustawić wskaźnik tablicy symboli na wpis dla BLOKI w celu zaznaczenia, że są to nazwy wspólne i pochodzą z BLOKI. 3a. Jeśli właśnie teraz został utworzony rekord dla BLOKI, ustawić wskaźnik w tym rekordzie n a wpis w tablicy symboli dla NAZWA1, który zaznacza pierwszą nazwę w tym bloku COMMON. Następnie, dowiązać wpis w tablicy symboli dla NAZWA1 do wpisu dla NAZWA2, wykorzystując pole w tablicy symboli zarezerwowane d o łączenia członków tego samego bloku COMMON. W końcu, ustawić wskaźnik w re kordzie dla BLOKI do wpisu w tablicy symboli dla NAZWA2, oznaczający, że jest ona ostatnim członkiem tego bloku.
3b. Jeśli, jednak, nie jest to pierwsza deklaracja w BLOKI, po prostu dołączyć NAZWA1 i NAZWA2 na koniec listy nazw dla BLOKI. Wskaźnik końca listy dla BLOKI, znajdujący się w rekordzie dla BLOKI, jest również aktualizowany. Po przetworzeniu procedury jest stosowany algorytm równoważnościowy, który omó wiliśmy poniżej. Może się okazać, że niektóre dodatkowe nazwy należą do bloku COMMON, ponieważ są zadeklarowane jako równoważne nazwom, które znajdują się w bloku COMMON. Powinniśmy zauważyć, że nazwy XYZ nie trzeba wiązać do łańcucha bloku COMMON. W tablicy symboli ustawiany jest bit we wpisie dla XYZ oznaczający, że XYZ zostało zadeklarowane jako równoważne czemuś innemu. Struktura danych, która zostanie omówiona, będzie zawierała pozycję XYZ względem nazwy, która w rzeczywi stości znajduje się w bloku COMMON. Po wykonaniu operacji równoważności można utworzyć mapę pamięci dla każdego bloku COMMON, przeglądając listy nazw dla tego bloku. Po zainicjowaniu licznika na 0, każdej nazwie na liście należy przypisać jej przesunięcie równe aktualnej wartości licznika. Następnie do licznika należy dodać liczbę jednostek pamięci zajętych przez obiekt danych oznaczony tą nazwą. Wtedy można usunąć rekordy dla bloków COMMON i wykorzystać pamięć przez następną procedurę. Jeśli nazwa XYZ w COMMON jest zadeklarowana jako równoważna nazwie nie znajdu jącej się w bloku COMMON, należy wyznaczyć maksymalne przesunięcie względem po czątku XYZ dla każdego słowa pamięci potrzebnego dla jakiejkolwiek nazwy równoważ nej XYZ. Przykładowo, jeśli XYZ jest liczbą rzeczywistą i jest zadeklarowane jako rów noważne A ( 5 , 5 ) , gdzie A jest tablicą liczb rzeczywistych o wymiarach 10 x 10, to w A (1,1) występują 4 4 słowa przed XYZ, a w A (10,10) — 55 słów po XYZ, j a k na rys. 7.45. Istnienie A nie wpływa na licznik w bloku wspólnym, jest on jedynie zwięk szany o jedno słowo, gdy rozważane jest XYZ, niezależnie z czym jest ono równoważne. Jednak koniec obszaru danych dla bloku COMMON musi być dostatecznie daleko, aby zmieściła się cała tablica A. Zapamiętywane jest więc największe przesunięcie względem początku bloku COMMON dla słów używanych przez nazwy przyrównane do elementów tego bloku. N a rysunku 7.45 wielkość ta musi być co najmniej równa wartości przesu nięcia XYZ plus 55. Należy również sprawdzić, czy tablica A nie znajduje się z przodu, przed początkiem obszaru danych. Zatem przesunięcie XYZ musi wynosić co najmniej 44. W przeciwnym przypadku pojawi się błąd i kompilator zgłosi komunikat diagnostyczny.
A(l,l)
Obszar danych dla bloku
XYZ
A(5,5)
COMMON
A(10,10) Rys. 7.45. Relacja między instrukcjami COMMON i EQUIVALENCE
Prosty algorytm równoważności Pierwsze algorytmy przetwarzające instrukcje równoważności pojawiły się w asem blerach, nie w kompilatorach. Ponieważ te algorytmy mogą być bardzo złożone, szcze-
golnie gdy rozważane są wzajemne oddziaływania między instrukcjami COMMON i EQUIVALENCE, rozważmy najpierw sytuację typową dla asemblera, gdzie jedyne in strukcje EQUIVALENCE mają postać EQUIVALENCE A, V>+prze sunięcie gdzie A i B są nazwami lokacji. Taka instrukcja powoduje, że A oznacza lokację, która znajduje się przesunięcie jednostek pamięci za lokacją B. Sekwencja instrukcji EQUIVALENCE grupuje nazwy w zbiory równoważności, w któ rych pozycje jednych nazw względem innych są zdefiniowane instrukcjami EQUIVALENCE Na przykład, sekwencja instrukcji
EQUIVALENCE EQUIVALENCE EQUIVALENCE EQUIVALENCE
A, C, A, E,
B+100 D-4 0 C+4 0 F
grupuje nazwy w zbiory {A, B, C, D} i {E, F}, gdzie E i F oznaczają tę samą lokację. C znajduje się 70 lokacji za B, A — 30 za C, a D — 10 za A. W celu obliczenia zbiorów równoważności należy stworzyć drzewo dla każdego z nich. Każdy węzeł drzewa reprezentuje nazwę i zawiera przesunięcie tej nazwy wzglę dem nazwy w rodzicu tego węzła. Nazwę w korzeniu drzewa nazywamy liderem. Pozycja każdej nazwy względem lidera może być obliczona przez przejście ścieżki dostępu z wę zła dla tej nazwy i dodanie przesunięcia w trakcie przechodzenia.
P r z y k ł a d 7.12. Wspomniany już zbiór równoważności {A, B, C, D} można przed stawić za pomocą drzewa z rys. 7.46, D jest liderem. Można odczytać, że A znaj duje się 10 pozycji przed D, ponieważ suma przesunięć na ścieżce z A do D wynosi 100+(-110) = -10. •
D
Rys. 7.46. Drzewo reprezentujące zbiór równoważności
Podamy algorytm do konstrukcji drzew dla zbiorów równoważności. Istotnymi po lami we wpisach w tablicy symboli są: 1) 2)
rodzic, wskazujący wpis w tablicy symboli dla rodzica (jeśli nazwa jest korzeniem, to jest równy zeru), przesunięcie nazwy względem nazwy rodzica.
W poniższym algorytmie zakłada się, że każda nazwa może być liderem zbioru równo ważności. W asemblerze tylko jedna nazwa w zbiorze będzie miała rzeczywistą lokację
zdefiniowaną przez pseudooperację i ta nazwa zostanie wybrana na lidera. Wierzymy, że Czytelnik wie, w jaki sposób można zmodyfikować algorytm, aby pewna szczególna nazwa została liderem. Algorytm 7.1.
Konstrukcja drzew równoważności.
Wejście. Lista instrukcji definiujących równoważność o postaci EQUIVALENCE A, B+odl Wyjście. Kolekcja drzew, takich że dla każdej nazwy z wejścia wspomnianej na liście równoważności, przez przejście p o ścieżce od tej nazwy d o korzenia i zsumowaniu na niej przesunięć, można wyznaczyć pozycję nazwy względem lidera. Metoda. Powtarzamy kroki z rysunku 7.47 p o kolei dla każdej instrukcji EQUIVALENCE A, B+odl. Uzasadnienie przypisania z wiersza (12), służącego do obliczania przesunię cia lidera dla A względem lidera B, jest następujące. Niech l będzie lokacją A, równą c - h m , gdzie m jest lokacją lidera dla A. Niech l , d oraz m będą analogicznymi wartościami dla B. Ale l = l + odl, więc c + m = d + m + odl. Zatem m — m jest równe d~c + dist. begin A
A
A
B
A
B
A
B
B
(1)
niech p i q wskazują odpowiednio w ę z ł y dla A i B;
(2)
c : = 0; d : = 0;
A
B
/ * c i d służą d o obliczania przesunięć A i B
w z g l ę d e m liderów z ich zbiorów * /
(3) (4) (5)
while rodzic(p) ^ nuli do begin c :— c+przesunięcie(p); p := rodzic(p)
(6) (7) (8)
while rodzic(q) / nuli do begin d := d + przesunięcie(q)\ q := rodzic(q)
end;
/ * przejdź z p d o lidera d l a a, s u m u j ą c p r z e s u n i ę c i a * /
end;
(9) (10)
/ * to s a m o dla B * /
if p — q then / * A i B znajdują if c-d + odl then błąd;
się j u ż w t y m samym zbiorze * /
/ * podano d w a różne położenia względne dla A i B * /
else begin / * połącz zbiory dla A i B * / rodzic(p) := q / * uczyfi lidera A d z i e c k i e m przesunięcie(p) := d — c + odl end
(11)
(12)
lidera B * /
end Rys. 7.47. A l g o r y t m konstrukcji d r z e w r ó w n o w a ż n o ś c i
P r z y k ł a d 7.13.
Jeśli przetworzymy
EQUIVALENCE EQUIVALENCE
A, B+l0 0 C, D-40
otrzymamy konfigurację j a k na rys. 7.46, ale bez przesunięcia - 1 1 0 w węźle dla B i bez wiązania z B d o D. Następnie, po przetworzeniu
EQUIVALENCE
A, C+30
p będzie wskazywało B po pętli while z wiersza (3), a ą będzie wskazywało D po pętli while z wiersza (6). Otrzymamy również c = 100 id — —40. Następnie, w wierszu (11), D stanie się rodzicem dla B , a pole przesunięcie dla B otrzyma wartość 110, która pochodzi od ( - 4 0 ) - (100) + 30. • 2
Algorytm 7.1 może działać w czasie proporcjonalnym do n dla n deklaracji rów noważności, ponieważ w najgorszym przypadku ścieżki w pętlach z wierszy (3) i (6) mogą zawierać wszystkie węzły odpowiednich drzew. Równoważenie wymaga jedynie krótkiego czasu kompilacji, więc dopuszczalne jest n kroków; algorytm bardziej skom plikowany niż ten z rys. 7.47 nie ma właściwie uzasadnienia. Jednak algorytm 7.1 można zmienić dwoma prostymi sposobami i czas działania będzie prawie liniowy względem liczby równoważności, które przetwarza. Chociaż jest mało prawdopodobne, że zbiory równoważności będą dostatecznie duże, aby te poprawki musiały być implementowane, warto jest zauważyć, że znajdowanie zbiorów równoważności odpowiada wielu procesom dotyczącym „łączenia zbiorów". Przykładowo, wiele wydajnych algorytmów z analizy przepływów danych bazuje na szybkich algorytmach równoważnościowych. Zaintereso wanego Czytelnika odsyłamy do literatury omówionej na końcu rozdz. 10. Pierwszym ulepszeniem jest trzymanie licznika dla każdego lidera zawierającego liczbę węzłów w drzewie. Następnie, w wierszach (11) i (12), zamiast arbitralnego dołą czenia lidera A do lidera B , należy połączyć drzewo z mniejszą liczbą węzłów z drzewem z większą liczbą. Powoduje to, że drzewo rośnie wszerz, więc ścieżki będą krótkie. Jako ćwiczenie pozostawiamy uzasadnienie, że dla n równoważności przetworzonych w ten sposób nie utworzą się ścieżki dłuższe niż \og n. Drugi pomysł jest znany jako kompresja ścieżek. Gdy przechodzi się po ścieżce do korzenia w pętlach z wierszy (3) i (6), wszystkim natrafionym węzłom należy ustawić korzeń jako rodzica, jeśli jeszcze tak nie jest. Czyli, w trakcie przechodzenia po ścieżce należy zapamiętać wszystkie węzły n n , . . . , n , gdzie n jest węzłem dla A lub B , a n jest liderem. Następnie należy zaktualizować przesunięcia i uczynić n , n , . . . » n _ dziećmi n , wykonując fragment programu z rys. 7.48. 2
2
v
2
k
x
k
x
2
k
2
k
begin h:— przesunięcie(n _ )\ for i\—k~2 downto 1 do begin rodzicin^ :~n \ h h + przesunięcie^^; przesunięcie^^ := h end end k
x
k
Rys. 7.48. Uaktualnienie przesunięć
Algorytm równoważności dla Fortranu Istnieje kilka dodatkowych możliwości, które należy dodać do algorytmu 7.1, aby mógł być wykorzystywany w Fortranie. Po pierwsze, należy określić, czy zbiór równoważności znajduje się w bloku COMMON, co można wykonać, rejestrując w każdym liderze, czy jakakolwiek nazwa z jego zbioru znajduje się w bloku wspólnym, a jeśli tak, to w jakim.
Po drugie, w asemblerze, jeden z elementów zbioru równoważności, będąc etykietą instrukcji, zwiąże cały zbiór z konkretnym położeniem w pamięci. Umożliwi zatem, aby adresy wszystkich nazw ze zbioru były obliczane względem tej jednej lokacji. W Fortranie jednak zadaniem kompilatora jest wyznaczenie lokacji pamięci, w taki sposób, aby zbiór równoważności — nie będący w bloku wspólnym — był widziany jako „ruchomy", aż kompilator wyznaczy pozycję całego zbioru we właściwym swoim obszarze danych. Aby wykonać to poprawnie, kompilator musi znać rozmiar zbioru równoważności, czyli liczbę lokacji, które zostaną zajęte na nazwy ze zbioru równoważności. W tym celu d o lidera dołącza się d w a pola dolny i górny, zawierające przesunięcia względem lidera lokacji będącej najmniejszą i największą z lokacji zajętych przez elementy zbioru. Po trzecie, niewielkie problemy pojawiają się, gdy nazwy mogą oznaczać tablice, a jako równoważne są deklarowane lokacje w innych tablicach. Ponieważ z każdym liderem muszą być związane trzy pola (dolny, górny i wskaź nik bloku COMMON), nie trzeba rezerwować miejsca na te pola we wszystkich wpisach w tablicy symboli. Jedną z możliwości jest wykorzystanie pola rodzic z algorytmu 7 . 1 , jeżeli węzeł jest liderem na wskaźnik nowej tablicy z trzema polami dolny, górny i blokcom. W związku z tym, że ta tablica i tablica symboli zajmują odrębne obszary, łatwo można ocenić, do której tablicy ten wskaźnik prowadzi. Inną możliwością jest użycie pojedynczego bitu oznaczającego, czy nazwa jest obecnie liderem. Jeśli pamięć jest rze czywiście cenna, można zastosować alternatywny algorytm bez dodatkowej tabeli, ale trochę trudniejszy d o programowania. Algorytm ten jest omówiony w ćwiczeniach. Rozważmy obliczenia, które wymagają zmiany wierszy (11) i (12) z rys. 7.47. Na rysunku 7.49(a) przedstawiliśmy sytuację, w której należy złączyć d w a zbiory równoważ ności, których liderzy są wskazywani przez p i q. Struktura danych reprezentująca dwa zbiory znajduje się na rys. 7.49(b). Przede wszystkim należy sprawdzić, czy w obu zbio rach nie m a dwóch nazw, które znajdują się blokach COMMON. Nawet, jeśli obie nazwy pochodzą z tego samego bloku wspólnego, to standard Fortranu tego zabrania. Jeśli jakiś blok COMMON zawiera element jednego ze zbiorów równoważności, to połączony zbiór ma wskaźnik tego bloku w blokcom. Kod wykonujący to sprawdzenie, przy założeniu, że lider wskazywany przez ą staje się liderem połączonego zbioru, znajduje się na rys. 7.50. W wierszach (11) i (12) z rys. 7.47 należy również obliczyć wielkość połączonego zbioru równoważności. Na rysunku 7.49(a) podano wzory d o obliczania nowych wartości dolny i górny względem lidera wskazywanego przez q. Należy zatem wykonać begin dolny(rodzic(q)) '.— m\n(dolny(rodzic(q)), dolny(rodzic(p)) —c-hodl-i-d)', górny(rodzic(q)) := max(górny(rodzic(q)), górny(rodzic(p)) — c + odl -f- d) end Te instrukcje powinny znajdować się przed wierszami (11) i (12) na rys. 7,47, aby mogły powodować scalenie obu zbiorów równoważności. Pozostają dwa ostatnie detale, które należy uwzględnić, aby algorytm 7.1 mógł być użyty w Fortranie, w którym — jako równoważne — można deklarować pozycje ze środka tablicy z innymi pozycjami z e środka tablicy lub z prostymi nazwami. Przesunięcie tablicy A względem jej lidera oznacza przesunięcie pierwszej lokacji tablicy A względem pierwszej lokacji lidera. Jeśli pozycja, j a k A ( 5 , 7 ) , jest deklarowana jako równoważna
dolny
Lokacja lidera wskazywana przez p
1
górny
1
Lokacja a
dolny odl
Lokacja b
górny
__ł dolny
Lokacja lidera wskazywana przez ą
2
dolny
= mm(dolny
2 , dolny
górny
= max(górny
2 , górny
górny
2
1 - c + odl + d) 1- c + o < # + */)
(a) Względne pozycje zbiorów równoważności
•i-
dolny
1
górny
1
blokcom
ć/o/wy 2 górwy 2 1
blokcom
2
(b) Struktura danych Rys. 7.49. Łączenie zbiorów równoważności
begin blokcoml := blokcom(rodzic(p)); blokcom! \— blokcom(rodzic(q))\ if blokcoml / nuli and blokcom! ^ nuli then Węd; / * dwie nazwy z bloków COMMON są równoważne * / if blokcom! = nuli then blokcom(rodzic(q)) '.— blokcoml; end Rys. 7.50. Obliczanie bloków COMMON
B ( 2 0 ) , należy obliczyć pozycję A ( 5 , 7) względem A ( l , 1) i ustawić c na wartość przeciwną do tej odległości w wierszu (2) z rys. 7 . 4 7 . Podobnie należy postąpić z d — należy zainicjować na wartość przeciwną do pozycji B (2 0) względem B ( 1 ) . Formuły z podrozdziału 8.3 wraz z wiedzą na temat rozmiarów elementów tablic A i B wystarczają do obliczenia początkowych wartości c i d.
Ostatnim detalem, który należy uwzględnić, jest możliwość deklaracji w Fortranie równoważności jednocześnie między wieloma lokacjami, j a k w EQUIVALENCE
(A(5,7), B ( 2 0 ) ,
C,
D(4,5,6))
Powyższą równoważność można traktować jako
EQUIVALENCE (B(20), A(5,7)) EQUIVALENCE (C, A(5,7)) EQUIVALENCE (D(4,5,6), A(5,7)) Zauważmy, że jeśli równoważności są wykonywane w tej kolejności, jedynie A staje się liderem zbioru więcej niż jednoelementowego. Rekord zawierający pola dolny, górny i blokcom może być używany wielokrotnie dla „zbiorów równoważności" z pojedynczymi nazwami. Przydzielanie pamięci n a obszary
danych
Poniżej opisaliśmy sposób, w jaki jest przydzielana przestrzeń dla różnych obszarów danych dla wszystkich nazw procedur. 1.
Dla każdego bloku COMMON odwiedź wszystkie nazwy zadeklarowane w t y m bloku w kolejności ich deklaracji (wykorzystaj ciągi nazw z bloków COMMON utworzo ne w tablicy symboli w tym celu). Zarezerwuj liczbę słów pamięci wymaganą dla każdej nazwy, przechowując liczbę słów zarezerwowanych, tak, aby było możliwe obliczenie przesunięć dla każdej nazwy. Jeśli nazwa A jest zadeklarowana jako rów noważna, zasięg równoważności nie m a znaczenia, ale trzeba sprawdzić, czy wartość dolny dla lidera nie znajduje się poza początkiem bloku COMMON. Na podstawie war tości górny dla lidera można zmniejszyć ograniczenie na ostatnie słowo w bloku. Dokładne wyprowadzenie pozostawiamy czytelnikowi.
2.
Odwiedź wszystkie nazwy dla procedury w dowolnej kolejności. a) Jeśli nazwa znajduje się w bloku COMMON, nic nie rób. Pamięć została zarezer wowana w p. 1. b) Jeśli nazwa nie znajduje się w COMMON i nie została zadeklarowana jako rów noważna, zarezerwuj potrzebną liczbę słów pamięci w obszarze danych dla procedury. c) Jeśli nazwa A jest zadeklarowana jako równoważna, znajdź jej lidera L. Jeśli L przydzielono j u ż pozycję w obszarze danych dla procedury, oblicz pozycję A, dodając do niej wszystkie przesunięcia znalezione n a ścieżce dostępu od A do L w drzewie reprezentującym zbiór równoważności A i L. Jeśli L nie otrzymało jeszcze pozycji, zarezerwuj kolejnych górny — dolny słów w obszarze danych dla tego zbioru równoważności. Pozycja L w zarezerwowanym obszarze jest równa —dolny słów od początku obszaru, natomiast pozycję A można obliczyć, podobnie j a k wcześniej, sumując przesunięcia.
ĆWICZENIA 7.1 Używając reguł widzialności dla Pascala, wyznacz deklaracje właściwe dla każ dego wystąpienia nazw a i b na rys. 7.51. Wyjście programu składa się z liczb 1 do 4. program a(input, output); procedurę b(u, v, x, y: integer); var a : record a, b : integer end; b : record b, a : integer end; begin with a do begin a :- u; b := v end; with b do begin a := x; b := y end; writeln(a.a, a.b, b.a, b.b) end; begin b(l, 2, 3, 4) end. Rys. 7.51. Program w Pascalu z kilkoma deklaracjami a i b 7.2 Rozważmy język o strukturze blokowej, w którym nazwę można zadeklarować jako całkowitą bądź rzeczywistą. Przyjmijmy, że wyrażenia są reprezentowane przez terminal w y r i że jedynymi instrukcjami są przypisania, instrukcje warunkowe, while i sekwencje instrukcji. Przyjmując, że dla liczby całkowitej rezerwowane jest jedno słowo, a dla liczby rzeczywistej — dwa, podaj algorytm sterowany składnią (oparty na rozsądnej gramatyce dla deklaracji i bloków), wyznaczający wiązania z nazw do słów, które mogą być używane przez aktywację bloku. Czy Twoja strategia rezerwacji używa najmniejszej liczby słów dla każdego możliwego wykonania bloku? *7.3 W podrozdziale 7.4 stwierdziliśmy, że tablica display może być poprawnie utrzymy wana, jeśli każda procedura na głębokości i zapamiętuje d[i] na początku aktywacji i odtwarza d[i] na końcu. Udowodnij przez indukcję po liczbie wywołań, że każda procedura „widzi" poprawną tablicę display. 7.4 Makroinstrukcja jest rodzajem procedury, implementowanej przez tekstowe pod stawienie treści w miejscu wywołania. Na rysunku 7.52 przedstawiliśmy program w języku Pic oraz j e g o wyjście. Pierwsze dwa wiersze definiują makroinstrukcje s h o w i s m a l i . Treść tych makroinstrukcji jest zawarta między parą znaków % w tych wierszach. Każdy z czterech okręgów na rysunku jest kreślony z użyciem s h o w ; promień okręgu jest podawany przez nielokalną nazwę r . Bloki w Pic są oddzielane przez [ i ]. Każda zmienna, do której następuje przypisanie w blo ku, jest w nim niejawnie deklarowana. Patrząc na wyjście programu, co możesz powiedzieć o zakresie każdego z wystąpień r ? 7.5 Napisz procedurę wstawiającą element danych na listę po przekazaniu wskaźnika początku tej listy. Przy jakich sposobach przekazywania parametrów ta procedura działa? 7.6 Co wypisuje program z rys. 7.53, jeżeli założymy: (a) przekazywanie przez war tość, (b) przekazywanie przez referencję, (c) skopiowanie-przywrócenie, (d) prze kazywanie przez nazwę?
cłefine show % { circle radius r at Here } % define smali % [ r = 1/12; show ] % [
1 = 1/6; show; smali; move; show; smali; ]
Rys. 7.52. Okręgi narysowane przez program w języku Pic
7.7 Gdy procedura jest przekazywana jako parametr w języku o widzialności statycz nej, jej nielokalne środowisko może zostać przekazane z użyciem wiązania dostępu. Podaj algorytm wyznaczający to wiązanie.
program main(input,
output)',
procedurę p(x, y, z)\ begin y:=y+l; z:=z + x\ end; begin a '.— 2;
b:=3; p(a + b, a, a);
print a end. Rys. 7.53. Pseudoprogram ilustrujący przekazywanie parametrów
7.8 Program w Pascalu z rysunku 7.54 przedstawia trzy rodzaje środowisk, które mo gą być związane z procedurą przekazywaną jako parametr. Środowiska leksykalne, przekazywane i aktywacji dla takiej procedury składają się z wiązań identyfikato rów w punkcie, w którym procedura jest, odpowiednio, definiowana, przekazywana jako parametr i aktywowana. Rozpatrzmy funkcję f, przekazywaną jako parametr w wierszu 11. Gdy używamy środowiska leksykalnego, przekazywanego i aktywacji dla f, nielokalne m z wiersza 8 jest w zakresie deklaracji m z, odpowiednio, wierszy 6, 10 i 3.
(1)
program param(input, output) ;
(2) (3) (4)
procedurę b(function h(n: integer): integer); var m : integer; begin m := 3 ; writełn (h ( 2 ) ) end { b };
(5) (6)
procedurę c; var m : integer;
(7) (8)
function f(n : integer) : integer; begin f := m + n end { f };
(9) (10) (11) (12) (13) (14) (15)
procedurę r; var m : integer; begin m := 7 ; b(f) end { r }; begin m := 0; r end { c }; begin c end. Rys. 7.54. Przykład środowisk leksykalnych, przekazywanych i aktywacji
a) Narysuj drzewo aktywacji dla tego programu. b) Co jest wyjściem programu, gdy dla f używamy środowiska leksykalnego, przekazywanego i aktywacji? *c) Zmień implementację korzystającą z tablicy display języka o widzialności leksy kalnej, tak by język poprawnie ustawiał środowisko leksykalne, gdy aktywowana jest procedura przekazywana jako parametr. Instrukcja / : = a z wiersza 11 pseudoprogramu z rys. 7.55 wywołuje funkcję
program ret(input, output); var / : function (integer): integer;
(3) (4) (5) (6) (7)
function a : function (integer): integer; v a r m : integer; function addm(n : integer): integer; begin return m + n end; begin m := 0; return addm end;
(8) (9)
procedurę b(g : function (integer) : integer); begin writeln(g(2)) end;
(10) (11) (12)
begin f:=a; end.
b(f)
Rys. 7.55. Pseudoprogram, w którym jako wynik jest zwracana funkcja addm
*7.10 Pewne języki, takie jak Lisp, w trakcie wykonywania programu mogą zwracać no wo utworzone procedury. Wszystkie funkcje z rysunku 7.56, zdefiniowane w kodzie źródłowym i tworzone w czasie wykonywania programu, pobierają co najwyżej j e den argument i zwracają jedną wartość, funkcję albo liczbę rzeczywistą. Operator o oznacza złożenie funkcji, tj. (f°g)(x) = f(g(x)). a) Jaka wartość jest wypisywana przez main? *b) Przypuśćmy, że za każdym razem, gdy tworzona i zwracana jest procedura /?, jej rekord aktywacji staje się dzieckiem rekordu aktywacji funkcji zwracającej p. Przekazywane środowisko p może być wówczas utrzymywane przez pamiętanie drzewa zamiast stosu dla rekordów aktywacji. Jak wygląda drzewo rekordów aktywacji, gdy a jest obliczane przez main (rys. 7.56)? *c) Przyjmijmy, że rekord aktywacji dla p jest tworzony, gdy p jest aktywowana, i staje się on dzieckiem rekordu aktywacji procedury wywołującej p. Podej ście takie może być używane do utrzymywania środowiska aktywacji dla p. Narysuj stan rekordów aktywacji i ich związków rodzic-dziecko w trakcie wy konywania instrukcji z main. Czy przy takim podejściu stos jest wystarczający do przechowywania rekordów aktywacji?
function f(x: function); var y: function; y :—xoh\ I* w trakcie wykonywania tworzy */ return y end { / }; function h()\ return sin end { h }; function g(z: function); var w: function; w := aretanoz', /* w trakcie wykonywania tworzy */ return w end { g } ; function main()\ var a: real; u, v; function; v :=/(*);
«:=K); a — u(nj!)\ print a end { main } ; Rys. 7.56. Pseudoprogram tworzący funkcje w czasie wykonywania 7.11 Innym podejściem do obsługi usuwania z tablic mieszających nazw, których zakres został opuszczony (tak jak w p . 7.6), jest pozostawienie przedawnionych nazw na liście, aż d o ponownego przeszukania tej listy. Przyjmując, że wpisy zawierają nazwę procedury, w której znajduje się deklaracja, możemy w zasadzie stwierdzić, czy nazwa jest stara, i jeśli tak, to ją skasować. Podaj schemat indeksowania pro-
cedur, który pozwala w czasie 0 ( 1 ) stwierdzić, czy procedura jest „stara", tzn. czy jej zakres został opuszczony. 7.12 Wiele funkcji mieszających można scharakteryzować ciągiem stałych całkowitych, OQ, a ^ . . . Jeśli c , 1 ^ i ^ n, jest liczbą całkowitą /-tego znaku w napisie s, to napis ten jest mieszany t
gdzie m jest rozmiarem tablicy mieszającej. W każdym z poniższych przypadków wyznacz sekwencję stałych 0^, a . . . albo pokaż, że sekwencja taka nie istnieje. Każdy przypadek wyznacza liczbę całkowitą; wartość funkcji mieszającej otrzy mujemy, biorąc tę wartość modulo m. p
a) Oblicz sumę znaków. b) Oblicz sumę pierwszego i ostatniego znaku. c) Oblicz h , gdzie h = O i h = 2/z _ + c . n
0
i
/
]
t
d) Potraktuj bity w środkowych 4 znakach jako 32-bitową liczbę całkowitą. e) Na 32 bitową liczbę całkowitą można patrzeć jak na składającą się z 4 bajtów, gdzie każdy bajt jest cyfrą, która może przyjąć jedną z 256 wartości. Zaczy nając od 0000, dla 1 ^ i $J n, dodawaj c do bajtu i m o d 4, z dozwolonymi przeniesieniami. Oznacza to, że c i c są dodawane do bajtu 1, c i c do bajtu 2 itd. Zwróć ostateczną wartość. t
x
5
2
6
*7.13 Czemu funkcje mieszające, opisane sekwencją liczb całkowitych OQ, a ,... — tak jak w ćwiczeniu 7.12, czasem zachowują się źle, gdy wejście składa się z kolejnych tekstów, np. v0 00, v001, . . . ? Charakterystyczne jest to, że gdzieś po drodze ich zachowanie przestaje być losowe i można j e przewidzieć. **7.14 Gdy n napisów jest mieszane w m list, średnia liczba napisów na liście to n/m, niezależnie od tego, jak nierównomiernie napisy są rozłożone. Przypuśćmy, że d jest „rozkładem", tj. losowy ciąg jest umieszczany na i-tej liście z prawdopodo bieństwem d(i). Przyjmijmy, że funkcja mieszająca o rozkładzie d umieszcza blosowo wybranych napisów na liście j , 0 ^ j ^ m— 1. Wykaż, że wartość oczekix
m-l
wana W = £ (bMb; + l ) / 2 jest liniowo związana z wariancją rozkładu d, oraz 7=0
że dla rozkładu jednostajnego wartością oczekiwaną W jest (n/2m)(n
+ 2m - 1).
7.15 Załóżmy, że mamy daną sekwencję definicji z programu w Fortranie
SUBROUTINE SUB(X,Y) INTEGER A, B(20), C(10,15), D, E COMPLEX F, G COMMON /CBLK/ D,E EQUIVALENCE (G, B(2)) EQUIVALENCE (D, F, B(l)) Pokaż zawartość obszarów danych dla SUB i CBLK (przynajmniej części obszaru CBLK dostępnej z SUB). Dlaczego nie ma miejsca dla X i Y? *7.16 Wygodną strukturą danych dla obliczeń równoważności jest struktura pierście niowa. Używamy jednego wskaźnika i pola przesunięcia w każdym z wpisów
w tablicy symboli d o połączenia elementów zbioru równoważności. Struktura taka jest naszkicowana na rys. 7.57, gdzie A, B, C i D są równoważne oraz E i F są równoważne, z lokacją B będącą 20 słów po A itd. a) Podaj algorytm obliczający przesunięcie X względem Y, przyjmując, że X i Y są w tej samej klasie równoważności. b) Podaj algorytm obliczający dolne i górne, zdefiniowane w p. 7.9, względem lokacji o nazwie Z . c) Podaj algorytm przetwarzający
EQUIVALENCE U, V Nie przyjmuj założenia, że U i V muszą należeć d o różnych klas równoważności.
A: 20
B:
-30
C:
40
D:
-30
E: 10
10
Rys. 7.57. Struktury p i e r ś c i e n i o w e
*7.17 Algorytm z podrozdziału 7.9, odwzorowujący obszary danych, wymaga sprawdze nia, czy dolny dla lidera zbioru równoważności dla A nie powoduje, że obszar pamięci dla tego zbioru nie rozszerza się poza początek bloku COMMON oraz ob liczenia górny w celu zwiększenia — w razie potrzeby — górnego ograniczenia bloku COMMON. Podaj równania zależne od następny, przesunięcia A w bloku COMMON, oraz ostatni, czyli ostatniego słowa w bloku, służące do sprawdzenia i uaktualnienia — w razie potrzeby — ostatni.
UWAGI
BIBLIOGRAFICZNE
Stosy odegrały istotną rolę w implementacji funkcji rekurencyjnych. McCarthy [1981, s. 178] wspomina, że podczas implementowania języka Lisp, rozpoczętego w 1958 ro ku, zdecydowano się „użyć jednej ciągłej publicznej tablicy stosowej do zapamiętywania wartości zmiennych i adresów powrotów z procedur w implementacji procedur rekuren cyjnych". Włączenie bloków i procedur rekurencyjnych d o Algola 60 — Naur [1981, p. 2.10] podaje dokładny opis takiego projektu — również stymulowało rozwój rezerwa cji stosowej. Pomysł wykorzystania tablicy display do dostępu d o zmiennych nielokalnych w języku o widzialności leksykalnej pochodzi od Dijkstry [1960, 1963]. Chociaż Lisp używa widzialności dynamicznej, możliwe jest osiągnięcie efektu widzialności leksykal nej przy użyciu argumentów funkcyjnych (funargs), składających się z funkcji i wiązania dostępu; McCarthy [1981] opisał rozwój tego mechanizmu. W następcach Lispu, j a k Common Lisp (Steele [1984]) zrezygnowano z widzialności dynamicznej. Wyjaśnienia wiązania nazw można znaleźć w podręcznikach języków programo wania, na przykład Abelsona i Sussmana [1985], Pratta [1984] lub Tennenta [1981], Alternatywnym podejściem, sugerowanym w rozdz. 2, jest czytanie opisu kompilatora. Kernighan i Pike [1984] rozwinęli krok po kroku kalkulator dla wyrażeń arytmetycznych, otrzymując interpreter dla prostego języka z procedurami rekurencyjnymi. Interesujący jest również kod Pascala-S (Wirth [1981]). Szczegółowy opis rezerwacji stosowej, używa-
nia tablicy display i dynamicznej rezerwacji tablic można znaleźć u Randella i Russella [1964]. Johnson i Ritchie [1981] opisali projekt sekwencji wywołującej, która pozwala poda wać różną liczbę argumentów w różnych wywołaniach procedury. Ogólną metodą nada wania wartości globalnej tablicy display jest śledzenie łańcucha wiązań dostępu i w trak cie tego ustawianie elementów tablicy display. Podejście z podrozdziału 7.4, w którym dotykamy tylko jednego elementu, wydaje się być „dobrze znane" od pewnego czasu (Rohl [1975]). Moses [1970] opisuje różnicę między środowiskami, które są widoczne, gdy funkcja jest przekazywana jako argument; rozważa także problemy powstające, gdy środowiska takie są implementowane z użyciem płytkiego i głębokiego dostępu. Rezer wacji stosowej nie można używać w językach ze współprogramami lub wielowątkowych. Lampson [1982] rozważa szybkie implementacje korzystające z rezerwacji stertowej. Logiką matematyczną, a ściślej zmiennymi kwantyfikowanymi o ograniczonym za kresie oraz podstawieniami zajął się Frege w BegrifFschrift [1879]. Podstawienia i prze kazywanie argumentów były tematem wielu dyskusji zarówno w środowisku logików matematycznych, jak i twórców języków programowania. Church [1956, s. 288] zauwa ża: „Szczególnie trudny jest problem właściwego zdefiniowania reguły podstawień dla zmiennych funkcyjnych" i opisuje tworzenie takiej reguły dla rachunku zdań. Rachu nek lambda Churcha [1941] był stosowany do środowisk w językach programowania, na przykład przez Landina [1964]. Para składająca się z funkcji i wiązania dostępu często, za Landinem [1964], jest nazywana domknięciem. Struktury danych dla tablic symboli i algorytmy do ich przeszukiwania są szczegóło wo opisane przez Knutha [1973b] oraz Aho, Hopcrofta i Ullmana [1974, 1983]. Wiedzę dotyczącą mieszania przedstawili Knuth [1973b] i Morris [1968b]. Pierwszy tekst opi sujący mieszanie należy do Petersona [1957]. Więcej informacji dotyczących technik organizacji tablic symboli można znaleźć u McKeemana [1976]. Przykład 7.10 pochodzi od Bentleya, Clevelanda i Sethiego [1985]. Reiss [1983] opisał generator tablicy symboli. Algorytmy równoważności przedstawili Arden, Galler i Graham [1961] oraz Galler i Fischer [1964]; my przyjęliśmy drugie podejście. Wydajność algorytmów równoważno ści badał Fischer [1972], Hopcroft i Ullman [1973] oraz Tarjan [1975].
ROZDZIAŁ
Generowanie kodu pośredniego
W modelu kompilatorów typu analiza-synteza przód kompilatora tłumaczy kod źródło wy programu na pośrednią reprezentację, z której tył kompilatora generuje kod wy nikowy. Szczegóły dotyczące języka wynikowego, jeśli jest to możliwe, ukryte są w tyle kompilatora. Chociaż program źródłowy można tłumaczyć bezpośrednio na język wynikowy, to z używania reprezentacji pośredniej niezależnej od maszyny wynikają korzyści: 1.
2.
Łatwiejsza jest zmiana maszyny docelowej; kompilator dla nowej maszyny można stworzyć, dołączając tył kompilatora dla tej maszyny do istniejącego przodu kom pilatora. Do kodu pośredniego można stosować niezależny od maszyny optymalizator kodu. Takie optymalizatory szczegółowo opisaliśmy w rozdz. 10.
W niniejszym rozdziale omówiliśmy zastosowanie metod sterowanych składnią (rozdz. 2 i 5) do translacji do pośredniej postaci takich konstrukcji, jak: deklaracje, przypisania i instrukcje sterujące. Dla uproszczenia założyliśmy, że program źródłowy przeszedł już przez analizator składniowy oraz wykonano dla niego analizę statyczną (rys. 8.1). Większość z opisywanych w tym rozdziale definicji sterowanych składnią można zaimplementować tak, by były one wykonywane podczas opisanej w rozdz. 5 analizy zstępującej lub wstępującej, więc jeśli jest to pożądane, to generowanie kodu pośredniego może być wykonywane w trakcie analizy składniowej.
Analizator składniowy
Analizator statyczny
Generator kodu pośredniego
Kod pośredni
Rys. 8.1. Miejsce generatora kodu pośredniego
Generator kodu
8.1
Języki pośrednie
Drzewa składniowe i notacja postfiksową (omówione w p. 5.2 i 2.3) to dwa rodzaje reprezentacji pośredniej. Trzecim rodzajem, nazywanym kodem trójadresowym, zajmiemy się w tym rozdziale. Reguły semantyczne używane do generowania kodu trójadresowego ze zwykłych języków programowania są podobne do używanych przy konstrukcji drzew składniowych czy generowaniu notacji postfiksowej. Reprezentacje graficzne Drzewo składniowe przedstawia naturalną, hierarchiczną strukturę programu źródłowego. Dag przedstawia te same informacje, ale w bardziej zwięzłej formie, ponieważ wspól ne podwyrażenia są utożsamione. Drzewo składniowe i dag dla instrukcji przypisania a:=b*-c+b*-c przedstawiono na rys. 8.2.
przypisanie a
\
/
+
* b
przypisanie a
/
\
+
* jminus b |
jminus |
(a) Drzewo składniowe
/ b
\ jminus
(b) Dag
Rys. 8.2. Graficzna reprezentacja a: = b * - c + b * - c
Notacja postfiksową jest liniową reprezentacją drzewa składniowego; jest to lista wierzchołków drzewa, na której wierzchołek występuje bezpośrednio po swoich dzie ciach. Notacją postfiksową drzewa składniowego z rys. 8.2(a) jest a b c
jminus
* b c jminus
* + przypisanie
(8.1)
Krawędzie drzewa składniowego nie występują jawnie w notacji postfiksowej. Można j e odzyskać, korzystając z kolejności występowania wierzchołków oraz znajomości liczby argumentów, których wymagają operatory w wierzchołkach. Odzyskiwanie krawędzi jest podobne do wyliczania, z użyciem stosu, wartości wyrażenia zapisanego w notacji post fiksowej. Więcej szczegółów oraz opis zależności notacji postfiksowej i kodu dla maszyny stosowej przedstawiliśmy w p. 2.8. Drzewa składniowe dla instrukcji przypisania są tworzone z użyciem definicji stero wanej składnią z rys. 8.3; jest ona rozszerzeniem definicji z p. 5.2. Nieterminal S generuje instrukcję przypisania. Dwa dwuargumentowe operatory, + i *, służą jako przykład peł nego zestawu operatorów dla typowego języka. Łączność i priorytety tych operatorów są klasyczne, mimo że nie jest to przedstawione w gramatyce. Korzystając z tej definicji, dla wejścia a : = b * - c + b * - c otrzymujemy drzewo jak na rys. 8.2(a).
R E G U Ł A SEMANTYCZNA
PRODUKCJA
S.wwtfjfc := rwwczefC p r z y p i s a n i e ' , ftv/tfc*(id, id.pozyc/a), £.wwjit := nvwczW('+', ^ . w w s * , £ .ww>s£) £ . w w i : = twwęzeł{' * ' , ^ . M w i t , £ . W W J * ) E.wwsA: := twjwęzeł( j m i n u s ' , ^ . w t ó ) E.wwsk := £ w i E.wwsJ: := rw/tfć(id, id.pozyc/a)
S id := E E -> E +E E E *E E -+ -E E^(E ) E -> id x
2
X
2
2
2
r
x
r
X
Rys. 8.3. Definicja sterowana składnią do konstrukcji drzewa składniowego dla instrukcji przypisania
Jeśli funkcje twjwęzeł(op, potomek) oraz twwęzeł(op, lewy, prawy) zwracają — gdy jest to możliwe — wskaźniki istniejących wierzchołków zamiast tworzyć nowe, to — ko rzystając z tej definicji — dostaniemy dag z rys. 8.2(b). Symbol id m a atrybut pozycja, który wskazuje wpis w tablicy symboli odpowiadający temu symbolowi. W podrozdzia le 8.3 pokazaliśmy, j a k znaleźć wpis w tablicy symboli, korzystając z atrybutu id.nazwa, reprezentującego leksem związany z danym wystąpieniem id. Jeśli analizator leksykalny przechowuje wszystkie leksemy w pojedynczej tablicy znaków, to atrybut nazwa może być indeksem pierwszego znaku tego Ieksemu. Na rysunku 8.4 przedstawiamy dwie reprezentacje drzewa składniowego z rys. 8.2(a). Każdy wierzchołek jest reprezentowany j a k o rekord z polem dla operatora oraz dodat kowymi polami dla wskaźników dzieci. Wierzchołki są alokowane w tablicy rekordów (rys. 8.4(b)), a indeks, czyli pozycja wierzchołka w tablicy, jest używany jako wskaź nik tego wierzchołka. Wszystkie wierzchołki drzewa składniowego można odwiedzić, przechodząc po wskaźnikach, zaczynając od korzenia na pozycji 10.
: :—i r przypisanie, | , id r
T
id
id
-—: 1 jminus, id
|
jminus
r t
id
i r i I i
b; id 0 c id 1 jminus , 1 2 0 3 b id 4 c id 5 3 m i n u s 5 6 4 7 3 8 a id 9 10 p r z y p i s a n i e 9 11 i i
*
i
2
*
i
6 7
(a) Rys. 8.4. Dwie reprezentacje drzewa składniowego z rys. 8.2(a)
(b)
8
Kod trójadresowy Kod trójadresowy jest ciągiem instrukcji o ogólnej postaci x:=y
op
z
gdzie x, y i z są nazwami, stałymi albo wygenerowanymi przez kompilator zmiennymi tymczasowymi; op oznacza dowolny operator, taki jak stało- bądź zmiennopozycyjny operator arytmetyczny czy też operator dla wartości logicznych. Zauważmy, że nie są dozwolone skomplikowane wyrażenia arytmetyczne, bo po prawej stronie instrukcji jest tylko jeden operator. W związku z tym wyrażenie, takie jak x + y * z można przetłumaczyć na sekwencję t!:=y*z t :=x+t 2
t
gdzie t j i t są nazwami tymczasowymi wygenerowanymi przez kompilator. To rozwikływanie skomplikowanych wyrażeń arytmetycznych i zagnieżdżonych instrukcji sterują cych czyni kod trójadresowy przydatnym do generacji kodu wynikowego oraz optyma lizacji (patrz rozdz. 10 i 12). Użycie nazw dla pośrednich wartości wyliczanych przez program pozwala na łatwe przestawianie instrukcji w kodzie trójadresowym, w przeci wieństwie do notacji postfiksowej. Kod trójadresowy jest liniową reprezentacją drzewa składniowego albo daga, w któ rym wierzchołkom wewnętrznym nazwy są przypisane jawnie. Drzewo składniowe i dag z rys. 8.2 odpowiadają kodowi trójadresowemu z rys. 8.5. Nazwy zmiennych mogą wy stąpić bezpośrednio w instrukcjach trójadresowych, więc na rys. 8.5 nie ma instrukcji odpowiadających liściom z rys. 8.4. 2
•c t :=b*t! t :=-c t :=b*t t : = t 2+ t a :=t.5
t,:= t :=b*t 5 2 a :=t
2
2
t
3
4
5
3
2
: = t
1
+ t
5
4
(a) Kod dla drzewa składniowego
(b) Kod dla daga
Rys. 8.5. Kod trójadresowy odpowiadający drzewu i dagowi z rys. 8.2
Większość instrukcji zawiera trzy adresy: dwa dla argumentów i jeden dla wyniku — stąd nazwa „kod trójadresowy". W opisanej dalej implementacji kodu trójadresowego nazwy podane przez programistę zastąpiliśmy wskaźnikami wpisów dla tych nazw w tablicy symboli. Rodzaje instrukcji trójadresowych Instrukcje trójadresowe są podobne do kodu maszynowego. Mogą mieć etykiety symbo liczne; istnieją także instrukcje kontroli przepływu. Etykieta symboliczna oznacza pozycję instrukcji trójadresowej w tablicy przechowującej kod pośredni. Faktyczne indeksy mogą
być podstawione za etykiety w trakcie oddzielnego przebiegu albo wskutek „poprawia nia", opisanego w p . 8.6. Poniżej przedstawiamy typowe instrukcje trójadresowe, których będziemy używać w dalszej części książki. 1. 2.
3. 4. 5.
6.
Instrukcje przypisania o postaci x : = y op z, gdzie op jest dwuargumentowym ope ratorem arytmetycznym lub dwuargumentową operacją logiczną. Instrukcje przypisania o postaci x : =opy, gdzie op jest operacją jednoargumentową. Najważniejsze operacje jednoargumentowe to m.in. jednoargumentowy minus, negacja logiczna, operatory przesunięcia i konwersji, które na przykład zmieniają liczbę stałopozycyjną w zmiennopozycyjną. Instrukcje kopiujące o postaci x : = y , w których wartość y jest przypisywana x. Skoki bezwarunkowe o postaci g o t o L. Następną wykonywaną instrukcją trójadresową jest instrukcja z etykietą L. Skoki warunkowe, takie jak i f x oprel y g o t o L. Ta instrukcja aplikuje ope rator relacyjny (<, =, >= itp.) do x oraz y i jako następną wykonuje instrukcję z etykietą L, jeśli x jest w relacji oprel z y. Jeśli nie, to jako następna jest wykonywana instrukcja trójadresowa znajdująca się w kodzie po rozpatrywanym i f x oprel y g o t o L. p a r a m x i c a l i p , n dla wywołań procedur oraz r e t u r n y, gdzie y — ozna czające zwracaną wartość — nie jest obowiązkowe. Zazwyczaj wykorzystuje się je w następującej sekwencji instrukcji trójadresowych: p a r a m Xj param x
2
p a r a m x„ cali p, n generowanym jako część wywołania procedury p ( x x , . . . , x ) . Liczba n, oznaczająca liczbę parametrów aktualnych, jest konieczna w wywołaniu c a l i p , n, gdyż wywołania mogą być zagnieżdżone. Implementację wywoływania procedur opisaliśmy w p. 8.7. Przypisania indeksowane o postaci x : = y [ i ] oraz x [ i ] : = y . Pierwsza z tych in strukcji nadaje x wartość z lokacji i jednostek pamięci za lokacją y. Instrukcja x [ i ] : = y nadaje lokacji i jednostek za x wartość y. W obu tych instrukcjach x, y oraz i odnoszą się do danych. Przypisania adresowe i wskaźnikowe o postaci x : =&y, x : =*y oraz * x : - y . Pierw sze z nich nadaje x wartość równą lokacji y. Zakładamy, że y jest nazwą, być może tymczasową, która oznacza wyrażenie z /-wartością, takie jak A [ i , j ] , a x jest nazwą wskaźnika lub zmiennej tymczasowej. Oznacza to, że r-wartością x jest /-wartość (lokacja) pewnego obiektu. W instrukcji x : - * y przypuszczalnie y jest wskaźnikiem bądź zmienną tymczasową, której r-wartość jest lokacją. r-Wartość x jest potem równa zawartości tej lokacji. Natomiast * x : = y przypisuje r-wartości obiektu wskazywanego przez x r-wartość y. 1 7
7.
8.
2
rt
Wybór dozwolonych operatorów jest ważnym zagadnieniem w trakcie projektowania języka pośredniego. Ich zbiór musi być, oczywiście, wystarczająco bogaty, aby dało się
zaimplementować wszystkie operacje języka źródłowego. Mały zbiór operatorów jest ła twiejszy w implementacji dla nowej maszyny docelowej, ale ograniczony zbiór instrukcji może powodować konieczność generowania długich ciągów instrukcji dla pewnych opera cji z języka źródłowego. Może to utrudniać tworzenie dobrego kodu przez optymalizator i generator kodu. Translacja na kod trójadresowy sterowana składnią Nazwy tymczasowe dla wewnętrznych wierzchołków drzewa składniowego tworzy się, gdy generowany jest kod trójadresowy. Wartość nieterminala E z lewej strony E -*E +E zostanie zapamiętana w nowej zmiennej tymczasowej t. Kod trójadresowy dla i d : = £ składa się z kodu wyliczającego E w jakiejś zmiennej tymczasowej t, po którym nastę puje przypisanie ±d.pozyc ja:-t. Jeśli wyrażenie jest pojedynczym identyfikatorem, na przykład y, to samo y przechowuje wartość wyrażenia. Chwilowo będziemy tworzyli nową nazwę za każdym razem, gdy potrzebna będzie nowa zmienna tymczasowa; techniki pozwalające ponownie wykorzystywać zmienne tymczasowe przedstawiliśmy w p. 8.3. X
2
Definicja S-atrybutowana z rysunku 8.6 generuje kod trójadresowy dla instrukcji przypisania. Dla wejścia a : = b * - c + b * - c produkuje ona kod z rys. 8.5(a). Atrybut syn tezowany S.kod reprezentuje kod trójadresowy dla przypisania S. Nieterminal E ma dwa atrybuty: 1) 2)
E.pozycja, nazwę, która będzie przechowywała wartość E, E.kod, sekwencję instrukcji trójadresowych wyliczających E.
Funkcja nowatymcz
zwraca w kolejnych wywołaniach nowe nazwy tp t , . • • 2
R E G U Ł A SEMANTYCZNA
PRODUKCJA
S -* id E -»
E
E +E x
2
S.kod = E.kod || gen(\d.pozycja ' :='
E.pozycja)
E.pozycja = nowatymcz; E.kod = E .kod || E .kod || gen(E.pozycja ' :=' E .pozycja ' +' E . pozycja) x
2
2
x
E -•» E *E X
2
E.pozycja := nowatymcz; E.kod := E kod || E .kod || gen(E.pozycja ' :=' E .pozycja ' *' E .pozycja) v
2
2
x
E -+
E.pozycja := nowatymcz; E.kod := E .kod || gen(E.pozycja ' : ' x
E -
jminus '
E .pozycja) x
E.pozycja := E .pozycja; E.kod :~ E .kod x
x
E --ł id
E.pozycja := id.pozycja; E.kod . i i
Rys. 8.6. Definicja sterowana składnią, generująca trójadresowy kod dla przypisań Dla wygody, na rysunku 8.6 używamy zapisu gen(x' : = ' y ' +'z) do reprezentacji instrukcji trójadresowej x: =y+z. Wyrażenia występujące w miejscu x, y i z są obliczane po
przekazaniu d o gen, a operatory umieszczone w apostrofach, takie jak ' + ' , są traktowane jak tekst. W praktyce, instrukcje trójadresowe mogą być wysyłane do pliku wyjściowego, a nie przechowywane w atrybutach kod. D o języka przypisań z rysunku 8.6 można dodać instrukcje sterujące za pomocą produkcji i reguł semantycznych podobnych do przedstawionych na rys. 8.7 dla instrukcji while. Na tym rysunku, kod dla S —> while E d o S jest generowany z użyciem nowych atrybutów: S.początek i S.pokońcu, oznaczających — odpowiednio — pierwszą instrukcję w kodzie dla E i instrukcję następującą po kodzie dla S. Atrybuty te przechowują etykiety tworzone przez funkcję nowaetykieta dla każdego wywołania, zwracającą nową etykietę. Zauważmy, że S.pokońcu będzie etykietą instrukcji, która znajdzie się po kodzie dla instrukcji while. Przyjmujemy, że niezerowa wartość wyrażenia oznacza prawdę, tj. gdy wartością E jest zero, sterowanie opuszcza instrukcję while. x
S.początek: E.kod
~\
i f E.pozycja = 0 g o t o S.pokońcu S .kod x
g o t o S.początek S.pokońcu: PRODUKCJA
REGUŁY SEMANTYCZNE
S —• while E do S
x
S.początek := nowaetykieta; S.pokońcu := nowaetykieta; S.kod := gen(S.początek ' :') || E.kod || gen('if E.pozycja '-' '0' ' g o t o ' S.pokońcu) \\ S .kod || gen( goto' S.początek) \\ gen(S.pokońcu ' :') x
r
Rys. 8.7. Reguły semantyczne generujące kod dla instrukcji while
Wyrażenia zarządzające przepływem sterowania mogą, w ogólności, być wyraże niami logicznymi zawierającymi operatory relacyjne i logiczne. Reguły semantyczne dla instrukcji while przedstawione w p. 8.6 różnią się od tych z rys. 8.7 tym, że uwzględniają przepływ sterowania w wyrażeniach logicznych. Notację przyrostkową można otrzymać, zmieniając reguły semantyczne z rys. 8.6 (patrz rys. 2.5). Notacją przyrostkową dla identyfikatora jest on sam. Reguły dla innych produkcji za kodem dla argumentów umieszczają tylko operator. Przykładowo, z produk cją E —>• -E jest związana reguła x
E.kod
:= E .kod x
\\ ' jminus'
Postać pośrednią tworzoną przez translację sterowaną składnią — z tego rozdziału — można zmienić, wykonując analogiczne modyfikacje reguł semantycznych.
Implementacje instrukcji trójadresowych Instrukcje trójadresowe są abstrakcyjną postacią kodu pośredniego. W kompilatorze in strukcje te mogą być implementowane jako rekordy z polami dla operatora i argumentów. Trzy takie reprezentacje to czwórki, trójki i trójki pośrednie. Czwórki Czwórka jest rekordem o czterech polach, nazywanych op, argl, argl i wynik. Pole op przechowuje wewnętrzny identyfikator operatora. Instrukcja trójadresowa x : - y o p z jest reprezentowana przez umieszczenie y w polu argl, z w argl oraz x w wynik. In strukcje z jednoargumentowymi operatorami, takie jak x : = - y lub x : = y nie korzystają z argl. Operatory, takie jak p a r a m , nie używają arg! ani wynik. Etykieta adresu dla sko ków warunkowych i bezwarunkowych jest umieszczana w polu wynik. Na rysunku 8.8(a) są przedstawione czwórki dla przypisania a : = b * - c + b * - c . Otrzymuje się j e z instrukcji trójadresowych z rys. 8.5(a).
op
(0) jminus * (1) (2) jminus * (3) (4)
+
(5)
:-
arg 1
arg 1
c b c b
tl
wynik
(0) (1) (2)
ti fc
3 t
(3) (4)
4
fc
fc
5 (a) Czwórki
5 a
op
arg 1
jminus * jminus *
c b c b
+
(1)
(5) przypisanie
a
arg 1
(0) (2) (3) (4)
(b) Trójki
Rys. 8.8. Instrukcje trójadresowe przedstawione w postaci trójek i czwórek Zawartością pól argl, argl i wynik są zazwyczaj wskaźniki wpisów w tablicy sym boli dla nazw odpowiadających tym polom. Jeśli tak jest, to utworzone nazwy tymczasowe muszą być umieszczane w tablicy symboli. Trójki Wpisywania nazw tymczasowych do tablicy symboli można uniknąć, odwołując się do wartości tymczasowych przez pozycje instrukcji, w których są one obliczane. Jeśli tak zrobimy, to instrukcje trójadresowe mogą być pamiętane jako rekordy z trzema polami: op, argl i argl, co pokazano na rys. 8.8(b). Pola argl i argl, czyli argumenty op, wska zują wpisy w tablicy symboli (dla nazw i stałych zdefiniowanych przez programistę) albo trójki (dla wartości tymczasowych). Ponieważ rekordy takie mają trzy pola, ta postać ko du pośredniego jest znana pod nazwą trójek . Z wyjątkiem obsługi nazw zdefiniowanych przez programistę, trójki odpowiadają reprezentacji drzewa składniowego lub daga przez tablicę wierzchołków, taką jak na rys. 8.4. 1
1
Niektórzy określają trójki mianem „kodu dwuadresowego", wybierając identyfikowanie „czwórek" z okre śleniem „kod trójadresowy". My jednak uważamy „kod trójadresowy" za notację abstrakcyjną o różnych możliwych implementacjach, a trójki i czwórki to właśnie najczęściej używane implementacje.
Liczby w nawiasach oznaczają wskaźniki trójek, a wskaźniki wpisów w tablicy symboli są przedstawione jako nazwy wskazywanych symboli. W praktyce, informacje potrzebne do rozróżnienia rodzajów argumentów zapisanych w polach argl i argl mogą być zakodowane w polu op albo w pewnych dodatkowych polach. Trójki z rysunku 8.8(b) odpowiadają czwórkom z rys. 8.8(a). Jak widać, instrukcja kopiująca a : = t jest przed stawiona jako trójka z a w polu argl i operatorem p r z y p i s a n i e . Trój argumentowe operacje, takie jak x [ i ] : = y , muszą być zapisywane w dwóch trójkach, co widać na rys. 8.9(a), podczas gdy x : = y [ i ] jest naturalnie reprezentowane przez dwie operacje z rys. 8.9(b). 5
op
arg 1
X ]= (0) przypisanie (0) (1) (a)x[i] : = y [
arg 1
arg 2
op
i y
= ] y (0) X (1) przypisanie (b)x := y[i] [
arg 2
i (0)
Rys. 8.9. Kolejne trójki Trójki
pośrednie
Kolejną implementacją kodu trójadresowego, którą już rozważaliśmy, jest przechowywa nie wskaźników trójek zamiast samych trójek. Taka implementacja jest nazywana trójkami pośrednimi. Niech na przykład tablica instrukcje przechowuje w odpowiednim porządku wskaź niki trójek. Wówczas trójki z rys. 8.8(b) mogą wyglądać tak, j a k na rys. 8.10.
instrukcje
(0) (1) (2) (3) (4) (5)
(14) (15) (16) (17) (18) (19)
op
jminus (14) * (15) jminus (16) * (17) + (18) (19) przypisanie
arg 1
c b c b (15) a
arg 2
(14) (16) (17) (18)
Rys. 8.10. Instrukcje trójadresowe przedstawione w postaci trójek pośrednich Porównanie reprezentacji: korzystanie z pośredniości Różnicę między trójkami a czwórkami można rozpatrywać ze względu na ilość stopni pośrednich obecnych w reprezentacji. W chwili, gdy produkujemy już kod wynikowy, każdej nazwie tymczasowej i zdefiniowanej przez programistę będzie przypisana pewna lokacja w pamięci, pamiętana w tablicy symboli. Używając notacji czwórkowej, instrukcja trójadresowa definiująca bądź używająca zmiennej tymczasowej może, poprzez tablicę symboli, uzyskać bezpośredni dostęp do lokacji dla tej zmiennej tymczasowej. Ważniejszą zaletę czwórek widać w kompilatorach optymalizujących, gdzie instruk cje są często przesuwane. Jeżeli używamy notacji czwórkowej, tablica symboli stanowi
dodatkowy poziom miedzy obliczaniem wartości a jej używaniem. Jeśli przesuniemy in strukcję obliczającą x , to instrukcje używające x nie wymagają zmian. Jednakże w notacji trójkowej przesunięcie instrukcji, która definiuje wartość tymczasową, wymaga zmiany wszystkich odwołań do tej instrukcji z tablic argl i argl. Ten problem powoduje, że w kompilatorach optymalizujących trudno jest korzystać z trójek. Trójki pośrednie nie stwarzają takich problemów. Instrukcja może być przesuwana poprzez zmianę kolejności elementów w tablicy instrukcje. Ponieważ wskaźniki wartości tymczasowych odwołują się do tablic op-argl-argl, które nie są zmieniane, to żaden z tych wskaźników nie musi być zmieniony. Czyli, jeśli chodzi o wygodę, trójki pośred nie zachowują się bardzo podobnie do czwórek. Obie notacje wymagają podobnej ilości pamięci i są tak samo efektywne przy zmianie kolejności instrukcji. Tak jak w zwykłych trójkach, przydział pamięci dla zmiennych tymczasowych może nastąpić dopiero w chwili generowania kodu maszynowego. Trójki pośrednie mogą jednak oszczędzić trochę miej sca (w porównaniu z czwórkami), jeśli ta sama wartość tymczasowa jest używana więcej niż raz. Wynika to z tego, że dwa lub więcej wpisów w tablicy instrukcje mogą wska zywać ten sam wiersz w strukturze op-argl-argl. Przykładowo (patrz rys. 8.10) można połączyć wiersze (14) i (16), a następnie (15) i (17).
8.2
Deklaracje
Badając ciąg deklaracji w procedurze albo bloku, możemy przygotować pamięć dla nazw lokalnych z procedury. Dla każdej nazwy lokalnej tworzymy wpis w tablicy symboli z informacjami, takimi jak typ i względny adres pamięci dla tej nazwy. Adres względny jest przesunięciem w stosunku do początku obszaru danych statycznych lub pola dla danych lokalnych w rekordzie aktywacji. Gdy przód kompilatora generuje adres, może on uwzględniać konkretną maszynę docelową. Przypuśćmy, że maszyna może adresować pojedyncze bajty, a adresy nastę pujących po sobie liczb całkowitych różnią się o 4. Obliczenia adresów tworzone przez przód kompilatora mogą więc zawierać mnożenia przez 4. Zbiór rozkazów maszyny do celowej może również faworyzować pewne ułożenia danych i, w związku z tym, ich adresy; przykład 7.3 ilustruje wyrównywanie danych przez dwa kompilatory.
Deklaracje w procedurach Składnia języków, takich jak C, Pascal i Fortran, pozwala na przetwarzanie wszystkich de klaracji z jednej procedury jako grupy. W takim przypadku zmienna globalna, na przykład przesunięcie, może służyć do zapamiętywania następnego wolnego adresu względnego. W schemacie translacji z rysunku 8.11 nieterminal P generuje sekwencję deklara cji o postaci id : T. Przed rozpatrzeniem pierwszej deklaracji, przesunięciu nadajemy wartość 0. Po napotkaniu każdej nowej nazwy jest ona wstawiana do tablicy symboli z przesunięciem równym aktualnej wartości przesunięcie, a przesunięcie jest zwiększane o wielkość obiektu opisywanego tą nazwą. Procedura wstaw(nazwa, typ, przesunięcie) tworzy w tablicy symboli wpis dla nazwy, nadając jej typ typ oraz względny adres przesunięcie w swoim obszarze danych.
Używamy syntezowanych atrybutów typ i szerokość dla nieterminala T do zapamiętania jego typu i szerokości, czyli liczby jednostek pamięci zajmowanych przez obiekt tego typu. Atrybut typ reprezentuje wyrażenie typowe skonstruowane z typów podstawowych integer i real przy zastosowaniu konstruktorów typów pointer i array, tak jak w p. 6.1. Jeśli wyrażenia typowe przedstawimy za pomocą grafów, to atrybut typ może być wskaź nikiem wierzchołka reprezentującego wyrażenie typowe. P —> D
{ przesunięcie := 0 }
D —> D ; D D —> id : T
{ wstawfi&.nazwa, T.typ, przesunięcie); przesunięcie := przesunięcie + T.szerokość }
T -> integer
{ T.typ := integer; T.szerokość := 4 }
T -)• real
{ T.typ := real; T.szerokość := 8 }
T —» array [ num ] of T
x
{ T.typ := array(mim.wartość, T .typ); T.szerokość := num.wartośćxT .szerokość {
x
T —> T T
}
{ T.typ:— pointer(Ty.typ); T.szerokość := 4 }
x
Rys. 8.11. Obliczanie typów i adresów względnych nazw zadeklarowanych Na rysunku 8.11 liczby całkowite mają szerokość 4, a rzeczywiste — 8. Szero kość tablicy otrzymujemy, mnożąc szerokość każdego elementu przez liczbę elementów w tablicy . Przyjmujemy, że szerokością wszystkich wskaźników jest 4. W Pascalu i C możemy napotkać wskaźnik zanim znajdziemy typ wskazywanego obiektu (patrz opis typów rekurencyjnych w p . 6.3). Przydział pamięci dla takich typów jest prostszy, jeśli wszystkie wskaźniki mają taką samą szerokość. Inicjowanie przesunięcie w schemacie translacji z rys. 8.11 jest lepiej widoczne, gdy pierwsza produkcja jest napisana w jednym wierszu, j a k 1
P
{ przesunięcie
:= 0 } D
(8.2)
Nieterminale generujące e, nazywane w p. 5.6 znacznikami, mogą być używane do prze pisania produkcji w taki sposób, aby wszystkie akcje znajdowały się na krańcach prawych stron produkcji. Używając nieterminala M, (8.2) możemy sformułować jako M
M
M -> e
D
{ przesunięcie
:- 0 }
Utrzymywanie informacji o zakresie W języku z procedurami zagnieżdżonymi, nazwom lokalnym dla każdej procedury moż na przydzielać adresy względne za pomocą podejścia z rys. 8.11. Gdy napotykamy pro1
Dla tablic, dla których dolnym ograniczeniem nie jest 0, obliczanie adresów elementów tablicy jest uprosz czone, jeśli przesunięcie wpisane w tablicy symboli jest poprawione, tak jak opisaliśmy to w p. 8.3.
cedurę zagnieżdżoną, tymczasowo wstrzymujemy przetwarzanie deklaracji z procedury otaczającej. Podejście takie zilustrujemy dodaniem akcji semantycznych do następującego języka: P D D -» D ; D | id : T | proc id ; D ; S
(8.3)
Produkcji dla nieterminali S dla instrukcji oraz T dla typów nie pokazujemy, gdyż kon centrujemy się na deklaracjach. Nieterminal T ma syntezowane atrybuty typ i szerokość, tak jak w schemacie translacji z rys. 8.11. Dla uproszczenia załóżmy, że mamy oddzielną tablicę symboli dla każdej procedury w języku (8.3). Jedną z możliwych implementacji tablicy symboli jest lista wpisów dla nazw. Jeśli jest to pożądane, można korzystać z bardziej wyszukanej implementacji. Po napotkaniu deklaracji procedury, D -> proc id D ; S, jest tworzona nowa tablica symboli i wpisy dla deklaracji z D są tworzone w tej nowej tablicy. Wskaźnik z nowej tablicy prowadzi do tablicy symboli dla otaczającej procedury; nazwa reprezentowana przez id jest lokalna dla procedury otaczającej. Jedyną zmianą w stosunku do obsługi deklaracji zmiennych z rys. 8.11 jest to, że procedurze wstaw podajemy, w której tablicy symboli powinien zostać dodany wpis. Jako przykład, na rysunku 8.12 pokazaliśmy tablice symboli dla pięciu procedur. Struktura zagnieżdżeń może być wywnioskowana z połączeń między tablicami symboli; program przedstawiliśmy na rys. 7.22. Tablice symboli dla procedur c z y t a j t a b , z a m i e ń i ą u i c k s o r t wskazują tablicę dla otaczającej j e procedury s o r t u j , która jest całym programem. Ponieważ p o d z i a ł jest zadeklarowany we wnętrzu ą u i c k s o r t , z jego tablicy symboli wskaźnik prowadzi do tablicy dla ą u i c k s o r t . x
x
sortuj nil
Nagłówek
czytajtab
zamień
do c z y t a j t a b do z a m i e ń
ąuicksort czytajtab Nagłówek
ąuicksort
zamień
Nagłówek
Nagłówek
i podział
podział Nagłówek
i j Rys. 8.12. Tablice symboli dla procedur zagnieżdżonych
Reguły semantyczne są zdefiniowane za pomocą następujących operacji: 1.
2.
3. 4.
róbtab(poprz) tworzy nową tablicę symboli i zwraca wskaźnik do niej. Argument poprz jest wskaźnikiem wcześniej utworzonej tablicy symboli, zapewne tej dla ota czającej procedury. Wskaźnik poprz jest umieszczany w nagłówku nowej tablicy symboli razem z dodatkowymi informacjami, takimi jak poziom zagnieżdżenia pro cedury. Możemy również numerować procedury w kolejności ich deklaracji i prze chowywać takie numery w nagłówku. wstaw(tablica, nazwa, typ, przesunięcie) tworzy nowy wpis dla nazwy nazwa w ta blicy symboli wskazywanej przez tablica. Tak jak wcześniej, wstaw umieszcza typ typ oraz względny adres przesunięcie w polach we wpisie. sumujszeritablica, szerokość) zapisuje łączną szerokość wszystkich wpisów w tabli cy tablica w nagłówku związanym z tą tablicą. wstawproc(tablica, nazwa, nowatab) tworzy nowy wpis dla procedury nazwa w tabli cy symboli wskazywanej przez tablica. Argument nowatab wskazuje tablicę symboli dla procedury nazwa.
Ze schematu translacji z rysunku 8.13 widać, j a k w jednym przebiegu można roz mieszczać dane, korzystając ze stosu wsktbl do przechowywania wskaźników do tablic symboli dla otaczających procedur. Dla tablic symboli z rys. 8.12, podczas obsługiwania deklaracji w p o d z i a ł , wsktbl będzie zawierał wskaźniki tablic dla s o r t u j , ą u i c k s o r t i p o d z i a ł . Wskaźnik aktualnej tablicy symboli jest n a wierzchołku. Drugi wierz chołek, przesunięcia, jest naturalnym uogólnieniem atrybutu p r z e s u n i ę c i e z rys. 8.11 dla procedur zagnieżdżonych. Szczytowy element przesunięć jest najbliższym adresem względnym dostępnym dla zmiennych lokalnych w aktualnej procedurze. Wszystkie akcje semantyczne w poddrzewach dla B i C w A -> B C { akc ja
}
A
są wykonywane przed akcją akcja na końcu produkcji. Wynika stąd, że akcje związane ze znacznikiem M z rys. 8.13 będą wykonane jako pierwsze. A
P —> M D
{ sumujszer(top(wsktbl), top(przesunięcia))', pop(wsktbl); pop(przesunięcia) }
M -> e
{ / : = róbtab(nil); push(t, wsktbl); push(0, przesunięcia) }
D -> D ; D x
2
D -+ proc \d;N
D; x
S
{ t :top(wsktbl); sumujszer(t, topiprzesunięcia)); pop(wsktbl); popiprzesunięcia); wstawproc(top(wsktbl), \d.nazwa, t) }
D -> id : T
{ wstaw(top(wsktbl), id.nazwa, T.typ, topiprzesunięcia)); topiprzesunięcia) := topiprzesunięcia) + T. szerokość }
N -> e
{ t := róbtab(tob(wsktbl)); push(t, wsktbl); push(0, przesunięcia) } Rys. 8.13. Obsługa deklaracji w procedurach zagnieżdżonych
Akcje dla nieterminala M inicjują stos wsktbl tablicą symboli dla najbardziej ze wnętrznego zakresu, utworzoną przez operację róbtab(nil). Ponadto, akcja ta wstawia adres względny 0 na stos przesunięcia. Nieterminal N odgrywa podobną rolę, gdy napo tykana jest deklaracja procedury. Jego akcja korzysta z operacji róbtab(top(wsktbl)) do utworzenia nowej tablicy. Argument topiwsktbl) wyznacza zakres otaczający nową ta blicę. Wskaźnik nowej tablicy jest wstawiany nad ten dla tablicy z zakresu otaczającego. Na stos przesunięcia ponownie jest wstawiane 0. Dla każdej deklaracji zmiennej, id : T, tworzymy wpis dla id w aktualnej tablicy symboli. Deklaracja ta nie zmienia wsktbl; szczyt stosu przesunięcia jest zwiększany o T.szerokość. Gdy jest wykonywana akcja z prawej strony D —• proc id; N D ; S, na wierzchołku stosu przesunięcia znajduje się szerokość wszystkich deklaracji generowa nych przez D p jest ona zapamiętywana przy użyciu funkcji sumujszer. Następnie, ze stosów wsktbl i przesunięcia zdejmujemy po jednym elemencie i wracamy do badania deklaracji w procedurze otaczającej. W tym momencie nazwa procedury zagnieżdżonej jest wpisywana do tablicy symboli procedury otaczającej. x
Nazwy pól w rekordach Następująca produkcja pozwala nieterminalowi T generować oprócz typów podstawo wych, tablic, wskaźników, także rekordy T -» record D
end
Akcje w schemacie translacji z rys. 8.14 podkreślają podobieństwo między wyglądem rekordów jako konstrukcji języka i rekordami aktywacji. Ponieważ definicje procedur nie mają wpływu n a obliczenia szerokości na rys. 8.13, pomijamy fakt, że powyższe produkcje pozwalają umieszczać w rekordach definicje procedur.
T -» record L D end
{ T.typ := record(top(wsktbl)); T.szerokość := topiprzesunięcia); pop(wsktbl); popiprzesunięcia) }
L -> e
{ t := róbtab(nil); push(t, wsktbl); push(Q, przesunięcia) }
Rys. 8.14. Przygotowanie tablicy symboli dla nazw pól w rekordzie
Po napotkaniu słowa kluczowego record, akcja związana ze znacznikiem L tworzy nową tablicę symboli dla nazw pól. Wskaźnik tej tablicy jest wstawiany na stos wsktbl, a względny adres 0 jest wstawiany na stos przesunięcia. Akcja dla D -»•id : T z rys. 8.13 wpisuje więc informacje o polu o nazwie id do tablicy symboli dla tego rekordu. Co więcej, na wierzchołku stosu przesunięcia, po zbadaniu wszystkich pól, będzie szero kość wszystkich danych dla rekordu. Akcja umieszczona p o end na rys. 8.14 zwraca tę szerokość jako syntezowany atrybut T.szerokość. Typ T.typ otrzymujemy przez zastoso wanie konstruktora record do wskaźnika tablicy symboli dla tego rekordu. W następnych podrozdziałach użyliśmy tego wskaźnika do odczytywania nazw, typów i szerokości pól w rekordach z T.typ.
8.3
Instrukcje przypisania
W tym podrozdziale omówiliśmy wyrażenia, które mogą być liczbami całkowitymi, rze czywistymi, tablicami i rekordami. Tłumacząc przypisania d o kodu trójadresowego, wy jaśniliśmy, jak sprawdzać nazwy w tablicy symboli oraz jak uzyskiwać dostęp do ele mentów tablic i rekordów. Nazwy w tablicy symboli W podrozdziale 8.1 stworzyliśmy instrukcje trójadresowe, używając tylko nazw symbo li, rozumiejąc, że nazwy te reprezentują wskaźniki ich wpisów w tablicy symboli. Ze schematu translacji z rys. 8.15 wynika, jak wyszukiwać takie wpisy w tablicy symboli. Leksem reprezentowany przez id jest przechowywany w atrybucie id.nazwa. Operacja sprawdź(id.nazwa) sprawdza, czy w tablicy symboli jest wpis dla danego wystąpienia na zwy. Jeśli tak, zwracany jest wskaźnik do tego wpisu; w przeciwnym przypadku sprawdź zwraca nil, aby zaznaczyć, że nie znaleziono wpisu. Akcje semantyczne z rysunku 8.15 zamiast utrzymywać atrybuty kod dla nietermina li, tak j a k na rys. 8.6, korzystają z pocedury emit do zapisywania instrukcji trójadresowych do pliku wyjściowego. Z podrozdziału 2.3 wiemy, że translację można wykonać przez zapisywanie do pliku wyjściowego, jeśli atrybuty kod dla nieterminali po lewej stronie produkcji są tworzone przez połączenie atrybutów kod nieterminali z prawej strony tej produkcji, w tej samej kolejności, w której występują one po prawej stronie i, być może, z pewnymi dodatkowymi tekstami między nimi. Zmieniając działanie operacji sprawdź z rys. 8.15, można użyć schematu transla cji, nawet jeśli regułę najbliższego zagnieżdżania widzialności stosuje się do zmiennych nielokalnych, tak jak w Pascalu. Dla uściślenia, przypuśćmy, że kontekst, w którym wy stępują przypisania, dany jest przez następującą gramatykę: S -> id := E
{ p := sprawdź(id.nazwa); if p ^ nil then emit(p ' :=' E.pozycja) else błąd }
E -*
{ E.pozycja \— nowatymcz; emit (E.pozycja ' :=' E pozycja ' +' E .pozycja)
}
{ E.pozycja := nowatymcz; emit (E.pozycja ' :=' E pozycja ' *' E .pozycja)
}
E +E x
2
2
v
E -
2
v
E -
{
E.pozycjanowatymcz; emit (E.pozycja ' ' j m i n u s ' E pozycja) v
E -
{ E.pozycja := E pozycja
E -•» id
{ p := sprawdź(\d.nazwa); if p ^ nil then E.pozycja := p else błąd }
v
}
}
Rys. 8.15. Schemat translacji produkujący trójadresowe kody dla przypisań
P ^
M D
M -> e
D -> D ; D | id : T \ proc id ; N D ; 5 rV - > e Po dodaniu powyższych produkcji do tych z rys. 8.15 nieterminal P zostanie nowym symbolem startowym. Dla każdej z procedur generowanych przez taką gramatykę schemat translacji z rys. 8.13 tworzy nową tablicę symboli. Każda taka tablica ma nagłówek zawierają cy wskaźnik tablicy dla otaczającej procedury (przykład na rys. 8.12). Gdy rozpatrywana jest instrukcja będąca treścią procedury, wskaźnik tablicy symboli dla tej procedury znajduje się na wierzchołku stosu wsktbl. Wskaźnik ten jest wstawiany na stos przez akcje związane z używanym jako znacznik nieterminalem N z prawej strony produkcji
D -> proc id ; W D ; S. x
Niech produkcjami dla nieterminala S będą te z rys. 8.15. Nazwy w przypisaniu generowanym przez 5 muszą być zadeklarowane albo w procedurze, w której znajduje się 5, albo w którejś z procedur otaczających. Zastosowana do nazwy zmodyfikowana operacja sprawdź najpierw sprawdza, czy nazwa występuje w aktualnej tablicy symboli, dostępnej przez top(wsktbl). Jeśli nie, to sprawdź używa wskaźnika w nagłówku tablicy do znalezienia tablicy symboli dla otaczającej procedury i tam szuka nazwy. Jeśli nazwy nie można znaleźć w żadnym z tych zakresów, to sprawdź zwraca nil. Rozpatrzmy, na przykład, tablice symboli, takie jak na rys. 8.12, i przyjmijmy, że właśnie obsługujemy przypisanie w treści procedury p a r t i t i o n . Operacja sprawdź{i) znajdzie wpis w tablicy symboli dla p a r t i t i o n . Ponieważ v nie ma w tej tablicy, sprawdź(v) skorzysta ze wskaźnika w nagłówku tej tablicy symboli, aby kontynuować poszukiwanie w tablicy symboli dla procedury otaczającej ą u i c k s o r t .
Ponowne użycie nazw tymczasowych Do tej pory zakładaliśmy, że nowatymcz generuje nową nazwę tymczasową za każdym razem, gdy jest ona potrzebna. Wygodnie jest, zwłaszcza w kompilatorach optymalizują cych, faktycznie generować nową nazwę przy każdym wywołaniu nowatymcz; w rozdz. 10 uzasadniliśmy takie postępowanie. Jednakże zmienne tymczasowe używane do przecho wywania wartości pośrednich w trakcie obliczania wyrażeń zaśmiecają tablicę symboli, a do przechowywania ich wartości musi być zarezerwowane miejsce. Zmienne tymczasowe mogą być ponownie użyte dzięki zmianie nowatymczAl ternatywne podejście, polegające na umieszczaniu podczas generowania kodu różnych zmiennych tymczasowych w tym samym miejscu, omówiliśmy w następnym rozdziale. Wiele nazw tymczasowych oznaczających dane jest tworzonych podczas sterowanej składnią translacji wyrażeń, zgodnie z regułami z rys. 8.15. Kod stworzony przez reguły dla E -> E +E ma postać x
2
oblicz E do t j {
oblicz E do t 2
t:«t +t 1
2
2
Z reguł dla syntezowanego atrybutu E.pozycja wynika, że t i t nie będą używane w żadnym innym miejscu programu. Czasy życia takich zmiennych tymczasowych są za{
2
gnieżdżone tak, jak pasujące pary zrównoważonych nawiasów. W istocie, czas istnienia wszystkich zmiennych tymczasowych używanych do obliczenia E jest zawarty w cza sie istnienia t j . Możliwa jest więc taka modyfikacja operacji nowatymcz, aby do prze chowywania wartości tymczasowych korzystała ona z małej tablicy w obszarze danych procedury, tak j a k ze stosu. Dla uproszczenia przyjmijmy, że zajmujemy się tylko liczbami całkowitymi. Utrzy mujmy licznik c, inicjowany na zero. Za każdym razem, gdy zmienna tymczasowa jest używana jako argument, zmniejszajmy c o 1. Z a każdym razem, gdy tworzona jest nowa zmienna tymczasowa, używajmy nazwy $ c i zwiększajmy c o l . Zauważmy, że na „stos" zmiennych tymczasowych w trakcie wykonywania programu nie wstawiamy ani z niego nie zdejmujemy żadnych wartości, chociaż tak się składa, że kompilator generuje kod, który zapisuje i odczytuje wartości tymczasowe z „wierzchołka". 2
P r z y k ł a d 8.1-
Rozważmy przypisanie
x:=a*b+c*d-e*f Na rysunku 8.16 widać ciąg instrukcji trójadresowych, które mogły zostać wygenerowane przez reguły semantyczne z rys. 8.15, jeśli zmienilibyśmy operację nowatymcz* Na rysun ku tym przedstawiono również „aktualną" wartość c po wygenerowaniu każdej instrukcji. Zauważmy, że po obliczeniu $ 0 - $ l wartość c jest zmniejszana do zera, więc $ 0 jest ponownie dostępne do przechowywania wyniku. • INSTRUKCJA
$0:=a*b $l:=c*d $0:=$0+$l $l:=e*f $0:=$0-$l x :=$0
WARTOŚĆ C
0 i
2 1 2 1 0
Rys. 8.16. Kod trójadresowy ze zmiennymi tymczasowymi na „stosie" Zmienne tymczasowe, którym możemy przypisywać bądź używać ich wartości wię cej niż raz, na przykład w przypisaniu warunkowym, nie mogą otrzymywać nazw zgodnie z opisaną powyżej metodą ostatni na wejściu-pierwszy na wyjściu ( L I F O ) . Ponieważ ta kich zmiennych jest zazwyczaj niewiele, wszystkim im można przypisać oddzielne nazwy. Analogiczny problem zmiennych tymczasowych definiowanych lub używanych więcej niż raz występuje podczas wykonywania optymalizacji kodu, takiej j a k łączenie wspólnych podwyrażeń lub przesuwanie obliczeń poza pętlę (patrz rozdz. 10). Rozsądną strategią jest tworzenie nowej nazwy za każdym razem, gdy tworzymy dodatkową definicję bądź użycie dla zmiennej tymczasowej albo przesuwamy jej obliczenie. Adresowanie elementów tablicy Do elementów tablicy można uzyskać szybki dostęp, jeśli są one zapamiętane w bloku kolejnych lokacji. Jeśli szerokością każdego elementu tablicy jest w, to i-ty element
tablicy A zaczyna się w miejscu baza-i-(i-doi)
xw
(8.4)
gdzie doi jest dolnym ograniczeniem indeksów, a baza to względny adres pamięci zare zerwowanej dla tablicy; baza jest więc względnym adresem A [doi]. Wyrażenie (8.4) można częściowo obliczyć w trakcie kompilacji, jeśli przepiszemy je jako ixw+
(baza
— doi x w)
Podwyrażenie c = baza — doi x w może być obliczone w trakcie przetwarzania deklaracji tablicy. Przyjmując, że c zostanie zapamiętane we wpisie tablicy symboli dla A, względny adres A [ / ] otrzymamy przez proste dodanie ix w do c. Obliczanie w trakcie kompilacji można również stosować do adresów elementów w tablicach wielowymiarowych. Dwuwymiarowa tablica jest zazwyczaj pamiętana w jed nej z dwóch postaci, wierszowej (wiersz-po-wierszu) bądź kolumnowej (kolumna-po-kolumnie). Na rysunku 8.17 przedstawiliśmy tablicę A o rozmiarze 2 x 3 w postaci: (a) wierszowej i (b) kolumnowej. Fortran używa postaci kolumnowej, Pascal — wier szowej, bo A [ i , j ] jest równoważne A [ i ] [ j ] , a elementy każdej tablicy A [ i ] są przechowywane kolejno.
T
A[l,l]
Pierwszy wiersz
A[l,2]
A[2,ll
A[l,3]
A[l,2]
A[2,l]
A[2,2]
A[2,2]
A[l,3]
T
A[l,l]
Pierwsza kolumna
+ Druga kolumna
Drugi wiersz
4Trzecia kolumna
A[2,3]
A[2,3]
(b) Postać kolumnowa
(a) Postać wierszowa
Rys. 8.17. Układy tablicy dwuwymiarowej
W tablicy dwuwymiarowej przechowywanej w postaci wierszowej adres relatywny A [ i , i ] można obliczyć ze wzoru x
2
baza + ((i
— dol )
x
x n -ł- i — dol )
x
2
2
2
x w
gdzie dol i dol to dolne ograniczenia wartości, które może przyjmować i oraz i , a n to liczba wartości, które może przyjmować i . Czyli, jeśli gor jest górnym ograniczeniem wartości / » 1° 2 8 2 ~ dol + 1. Przyjmując, że i i i są jedynymi wartościami nieznanymi w trakcie kompilacji, możemy przepisać powyższe wyrażenie jako x
2
x
2
n
=
2
((i
x
x n ) + i ) x w + (baza 2
2
or
2
2
2
2
x
- ((dol
x
x n ) -\-dol ) 2
2
2
x w)
Ostatni czynnik tego wyrażenia można obliczyć w trakcie kompilacji.
(8.5)
Postać wierszową i kolumnową możemy rozszerzyć do wielu wymiarów. Uogólnie niem postaci wierszowej jest takie przechowywanie elementów, w którym przeglądając blok pamięci wydaje się, że indeks prawostronny zmienia się najszybciej, tak jak liczby na drogomierzu. Wyrażenie (8.5) dla adresu względnego A [ / i , - . . , i ] uogólnia się wtedy do następującego wyrażenia: l f
( ( • • • ( 0 > + h) 3 N
x
2
k
x
*' ' K + '*) w + dol )n + dol ) • • -)n + dol )
+ '3)
2
4- baza —((••• ((dol n
2
2
3
3
k
k
( 8
6 )
x w
Ponieważ dla wszystkich j przyjmujemy, że n - = g o r . — dolj + 1 jest wartością stałą, więc człon w drugim wierszu (8.6) może być obliczony przez kompilator i zapamiętany z wpisem w tablicy symboli dla A . Postać kolumnową można uogólnić do przeciwnego ułożenia, w którym najszybciej zmieniają się indeksy lewostronne. Niektóre języki pozwalają na dynamiczne specyfikowanie rozmiaru tablicy podczas wykonywania procedury. Alokację takich tablic na stosie rozważaliśmy w p . 7.3. Formuły używane do dostępu do elementów takich tablic są identyczne jak dla tablic o stałym rozmiarze, ale dolne i górne ograniczenia indeksów nie są znane w trakcie kompilacji. Głównym problemem w trakcie generacji kodu dla odwołań do tablic jest powiązanie obliczania (8.6) z gramatyką dla odwołań do tablic. Odwołania do tablic mogą być dozwolone w przypisaniach, jeśli zamiast id z rys. 8.15 możemy używać nieterminala L o następujących produkcjach: ;
1
L -> id [ Elist ] | id Elist - ł Elist , E\E Ograniczenia wymiarów będą dostępne w trakcie łączenia wyrażeń indeksowych w jeżeli produkcje przepiszemy do postaci
Elist,
L -> Elist ] | id Elist -> Elist , E | id [ E Wówczas nazwa tablicy jest związana z lewostronnym wyrażeniem indeksowym, a nie do łączana do Elist podczas tworzenia L. Takie produkcje pozwalają przekazywać wskaźnik wpisu w tablicy symboli dla nazwy tablicy jako syntezowany atrybut tablica nieterminala Elist . Korzystamy również z atrybutu Elist.lwym do pamiętania liczby wymiarów (wy rażeń indeksowych). Funkcja limit(tablica, j) zwraca n l i c z b ę elementów 7-tego wy miaru tablicy, na której wpis w tablicy symboli wskazuje tablica. Elist .pozycja ozna cza natomiast zmienną tymczasową przechowującą wartość obliczoną z wyrażeń indek sowych z Elist. Elist, który tworzy m indeksów odwołania do k wymiarowej tablicy A [*!, i f . . . i ], wygeneruje kod trójadresowy obliczający 2
2
1
2
f
k
W języku C tablice wielowymiarowe są symulowane przez definiowanie tablic, których elementami są tablice. Przykładowo, załóżmy, że x jest tablicą tablic liczb całkowitych. Wówczas można napisać zarówno x [ i ], jak i x [ i ] [ j ] , a rozmiary wartości tych wyrażeń będą różne. Jednakże dolnym ograniczeniem indeksu jest zawsze 0, więc czynnik w drugim wierszu (8.6) jest zawsze upraszczany do postaci baza. Przekształcenie to jest podobne do przekształcenia opisanego na końcu p. 5.6; służy do eliminacji atrybutów dziedziczonych. Również tu problem mogliśmy rozwiązać, korzystając z atrybutów dziedziczonych.
korzystając z rekurencji
Czyli, gdy m = k, do otrzymania wartości z pierwszego wiersza (8.6) będzie potrzebne już tylko mnożenie przez szerokość w. Zauważmy, że i- mogą być wartościami wyrażeń, a kod obliczający te wartości będzie przemieszany z kodem obliczającym (8.7). /-Wartość L będzie miała dwa atrybuty, L.pozycja i L.przesunięcie. W przypadku gdy L jest nazwą prostą, L.pozycja jest wskaźnikiem wpisu w tablicy symboli dla tej nazwy, a L.przesunięcie jest równe nuli, wskazując, że /-wartość jest nazwą prostą, a nie odwołaniem do tablicy. Nieterminal E ma atrybut E.pozycja, o takim samym znaczeniu, jak na rys. 8.15.
Schemat translacji do adresowania elementów tablicy Do następującej gramatyki dodamy akcje semantyczne: (1)
S
L
(2)
:= E +
(3)
E->(E)
(4)
E
(5)
L -> Elist ]
(6)
L
L -> id
(7)
Elist -> Elist , E
(8)
Elist -> id [ £
Tak jak w przypadku wyrażeń bez odwołań d o tablic, kod trójadresowy także jest pro dukowany przez procedurę emit wywoływaną przez akcje semantyczne. Generujemy przypisanie normalne, jeśli L jest nazwą prostą, a indeksowane — d o lokacji oznaczanej przez L w innym przypadku. (1)
S -> L := E
{ i f L.przesunięcie
— nuli then /* L jest prostym id */
emit (L.pozycja
'
E.pozycja);
else emit (L.pozycja ' [' L.przesunięcie' E.pozycja) }
]'
' :='
Kod dla wyrażeń arytmetycznych jest dokładnie taki sam, j a k na rys. 8.15 (2) (3)
E -t £
E
+E
x
-* ( E
2
x
)
{ E.pozycja := emit (E.pozycja { E.pozycja
nowatymcz; ' :=' E pozycja v
:= E pozycja v
'+'
E pozycja) v
}
}
Jeżeli odwołanie L do tablicy jest redukowane do E, potrzebujemy r-wartości L. Używamy więc indeksowania do otrzymania zawartości lokacji L.pozycja[L.przesunięcie]
(4)
E -¥ L
{ if L.przesunięcie — nuli then /* L jest prostym id */ E.pozycja := L.pozycja else begin E.pozycja := nowatymcz; emit (E.pozycja' L.pozycja' ['L.przesunięcie ' end }
]')
Poniżej, L.przesunięcie jest nową zmienną tymczasową reprezentującą pierwszy czynnik (8.6); funkcja szerokość(Elist.tablica) zwraca w z (8.6). L.pozycja reprezentuje drugi człon (8.6), zwrócony przez funkcję c(Elist.tablica). (5)
L -> Elist ]
{ L.pozycja := nowatymcz; L.przesunięcie := nowatymcz; emit (L.pozycja ' :=' c(E list.tablica)); emit (L.przesunięcie ' :=' Elist.pozycja ' *' szerokość(Elist .tablica)) }
Przesunięcie równe nuli oznacza nazwę prostą. (6)
L
id
{ L.pozycja := L.przesunięcie
id.pozycja; :- nuli }
Gdy widzimy kolejne wyrażenie indeksowe, stosujemy rekurencję (8.8). W poniż szej akcji, Elist .pozycja odpowiada e _ z (8.8), a Elist.pozycja odpowiada e . Za uważmy, że jeśli Elist ma m - 1 czynników, to Elist po lewej stronie produkcji ma m czynników. x
m
x
m
x
(7)
Elist
-¥ Elist
x
, E
{ t := nowatymcz; m := Elist .lwym + 1; emit(t ' :-' Elist .pozycja ' *' limit(Elist .tablica m)); emit(t ':='?'+' E.pozycja); Elist.tablica :~ Elist .tablica; Elist .pozycja := t; Elist.lwym := m } x
x
x
i
x
E.pozycja (8)
Elist
przechowuje zarówno wartość wyrażenia £ , jak i wartość (8.7) dla m = 1. -> id [ E
{ Elist .tablica := id.pozycja; Elist .pozycja := E.pozycja; Elist.lwym := 1 }
Przykład 8.2. Niech A będzie tablicą o rozmiarze 10 x 20, z dol = dol = 1. Wobec tego n = 10, a n = 20. Niech w będzie równe 4. Opisane drzewo wyprowadzenia dla przypisania x : =A [ y , z ] widać na rys. 8.18. Przypisanie jest tłumaczone na następujący ciąg instrukcji trójadresowych: x
x
2
2
t[:=y*20 t,:=t[+z
t :=c
/ * stała c = baza-%4
2
t :=4*r 3
t x
4
: = t
:=t
1
[ t
2
*/
3
]
4
Dla wszystkich zmiennych używaliśmy ich nazw zamiast
L.pozycja L.przesunięcie
id.pozycja.
E.pozycja
= t
4
L.pozycja
~ t
2
L.przesunięcie
~ t
3
= x
= nuli
I
Elist.pozycja
=
t
Elist.lwym
-
2
Elist.tablica
=
A
Ł
Elist.pozycja
E.pozycja
Elist.lwym
I
Elist.tablica
L.pozycja L.przesunięcie
[
E.pozycja L.pozycja L.przesunięcie
=
y
~
y
=
z
-
z
=
nuli
= nuli
I y Rys. 8.18. Opisane drzewo składniowe dla x : = A [ y , z
Konwersje typów w przypisaniach W praktyce, zmienne i stałe mogą być różnych typów, więc kompilator musi albo nie pozwalać na wykonywanie pewnych operacji na różnych typach danych, albo generować odpowiednie instrukcje konwersji typów. Rozważmy gramatykę dla instrukcji przypisania, tak j a k powyżej, ale zakładając, że istnieją dwa typy — liczby rzeczywiste i całkowite i że liczby całkowite są konwertowane na liczby rzeczywiste, gdy jest to konieczne. Wprowadźmy kolejny atrybut, E.typ, którego wartością jest real albo integer. Regułą semantyczną dla E.typ związaną z produkcją + £ będzie
E
E +E
{ E.typ := if E .typ ~ integer and E .typ = integer then integer x
2
else rea/ } Reguła ta przypomina reguły z p . 6.4; jednakże tu i w pozostałej części rozdziału p o mijamy sprawdzanie błędów związanych z typami; opis sprawdzania zgodności typów przedstawiliśmy w rozdz. 6. Reguły semantyczne dla E -> E + E i większości pozostałych produkcji należy tak zmodyfikować, aby — jeżeli okaże się to konieczne — generować instrukcje trójadre sowe o postaci x : = i n t t o r e a l y , których efektem jest konwersja liczby całkowitej y na liczbę rzeczywistą o tej samej wartości, nazywaną x . D o kodu dla operatora musimy również dołączyć wskazanie, czy chcemy używać arytmetyki stało- czy zmiennopozycyj nej. Kompletna akcja semantyczna dla produkcji o postaci E —> E + E jest pokazana na rys. 8.19. x
2
E.pozycja := nowatymcz; if Eytyp = integer and E .typ — integer then begin emit (E.pozycja ' :=' E .pozycja 'int + ' E .pozycja); E.typ '.— integer end e\se'ń E .typ = real and E .typ = real then begin emit (E.pozycja ' \ - E .pozycja real+' E .pozycja)\ E.typ := real end else if E .typ = integer and E .typ = real then begin u := nowatymcz; emit(u ' :=' 'inttoreal' E .pozycja)\ emit (E.pozycja ' :=' u 'real+' E .pozycja)\ E.typ := real end else if Ej .typ = real and E .typ = integer then begin u :— nowatymcz; emit(u ' :=' 'inttoreal' E .pozycja); emit(E.pozycja ' :=' E .pozycja 'real+' u); E.typ :— real end else E.typ := błąd~typów; 2
x
x
2
2
r
,
x
2
x
2
x
2
2
2
x
Rys. 8.19. Akcja semantyczna dla E —> E + E x
2
Przykładowo, dla wejścia x:=y+i*j przyjmując, że x i y są typu real, a i i j — typu integer, wyjście wyglądałoby nastę pująco:
t :=i int* j t :=inttoreal t :=y real + t x: = t 1
tj
3
2
3
2
Akcje semantyczne z rysunku 8.19 używają dwóch atrybutów, E.pozycja i E.typ dla nieterminala E. Wraz ze wzrostem liczby typów podlegających konwersji liczba powsta jących przypadków może rosnąć kwadratowo (albo nawet szybciej, jeśli istnieją operatory o więcej niż dwóch argumentach). W związku z tym, przy dużej liczbie typów znaczenia nabiera uważne organizowanie akcji semantycznych.
Odwołania do pól w rekordach Kompilator musi przechowywać zarówno typ, jak i adres względny pól rekordów. Korzy ścią płynącą z przechowywania tych informacji we wpisach w tablicy symboli dla nazw pól jest to, że procedury sprawdzającej nazwy w tablicy symboli można wówczas używać również do sprawdzania nazw pól. W związku z tym, akcje semantyczne z rys. 8.14 two rzą oddzielne tablice symboli dla każdego typu rekordu. Jeśli t jest wskaźnikiem tablicy symboli dla typu rekordu, to typ record(t) utworzony przez zastosowanie konstruktora record do wskaźnika jest zwracany jako T.typ. Wyrażenia pt.info
+ 1
używamy do pokazania, jak z atrybutu E.typ można wydobyć wskaźnik tablicy symboli. Z operacji w tym wyrażeniu wynika, że p musi być wskaźnikiem rekordu, w którym jest pole i n f o typu arytmetycznego. Jeśli typy są tworzone tak, jak na rys. 8.13 i 8.14, to typ p musi być dany wyrażeniem point er
(record(t))
Typem pT jest wówczas record(t), z którego można wydobyć t. Nazwa pola i n f o jest sprawdzana w tablicy symboli wskazywanej przez t.
8.4
Wyrażenia logiczne
Wyrażenia logiczne w językach programowania mają dwa główne zastosowania. Są używane do obliczania wartości logicznych, ale częściej jako wyrażenia warunkowe w instrukcjach zmieniających przepływ sterowania, takich jak instrukcje if-then-else czy while-do. Wyrażenia logiczne składają się z operatorów logicznych (and, or i not) stoso wanych do elementów, które są zmiennymi logicznymi bądź wyrażeniami relacyjnymi. Z kolei wyrażenia relacyjne mają postać E oprel E , gdzie E i E to wyrażenia aryt metyczne. Niektóre języki, takie jak PL/I, pozwalają używać bardziej ogólnych wyrażeń, w których operatory logiczne, relacyjne i arytmetyczne mogą być stosowane do wyrażeń dowolnego typu, bez rozróżniania wartości logicznych i arytmetycznych; konwersja tyx
2
x
2
pów jest wykonywana, gdy jest to konieczne. W tym podrozdziale rozważamy wyrażenia logiczne generowane przez następującą gramatykę:
E -> E or E | E and E | not E | ( E ) | id oprel id | true | false Używamy atrybutu op do stwierdzenia, który z operatorów porównania: < , ^ , = , ^ , > , > jest reprezentowany przez oprel. Tak jak zwykle, przyjmujemy, że or i and są lewostronnie łączne oraz że or ma najniższy priorytet, and wyższy, a not — najwyższy.
Metody tłumaczenia wyrażeń logicznych Istnieją dwie główne metody reprezentowania wartości wyrażeń logicznych. Pierw sza z nich to zakodowanie stałych true i false za pomocą liczb i wyliczanie wyrażenia logicznego, tak jak wyrażenia arytmetycznego. Często l oznacza true, a 0 — false, choć możliwych jest wiele innych kodowań. Możemy, na przykład, ustalić, że dowolna wartość niezerowa oznacza true, a 0 oznacza false, albo że wartości nieujemne oznaczają true, a ujemne — false. Drugą metodą jest implementacja wyrażeń logicznych korzystająca z przepływu ste rowania, czyli reprezentowanie wartości wyrażenia przez pozycję osiągniętą w programie. Metoda ta jest wyjątkowo wygodna do implementacji wyrażeń logicznych w instrukcjach sterujących, takich jak if-then i while-do. Rozpatrując na przykład wyrażenie E or E — gdy stwierdzimy, że E jest prawdziwe — to nie wyznaczając wartości E możemy wywnioskować, że całe wyrażenie jest prawdziwe. Semantyka języka programowania określa, czy wszystkie części wyrażenia logicz nego muszą być wyliczane. Jeśli definicja języka pozwala (lub nakazuje), aby część wy rażenia logicznego nie była wyliczana, to kompilator może zoptymalizować wyliczanie wartości wyrażenia logicznego tak, aby wyliczać tylko fragment wyrażenia pozwalający określić jego wartość. Wówczas, w wyrażeniach typu E or £ , ani E , ani E nie muszą być wyliczone w całości. Jeśli E bądź E jest wyrażeniem wywołującym efekty uboczne (np. zawierającym funkcję zmieniającą wartość zmiennej globalnej), to można otrzymać nieoczekiwaną odpowiedź. Żadna z tych metod nie jest zawsze lepsza od drugiej. Na przykład, kompilator opty malizujący BLISS/11 (Wulf i in. [1975]) dla każdego wyrażenia indywidualnie wybiera właściwą metodę. W tym podrozdziale rozpatrujemy obie metody tłumaczenia wyrażeń logicznych na kod trójadresowy. x
x
2
v
x
x
2
x
2
2
Reprezentacja liczbowa Rozważmy najpierw implementację wyrażeń logicznych używającą 1 na oznaczenie true i 0 na oznaczenie false. Wyrażenia będą wyliczane w całości, od lewej do prawej, po dobnie jak wyrażenia arytmetyczne. Przykładowo, tłumaczeniem
a or b and not c *
jest następujący kod trójadresowy:
tj:=not c t :=b ancł tj t :-a or t 2
3
2
Wyrażenie relacyjne, takie j a k a < b , jest równoważne instrukcji warunkowej i f a < b t h e n 1 e l s e 0, która może być przetłumaczona na następujący fragment kodu trójadresowego (ponownie rozpoczynamy numerowanie instrukcji od 100): 100: 101: 102: 103: 104:
i f a
103
Schemat translacji tworzący instrukcje trójadresowe z wyrażeń logicznych przedsta wiliśmy na rys. 8.20; przyjęliśmy, że emit umieszcza instrukcje trójadresowe o odpo wiednim formacie w pliku wyjściowym, nastinstr podaje indeks kolejnej instrukcji trójadresowej w sekwencji wyjściowej, a emit zwiększa nastinstr p o wygenerowaniu każdej instrukcji trój adresowej. E -» E or E x
2
{ E.pozycja nowatymcz; emit (E.pozycja ' E pozycja
'or' E .pozycja)
{ E.pozycja :— nowatymcz; emit (E.pozycja ' :=' E pozycja
'and' E .pozycja)
v
E and E x
2
2
v
}
2
}
{ E.pozycja :— nowatymcz; emit (E.pozycja ' :=' 'not' E . pozycja) }
E —• not E
x
x
{ E.pozycja := E pozycja v
E -> idj oprel id
2
}
{ E.pozycja \— nowatymcz; emit( i f id .pozycja relop.o/? id .pozycja emit(E.pozycja ' :=' '0'); emit('goto' nastinstr + 2); emit (E.pozycja ' :=' ' 1') } r
{
'goto'
2
true
{ E.pozycja := nowatymcz; emit (E.pozycja ' :=' ' 1') }
false
{ E.pozycja := nowarymcz; emit (E.pozycja ' :=' '0') }
nastinstr+3);
Rys. 8.20. Schemat translacji używającej liczbowej reprezentacji wartości logicznych P r z y k ł a d 8.3. Schemat z rysunku 8.20 dla wyrażenia a < b o r ruje kod z rys. 8.21. 100 101 102 103 104 105 106
if a
t l
2
107 108 109 110 111 112 113
c < d a n d e < f gene •
t :=l if e
3
3
4
2
5
Rys. 8.21. Tłumaczenie a
3
4
Kod skaczący Wyrażenia logiczne możemy również tłumaczyć na kod trójadresowy, nie generując ko du dla żadnego z operatorów i nie wymagając, by kod wyliczał wszystkie składniki wyrażenia. Taki styl wyznaczania wartości jest czasem nazywany kodem „krótkim" lub „skaczącym". Wartość wyrażenia logicznego można wyznaczyć, nie generując kodu dla operatorów logicznych and, or ani not, jeśli wartość wyrażenia będzie reprezentowa na przez pozycję w kodzie. Z rysunku 8.21, na przykład, możemy stwierdzić, jaka jest wartość t j , sprawdzając, czy wykonamy instrukcję 101 czy 103, a więc wartość t j jest zbędna. Dla wielu wyrażeń logicznych możliwe jest wyznaczenie wartości tych wyrażeń bez konieczności wyliczania ich w całości.
Instrukcje sterujące Rozważmy teraz tłumaczenie wyrażeń logicznych na instrukcje trójadresowe w kontek ście instrukcji if-then, if-then-else i while-do, takich jak generowane przez następującą gramatykę:
S -> if E then S, | if E then S else S | while E do S {
2
{
W każdej z tych produkcji E jest wyrażeniem logicznym, które chcemy przetłumaczyć. W translacji przyjmujemy, że instrukcjom trójadresowym można przypisywać etykiety symboliczne i że funkcja nowaetykieta przy każdym wywołaniu zwraca nową etykietę symboliczną. Z wyrażeniem logicznym E wiążemy dwie etykiety: E.true, d o której przechodzi sterowanie, gdy E jest prawdziwe, oraz E.false, do której sterowanie przechodzi, gdy E jest równe false. Reguły semantyczne dla tłumaczenia instrukcji sterującej S pozwalają sterowaniu przejść z tłumaczonego S.kod d o instrukcji trójadresowej bezpośrednio p o S.kod. W niektórych przypadkach instrukcją bezpośrednio p o S.kod jest skok do pewnej etykiety L. Skoku d o skoku d o L z wnętrza S.kod unika się, używając dziedziczonego atrybutu S.nast. Wartością S.nast jest etykieta dołączana do pierwszej instrukcji trójadre sowej, która będzie wykonywana p o kodzie dla S . Inicjowania S.nast nie pokazujemy. W trakcie tłumaczenia instrukcji S —>• if E then S jest tworzona nowa etykieta E.true, która jest dołączana do pierwszej instrukcji trójadresowej generowanej dla in strukcji S p tak j a k pokazano na rys. 8.22(a). Definicja sterowana składnią jest przedsta wiona na rys. 8.23. Kod dla E generuje skok do E.true, jeśli E jest prawdą, i skok do S.nast, gdy E jest fałszem. Nadajemy więc E.false wartość S.nast. Podczas tłumaczenia instrukcji S —> if E then S else S , w kodzie dla wyrażenia logicznego E umieszczany jest skok do pierwszej instrukcji kodu dla S j , wykonywany, gdy wartością E jest true, oraz skok do pierwszej instrukcji kodu dla S , wykonywa ny* gdy tą wartością jest false. Pokazano to na rys. 8.22(b). Tak jak przy instrukcji if-then, atrybut dziedziczony S.nast przechowuje etykietę pierwszej instrukcji trójadresol
x
x
2
2
1
Przy najprostszej implementacji podejście z dziedziczeniem etykiety S.nast może prowadzić do zwiększenia liczby etykiet. Podejście nazywane poprawianiem, przedstawione w p. 8.6, tworzy etykiety tylko wtedy, gdy są one konieczne.
wej wykonywanej po kodzie dla S. Jawna instrukcja g o t o S.nast jest umieszczana po ko dzie dla S ale nie ma jej po S . Czytelnikowi pozostawiamy wykazanie, że przy takich regułach semantycznych — jeśli S.nast nie jest etykietą instrukcji występującej bez pośrednio po S .kod — to otaczająca instrukcja wstawi za kodem dla S skok do etykie ty S.nast. v
2
2
2
do E.true E.kod
do E.false
do E.true E.kod
E.true:
do E.false
S kod v
E.true:
goto
S .kod x
S.nast
E.false: S .kod
E.false
2
S.nast: (b) if-then-else
(a) if-then
do E.true S.początek: E.kod
do E.false
E.true: S .kod x
goto
S.początek
E.false:
(c) while-do Rys. 8.22. Kod dla instrukcji if-then, if-then-else i while-do
Kod dla S -> while E d o S wygląda tak, jak na rys. 8.22(c). Tworzona jest no wa etykieta S.początek, która jest dołączana do pierwszej instrukcji generowanej dla E. Kolejna nowa etykieta E.true, jest dołączana do pierwszej instrukcji kodu dla S . Kod dla E wykonuje skok do tej etykiety, gdy E jest równe true oraz skok do S.nast, gdy E jest równe false; tak j a k poprzednio, nadajemy E.false wartość S.nast. Po kodzie dla S umieszczamy instrukcję g o t o S.początek, która powoduje skok wstecz do początku kodu dla wyrażenia logicznego. Zauważmy, że S nast ma war tość równą S.początek, więc skoki z wnętrza S .kod mogą prowadzić bezpośrednio do S.początek. x
x
{
v
{
Tłumaczenie instrukcji sterujących bardziej szczegółowo opisaliśmy w p. 8.6, przed stawiając tam alternatywną metodę, nazywaną „poprawianiem", pozwalającą tworzyć kod dla takich instrukcji w jednym przebiegu.
REGUŁY SEMANTYCZNE
PRODUKCJA
= = = •=
E.true E.false S .nast S.kod
5 -* if E then S
x
x
x
:= := := :=
E.true E.false S .nast S .nast S.kod
5 --» if E then S else S
2
x
2
:=
nowaetykieta; S.nast; S.nast; E.kod || gen(E.true '
:') II S
x
.kod
nowaetykieta; nowaetykieta; S.nast; S.nast;
|| gen(E.true ' S .kod U goto' S.nart) || gen(E.false ' : ' ) II S .torf x
2
S -4 while E do 5
:= := := := :=
S.początek E.true E.false S .nast S.kod
t
x
nowaetykieta; nowaetykieta; S.nast; S.początek; gen(S.początek ' : ' ) || || gen(E.true ' : ' ) I I SpJfcorf || gen( goto' S.początek) r
Rys. 8.23. Definicja sterowana składnią dla instrukcji sterowania
Tłumaczenie wyrażeń logicznych na ciąg instrukcji sterujących Zajmiemy się teraz E.kod, kodem tworzonym dla wyrażeń logicznych E z rys. 8.23. Tak j a k wspominaliśmy, E jest tłumaczone na ciąg instrukcji trójadresowych, które wyli czają E jako ciąg bezwarunkowych i warunkowych skoków d o jednego z dwóch miejsc: E.true, miejsca do którego sterowanie przechodzi, gdy E jest prawdą, i E.false — gdy E jest fałszem. Ogólne podejście do translacji jest następujące: załóżmy, że E m a postać a < b ; generowany wówczas kod to if a
E.true
Przypuśćmy, że E m a postać E or E . Jeśli E jest prawdą, to od razu wiemy, że całe E jest prawdą, więc E true jest takie, j a k E.true. Jeśli E jest fałszem, to musimy obliczyć E , więc E.false jest równe etykiecie pierwszej instrukcji kodu dla E . Wyjścia z E dla prawdy i fałszu są takie same jak, odpowiednio, wyjścia dla prawdy i fałszu z E. Analogiczne rozumowanie stosujemy d o tłumaczenia E and E . D l a wyrażenia E o postaci not E nie jest wymagany żaden kod: aby otrzymać wyjścia dla prawdy i fałszu z E, zamieniamy tylko miejscami wyjścia dla prawdy i fałszu z E . Definicja sterowa na składnią, która tą metodą generuje kod trójadresowy dla wyrażeń logicznych, jest przedstawiona na rys. 8.24. Zauważmy, że atrybuty true i false są dziedziczone. x
2
x
v
x
2
2
2
x
2
x
x
REGUŁY SEMANTYCZNE
PRODUKCJA
E —> E or E x
E .true E .false E .true E . false E.kod
:— E.true; := nowaetykieta; := E.true; := E.false; := E kod || gen(E .false
E .true E . false E .true E . false E.kod
:= := := := :=
x
2
x
2
2
E —> E and E x
x
2
x
2
2
v
' :') || £ .A:o^
x
nowaetykieta; E.false; E.true; E.false; Epifeorf || gen(E .true x
2
' : ' ) || £ .&0rf 2
E .true := E.false; E .false := E.true; E.kod := E .kod
E -> not E
x
x
x
x
E -+(E
E .true := E.true; E .false := E.false; E.kod := E .kod
)
X
x
x
x
E
id! relop i d
2
E.kod := gen(' i f ' idj .pozycja relop. id .pozycja ' g o t o ' E.true) \\ gen( goto' E.false) 2
f
r
E —> true
E.kod := gen( goto'
E - ł false
E.kod := gen(' g o t o '
E.true) E.false)
Rys. 8.24. Definicja sterowana składnią generująca kod trójadresowy dla wyrażeń logicznych Przykład 8.4. a
Rozpatrzmy ponownie wyrażenie c
Przypuśćmy, że wyjścia dla prawdy i fałszu dla całego wyrażenia zostały ustawione na, odpowiednio, L t r u e i L f a l s e . Wówczas, korzystając z definicji z rys. 8.24, otrzymamy następujący kod:
LI: L2:
i f a
Widać, że generowany kod nie jest optymalny, bo drugą instrukcję można usunąć, nie zmieniając wyniku. Zbędne instrukcje tego rodzaju można później usunąć, wykonując prostą optymalizację przez szparkę (patrz rozdz. 9). Innym podejściem, które pozwala uniknąć generowania tych niepotrzebnych skoków, jest tłumaczenie wyrażenia relacyjnego o postaci idj < i d na instrukcję i f i d ^ i d g o t o E.false przy założeniu, że gdy wynikiem jest prawda, wykonujemy kod. • 2
x
2
P r z y k ł a d 8.5.
Rozważmy instrukcję
while a
LI: if a
x: = t goto LI Lnase: 2
Widzimy, że pierwsze dwie instrukcje goto można wyeliminować, zmieniając kierunek testów. Ten rodzaj lokalnego przekształcenia można wykonać przez optymalizację przez szparkę, opisaną w rozdz. 9. •
Mieszane wyrażenia logiczne Należy zauważyć, że uprościliśmy gramatykę dla wyrażeń logicznych. W praktyce, wyra żenia logiczne często zawierają podwyrażenia, tak jak w (a+b) < c . W językach, w któ rych fałsz m a wartość liczbową 0, a prawda — 1, (a
E -> E + E \E and £ | E oprel E | id Możemy założyć, że wynikiem E+E jest liczba całkowita (dodanie liczb rzeczywi stych albo dowolnego innego typu arytmetycznego skomplikowałoby rozwiązanie, ale nie zwiększyło wartości dydaktycznej tego przykładu), a wyrażenia E and E oraz E oprel E tworzą wartości logiczne reprezentowane przez przepływ sterowania. Wyrażenie E and E wymaga, by oba argumenty były typu logicznego, a argumenty operacji + i oprel mogą być dowolnego typu lub nawet różnych typów. E -> id również musi być liczbą, cho ciaż można rozszerzyć ten przykład, pozwalając używać identyfikatorów dla wartości logicznych.
D o generowania kodu w tej sytuacji, możemy użyć atrybutu syntezowanego E.typ, który będzie równy arytm albo log, w zależności od typu E. E będzie miało atrybuty dziedziczone E.true i E.false dla wyrażeń logicznych i atrybut syntezowany E.pozycja dla wyrażeń arytmetycznych. Część reguł semantycznych dla E - ł E + E jest przed stawiona na rys. 8.25. x
2
E.typ := arytm; if E .typ — arytm and E .typ = arytm then begin /* zwykłe dodawanie liczb */ E.pozycja := nowatymcz; E.kod :=E kod || E .kod || gen(E.pozycja ' E^.pozycja '+' E .pozycja) end else if E .typ = arytm and E .typ = log then begin E.pozycja := nowatymcz; E .true := nowaetykieta; E .false :~ nowaetykieta; E.kod :=E kod \\ E .kod \\ gen(E .true ' :' E.pozycja ' :-' E .pozycja + 1) || gen( g o t o ' nosnnsfr-h 1) || gen(E .false ' E.pozycja ' :-' Eypozycja) else if • • x
2
v
2
2
x
2
2
2
y
2
2
{
r
2
Rys. 8.25. Reguły semantyczne dla produkcji E -> E + £ x
W przypadku trybu mieszanego generujemy kod dla E
v
2
potem dla E , po którym 2
wstawiamy trzy następujące instrukcje: E .true:
E.pozycja
g o t o nastinst+
1
E .false:
E.pozycja
E .pozycja
2
2
:= E .pozycja
+ 1
x
:=
{
e s
Pierwsza instrukcja oblicza wartość E + 1 dla £ , gdy £ J * prawdą, trzecia — wartość E dla £ , gdy E J fałszem. Druga instrukcja jest skokiem poza trzecią. Reguły seman tyczne dla pozostałych przypadków i innych produkcji są dość podobne i opracowanie ich pozostawiamy jako ćwiczenie. {
2
e s t
x
8.5
2
Instrukcje wyboru
Instrukcja „case" lub „switch" jest dostępna w wielu językach; nawet znane z Fortranu obliczane i przypisywane g o t o mogą być uważane za rodzaj instrukcji switch. Składnię naszej instrukcji switch przedstawiliśmy na rys. 8.26. Rozpatrzmy wyrażenie nazywane selektorem, które musimy obliczyć, i po którym następuje n stałych wartości, które to wyrażenie może przyjąć, w tym — być może — wartość domyślna, która zawsze pasuje do wyrażenia, jeśli nie pasuje d o niego żadna inna wartość. Zamierzonym tłumaczeniem instrukcji wyboru jest:
1. 2.
3.
Obliczenie wyrażenia. Wyszukanie na liście przypadków takiej samej wartości, j a k wartość wyrażenia. Przypomnijmy, że wartość domyślna pasuje do wyrażenia, jeśli żadna z jawnie wy mienionych wartości d o niego nie pasuje. Wykonanie instrukcji związanej ze znalezioną wartością.
Krok 2. jest n-kierunkowym rozgałęzieniem, które można zaimplementować na kilka sposobów. Jeśli liczba kierunków rozgałęzień nie jest zbyt wielka, powiedzmy co najwy żej równa 10, rozsądne jest użycie sekwencji warunkowych instrukcji g o t o , z których każda sprawdza pojedynczą wartość i przekazuje sterowanie do kodu dla odpowiadającej instrukcji.
switch wyrażenie begin case wartość: case wartość: case wartość: default: end
instrukcja instrukcja instrukcja instrukcja
Rys. 8.26. Składnia instrukcji switch
Bardziej zwięzłą metodą implementacji sekwencji warunkowych g o t o jest stworze nie tablicy par składających się z wartości i etykiety dla kodu odpowiadającej instrukcji. Generowany jest kod umieszczający na końcu tej tablicy obliczaną wartość wyrażenia połączoną z etykietą dla instrukcji domyślnej. Kompilator może wówczas wygenerować prostą pętlę, w której wartość wyrażenia jest porównywana z każdą z wartości z tabli cy, wiedząc, że jeśli żadna inna wartość nie będzie odpowiednia, to ostatnia (domyślna) wartość z pewnością będzie pasować d o wyrażenia. Jeśli liczba wartości przekracza 10, bardziej wydajnym podejściem jest budowa tablicy mieszającej (patrz p . 7.6) dla wartości, z etykietami odpowiadających instrukcji jako danymi. Jeśli w tablicy nie m a wartości wyrażenia, to może być generowany skok do instrukcji domyślnej. Często zdarza się szczególny przypadek, dla którego możemy stworzyć jeszcze bardziej efektywną implementację rozgałęzienia n-kierunkowego. Jeśli wszystkie war tości należą do pewnego małego zakresu, powiedzmy i do i ' ™ ^ , a liczba różnych wartości jest istotną częścią zakresu i ^ — i to możemy stworzyć tablicę etykiet z etykietą instrukcji dla wartości j n a pozycji tablicy o numerze j — i i etykietami dla instrukcji domyślnej na nie wypełnionych wcześniej pozycjach. Aby dokonać wy boru, obliczamy wartość wyrażenia, otrzymując j , sprawdzamy, że j jest w zakresie i do imax i wykonujemy skok pośredni do wartości z pozycji j - i tablicy. Na przykład, jeśli wyrażenie jest typu znakowego, można stworzyć tablicę o, powiedzmy, 128 pozy cjach (zależnie od rozmiaru zestawu znaków) i przekazywać sterowanie bez sprawdzania zakresów. m i n
m i n i
m i n
m i n
m i n
Translacja sterowana składnią dla instrukcji wyboru Rozpatrzmy następującą instrukcję switch:
switch E begin case V : case V : x 2
case^: default: end
S S
x 2
S_ S n
x
n
Używając schematu translacji sterowanej składnią, wygodnie jest przetłumaczyć taką instrukcję wyboru n a kod pośredni o postaci przedstawionej na rys. 8.27.
kod obliczający E w t goto t e s t kod dla S goto n a s t kod dla S
L: x
x
L :
2
2
goto nast V i
!
kod dla goto nast L : kod dla S goto nast t e s t : if t~V g o t o L if t=V g o t o L n
rt
x
x
2
2
if t=V _, g o t o L„_ goto h B
t
n
nast: Rys. 8.27. Tłumaczenie instrukcji wyboru
Wszystkie testy są wykonywane na końcu, aby prosty generator kodu mógł rozpoznać wielokierunkowe rozgałęzienia i wygenerować dla nich efektywny kod, używając najod powiedniejszej z opisanych w tym podrozdziale implementacji. Jeśli generowalibyśmy bardziej oczywistą sekwencję instrukcji (jak z rys. 8.28), kompilator musiałby wykonać skomplikowane analizy, aby znaleźć najefektywniejszą implementację. Podkreślmy, że umieszczanie instrukcji rozgałęziających n a początku nie jest wygodne, b o wtedy kom pilator nie mógłby od razu emitować kodu dla każdego napotkanego S . t
Wykonanie tłumaczenia d o postaci z rys. 8,27, gdy widzimy słowo kluczowe switch, wymaga utworzenia dwóch nowych etykiet, t e s t i n a s t , oraz nowej zmiennej tym czasowej t . Wówczas, gdy analizujemy wyrażenie E generujemy kod obliczający E i umieszczający wartość w t . Po przetworzeniu E wykonujemy skok g o t o t e s t . y
Następnie, gdy widzimy słowo kluczowe case, tworzymy nową etykietę L - i wstawia my ją do tablicy symboli. Na stosie, używanym tylko do pamiętania wyborów, umiesz czamy wskaźnik do tej etykiety w tablicy symboli i wartość V stałej wyboru. (Jeśli instrukcja switch jest umieszczona w jednej z instrukcji wewnętrznych innej instrukcji switch, to umieszczamy na stosie znacznik rozdzielający wybory wewnętrznej instrukcji switch od tych dla instrukcji zewnętrznej). r
i
kod obliczający E w t i f t^V g o t o Lj kod dla S goto nast i f t^V goto L kod dla S {
x
L : t
2
2
2
goto L
nast
2
L„_ : 2
i f t^V ^ goto kod dla 5 „ , goto nast kod dla S n
x
L_ n
{
n
L _!: nast: n
n
Rys. 8.28. Inne tłumaczenie instrukcji wyboru
Każdą instrukcję case V-: S przetwarzamy, emitując nowo utworzoną etykietę L,., po której następuje kod dla i skok g o t o n a s t . Gdy napotkamy słowo kluczo we e n d zamykające treść instrukcji wyboru, możemy generować kod dla rozgałęzienia n-kierunkowego. Odczytując od dołu do góry pary wskaźnik-wartość ze stosu wyborów, możemy wygenerować sekwencję instrukcji trójadresowych o postaci T
case case
V V
x
2
L L
Ł
2
case t L label nast rt
gdzie t jest nazwą przechowującą wartość wyrażenia £ , a L jest etykietą dla instrukcji domyślnej. Instrukcja trójadresowa c a s e V L- jest synonimem i f t=V g o t o L,z rys. 8.27, ale końcowy generator kodu łatwiej zauważy, że c a s e jest kandydatem do specjalnego potraktowania. W trakcie generowania kodu takie sekwencje instrukcji c a s e można przetłumaczyć na najefektywniejszą postać rozgałęzienia n-kierunkowego, w zależności od tego, j a k wiele ich jest i czy wszystkie wartości testowane w tym rozgałęzieniu pochodzą z niewielkiego zakresu. rt
i
i
8.6
Poprawianie
Najprostszą metodą implementacji definicji sterowanych składnią, opisanych w p . 8.4, jest użycie dwóch przebiegów. Najpierw konstruujemy drzewo składniowe dla wejścia, a następnie przechodzimy to drzewo w głąb, obliczając tłumaczenia opisane w definicji. Głównym problemem z jednoprzebiegowym generowaniem kodu dla wyrażeń logicznych i instrukcji sterujących jest to, że podczas pojedynczego przebiegu etykiety, do których sterowanie ma zostać przekazane w generowanych instrukcjach skoku, mogą być jesz cze nieznane. Problem ten możemy ominąć, generując ciągi instrukcji rozgałęziających z tymczasowo nieokreślonymi adresami docelowymi. Każdą taką instrukcję dodajemy do listy instrukcji skoku, dla których etykiety celów będą wypełnione wtedy, gdy odpowied nie etykiety będą znane. Takie późniejsze wypełnianie etykiet nazywamy poprawianiem (ang. backpatching). W tym podrozdziale omówiliśmy zastosowanie poprawiania do jednoprzebiegowego generowania kodu dla wyrażeń logicznych i instrukcji sterujących. Tłumaczenia, które będziemy tworzyli, będą takie, jak w p. 8.4, z wyjątkiem metody tworzenia etykiet. Dla ustalenia uwagi, będziemy generowali czwórki i umieszczali j e w tablicy czwórek. Etykiety będą indeksami tej tablicy. D o wykonania manipulacji na listach etykiet użyjemy trzech funkcji: 1) 2) 3)
twórzlistę(i) tworzy nową listę zawierającą tylko i, indeks do tablicy czwórek; twórzlistę zwraca wskaźnik do stworzonej listy, łącz(Pi, p ) łączy listy wskazywane przez p ip i zwraca wskaźnik do połączonych list, popraw(p, i) wstawia i jako etykietę celu do każdej z instrukcji z listy wskazywanej przez p. 2
x
2
Wyrażenia logiczne Stworzymy teraz schemat translacji odpowiedni do budowania czwórek dla wyrażeń lo gicznych podczas analizy wstępującej. Do gramatyki dodajemy, jako znacznik, nieter minal M, aby spowodować wykonanie w odpowiednich chwilach akcji semantycznej pobierającej indeks kolejnej generowanej czwórki. Gramatyka, z której korzystamy, to: (1) (2) (3) (4) (5) (6) (7) (8)
E -•> E or M E E and M E not E (E ) i d oprel i d true false M x
2
x
2
{
x
1
2
Syntezowane atrybuty listaprawd i listafalsz dla nieterminala E są używane do gene rowania skaczącego kodu dla wyrażeń logicznych. Podczas generowania kodu dla £ , skoki do wyjść dla prawdy i fałszu są pozostawiane niekompletne, z niewypełnionym polem etykiety celu. Takie niekompletne skoki są umieszczane na odpowiedniej z list wskazywanych przez E.listaprawd i E.listafalsz.
Akcje semantyczne odwzorowują prowadzone powyżej rozważania. Rozpatrzmy pro dukcję E ->• E a n d M E . Jeśli wartością E jest fałsz, to E również jest fałszem, więc instrukcje z E Mstafałsz zostają częścią EMstafałsz. Jeśli jednak E jest prawdziwe, to musimy sprawdzić wartość E więc celem instrukcji z E Mstaprawd musi być początek kodu wygenerowanego dla E . Cel ten jest znajdowany za pomocą znacznika — nieter minala M. W atrybucie M.czwórka pamiętany jest numer pierwszej instrukcji E .kod. Z produkcją M —> e wiążemy akcję semantyczną x
2
x
l
x
v
x
2
2
{ M.czwórka
:= nastczwórka
Zmienna nastczwórka
}
przechowuje indeks kolejnej generowanej czwórki. Wartością tą
poprawiamy E Mstaprawd,
gdy obejrzymy resztę produkcji E -» E
x
and M E . Schemat
x
2
translacji jest następujący: (1)
E —> E
or M E
x
{ popraw(E Mstafałsz M.czwórka); E.listaprawd := łącz(E Mstaprawd
2
x
1
x
EMstafałsz (2)
E -> E
and M E
x
:= E .listafałsz 2
y
E .listaprawd); 2
}
{ popraw(E Mstaprawd, M.czwórka); E.listaprawd := E .listaprawd; EMstafałsz := łącz(E Mstafałsz,E Mstafałsz)
2
x
2
x
(3)
E -> not E
2
}
{ E.listaprawd := E Mstafałsz\ EMstafałsz := E Mstaprawd }
x
x
x
(4)
E
( E
x
)
{ E.listaprawd :E .listaprawd; EMstafałsz := E Mstafałsz } x
x
(5)
E —> id
x
oprel id { E.listaprawd 2
EMstafałsz emit( i f r
f
emit( E
true
{ E.listaprawd
(7)
E ->
false
{ EMstafałsz
' goto
_')
:twórziistę(nastczwórka); emit(' g o t o _ ' ) }
r
emit( M —» e
2
goto _ ') }
(6)
(8)
:= twórzlistę(nastczwórka); '•= twórzlistę(nastczwórka-\-1); idj.pozycja oprel.op id .pozycja
:= twórziistę(nastczwórka); goto _') }
{ M.czwórka
:= nastczwórka
}
Dla uproszczenia, akcja (5) generuje dwie instrukcje, skok warunkowy i bezwarunko wy, z których żaden nie ma podanego celu. Indeks pierwszej generowanej instrukcji jest wstawiany do nowej listy, a E.listaprawd zostaje wskaźnikiem tej listy. Indeks dru giej instrukcji, g o t o _ , również jest wstawiany do nowej listy, do której wskaźnik jest zapamiętywany w EMstafałsz.
Przykład 8,6,
Rozważmy ponownie wyrażenie a < b o r c < d a n d e < f . Opisane drzewo składniowe dla tego wyrażenia jest na rys. 8.29. Akcje są wykonywane podczas przechodzenia drzewa w głąb. Ponieważ wszystkie akcje występują na końcu prawych
E.t= {100, 104} E.f = {103,105} Mx « 102 E.t= E.f= a
{100} {101}
E.t = {104} £ . / = {103, 105}
e
/ I< \ b
Mc E.t = {102} £ . / = {103}
c
104 £./ = {104} E.f = {105}
e
/ l< \ d
e
<
f
Rys. 8.29. Drzewo składniowe dla wyrażenia a < b o r c
stron, mogą być wykonywane w trakcie analizy wstępującej w połączeniu z redukcjami. W odpowiedzi na redukcję a < b do E, zgodnie z produkcją (5), generowane są dwie czwórki
100: i f a < b goto _ 101: goto _ (Ponownie zaczynamy numerować instrukcje od 100). Nieterminal M w produkcji E -> E or M E zapamiętuje wartość nastczwórka, którą w tej chwili jest 102. Redukcja c
2
102: i f c E and M E . Znacznik M w tej produkcji za pamiętuje aktualną wartość nastczwórka, którą jest 104. Redukcja e < f do E, zgodnie z produkcją (5), generuje x
{
2
104: i f e < f goto _ 105: goto _ Redukujemy teraz według E -»E and M E . Odpowiadająca akcja semantyczna wywołuje popraw({\02}, 104), gdzie { 1 0 2 } jako argument oznacza wskaźnik listy za wierającej tylko 102, czyli listy, którą wskazuje E listaprawd. To wywołanie popraw wstawia 104 do instrukcji 102. Sześć wygenerowanych do tej pory instrukcji to x
2
v
100: 101: 102: 103: 104: 105:
i f a < b goto _ goto _ i f c
Akcja semantyczna związana z redukcją końcową zgodnie z E —t E or M E wołuje popraw({\0\}, 102), co powoduje, że instrukcje wyglądają następująco: x
2
wy
100: 101: 102: 103: 104: 105:
i f a
Całe wyrażenie jest prawdziwe wtedy i tylko wtedy, gdy zostaną osiągnięte instrukcje skoku w wierszu 100 lub 104, a fałszywe — gdy zostaną osiągnięte w wierszu 103 lub 105. Cele skoków w tych instrukcjach zostaną wypełnione później, gdy będzie wiadomo, co należy zrobić, gdy wyrażenie jest prawdziwe, a co, gdy jest fałszywe. •
Instrukcje sterujące Poniżej omówiliśmy zastosowanie poprawiania do jednoprzebiegowego tłumaczenia in strukcji sterujących. Tak j a k powyżej, zajmiemy się generowaniem czwórek; będziemy również korzystać z nazw pól i procedur obsługujących listy z poprzedniego podrozdzia łu. Jako przykład opracowaliśmy schemat translacji dla instrukcji generowanych przez następującą gramatykę:
(1) S if E then S (2) | if E then S else S (3) | while £ do 5 (4) | begin L end (5) (6) (7)
| A L->L;5 | S
S oznacza tu instrukcję, L — listę instrukcji, A — instrukcję przypisania, a £ — wy rażenie logiczne. Zauważmy, że muszą istnieć inne produkcje, takie j a k produkcje dla instrukcji przypisania. Podane produkcje są jednak wystarczające do przedstawienia tech nik używanych w tłumaczeniu instrukcji sterujących. Używamy tej samej struktury kodu dla instrukcji if-then, if-then-else i while-do, co w p. 8.4. Zakładamy, że kod, który występuje bezpośrednio po danej instrukcji w trakcie wykonywania, jest również bezpośrednio po kodzie tej instrukcji w tablicy czwórek. Jeśli nie jest to prawdą, konieczne jest wstawienie jawnych instrukcji skoku. Ogólnym podejściem będzie wypełnianie adresów skoków w instrukcjach, gdy ad resy te będą znajdowane. Wyrażenia logiczne wymagają dwóch list skoków, które są wy konywane, gdy wyrażenie jest prawdziwe i gdy jest fałszywe, a ponadto każda instrukcja potrzebuje listy skoków (przechowywanych w atrybucie listanast) do kodu, który jest wykonywany po danej instrukcji.
Schemat implementacji translacji W tym punkcie opisaliśmy schemat translacji sterowanej składnią, generujący tłumacze nia opisanych powyżej instrukcji sterowania. Nieterminal E ma, tak jak powyżej, dwa atrybuty: E.listaprawd i EMstafałsz. L i S również potrzebują nie wypełnionych list czwórek, które w końcu muszą zostać uzupełnione przez poprawianie. Wskaźnikami do
tych list są atrybuty S.listanast i L.listanast. S.listanast jest wskaźnikiem listy wszyst kich warunkowych i bezwarunkowych skoków d o czwórki wykonywanej p o instrukcji S, i L.listanast jest definiowane analogicznie. W modelu kodu dla S -> while E do S (patrz rys. 8.22(c)) mamy etykiety S.początek i E.true zaznaczające początek kodu dla całej instrukcji S i początek treści S . D w a wy stąpienia znacznika M w następującej produkcji służą zapamiętaniu numerów czwórek w tych miejscach: x
x
S -> while M E do M S x
2
x
Ponownie, jedyną produkcją dla M jest M —> e, z akcją ustawiającą atrybut M.czwórka na numer następnej czwórki. Po wykonaniu treści S instrukcji while sterowanie wraca do jej początku. Wobec tego, gdy redukujemy while M E do M S d o 5, S .listanast poprawiamy w taki sposób, aby celem wszystkich instrukcji z tej listy było M czwórka. Jawny skok do początku kodu dla E jest dodawany p o kodzie dla S bo sterowanie może „wypaść poza instrukcję". E.listaprawd jest poprawiana tak, aby wskazywała początek S przez wstawienie do skoków z listy E.listaprawd adresu M .czwórka. x
x
2
x
x
v
v
[t
2
Ważniejszy argument przemawiający za używaniem S.listanast i L.listanast znaj dujemy, gdy generujemy kod dla instrukcji warunkowej if E then S else S . Gdy ste rowanie może „wypaść poza" S tak j a k wtedy, gdy S jest przypisaniem, na końcu kodu dla S musimy umieścić skok poza kod dla S . Użyjemy kolejnego znacznika, aby wprowadzić ten skok p o S . Niech znacznikiem tym będzie nieterminal N , z produkcją N -¥ e. N ma atrybut N.listanast, który będzie listą składającą się z numeru czwórki in strukcji goto generowanej przez reguły semantyczne dla W. Podajemy teraz reguły semantyczne dla poprawionej gramatyki. x
v
2
x
x
2
x
(1) S -> if E then M S N else M S x
x
2
2
{ popraw(E Mstaprawd, M .czwórkd)\ popraw(E Mstafałsz, M .czwórka); S.listanast := łącz(S .listanast, łącz(N.listanast, x
2
S .listanast))
x
}
2
Skoki dla przypadku, gdy E jest prawdziwe, poprawiamy, wstawiając adres czwórki M .czwórka, będący adresem początku kodu dla S . Podobnie, skoki dla przypadku, gdy E jest fałszywe, poprawiamy, wstawiając adres początku kodu dla S . Lista S.listanast zawiera wszystkie skoki prowadzące na zewnątrz z S i S oraz skok generowany przez N. x
x
2
x
(2)
N -> e
2
{ N.listanast := twórzlistę(nastczwórka); emit( goto _') } { M.czwórka := nastczwórka } { popraw(EXistaprawd M.czwórka); S.listanast := łącz(E.listafałsz, S .listanast) { popraw(S .listanast M .czwórka); popraw(E.listaprawd, M .czwórka); S.listanast := EMstafałsz emit(' goto' M .czwórka) } { S.listanast := L.listanast } { S.listanast := nil } f
(3) (4)
M —> e S -> if E
(5)
S —> while M E
then M S
x
%
x
x
do M S 2
x
x
)
x
2
x
(6) (7)
5 -)• begin L S -> A
end
}
Przypisanie S.listanast (8)
L —• L
x
:= nil powoduje, że S.listanast
; M S
jest listą pustą.
{ popraw(L .listanast, M.czwórka); L.listanast := S.listanast } x
Instrukcją następną po L w kolejności wykonywania jest początek S. Wobec tego, skoki z Lylistanast są poprawiane tak, aby ich celem był początek kodu dla S, którego indeks jest w M.czwórka. x
(9)
L -> S
{ L.listanast
:= S.listanast
}
Zauważmy, że oprócz reguł (2) i (5), te akcje semantyczne nie generują nowych czwórek. Cały kod jest generowany przez akcje semantyczne związane z instrukcjami przypisania i wyrażeniami. Badanie przepływu sterowania powoduje właściwe poprawia nie, »tak że przypisania i obliczanie wartości logicznych będą odpowiednio połączone. Etykiety i skoki Najprostszymi konstrukcjami języka programowania zmieniającymi przepływ sterowania w programie są etykiety i instrukcje skoku. Gdy kompilator napotyka instrukcję g o t o L, musi sprawdzić, czy w zakresie instrukcji g o t o jest dokładnie jedna instrukcja o ety kiecie L. Jeśli taka etykieta już wystąpiła— albo w instrukcji deklarującej etykietę, albo jako etykieta pewnej instrukcji w kodzie źródłowym — to w tablicy symboli będzie wpis podający wygenerowaną przez kompilator etykietę dla pierwszej instrukcji trójadresowej skojarzonej z instrukcją z kodu źródłowego oznaczoną L. W tłumaczeniu generujemy instrukcję trójadresową g o t o z tą wygenerowaną przez kompilator etykietą jako celem. Jeżeli w kodzie źródłowym po raz pierwszy napotykamy etykietę L — znajdując jej deklarację albo widząc ją jako cel skoku w przód — wstawiamy L d o tablicy symboli i generujemy etykietę symboliczną dla L.
8.7
Wywołania procedur 1
Procedura jest tak ważną i często używaną konstrukcją w językach programowania, że istotne jest, aby kompilator generował dobry kod dla wywołań i powrotów z proce dur. Procedury wspomagania przetwarzania, które obsługują przekazywanie argumentów, wywoływanie i powroty, są częścią biblioteki wspomagania przetwarzania. Różne rodza j e mechanizmów potrzebnych d o implementacji biblioteki wspomagania przetwarzania opisaliśmy w rozdz. 7. W tym podrozdziale opisaliśmy typowy kod, generowany dla wywołań i powrotów z procedur. Weźmy implementację dla prostej instrukcji wywołania procedury: (1) (2) (3)
1
S -> cali id ( Elist ) Elist -> Elist , E Elist -> E
Nazwy procedura używamy również do określania funkcji. Funkcja jest procedurą zwracającą wartość.
Sekwencje wywołujące Jak już wiemy z rozdziału 7, tłumaczenie wywołania zawiera sekwencję wywołującą, sekwencję akcji wykonywanych przy wejściu do i wyjściu z każdej procedury. Chociaż sekwencje wywołujące są różne, nawet dla implementacji tego samego języka progra mowania, to zazwyczaj są wykonywane następujące czynności. Gdy następuje wywołanie procedury, trzeba zarezerwować miejsce dla rekordu ak tywacji procedury wywoływanej. Argumenty tej procedury należy obliczyć i udostępnić procedurze wywoływanej w znanym miejscu. Wskaźniki środowiska muszą zostać ustalo ne w taki sposób, aby procedura wywoływana mogła uzyskać dostęp do danych z otacza jących ją bloków. Trzeba zapamiętać stan procedury wywołującej, aby mogła wznowić działanie po zakończeniu wywołania. Należy również w znanym miejscu zapamiętać adres powrotu, miejsce, do którego sterowanie ma wrócić po zakończeniu działania wy woływanej procedury. Adres powrotu jest zazwyczaj pozycją rozkazu, który w procedurze wywołującej jest za rozkazem procedury wywołania. Na koniec trzeba wygenerować skok do początku kodu dla wywoływanej procedury. Po powrocie z procedury również należy wykonać kilka czynności. Jeśli procedura wywoływana jest funkcją, to wynik musi być zapamiętany w odpowiednim miejscu. Należy odtworzyć rekord aktywacji procedury wywołującej. Trzeba również wygenerować skok pod adres powrotu w procedurze wywołującej. Nie ma ustalonego podziału zadań w trakcie wykonywania programu między pro cedurę wywołującą i wywoływaną. Często język źródłowy, maszyna docelowa i system operacyjny narzucają wymagania, które faworyzują któreś z rozwiązań.
Prosty przykład Rozważmy prosty przykład, w którym parametry są przekazywane przez referencję, a pa mięć jest alokowana statycznie. W takiej sytuacji możemy użyć samych instrukcji p a r a m jako miejsc dla argumentów. Procedura wywoływana otrzymuje w rejestrze wskaź nik pierwszej z instrukcji p a r a m , a wskaźnik dowolnego ze swoich argumentów może otrzymać, dodając odpowiednie przesunięcie do tego wskaźnika bazowego. Gdy generu jemy kod trójadresowy dla takiego wywołania, wystarczy wygenerować instrukcje trój adresowe potrzebne do obliczenia tych argumentów, które są wyrażeniami innymi od prostych nazw, a po nich umieścić instrukcje trójadresowe p a r a m , po jednej dla każde go argumentu. Jeśli nie chcemy mieszać instrukcji obliczających wartości argumentów z instrukcjami p a r a m , będziemy musieli zapamiętywać wartość E.pozycja dla każdego wyrażenia E z id(2s, E) . Wygodną strukturą danych, w której możemy zapamiętywać te wartości, jest kolej ka, czyli lista fifo. Nasza procedura semantyczna E dla Elist -* Elist będzie zawierała krok zapamiętujący atrybut E.pozycja w kolejce kolejka. Procedura semantyczna dla S -¥ cali id ( Elist) wygeneruje instrukcję p a r a m dla każdego elementu z kolejki, powo dując umieszczenie tych instrukcji za instrukcjami obliczającymi wartości argumentów. 1
1
Jeśli parametry są przekazywane do procedury wywoływanej przez stos, co jest normalne przy danych alokowanych dynamicznie, nie ma powodu zabraniania mieszania wyliczania argumentów i instrukcji p a r a m . W trakcie generowania kodu docelowego instrukcja p a r a m jest zastępowana instrukcją wstawiającą parametr na stos.
Instrukcje te zostały wygenerowane podczas redukowania poszczególnych parametrów do E. Poniższy schemat translacji sterowanej składnią korzysta z tych pomysłów. (1)
S -> cali id( Elist ) { for każdego elementu p z kolejki emit(' p a r a m ' p)\ emit(' c a l i ' id.pozycja) }
do
Kodem dla S jest kod dla Elist, który oblicza wartości argumentów, po którym następują instrukcje p a r a m p dla wszystkich argumentów i instrukcja cali. Nie zliczamy przekazywanych argumentów, ale ich liczbę można wyznaczyć podobnie j a k obliczanie Elist.lwym z poprzedniego podrozdziału. (2) (3)
Elist -> Elist , E { dopisz E.pozycja na koniec kolejki } Elist ->• E { zainicjuj kolejkę tak, aby zawierała tylko E.pozycja
}
Kolejka jest opóźniana, a następnie jest do niej wstawiany pojedynczy wskaźnik pozycji w tablicy symboli, na której jest nazwa oznaczająca wartość E.
ĆWICZENIA 8.1 Przetłumacz wyrażenie arytmetyczne a * - ( b + c ) na postać: a) drzewa składniowego, b) notacji postfiksowej, c) kodu trójadresowego. 8.2 Przetłumacz wyrażenie - ( a + b ) * ( c + d ) + ( a + b + c ) na postać: a) czwórek, b) trójek, c) trójek pośrednich. 8.3 Przetłumacz wykonywalne instrukcje z następującego programu w C: main() { int i; int a[10]; i = i; w h i l e ( i <= 1 0 ) { a [ i ] = 0; i = i }
}
na postać: a) drzewa składniowego, b) notacji postfiksowej, c) kodu trójadresowego.
+
1;
*8.4 Wykaż, że jeśli wszystkie operatory są dwuargumentowe, to ciąg operatorów i ar gumentów jest wyrażeniem postfiksowym wtedy i tylko wtedy, gdy: (1) operatorów jest dokładnie o jeden mniej niż argumentów i (2) wszystkie niepuste prefiksy wy rażenia zawierają mniej operatorów niż argumentów. 8.5 Zmodyfikuj schemat translacji do obliczania typów i adresów względnych nazw zadeklarowanych z rys. 8.11 tak, aby móc zapisywać listy nazw zamiast pojedyn czych nazw w deklaracjach o postaci D —v id : T. 8.6 Prefiksową postacią wyrażenia, w którym operator 0 jest aplikowany do wyrażeń e e • • e jest 0 p p ... p , gdzie p jest prefiksową postacią e v
2>
k
x
2
k
t
v
a) Podaj prefiksową formę wyrażenia a * - ( b + c ) . **b) Wykaż, że wyrażenia w postaci infiksowej nie mogą być przetłumaczone na postać prefiksową przez schematy translacji, w których wszystkie akcje wypisują tylko tekst i wszystkie akcje znajdują się na krańcach prawych stron produkcji. c) Podaj definicję sterowaną składnią, pozwalającą tłumaczyć wyrażenia w postaci infiksowej na postać prefiksową. Której z metod z rozdz. 5 możesz użyć? 8.7 Napisz program implementujący przedstawioną na rys. 8.24 definicję sterowaną składnią dla tłumaczenia wyrażeń logicznych na kod trójadresowy. 8.8 Zmodyfikuj definicję sterowaną składnią z rys. 8.24 tak, aby generowała kod dla maszyny stosowej z p . 2.8. 8.9 Sterowana składnią definicja z rys. 8.24 tłumaczy E —»idj < i d na parę instrukcji 2
if idj < i d goto • • • goto 2
Zamiast tego, moglibyśmy wykonywać tłumaczenie na pojedynczą instrukcję if idj ^ i d goto _ 2
8.10 8.11 8.12
*8.13
i wykonywać kod bez skoku, gdy E jest prawdą. Zmodyfikuj definicję z rys. 8.24 tak, aby generowała taki kod. Napisz program implementujący przedstawioną na rys. 8.23 definicję sterowaną składnią dla instrukcji sterujących. Napisz program implementujący algorytm poprawiania podany w p . 8.6. Przetłumacz poniższą instrukcję przypisania na kod trójadresowy, korzystając ze schematu translacji z p. 8.3 A[i,j] := B [ i , j ] + C [ A [ k , l ] ] + D [ i + j ] Pewne języki, takie j a k PL/I, dopuszczają, aby lista nazw otrzymywała listę atry butów oraz pozwalają, aby deklaracje były w sobie zagnieżdżone. Następująca gramatyka przedstawia ten problem w sposób abstrakcyjny: D —• listanazw listaatr | ( D ) listaatr listanazw —>• id , listanazw I id listaatr
—• A
listaatr
I A A
decimal | fixed | float | real
D -> ( D ) listaatr oznacza, że wszystkie nazwy wymienione w deklaracji w na wiasach mają nadawane atrybuty z listaatr, niezależnie od tego, na j a k i m poziome zagnieżdżenia znajduje się dana nazwa. Widać, że deklaracja n nazw i m atrybutów może spowodować wstawienie nm elementów informacji do tablicy symboli. Podaj definicję sterowaną składnią dla deklaracji definiowanych przez tę gramatykę. 8.14 W języku C instrukcja for ma następującą postać: for
( e
x
; e
; £
2
) instr
3
Zakładając, że oznacza to
e; x
while ( e instr; e^;
) {
2
} zbuduj definicję sterowaną składnią dla tłumaczenia instrukcji for z języka C na kod trójadresowy. 8.15 Standard Pascala definiuje instrukcję for v := pocz to końc do instr jako oznaczającą to samo, co poniższy fragment kodu begin t := pocz; t := końc; if t ^ t then begin v:= t; instr while v / f do begin v := succ(v); instr end end end {
2
x
2
x
2
a) Rozpatrzmy następujący program w Pascalu: program forloop(input, output); var i, pocz, konc: integer; begin read(pocz, konc); f o r i := p o c z t o k o n c do writełn(i) end. Jak ten program zachowa się dla pocz = MAX INT - 5 i końc = MAXINT, gdzie MAX INT jest największą liczbą typu integer dla maszyny docelowej. *b) Podaj definicję sterowaną składnią, która wygeneruje poprawny kod trójadreso wy dla Pascalowych instrukcji for.
UWAGI B I B L I O G R A F I C Z N E U N C O L (ang. Universal Compiler Ońented Language, czyli uniwersalny język przezna czony dla kompilatorów) jest mitycznym uniwersalnym językiem pośrednim poszukiwa nym od połowy lat pięćdziesiątych. W raporcie komitetu Stronga i in. [1958] pokazano, jak — mając dany U N C O L — można budować kompilator, łącząc przód dla danego języka źródłowego z tyłem dla danej maszyny docelowej. Przedstawione w tym raporcie techniki wciągania (butstrapingu) są rutynowo używane do zmiany maszyny docelowej dla kompilatorów (patrz p. 11.2). Steel [1961] przedstawił oryginalną propozycję definicji UNCOL-a. Kompilator przenośny składa się z jednego przodu, do którego można podłączyć jeden z kilku tyłów, aby otrzymać implementację języka dla różnych maszyn docelo wych. Neliac jest jednym z pierwszych języków mających kompilator przenośny (Huskey, Halstead i McArthur [1960]) napisany w kompilowanym przez siebie języku. Richards [1971] przedstawił kompilator przenośny dla BCPL, Nori i in. [1981] dla Pascala, a John son [1979] dla C. Newey, Poole i Waite [1972] zastosowali pomysł wymiany tyłów do makrogeneratora, edytora tekstów i kompilatora języka Basic. Do ideału UNCOL-a, czyli implementacji n języków na m maszynach poprzez na pisanie n przodów i m tyłów, w przeciwieństwie do n x m oddzielnych kompilatorów, podchodzono kilkoma sposobami. Jeden z nich to podłączanie przodu dla nowego j ę zyka do istniejącego kompilatora. Feldman [1979b] opisał dodawanie przodu dla języka Fortran 77 do kompilatorów C napisanych przez Johnsona [1979] i Ritchiego [1979]. Bu dowę kompilatorów projektowanych tak, aby możliwe było korzystanie z wielu przodów i tyłów, opisali Davidson i Fraser [1984b], Leverett i in. [1980] oraz Tanenbaum i in. [1983]. Davidson i Fraser [1984b], używając pojęcia „sumy" i „iloczynu" maszyn abstrak cyjnych, podkreślili rolę zbioru operatorów dopuszczalnych w reprezentacjach pośred nich. Zbiór rozkazów i trybów adresowania dla maszyny iloczynowej jest ograniczony, więc przody kompilatorów nie muszą wykonywać wielu wyborów podczas generowania kodu pośredniego. Maszyny sumaryczne dostarczają alternatywnych metod implemen tacji konstrukcji z języka źródłowego. Ponieważ nie wszystkie możliwości muszą być bezpośrednio implementowane przez wszystkie maszyny docelowe, więc bogatszy zbiór rozkazów maszyny sumarycznej może wprowadzić zależność generowanego kodu od ma szyny docelowej. Podobne uwagi dotyczą także innych rodzajów kodu pośredniego, takich jak drzewa składniowe i kod trójadresowy. Fraser i Hanson [1982] rozważali metody opi sywania dostępu do stosu przy użyciu operacji niezależnych od maszyny. Implementację Algola 60 szczegółowo opisali Randell i Russell [1964] oraz Grau, Hill i Langmaack [1967]. Freiburghouse [1969] opisał PL/I, Wirth [1971] Pascala, a Branąuart i in. [1976] — Algol 68. Minker i Minker [1980] oraz Giegerich i Wilhelm [1978] omówili generację opty malnego kodu dla wyrażeń logicznych. Ćwiczenie 8.15 pochodzi z pracy Neweya i Waite'a [1985].
ROZDZIAŁ
Generowanie kodu
Końcowym elementem naszego modelu kompilatora jest generator kodu, którego wej ściem jest pośrednia reprezentacja programu, a wyjściem — równoważny program wy nikowy (rys. 9.1). Techniki generacji kodu przedstawione w tym rozdziale mogą być używane niezależnie od tego, czy przed generacją kodu jest wykonywana optymalizacja, tak jak w niektórych tzw. kompilatorach „optymalizujących", czy też nie. W trakcie opty malizacji jest podejmowana próba przekształcenia kodu pośredniego na postać, z której można wygenerować bardziej efektywny kod wynikowy. Optymalizacją kodu zajmiemy się w następnym rozdziale.
Program źródłowy
Przód kompilatora
Kod pośredni
Kod 1 Optymalizator pośredni kodu
Generator | ^ Program wynikowy kodu
Tablica symboli
Rys. 9.1. Miejsce generatora kodu
Generatorowi kodu tradycyjnie są stawiane duże wymagania. Kod wyjściowy musi być poprawny i wysokiej jakości, co znaczy, że powinien efektywnie wykorzystywać zasoby maszyny docelowej. Co więcej, sam generator kodu również powinien działać sprawnie. Z matematycznego punktu widzenia, problem generowania optymalnego kodu jest nierozstrzygalny. W praktyce, musimy zadowolić się metodami heurystycznymi, które produkują dobry, ale niekoniecznie optymalny kod. Wybór heurystyk jest istotny, bo roz sądnie zaprojektowany algorytm generacji może produkować kod, który jest kilkakrotnie szybszy niż kod produkowany przez algorytm wymyślony w pośpiechu.
9.1
Zagadnienia związane z projektowaniem generatora kodu
Chociaż wiele szczegółów zależy od języka wynikowego i systemu operacyjnego, to takie zagadnienia, jak zarządzanie pamięcią, wybór instrukcji, przydział rejestrów i wybór kolejności obliczeń są spotykane w prawie wszystkich problemach generacji kodu. W tym podrozdziale omówiliśmy ogólne zagadnienia projektowania generatorów kodu. Wejście dla generatora kodu Wejście dla generatora kodu składa się z pośredniej reprezentacji programu źródłowego utworzonej przez przód kompilatora oraz informacji z tablicy symboli, używanych do wyznaczania adresów obiektów danych, d o których w kodzie pośrednim odwołujemy się przez nazwy. Jak wiemy z poprzedniego rozdziału, używanych jest wiele różnych języków pośred nich, między innymi reprezentacje liniowe (takie j a k notacja postfiksową), reprezentacje trójadresowe (takie jak czwórki), reprezentacje dla maszyn wiurtualnych (takie jak kod dla maszyny stosowej) i reprezentacje graficzne (takie jak drzewa składniowe i dagi). Chociaż w tym rozdziale przedstawiamy algorytmy dla kodu trójadresowego, drzew i da gów, to wiele z prezentowanych technik można również stosować dla innych reprezentacji pośrednich. Przyjmujemy, że przed generacją kodu przód kompilatora wykonał analizy leksy kalną i składniową oraz przetłumaczył program źródłowy do wystarczająco szczegółowej reprezentacji pośredniej, tak że wartości dla nazw występujących w języku pośrednim mogą być reprezentowane przez wartości, którymi maszyna docelowa może bezpośred nio manipulować (bity, liczby całkowite, zmiennopozycyjne, wskaźniki itp.). Zakładamy również, że wykonano kontrolę typów i w miejscach, w których są potrzebne, dodano operatory konwersji typów oraz wykryto oczywiste błędy semantyczne (np. próby indek sowania tablicy za pomocą liczby zmiennopozycyjnej). Można więc przyjąć, że wejście dla generatora kodu jest poprawne. Istnieją jednak kompilatory, w których tego rodzaju sprawdzanie poprawności semantycznej jest wykonywane w trakcie generacji kodu. Programy wynikowe Wyjściem generatora kodu jest program wynikowy. Tak jak dla kodu pośredniego, uży wanych jest wiele postaci: kod maszynowy z adresami bezwzględnymi, relokowalny kod maszynowy albo asembler. Zaletą produkowania kodu maszynowego z adresami bezwzględnymi jest to, że moż na go umieścić pod ustalonym adresem w pamięci i od razu wykonać. Mały program może zostać szybko skompilowany i wywołany. Kilka kompilatorów „dla studentów", takich jak WATFIV i PL/C, tworzy kod z adresami bezwzględnymi. Produkowanie relokowalnego kodu maszynowego (modułu wynikowego) jako wyj ścia pozwala oddzielnie kompilować podprogramy. Zestaw relokowalnych modułów wy nikowych można połączyć i załadować w celu wykonania przez ładowacz konsolidujący. Generując moduły relokowalne, musimy wykonać dodatkową pracę związaną z łącze niem i ładowaniem, zyskujemy jednak możliwość oddzielnego kompilowania procedur
i wywoływania innych, uprzednio skompilowanych programów z modułu wynikowego. Jeśli maszyna docelowa nie obsługuje automatycznie relokacji, to kompilator musi jawnie dostarczyć informacje o relokacji do ładowacza, aby umożliwić konsolidację oddzielnie kompilowanych fragmentów programu. Produkowanie programu w asemblerze trochę upraszcza proces generacji kodu. Moż na wówczas generować rozkazy symboliczne i korzystać z dostarczanych przez asembler narzędzi do makrodefinicji ułatwiających generowanie kodu. Ceną jest dodatkowy krok w procesie kompilacji, wywołanie asemblera po generacji kodu. Ponieważ tworzenie kodu w asemblerze nie duplikuje całej pracy asemblera, jest więc to kolejna rozsądna moż liwość, zwłaszcza dla maszyn z małą pamięcią, dla których kompilator musi używać wielu przebiegów. W tym rozdziale, dla uzyskania większej czytelności, jako języka wy nikowego użyliśmy asemblera. Pamiętajmy jednak, że jeśli adresy mogą być wyliczone z przesunięć i innych informacji zapamiętanych w tablicy symboli, to generator kodu może produkować adresy dla nazw, relokowalne lub bezwzględne, tak łatwo jak adresy symboliczne.
Zarządzanie pamięcią Odwzorowanie nazw z programu źródłowego na adresy obiektów w pamięci — w czasie działania programu — wykonują wspólnie przód kompilatora i generator kodu. W po przednim rozdziale przyjęliśmy, że nazwa w instrukcji trójadresowej oznacza wpis w ta blicy symboli dla tej nazwy. W podrozdziale 8.2 wpisy w tablicy symboli były tworzone w trakcie przetwarzania deklaracji w procedurach. Typ w deklaracji wyznacza szerokość, czyli wielkość pamięci potrzebnej dla zadeklarowanej nazwy. Z informacji w tablicy sym boli można wyznaczyć względny adres nazwy w obszarze danych procedury. W podroz dziale 9.3 przedstawiliśmy w ogólnych zarysach implementację statycznego i stosowego rezerwowania obszarów danych i pokazaliśmy, jak nazwy z reprezentacji pośredniej mogą być przekształcane na adresy w kodzie wynikowym. Jeśli generujemy kod maszynowy, to etykiety instrukcji trójadresowych muszą być przekształcone na adresy rozkazów. Proces ten jest analogiczny do „poprawiania" z p . 8.6. Załóżmy, że etykiety to numery czwórek w tablicy czwórek. Gdy przetwarzamy kolej ną czwórkę, możemy wyznaczyć adres pierwszego rozkazu maszynowego generowanego przez tę czwórkę, utrzymując po prostu licznik słów użytych przez rozkazy j u ż wygene rowane. Liczby te mogą być przechowywane w tablicy czwórek (w dodatkowym polu). Jeżeli napotkamy instrukcję, taką j a k j : g o t o i, a / jest mniejsze niż j — numer przetwarzanej czwórki, możemy po prostu wygenerować rozkaz skoku z adresem celu równym lokacji pierwszego rozkazu w kodzie maszynowym dla czwórki i. Jeżeli jednak skok jest do przodu, czyli / jest większe niż j , to na liście dla czwórki i musimy zapamię tać lokację pierwszego rozkazu maszynowego dla j . Następnie, podczas przetwarzania czwórki z, wstawiamy właściwy adres do wszystkich rozkazów maszynowych, które są skokami w przód do /.
Wybór rozkazów Od rodzaju zbioru rozkazów maszyny docelowej zależy trudność zadania wyboru roz kazów. Jednorodność i kompletność zbioru rozkazów to ważne czynniki. Jeśli maszyna
docelowa nie obsługuje każdego typu danych w ten sam sposób, to każdy wyjątek od reguły wymaga specjalnej obsługi. Czas wykonywania rozkazów i idiomy maszynowe to kolejne ważne czynniki. Je śli nie interesujemy się efektywnością programu wynikowego, to wybór rozkazów jest bardzo prosty. Dla każdej instrukcji trójadresowej możemy zaprojektować szkielet kodu, przedstawiający kod wynikowy, który ma zostać wygenerowany dla danej konstrukcji. Przykładowo, każda instrukcja trójadresowa o postaci x : = y + z , gdzie x, y i z mają statycznie przydzielaną pamięć, może zostać przetłumaczona na następujący kod;
MOV y,R0 ADD z,R0 MOV R0,x
/* ładuj y do rejestru RO */ /* dodaj z do RO */ /* zapamiętaj RO w x */
Niestety, taka metoda generowania kodu dla kolejnych instrukcji często skutkuje kodem złej jakości. Przykładowo, następujące instrukcje:
a:=b+c d:=a+e zostałyby przetłumaczone na
MOV ADD MOV MOV ADD MOV
b,R0 c,R0 R0,a a,R0 e,R0 R0,d
Czwarty rozkaz jest niepotrzebny, a jeśli a nie jest używane nigdzie dalej, to trzeci również. Jakość generowanego kodu jest określana przez prędkość jego działania i rozmiar. Maszyna docelowa z bogatym zbiorem rozkazów może dostarczać wielu metod imple mentacji danej operacji. Ponieważ różnice kosztu między implementacjami mogą być znaczące, więc naiwne tłumaczenie kodu pośredniego może prowadzić do poprawne go, ale niedopuszczalnie nieefektywnego kodu wynikowego. Jeśli, na przykład, maszyna docelowa ma rozkaz zwiększający wartość (INC), to instrukcja trójadresowa a:=a+l może być efektywniej zaimplementowana przez pojedynczy rozkaz INC a niż przez bar dziej oczywistą sekwencję rozkazów ładującą a do rejestru, dodającą jeden d o rejestru i zapamiętującą wynik w a
MOV ADD MOV
a, RO #1, RO RO, a
Znajomość czasu wykonywania rozkazów jest konieczna do projektowania dobrych se kwencji kodu, ale — niestety — dokładne informacje tego dotyczące są trudne do uzyska nia. Podjęcie decyzji, która sekwencja rozkazów maszynowych jest najlepsza dla danej instrukcji trójadresowej, może także wymagać znajomości kontekstu, w którym ta in strukcja występuje. Narzędzia d o automatyzacji wyboru rozkazów opisaliśmy w p . 9.12.
Przydział rejestrów Rozkazy używające argumentów w rejestrach są zazwyczaj krótsze i szybsze niż te, których argumenty są w pamięci. W związku z tym, efektywne gospodarowanie rejestrami jest szczególnie ważne dla generacji dobrego kodu. Problem używania rejestrów jest często dzielony na dwa podproblemy: 1. 2.
Podczas przydziału rejestrów (ang. register allocatioń) wybieramy zbiór zmiennych, które będą przechowywane w rejestrach w danym punkcie programu. Podczas wyznaczania rejestrów (ang. register assignment) wybieramy konkretne re jestry dla zmiennych.
Znalezienie optymalnego przyporządkowania rejestrów zmiennym jest trudne, nawet z wartościami zajmującymi pojedyncze rejestry. Problem ten jest NP-zupełny. Ponadto, jest on jeszcze bardziej utrudniany przez to, że sprzęt i/lub system operacyjny może wymagać przestrzegania pewnych konwencji dotyczących używania rejestrów. Niektóre komputery wymagają par rejestrów (rejestru o numerze parzystym i nastę pującego po nim rejestru o numerze nieparzystym) dla pewnych argumentów i wyników. Przykładowo, w komputerach IBM System/370 mnożenie i dzielenie liczb całkowitych korzysta z par rejestrów. Rozkaz mnożenia ma postać M
x,
y
gdzie x, mnożna, jest rejestrem o numerze parzystym z pary rejestrów. Wartość mnożnej jest pobierana z rejestru o numerze nieparzystym. Mnożnik y jest pojedynczym rejestrem. Wynik zajmuje całą parę rejestrów. Rozkaz dzielenia m a postać
D
x,
y
gdzie 64-bitowa dzielna zajmuje parę rejestrów, z których parzystym jest x; y jest dzielni kiem. Po dzieleniu rejestr o numerze parzystym zawiera resztę, a o numerze nieparzystym — iloraz. Rozważmy teraz d w a fragmenty kodu trójadresowego z rys. 9.2(a) i (b), które różnią się tylko operatorem w drugiej instrukcji. Najkrótsze sekwencje rozkazów maszynowych dla (a) i (b) przedstawiono na rys. 9.3.
= a + b = t * c = t / d (a)
= a + b = t + c = t / d (b)
Rys. 9.2. Dwie sekwencje kodu trójadresowego
Ri oznacza rejestr i. (SRDA RO, 32 przesuwa dzielną do Rl i czyści RO tak, że wszystkie bity RO są później równe bitowi znaku). L, ST i A oznaczają, odpowiednio, ładowanie (ang. load), zapamiętywanie (ang. storę) i dodawanie (ang. add). Zauważmy, 1
1
Ang. — Shift Right Double Arithmetic.
L A M D ST
Rl, Rl, RO, RO, Rl,
a b c d
t (a)
L A A SRDA D ST
RO, RO, RO, RO, RO, Rl,
a b c 32 d t
(b)
Rys. 9.3. Optymalne sekwencje kodu maszynowego
że wybór optymalnego rejestru, do którego powinniśmy wstawić a, zależy od tego, co później stanie się z t. Strategie przydziału rejestrów opisaliśmy w p . 9.7.
Wybór kolejności obliczeń Kolejność, w której mają być wykonywane obliczenia, może wpłynąć na efektywność ko du wynikowego. Pewne kolejności obliczeń wymagają mniejszej niż inne liczby rejestrów do przechowywania wartości pośrednich. Wybór kolejności optymalnej jest następnym trudnym, NP-zupełnym, problemem. Początkowo będziemy omijali ten problem, generu jąc kod dla instrukcji trójadresowych w kolejności, w której zostały one ustawione przez generator kodu pośredniego.
Podejścia do generacji kodu Niewątpliwie najważniejszym wymaganiem dotyczącym generatora kodu jest generowa nie kodu poprawnego. Poprawność nabiera specjalnego znaczenia w związku z liczbą szczególnych przypadków, które generator musi brać pod uwagę. Ważnym celem pro jektu, uwzględniającym poprawność, jest takie projektowanie generatora kodu, aby był łatwo implementowalny i aby łatwo było go utrzymywać. W podrozdziale 9.6 przedstawiliśmy prosty algorytm generacji kodu, który korzysta z informacji o późniejszym wykorzystywaniu argumentu do generacji kodu dla maszyny z rejestrami. Rozpatruje on instrukcje po kolei, przechowując argumenty w rejestrach tak długo, jak to jest możliwe. Wyjście takiego generatora może być poprawiane technikami optymalizacji przez szparkę, takimi jak opisane w p. 9.9. W podrozdziale 9.7 opisaliśmy techniki pozwalające lepiej używać rejestrów dzięki rozpatrywaniu przepływu sterowania w kodzie pośrednim. Nacisk położyliśmy na przy dział rejestrów dla często używanych wartości w pętlach wewnętrznych. W podrozdziałach 9.10 i 9.11 przedstawiliśmy pewne, korzystające z drzew, techniki wyboru kodu ułatwiające budowę przenośnych generatorów kodu. Wersje PCC, przeno śnego kompilatora C z takim generatorem kodu, zostały stworzone dla wielu różnych maszyn. Dostępność systemu UNIX dla różnych maszyn wiele zawdzięcza przenośności PCC. W podrozdziale 9.12 pokazaliśmy, jak generację kodu można traktować jako proces przepisywania drzewa.
9.2
Maszyna docelowa
Dobra znajomość maszyny docelowej i zestawu jej rozkazów jest niezbędna d o zaprojek towania dobrego generatora kodu. Niestety, w ogólnym opisie generatorów kodu nie da się wystarczająco dokładnie przedstawić niuansów konkretnej maszyny, aby umożliwić napisanie dobrego generatora kodu tej maszyny dla pełnego języka. W tym rozdziale — jako maszyny docelowej — będziemy używali maszyny rejestrowej, która jest podobna do niektórych minikomputerów. Przedstawione techniki generacji kodu mogą być również stosowane dla wielu innych maszyn. Nasza maszyna docelowa adresuje pojedyncze bajty: ma cztery bajty w słowie i n rejestrów ogólnego przeznaczenia, RO, Rl, Rrc-1. M a ona dwuadresowe rozkazy o postaci op
źródło,
ceł
gdzie op jest kodem operacji, a źródło i cel to dane. Między innymi obsługiwane są następujące operacje: MOV (przenieś źródło do celu) A D D (dodaj źródło do celu) S U B (odejmij źródło od celu) Inne rozkazy będziemy wprowadzać wtedy, gdy będą potrzebne. Pola źródło i cel nie są wystarczająco szerokie, by przechowywać adresy w pamię ci, więc pewne ustalone układy bitów w tych polach oznaczają, że słowa występujące po rozkazie zawierają argumenty i/lub adresy. Źródło i cel rozkazu są opisywane po przez łączenie rejestrów i lokacji pamięci z trybami adresowania. W poniższym opisie zawartością) oznacza zawartość rejestru lub adresu pamięci reprezentowanego przez a. Tryby adresowania razem z ich zapisem w asemblerze i związanymi kosztami to: TRYB
POSTAĆ
bezwzględny rejestrowy indeksowany rejestrowy pośredni indeksowany pośredni
M R c(R) *R *c (R)
ADRES
K O S Z T DODATKOWY
M R c + zawartośćCR) zawartość(R) zawartość(c + zawartość(R))
1 0 1 0 1
Lokacja w pamięci M i rejestr R, użyte jako źródło albo cel, oznaczają same siebie. Przykładowo, rozkaz
MOV
RO,M
zapisuje zawartość rejestru RO w lokacji pamięci M. Przesunięcie c od wartości w rejestrze R zapisujemy jako c(R). Czyli
MOV
4(R0),M
zapamięta wartość zawartości^
+ zawartoic(RO))
w lokacji pamięci M.
Pośrednie wersje ostatnich dwóch trybów adresowania są oznaczane przedrostkiem *. Wobec tego
MOV
*4(RO),M
zapamięta wartość zawartość(zawartość(A
+
zawartość(RO)))
w lokacji pamięci M. Ostatni tryb adresowania pozwala używać stałych jako argumentów źródłowych: TRYB literał
POSTAĆ
STAŁA
K O S Z T DODATKOWY
#c
c
1
Czyli rozkaz
MOV
#1,R0
załaduje stałą 1 do rejestru RO. Koszty
rozkazów
Przyjmijmy, że kosztem rozkazu będzie jeden plus koszt związany z trybami adresowania dla argumentu źródłowego i docelowego (oznaczonymi jako „koszt dodatkowy" w powyż szych tabelach). Koszt ten odpowiada długości rozkazu (w słowach). Tryby adresowania korzystające z rejestrów mają koszt równy zeru, a używające pamięci lub stałych mają koszt równy jeden, bo takie argumenty muszą być zapamiętane razem z rozkazem. Jeśli wielkość kodu jest istotna, to powinniśmy minimalizować długość rozkazu. Ma to zresztą ważną dodatkową zaletę. Dla większości maszyn i większości rozkazów czas potrzebny na ściągnięcie rozkazu z pamięci przekracza czas spędzony na jego wy konywaniu. Czyli, minimalizując długość rozkazu, minimalizujemy również czas jego wykonywania . Kilka przykładów podajemy poniżej. 1
1. 2.
3. 4.
Rozkaz MOV RO, Rl kopiuje zawartość rejestru RO do rejestru Rl. Rozkaz ten ma koszt równy jeden, gdyż zajmuje tylko jedno słowo w pamięci. Rozkaz MOV R 5 , M kopiuje zawartość rejestru R 5 do miejsca w pamięci oznaczane go przez M. Kosztem tego rozkazu jest dwa, bo adres M jest w słowie następującym po rozkazie. Rozkaz A D D # 1 , R 3 dodaje stałą 1 do zawartości rejestru 3, a koszt jego jest równy dwa, bo stała jeden musi być w słowie następującym po rozkazie. Rozkaz SUB 4 (RO) , * 1 2 (Rl) zapamiętuje wartość zawartość(zawartość(
12 + zawartość(Rl)))
- zawartość(zawartość(4
+ RO))
pod adresem * 1 2 (Rl). Kosztem tego rozkazu jest trzy, bo stałe 4 i 12 są zapamię tane w dwóch kolejnych słowach następujących po rozkazie. 1
Podany sposób obliczania kosztu jest poglądowy, a nie realistyczny. Używanie całego słowa dla rozkazu upraszcza regułę obliczania kosztu. Dokładniejsze przybliżenie czasu zajmowanego przez rozkaz uwzględ niałoby, czy rozkaz wymaga ściągnięcia wartości argumentu oraz jego adresu (umieszczonego w rozkazie) z pamięci.
Pewne problemy związane z generowaniem kodu dla tej maszyny stają się widoczne, gdy rozważamy, jaki kod wygenerować dla instrukcji trójadresowej o postaci a: =b+c, gdzie b i c są zmiennymi prostymi w różnych lokacjach pamięci oznaczanych przez te nazwy. Instrukcję taką można zaimplementować za pomocą wielu różnych sekwencji rozkazów. Oto kilka przykładów: 1.
2.
MOV ADD MOV MOV
b, RO c, RO RO, a b, a
koszt = 6 i
.
*
koszt ~ o ADD
c, a
MOV ADD
*R1, *R0
Zakładając, że RO, Rl i R2 zawierają, odpowiednio, adresy a, b i c , możemy napisać 3.
*R2, *R0
koszt-2
Przyjmując, że Rl i R2 zawierają, odpowiednio, wartości b i c , oraz że wartość b nie jest potrzebna po przypisaniu, możemy użyć 4.
ADD MOV
R2, Rl Rl, a
k 0 S Z t =
3
Wynika z tego, że jeśli chcemy generować dobry kod dla tej maszyny, to musimy efektywnie wykorzystywać dostępne tryby adresowania. Dobrze jest, o ile to możliwe, przechowywać Z- lub r-wartość nazwy w rejestrze, pod warunkiem, że będzie ona wy korzystana w bliskiej przyszłości.
9.3
Zarządzanie pamięcią w czasie wykonywania programu
Jak wiemy z rozdziału 7, semantyka procedur w języku determinuje sposób wiązania nazw z adresami w trakcie wykonywania programu. Informacje potrzebne podczas wy konywania procedury są przechowywane w bloku pamięci nazywanym rekordem akty wacji; pamięć dla nazw lokalnych dla tej procedury również znajduje się w jej rekordzie aktywacji. W tym podrozdziale opisaliśmy, jaki kod trzeba generować d o zarządzania rekordami aktywacji w czasie wykonania. W podrozdziale 7.3 przedstawiliśmy dwie standardowe strategie rezerwacji pamięci: rezerwację statyczną i stosową. Gdy korzystamy z rezerwacji statycznej, miejsce w pamięci rekordu aktywacji jest ustalane w czasie kompilacji. Przy rezerwacji stosowej nowy rekord aktywacji jest wstawiany na stos dla każdego wykonania procedury. Rekord ten jest zdejmowany po zakończeniu aktywacji. W tym podrozdziale przedstawiliśmy również, jak kod wynikowy procedury może się odwoływać do obiektów w rekordach aktywacji. Jak wiemy j u ż z podrozdziału 7.2, rekord aktywacji procedury zawiera pola prze chowujące argumenty, wyniki, informacje o stanie maszyny, dane lokalne, zmienne tym-
czasowe itp. Korzystając z pola stanu maszyny do zapamiętywania adresu powrotnego i pola dla danych lokalnych, pokazaliśmy strategie rezerwacji. Przyjęliśmy, że pozostałe pola są obsługiwane tak, j a k opisano to w rozdz. 7. Ponieważ rezerwacja i zwalnianie pamięci dla rekordów aktywacji w trakcie wykony wania programu jest częścią sekwencji wywołania i powrotu z procedury, więc skupimy się na następujących instrukcjach trójadresowych:
1) 2)
cali, return,
3)
halt,
4)
action, zastępującej pozostałe instrukcje.
Przykładowo, kod trójadresowy dla procedur c i p z rys. 9.4 zawiera wyłącznie instrukcje tego rodzaju. Wielkość i wygląd rekordu aktywacji są przekazywane d o ge neratora kodu przez informacje o nazwach z tablicy symboli. D l a przejrzystości, wygląd przedstawiliśmy na rysunku, zamiast pokazywać wpisy w tablicy symboli.
KOD TRÓJADRESOWY
REKORD AKTYWACJI dla c (64 bajty)
/* kod dla c */ action cali p action halt /* kod dla p */ action return
adres powrotu
REKORD AKTYWACJI dla p (88 bajtów)
arr
2
56: 60:
3
Rys. 9.4. Wejście dla generatora kodu
Zakładamy, że pamięć w trakcie wykonania jest podzielona na obszary dla kodu, danych statycznych i stosu, tak j a k w p . 7.2 (dodatkowy obszar przeznaczony dla sterty nie jest używany w tym podrozdziale).
Rezerwacja statyczna Rozważmy kod potrzebny do zaimplementowania rezerwacji statycznej. Instrukcja cali z kodu pośredniego jest implementowana jako dwa rozkazy maszyny docelowej: rozkaz MOV zapamiętuje adres powrotny, a GOTO przekazuje sterowanie d o kodu wynikowego dla wywoływanej procedury MOV GOTO
fthere
+ 20, wywoływana, obszar-stat wywotywana.obszar-kodu
Atrybuty wywotywana.obszar^stat i wywoływana.obszar-kodu są stałymi oznaczający mi, odpowiednio, adres rekordu aktywacji i pierwszego rozkazu wywoływanej procedury. Argument #here+20 w rozkazie MOV jest adresem powrotnym; jest to adres rozkazu na-
stępującego p o rozkazie GOTO. (Z opisu w p . 9.2 wynika, że trzy stałe plus dwa rozkazy w sekwencji wywołania zajmują 5 słów, czyli 20 bajtów). Kod procedury kończy się powrotem d o procedury wywołującej, z wyjątkiem pierw szej procedury, dla której nie istnieje procedura wywołująca i jej ostatnim rozkazem jest HALT, o którym zakładamy, że zwróci kontrolę do systemu operacyjnego. Powrót z pro cedury wywoływana jest zaimplementowany przez GOTO
* wy woły wana. obszar^ stat
co przekazuje sterowanie do adresu zapisanego na początku rekordu aktywacji.
P r z y k ł a d 9,1. Kod na rysunku 9.5 powstał z procedur c i p z rys. 9.4. Używamy pseudorozkazu ACTION d o zaimplementowania instrukcji a c t i o n . Arbitralnie ustalamy, że początkowymi adresami kodu tych procedur będą, odpowiednio, 100 i 200, oraz za kładamy, że każdy rozkaz ACTION zajmuje 2 0 bajtów. Rekordy aktywacji dla tych pro cedur mają statycznie przydzieloną pamięć, poczynając od adresów, odpowiednio, 300 i 364. /* kod dla c */ 100: 120: 132: 140: 160:
ACTION MOV #140, 364 GOTO 200 ACTION HALT
200: 220:
ACTION3 GOTO *364
1
/* /*
2
/* /*
300: 304:
/* /* /*
364: 368:
/* /* /* Rys. 9.5. Kod wynikowy dla wejścia z rys. 9.4
Rozkazy* z których pierwszy jest pod adresem 100, implementują instrukcje
actiorij; cali p; action ; halt 2
procedury c. Wykonanie programu zaczyna się więc od rozkazu ACTION pod adre sem 100. Rozkaz MOV pod adresem 120 zapamiętuje adres powrotu, 140, w polu stanu maszyny, które jest pierwszym słowem rekordu aktywacji p. Rozkaz GOTO pod adresem 132 przekazuje sterowanie do pierwszego rozkazu kodu wynikowego wywo ływanej procedury. 1
Ponieważ 140 zostało zapamiętane pod adresem 364 przez powyższą sekwencję wywołującą, więc gdy wykonywany jest rozkaz GOTO spod adresu 220, to * 3 6 4 jest równe 140. Sterowanie wraca więc do adresu 140 i wznawiane jest wykonywanie proce dury c. O Rezerwacja stosowa Rezerwację statyczną można przerobić na rezerwację stosową poprzez używanie względ nych adresów dla pamięci w rekordach aktywacji. Pozycja rekordu dla aktywacji procedu ry nie jest znana aż do czasu wykonania. W rezerwacji stosowej pozycja ta jest zazwyczaj zapamiętywana w rejestrze w sposób umożliwiający uzyskanie dostępu do słów w rekor dach aktywacji dzięki wykorzystaniu przesunięcia względem wartości tego rejestru. Tryb adresowania indeksowanego z naszej maszyny docelowej dobrze się do tego nadaje. Jak wiemy z podrozdziału 7.3 względne adresy w rekordzie aktywacji mogą być obliczone jako przesunięcia od dowolnej ustalonej pozycji w rekordzie aktywacji. Dla wygody będziemy używali dodatnich przesunięć poprzez zapamiętywanie w rejestrze SP wskaźnika do początku rekordu aktywacji na wierzchołku stosu. Gdy dochodzi do wywołania procedury, procedura wywołująca zwiększa SP i przekazuje sterowanie d o procedury wywoływanej. Po powrocie sterowania do procedury wywołującej zmniejsza ona SP i w ten sposób zwalnia pamięć zarezerwowaną dla rekordu aktywacji procedury wywoływanej . Kod pierwszej procedury inicjuje stos poprzez nadanie SP wartości równej adresowi 1
początku obszaru stosu w pamięci MOV #poczstosu, SP /* inicjuje stos */ kod dla pierwszej procedury /* kończy wykonanie */
HALT
Sekwencja wywołania procedury zwiększa SP, zapamiętuje adres powrotny i przekazuje sterowanie do wywoływanej procedury ADD #wywołująca.rozmiar_rekordu, SP MOV #here + 16, *SP /* zapamiętuje adres powrotny */ GOTO wywoływana.obszar_kodu Atrybut wywołująca, rozmiar-rekordu to rozmiar rekordu aktywacji, więc rozkaz ADD powoduje, że SP wskazuje początek następnego rekordu aktywacji. Argument #here+\6 w rozkazie MOV jest adresem rozkazu następującego po GOTO; jest on zapisywany pod adresem wskazywanym przez SP. Sekwencja powrotu składa się z dwóch części. Procedura wywoływana przekazuje sterowanie pod adres powrotu za pomocą rozkazu GOTO *0 (SP)
/* powrót do wywołującej */
Powodem użycia *0 (SP) w rozkazie GOTO jest to, że potrzebujemy dwóch poziomów pośrednich: 0(SP) to adres pierwszego słowa w rekordzie aktywacji, a *0(SP) to zapamiętany tam adres powrotny.
Gdybyśmy dopuszczali ujemne przesunięcia, moglibyśmy ustalić, że S P wskazuje koniec stosu i moglibyśmy nakazać procedurze wywoływanej zwiększanie S P .
Druga część sekwencji powrotu jest w procedurze wywołującej, która zmniejsza S P i w ten sposób przywraca mu wcześniejszą wartość, czyli po wykonaniu odejmowania S P wskazuje początek rekordu aktywacji procedury wywołującej S U B #wywołująca.rozmiar-rekordu,
SP
Obszerniejszy opis sekwencji wywołań i możliwości podziału pracy między proce durę wywołującą i wywoływaną można znaleźć w p. 7.3. P r z y k ł a d 9.2. Program z rysunku 9.6 jest skróconą wersją kodu trójadresowego dla programu w Pascalu opisanego w p. 7 . 1 . Procedura q jest rekurencyjna, więc w danej chwili może istnieć więcej niż jeden rekord aktywacji dla q.
KOD TRÓJADRESOWY /* kod dla s
*/
actionj cali
q
action
2
halt / * k o d dla p action
*/
3
return /* kod dla q action cali
p
action cali
5
q
action cali
*/
4
6
q
return Rys.
9.6. K o d t r ó j a d r e s o w y ilustrujący r e z e r w a c j ę s t o s o w ą
Przypuśćmy, że rozmiary rekordów aktywacji dla procedur s , p i q zostały w czasie kompilacji wyliczone jako, odpowiednio, rozmiars, rozmiar-p i rozmiar-ą. W pierw szym słowie każdego rekordu aktywacji będziemy przechowywać adres powrotny. Arbi tralnie przyjmujemy, że kody procedur zaczynają się, odpowiednio, pod adresami 100, 200 i 300, a stos zaczyna się pod adresem 600. Kod wynikowy dla programu z rys. 9.6 jest następujący: /* kod dla s */ /* inicjowanie stosu */ 100: MOV #600, S P
108: ACTIONj 128: ADD #rozmiar-s, SP 136: MOV # 1 5 2 , * S P
144: GOTO 300 152: S U B #rvzmiars,
160: ACTION
2
SP
/* /* /* /*
początek sekwencji wywołania */ wstaw na stos adres powrotny */ wywołaj q */ odtwórz S P */
180: HALT /* 200: A C T I O N 3 220: GOTO * 0 (SP)
/*
300: ACTION
/* /*
320: 328: 336: 344: 352: 372: 380: 388: 396: 404: 424: 432: 440: 448: 456:
ADD MOV
4
%rozmiar-q, SP # 3 4 4 , *SP
/* /*
GOTO 2 0 0 SUB
# r o z m i a r ^ q , SP
ACTION ADD MOV
5
%rozmiar-q, SP # 3 9 6 , *SP
/* /*
GOTO 3 0 0 SUB
%
ACTION ADD MOV
rozmiar-q, SP 6
#rozmiar-q, SP # 4 4 8 , *SP
/* /*
GOTO 3 0 0 SUB
trozmiar-q, SP /* powrót */
GOTO * 0 (SP)
/* początek stosu */
600:
Zakładamy, że ACTION zawiera warunkowy skok do adresu 456 w sekwencji po wrotu z q; w przeciwnym razie procedura rekurencyjna q będzie wywoływać się w nie skończoność. W poniższym przykładzie rozpatrujemy wykonanie programu, w którym pierwsze wywołanie q nie powoduje natychmiastowego powrotu, ale wszystkie następne — tak. Przyjmujemy, że rozmiars, rozmiar-p i rozmiar-q to, odpowiednio, 2 0 , 4 0 i 60. SP jest inicjowane na 600 — początkowy adres stosu — przez pierwszy rozkaz, znajdujący się pod adresem 100. Tuż przed przekazaniem sterowania z s do q zawartością SP jest 620, bo rozmiars to 20. Następnie, gdy q wywołuje p, rozkaz pod adresem 320 zwiększa SP do 680, gdzie zaczyna się rekord aktywacji dla p ; SP jest przywracana wartość 620 po powrocie sterowania do q. Jeśli kolejne dwa wywołania rekurencyjne q wracają od razu, to maksymalną wartością SP podczas tego wykonania programu jest 680. Podkreślmy jednak, że ostatnim używanym adresem na stosie jest 739, bo rekord aktywacji dla q zaczynający się pod adresem 680 zajmuje 60 bajtów. • 4
Adresy dla nazw w czasie wykonania Strategia przydziału pamięci i ułożenie danych lokalnych w rekordzie aktywacji proce dury determinują sposób dostępu do pamięci związanej z daną nazwą. W rozdziale 8 przyjęliśmy, że nazwa w instrukcji trójadresowej jest w istocie wskaźnikiem wpisu dla tej nazwy w tablicy symboli. Podejście to ma znaczącą zaletę — pozwala uczynić kom-
pilator bardziej przenośnym, gdyż przód kompilatora nie musi być zmieniany, nawet gdy kompilator jest przenoszony na nową maszynę, w której potrzebna jest inna organizacja czasu wykonania (np. display może być przechowywany w rejestrach zamiast w pamię ci). Jednakże, generowanie sekwencji konkretnych instrukcji służących do dostępu do pamięci może być bardzo przydatne w kompilatorze optymalizującym, ponieważ pozwa la optymalizatorowi czerpać korzyści ze szczegółów, które są niewidoczne w „prostym" kodzie trójadresowym. Tak czy inaczej, nazwy muszą w końcu zostać zastąpione kodem, który uzysku j e dostęp do lokacji pamięci. Rozważmy więc prostą instrukcję trójadresowa kopiującą x: = 0. Załóżmy, że po obsłużeniu deklaracji w procedurze, wpis w tablicy symboli dla x zawiera adres względny 12. Rozpatrzmy najpierw przypadek, w którym x jest w ob szarze statycznie rezerwowanym rozpoczynającym się od adresu stat. Wówczas adresem x w czasie wykonania jest stat+ 12. Chociaż kompilator może ostatecznie wyznaczyć wartość statĄ-12 w czasie kompilacji, to pozycja obszaru rezerwowanego może nie być jeszcze znana podczas tworzenia kodu pośredniego uzyskującego dostęp do nazwy. Wte dy rozsądnie jest wygenerować instrukcję trójadresowa „obliczającą" stat+ 12, z myślą o tym, że obliczenie to zostanie wykonane podczas generacji kodu bądź też przez program ładujący przed rozpoczęciem wykonywania programu. Przypisanie x:=0 jest wówczas tłumaczone na
static[12]:=0 Jeśli obszar statyczny zaczyna się pod adresem 100, to kodem wynikowym dla tej in strukcji jest
MOV #0, 112 Załóżmy teraz, że nasz język jest podobny do Pascala i że do dostępu nielokalnych używamy, tak j a k to opisano w p . 7.4, tablicy display. Załóżmy że tablica display jest przechowywana w rejestrach oraz że x jest lokalne dla ry aktywnej, której wskaźnik z tablicy display jest w rejestrze R3. Możemy przetłumaczyć x: = 0 na następujące instrukcje trójadresowe:
do nazw ponadto, procedu wówczas
t,:=12+R3 w których t przechowuje adres x. Te sekwencje mogą być zaimplementowane za pomocą {
jednego rozkazu maszynowego
MOV #0, 12(R3) Zauważmy, że wartości w rejestrze R3 nie da się wyznaczyć w trakcie kompilacji.
9.4
Bloki bazowe i grafy przepływu
Grafowa reprezentacja instrukcji trójadresowych, nazywana grafem przepływu, jest przy datna podczas przedstawiania algorytmów generacji kodu, nawet jeśli graf nie jest jawnie tworzony przez algorytm generacji kodu. Wierzchołki w grafie przepływu symbolizują
obliczenia, a krawędzie — przepływ sterowania. Grafu przepływu można używać jako narzędzia do zbierania informacji o programie pośrednim (patrz rozdz. 10). Niektóre algo rytmy przydziału rejestrów korzystają z grafów przepływu do wykrywania wewnętrznych pętli, w których program będzie zapewne spędzał większość czasu.
Bloki bazowe Blok bazowy jest sekwencją kolejnych instrukcji, do której sterowanie wchodzi na począt ku i wychodzi na końcu, bez zatrzymywania ani możliwości rozgałęzienia przed końcem. Następująca sekwencja instrukcji trójadresowych tworzy blok bazowy
(9.1)
O instrukcji trójadresowej x : = y + z mówimy, że jest definicją x oraz użyciem (lub odwołaniem do) y i z. Nazwa w bloku bazowym jest żywa w danym punkcie, jeśli wartość jej jest używana za tym punktem w programie, być może w innym bloku bazowym. Do podziału sekwencji instrukcji trójadresowych na bloki bazowe można użyć po niższego algorytmu. A l g o r y t m 9.1.
Podział na bloki bazowe.
Wejście. Sekwencja instrukcji trójadresowych. Wyjście. Lista bloków bazowych z każdą z instrukcji trójadresowych w dokładnie jednym bloku. Metoda. 1.
Najpierw wyznaczamy zbiór liderów, czyli pierwszych instrukcji bloków bazowych. Korzystamy z następujących reguł: i) pierwsza instrukcja jest liderem, ii) każda instrukcja, która jest celem warunkowego lub bezwarunkowego skoku, jest liderem, iii) każda instrukcja następująca bezpośrednio po skoku warunkowym lub bezwa runkowym jest liderem.
2.
Blok każdego lidera składa się z tego lidera i instrukcji po nim następujących, aż do — nie włączanego — kolejnego lidera bądź końca programu. •
P r z y k ł a d 9.3. Rozpatrzmy fragment kodu źródłowego przedstawiony na rys. 9.7; obli cza on iloczyn skalarny dwóch wektorów, a i b , o długości równej 20. Lista instrukcji trójadresowych wykonujących to obliczenie na naszej maszynie docelowej jest przedsta wiona na rys. 9.8.
begin ilocz := 0; i := 1; do begin ilocz := ilocz + a[i] * b[i]; i := i + 1 end while i <= 20 end Rys. 9.7. Program obliczający iloczyn skalarny
Zastosujmy algorytm 9.1 do kodu trójadresowego z rys. 9.8, aby wyznaczyć jego bloki bazowe. Instrukcja (1) jest liderem zgodnie z regułą i), a instrukcja (3) jest liderem zgodnie z regułą ii), bo ostatnia instrukcja wykonuje skok do niej. Zgodnie z regułą iii) instrukcja następująca po (12) (przypomnijmy, że na rys. 9.8 przedstawiono tylko fragment programu) jest liderem. Wobec tego instrukcje (1) i (2) tworzą blok bazowy. Pozostała część programu, począwszy od instrukcji (3), tworzy drugi blok bazowy. •
(1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12)
ilocz := 0 i := 1 ti = 4 * i t = a [ t! ] = 4 * i t t = b [ t ] - t * t = ilocz + t ilocz := t = i + 1 i : if i <= 20 goto 2
/* oblicz a[i] */
3
4
fc
3
2
/* oblicz b[i] */
4
5
5
6
Rys. 9.8. Trójadresowy kod obliczający iloczyn skalarny Przekształcenia bloków bazowych Blok bazowy oblicza zbiór wyrażeń. Wyrażenia te są wartościami nazw żywych przy wyjściu z bloku. O dwóch blokach mówimy, że są równoważne, jeśli obliczają taki sam zbiór wyrażeń. Do bloku bazowego można zastosować różne przekształcenia, które nie zmieniają zbioru wyrażeń obliczanych przez blok. Wiele z tych przekształceń jest przydatnych do poprawienia jakości kodu, który zostanie ostatecznie wygenerowany z tego bloku bazo wego. W następnym rozdziale pokazaliśmy, jak globalny „optymalizator" kodu próbuje wykonywać takie przekształcenia, aby przestawić obliczenia w programie i przez to skró cić czas działania ostatecznego programu wynikowego lub zmniejszyć pamięć dla niego potrzebną. Istnieją dwie ważne klasy przekształceń lokalnych, które można stosować do bloków bazowych: są to przekształcenia zachowujące strukturę oraz przekształcenia algebraiczne.
Przekształcenia zachowujące strukturę Najważniejszymi przekształceniami zachowującymi strukturę bloków bazowych są: 1) 2) 3) 4)
usuwanie podwyrażeń wspólnych, usuwanie kodu martwego, zmiana nazw zmiennych tymczasowych, wymiana dwóch niezależnych sąsiednich instrukcji.
Przyjrzyjmy się tym przekształceniom trochę dokładniej. Załóżmy chwilowo, że w blo kach bazowych nie korzystamy z tablic, wskaźników ani wywołań procedur. 1. Usuwanie podwyrażeń a b c d
=b+c =a-d =b+c =a-d
wspólnych.
Weźmy blok bazowy
(9.2)
Druga i czwarta instrukcja obliczają to samo wyrażenie, konkretnie b + c - d , i w związku z tym ten blok bazowy można przekształcić na równoważny blok =b+c -a-d =b+c =b
a b c d
(9.3)
Zauważmy, że chociaż pierwsza i trzecia instrukcja w (9.2) i (9.3) wydają się mieć po prawej stronie to samo wyrażenie, to druga instrukcja przedefiniowuje b . Wobec tego wartość b w trzeciej instrukcji jest inna niż w pierwszej i pierwsza oraz trzecia instrukcja nie obliczają takiego samego wyrażenia. 2. Usuwanie kodu martwego. Przypuśćmy, że zmienna x jest martwa, tzn. nie jest używana nigdzie dalej, w punkcie, w którym w bloku bazowym jest instrukcja x : = y + z . Taką instrukcję można wówczas bezpiecznie usunąć, nie zmieniając wartości bloku ba zowego. 3. Zmiana nazw zmiennych tymczasowych. Przypuśćmy, że mamy instrukcję t : = b + c , gdzie t jest zmienną tymczasową. Jeśli zamienimy ją na u : = b + c , gdzie u jest nową zmienną tymczasową, i zmienimy wszystkie użycia tej instancji t na u, to wartość bloku bazowego nie ulegnie zmianie. W istocie, zawsze możemy przekształcić blok bazowy na blok równoważny, w którym każda instrukcja, która definiuje zmienną tymczasową, definiuje nową zmienną tymczasową. O takim bloku bazowym mówimy, że jest blokiem w postaci normalnej. 4. Wymiana instrukcji. Przypuśćmy, że mamy blok z dwiema sąsiadującymi instruk cjami t :=b+c t :=x+y x
2
Możemy zamienić miejscami te instrukcje, nie zmieniając wartości bloku wtedy i tylko wtedy, gdy t j nie jest x ani y oraz t nie jest b ani c . Zauważmy, że blok bazowy w postaci normalnej pozwala wykonywać wszystkie możliwe wymiany instrukcji. 2
Przekształcenia algebraiczne Do przekształcenia zbioru wyrażeń obliczanych przez blok bazowy w równoważny al gebraicznie zbiór można używać bardzo wielu przekształceń algebraicznych. Przydatne są te, które upraszczają wyrażenia albo zastępują drogie operacje tanimi. Przykładowo, instrukcje, takie jak x:=x+0 lub x:=x*l mogą zostać usunięte z bloku bazowego bez zmiany zbioru wyrażeń przez niego obli czanych. Operator potęgowania w instrukcji x:=y**2 zazwyczaj jest zaimplementowany jako wywołanie funkcji. Przy użyciu transformacji algebraicznej instrukcja ta może zostać zastąpiona tańszą, ale równoważną instrukcją x:=y*y Transformacje algebraiczne są dokładniej opisane w p. 9.9 o optymalizacji przez szparkę oraz w p . 10.3 o optymalizacji bloków bazowych.
Grafy przepływu Do zbioru bloków bazowych możemy dodać informacje o przepływie sterowania, two rząc program przez konstruowanie grafu skierowanego, nazywanego grafem przepływu. Wierzchołkami grafu przepływu są bloki bazowe. Jeden wierzchołek jest wyróżniony jako początkowy; jest to blok, którego liderem jest pierwsza instrukcja programu. W gra fie jest krawędź od bloku B do bloku B , jeśli B może wystąpić bezpośrednio po B w pewnej sekwencji wykonania; czyli gdy x
1)
2
2
x
istnieje bezwarunkowy lub warunkowy skok od ostatniej instrukcji B do pierwszej instrukcji B lub B jest bezpośrednio po B w porządku programu i B nie kończy się bezwarunko wym skokiem. x
2
2)
2
x
x
Mówimy, że B jest poprzednikiem B oraz że B jest następnikiem B . x
2
2
x
Przykład 9.4. Graf przepływu programu z rys. 9.7 pokazano na rys. 9.9. B jest wierz chołkiem początkowym. Zauważmy, że w ostatniej instrukcji skok do instrukcji (3) za stąpiono równoważnym skokiem do początku bloku B . • x
2
ilocz := 0 i := 1 tl = 4 * i t = a [ tj ] t = 4 *i t = b [t ] t = t * t t = ilocz + tg ilocz : = t t = i + 1 i := t if i <= 20 goto B 2
3
4
3
2
5
4
6
6
7
7
2
Rys. 9.9. Graf przepływu dla programu Reprezentacja bloków bazowych Bloki bazowe można reprezentować, korzystając z wielu różnych struktur danych. Przy kładowo, po podziale instrukcji trójadresowych przez algorytm 9 . 1 , każdy blok bazowy może być reprezentowany przez rekord składający się z liczby czwórek w bloku, po której następuje wskaźnik do lidera (pierwszej czwórki) bloku oraz lista poprzedników i następników bloku. Innym pomysłem jest tworzenie listy czwórek w każdym bloku. Jawne odwołania do numerów czwórek w instrukcjach skoku na krańcach bloków ba zowych mogą powodować problemy, jeśli czwórki są przesuwane podczas optymalizacji kodu. Przykładowo, jeśli blok B który zawiera instrukcje od (3) do (12) w kodzie po średnim z rys. 9.9, zostałby przesunięty w inne miejsce w tablicy czwórek albo zostałby zmniejszony, to (3) w i f i <= 2 0 g o t o (3) musiałaby zostać zmieniona. W związku z tym wolimy, żeby — tak jak na rys. 9.9 — skoki wskazywały bloki, a nie czwórki. Trzeba zauważyć, że krawędź w grafie przepływu od bloku B do bloku B' nie określa warunków, które muszą być spełnione, żeby sterowanie przeszło od B do B . Czyli krawędź nie określa, czy skok warunkowy na końcu B (jeśli jest tam skok warunkowy) prowadzi do lidera B' wtedy, gdy warunek jest spełniony, czy wtedy, gdy nie jest. Jeśli informacja taka jest potrzebna, to można ją odzyskać z instrukcji skoku w B. v
f
Pętle Co jest pętlą w grafie przepływu i jak znaleźć wszystkie pętle? Zwykle łatwo jest odpo wiedzieć na te pytania. Przykładowo, na rys. 9.9 jest jedna pętla, składająca się z bloku B . Ogólne odpowiedzi na te pytania są jednakże dość skomplikowane i dokładnie zba daliśmy j e w następnym rozdziale. Na razie przyjmijmy, że pętla to zbiór wierzchołków w grafie przepływu, taki że: 2
1)
wszystkie wierzchołki w zbiorze są w jednej silnie spójnej składowej; tj. z każdego wierzchołka w pętli do każdego innego istnieje ścieżka długości jeden lub więcej, całkowicie zawarta w pętli,
2)
zbiór wierzchołków ma jedno wejście, czyli wierzchołek w pętli, taki że wszystkie drogi do wierzchołka w pętli z wierzchołka na zewnątrz tej pętli prowadzą przez wejście.
Pętla, która nie zawiera innych pętli, jest nazywana pętlą
9.5
wewnętrzną.
Informacje o następnym użyciu
W tym podrozdziale zajęliśmy się zbieraniem informacji o następnym użyciu nazw z blo ków bazowych. Jeśli nazwa w rejestrze nie jest już dłużej potrzebna, to rejestr może zostać przydzielony jakiejś innej nazwie. Pomysł utrzymywania w pamięci tylko nazwy, która będzie jeszcze używana, może zostać zastosowany w wielu sytuacjach. Korzystaliśmy z niego w p . 5.8 d o przydzielania miejsca wartościom atrybutów. W prostym generatorze kodu z następnego podrozdziału zastosowaliśmy go do przydziału rejestrów; stosuje się go także d o przydziału pamięci dla nazw tymczasowych.
Obliczanie następnych użyć Użycie nazwy w instrukcji trójadresowej jest zdefiniowane następująco. Zakładamy, że instrukcja trójadresowa i przypisuje wartość zmiennej x. Jeśli instrukcja j korzysta z x jako z argumentu i sterowanie może przejść z i do j p o ścieżce, na której nie m a dalszych przypisań do x, to mówimy, że instrukcja j używa wartości x obliczonej w i. Dla każdej instrukcji trójadresowej x : = y op z chcemy wyznaczyć następne użycia x, y i z. Chwilowo nie będziemy się zajmowali użyciami na zewnątrz bloku bazowego zawierającego analizowaną instrukcję, ale jeśli chcemy, to możemy próbować wyznaczyć takie użycia, korzystając z opisanej w rozdz. 10 techniki nazywanej analizą żywych zmiennych. Nasz algorytm wyznaczania następnych użyć wykonuje przejście do tyłu po każdym bloku bazowym. Przeglądając ciąg instrukcji trójadresowych, łatwo możemy odnaleźć krańce bloków bazowych, podobnie jak w algorytmie 9 . 1 . Ponieważ procedury mogą mieć dowolne efekty uboczne, dla wygody przyjmujemy, że każde wywołanie procedury jest początkiem nowego bloku bazowego. Po znalezieniu końca bloku bazowego przeglądamy instrukcje do tyłu, aż do jego początku, zapisując (w tablicy symboli) dla każdej nazwy x, czy x m a następne użycie w bloku, a jeśli nie, to czy jest żywa przy wyjściu z tego bloku. Jeśli wykonamy opisaną w rozdz. 10 analizę przepływu danych, to wiemy, które nazwy są żywe przy wyjściu z każdego bloku. Jeśli nie wykonaliśmy analizy żywych zmiennych, możemy zachowaw czo przyjąć, że wszystkie zmienne nietymczasowe są żywe przy wyjściu z bloku. Jeśli algorytmy generujące kod pośredni albo optymalizujące kod pozwalają używać pewnych zmiennych tymczasowych w wielu blokach, to j e również musimy uważać za żywe. D o brym pomysłem jest oznaczanie takich zmiennych tymczasowych po to, żebyśmy nie musieli przyjmować, że wszystkie zmienne tymczasowe są żywe. Przypuśćmy, że w trakcie naszego przeglądania wstecz natrafiliśmy na instrukcję i: x : = y op
z. Wówczas robimy, c o następuje:
1.
Dołączamy do instrukcji / informacje aktualnie znajdujące się w tablicy symboli 1
2. 3.
dotyczące następnego użycia i żywotności x, y i z . W tablicy symboli nadajemy zmiennej x atrybuty „martwa" i „nie ma dalszych użyć". W tablicy symboli zapisujemy, że y i z są „żywe" i że ich następnym użyciem jest /. Podkreślmy, że kolejność kroków 2 . i 3. nie może zostać zmieniona, bo x może być y albo z.
Jeśli instrukcja trójadresowa i ma postać x : = y lub x : = op y, to kroki pozostają te same, tylko pomijamy z. Pamięć dla nazw tymczasowych Chociaż w kompilatorze optymalizującym tworzenie nowej nazwy za każdym ra zem, gdy potrzebna jest nazwa tymczasowa (uzasadnienie przedstawiliśmy w rozdz. 10) może być użyteczne, to — aby przechowywać wartości tych zmiennych tymczasowych — trzeba im przydzielić pamięć. Wielkość pola dla zmiennych tymczasowych w rekor dzie aktywacji z p. 7.2 zwiększa się wraz ze zwiększaniem się liczby zmiennych tym czasowych. W ogólności, dwie zmienne tymczasowe możemy umieścić w tym samym obszarze pamięci, jeśli nie są one żywe w tym samym czasie. Ponieważ prawie wszystkie zmien ne tymczasowe są definiowane i używane wewnątrz bloków bazowych, więc informacje o następnym użyciu można zastosować do upakowania zmiennych tymczasowych. W roz dziale 10 opisaliśmy algorytmy analizy przepływu potrzebne do określenia żywotności zmiennych tymczasowych używanych w wielu blokach. Lokacje pamięci dla zmiennych tymczasowych możemy przydzielać poprzez prze glądanie ich po kolei i przydzielanie zmiennej tymczasowej pierwszej lokacji w polu dla zmiennych tymczasowych, która nie zawiera żywej zmiennej. Jeśli zmiennej nie można przydzielić żadnej poprzednio utworzonej lokacji, trzeba dodać nową lokację do obszaru danych aktualnej procedury. W wielu przypadkach zmienne tymczasowe moż na umieszczać w rejestrach, a nie w pamięci, co pokazujemy w następnym podroz dziale. Przykładowo, sześć zmiennych z bloku bazowego (9.1) można upakować w dwóch lokacjach. Lokacje te są w poniższym kodzie przedstawiane przez t j i t : 2
t,:
a*a a*b
t : t, : 2
1
Jeśli zmienna x nie jest żywa, to rozpatrywaną instrukcję można usunąć; takie przekształcenia są omówione w p.
9.8.
9.6
Prosty generator kodu
Przedstawiona w tym podrozdziale strategia generacji kodu produkuje kod wynikowy dla sekwencji instrukcji trójadresowych. Rozpatruje ona po kolei wszystkie instrukcje, spraw dzając, czy któreś z argumentów rozkazu znajdują się akurat w rejestrach, i jeśli tak, to wykorzystuje ten fakt. Dla uproszczenia przyjmujemy, że każdy operator z instrukcji trój adresowej ma odpowiadający mu operator w języku wynikowym. Przyjmujemy ponadto, że obliczone wyniki mogą pozostać w rejestrach tak długo, j a k jest to możliwe, zapisując j e w pamięci tylko wtedy, gdy (a) dany rejestr jest potrzebny do innego obliczenia łub (b) tuż przed wywołaniem procedury, skokiem lub rozkazem z etykietą . Warunek (b) implikuje, że wszystko musi być zapamiętane przed końcem bloku ba zowego . Powodem jest to, że p o wyjściu z bloku bazowego często możemy przejść d o kilku różnych bloków albo do jednego bloku, do którego można wejść z wielu innych bloków. W obu przypadkach nie możemy, bez dodatkowego wysiłku, założyć, że dane używane przez blok są przechowywane w tych samych rejestrach, niezależnie od tego, jak sterowanie weszło d o tego bloku. W związku z tym, aby uniknąć możliwych błę dów, nasz prosty algorytm generacji kodu zapamiętuje wszystko, gdy przechodzi przez granice bloków oraz gdy wykonuje wywołanie procedury. W dalszej części rozdziału rozważyliśmy metody pozwalające przechowywać pewne dane w rejestrach dla wielu bloków. Dla instrukcji trójadresowej a : = b + c możemy utworzyć rozsądny kod, generując pojedynczy rozkaz ADD R j, Ri o koszcie jeden, pozostawiając wynik a w rejestrze Ri. Jest to możliwe tylko wtedy, gdy rejestr Ri zawiera b , Rj zawiera c oraz zmienna b nie jest żywa po tej instrukcji (to znaczy, zmienna b nie jest po niej używana). 1
2
Jeśli Ri zawiera b , ale c jest w lokacji pamięci (oznaczanej dla wygody c ) , możemy wygenerować sekwencję ADD
c,
Ri
koszt = 2
lub
MOV ADD
Ri
koszt = 3
pod warunkiem, ż e zmienna b nie jest potem żywa. Druga sekwencja jest atrakcyjna, jeśli wartość c jest później używana, bo wówczas można ją pobrać z rejestru Rj. Trzeba rozważyć wiele innych przypadków, w zależności od tego, gdzie aktualnie są przecho wywane zmienne b i c oraz czy b i c są potem używane. Musimy również rozważyć przypadek, w którym b i/lub c są stałe. Liczba przypadków, które należy rozpatrzyć, zwiększa się, jeśli przyjmiemy, że operator + jest przemienny. Widać więc, że genera cja kodu wymaga analizy wielu przypadków i że wybór zależy od kontekstu, w którym występuje instrukcja trójadresowa. 1
Jednakże, aby produkować zrzut symboliczny, który udostępnia wartości lokacji pamięci i rejestrów w postaci nazw w programie źródłowym dla tych wartości, wygodniejsze może być zapisywanie nazw zdefiniowanych przez programistę (ale niekoniecznie wygenerowanych przez kompilator zmiennych tymczasowych) od razu po ich obliczeniu, na wypadek wystąpienia błędu, który nagle spowodowałby nieoczekiwane przerwanie i wyjście.
2
Zauważmy, że nie zakładamy, że czwórki zostały faktycznie podzielone przez kompilator na bloki bazowe; pojęcie bloku bazowego jest tak czy inaczej wygodne.
Deskryptory rejestrów i adresów Algorytm generacji kodu korzysta z deskryptorów do pamiętania zawartości rejestrów i adresów dla nazw. 1.
2.
Deskryptor rejestrów przechowuje informacje o zawartości wszystkich rejestrów. Odwołujemy się do niego za każdym razem, gdy potrzebny jest nowy rejestr. Przyj mujemy, że początkowo deskryptor rejestrów wskazuje, że wszystkie z nich są puste. (Jeśli rejestry są przydzielane wielu blokom, nie musi tak być). W trakcie generacji kodu dla bloku każdy z rejestrów będzie w danej chwili przechowywał wartości zera lub więcej nazw. Deskryptor adresów pamięta lokacje (jedną lub wiele), w których w trakcie wy konywania można znaleźć aktualną wartość nazwy. Lokacja może być rejestrem, miejscem na stosie, adresem w pamięci lub pewnym zbiorem wspomnianych wcze śniej miejsc, bo po skopiowaniu wartość zostaje również tam, skąd została skopio wana. Informacje te są przechowywane w tablicy symboli i korzysta się z nich, aby wyznaczyć sposób dostępu do nazwy.
Algorytm generacji kodu Algorytm generacji kodu przyjmuje jako wejście sekwencję instrukcji trójadresowych tworzących blok bazowy. Dla każdej instrukcji trójadresowej o postaci x : = y op z wy konujemy następujące czynności; 1.
2.
3.
4.
Wywołujemy funkcję dajrej, aby wyznaczyć lokację L, w której powinien zostać zapamiętany wynik obliczenia y op z. L będzie najczęściej rejestrem, ale może też być lokacją w pamięci. Funkcję da jre j opiszemy dalej. Sprawdzamy deskryptor adresów dla y, aby wyznaczyć y ' , aktualną lokację (lub jedną z lokacji) y . Jeśli wartość y ' jest jednocześnie w pamięci i w rejestrze, to wybieramy rejestr. Jeśli wartość y nie jest jeszcze w L , to generujemy rozkaz MOV y ' , L , aby umieścić kopię y w L. Generujemy rozkaz O P z ' , L , gdzie z ' jest aktualną lokacją z. Tak jak poprzed nio, wybieramy rejestr, a nie pamięć, jeśli jest to możliwe. Uaktualniamy deskryptor adresów dla x tak, aby wskazywał, że x jest w lokacji L . Jeśli L jest rejestrem, uak tualniamy j e g o deskryptor, wskazując, że rejestr przechowuje wartość x, i usuwamy x ze wszystkich innych deskryptorów rejestrów. Jeśli aktualne wartości y i/lub z nie mają dalszych użyć i nie są żywe przy wyj ściu z bloku, oraz znajdują się w rejestrach, to zmieniamy deskryptory rejestrów, aby wskazywały, że p o wykonaniu x : = y op z te rejestry nie będą j u ż zawierały, odpowiednio, y i/lub z.
Jeśli rozpatrywana instrukcja trójadresowa ma operator jednoargumentowy, to ko nieczne kroki są podobne do powyższych i nie będziemy ich szczegółowo opisywać. Ważnym przypadkiem szczególnym jest x : = y . Jeśli y jest w rejestrze, należy po pro stu zmienić deskryptory adresów i rejestrów tak, by wskazywały, że wartość x można aktualnie znaleźć tylko w rejestrze przechowującym wartość y. Jeśli y nie ma następne go użycia i nie jest żywe przy wyjściu z bloku, to później rejestr nie przechowuje już wartości y.
Jeśli y jest tylko w pamięci, możemy w zasadzie zapamiętać, że wartość x jest w lokacji y , ale takie podejście skomplikowałoby nasz algorytm, bo nie moglibyśmy zmienić wartości y bez wcześniejszego zabezpieczenia wartości x . Wobec tego, gdy y jest w pamięci, używamy funkcji da jre j do znalezienia rejestru, do którego ładujemy wartość y i czynimy ten rejestr lokacją x. Alternatywnie, możemy wygenerować rozkaz MOV y , x , który byłby preferowany, jeśli wartość x nie miałaby następnych użyć w bloku. Warto zauważyć, że większość, jeśli nie wszystkie, rozkazów kopiujących zostanie usuniętych po zastosowaniu algorytmów poprawiających bloki i wykonaniu propagacji kopii z rozdz. 10. Po obsłużeniu wszystkich instrukcji trójadresowych z bloku bazowego zapamię tujemy, korzystając z rozkazu MOV, te nazwy, które są żywe przy wyjściu i nie są w swoich lokacjach w pamięci. Aby to zrobić, używamy deskryptora rejestrów do wyznaczenia, które nazwy są w rejestrach, deskryptora adresów do sprawdzenia, czy nazwy te nie są już zapisane w lokacjach w pamięci oraz informacji o żywych zmiennych d o określenia, czy daną nazwę trzeba zapamiętać. Jeśli nie obliczyliśmy in formacji o żywych zmiennych za pomocą analizy przepływu danych między blokami, musimy założyć, że wszystkie zdefiniowane przez użytkownika nazwy są żywe na końcu bloku. Funkcja dajrej Funkcja dajrej zwraca lokację L używaną do przechowywania wartości x dla przypi sania x : = y op z. Można włożyć ogromny wysiłek w implementację tej funkcji tak, aby zwracała rozsądnie wybraną lokację L. Poniżej opisaliśmy prosty, łatwy do zaim plementowania schemat oparty na informacjach o następnych użyciach (patrz poprzedni podrozdział). 1.
2. 3.
4.
Jeśli nazwa y jest w rejestrze, który nie przechowuje wartości innych nazw (przy pomnijmy, że kopiowanie x : = y może spowodować, że rejestr będzie jednocześnie przechowywał wartości dwóch lub więcej zmiennych), oraz zmienna y nie jest ży wa i nie ma następnych użyć po wykonaniu x : = y op z, to jako L zwróć rejestr zawierający y . Uaktualnij deskryptor adresów dla y , aby wskazywał, że y nie jest już pamiętane w L. Jeśli nie uda się wykonać 1., zwróć pusty rejestr jako L, jeśli taki istnieje. Jeśli nie uda się wykonać 2. oraz x m a następne użycie w bloku lub op jest ope ratorem, który wymaga rejestru, takim jak indeksowanie, znajdź zajęty rejestr R. Zapamiętaj wartość R w pamięci (MOV R, M), jeśli nie została wcześniej zapi sana pod właściwym adresem M, uaktualnij deskryptor adresów dla M i zwróć R. Jeśli R przechowuje wartości kilku zmiennych, to rozkaz MOV musi zostać wyge nerowany dla każdej zmiennej, którą trzeba zapamiętać. Odpowiednim zajętym re jestrem może być ten, którego zawartość będzie w przyszłości wykorzystywana najpóźniej, albo taki, którego wartość jest już zapisana w pamięci. Nie podajemy tu konkretnego rozwiązania, bo nie ma jedynie słusznej metody dokonania wy boru. Jeśli zmienna x nie jest używana w bloku lub nie ma odpowiedniego zajętego rejestru, wybierz lokację x w pamięci jako L.
Bardziej wyszukana funkcja dajrej podczas wyznaczania rejestru dla x brałaby ponadto pod uwagę dalsze użycia x oraz łączność operatora op. Opracowanie takich rozszerzeń do funkcji dajrej pozostawiamy jako ćwiczenie. P r z y k ł a d 9.5. Przypisanie d : = ( a - b ) + ( a - c ) + ( a - c ) można przetłumaczyć na na stępującą sekwencję instrukcji trójadresowych:
t :=a-b u: = a - c
v:=t+u d: =v+u z żywym d na końcu. Przedstawiony powyżej algorytm generacji kodu dla takich in strukcji trójadresowych wyprodukowałby sekwencję instrukcji przedstawioną na rys. 9.10. Obok kodów podano wartości rejestrów i deskryptor adresów w trakcie generacji kodu. W deskryptorze adresów pomijamy fakt, że a, b i c są przez cały czas w pamięci. Przyj mujemy ponadto, że t, u i v, które są zmiennymi tymczasowymi, nie są przechowywane w pamięci, chyba że jawnie j e zapamiętamy, korzystając z rozkazu MOV.
INSTRUKCJE
WYGENEROWANY KOD
DESKRYPTOR REJESTRÓW
DESKRYPTOR ADRESÓW
puste rejestry RO zawiera t
t
W
RO
W W
RO Rl
t:=a-b
MOV a, RO SUB b, RO
u:=a-c
MOV a, Rl SUB c, Rl
RO zawiera t Rl zawiera u
t u
v:=t+u
ADD Rl, RO
RO zawiera v Rl zawiera u
u W Rl v w RO
d:=v+u
ADD Rl, RO
RO zawiera d
d w RO
MOV RO, d
d w RO i w pamięci
Rys. 9.10. Fragment kodu
Pierwsze wywołanie dajrej zwraca RO jako lokację, w której wyliczymy t. Po nieważ a nie jest w RO, więc generujemy rozkazy MOV a, RO oraz SUB b, RO. Następnie zmieniamy deskryptory rejestrów, tak aby wskazywały, ż e RO zawiera t. Generacja kodu jest dalej wykonywana analogicznie, aż do obsłużenia ostatniej in strukcji trójadresowej, d: =v+u. Zauważmy, że Rl staje się pusty, bo u nie m a następnych użyć. Generujemy wówczas rozkaz MOV RO, d, aby zapamiętać żywą zmienną d na końcu bloku. Koszt wygenerowanego kodu z rys. 9.10 wynosi 12. Moglibyśmy go zmniejszyć do 11 przez wygenerowanie rozkazu MOV RO, Rl bezpośrednio po pierwszym rozkazie oraz przez usunięcie rozkazu MOV a, Rl, ale wymagałoby to bardziej złożonego algo rytmu generacji kodu. Oszczędność wynika z tego, że taniej jest załadować Rl z RO niż z pamięci. •
Generowanie kodu dla innych typów instrukcji Indeksowanie i operacje na wskaźnikach w instrukcjach trójadresowych są obsługiwa ne tak samo, jak operatory dwuargumentowe. W tabeli na rysunku 9.11 są sekwencje kodu generowane dla indeksowanych instrukcji przypisania o postaci a : = b [ i ] oraz a [ i ] : = b , przy założeniu, że pamięć dla b jest rezerwowana statycznie.
i W REJESTRZE Ri INSTRUKCJA
i NA STOSIE
i W PAMIĘCI Mi
KOD
KOSZT
KOD
KOSZT
KOD
KOSZT
a:=b[i]
MOV b (Ri) ,R
2
MOV Mi,R MOV b(R) ,R
4
MOV Si(A),R MOV b{R) ,R
4
a[i]:=b
MOV b,a(Ri)
3
MOV Mi,R MOV b,a(R)
5
MOV Si(A),R MOV b,a(R)
5
Rys. 9.11. Sekwencje rozkazów dla przypisań indeksowanych Aktualna lokacja i determinuje wybór sekwencji kodu. Rozpatrujemy trzy przypad ki, w zależności od tego czy i jest w rejestrze R i , czy w pamięci o lokacji Mi, czy też na stosie, z przesunięciem S i , a wskaźnik rekordu aktywacji dla i jest w rejestrze A. Rejestr R to rejestr zwracany przez wywoływaną funkcję da jre j . Po pierwszym przy pisaniu wolelibyśmy pozostawić a w rejestrze R, jeśli a ma następne użycie w bloku, a rejestr R jest wolny. W drugim przypisaniu zakładamy, że a ma statycznie przydzieloną pamięć. Tabela z rysunku 9.12 przedstawia sekwencje kodu generowane dla wskaźnikowych przypisań a : = * p oraz * p : = a . Wybrana sekwencja jest determinowana aktualną loka cją Pp W REJESTRZE Rp INSTRUKCJA KOD
p W PAMIĘCI Mp
p NA STOSIE
KOSZT
KOD
KOSZT
KOD
KOSZT
3
MOV Sp (A) , R MOV *R,R
3
4
MOV a,R MOV R,*Sp (A)
4
a :=*p
MOV *Rp,a
2
MOV Mp,R MOV *R,R
*p: =a
MOV a,*Rp
2
MOV Mp,R MOV a,*R
Rys. 9.12. Sekwencje rozkazów dla przypisań wskaźnikowych Rozpatrujemy trzy przypadki, w zależności od tego, czy p jest początkowo w re jestrze Rp, czy w pamięci pod adresem Mp, czy też na stosie z przesunięciem S p , a wskaźnik rekordu aktywacji dla p jest w rejestrze A. Rejestr R to rejestr zwracany przez wywoływaną funkcję dajrej. W drugim przypisaniu zakładamy, że a ma statycz nie przydzieloną pamięć. Instrukcje warunkowe Maszyny implementują skoki warunkowe dwoma sposobami. Pierwszy to wykonywanie skoku, gdy wartość wyróżnionego rejestru spełnia jeden z sześciu warunków: jest ujem-
na, równa zero, dodatnia, nieujemna, niezerowa lub niedodatnia. Na takich maszynach instrukcje trójadresowe, takie j a k if x < y goto z, mogą być implementowane przez odejmowanie y od x w rejestrze R, a następnie wykonywanie skoku do z, jeśli wartość w rejestrze R jest ujemna. Drugim podejściem, często spotykanym, jest używanie zbioru znaczników do pamię tania, czy ostatnia wartość obliczona lub załadowana d o rejestru jest ujemna, zerowa czy dodatnia. Często rozkaz porównania (CMP dla naszej maszyny) m a pożądaną własność ustawiania znaczników bez właściwego obliczania wartości. Czyli CMP x , y ustawia znaczniki na wynik dodatni, gdy x > y i tak dalej. Maszynowy rozkaz skoku warunkowe go wykonuje skok, gdy jeden z warunków < , = , > , ^ , ^ , ^ jest spełniony. Używamy rozkazu CJ<=z, co oznacza „skocz d o z, jeśli znacznik wskazuje, że wynik jest ujemny lub równy zeru". Przykładowo, if x < y goto z można zaimplementować następująco:
CMP CJ<
x, z
y
Jeśli generujemy kod dla maszyny ze znacznikami, to podczas generacji wygodnie jest utrzymywać deskryptor znaczników. Deskryptor ten podaje nazwę, która ostatnio spowodowała ustawienie znacznika, lub parę porównywanych nazw, jeśli znacznik został ustawiony w taki sposób. Wówczas moglibyśmy zaimplementować x: =y+z
if x<0 goto z za pomocą
MOV ADD MOV CJ<
y, RO z , RO RO, X z
jeśli wiedzielibyśmy, że znacznik został ustalony przez x po ADD z , RO.
9.7
Przydział i wyznaczanie rejestrów
Rozkazy korzystające tylko z argumentów w rejestrach są krótsze i szybsze niż te, któ re mają argumenty w pamięci. Wobec tego, wydajne wykorzystywanie rejestrów jest ważne, aby generować dobry kod. W tym podrozdziale przedstawiliśmy różne strategie decydowania o tym, które wartości z programu powinny być przechowywane w rejestrach (przydział rejestrów) oraz w którym konkretnie rejestrze m a być przechowywana każda z wybranych wartości (wyznaczanie rejestrów). Jednym ze sposobów przydziału i wyznaczania rejestrów jest przypisywanie okre ślonych wartości z programu wynikowego do pewnych rejestrów. Możemy zdecydować, na przykład, że adresy bazowe przypisujemy do jednej grupy rejestrów, obliczenia aryt metyczne do drugiej, wierzchołek stosu do ustalonego rejestru i tak dalej. Zaletą takiego podejścia jest uproszczenie budowy kompilatora. Wadą to, że jeśli będziemy się ściśle trzymali powyższych reguł, możemy nieefektywnie używać rejestrów;
niektóre rejestry mogą pozostać nieużywane w długich fragmentach kodu, podczas gdy generowane będą niepotrzebne rozkazy ładujące i zapisujące. M i m o to, w większości środowisk rozsądnie jest zarezerwować kilka rejestrów na rejestry bazowe, wskaźniki stosu i tym podobne, a pozostałe rejestry udostępnić kompilatorowi do dowolnego użycia.
Globalny przydział rejestrów Algorytm generacji kodu przedstawiony w p . 9.6 korzystał z rejestrów do przechowywa nia wartości w obrębie jednego bloku bazowego. Wszystkie żywe zmienne były jednak zapamiętywane na końcu każdego bloku. Aby zachować niektóre takie zapisy i odpo wiadające im odczyty, możemy przydzielać rejestry często używanym zmiennym tak, by utrzymać zgodność między blokami (globalnie). Ponieważ programy spędzają większość swojego czasu w pętlach wewnętrznych, więc naturalnym podejściem do globalnego wy znaczania rejestrów jest próba przechowywania często używanych wartości w tym samym rejestrze w całej pętli. Załóżmy na razie, że znamy strukturę pętli w grafie przepływu oraz że wiemy, które wartości obliczane w bloku bazowym są używane na zewnątrz tego bloku. W następnym rozdziale opisaliśmy techniki obliczania tych informacji. Jedną ze strategii globalnego przydziału rejestrów jest przeznaczenie pewnej usta lonej liczby rejestrów do przechowywania najbardziej aktywnych wartości z każdej pętli wewnętrznej. Wybrane wartości mogą być różne dla różnych pętli. Rejestry, które nie zostały przydzielone, mogą być używane do przechowywania wartości lokalnych dla jed nego bloku, tak jak w p. 9.6. Wadą takiej strategii jest to, że ustalona liczba rejestrów nie zawsze jest właściwą liczbą rejestrów potrzebnych do globalnego przydziału reje strów. M i m o to, metoda ta jest łatwa w implementacji i była używana w Fortranie H, optymalizującym kompilatorze Fortranu dla IBM-360 (Lowry i Medlock [1969]). W językach, takich j a k C i Bliss, programista może bezpośrednio wpływać na przy dział rejestrów, używając deklaracji zmiennych rejestrowych do wskazania, że pewne wartości wewnątrz procedury powinny być przechowywane w rejestrach. Rozważne ko rzystanie z deklaracji zmiennych rejestrowych może przyspieszyć wiele programów, ale programista nie powinien przydzielać rejestrów bez uprzedniego profilowania programu.
Liczniki użyć W prostszej metodzie określania korzyści uzyskiwanych przez przechowywanie zmiennej x w rejestrze podczas wykonywania pętli L wykorzystujemy fakt, że nasz model maszyny pozwala zaoszczędzić jedną jednostkę kosztu przy każdym odwołaniu do ;c, jeśli x jest w rejestrze. Jednakże, jeśli do generowania kodu dla bloku używamy podejścia z poprzed niego podrozdziału, to istnieje duże prawdopodobieństwo, że po obliczeniu wartości x w bloku zostanie ona w rejestrze, jeśli w tym bloku są następne użycia x. W związku z tym liczymy, że oszczędziliśmy jedną jednostkę dla każdego użycia x w pętli L, które nie jest poprzedzone przypisaniem d o x w tym samym bloku. Ponadto, oszczędzamy dwie jednostki, gdy możemy uniknąć zapisywania x w pamięci na końcu bloku. Czyli, jeśli x ma przydzielony rejestr, to oszczędzamy dwie jednostki w każdym bloku z L, w którym x jest żywa na końcu i w którym x ma przypisywaną wartość. Zauważmy, że jeśli zmienna x jest żywa przy wejściu do pętli, to musimy załadować x do odpowiedniego rejestru tuż przed wejściem do pętli L. Takie ładowanie kosztuje
dwie jednostki. Podobnie, dla każdego bloku wyjściowego B z pętli L, gdzie x jest żywa na wejściu do pewnego następnika B na zewnątrz L, musimy zapamiętać x, co kosztuje dwie jednostki. Zakładając jednak, że wykonywanych będzie wiele iteracji pętli, możemy pominąć te koszty, ponieważ występują one tylko raz dla każdego wejścia do pętli. Stąd, przybliżona formuła opisująca zysk otrzymywany przez przydzielenie zmiennej x rejestru w pętli L to: £
[użycia(x,
B)+2*źywe(x,
B))
(9.4)
bloki B z L
gdzie użycia(x, B) to liczba użyć x w B przed którąkolwiek kolejną definicją x\ żywe(x, B) jest równe 1, gdy x jest żywa przy wyjściu z B i ma przypisywaną war tość w B, w przeciwnym przypadku źywe(x, B) jest równe 0. Zauważmy, że wzór (9.4) jest przybliżony, bo nie wszystkie bloki w pętli są wykonywane z równą częstotliwością, oraz dlatego, że jest on oparty na założeniu, iż pętla jest iterowana „wiele" razy. Dla in nych maszyn trzeba opracować wzór analogiczny do (9.4), który może jednak wyglądać zupełnie inaczej. P r z y k ł a d 9.6. Weźmy bloki bazowe pętli wewnętrznej przedstawionej na rys. 9.13, gdzie pominięte zostały instrukcje skoków i skoków warunkowych. Załóżmy, że rejestry RO, R l i R2 są zarezerwowane do przechowywania wartości w całej pętli. Zmienne żywe na wejściu i przy wyjściu z każdego bloku są, dla wygody, przedstawione odpowiednio nad i pod każdym z bloków (rys. 9.13). Jest kilka cech zmiennych żywych, które omówi liśmy w rozdz. 10. Przykładowo, zauważmy, że zarówno e , jak i f są żywe przy końcu B j , ale spośród nich tylko zmienna e jest żywa na wejściu do B a tylko f przy wejściu do By W ogólności, żywe zmienne na końcu bloku to suma zbiorów zmiennych żywych na początku każdego z następników danego bloku. v
b, c, d, e, f żywe Rys. 9.13. Graf przepływu dla pętli wewnętrznej
z
Aby obliczyć (9.4) dla x = a, zauważmy, że zmienna a jest żywa przy wyjściu i ma tam przypisywaną wartość, ale nie jest żywa przy wyjściu z B B ani B . v
3
4
Stąd
£ 2*żywe(a, B) ~ 2. Ponadto, utycia(a., B ) = 0, bo a jest definiowane w B BzL przed jakimkolwiek użyciem. Mamy również użyc/a(a, B ) = wżyda(a, Z? ) — 1 oraz użycia(a, B ) — 0. Stąd £ wżyc/a(a, B) — 2. Wobec tego, wartością (9.4) dla x — a x
x
2
3
Ą
B z Z, jest 4. Czyli, umieszczając a w jednym z globalnych rejestrów, możemy oszczędzić czte ry jednostki kosztu. Wartości ze wzoru (9.4) dla b, c, d, e i f to, odpowiednio, 6, 3, 6, 4 i 4. Możemy więc wybrać a, b i d do rejestrów, odpowiednio, RO, Rl i R2. Używanie RO dla e lub f, a nie dla a to inne możliwe wybory, które wydają się być tak samo dobre. Na rysunku 9.14 przedstawiono kod wynikowy wygenerowany na podstawie rys. 9.13, przy założeniu, że do generacji kodu dla każdego z bloków jest stosowana stra tegia z p . 9.6. Nie pokazujemy kodu wygenerowanego dla pominiętych instrukcji skoków i skoków warunkowych, które kończą każdy z bloków z rys. 9.13, i — w związku z tym — kod ten nie jest przedstawiony jako pojedynczy łańcuch, jak wyglądałby w praktyce. Warto zauważyć, że jeśli nie stosowalibyśmy się ściśle do naszej strategii rezerwowania RO, Rl i R2, moglibyśmy użyć
SUB MOV
R2, RO RO, f
dla B , oszczędzając jedną jednostkę, b o a nie jest żywe na wyjściu z B . Analogiczną 2
2
oszczędność można uzyskać dla
•
B
y
MOV b,Rl MOV d,R2
-
x \ MOV ADD SUB MOV ADD MOV
R1,R0 c,R0 Rl,R2 R0,R3 f ,R3 R3,e
B
/ MOV R0,R3 SUB R2,R3 MOV R3,f
B
MOV R2,R1 ADD c,Rl
MOV ADD MOV SUB MOV
R2,R1 f ,R1 R0,R3 c,R3 R3,e
B
B
MOV Rl,b MOV R2,d
MOV Rl,b MOV R2,d Rys. 9.14. Fragment kodu ilustrujący globalny przydział rejestrów
Wyznaczanie rejestrów dla pętli zewnętrznych Po wyznaczeniu rejestrów i wygenerowaniu kodu dla pętli wewnętrznych możemy za stosować ten sam pomysł do coraz większych pętli. Jeśli pętla zewnętrzna L zawiera pętlę wewnętrzną L , to nazwy, które mają przydzielone rejestry w L , nie muszą mieć przydzielonych rejestrów w L — L . Jeśli jednak nazwa x ma przydzielony rejestr w pętli L ale nie L , to musimy zapamiętać x na wejściu do L i ładować x przy opuszcza niu L i wchodzeniu do bloku z L —L . Analogicznie, jeśli zdecydujemy się przydzielić zmiennej x rejestr w L , ale nie w L , to musimy ładować x przy wejściu do L i zapamię tywać x przy wyjściu z L . Jako ćwiczenie pozostawiamy Czytelnikowi wyprowadzenie kryteriów wyboru nazw, którym należy przydzielić rejestry w pętli zewnętrznej L, pod warunkiem że wybór ten został już dokonany dla wszystkich pętli zagnieżdżonych w L. x
2
2
x
p
2
2
2
2
{
2
2
x
2
2
Przydział rejestrów przez kolorowanie grafów Jeżeli potrzebujemy rejestru do wykonania obliczeń, a wszystkie rejestry są już używane, to zawartość jednego z tych rejestrów musi zostać zapisana (przepisana do) w odpowied niej lokacji w pamięci, aby zwolnić rejestr. Kolorowanie grafów jest prostą, systematyczną metodą przydziału rejestrów i zarządzania przepisywaniem rejestrów. Metoda ta składa się z dwóch przebiegów. W pierwszym są wybierane rozkazy maszyny docelowej tak, jakby istniało nieskończenie wiele rejestrów symbolicznych; wówczas nazwy używane w kodzie pośrednim stają się nazwami rejestrów, a instrukcje trójadresowe — instrukcjami języka maszynowego. Jeśli dostęp do zmiennych wyma ga rozkazów korzystających ze wskaźnika stosu, wskaźników tablicy display, rejestrów bazowych albo innych wartości, które pomagają w dostępie, to możemy przyjąć, że war tości te są przechowywane w rejestrach zarezerwowanych dla odpowiednich celów. Jeśli dostęp jest bardziej skomplikowany, to należy go implementować, używając kilku roz kazów maszynowych, co może powodować potrzebę utworzenia tymczasowego rejestru symbolicznego (lub kilku takich rejestrów). Po wyborze rozkazów, w drugim przebiegu, są przypisywane rejestry fizyczne do rejestrów symbolicznych. Celem jest znalezienie takiego przypisania, które minimalizuje koszt przepisań. W drugim przebiegu dla każdej procedury jest budowany graf kolizji rejestrów, w którym wierzchołkami są rejestry symboliczne, a krawędź łączy dwa wierzchołki, gdy jeden z nich jest żywy w punkcie, w którym drugi jest definiowany. Przykładowo, graf kolizji dla rejestrów z rys. 9.13 miałby wierzchołki dla nazw a i d. W bloku B zmienna a jest żywa w drugiej instrukcji, która definiuje d; wobec tego, w grafie istniałaby krawędź między wierzchołkami dla a i d. Próbujemy pokolorować graf kolizji rejestrów, używając k kolorów, gdzie k jest liczbą dostępnych rejestrów. (O grafie mówimy, że jest pokolorowany, jeśli każdemu wierzchołkowi przydzielimy kolor w taki sposób, że nie ma dwóch sąsiednich wierzchoł ków o tym samym kolorze). Kolor reprezentuje rejestr, a kolorowanie zapewnia, że dwa rejestry symboliczne, które mogą na siebie oddziaływać, nie będą miały przydzielonego tego samego rejestru fizycznego. Chociaż problem sprawdzania, czy graf jest fc-kolorowalny, jest w ogólności NP-zupełny, to poniższa technika heurystyczna może być stosowana w praktyce do szyb{
kiego kolorowania. Przypuśćmy, że wierzchołek n w grafie G ma mniej niż k sąsiadów (wierzchołków połączonych z n krawędzią). Usuńmy n i jego krawędzie z G, otrzymując graf G'. ^-Kolorowanie G' może zostać rozszerzone do ^-kolorowania G przez przypisanie n koloru nie przypisanego żadnemu z jego sąsiadów. Powtarzając usuwanie z grafu kolizji rejestrów wierzchołków mających mniej niż k krawędzi, otrzymamy albo graf pusty — co oznacza, że możemy otrzymać A>kolorowanie pierwotnego grafu, kolorując wierzchołki w porządku odwrotnym do porządku usuwania, albo otrzymamy graf, w którym każdy wierzchołek ma co najmniej k sąsiadów. W tym przypadku ^-kolorowanie nie jest możliwe. W tej chwili wierzchołek jest przepisywany przez wprowadzenie kodu zapisującego i ponownie ładującego zawartość rejestru. Na stępnie graf kolizji jest odpowiednio modyfikowany i wznawiany jest proces kolorowania. Chaitin [1982] oraz Chaitin i in. [1981] opisali różne heurystyki wyboru wierzchołka do przepisania. Ogólną zasadą jest unikanie wprowadzania kodu przepisującego do pętli wewnętrznych.
9.8
Reprezentacja bloków bazowych przy użyciu dagów
Skierowane grafy acykliczne (od angielskiego skrótu nazywane dagami) są wygodną strukturą danych do implementacji przekształceń na blokach bazowych. Dag pokazuje, jak wartość obliczana przez każdą z instrukcji w bloku bazowym jest używana przez następne instrukcje w bloku. Budowa daga z instrukcji trójadresowych jest dobrą meto dą wyznaczania wspólnych podwyrażeń (wyrażeń obliczanych więcej niż raz) w bloku, sprawdzania, które nazwy są w bloku używane, ale obliczane poza nim, oraz badania, które z instrukcji w bloku obliczają wartości, które mogą być używane poza tym blokiem. Dagiem dla bloku bazowego (lub po prostu dagiem) nazywamy skierowany graf acykliczny z następującymi etykietami wierzchołków: 1.
2. 3.
Liście są etykietowane unikalnymi identyfikatorami, które są nazwami zmiennych albo stałych. Na podstawie operatora zastosowanego do nazwy wnioskujemy, czy potrzebna jest jej /- czy r-wartość; większość liści reprezentuje r-wartości. Liście reprezentują wartości początkowe nazw i indeksujemy j e liczbą 0, aby nie mylić ich z etykietami oznaczającymi „aktualne" wartości nazw, jak w 3. poniżej. Wierzchołki wewnętrzne są etykietowane symbolem operatora. Wierzchołki mogą również mieć etykietę będącą sekwencją identyfikatorów. Inten cją przydzielania takiej etykiety jest to, że wierzchołki wewnętrzne reprezentują obliczone wartości, a identyfikatory etykietujące ten wierzchołek muszą mieć taką wartość.
Ważne jest, by nie mylić dagów z grafami przepływu. Każdy wierzchołek grafu przepływu może być reprezentowany przez dag, bo każdy wierzchołek grafu przepływu odpowiada blokowi bazowemu. P r z y k ł a d 9.7. Na rysunku 9.15 widzimy kod trójadresowy odpowiadający blokowi B z rys. 9.9. Dla wygody użyliśmy numerów instrukcji, poczynając od (1). Dag odpo wiadający temu blokowi jest przedstawiony na rys. 9.16. Znaczenie dagów opisaliśmy 2
po podaniu algorytmu ich konstrukcji. Na razie zauważmy tylko, że każdy wierzchołek w dagu reprezentuje formułę, korzystając z liści, czyli wartości zmiennych i stałych po wejściu do bloku. Przykładowo, wierzchołek oznaczony t na rys. 9.16 reprezentuje formułę 4
b
[4 * i ]
czyli wartość słowa, którego adres jest 4*i bajtów przesunięty od adresu b , co jest zamierzoną wartością t .
•
4
(1) (2) (3) (4) (5) (6) (7) (8) (9) (10)
ti
= 4 * i
a [ tj ] 4 * i 3 t = b [ t ] t t * t t = ilocz + t ilocz i + 1 i : if i <= 20 goto (1) Ł
2
fc
4
5
6
3
2
4
5
Rys. 9.15. Kod trójadresowy dla bloku B
2
Budowa
daga
Zbudowanie daga dla bloku bazowego wymaga przetworzenia po kolei wszystkich in strukcji w bloku. Gdy widzimy instrukcję o postaci x : = y + z , szukamy wierzchołków reprezentujących „aktualne" wartości y i z . Mogą to być liście albo wierzchołki we wnętrzne — wtedy gdy y i/lub z były obliczone we wcześniejszych instrukcjach bloku. Następnie tworzymy wierzchołek o etykiecie + i dwóch dzieciach: lewe jest wierzchoł kiem dla y, prawe dla z. Dodajemy do stworzonego wierzchołka etykietę x. Jeżeli jednak w dagu jest już wierzchołek oznaczający wartość taką samą jak y + z , nie dodajemy do daga nowego wierzchołka, lecz dodajemy etykietę x do istniejącego wierzchołka. Trzeba wspomnieć o dwóch szczegółach. Po pierwsze, jeśli x (ale nie x ) był wcze śniej etykietą jakiegoś innego wierzchołka, to usuwamy g o z tej etykiety, b o „aktualną" wartością x jest właśnie utworzony wierzchołek. Po drugie, dla przypisań, takich jak x : = y , nie tworzymy nowego wierzchołka. Zamiast tego, dodajemy etykietę x do listy nazw w wierzchołku oznaczającym „aktualną" wartość y . 0
Poniżej podajemy algorytm tworzenia daga z bloku. Jest on prawie identyczny jak al gorytm 5.1, z wyjątkiem dodatkowej listy identyfikatorów, które tu dołączamy d o każdego wierzchołka. Czytelnik powinien wiedzieć, że ten algorytm może nie działać poprawnie, gdy korzysta się z przypisań do tablic, pośrednich przypisań przez wskaźniki, albo gdy do jednego miejsca w pamięci można się odwoływać przez dwie lub więcej nazwy z po wodu instrukcji EQUIVALENCE lub odpowiedniości między formalnymi i aktualnymi argumentami w wywołaniu procedury. N a końcu podrozdziału opisaliśmy modyfikacje konieczne do obsłużenia tych sytuacji.
A l g o r y t m 9.2.
Budowa daga.
Wejście. Blok bazowy. Wyjście. Dag dla bloku bazowego, zawierający następujące informacje: 1.
Etykietę dla każdego wierzchołka. Dla liści etykieta jest identyfikatorem (być może stałej), a dla wierzchołków wewnętrznych — symbolem operatora. Dla każdego wierzchołka — lista (być może pusta) dołączonych identyfikatorów (tu nie mogą występować stałe).
2.
Metoda. Zakładamy, że mamy odpowiednie struktury danych do tworzenia wierzchoł ków z jednym lub dwojgiem dzieci, pozwalające — w drugim przypadku — rozróżnić dziecko „lewe" od „prawego". W strukturze tej jest też miejsce na etykietę dla każ dego wierzchołka i możliwość tworzenia listy identyfikatorów dołączonych do każdego wierzchołka. Musimy ponadto utrzymywać zbiór wszystkich identyfikatorów (włączając stałe), z którymi jest związany jakiś wierzchołek. Wierzchołek taki jest albo liściem etykie towanym odpowiednim identyfikatorem, albo wierzchołkiem wewnętrznym, z rozpatry wanym identyfikatorem na swojej liście identyfikatorów. Zakładamy istnienie funkcji wierzchołek(identyfikator), która podczas budowy daga zwraca ostatnio utworzony wierz chołek związany z identyfikatorem. Intuicyjnie, wierzchołek(identyfikator) jest wierzchoł kiem w dagu, który reprezentuje wartość identyfikatora w danej chwili procesu budowy daga. W praktyce, wpis w rekordzie tablicy symboli dla identyfikatora może przechowy wać wartość wierzchołek(identyfikator). Proces budowy daga to powtarzanie poniższych kroków ( 1 . do 3.) po kolei dla każdej instrukcji z bloku. Zakładamy, że początkowo nie ma wierzchołków, a funkcja wierzchołek jest niezdefiniowana dla wszystkich argumentów. Przyjmijmy, że „aktualna" instrukcja trójadresowa to (i) x : = y op z albo (ii) x : = op y, albo (iii) x : =y . Będziemy się do tych możliwości odwoływali jako do przypadków (i), (ii) i (iii). Operator relacyjny, taki jak i f i < = 2 0 g o t o , jest dla nas przypadkiem (i), z nieokreślonym x. {
1.
2.
3.
1
Jeśli wartość wierzchołek(y) jest niezdefiniowana, stwórz liść o etykiecie y i niech dalej wierzchołek(y) będzie tym wierzchołkiem. W przypadku (i), gdy wierzchołek(z) jest niezdefiniowana, stwórz wierzchołek o etykiecie z i niech dalej wierzchołek ten będzie wartością wierzchołek(z). Dla przypadku (i) sprawdź, czy istnieje wierzchołek o etykiecie op, którego lewym dzieckiem jest wierzchołek(y), a prawym — wierzchołek(z). (Sprawdzamy to, aby wykryć wspólne podwyrażenia). Jeśli nie, stwórz taki wierzchołek. Tak czy ina czej, niech n będzie znalezionym albo stworzonym wierzchołkiem. Dla przypadku (ii) sprawdź, czy istnieje wierzchołek z etykietą op, którego jedynym dzieckiem jest wierzchołek(y). Jeśli nie, stwórz taki wierzchołek i niech n będzie wierzchoł kiem znalezionym lub stworzonym. W przypadku (iii) niech n będzie wartością wierzchołek(y). Usuń x z listy identyfikatorów dołączonych do wierzchołek(x). Dodaj x do listy identyfikatorów związanej z wierzchołkiem n znalezionym w 2. i nadaj wierzchołek(x) wartość n. •
Zakładamy, że operatory mają co najwyżej dwa argumenty. Uogólnienie dla trzech lub więcej argumentów jest oczywiste.
P r z y k ł a d 9.8. Wróćmy do bloku z rys. 9.15 i zobaczmy, jak jest budowany odpowiada jący mu dag z rys. 9.16. Pierwszą instrukcją jest t : =4*i. W kroku 1. musimy stworzyć liście etykietowane 4 i i . (Indeksu 0 używamy, aby ułatwić odróżnianie na obrazkach etykiet od dołączonych identyfikatorów, ale indeks nie jest częścią etykiety). W kroku 2. tworzymy wierzchołek z etykietą *, a w kroku 3. dołączamy do niego identyfikator t . Na rysunku 9.17(a) przedstawiono taki etap budowy daga. x
0
x
Rys. 9.16. Dag dla bloku z rys. 9.15
(a)
(b) Rys. 9.17. Etapy procesu budowy daga Dla drugiej instrukcji, t : = a [t, ] , tworzymy nowy wierzchołek o etykiecie a oraz znajdujemy wcześniej stworzony wierzchołek(t ). Tworzymy następnie nowy wierzcho łek o etykiecie [ ] , d o którego jako dzieci dołączamy wierzchołki dla a i t . Zajmując się instrukcją (3), t : = 4 * i , sprawdzamy, że wierzchołek^) oraz wierzchołek(±) j u ż istnieją. Ponieważ operatorem jest *, więc nie tworzymy nowego wierzchołka dla instrukcji (3), lecz, zamiast tego, dodajemy t do listy identyfikatorów dla wierzchołka tj. Dag wynikowy jest pokazany na rys. 9.17(b). Metoda numerowania wartości z p. 5.2 może być użyta do szybkiego stwierdzenia istnienia wierzchołka dla 2
x
{
3
3
4*i.
Proponujemy, żeby Czytelnik dokończył konstrukcję daga. Opisaliśmy tylko kro ki wykonywane dla instrukcji (9), i:=t . Przed instrukcją (9) wierzchołek(i) to liść z etykietą i . Instrukcja (9) jest instancją przypadku (iii); wobec tego wyszukujemy wierzchołek(t ), dodajemy i do jego listy identyfikatorów i nadajemy wierzchołek(i) wartość równą wierzchołek(t~j). Jest to jedna z zaledwie dwóch instrukcji — tą drugą jest instrukcja (7) — w której zmieniana jest wartość funkcji wierzchołek dla identyfi katora. Właśnie ta zmiana powoduje, że nowy wierzchołek dla i jest lewym dzieckiem wierzchołka dla operatora <= budowanego dla instrukcji (10). • 7
0
7
Zastosowanie dagów Uruchamiając algorytm 9.2, możemy uzyskać kilka przydatnych informacji. Po pierwsze, możemy automatycznie wykrywać wspólne podwyrażenia. Po drugie, możemy sprawdzić, których identyfikatorów wartości są używane w bloku; są to dokładnie te identyfikatory, dla których w jakiejś chwili tworzymy liść w kroku (1). Po trzecie, możemy określić, które instrukcje obliczają wartości mogące być używane poza blokiem. Są to instruk cje 5, których wierzchołek n — tworzony lub znajdowany w kroku (2) — spełnia na końcu budowy daga warunek wierzchołek(x) = n, gdzie x to identyfikator, któremu war tość przypisuje instrukcja S. (Równoważnie, x ciągle jest identyfikatorem na liście dla wierzchołka n). P r z y k ł a d 9.9. W przykładzie 9.8 wszystkie instrukcje spełniały powyższy warunek, gdyż w obu przypadkach redefiniowania wierzchołka — dla i l o c z i i — poprzednie wartości wierzchołka były liśćmi. Dlatego, wartości wszystkich wierzchołków wewnętrz nych mogą być używane na zewnątrz bloku. Przypuśćmy, że przed instrukcją (9) doda liśmy nową instrukcję s, która nadawała wartość zmiennej i. Obsługując instrukcję s, dodalibyśmy wierzchołek m i nadali wierzchołek(i) wartość m. Potem jednak, obsługu jąc instrukcję (9), przedefilowalibyśmy wierzchołek(i). Wobec tego, wartość obliczona w instrukcji s nie mogłaby być używana poza blokiem. • Kolejnym ważnym zastosowaniem daga jest odtwarzanie uproszczonej listy czwórek, wykorzystującej wspólne podwyrażenia oraz nie wykonującej przypisań o postaci x : = y , chyba że jest to niezbędne. Czyli — za każdym razem, gdy wierzchołek ma więcej niż jeden identyfikator na swojej liście — sprawdzamy, czy i które z tych identyfikatorów są używane poza blokiem. Jak wspomnieliśmy, wyszukiwanie zmiennych żywych przy końcu bloku wymaga analizy przepływu danych nazywanej „analizą żywych zmiennych" — opisanej w rozdz. 10. Jednakże w wielu przypadkach możemy założyć, że żadna z nazw tymczasowych, takich jak t t , ..., t na rys. 9.15, nie jest potrzebna na zewnątrz bloku. (Ale trzeba uważać na to, jak są tłumaczone wyrażenia logiczne; jedno wyrażenie może zostać rozłożone na wiele bloków bazowych). 1(
2
7
Wierzchołki daga możemy, w ogólności, obliczać w dowolnej kolejności, która jest jego topologicznym uporządkowaniem. W porządku topologicznym wierzchołek nie jest wyliczany przed wyliczeniem wszystkich jego dzieci, które są wierzchołkami wewnętrz nymi. Gdy wyliczamy wierzchołek, przypisujemy jego wartość jednemu z dołączonych identyfikatorów, x, preferując taki, którego wartość jest potrzebna poza blokiem. Nie możemy jednak wybrać x, jeśli istnieje inny wierzchołek m, którego wartość również
była przechowywana przez x, taki że m został wyliczony i ciągle jest „żywy". Mówimy, że m jest żywy, jeśli jego wartość jest potrzebna poza blokiem lub jeśli m ma rodzica, który nie został jeszcze wyliczony. Jeśli do wierzchołka n są dodatkowo dołączone identyfikatory y y , y^, to wykonujemy przypisania, używając instrukcji y j : = x , y : = x , y :=x. Jeśli n nie ma żadnych dołączonych identyfikatorów (może się tak zdarzyć, jeśli, n a przykład, n zostało stworzone przez przypisanie wartości zmiennej x, ale później x przypisano nową wartość), to tworzymy nową nazwę tymczasową, która będzie przechowywała wartość n. Czytelnik powinien wiedzieć, że gdy używamy przypisań do wskaźników lub tablic, nie każde uporządkowanie topologiczne jest dopuszczalne; tym problemem zajmiemy się wkrótce. p
2
2
k
P r z y k ł a d 9.10. Zbudujmy blok bazowy z daga z rys. 9.16, porządkując wierzchołki w kolejności ich tworzenia: t t , t , t , t , t (1). Zauważmy, że instrukcje (3) i (7) nie spowodowały stworzenia nowych wierzchołków, ale dodały etykiety t i ilocz do list identyfikatorów dla wierzchołków, odpowiednio, t i t . Zakładamy, że żadna ze zmiennych tymczasowych t- nie jest potrzebna na zewnątrz bloku. v
2
4
5
6
7
3
{
6
(
Zaczynamy od wierzchołka reprezentującego 4*i. Wierzchołek ten m a dołączone dwa identyfikatory, tj i t . D o przechowywania wartości 4*i wybierzmy tj i wtedy pierwszą zrekonstruowaną instrukcją będzie 3
t :=4*i Ł
dokładnie taka, j a k w pierwotnym bloku bazowym. Drugi rozpatrywany wierzchołek ma etykietę t . Instrukcja tworzona z tego wierzchołka to 2
t :=a[ tj ] 2
również taka, jak wcześniej. Kolejny obsługiwany wierzchołek m a etykietę t i generuje instrukcję 4
t :=b[ t
x
4
]
Ta instrukcja używa t jako argumentu, a nie t , tak j a k w pierwotnym bloku, b o wybraliśmy nazwę t do reprezentowania wartości 4*i. Weźmy teraz wierzchołek z etykietą t i stwórzmy instrukcję {
3
{
5
t :=t *t 5
2
4
Dla wierzchołka z etykietami t , ilocz wybieramy ilocz d o przechowywania war tości, bo ten identyfikator, a nie t będzie (zapewne) potrzebny na zewnątrz bloku. Tak jak t , zmienna tymczasowa t znika. Kolejną wygenerowaną instrukcją będzie 6
6
3
ilocz:=ilocz+t
6
5
Podobnie, wybieramy i, a nie t do przechowywania wartości i + 1. Ostatnie dwie two rzone instrukcje to 7
i:=i + l if i<=20 goto (1)
Zauważmy, że dziesięć instrukcji z rys. 9.15 zostało zastąpionych siedmioma na skutek wykorzystania wspólnych podwyrażeń wykrytych w procesie budowy daga oraz przez usunięcie niepotrzebnych przypisań. • Tablice, wskaźniki i wywołania procedur Rozpatrzmy blok bazowy x:=a[i] a [ j ] :=y z:=a [i]
(9.5)
Jeśli skorzystamy z algorytmu 9.2 do budowy daga dla (9.5), a [ i ] zostanie wspólnym podwyrażeniem i „zoptymalizowaną" wersją bloku będzie x:=a[i] z : =x
(9.6)
a[j]:=y Jednakże, w przypadku gdy i = j i y / a [ i ] , bloki (9.5) i (9.6) obliczą różne wartości. Problemem jest to, że gdy zapisujemy jakąś wartość w tablicy a, to możemy zmieniać r-wartość wyrażenia a [ i ] , mimo że ani a, ani i nie są zmieniane. Musimy więc, gdy obsługujemy przypisanie do tablicy a, zabić wszystkie wierzchołki etykietowane [ ] , których lewym argumentem jest a plus lub minus stała (być może równa z e r u ) . Oznacza to, że nie pozwalamy na dodawanie nowych identyfikatorów do tych wierzchołków i w ten sposób zabezpieczamy się przed rozpoznawaniem fałszywych wspólnych podwyrażeń. Musimy więc w każdym wierzchołku przechowywać dodatkowy bit informacji o tym, czy wierzchołek został zabity, czy nie. Ponadto, dla każdej tablicy a wygodnie jest mieć listę wszystkich wierzchołków aktualnie nie zabitych, które jednak muszą zostać zabite w przypadku przypisania zmieniającego wartość elementu z a. Podobny problem występuje, gdy mamy przypisanie, takie jak * p : =w, gdzie p jest wskaźnikiem. Jeśli nie wiemy co może wskazywać p , to każdy wierzchołek daga musi być zabity (w znaczeniu podanym powyżej). Jeśli wierzchołek n z etykietą a jest zabijany, a dalej występuje przypisanie d o a, to musimy utworzyć nowy liść dla a i używać go zamiast n. Dalej rozpatrujemy ograniczenia kolejności obliczeń narzucane przez zabijanie wierzchołków. W rozdziale 10 opisaliśmy metody pozwalające wykryć, że p może wskazywać tylko pewien podzbiór identyfikatorów. Jeśli p może wskazywać tylko r albo s, to mu simy zabić tylko wierzchołki wierzchołek(r) i wierzchołek(s). Możliwe jest również, że wykryjemy, że w bloku (9.5) sytuacja i = j jest niemożliwa, a wtedy wierzchołek dla a [ i ] nie musi być zabijany przez a [ j ] : = y . Próby dokonywania takich odkryć nie są jednak warte powodowanych przez nie kłopotów. Wywołanie procedury zabija wszystkie wierzchołki, bo gdy nie mamy informacji o wywoływanej procedurze, to musimy założyć, że efektem ubocznym może być zmiana wartości każdej ze zmiennych. W rozdziale 10 opisaliśmy, jak sprawdzić, że pewne 1
1
Zauważmy, że argumentem [ ] wskazującym nazwę tablicy może być samo a albo wyrażenie, takie jak a - 4 . W drugim przypadku wierzchołek a byłby wnukiem, a nie dzieckiem wierzchołka [ ] .
identyfikatory nie zostaną zmienione przez procedurę, a wtedy wierzchołki związane z tymi identyfikatorami nie muszą być zabijane. Jeśli chcemy odtworzyć blok bazowy z daga i nie chcemy używać porządku, w któ rym były budowane wierzchołki daga, to musimy wskazać, że pewne wierzchołki w dagu, które zdają się być niezależne, muszą być obsługiwane w określonym porządku. Przy kładowo, w (9.5) instrukcja z : = a [ i ] musi występować po a [ j ] : = y , co z kolei musi być po x : = a [ i ] . Dodajmy do daga pewne krawędzie n - » m, nie wskazujące, że m jest argumentem n, ale że wyliczenie n musi znajdować się po wyliczeniu m w dowolnych obliczeniach związanych z dagiem. Reguły, których trzeba przestrzegać, to: 1. 2. 3. 4.
Każde odczytanie bądź zapisanie elementu w tablicy a musi następować po po przednim przypisaniu do elementu tablicy, jeśli takie było. Każde przypisanie do elementu tablicy a musi następować po wszystkich poprzed nich odczytach elementów a. Każde użycie identyfikatora musi następować po poprzednim wywołaniu procedury bądź pośrednim przypisaniu przez wskaźnik, jeśli takie były. Każde wywołanie procedury lub pośrednie przypisanie przez wskaźnik musi nastę pować po wszystkich wcześniejszych odczytach dowolnych identyfikatorów.
Czyli, zmieniając kolejność instrukcji, nie możemy zmienić kolejności dostępów do ta blicy a i żadna instrukcja nie może przejść przez wywołanie procedury ani przypisanie przez wskaźnik.
9.9
Optymalizacja przez szparkę
Strategie generacji kodu dla kolejnych instrukcji często produkują kod wynikowy za wierający niepotrzebne rozkazy i nieoptymalne konstrukcje. Jakość takiego kodu może zostać poprawiona przez zastosowanie przekształceń „optymalizujących" program. Słowo „optymalizujących" jest nieco zwodnicze, gdyż nie ma żadnej gwarancji, że otrzymany kod jest optymalny względem jakiejkolwiek miary matematycznej. Pomimo to, wiele prostych przekształceń może znacząco poprawić czas działania bądź wymagania pamię ciowe programu wynikowego, więc ważne jest by wiedzieć, jakie rodzaje przekształceń są przydatne w praktyce. Prostą i efektywną techniką lokalnego poprawiania kodu wynikowego jest optymali zacja przez szparkę, która próbuje poprawić wydajność programu wynikowego, badając krótkie sekwencje instrukcji wynikowych (nazywane szparką) i zastępując te instrukcje krótszymi lub bardziej wydajnymi sekwencjami, jeśli jest to możliwe. Chociaż opisuje my optymalizację przez szparkę jako technikę poprawy jakości kodu wynikowego, można ją również stosować bezpośrednio po generacji kodu pośredniego do poprawienia jego jakości. Szparka to małe okienko przesuwające się po programie wynikowym. Kod w szparce nie musi być ciągły, choć pewne implementacje mają takie wymaganie. Charakterystyczne dla optymalizacji przez szparkę jest to, że każda poprawka może tworzyć okazję do wy konywania dalszych poprawek. Zwykle, aby otrzymać maksymalne korzyści, konieczne jest wykonywanie wielu przejść przez kod programu. W tym podrozdziale przedstawimy
następujące przykłady przekształceń programów, które są charakterystyczne dla optyma lizacji przez szparkę: • • • •
usuwanie niepotrzebnych rozkazów, optymalizacje przepływu sterowania, uproszczenia algebraiczne, wykorzystywanie idiomów maszyny.
Odczyty nadmiarowe i zapisy Jeśli mamy sekwencję (1) (2)
MOV RO, a K
MOV a, RO
}
to możemy skasować rozkaz (2), bo za każdym razem, gdy wykonywany jest (2), (1) zapewnia, że wartość a jest j u ż w rejestrze RO. Zauważmy, że jeśli rozkaz (2) miałby etykietę , nie bylibyśmy pewni, że (1) zawsze jest wykonywany bezpośrednio przed (2) i nie moglibyśmy usunąć (2). Inaczej mówiąc, żeby takie przekształcenie było bezpieczne, rozkazy (1) i (2) muszą być w tym samym bloku bazowym. Chociaż kod, taki jak (9.7), nie zostałby wygenerowany przez algorytm proponowany w p. 9.6, to jednak bardziej naiwny algorytm, taki jak wspomniany na początku p . 9.1, mógłby spowodować jego powstanie. 1
Kod nieosiągalny Innym zastosowaniem optymalizacji przez szparkę jest usuwanie nieosiągalnych rozka zów. Rozkaz bez etykiety następujący bezpośrednio p o skoku bezwarunkowym może być usunięty. Operację taką należy powtarzać, aby usunąć sekwencję rozkazów. Przykłado wo, dla potrzeb testowania, duży program może mieć w sobie pewne fragmenty, które są wykonywane tylko wtedy, gdy zmienna d e b u g ma wartość 1. W języku C kod źródłowy mógłby wyglądać następująco: #define if
debug
0
( debug ) { wypisz informacje uruchomieniowe
(9.8)
L2:
1
Pewną zaletą generowania kodu w asemblerze jest to, że są tam etykiety, co umożliwia optymalizacje, takie jak właśnie opisana. Gdy generowany jest kod maszynowy i pożądana jest optymalizacja przez szparkę, możemy używać znacznika do wyróżniania rozkazów mających etykiety.
Pewną oczywistą optymalizacją przez szparkę jest eliminacja skoków ponad skokami. Czyli, niezależnie od wartości zmiennej debug, (9.8) może zostać zastąpione instrukcją
if debug / 1 goto L2 wypisz informacje uruchomieniowe
(9.9)
L2 : 1
debug m a na początku programu nadawaną wartość O , dlatego propagacja stałych pozwala zastąpić (9.9) instrukcją
if 0 / 1 goto L2 wypisz informacje uruchomieniowe
(9.10)
L2 : Ponieważ wynikiem obliczenia argumentu pierwszej instrukcji z (9.10) jest stała true, więc instrukcję tę można zastąpić goto L2. Wówczas jest jasne, że wszystkie instrukcje służące do wypisywania informacji uruchomieniowych są nieosiągalne i że można j e po kolei eliminować. Optymalizacje przepływu sterowania Algorytmy generacji kodu pośredniego (patrz rozdz. 8) często produkują skoki do skoków, skoki do skoków warunkowych lub skoki warunkowe do skoków. Takie niepotrzebne skoki mogą zostać usunięte z kodu pośredniego albo z kodu wynikowego przy użyciu optymalizacji przez szparkę opisanych poniżej. Skoki
goto LI LI: goto L2 mogą zostać zastąpione
goto L2 LI: goto L2 Jeśli po tym nie m a j u ż skoków do LI , to możliwa staje się eliminacja instrukcji LI: goto L2, pod warunkiem, że jest ona poprzedzona skokiem bezwarunkowym. Analogicznie, sekwencję 2
if a < b goto LI LI: goto L2 można zastąpić
Aby wiedzieć, że d e b u g ma wartość 0, musimy przeprowadzić globalną analizę „definicji osiągających", opisaną w rozdz. 10. Jeśli próbujemy takiej optymalizacji przez szparkę, to możemy zliczać skoki do każdej etykiety we wpisie w tablicy symboli dla tej etykiety; przeszukiwanie kodu nie jest konieczne.
if
a < b goto L2
LI: goto L2 Na koniec załóżmy, że jest tylko jeden skok do LI i że LI jest poprzedzane przez skok bezwarunkowy. Wówczas sekwencję rozkazów
goto LI LI: i f
a < b goto L2
9
11
C - )
L3: można zastąpić sekwencją
i f a < b goto L2 goto L3
( 9 1 2 )
L3: Chociaż liczba rozkazów w (9.11) i (9.12) jest taka sama, to czasem pomijamy skok bezwarunkowy w (9.12), a w (9.11) jest on wykonywany zawsze. Czyli, pod względem czasu wykonania, (9.12) jest lepsze niż (9.11).
Uproszczenia algebraiczne Istnieje bardzo wiele algebraicznych uproszczeń, których można próbować w trakcie optymalizacji przez szparkę. Jednak, niewiele tożsamości algebraicznych występuje na tyle często, aby warto było rozważać ich implementację. Na przykład, instrukcje, takie jak x
:= x + 0
x
:= x * 1
czy
są często tworzone przez proste algorytmy generacji kodu pośredniego i mogą zostać łatwo wyeliminowane przy użyciu optymalizacji przez szparkę.
Osłabienie mocy Osłabienie mocy zastępuje drogie operacje tańszymi, ale równoważnymi operacjami ma szyny docelowej. Pewne rozkazy maszynowe są znacząco tańsze niż inne i mogą być często używane jako szczególne przypadki droższych operatorów. Przykładowo, x jest zawsze tańsze w implementacji jako x * x niż jako wywołanie procedury. Stałopozycyjne mnożenie lub dzielenie przez potęgę dwójki jest tańsze w implementacji jako przesu nięcie. Zmiennopozycyjne dzielenie przez stałą można implementować (aproksymować) jako mnożenie przez stałą, co może być tańsze. 2
Korzystanie z idiomów maszyny Maszyna docelowa może mieć rozkazy sprzętowe wydajnie implementujące pewne spe cyficzne operacje. Wykrywanie sytuacji pozwalających na użycie takich rozkazów może znacząco zmniejszyć czas wykonania. Przykładowo, niektóre maszyny dysponują tryba mi adresowania z automatycznym zwiększaniem bądź zmniejszaniem. W trybach tych do argumentu jest dodawane lub jest od niego odejmowane 1 przed lub po wykorzystaniu jego wartości. Używanie takich trybów bardzo poprawia jakość kodu, gdy wartości od kłada się na stos bądź się j e z niego zdejmuje, tak jak przy przekazywaniu argumentów. Tryby takie mogą być również używane w instrukcjach typu i : = i + l .
9.10
Generowanie kodu z dagów
W tym podrozdziale pokazaliśmy, j a k generować kod dla bloków bazowych z j e g o re prezentacji w postaci dagów. Patrząc na dag, dużo łatwiej jest zauważyć, jak powinno się zmienić kolejność obliczeń, niż patrząc na liniowy ciąg instrukcji trójadresowych lub czwórek. W naszym opisie istotnym jest przypadek, w którym dag jest drzewem. Może my wtedy generować kod, optymalny pod względem długości programu czy najmniejszej możliwej liczby używanych zmiennych tymczasowych. Algorytm optymalnej generacji kodu z drzewa jest również przydatny, gdy kod pośredni jest drzewem wyprowadzenia. Zmiana kolejności Zastanówmy się, jaki wpływ na koszt otrzymywanego kodu wynikowego ma kolejność wykonywania obliczeń. Rozważmy poniższy blok bazowy, którego reprezentacja w po staci daga jest pokazana na rys. 9.18 (dag jest akurat drzewem).
tj :=a+b t :=c+d t :=e-t t :=t -t 2
3
4
2
1
3
Rys. 9.18. Dag dla bloku bazowego Kolejność ta w naturalny sposób wynika ze sterowanej składnią translacji wyrażenia ( a + b ) - ( e - ( c + d ) ) z użyciem algorytmu z p . 8.3.
Jeśli wygenerujemy kod dla instrukcji trójadresowych, korzystając z algorytmu z p. 9.6, to otrzymamy kod z rys. 9.19 (przyjmując, że dostępne są dwa rejestry, RO i Rl, i że na wyjściu żywa jest tylko zmienna t ). 4
MOV ADD MOV ADD MOV MOV SUB MOV SUB MOV
a, RO b, RO c, Rl d, Rl RO, t e, RO Rl, RO t], Rl RO, Rl Rl, t t
4
Rys. 9.19. Fragment kodu
Załóżmy teraz, że zmieniliśmy kolejność instrukcji tak, że obliczenie t bezpośrednio przed obliczeniem t , tak jak w
następuje
x
4
t :=c+d 3 ~t t :=a+b t :=t t 2
t
:= = e
2
t
4
r
3
Wówczas, używając algorytmu generacji kodu z p. 9.6, otrzymujemy sekwencję rozkazów z rys. 9.20. (Ponownie dostępne są tylko rejestry RO i Rl). Wykonując obliczenia w tej ko lejności, zaoszczędziliśmy dwa rozkazy, MOV RO, t (który zapamiętywał wartość RO w pamięci o adresie tj) i MOV tj, Rl (ponownie ładujący wartość tj do rejestru Rl). x
MOV ADD MOV SUB MOV ADD SUB MOV
c, RO d, RO e, Rl RO, Rl a, RO b, RO Rl, RO RO, t 4
Rys. 9.20. Poprawiony fragment kodu Heurystyczne p o r z ą d k o w a n i e dagów Powyższa zmiana kolejności poprawiła generowany kod dlatego, że obliczenie t znalazło się bezpośrednio po obliczeniu t , swojego lewego argumentu w drzewie. Jasne powinno być, że taki układ jest opłacalny. Lewy argument do obliczenia t musi być w rejestrze, aby wydajnie obliczyć t , a obliczanie t bezpośrednio przed t zapewnia, że tak właśnie będzie. 4
{
4
4
{
4
W wyborze porządku wierzchołków daga jesteśmy ograniczeni tylko tym, że mu simy zapewnić, aby nasz porządek zachowywał związki ustalone przez krawędzie daga. Przypomnijmy z podrozdziału 9.8, że te krawędzie mogą reprezentować związki między argumentem i operatorem albo ukryte ograniczenia związane z możliwymi interakcjami z wywołaniami procedur, przypisaniami do elementów tablic albo przypisaniami z uży ciem wskaźników. Proponujemy poniższy heurystyczny algorytm porządkowania, który próbuje, jeśli jest to tylko możliwe, umieścić obliczanie wierzchołka bezpośrednio za ob liczeniem jego lewego dziecka. Algorytm z rysunku 9.21 tworzy takie uporządkowanie od tyłu. (1) (2)
while istnieją niewypisane wierzchołki wewnętrzne do begin wybierz niewypisany wierzchołek n, którego wszyscy rodzice zostali wypisani; (3) wypisz n\ (4) while skrajnie lewe dziecko m wierzchołka n nie ma niewypisanych rodziców i nie jest liściem do /* ponieważ n zostało wypisane przed chwilą, więc m nie było jeszcze wypisane */ begin (5) wypisz m; (6) n\-m end end Rys. 9.21. Algorytm wypisujący wierzchołki P r z y k ł a d 9 , 1 1 . Algorytm z rysunku 9.21 zastosowany do drzewa z rys. 9.18 tworzy porządek, z którego jest generowany kod z rys. 9.20. Jako pełny przykład rozważmy dag z rys. 9.22. Początkowo jedynym wierzchołkiem bez niewypisanych rodziców jest 1, więc w wier szu (2) wykonujemy n = 1 i wypisujemy 1 w wierszu (3). Wtedy lewy argument 1, którym jest 2, ma wypisanych swoich rodziców, więc wypisujemy 2 i w wierszu (6) wykonujemy
Rys. 9.22. Dag
n = 2. Następnie w wierszu (4) okazuje się, że skrajne lewe dziecko 2, którym jest 6, ma niewypisanego rodzica, 5. Wobec tego w wierszu (2) wybieramy nowe /z, a jedynym kandydatem jest tam wierzchołek 3. Wypisujemy 3 i następnie schodzimy do jego lewych potomków, wypisując 4, 5 i 6. Z wierzchołków wewnętrznych zostaje tylko 8, więc go wypisujemy. Wynikową listą jest 1234568, czyli sugerowaną kolejnością obliczania jest 8654321. Porządek ten odpowiada następującej sekwencji instrukcji trójadresowych: t t t t
:=d+e :=a+b :=t -c :=t *t
8
ti==t *t
3
8
6
5
4
6
5
2
co da nam optymalny kod dla tego daga i naszej maszyny, niezależnie od liczby rejestrów, jeśli skorzystamy z algorytmu generacji kodu z p. 9.6. Trzeba zauważyć, że w tym przykładzie nasza heurystyka porządkowania nigdy nie miała żadnego wyboru w kroku (2), ale zwykle zdarza się, że są tam różne możliwości. •
Optymalny porządek dla drzew Okazuje się, że dla modelu maszyny z p. 9.2 możemy podać prosty algorytm znajdujący optymalny porządek, w którym należy wyliczać instrukcje w bloku bazowym, jeśli repre zentacja tego bloku w postaci daga jest drzewem. Optymalny porządek oznacza kolejność generującą najkrótszą sekwencję rozkazów ze wszystkich sekwencji rozkazów wylicza jących drzewo. Taki algorytm, zmodyfikowany w ten sposób, by brał pod uwagę pary rejestrów i inne dziwactwa maszyn docelowych, został użyty w kompilatorach języków Algol, Bliss i C. Algorytm jest dwuczęściowy. Pierwsza część etykietuje wszystkie wierzchołki drze wa, od spodu do góry, liczbami oznaczającymi najmniejszą liczbę rejestrów potrzebnych do wyliczenia drzewa bez zapisywania pośrednich wyników. Druga część algorytmu to przechodzenie drzewa w porządku narzuconym przez wyliczone etykiety wierzchołków. Podczas tego przechodzenia drzewa generowany jest kod wyjściowy. Jeżeli algorytm ma do wyboru jeden z argumentów operacji dwu argumentowej, intuicyjnie wylicza najpierw argument wymagający większej liczby rejestrów (trudniej szy argument). Jeśli oba argumenty wymagają tej samej liczby rejestrów, to kolejność wyliczania nie ma znaczenia.
Algorytm etykietowania Nazwy „liść lewostronny" używamy na oznaczenie wierzchołka, który jest liściem i skraj nym lewym potomkiem swojego rodzica. Wszystkie inne liście nazywamy „liśćmi pra wymi". Etykietowanie możemy wykonać, odwiedzając wierzchołki od dołu do góry tak, że nie odwiedzamy wierzchołka przed poetykietowaniem jego wszystkich dzieci. Kolejność,
w której są tworzone wierzchołki drzewa wyprowadzenia, jest odpowiednia, jeśli używa my drzewa wyprowadzenia jako kodu pośredniego i w takim wypadku etykiety mogą być obliczone za pomocą translacji sterowanej składnią. Na rysunku 9.23 przedstawiono al gorytm obliczania etykiety wierzchołka n. W ważnym specjalnym przypadku, gdy n jest wierzchołkiem z dwójką dzieci, które mają etykiety l i l , wzór z wiersza (6) redukuje się do postaci x
~>={r
x ( +
2
2 ,
.'"' S!:^
(1) if n jest liściem then
(2) (3) (4)
if n jest liściem lewostronnym swojego rodzica then etykieta(n) := 1 else etykieta(n) := 0 else begin /* n jest wierzchołkiem wewnętrznym */
(5)
niech n , n , ..., n będą dziećmi n uporządkowanymi według etykiet,
(6)
tak, że etykieta(n ) ^ etykieta(n ) ^ • • • ^ etykieta(n ); etykieta(n) := max (etykieta(n-) + /— 1)
x
2
k
l
2
k
end
Rys. 9.23. Wyznaczanie etykiet P r z y k ł a d 9.12. Rozpatrzmy drzewo z rysunku 9.18. Przejście wierzchołków w kolej ności postorder powoduje odwiedzanie wierzchołków w kolejności a, b , t e , c , d, t , t i t . Postorder jest zawsze właściwym porządkiem wykonywania obliczeń związanych z etykietami. Wierzchołek a dostaje etykietę 1, gdyż jest liściem lewostronnym. Wierz chołek b dostaje etykietę 0, bo jest liściem prawym. Wierzchołek t dostaje etykietę 1, bo etykiety jego dzieci nie są równe, a maksymalną etykietą jest 1. Na rysunku 9.24 pokazano końcowe poetykietowane drzewo. Wynika z niego, że do wyliczenia t są potrzebne dwa rejestry i, co więcej, dwa rejestry są potrzebne nawet do wyliczenia t . • 1
p
3
2
4
{
4
3
Rys. 9.24. Poetykietowane drzewo
Przechodzenie w porządku postorder najpierw odwiedza poddrzewa zakorzenione w dzieciach n n , ..., n wierzchołka n, a następnie wierzchołek n. Jest to kolejność, w której są tworzone wierzchołki drzewa wy prowadzenia podczas analizy wstępującej. v
2
k
Generowanie kodu z poetykietowanego drzewa Przedstawiamy teraz algorytm, którego wejściem jest poetykietowane drzewo 7 , a wyj ściem — sekwencja kodu maszynowego, który wylicza T w RO (RO może później być zapisany pod odpowiednim adresem w pamięci). Przyjmujemy, że operatory w T są tylko dwuargumentowe. Uogólnienie pozwalające używać operatorów z dowolną liczbą argumentów nie jest trudne i zostawiamy j e Czytelnikowi jako ćwiczenie. Algorytm korzysta z rekurencyjnej procedury genkod(n) do tworzenia kodu maszy nowego wyliczającego poddrzewo T o korzeniu n do rejestru. Procedura genkod używa stosu stosr do przydzielania rejestrów. Początkowo stosr zawiera wszystkie dostępne re jestry, o których zakładamy, że nazywają się RO, R l , . . . , R(r— 1) i są w tej kolejności. Wywołanie genkod może na stosie stosr znaleźć podzbiór tych rejestrów, być może w in nej kolejności. Gdy genkod wraca, pozostawia rejestry ze stosu stosr w tym samym porządku, w którym j e zastał. Kod wynikowy wylicza wartość drzewa T d o rejestru na wierzchołku stosr. Funkcja zamień(stosr) wymienia dwa rejestry z wierzchołka stosu stosr. Z funkcji tej korzystamy, aby upewnić się, że lewe dziecko i jego rodzic zostaną obliczeni do tego samego rejestru. Procedura genkod używa stosu stost do przydzielania lokacji tymczasowych w pa mięci. Zakładamy, że stost zawiera początkowo TO, T l , T 2 , . . . W praktyce, stost nie musi być implementowany jako lista, jeśli tylko będziemy pamiętali / takie, że Ti jest aktualnie na wierzchołku. Zawartość stost jest zawsze przyrostkiem TO, T l , . . . Instrukcja X := pop(stos) oznacza „zdejmij element ze stosu stos i przypisz zdjętą wartość d o X " . Odwrotnie, push(stos X) znaczy „wstaw X na stos"; top(stos) to wartość na wierzchołku stosu. Algorytm generacji kodu to wywołanie genkod dla korzenia 7\ gdzie genkod to procedura przedstawiona na rys. 9.25. Objaśnić ją można, badając każdy z pięciu przy padków. W przypadku 0 mamy poddrzewo o postaci y
nazwa
Czyli n jest liściem i lewostronnym dzieckiem swojego rodzica. Wobec tego generujemy tylko rozkaz ładujący. Dla przypadku 1 mamy poddrzewo o postaci
nazwa
dla którego generujemy kod wyliczający n do rejestru R = top(stosr) W przypadku 2 mamy poddrzewo o postaci x
i rozkaz op
nazwa,R
procedurę genkod(n)\ begin /* przypadek 0 */ if n jest liściem reprezentującym argument nazwa and n jest lewostronnym dzieckiem swojego rodzica then print ' M O V || nazwa || ' , ' || top(stosr) else if n jest wewnętrznym wierzchołkiem z operatorem op, lewym dzieckiem n i prawym dzieckiem n then /* przypadek 1 */ if etykieta(n ) = 0 then begin niech nazwa będzie argumentem reprezentowanym przez n \ genkod(n )\ print op || nazwa \\ ' ,' || top(stosr) end /* przypadek 2 */ else if 1 ^ etykieta(n ) < etykieta(n ) and etykieta(n ) < r then begin zamień(stosr)\ genkod (n ); R := pop(stosr)\ /* n zostało wyliczone w rejestrze R */ genkod (n ); print op || R || ' ,' || top(stosr); push(stosr R)\ zamień{stosr) end /* przypadek 3 */ else if 1 ^ etykieta(n ) ^ etykieta(n ) and etykieta(n ) < r then begin genkod{n )\ R := pop(stosr); /* n zostało wyliczone w rejestrze R */ gen/rod (n ); print op || top(stosr) || ' , ' || /?; push(stosr, R) end /* przypadek 4, obie etykiety ^ r, liczbie wszystkich rejestrów */ else begin genkod(n )\ T := pop(stost);^ print ' M O V || top{stosr) || ' , ' || 7 ; genkod(n ); push(stost 7); print 0 7 ? || 7 || ' , ' || top(stosr) end end Rys. 9.25. Funkcja genkod x
2
2
2
x
x
2
x
2
2
x
}
2
x
2
x
t
2
2
x
y
gdzie można wyliczyć bez zapisywania, ale n jest trudniejsze do wyliczenia (tj. wy maga większej liczby rejestrów) niż n . W tym przypadku zamieniamy wierzchołki dwóch rejestrów na stosie stosr, a następnie wyliczamy n do R = top(stosr). Usuwamy R ze stosu stosr i wyliczamy n do 5 = top{stosr). Zauważmy, że 5 to wierzchołek, który na początku przypadku 2 był na wierzchołku stosu. Potem generujemy rozkaz op /?, 5, 2
x
2
x
który zapisuje wartość n (wierzchołka o etykiecie op) w rejestrze S. Kolejne wywoła nie funkcji zamień przywraca stosr do postaci, w której był na początku obsługiwanego wywołania genkod. Przypadek 3 jest podobny do 2; różni się tym, że trudniejsze jest w nim lewe pod drzewo i jest ono wyliczane jako pierwsze. Wobec tego, nie trzeba zamieniać rejestrów. Przypadek 4 m a miejsce, gdy oba poddrzewa potrzebują co najmniej r rejestrów do wyliczenia bez zapisów. Ponieważ musimy korzystać z tymczasowych lokacji w pamięci, więc najpierw wyliczamy prawe poddrzewo d o zmiennej tymczasowej 7 , następnie lewe poddrzewo i, w końcu, korzeń. P r z y k ł a d 9.13. Wygenerujmy kod dla etykietowanego drzewa z rys. 9.24, ze stosem stosr początkowo zawierającym RO i R l . Sekwencja wywołań genkod i kolejne kroki drukujące kod są przedstawione na rys. 9.26. W nawiasach kwadratowych jest podana zawartość stosr przy każdym wywołaniu, a wierzchołek tego stosu jest po prawej stronie. Otrzymana sekwencja kodu jest permutacją tej z rys. 9.20. •
genkod(t )
[Rj R ]
Ą
0
genkod(t )
[R
3
genkod(e)
R]
0
t
[R
Rj ]
0
p r i n t M O V e,R
{
genkod(t ) genkod(c) 2
[R ] [RQ] 0
print MOV c, RQ print ADD d, RQ p r i n t S U B ^fRi genkod(t ) {
genkod(a)
[R ] 0
[RQ]
/* przypadek 2 */ /* przypadek 3 */ /* przypadek 0 */ /* przypadek 1 */ /* przypadek 0 */
/* przypadek 1 */ /* przypadek 0 */
print MOV a, R, print ADD b, R print SUB Rj, R 0
Q
Rys. 9.26. Wykonanie procedury genkod
Możemy dowieść, że genkod produkuje optymalny kod dla wyrażeń naszego modelu maszyny, zakładając, że nie bierzemy pod uwagę algebraicznych własności operatorów oraz że nie m a wspólnych podwyrażeń. Dowód, pozostawiony Czytelnikom jako ćwicze nie, opiera się na wykazaniu, że każdy kod wyliczający dane wyrażenie musi wykonać: 1) 2) 3)
operację dla każdego wierzchołka wewnętrznego, ładowanie dla każdego liścia, który jest lewostronnym dzieckiem swojego rodzica, zapis dla każdego wierzchołka, którego dwoje dzieci m a etykiety równe bądź więk sze niż r.
Ponieważ genkod produkuje dokładnie takie kroki, więc kod jest optymalny.
Operacje na wielu rejestrach Możemy zmodyfikować nasz algorytm etykietowania, aby obsługiwał operacje takie jak mnożenie, dzielenie czy wywołanie funkcji, których wykonanie wymaga normalnie wię cej niż jednego rejestru. Trzeba po prostu zmodyfikować krok (6) algorytmu etykietowania z rys. 9.23, aby etykieta(n) była zawsze co najmniej równa liczbie rejestrów wymaganych przez operację. Przykładowo, jeśli o wywołaniu funkcji wiemy, że wymaga wszystkich r rejestrów, zastąpmy wiersz (6) tekstem etykieta(n) = r. Jeśli mnożenie wymaga dwóch rejestrów, w przypadku dwóch argumentów użyjemy fmax(2, l etyheta(n)
= ^
+
v
gdy l ć l
l)
{
2
l
g
d
y
2
{
i
—
h
gdzie l i l to etykiety dzieci n. Niestety, modyfikacja taka nie gwarantuje, że para sąsiednich rejestrów będzie do stępna dla mnożenia, dzielenia albo operacji o dużej dokładności. Wygodnym trikiem stosowanym na pewnych maszynach jest udawanie, że mnożenie i dzielenie potrzebu ją trzech rejestrów. Jeśli zamień nie jest nigdy używane przez genkod, to stosr będzie zawsze zawierał kolejne rejestry o dużych numerach, i, i + 1 , . . . , r — 1 dla pewnego i. Wobec tego, pierwsze trzy z nich z pewnością będą zawierały parę rejestrów. Korzy stając z faktu, że wiele operacji jest przemiennych, często możemy ominąć stosowanie przypadku 2 w funkcji genkod, czyli przypadku, w którym wywoływana jest funkcja zamień. Ponadto, nawet jeśli stosr nie zawiera trzech kolejnych rejestrów na wierzchoł ku, to z dużym prawdopodobieństwem znajdziemy taką parę sąsiednich rejestrów gdzieś w głębi tego stosu. x
2
Własności algebraiczne Jeśli możemy założyć, że dla różnych operatorów obowiązują prawa algebraiczne, to powstaje możliwość zastąpienia danego drzewa T drzewem z mniejszymi etykie tami (aby oszczędzić zapisów w przypadku 4 funkcji genkod) i/lub mniejszą liczbą liści (aby oszczędzić odczytów w przypadku 0). Przykładowo, ponieważ -f- jest zwy kle traktowany jak operator przemienny, możemy zastąpić drzewo z rys. 9.27(a) tym z rys. 9.27(b), zmniejszając liczbę lewostronnych liści o 1 i, być może, zmniejszając też jakieś etykiety. Ponieważ -I- jest zazwyczaj uważany za operator łączny, a nie tylko przemienny, więc możemy wziąć grupę wierzchołków z rys. 9.27(c) i zastąpić ją lewym łańcuchem, jak na rys. 9.27(d). Aby zminimalizować etykietę korzenia, musimy tylko spowodować, żeby poddrzewo 7) było tym z T , T , 7 , T , które ma największą etykietę, i aby 7} nie było liściem, chyba, że liśćmi są wszystkie z T , ..., T . x
2
3
Ą
x
Ą
Wspólne podwyrażenia Jeżeli w bloku bazowym występują wspólne podwyrażenia, to odpowiadający mu dag nie będzie j u ż drzewem. Wspólne podwyrażenia będą odpowiadały wierzchołkom z więcej niż jednym rodzicem, nazywanym wierzchołkami dzielonymi. W takim wypadku nie mo żemy już bezpośrednio stosować algorytmu etykietowania ani funkcji genkod. W istocie,
(c)
(d)
Rys. 9.27. Przekształcenia związane z przemiennością i łącznością wspólne podwyrażenia znacząco utrudniają generację kodu z matematycznego punktu widzenia. Bruno i Sethi [1976] wykazali, że generacja optymalnego kodu z dagów dla maszyny jednorejestrowej jest zadaniem NP-zupełnym. Z pracy Aho, Johnsona i Ullmana
Rys. 9.28. Podział na drzewa
[1977a] wynika, że nawet przy nieskończonej liczbie rejestrów problem pozostaje NP-zupełny. Trudność powstaje podczas próby wyznaczenia optymalnej kolejności, w której dag wyliczany jest najtaniej. W praktyce, rozsądne rozwiązanie możemy otrzymać po podzieleniu daga na zbiór drzew poprzez znalezienie dla każdego korzenia i/lub dzielonego wierzchołka n mak symalnego poddrzewa zakorzenionego w n, nie zawierającego innych dzielonych wierz chołków, chyba że jako liści. Przykładowo, dag z rysunku 9.22 może zostać podzielony na drzewa pokazane na rys. 9.28. Każdy dzielony wierzchołek z p rodzicami występuje jako liść w co najwyżej p drzewach. Wierzchołki mające więcej niż jednego rodzica w tym samym drzewie mogą zostać rozmnożone na tyle liści, ile jest potrzebnych, aby żaden z nich nie miał wielu rodziców. Po wykonaniu w ten sposób podziału daga na drzewa, możemy wyznaczyć porządek wyliczania drzew i skorzystać z któregoś z wcześniejszych algorytmów do generacji kodu dla drzew. Porządek drzew musi być taki, aby dzielone wartości, które są liśćmi drzewa, były dostępne, gdy wyliczane jest drzewo. Wartości dzielone mogą być wyliczane i za pisywane w pamięci (lub przechowywane w rejestrach, jeśli rejestrów jest wystarczająco dużo). Chociaż ten proces nie musi generować optymalnego kodu, to jednak kod ten często będzie satysfakcjonujący.
9.11
Algorytm generowania kodu metodą programowania dynamicznego
Przedstawiona w poprzednim podrozdziale procedura genkod produkowała optymalny kod na podstawie drzewa wyrażenia, zajmując ilość czasu, która była liniową funkcją wielkości drzewa. Procedura ta działa dla maszyn, które wszystkie obliczenia wykonują w rejestrach i których rozkazy składają się z operatora stosowanego do dwóch rejestrów bądź rejestru i lokacji w pamięci. Algorytm oparty na idei programowania dynamicznego może służyć do rozszerzenia klasy maszyn, dla których z drzew wyrażeń można wygenerować optymalny kod w czasie liniowym. Algorytm programowania dynamicznego stosuje się do szerokiej klasy maszyn rejestrowych ze złożonymi zbiorami rozkazów.
Klasa maszyn rejestrowych Algorytmu programowania dynamicznego można używać do generacji kodu dla dowolnej maszyny z r wymienialnymi rejestrami RO, Rl, ..., Rr- 1 i rozkazami o postaci R i : = £ , gdzie E jest dowolnym wyrażeniem zawierającym operatory, rejestry i lokacje w pamięci. Jeśli E używa jednego lub więcej rejestrów, to jednym z nich musi być RL Ten model obejmuje maszynę przedstawioną w p. 9.2. Przykładowo, rozkaz ADD RO, Rl odpowiadałby rozkazowi Rl: = R 1 + R 0 . Rozkaz ADD * R 0 , Rl odpowiadałby rozkazowi Rl : = R l + i n d RO, gdzie i n d oznacza ope rator dostępu pośredniego.
Zakładamy, że maszyna ma rozkaz odczytywania Ri: =M, rozkaz zapisywania M: =Rz i rozkaz kopiowania rejestru Ri: = R j . Dla uproszczenia przyjmujemy również, że każdy rozkaz ma jednostkowy koszt, ale algorytm programowania dynamicznego można łatwo zmodyfikować nawet dla przypadku, gdy każdy rozkaz ma inny koszt.
Zasada programowania dynamicznego Algorytm programowania dynamicznego dzieli problem generowania kodu optymalne go dla wyrażeń na podproblemy generowania kodu optymalnego dla podwyrażeń da nego wyrażenia. Jako prosty przykład rozpatrzmy wyrażenie E o postaci E + E . Optymalny program dla E powstaje przez połączenie optymalnych programów dla E i E w jednym z dwóch możliwych porządków i dodanie do nich kodu dla operato ra - f . Podproblemy generowania kodu optymalnego dla E i E są rozwiązywane ana logicznie. Optymalny program tworzony przez algorytm programowania dynamicznego ma ważną własność. Wylicza on wyrażenie E = E op £ „po kolei". Możemy zrozumieć, co to znaczy, patrząc na drzewo składniowe T dla E x
2
x
2
x
x
T i T to drzewa dla, odpowiednio, E x
2
x
i
2
0
E
v
Wyliczanie po kolei Mówimy, że program P wylicza drzewo T po kolei, jeśli najpierw oblicza te poddrzewa 7 \ które muszą zostać wyliczone w pamięci, a następnie wylicza resztę T albo w kolej ności T , T i korzeń, albo T , T i korzeń, w obu przypadkach korzystając z wcześniej wyliczonych wartości w pamięci, gdy jest to konieczne. Przykładem wyliczania nie po kolei może być wyliczenie przez P części T , zostawienie wyniku w rejestrze (zamiast w pamięci), następnie wyliczenie T i powrót do wyliczania T , Dla zdefiniowanej powyżej maszyny rejestrowej możemy dowieść, że dla dowolnego danego programu P wyliczającego drzewo wyrażenia T możemy znaleźć równoważny program P taki, że x
2
2
x
x
2
{
!
1) 2) 3)
P' ma koszt nie wyższy niż P , P' nie używa większej liczby rejestrów niż P P wylicza drzewo po kolei.
y
f
Z tego można wywnioskować, że każde drzewo wyrażenia może zostać optymalnie wy liczone przez program liczący po kolei. W przeciwieństwie do powyższych, maszyny z parami parzystych-nieparzystych re jestrów, takie jak IBM System/370, nie zawsze mają optymalne wyliczenia po kolei. Dla tych maszyn można znaleźć przykłady drzew wyrażeń, dla których optymalny pro-
gram w języku maszynowym musi najpierw wyliczyć do rejestru część lewego poddrze wa korzenia, następnie część prawego poddrzewa, potem inną część lewego poddrze wa, inną część prawego i tak dalej. Ten rodzaj oscylacji jest niepotrzebny do op tymalnego wyliczania dowolnego drzewa wyrażenia przy użyciu naszej maszyny re jestrowej. Z własności wyliczania po kolei, zdefiniowanej powyżej, wynika, że dla dowolnego drzewa wyrażenia T zawsze istnieje optymalny program, który składa się z optymalnych programów dla poddrzew korzenia, po których następuje rozkaz wyliczający korzeń. Własność ta pozwala stosować algorytm programowania dynamicznego do generowania optymalnych programów dla T. Algorytm programowania dynamicznego Działanie algorytmu programowania dynamicznego przebiega w trzech fazach. Załóż my, że maszyna docelowa ma r rejestrów. W pierwszej fazie wstępująco obliczamy dla każdego wierzchołka n z drzewa wyrażenia T tabelę C kosztów, w której i-ty skład nik C[i] to optymalny koszt wyliczenia poddrzewa S zakorzenionego w n do rejestru, przy założeniu, że do obliczeń dostępnych jest / rejestrów, 1 ^ i ^ r. Koszt ten obejmuje wszystkie odczyty z oraz zapisy do pamięci niezbędne do wyliczenia S w podanej liczbie rejestrów. Obejmuje on również koszt obliczeń związanych z operatorem w korzeniu S. Zerowy element wektora kosztów to optymalny koszt obliczenia poddrzewa S do pamięci. Dzięki własności wyliczania po kolei, optymalny program dla S może zostać znaleziony przez badanie kombinacji optymalnych programów tylko dla poddrzew korzenia S. Takie ograniczenie zmniejsza liczbę przypadków, które trzeba rozpatrywać. Aby obliczyć C[i] w wierzchołku n, rozpatrujemy wszystkie rozkazy maszynowe R:=£, których wyrażenie E odpowiada podwyrażeniu zakorzenionemu w wierzchoł ku n. Sprawdzając wektory kosztów w odpowiadających potomkach n, określamy koszty wyliczania argumentów E. Dla tych argumentów £ , które są rejestrami, rozpatrujemy wszystkie możliwe kolejności, w których odpowiadające im poddrzewa T mogą być wyliczane do rejestrów. W każdym z porządków pierwsze poddrzewo odpowiadające argumentowi z rejestru może zostać wyliczone z użyciem i dostępnych rejestrów, drugie z użyciem i — 1 rejestrów i tak dalej. Pamiętając o wierzchołku n, dodajemy koszt rozkazu R:=£\ który został wybrany dla wierzchołka n. Wartość C[i] jest wówczas minimalnym kosztem dla wszystkich możliwych porządków. Wektory kosztów dla całego drzewa T można wstępująco obliczyć w czasie liniowym względem liczby wierzchołków T. W każdym wierzchołku wygodnie jest pamiętać rozkazy używane do otrzymania najlepszego kosztu C[i] dla wszystkich war tości i. Najmniejszy koszt w wektorze dla korzenia T to najmniejszy koszt wyli czenia T. W drugiej fazie algorytmu przechodzimy 7\ używając wektorów kosztów do okre ślenia, które z poddrzew T muszą zostać wyliczone do pamięci. W trzeciej fazie przecho dzimy wszystkie drzewa, używając wektorów kosztów i związanych z nimi rozkazów do generowania kodu wynikowego. Najpierw generujemy kod dla poddrzew wyliczanych do pamięci. Te dwie fazy również mogą być zaimplementowane tak, by czas ich działania był liniowy względem wielkości drzewa wyrażenia.
P r z y k ł a d 9.14. Rozpatrzmy maszynę o dwóch rejestrach, RO i Rl, i następujące rozkazy o koszcie jednostkowym:
Ri:=My R/:=Rz op Rj R*:=Rz op Mj Ri:=Rj Mi: =Ri W powyższych rozkazach, Ri to RO lub Rl, a My to lokacja w pamięci. Zastosujmy algorytm programowania dynamicznego do wygenerowania kodu opty malnego dla drzewa składniowego z rys. 9.29. W pierwszej fazie obliczamy wektory kosztów pokazane przy każdym wierzchołku. Aby pokazać takie obliczenia, weźmy wek tor kosztów związany z liściem a. C[0], koszt wyliczenia a do pamięci, jest równy 0, bo a już jest w pamięci. C [ l ] , koszt wyliczenia a do rejestru, że jest równy 1, bo możemy załadować a do rejestru, używając rozkazu RO : = a . C[2], koszt załadowania a do reje stru, gdy dostępne są dwa rejestry, jest taki sam, jak przy jednym dostępnym rejestrze. Wektorem kosztów w liściu a jest więc (0,1,1).
Rys. 9.29. Drzewo składniowe dla (a-b)+c*(d/e) z wektorem kosztów przy każdym wierzchołku
Rozpatrzmy wektor kosztów w korzeniu. Najpierw wyznaczamy minimalny koszt obliczenia korzenia przy dostępnych jednym i dwóch rejestrach. Rozkaz R0:=R0+M pasuje d o korzenia, b o korzeń jest etykietowany operatorem + . Gdy korzystamy z tego rozkazu, to minimalny koszt wyliczenia korzenia z jednym dostępnym rejestrem jest minimalnym kosztem wyliczenia jego prawego poddrzewa do pamięci, powiększonym o minimalny koszt wyliczenia lewego poddrzewa do rejestru plus 1, czyli koszt rozkazu. Nie ma innej metody. Wektory kosztów lewego i prawego dziecka korzenia pozwalają stwierdzić, że minimalny koszt obliczenia korzenia przy jednym dostępnym rejestrze to 5 + 2 + 1 = 8. Rozpatrzmy teraz minimalny koszt wyliczenia korzenia przy dostępnych dwóch re jestrach. Mamy trzy możliwości, w zależności od tego, który rozkaz wybierzemy do obliczeń, i w jakiej kolejności wyliczymy lewe i prawe poddrzewo korzenia. 1.
Wyliczamy lewe poddrzewo przy dwóch dostępnych rejestrach d o rejestru RO, a na stępnie prawe poddrzewo przy jednym dostępnym rejestrze do Rl i używamy rozka zu RO : =R0+R1 do wyliczenia wierzchołka. Koszt tych obliczeń to 2 + 5 + 1 = 8.
2.
3.
Obliczamy prawe poddrzewo przy dwóch dostępnych rejestrach do Rl, wyliczamy lewe poddrzewo przy jednym dostępnym rejestrze do RO i korzystamy z rozkazu RO : =R0+R1. Koszt to 4 + 2 + 1 = 7. Obliczamy prawe poddrzewo do pamięci o adresie M, obliczamy lewe poddrzewo przy dwóch dostępnych rejestrach d o rejestru RO i korzystamy z rozkazu RO : =R0+M. Obliczenia te kosztują 5 + 2 + 1 = 8.
Druga możliwość daje najlepszy koszt, równy 7. Minimalny koszt obliczenia korzenia do pamięci określamy, dodając jeden do mi nimalnego kosztu obliczenia korzenia przy wszystkich rejestrach dostępnych, tj. wylicza my korzeń do rejestru i zapisujemy wynik. Wektorem kosztów dla korzenia jest więc (8,8,7). Z wektorów kosztów możemy łatwo budować kod w trakcie przechodzenia drzewa. Zakładając, że dostępne są dwa rejestry, dla drzewa z rys. 9.29 kod optymalny to
RO :=c Rl :=d Rl:=Rl/e RO:=R0*R1 Rl: =a Rl:=Rl-b R1:=R1+R0
•
Technika ta, pierwotnie zaproponowana przez Aho i Johnsona [1976], była używana w wielu kompilatorach, w tym w drugiej wersji przenośnego kompilatora C napisane go przez S. C. Johnsona, nazywanej PCC2. Technika ta umożliwia zmiany maszyny docelowej, bo technika programowania dynamicznego działa dla szerokiej klasy maszyn.
9.12
Generatory generatorów kodu
Generacja kodu obejmuje wybór kolejności obliczeń dla operacji, wyznaczanie rejestrów do przechowywania wartości i wybór odpowiednich rozkazów z języka maszyny doce lowej do implementacji operatorów z reprezentacji pośredniej. Nawet jeśli założymy, że kolejność obliczeń jest podana i że rejestry są przydzielane przy użyciu oddzielnego me chanizmu, problem wyboru rozkazów może być trudnym zadaniem kombinatorycznym, zwłaszcza dla maszyn z bogatym zestawem trybów adresowania. W tym podrozdziale przedstawiamy techniki przepisywania drzew, których można użyć do automatycznej bu dowy części generatora kodu wybierającej rozkazy, na podstawie wysokopoziomowego opisu maszyny docelowej. Generowanie kodu przez przepisywanie drzew Przyjmijmy, że wejściem dla procesu generacji kodu będzie ciąg drzew na semantycznym poziomie maszyny docelowej. Drzewa te możemy otrzymać po wstawieniu adresów czasu wykonania do reprezentacji pośredniej, co opisaliśmy w p. 9.3.
P r z y k ł a d 9.15. Na rysunku 9.30 widać drzewo dla instrukcji przypisania a [ i ] : =b+l, gdzie a oraz i to zmienne lokalne, których adresy czasu wykonania są podawane ja ko przesunięcia c o n s t i c o n s t względem SP, rejestru przechowującego wskaźnik początku aktualnego rekordu aktywacji. Tablica a jest przechowywana na stosie. Przypi sanie do a [ i ] jest pośrednim przypisaniem, w którym r-wartości lokacji a [ i ] nadajemy r-wartość wyrażenia b+1. Adres tablicy a otrzymujemy, dodając wartość stałej c o n s t do zawartości rejestru SP; wartość i jest w lokacji, którą znajdujemy, dodając wartość stałej c o n s t do zawartości rejestru SP. Zmienna b jest zmienną globalną w pamię ci o adresie m e m . Dla uproszczenia przyjmujemy, że wszystkie zmienne są typu zna kowego. a
i
a
i
b
ind
const
a
reg
+ SP
/ cons^
\ r
e
9sp
Rys. 9.30. Drzewo kodu pośredniego dla a [ i ] : = b + l W drzewie operator ind traktuje swoje argumenty jak adresy w pamięci. Jako lewe dziecko operatora przypisania wierzchołek ind podaje lokację, w której będzie zapisana r-wartość z prawej strony operatora przypisania. Jeśli argumentem + lub operatora ind jest lokacja w pamięci albo rejestr, to zawartość tej lokacji w pamięci albo rejestru jest przyjmowana jako wartość. Liście drzewa to atrybuty typowe z indeksami; indeksy te wskazują wartości atrybutów. • Kod wynikowy jest generowany w trakcie procesu redukcji drzewa wejściowego do pojedynczego wierzchołka za pomocą wielokrotnie stosowanych reguł przepisywania drzewa. Każda z tych reguł jest postaci zastępca
<- szablon
{ akcja }
gdzie: 1) 2) 3)
zastępca jest pojedynczym wierzchołkiem, szablon to drzewo, akcja to fragment kodu, tak jak w schemacie translacji sterowanej składnią.
Zbiór reguł przepisywania drzewa nazywamy schematem translacji drzewa. Każdy szablon drzewiasty reprezentuje obliczenie wykonywane przez sekwencję rozkazów maszynowych emitowanych przez związaną z szablonem akcję. Zazwyczaj szablon odpowiada pojedynczemu rozkazowi. Liście szablonu to atrybuty z indeksami,
tak jak w drzewie wejściowym. Często istnieją pewne ograniczenia dotyczące wartości indeksów w szablonach; ograniczenia te są opisywane przez predykaty semantyczne, które muszą być spełnione zanim powiemy, że szablon pasuje. Przykładowo, szablon może wymagać, aby wartość stałej była z określonego zakresu. Schemat translacji drzewa jest wygodną metodą przedstawienia fazy wyboru rozka zów w generatorze kodu. Jako przykład reguły przepisywania drzewa rozważmy regułę dla rozkazu dodawania dwóch rejestrów: { ADD R/,Ri }
reg,reg,-
reg,-
Z reguły tej korzystamy następująco: jeśli drzewo wejściowe zawiera poddrzewo, do któ rego pasuje ten szablon, czyli poddrzewo, którego korzeń jest etykietowany operatorem +, i którego lewe i prawe dziecko to wartości w rejestrach i oraz y, to możemy to pod drzewo zastąpić pojedynczym wierzchołkiem z etykietą reg,- i wyemitować jako wyjście rozkaz ADD W danej chwili do określonego poddrzewa może pasować więcej niż jeden szablon; wkrótce opiszemy pewne mechanizmy wyboru reguły w przypadku takich konfliktów. Zakładamy, że przydział rejestrów jest wykonywany przed wyborem kodu. P r z y k ł a d 9.16. Na rysunku 9.31 przedstawiono reguły przepisywania dla kilku rozka zów naszej maszyny docelowej. Reguły te są używane w kolejnych przykładach w tym podrozdziale. Pierwsze dwie reguły odpowiadają rozkazom odczytu, następne dwie — rozkazom zapisu, a pozostałe — indeksowanym odczytom i dodawaniu. Zauważmy, że reguła (8) wymaga, żeby wartością stałej było 1. Warunek ten mógłby zostać wyspecy fikowany za pomocą predykatu semantycznego. • Schemat translacji drzewa działa następująco. Otrzymawszy drzewo wejściowe sza blony z reguł przepisywania są stosowane do j e g o poddrzew. Jeśli szablon pasuje, to pasujące poddrzewo z drzewa wejściowego jest zastępowane wierzchołkiem zastępują cym reguły i wykonywana jest akcja skojarzona z regułą. Jeśli akcja zawiera sekwencję rozkazów maszynowych, to są one emitowane. Proces ten jest powtarzany aż do mo mentu, w którym drzewo zostanie zredukowane do jednego wierzchołka albo nie będzie już żadnych pasujących szablonów. Sekwencja rozkazów maszynowych wygenerowanych w trakcie redukowania drzewa wejściowego do pojedynczego wierzchołka stanowi wyj ście schematu translacji drzewa dla danego drzewa wejściowego. Proces specyfikowania generatora kodu jest więc podobny do używania schema tu translacji sterowanej składnią do specyfikacji analizatora składniowego. Zapisujemy schemat translacji drzewa, aby opisać zbiór rozkazów maszyny docelowej. W prakty ce, chcielibyśmy znaleźć schemat, który powoduje wygenerowanie dla każdego drzewa sekwencji rozkazów o minimalnym koszcie. Dostępne są narzędzia pomagające automa tycznie budować generator kodu na podstawie schematu translacji drzewa. P r z y k ł a d 9.17. Skorzystajmy ze schematu translacji drzewa z rys. 9.31 do wygenero wania kodu dla drzewa wejściowego z rys. 9.30. Przypuśćmy, że pierwszą regułę
(1)
reg,-
(2)
reg.
(3)
mem
«—
{ MOV a,Ri }
mem.
{ MOV Ri,a }
mem (4)
{ MOV #c,Ri }
const.
mem
reg,-
a
{ MOV Rj *Ri
<—
f
ind
}
reg
y
reg,(5)
reg,
const
(6)
reg,-
{ MOV c {Rj) ,Ri }
ind
regy
c
{ ADD
«reg,-
regy
c
{ ADD RjrRi
reg. reg,-
(8)
{Rj) , Ri }
ind
const
(7)
C
}
reg,{ INC Ri }
reg,const-L
reg,-
Rys. 9.31. Reguły przepisywania drzewa dla pewnych rozkazów maszyny docelowej
(1)
reg
<-
0
const
a
{ MOV # a , RO }
stosujemy do wczytania stałej a d o rejestru RO. Etykieta skrajnie lewego liścia jest wówczas zmieniana z c o n s t na r e g i generowany jest rozkaz MOV # a , RO. Siódma reguła a
0
+
(7)
reg
0
<-
/ reg
0
\ reg
{ ADD SP, RO } SP
pasuje teraz do lewego poddrzewa z korzeniem o etykiecie +. Korzystając z tej reguły, przepisujemy to poddrzewo jako pojedynczy wierzchołek z etykietą reg i generujemy rozkaz ADD SP, RO. Drzewo wygląda teraz następująco: 0
consti
reg
SP
W tej chwili możemy zastosować regułę (5), by zredukować poddrzewo
ind I +
/ const
\ reg
±
SP
do pojedynczego wierzchołka o etykiecie reg . Jednak możemy także użyć reguły (6) do zredukowania większego poddrzewa t
+
/ reg
\ ind I + / \ consti reg 0
SP
do pojedynczego wierzchołka o etykiecie reg i wygenerować rozkaz ADD i (SP) , RO. Przyjmując, że bardziej efektywne jest użycie pojedynczego rozkazu wyliczającego więk sze poddrzewo, wybieramy drugą redukcję, otrzymując 0
reg
mem
0
b
constj
W prawym poddrzewie, reguła (2) stosuje się do liścia m e m . Reguła ta generuje rozkazy wczytujące b do, powiedzmy, rejestru 1. Następnie, używając reguły (8), dopasowujemy poddrzewo b
+ regj
const!
i generujemy rozkaz INC Rl. Drzewo w tym momencie wygląda następująco:
ind
regj
I reg o Pozostałe drzewo dopasowujemy do reguły (4), co redukuje drzewo do pojedynczego wierzchołka i generuje rozkaz MOV R l , *R0. W procesie redukcji drzewa do pojedynczego wierzchołka wygenerowaliśmy taki fragment kodu MOV ADD ADD MOV INC MOV
#a,R0 SP,R0 i(SP) RO b,Rl Rl Rl,*R0 f
•
Pewne aspekty tego procesu redukcji drzewa wymagają dalszych wyjaśnień. Nie opisaliśmy, jak wykonywane jest dopasowywanie szablonów do drzew. Nie wyspecyfiko waliśmy również kolejności, w której szablony są dopasowywane, ani nie wyjaśniliśmy, co zrobić, gdy w danej chwili pasuje więcej niż jeden szablon. Zauważmy również, że gdy nie m a pasujących szablonów, to proces generacji kodu jest blokowany. Możliwe jest jednakże przepisywanie pojedynczego wierzchołka w nieskończoność, generujące nie skończenie długą sekwencję rozkazów kopiowania rejestrów lub nieskończoną sekwencję odczytów i zapisów do pamięci. Jedną z metod wydajnego dopasowywania szablonu jest rozszerzenie algorytmu dopasowywania wielu wzorców z ćwiczenia 3.32 do zstępującego algorytmu dopasowy wania drzew. Każdy szablon może być reprezentowany przez zbiór tekstów, konkretnie przez zbiór ścieżek od korzenia do liści. Z takiego zbioru napisów możemy zbudować automat dopasowujący drzewa, tak jak w ćwiczeniu 3.32. Problemy z wyborem kolejności i pasowaniem wielu wzorców można rozwiązać, korzystając z dopasowywania drzew w połączeniu z algorytmem programowania dyna micznego z poprzedniego podrozdziału. Schemat translacji drzewa można wzbogacić o informacje o koszcie przez związanie z każdą regułą przepisywania drzewa informacji o koszcie rozkazów maszynowych generowanych w wyniku zastosowania tej reguły. W praktyce, proces przepisywania drzewa można zaimplementować przez urucho mienie automatu dopasowującego drzewa podczas przechodzenia drzewa wejściowego w głąb i wykonywanie redukcji podczas ostatnich odwiedzin w wierzchołku. Jeśli jed nocześnie działa algorytm programowania dynamicznego, to możemy wybrać optymalną kolejność dopasowań, korzystając z informacji o koszcie związanym ze wszystkimi re gułami. Podjęcie decyzji o dopasowaniu możemy opóźnić aż do czasu poznania kosztów wszystkich możliwości. Korzystając z tego podejścia, ze schematu przepisywania drzewa łatwo można zbudować mały, wydajny generator kodu. Co więcej, algorytm programowa nia dynamicznego uwalnia projektanta generatora kodu od konieczności rozwiązywania
konfliktów w przypadku wielu możliwych dopasowań i konieczności wyboru kolejności obliczeń. Dopasowanie wzorca przez analizę składniową Innym podejściem jest wykorzystywanie do dopasowania wzorca analizatora LR. Drze wo wejściowe może być traktowane jako napis poprzez korzystanie z jego reprezentacji prefiksowej. Przykładowo, prefiksową reprezentacją drzewa z rys. 9.30 jest := ind + + const
a
reg
s p
ind + consta r e g
+ mem constj
S P
b
Schemat translacji drzewa można przerobić na schemat translacji sterowany skład nią, zastępując reguły przepisywania drzewa produkcjami gramatyki bezkontekstowej, w której prawe strony produkcji są prefiksową reprezentacją szablonów rozkazów. Przykład 9.18. Schemat translacji sterowany składnią z rys. 9.32 jest oparty na sche macie translacji drzewa z rys. 9.31. •
(1)
(2) (3) (4) (5) (6) (7) (8)
reg,- —y const reg,- —> m e m mem - 4 := m e m reg,mem —> := ind reg.^ regj reg,- —»• ind + const reg reg,- —> + reg,- ind + co'nst reg reg,- ~> + reg,- reg^reg,- —> + reg,- constj c
a
a
c
;
c
;
{ { { { { { { {
MOV MOV MOV MOV MOV ADD ADD INC
#c,Ri } a,Ri } Ri,a } Rj *Ri
}
c(Rj)
,Ri
c{Rj)
, R/
r
Rj r Ri } Ri }
Rys. 9.32. Schemat translacji sterowany składnią zbudowany w oparciu na schemacie z rys. 9.31
Z produkcji schematu translacji budujemy analizator LR, korzystając z jednej z po danych w rozdz. 4 technik budowy takich analizatorów. Kod wynikowy generujemy, emitując rozkazy maszynowe odpowiadające każdej redukcji. Gramatyka do generacji kodu jest zazwyczaj wysoce niejednoznaczna, a problemo wi rozwiązywania konfliktów między akcjami analizatora trzeba poświęcić trochę uwagi podczas budowy analizatora. Z powodu braku informacji o koszcie ogólnie stosowa ną zasadą jest preferowanie większych redukcji. Oznacza to, że w przypadku konfliktu redukcja/redukcja wybierana jest dłuższa redukcja; w konfliktach przesunięcie/redukcja wybieramy przesunięcie. Takie podejście „maksymalnego pożerania" powoduje wykony wanie dużej liczby operacji za pomocą jednego rozkazu. Jest kilka dobrych aspektów korzystania z analizy LR do generacji kodu. Po pierw sze, metody analizy są wydajne i dobrze zbadane, więc za pomocą algorytmów z rozdz. 4 można tworzyć niezawodne i wydajne generatory kodu. Po drugie, stosunkowo łatwo jest przenieść otrzymany generator kodu na inną maszynę; pisząc gramatykę opisującą rozka zy nowej maszyny, można otrzymać procedury wybierające rozkazy dla nowej maszyny. Po trzecie, jakość generowanego kodu można poprawiać, dodając produkcje dla specjal nych przypadków, które będą korzystały z idiomów maszyny.
Niestety, napotykamy również pewne utrudnienia. Porządek wyliczania od strony lewej do prawej jest ustalony przez metodę analizy. Poza tym, dla niektórych maszyn z dużą liczbą trybów adresowania gramatyka opisująca maszynę i wynikający z niej analizator mogą być niebywałe duże. Z tego powodu konieczne są specjalne techniki do kodowania i obsługi gramatyk opisujących maszyny. Musimy również uważać, aby otrzymywany analizator nie blokował się (nie miał możliwości kolejnego ruchu) podczas analizy drzewa wyrażenia z powodu nieobsługiwania przez gramatykę pewnych układów operatorów albo z powodu podjęcia złej decyzji dla jakiegoś konfliktu akcji. Musimy również upewnić się, że analizator nie wejdzie w nieskończoną pętlę redukcji produkcji z pojedynczymi symbolami po prawej stronie. Problem zapętlania się można rozwią zać, korzystając z techniki podziału stanów w trakcie tworzenia tablic analizatora (patrz Glanville [1977]).
Procedury do sprawdzania semantyki Liśćmi drzewa wejściowego są atrybuty typowe z indeksami wiążącymi wartość z atry butem. W schemacie translacji generującym kod występują takie same atrybuty, ale czę sto z ograniczeniami dotyczącymi wartości indeksów. Przykładowo, rozkaz maszynowy może wymagać, aby wartość atrybutu była z pewnego przedziału albo wartości dwóch atrybutów były ze sobą związane. Ograniczenia wartości atrybutów można opisywać jako predykaty, które są oblicza ne przed wykonaniem redukcji. Faktycznie, używanie akcji semantycznych i predykatów czysto może zapewnić większą elastyczność i łatwość opisu niż gramatyczna specyfikacja generatora kodu. Do reprezentowania klas rozkazów można używać szablonów, a akcje semantyczne mogą być wtedy używane do wybierania rozkazów dla konkretnych przy padków. Przykładowo, dwa rodzaje rozkazu dodawania można przedstawić za pomocą jednego szablonu reg,reg,-
const
c
{ if c = 1 then INC Bi else ADD #c,Ri }
Konflikty akcji analizatora można rozwiązywać za pomocą ujednoznaczniających predykatów, które pozwalają korzystać z różnych strategii wyboru w różnych kontekstach. Krótszy opis maszyny docelowej jest możliwy dzięki temu, że pewne cechy architektury maszyny, takie j a k tryby adresowania, mogą być ukryte w atrybutach. Problem z ta kim podejściem polega na tym, że sprawdzenie dokładności gramatyki atrybutowej jako wiernego opisu maszyny docelowej może być trudne, ale problem ten w pewnym stopniu dotyczy wszystkich generatorów kodu.
ĆWICZENIA
9.1 Wygeneruj kod wynikowy dla poniższych instrukcji w języku C dla maszyny doce lowej z p . 9.2, zakładając, że wszystkie zmienne są statyczne. Przyjmij, że dostępne są trzy rejestry.
a) x = l b) x = y c) x = x + l d) x = a + b * c e)
x=a/(b+c)-d*(e+f)
9.2 Powtórz ćwiczenie 9.1, przyjmując, że wszystkie zmienne są automatyczne (alo kowane na stosie). 9.3 Wygeneruj kod dla poniższych instrukcji w języku C dla maszyny docelowej z p. 9.2, zakładając, że wszystkie zmienne są statyczne. Przyjmij, że dostępne są trzy rejestry. a) x = a [ i ] + 1 b) a [ i ] = b [ c [ i ] ] c) a [ i ] [ j ] - b [ i ] [ k ] * c [ k ] [ j ] d) a [ i ] = a [ i ] + b [ j ] e)
a[i]+=b[j]
9.4 Wykonaj ćwiczenie 9.1, używając a) algorytmu z p. 9.6, b) procedury
genkod,
c) algorytmu programowania dynamicznego z p. 9.11. 9.5 Wygeneruj kod dla następujących instrukcji w języku C: a) x = f ( a ) + f ( a ) + f ( a ) b) x = f < a ) / g ( b , c ) c) x = f ( f ( a ) ) d) x = + + f ( a ) e) * p + + = * q + + 9.6 Wygeneruj kod dla poniższego programu w języku C: m a i n () { int i; i n t a [10]; w h i l e ( i <= 10) a [ i ] = 0;
} 9.7 Załóżmy, że w pętli z rys. 9.13 zdecydowaliśmy się przydzielić trzy rejestry do przechowywania a, b i c . Wygeneruj kod dla bloków tej pętli. Porównaj koszt Twojego kodu z tym z rys. 9.14. 9.8 Zbuduj graf kolizji wierzchołków dla programu z rys. 9.13. 9.9 Przypuśćmy, że dla ułatwienia automatycznie zapamiętujemy wszystkie rejestry na stosie (lub w pamięci, gdy nie używamy stosu) przed każdym wywołaniem
procedury i odtwarzamy je po powrocie. Jaki ma to wpływ na wzór (9.4) używany do oceny korzyści z przydzielenia danej zmiennej rejestru w pętli? 9.10 Zmodyfikuj funkcję dajrej
z p. 9.6 tak, by zwracała pary rejestrów, gdy jest to
potrzebne. 9.11 Podaj przykład daga, dla którego heurystyka porządkująca wierzchołki podana na rys. 9.21 nie daje najlepszego uporządkowania. *9.12 Wygeneruj optymalny kod dla poniższych instrukcji przypisania a) x : = a + b * c b) x : = ( a * - b ) + ( c - ( d + e ) ) c) x : = ( a / b - c ) /cl d) x : = a + ( b + c / d * e ) / ( f * g - h * i ) e) a [ i , j ] : = b [ i , j ] - c [ a [ k , l ] ] * d [ i + j 9.13 Wygeneruj kod dla poniższego programu w Pascalu program forloop(input, output); v a r i , początkowa, końcowa: i n t e g e r ; begin read(początkowa, końcowa); f o r i : = początkowa t o końcowa writełn (i); end.
do
9.14 Zbuduj dag dla bloku bazowego d:=b*c e:=a+b b:=b*c a:=e-d 9.15 Jakie są poprawne kolejności obliczeń i nazwy dla wartości w wierzchołkach daga z ćwiczenia 9.14 przy założeniu, że: a) a, b i c są żywe przy końcu bloku bazowego, b) tylko zmienna a jest żywa przy końcu? 9.16 W ćwiczeniu 9.15(b), jeśli chcemy wygenerować kod dla maszyny z jednym reje strem, to która kolejność obliczeń jest najlepsza? Czemu? 9.17 Możemy zmodyfikować algorytm budowy daga tak, by można było używać przy pisań do elementów tablic i przez wskaźniki. Gdy dowolnemu elementowi tablicy przypisujemy wartość, to zakładamy, że tworzona jest nowa wartość dla tej tablicy. Ta nowa wartość jest reprezentowana przez wierzchołek, którego dziećmi są: stara wartość tablicy, wartość indeksu w tablicy i przypisywana wartość. Gdy następu j e przypisanie przez wskźnik, to przyjmujemy, że stworzyliśmy nową wartość dla każdej zmiennej, którą mógł wskazywać ten wskaźnik; dzieci wierzchołka dla każ dej nowej wartości to wartość wskaźnika i stare wartości zmiennych, d o których mogło nastąpić przypisanie. Korzystając z tych założeń, zbuduj dag dla poniższego bloku bazowego
a[i]:=b *p: =c d:-a[j] e : =*p *p:=a[i] Przyjmij, że: (a) p może wskazywać cokolwiek, (b) p wskazuje tylko b lub d. Nie zapomnij pokazać implikowanych ograniczeń porządku. 9.18 Jeśli wskaźnik lub wyrażenie tablicowe, takie jak a [ i ] albo * p , ma przypisywaną wartość, a następnie jest używane bez możliwości zmiany tej wartości w tym czasie, możemy taką sytuację wykorzystać do uproszczenia daga. Przykładowo, w kodzie z ćwiczenia 9.17, ponieważ p nie m a przypisywanej wartości między drugą a czwartą instrukcją, więc instrukcja e : = * p może zostać zastąpiona e : = c , bo jesteśmy pewni, iż mimo że nie wiemy, co wskazuje p , to wartość tego jest taka sama, jak wartość c. Popraw algorytm budowy daga tak, by korzystał z takiego wnioskowania. Zastosuj swój algorytm do kodu z ćwiczenia 9.17. **9.19 Podaj algorytm generowania kodu optymalnego dla sekwencji instrukcji trójadre sowych o postaci a : - b + c na n rejestrowej maszynie z przykładu 9.14. Instrukcje muszą być wykonywane w podanym porządku. Jaka jest złożoność Twojego algo rytmu?
UWAGI B I B L I O G R A F I C Z N E Czytelnik zainteresowany badaniami nad generacją kodu powinien sięgnąć do następu jących źródeł: Waite [1976a,b], Aho i Sethi [1977], Graham [1980 i 1984], Ganapathi, Fisher i Hennessy [1982], Lunęli [1983] i Henry [1984]. Generator kodu dla języka Bliss omówili Wulf i in. [1975], dla Pascala — Ammann [1977], a dla PL.8 — Auslander i Hopkins [1982]. Statystyki dotyczące programów są przydatne w projektowaniu kompilatora. Knuth [197 lb] przeprowadził empiryczne badanie programów w Fortranie. Elshoff [1976] do starczył pewnych statystyk dotyczących używania języka PL/I, a Shimasaki i in. [1980] oraz Carter [1982] analizowali programy w Pascalu. Wydajność pewnych kompilato rów dla różnych zestawów rozkazów opisali Lunde [1977], Shustek [1978] oraz Ditzel i McLellan [1982]. Wiele z przedstawionych w tym rozdziale heurystyk związanych z generacją kodu używano w różnych kompilatorach. Freiburghouse [1974] opisał liczniki użyć jako pomoc w generowaniu dobrego kodu dla bloków bazowych. Belady [1966] wykazał, że strategia, zastosowana w funkcji dajrej, tworzenia wolnego rejestru przez wyrzucanie z rejestru zmiennej, której wartość była najdłużej nie używana, jest optymalna w kontekście pro blemu wymiany strony w systemie z pamięcią wirtualną. Nasza strategia przydzielania ustalonej liczby rejestrów do przechowywania zmiennych podczas wykonywania pętli została wspomniana przez Marilla [1962] i użyta przez L o w r y ' e g o i Medlocka [1969] w implementacji Fortranu H. Horowitz i in. [1966] podali algorytm optymalizowania wykorzystania rejestrów indeksowych w Fortranie. Kolorowanie grafów jako metodę przydzielania rejestrów za proponowali J. Cocke, Ershov [1971] i Schwartz [1973]. Opis kolorowania grafów z p. 9.7
podaliśmy wg Chaitina i in. [1981] oraz Chaitina [1982]. Chow i Hennessy [1984] opi sali algorytm kolorowania grafów dla przydziału rejestrów, oparty na priorytetach. Inne podejścia do przydziału rejestrów opisali Kennedy [1972], Johnsson [1975], Harrison [1975], Beatty [1974] i Leverett [1982]. Algorytm etykietowania drzew z p. 9.10 przypomina algorytm nazywania rzek: po łączenie dużej rzeki i małego dopływu używa nazwy dużej rzeki, lecz połączeniu dwóch równie dużych rzek nadaje się nową nazwę. Algorytm etykietowania pierwotnie przedsta wił Ershov [1958]. Algorytmy generacji kodu korzystające z tej metody zaproponowali Anderson [1964], Nievergelt [1965], Nakata [1967], Redziejowski [1969] i Beatty [1972]. Sethi i Ullman [1970] użyli metody etykietowania w algorytmie, który — udowodnili — generuje optymalny kod dla drzew wyrażeń w wielu sytuacjach. Procedura genkod z p. 9.10 jest modyfikacją algorytmu Sethiego i Ullmana wykonaną przez Stockhausena [1973]. Bruno i Lassagne [1975] oraz Coffman i Sethi [1983] podali algorytmy genera cji kodu optymalnego dla drzew wyrażeń, jeśli maszyna docelowa ma rejestry, których trzeba używać jak stosu. Aho i Johnson [1976] podali algorytm programowania dynamicznego przedstawio ny w p. 9.11. Algorytm ten był używany jako podstawa generatora kodu w przenośnym kompilatorze języka C, PCC2, S. C. Johnsona oraz w kompilatorze dla maszyn IBM 370 Ripkena [1977]. Knuth [1977] uogólnił algorytm programowania dynamicznego na maszyny z niesymetrycznymi klasami rejestrów, takie jak IBM 7090 i C D C 6600. Opraco wując uogólnienie, Knuth rozpatrywał generację kodu jako problem analizy składniowej dla gramatyk bezkontekstowych. Floyd [1961] podał algorytm obsługujący wspólne podwyrażenia w wyrażeniach arytmetycznych. Podział dagów na drzewa i użycie procedury podobnej do genkod od dzielnie dla każdego z drzew pochodzi od Waite'a [1976a]. Sethi [1975] oraz Bruno i Sethi [1976] wykazali, że problem generowania kodu optymalnego dla dagów jest NP-zupełny, natomiast Aho, Johnson i Ullman [1977a] — że problem ten jest NP-zupełny nawet w przypadku maszyn z jednym i nieskończenie wieloma rejestrami. Aho, Hopcroft i Ullman [1974] oraz Garey i Johnson [1979] opisali, jakie znaczenie ma fakt, że problem jest NP-zupełny. Przekształcenia bloków bazowych badali Aho i Ullman [1972a] oraz Downey i Sethi [1978]. Optymalizację przez szparkę opisali McKeeman [1965], Fraser [1979], Davidson i Fraser [1980 i 1984a,b], L a m b [1981] i Giegerich [1983]. Tanenbaum, van Staveren i Stevenson [1982] zalecali stosowanie optymalizacji przez szparkę również dla kodu pośredniego. Traktowanie generacji kodu jako procesu przepisywania drzewa opisali Wasilew [1971], Weingart [1973], Johnson [1978] oraz Cattell [1980]. Przykład przepisywa nia drzewa z p. 9.12 pochodzi od Henry'ego [1984], Aho i Ganapathi [1985] zapropo nowali opisane w tym samym podrozdziale połączenie wydajnego dopasowywania szablonów drzewiastych z algorytmem programowania dynamicznego. Tjiang [1986] jest autorem implementacji języka opisu generatorów kodu nazwanego Twig, oparte go na schematach translacji drzew z p. 9.12. Kron [1975], Huet i Levy [1979] oraz Hoffman i 0 ' D o n n e l l [1982] opisali ogólne algorytmy dla dopasowywania szablonów drzewiastych. Podejście Grahama-Glanville'a do generacji kodu, korzystające z analizatorów LR do wyboru rozkazu, opisali i ocenili Glanville [1977], Glanville i Graham [1978], Graham
[1980 i 1984], Henry [1984] oraz Aigrain i in. [1984]. Ganapathi [1980] oraz Ganapathi i Fischer [1982] użyli gramatyk atrybutowych do specyfikowania i implementacji generatorów kodu. Inne techniki automatyzowania budowy generatorów kodu zaproponowali Fraser [1977], Cattell [1980] oraz Leverett i in. [1980]. Przenoszenie kompilatorów na in ne maszyny opisał również Richards [1971 i 1977]. Szymański [1978] oraz Leverett i Szymański [1980] przedstawili techniki łączenia skoków o różnej długości. Yannakakis [1985] przedstawił algorytm dla ćwiczenia 9.19, działający w czasie wielomianowym.
ROZDZIAŁ
Optymalizacja kodu
Byłoby ideałem, gdyby kompilatory produkowały kod wynikowy tak dobry, jak pisany ręcznie. Można to jednak osiągnąć tylko w niektórych przypadkach i nie bez problemów. Jednakże kod produkowany przez proste algorytmy kompilacji często może być zmienio ny tak, by działał szybciej, zajmował mniej miejsca lub jednocześnie miał obie zalety. Taką poprawę można osiągnąć przez przekształcenia programu, tradycyjnie nazywane optymalizacją, chociaż słowo „optymalizacja" nie jest właściwe, ponieważ rzadko ma my gwarancję, że otrzymany kod jest najlepszym możliwym. Kompilatory, które stosują przekształcenia poprawiające kod, są nazywane kompilatorami optymalizującymi. W tym rozdziale zajęliśmy się głównie optymalizacjami niezależnymi od maszyny, czyli przekształceniami programu, które nie uwzględniają właściwości maszyny docelo wej. Optymalizacje zależne od maszyny, takie jak alokacja rejestrów i używanie specjal nych sekwencji instrukcji maszyny (idiomów maszyny), opisaliśmy w rozdz. 9. Największy zysk osiągamy najmniejszym kosztem wtedy, kiedy możemy zidenty fikować często wykonywane części programu i uczynić te fragmenty tak efektywny mi, jak to tylko możliwe. Istnieje popularne powiedzenie, że większość programów wy korzystuje dziewięćdziesiąt procent czasu wykonania na dziesięć procent kodu. Choć faktyczne liczby mogą być inne, często zdarza się, że mały fragment programu odpo wiada za znaczącą część czasu działania. Użycie programu profilującego do zbadania czasu działania programu na reprezentatywnych danych wejściowych dokładnie wska zuje często odwiedzane fragmenty programu. Niestety, kompilator nie ma dostępu do przykładowych danych wejściowych, musi więc próbować „zgadnąć", gdzie znajdują się istotne fragmenty. W praktyce, dobrymi kandydatami do poprawek są wewnętrzne pętle programu. W języku, w którym wyróżnione są instrukcje sterowania, takie jak while czy for, poło żenie pętli łatwo można poznać na podstawie składni programu; w ogólności identyfika cją pętli w grafie przepływu programu zajmuje się proces nazywany analizą przepływu sterowania. Ten rozdział jest rogiem obfitości pełnym użytecznych przekształceń optymalizują cych i technik ich implementacji. Najlepszą techniką podejmowania decyzji, które trans formacje warte są wstawienia do kompilatora, jest sporządzenie statystyki programów źródłowych i ocena zysku z użycia danego zestawu optymalizacji na reprezentatywnej
próbce rzeczywistych programów źródłowych. W rozdziale 12 opisaliśmy przekształce nia, które sprawdziły się w kompilatorach optymalizujących dla kilku różnych języków. Jednym z tematów tego rozdziału jest analiza przepływu danych, czyli proces zbie rania informacji o tym, jak w programie są używane zmienne. Informacje zebrane w róż nych punktach programu mogą być powiązane przy użyciu prostego zbioru równań. Przedstawiamy kilka algorytmów służących do zbierania informacji przy użyciu ana lizy przepływu danych i do skutecznego wykorzystania tych informacji w optymalizacji. Rozważamy również wpływ konstrukcji języka, takich jak procedury i wskaźniki, na optymalizację. W ostatnich czterech podrozdziałach podaliśmy bardziej złożony materiał. Obejmuje on pewne pojęcia teoriografowe, istotne w analizie przepływu sterowania, i zastosowanie tych pojęć do analizy przepływu danych. Rozdział zakończyliśmy opisem uniwersalnych narzędzi służących do analizy przepływu danych i technik debugowania zoptymalizo wanego kodu. Nacisk położyliśmy na metody optymalizacji, które mają zastosowanie do wielu języków. Kilka kompilatorów używających tych technik jest opisanych w rozdz. 12.
10.1
Wprowadzenie
Aby stworzyć wydajny program wynikowy, programista potrzebuje więcej niż tylko kom pilatora optymalizującego. W tym podrozdziale przedstawiliśmy możliwości — progra misty i kompilatora — pozwalające tworzyć efektywne programy wynikowe. Opisaliśmy rodzaje przekształceń poprawiających kod, które powinne być użyte przez programistę i autora kompilatora, do poprawienia wydajności programu. Rozważyliśmy również re prezentację programu, na której przekształcenia będą stosowane. Kryteria stosowania przekształceń poprawiających kod Najprościej rzecz ujmując, najlepsze przekształcenia to te, które przynoszą największe zy ski najmniejszym kosztem. Transformacje obsługiwane przez kompilator optymalizujący powinny charakteryzować się kilkoma cechami. Po pierwsze, przekształcenia muszą zachowywać znaczenie programu, czyli „opty malizacja" nie może zmieniać wyjścia programu dla danego wejścia bądź powodować błędu, np. dzielenia przez zero, którego nie było w pierwotnej wersji programu źródło wego; zawsze wybieramy „bezpieczne" podejście, tracąc możliwość zastosowania prze kształcenia, lecz nie ryzykując zmiany wartości obliczanych przez program. Po drugie, przekształcenie musi, dla średniego przypadku, wymiernie przyspieszać program. Czasem jesteśmy zainteresowani zmniejszeniem objętości generowanego kodu, chociaż wielkość kodu m a obecnie coraz mniejsze znaczenie. Oczywiście, nie każde przekształcenie poprawi każdy program, i zdarza się, że „optymalizacja" trochę spowolni program, choć w średnim przypadku program jest przyspieszany. Po trzecie, przekształcenie musi być warte wysiłku. Nie ma sensu, by twórca kompi latora zajmował się implementacją przekształcenia poprawiającego kod i kompilator tracił czas na kompilowanie programów źródłowych, jeżeli wysiłek ten nie zostanie docenio ny podczas wykonywania programów wynikowych. Niektóre przekształcenia lokalne czy
optymalizacje „przez szparkę", podobne do opisywanych w p. 9.9, są na tyle proste i wystarczająco korzystne, że mogą być dodane do każdego kompilatora. Pewne przekształcenia mogą być zastosowane jedynie po szczegółowej, często czaso chłonnej analizie kodu źródłowego, nie ma więc sensu stosowanie ich do programów, któ re zostaną uruchomione tylko kilka razy. Przykładowo, szybki, nieoptymalizujący kompi lator będzie bardziej użyteczny podczas debugowania lub do „programów studenckich", które zostaną wykonane kilka razy, a potem będą skasowane. Jedynie w przypadku, gdy rozważany program wykorzystuje znaczącą część czasu maszyny, polepszona jakość kodu usprawiedliwia dodatkowy czas spędzony na uruchamianie kompilatora optymalizującego program. U z y s k i w a n i e lepszej wydajności Ogromna poprawa czasu wykonywania programu — przykładowo, zmniejszenie z kilku godzin do kilku sekund — j e s t zazwyczaj wynikiem poprawiania programu na wszystkich poziomach, od programu źródłowego do kodu wynikowego, co pokazano na rys. 10.1. Na każdym poziomie dostępne możliwości mieszczą się między dwoma ekstremami: znalezieniem lepszego algorytmu i implementacją używanego algorytmu, tak aby wyko nywanych było mniej operacji.
Kod źródłowy
Przód kompilatora
Kod pośredni
Kompilator może poprawić pętle wywołania procedur obliczenia adresów
Użytkownik może profilować program zmienić algorytm przekształcić pętle
Generator kodu
Kod wynikowy
Kompilator może używać rejestrów wybierać rozkazy robić przekształcenia „przez szparkę"
Rys. 10.1. Miejsca możliwych poprawek wykonywanych przez użytkownika i kompilator Przeróbki algorytmu czasem przynoszą spektakularne zmiany czasu działania. Bentley [1982] napisał, na przykład, że czas działania programu sortującego W elementów skrócił się z 2,02JV mikrosekund do \2N\og N mikrosekund, gdy starannie zakodowa ny algorytm „insertion sort" został zastąpiony „ąuicksort" . Dla N = 100 zmiana przy spieszyła program 2,5-krotnie. Dla N = 100000 poprawa była dużo większa: zmiana przyspieszyła program ponad tysiąckrotnie. Niestety, żaden kompilator nie m o ż e znaleźć najlepszego algorytmu dla danego pro blemu. Czasem jednak kompilator może zastąpić pewną sekwencję operacji sekwencją algebraicznie równoważną, w ten sposób znacząco zmniejszając czas wykonania progra mu. Takie oszczędności są częstsze, gdy przekształcenia są stosowane do programów w językach bardzo wysokiego poziomu, np. językach zapytań baz danych (patrz Ullman [1982]). 2
2
1
1
U Aho, Hopcrofta i Ullmana [1983] można znaleźć opis tych algorytmów sortujących i ich szybkości.
W tym i następnym podrozdziale, do pokazania efektów przekształceń poprawiają cych kod będziemy używać programu sortującego, nazywanego ąuicksort. Program w j ę zyku C na rys. 10.2 pochodzi od Sedgewicka [1978], który opisał ręczne optymalizowanie takiego programu. Nie będziemy się tu zajmować algorytmicznymi właściwościami tego programu — w rzeczywistości, aby program działał, a [ 0 ] musi zawierać najmniejszy, a a [ m a x ] — największy z sortowanych elementów. voicl ąuicksort (m, n) int m,n;
{ int i, j; int v, x; if (n <= m) return; / * początek fragmentu * / i = m-1; j = n; v ~ a[n]; while(1) { do i = while ( a[i] < v ) ; do j = j-1; while ( a[j] > v ) ; if (i >= j) break; x = a [i ]; a [i] = a [ j ] ; a [ j] = x;
} x = a [ i ] ; a [ i ] = a [ n ] ; a [ n ] = x; /*
koniec fragmentu
*/
ąuicksort(m,j); ąuicksort(i+1,n);
} Rys. 10.2. Kod programu ąuicksort w C
Czasami pewnych przekształceń poprawiających kod nie da się zastosować na pozio mie programu źródłowego. Na przykład w takich językach, jak Pascal i Fortran, progra mista może odwoływać się do elementów tablicy tylko w normalny sposób, np. b [ i , j ] . Na poziomie kodu pośredniego mogą jednak pojawić się nowe możliwości poprawienia programu. Kod trójadresowy daje wiele możliwości poprawienia wyliczania adresów, zwłaszcza w pętlach. Rozpatrzmy kod trójadresowy wyliczający wartość a [ i ] , przyj mując, że każdy element tablicy zajmuje cztery bajty t
i :
=4*i;
t :=a[t!] 2
Naiwny kod pośredni będzie wyliczał 4 * i za każdym razem, gdy a [ i ] wystąpi w pro gramie źródłowym, a programista nie będzie miał żadnej kontroli nad niepotrzebnymi obliczeniami adresu, ponieważ są one ukryte w implementacji języka, a nie umieszczone jawnie w kodzie pisanym przez użytkownika. W takich przypadkach tylko kompilator może poprawić sytuację. W językach takich jak C, przekształcenie może być wykonane przez programistę, ponieważ odwołania do elementów tablicy — aby uczynić j e bar dziej efektywnymi — mogą być przepisane z użyciem wskaźników. Takie przepisywanie jest podobne do przekształcenia, które tradycyjnie wykonują optymalizujące kompilatory Fortranu.
Na poziomie kodu wynikowego kompilator jest odpowiedzialny za dobre wykorzy stanie zasobów maszyny. Przykładowo, przechowywanie najczęściej używanych zmien nych w rejestrach może znacząco zmniejszyć czas wykonywania programu, często aż o połowę. C pozwala programiście doradzić kompilatorowi, które zmienne powinny być umieszczone w rejestrach, lecz większość języków nie ma tej możliwości. Podobnie, kompilator może znacząco przyspieszyć programy poprzez wybór rozkazów, które wy korzystują tryby adresowania maszyny, pozwalające na wykonanie w jednym rozkazie tego, co oczekiwalibyśmy, że powinno zająć dwa lub trzy, jak pisaliśmy w rozdz. 9. Nawet gdy programista może zoptymalizować kod, wygodniejsze może być pozo stawienie niektórych poprawek kompilatorowi. Jeżeli można zaufać kompilatorowi, że wygeneruje efektywny kod, użytkownik może się skoncentrować na pisaniu przejrzyste go kodu. Struktura kompilatora optymalizującego Jak wspomnieliśmy, istnieje kilka poziomów, na których program może być poprawiany. Ponieważ techniki potrzebne do analizy i przekształcania programu nie zmieniają się zna cząco wraz z poziomem, w tym rozdziale koncentrujemy się na przekształceniach kodu pośredniego, używając struktury z rys. 10.3. Faza poprawiania kodu składa się z anali zy przepływu sterowania i danych, po których następuje zastosowanie przekształcenia. Generator kodu, opisany w rozdz. 9, produkuje kod wynikowy z przerobionego kodu pośredniego.
Przód i kompilatora -'.
>
Analiza przepływu sterowania
.<
Optymalizator kodu
Analiza przepływu danych
Przekształcenia
Rys. 10.3. Struktura kompilatora optymalizującego
Dla ułatwienia przyjmujemy, że kod pośredni składa się z instrukcji trójadresowych. Kod pośredni, taki jak produkowany przez metody opisane w rozdz. 8, dla fragmentu programu z rys. 10.2 jest przedstawiony na rys. 10.4. W przypadku innej reprezentacji pośredniej, tymczasowe zmienne tj, t , . . ., t z rys. 10.4 nie muszą wystąpić jawnie, co opisano w rozdz. 8. Struktura z rysunku 10.3 ma następujące zalety: 2
1.
15
Operacje potrzebne do zaimplementowania konstrukcji wysokiego poziomu są jawne w kodzie pośrednim, więc można je optymalizować. Na przykład obliczanie adresu a [ i ] jest na rys. 10.4 jawne, więc ponowne obliczanie wyrażeń typu 4*i może być wyeliminowane, co opisaliśmy w następnym podrozdziale.
(1)
(16) (17) (18) (19) (20) (21) (22) (23) (24) (25) (26) (27) (28) (29) (30)
i - m-1 j =n tl = 4*n V - a[tj] i = i+1 t = 4*i = a[t ] < v goto (5) if
(2) (3) (4) (5) (6) (7) (8) (9) = j - i j t = 4*j (10) t •= a[t ] (11) > v goto (9) (12) if (13) if i >= j goto (23) := 4*i (14) X := a[t ] (15) 2
2
4
5
4
6
t t
7 8
= 4*i = 4*j
=a[t ] 8
a[t ] 7
=
t
9
= 4*j a[t ] = X goto (5) = 4*i X = a[t = 4*i l2 = 4*n l3 = a[t a[t ] = t = 4*n a[t ] . = X 10
n
t
t
13
12
14
15
Rys. 10.4. Kod trójadresowy dla fragmentu z rys. 10.2 Kod pośredni może być (względnie) niezależny od maszyny docelowej, więc opty malizator nie musi się bardzo zmieniać, gdy generator kodu jest zastępowany genera torem dla innej maszyny. Kod pośredni z rys. 10.4 zakłada, że każdy element tablicy zajmuje cztery bajty. Niektóre reprezentacje kodu pośredniego, np. P-code dla Pasca la, pozostawiają generatorowi kodu wypełnienie rozmiaru elementów tablicy, a kod pośredni jest niezależny od rozmiaru słowa maszyny. W naszym kodzie pośrednim moglibyśmy osiągnąć ten sam efekt, gdybyśmy zastąpili 4 stałą symboliczną.
2.
W optymalizatorze kodu programy są reprezentowane przez grafy przepływu, w któ rych krawędzie wskazują przepływ sterowania, a wierzchołki reprezentują bloki bazowe, opisane w p . 9.4. Jeżeli nie jest podane inaczej, przez program jest rozumiana pojedyn cza procedura. W podrozdziale 10.8 zajęliśmy się optymalizacjami działającymi na wielu procedurach jednocześnie. P r z y k ł a d 10.1. Na rysunku 10.5 przedstawiono graf przepływu dla programu z rys. 10.4. B jest wierzchołkiem początkowym. Wszystkie warunkowe i bezwarunkowe skoki do in strukcji z rys. 10.4 zostały zastąpione na rys. 10.5 skokami do bloków, zaczynających się od tych instrukcji. Na rysunku 10.5 znajdują się trzy pętle; B i # same są pętlami. Bloki # , Z? , B i B tworzą pętle z wejściem przez B . • {
2
5
10.2
3
2
3
Ą
2
Podstawowe źródła optymalizacji
W tym podrozdziale przedstawiliśmy kilka z najbardziej użytecznych przekształceń po prawiających kod. Przekształcenie programu jest nazywane lokalnym, jeśli można j e wy konać, rozpatrując instrukcje tylko z pojedynczego bloku bazowego; w innym przypadku jest nazywane globalnym. Wiele przekształceń można wykonać zarówno na poziomie lo kalnym, jak i na poziomie globalnym. Transformacje lokalne są zazwyczaj wykonywane jako pierwsze.
Przekształcenia zachowujące funkcję Jest wiele metod, którymi kompilator może poprawić program, nie zmieniając funk cji, którą oblicza. Usuwanie podwyrażeń wspólnych, propagacja kopii, usuwanie kodu martwego i zwijanie stałych są często spotykanymi przykładami takich przekształceń za chowujących funkcję. W podrozdziale 9.8, o reprezentacji bloków bazowych z użyciem dagów, pokazaliśmy, jak lokalne podwyrażenia wspólne mogą zostać usunięte podczas budowania daga dla bloku bazowego. Pozostałe przekształcenia pojawiają się głównie podczas wykonywania optymalizacji globalnych; opisaliśmy j e poniżej. Program zawiera często wiele obliczeń takiej samej wartości, na przykład przesu nięcia w tablicy. Jak wspomnieliśmy w p. 10.1, niektóre z tych powtórzeń nie mogą być usunięte przez programistę, ponieważ pojawiają się na poziomie niższym niż dostępny z języka źródłowego. Przykładowo, blok B z rys. 10.6(a) wielokrotnie oblicza 4*i oraz 4* j . 5
B<
:= 4*i -
B.
a[t : := 4*i := 4*j t := a [ t ] a [ t ] := t t i o •= * J a [ t ] := x goto B 6
9
t := x := t := t := a[t ] a[t ] goto 6
8
8
7
9
9
4
6
8
1 0
2
(a) Przed Rys.
4*i a[t ] 4*j a[t := tę := x B 6
8
2
(b) Po
10.6. Lokalne usuwanie podwyrażeń wspólnych
P o d w y r a ż e n i a wspólne Wystąpienie wyrażenia E jest nazywane p o d w y r a ż e n i e m w s p ó l n y m , jeśli E było wcze śniej obliczone i wartości zmiennych z E nie zmieniły się po poprzednim obliczeniu. Możemy uniknąć ponownego wyliczania wyrażenia, jeżeli możemy użyć poprzednio ob liczonej wartości. Przykładowo, na rys. 10.6(a) do t i t są przypisane, odpowiednio, wspólne podwyrażenia 4*i oraz 4* j . Jest to pominięte na rys. 10.6(b), dzięki użyciu t zamiast t oraz t zamiast t . Taką zmianę otrzymalibyśmy po zrekonstruowaniu kodu pośredniego z daga dla bloku bazowego. 7
6
7
8
1 0
1 0
P r z y k ł a d 10.2. Na rysunku 10.7 przedstawiono wynik zarówno globalnej, jak i lokalnej eliminacji podwyrażeń wspólnych z bloków B oraz B w grafie przepływu z rys. 10.5. Najpierw opisaliśmy przekształcenie 5 , a następnie wspomnieliśmy o kilku subtelno ściach związanych z tablicami. Po lokalnym usunięciu podwyrażeń wspólnych B ciągle oblicza 4*i oraz 4* j , co widać na rys. 10.6(b). Są to dwa wystąpienia podwyrażeń wspólnych; w szczególności trzy instrukcje 5
6
5
5
t :=4*j;
t :=a[t ]; 9
8
a[t ]:=x
8
8
w B można zastąpić 5
t :=a[t ] ; 9
4
a [ t ] :=x 4
używając t obliczonego w bloku B Na rysunku 10.7 widać, że gdy sterowanie prze chodzi z wyliczenia 4*j w B do B , wartość j się nie zmienia, więc t może być użyte, gdy potrzebna jest wartość 4* j . Kolejne podwyrażenie wspólne pojawia się w B po zastąpieniu t przez t . Nowe wyrażenie a [ t ] odpowiada wartości a [ j ] na poziomie źródeł. Nie tylko j nie zmienia się, gdy sterowanie opuszcza B i wchodzi do B , stała pozostaje również wartość a [ j ] , zapamiętana w tymczasowej zmiennej t , ponieważ w międzyczasie nie ma przypisań do elementów tablicy. Instrukcje 4
y
3
5
4
5
4
3
5
5
g
4
w B
5
mogą być dzięki temu zastąpione a [ t ] :=t 6
5
Analogicznie, wartość przypisana do x w bloku B na rys. 10.6 jest taka sama, jak wartość przypisana do t w bloku B . Blok B na rys. 10.7 jest wynikiem usunięcia podwyrażeń wspólnych odpowiadających wartościom wyrażeń a [ i ] i a [ j ] w języku źródłowym z B z rys. 10.6(b). Podobny ciąg przekształceń jest wykonany na B na rys. 10.7. Wyrażenie a [t ] w blokach B i B na rys. 10.7 nie jest uważane za podwyrażenie wspólne, chociaż t może być używane w obu przypadkach. Po wyjściu sterowania z B , i przed przekazaniem go do B , może się ono znaleźć w 5 , gdzie znajdują się przypisania do a. Wobec tego a [t ] może po wejściu do B mieć inną wartość niż przy wyjściu z B i nie jest bezpiecznie uważać a [t ] za podwyrażenie wspólne. • 5
2
3
5
5
6
x
x
6
x
x
6
5
x
x
6
x
P r o p a g a c j a kopii Blok B z rysunku 10.7 można lepiej poprawić, usuwając x przy użyciu dwóch no wych przekształceń. Jedno zajmuje się przypisaniami o postaci f: = g , nazywanymi in strukcjami kopiowania lub kopiami. Jeśli dokładniej rozpatrzyć przykład 10.2, kopie 5
napotkalibyśmy znacznie wcześniej, ponieważ wprowadza j e algorytm usuwania podwy rażeń wspólnych, podobnie jak kilka innych algorytmów. Przykładowo, gdy podwyrażenie wspólne w c : = d + e na rys. 10.8 jest usuwane, algorytm używa nowej zmiennej t do przechowania wartości d + e . Ponieważ sterowanie może dojść do c : = d + e po przypi saniu do a albo po przypisaniu do b , nie byłoby poprawne zastąpienie c : = d + e przez c : - a ani c : = b .
= d+e t
d+e
= d+e
:
t d+e b := t
d+e
Rys. 10.8. Kopie wprowadzone podczas usuwania podwyrażeń wspólnych
Pomysłem, na którym oparte jest przekształcenie, nazywane propagacją kopii, jest używanie — gdy jest to możliwe — g zamiast f po instrukcji o postaci f : = g . Przykła dowo, przypisanie x : = t w bloku B z rys. 10.7 jest kopią. W wyniku propagacji kopii zastosowanej do B otrzymujemy 3
5
5
x: =t a[t ] a[t ] goto 2
4
3
:=t :=t B
5
(10.1)
3
2
Ta operacja może nie wyglądać na poprawiającą, ale — jak się przekonamy — pozwoli ona usunąć przypisanie do x. Usuwanie kodu martwego Zmienna jest żywa w danym punkcie programu, jeżeli jej wartość może zostać użyta; w przeciwnym wypadku jest ona martwa w tym punkcie. Podobnym pojęciem jest kod martwy lub bezużyteczny, instrukcje wyliczające wartości, które nigdy nie zostaną użyte. Chociaż programista, prawdopodobnie, nie będzie tworzył kodu martwego, może on powstać jako rezultat wcześniejszych przekształceń. W podrozdziale 9.9, na przykład, opisaliśmy użycie d e b u g , któremu nadano wartość prawda albo fałsz w różnych punktach programu, i używanego w instrukcjach o postaci if
(debug)
print
. . .
(10.2)
Z analizy przepływu danych można wywnioskować, że za każdym razem, gdy program będzie wykonywał tę instrukcję, wartością d e b u g będzie fałsz. Zazwyczaj jest to spo wodowane instrukcją debug:=false z której możemy wywnioskować, że jest ostatnim przypisaniem do zmiennej d e b u g przed testem w (10.2), niezależnie od przepływu sterowania w programie. Jeżeli pro pagacja kopii zastąpi d e b u g przez f a l s e , instrukcja print jest martwa, ponieważ nie
może zostać wykonana. Możemy usunąć test i drukowanie z kodu wynikowego. Wywnio skowanie w trakcie kompilacji, że wartość wyrażenia jest stała, i użycie stałej zamiast wyrażenia, jest nazywane zwijaniem stałych. Zaletą propagacji kopii jest to, że instrukcje kopiowania są często przekształcane w kod martwy. Propagacja kopii, na przykład, po której następuje eliminacja kodu mar twego, usuwa przypisanie do x i zmienia (10.1) w
a[t ] :=t a[t ] :=t goto B 2
5
4
3
2
Ten kod jest poprawioną wersją kodu z bloku B z rys. 10.7. 5
Optymalizacje pętli Potrzebne jest krótkie wprowadzenie na temat bardzo istotnego miejsca optymalizacji — pętli, szczególnie pętli wewnętrznych, w których programy spędzają zazwyczaj więk szość czasu. Czas działania programu może być poprawiony, jeżeli zmniejszymy liczbę instrukcji w pętli wewnętrznej, nawet kosztem zwiększenia ilości kodu na zewnątrz tej pętli. Ważne są trzy techniki optymalizacji pętli: przemieszczenie kodu, które przesuwa kod na zewnątrz pętli; eliminacja zmiennych indukcyjnych, którą stosujemy do usuwania i oraz j z wewnętrznych pętli B oraz B z rys. 10.7; redukcja mocy, która zastępuje drogie operacje tańszymi, np, mnożenie dodawaniem. 2
3
Przemieszczenie kodu Ważną modyfikacją zmniejszającą ilość kodu w pętli jest przemieszczenie kodu. Ta trans formacja pobiera wyrażenie, które zwraca taką samą wartość, niezależnie od ilości wy konań pętli (obliczenie niezmiennicze ze względu na pętle) i umieszcza to wyrażenie przed pętlą. Zwróćmy uwagę, że pojęcie „przed pętlą" zakłada istnienie wejścia do pętli. Obliczenie limit-2, na przykład, jest niezmiennicze w następującej instrukcji while: while
( i <= limit-2 ) / * instrukcja nie zmienia limit */
Wynik przemieszczenia kodu będzie zbliżony do
t - limit-2; while
( i <= t )
/ * instrukcja nie zmienia limit ani t */
Zmienne indukcyjne oraz redukcja mocy Chociaż przemieszczenia kodu nie można zastosować do przykładowego programu ąuick sort, można wykorzystać dwa pozostałe przekształcenia. Pętle są zazwyczaj rozpatrywane od wewnętrznych do zewnętrznych. Rozważmy n a przykład pętle wokół By Na rysun ku 10.9 są przedstawione tylko istotne dla przekształceń B fragmenty grafu przepływu. Zauważmy, że wartości j oraz t pozostają ściśle związane; za każdym razem gdy wartość j zmniejsza się o 1, wartość t zmniejsza się o 4, ponieważ d o t przypisujemy 4* j . Takie identyfikatory są nazywane zmiennymi indukcyjnymi. 3
4
4
4
i j
B
i := m-1 j := n :=
V
4*n
u
:= a[t,]
V
B
= m-1 - n := 4*n = a[tj] := 4*j Bi
2
B
3
j := t := t := if t 4
5
5
j t t -4 t := a[t ] if t > v goto B
j-1
4*j a[t > v goto #3
4
4
4
5
3
5
B
4
if i >= : goto Bd B,
B<
(b) Po
(a) Przed
Rys. 10.9. Redukcja mocy zastosowana do 4* j w bloku B
3
Jeżeli w pętli występują dwie zmienne indukcyjne lub więcej, można wyrzucić wszystkie z wyjątkiem jednej, używając procesu nazwanego eliminacją zmiennych in dukcyjnych. W wewnętrznej pętli wokół B na rys. 10.9(a) nie możemy usunąć j lub t — t jest używane w By a j w B . Możemy jednak pokazać redukcję mocy i część procesu usuwania zmiennych indukcyjnych; j zostanie w końcu usunięte, gdy będzie rozpatrywana zewnętrzna pętla B -B . 3
4
Ą
4
2
Przykład
5
10.3. Ponieważ związek t =4* j z pewnością jest zachowany po takim przy pisaniu do t jak na rys. 10.9(a) i t nie jest zmieniane w innym miejscu pętli wewnętrz nej wokół i? , z pewnością tuż po instrukcji j:=j-l musi być spełniona zależność t =4*j-4. Możemy więc zastąpić przypisanie t :=4*j przez t :=t -4. Jedynym problemem jest to, że t nie ma wartości przy pierwszym wejściu do bloku By Po nieważ zależność t =4* j musi być spełniona przy wejściu do bloku By umieszczamy inicjowanie t na końcu bloku, w którym inicjowane jest samo j, co zaznaczono na rys. 10.9(b) przerywaną linią dodaną do bloku B . Zastąpienie mnożenia odejmowaniem przyspieszy program wynikowy, jeśli mno żenie zabiera więcej czasu niż dodawanie i odejmowanie, co jest prawdą w przypadku wielu maszyn. • 4
4
4
3
4
4
4
4
4
4
4
{
W podrozdziale 10.7 opisaliśmy, jak można wykrywać zmienne indukcyjne i ja kie przekształcenia można zastosować. Teraz podajemy jeszcze jeden przykład usuwania zmiennych indukcyjnych, w którym i oraz j są obsługiwane w kontekście pętli ze wnętrznej zawierającej B , By B i By 2
Ą
P r z y k ł a d 10.4. Po zastosowaniu redukcji mocy do pętli wewnętrznych wokół B i B i oraz j są używane wyłącznie do wykonania testu w bloku B . Wiemy, że wartości i oraz t spełniają zależność t =4*i, a j i t spełniają zależność t =4* j, więc test t > = t jest równoważny testowi i>=j. Po wykonaniu takiej zmiany, i w bloku B oraz j w bloku 5 stają się zmiennymi martwymi; również przypisania do nich w tych blokach są martwe i mogą być usunięte (rys. 10.10). • 2
v
Ą
2
2
2
4
4
2
4
3
Rys. 10.10. Graf przepływu po usunięciu zmiennych indukcyjnych
Przekształcenia poprawiające kod były skuteczne. Na rysunku 10.10 liczba instrukcji w blokach B i £ zmniejszyła się -— względem pierwotnego grafu przepływu z rys. 10.5 — z 4 do 3, w B zmiej szyła się z 9 do 3, a w B z 8 do 3. Długość B zwiększyła się wprawdzie z 4 instrukcji do 6, ale w tym fragmencie blok B jest wykonywany tylko raz, więc ma niewielki wpływ na czas wykonania fragmentu. 2
3
5
6
{
x
10.3
Optymalizacja bloków bazowych
W rozdziale 9 przedstawiliśmy kilka przekształceń poprawiających kod w blokach bazo wych. Były to m.in. przekształcenia zachowujące strukturę, takie jak usuwanie podwy-
rażeń wspólnych i usuwanie kodu martwego oraz przekształcenia algebraiczne, takie jak redukcja mocy. Wiele z przekształceń zachowujących strukturę może być zaimplementowanych przez zbudowanie daga dla bloku bazowego. Przypomnijmy, że w dagu są wierzchołki dla war tości początkowych wszystkich zmiennych występujących w bloku bazowym i że dla każdej instrukcji s z bloku istnieje wierzchołek n. Dziećmi wierzchołka n są wierzchołki odpowiadające ostatnim przed s definicjom argumentów s. Etykietą wierzchołka n jest operator użyty w s, ponadto jest do niego dołączona lista zmiennych, dla których jest to ostatnia definicja w bloku. Istotne są wierzchołki, których wartości są żywe przy wyjściu z bloku, jeśli takie istnieją; są to wierzchołki wyjściowe. Wspólne podwyrażenia można wykryć, sprawdzając — przy dodawaniu nowego wierzchołka m — czy istnieje już wierzchołek n, mający takie same dzieci, w tej samej kolejności i z tym samym operatorem. Jeśli tak, n oblicza tę samą wartość co m i może być użyte zamiast niego.
P r z y k ł a d 10.5.
Dag dla bloku (10.3)
a. = b + c b : =a-d c : =h+c d: =a-d
(10.3)
jest pokazany na rys. 10.11. Gdy budujemy wierzchołek dla trzeciej instrukcji, c : = b + c , wiemy, że b w b + c odnosi się do wierzchołka oznaczonego - z rys. 10.11, ponieważ tam znajduje się najnowsza definicja b . Nie możemy więc mylić wartości obliczonych w instrukcjach pierwszej i trzeciej.
b
c 0
o
Rys. 10.11. Dag dla bloku bazowego (103)
Zauważmy jednak, że wierzchołek odpowiadający czwartej instrukcji, d: =a-d ma operator - oraz wierzchołki etykietowane a i d jako dzieci. Ponieważ operator i dzieci są takie same, jak w wierzchołku odpowiadającym instrukcji drugiej, nie tworzymy nowego wierzchołka, lecz dodajemy d do listy definicji wierzchołka etykietowanego - . • 0
Może się wydawać, że blok (10.3) może zostać zastąpiony blokiem z tylko trzema instrukcjami, skoro w dagu z rys. 10.11 są tylko trzy wierzchołki. Faktycznie, jeśli b albo d nie jest żywe przy wyjściu z bloku, nie potrzebujemy wyliczać wartości tej zmiennej
i wartość reprezentowaną przez wierzchołek etykietowany - z rys. 10.11 możemy za pamiętać w drugiej zmiennej. Na przykład, gdy b nie jest żywe przy wyjściu, możemy napisać
a:=b+c ci:-a-d c: =d+c Jeżeli jednak zarówno b, jak i d są żywe przy wyjściu z bloku, potrzebna jest czwarta instrukcja, kopiująca wartość z jednej zmiennej do drugiej . Zauważmy, że gdy szukamy wspólnych podwyrażeń, szukamy wyrażeń obliczają cych zawsze tę samą wartość, nie zważając, jak ta wartość jest obliczana. Wobec tego, korzystając z dagów, nie zauważymy, że wyrażenia obliczane przez pierwszą i czwartą instrukcję w sekwencji 1
a: =b+c b
:
=
b
^
(10.4)
e:=b+c są takie same, równe b+c. Możemy jednak zastosować tożsamości algebraiczne do da gów, co może wykazać ich równoważność. Dag dla tej sekwencji jest przedstawiony na rys. 10.12.
Operacje na dagach odpowiadające usuwaniu kodu martwego są stosunkowo proste w implementacji. Usuwamy z daga każdy korzeń (wierzchołek bez przodków), który nie zawiera zmiennych żywych. Powtarzanie tej operacji usunie z daga wszystkie wierzchołki odpowiadające kodowi martwemu.
Zwykle musimy uważać, gdy odtwarzamy kod z dagów, i ostrożnie wybierać nazwy zmiennych odpowiada jących wierzchołkom. Jeśli zmienna x jest definiowana dwukrotnie lub jeśli jest jej przypisywana wartość, a początkowa wartość x jest również używana, musimy upewnić się, że nie zmienimy wartości x przed użyciem jej wcześniejszej wartości. 0
Użycie tożsamości algebraicznych Tożsamości algebraiczne są kolejną istotną klasą optymalizacji bloków bazowych. W pod rozdziale 9.9 wprowadziliśmy pewne proste przekształcenia algebraiczne, których można próbować podczas optymalizacji. Możemy na przykład stosować tożsamości algebraiczne, takie jak
x+0=0+x=x x-0=x x*l=l*x=x x/l=x Inna klasa optymalizacji algebraicznych zawiera redukcję mocy, czyli zastępowanie droższego operatora tańszym, j a k w
x**2=x*x 2.0*x=x+x x/2=x*0.5 Trzecią klasą podobnych optymalizacji jest składanie stałych. Obliczamy wartości wyrażeń stałych podczas kompilacji i zastępujemy j e ich wartościami . Wówczas wy rażenie 2*3.14 będzie zastąpione 6.28. Wiele wyrażeń stałych powstaje w wyniku używania stałych symbolicznych. Proces budowy daga może nam pomóc w zastosowaniu tych i innych, bardziej ogól nych, przekształceń algebraicznych, takich jak przemienność i łączność. Załóżmy, na przykład, że * jest operacją przemienną, tzn. że x*y=y*x. Zanim stworzymy nowy wierzchołek etykietowany * z lewym dzieckiem m i prawym n, sprawdzamy, czy taki wierzchołek j u ż nie istnieje. Następnie sprawdzamy, czy istnieje wierzchołek z operato rem *, lewym dzieckiem n i prawym m. Operatory porównania < = , > = , < > , = oraz ^ czasem powodują powstanie nieoczekiwanych podwyrażeń wspólnych. Warunek x>y, na przykład, może być również sprawdzony przez odjęcie argumentów i sprawdzenie znaczników ustawionych przez odej mowanie. (Odejmowanie może dodatkowo wprowadzić nadmiar lub niedomiar, podczas gdy rozkaz porównania nie zrobiłby tego). W takim przypadku zostanie wygenerowany tylko jeden wierzchołek daga dla x-y oraz x>y. Również prawa łączności mogą być stosowane do wyszukiwania podwyrażeń wspól nych. Przykładowo, jeśli w kodzie źródłowym znajdują się przypisania 1
f
a: =b+c e:=c+d+b może zostać wygenerowany następujący kod pośredni:
a: =b+c t :=c+d e:=t+b 1
Wyrażenia arytmetyczne powinny być wyliczane w czasie kompilacji w ten sam sposób, co w czasie wyko nania. K. Thompson zaproponował eleganckie podejście do składania stałych: kompilować wyrażenie stałe, od razu wykonywać wygenerowany kod i zastępować wyrażenie wynikiem. Wtedy kompilator nie musi do datkowo zawierać interpretera.
Jeśli t nie jest potrzebne na zewnątrz danego bloku, możemy zmienić te sekwencje na
a:=b+c e:=a+d korzystając jednocześnie z łączności i przemienności +. Autor kompilatora powinien uważnie przeczytać specyfikację języka, aby spraw dzić, jakie zmiany w obliczeniach są dozwolone, ponieważ arytmetyka komputera nie zawsze zgadza się z matematycznymi tożsamościami algebraicznymi. Według standardu języka Fortran 77, na przykład, kompilator może stosować dowolne algebraicznie rów noważne wyrażenia, pod warunkiem nieprzestawiania istniejących nawiasów. To znaczy, że kompilator może obliczyć x * y - x * z jako x * ( y - z ) , ale nie może obliczyć a+ (b-c) jako (a+b) -c. Kompilator Fortranu musi więc pamiętać, gdzie znajdowały się nawiasy w wyrażeniach początkowych, jeśli chce optymalizować programy zgodnie z definicją języka.
10.4
Pętle w grafach przepływu
Zanim rozpatrzymy optymalizowanie pętli, musimy określić, co nazywamy pętlą w gra fie przepływu. Będziemy używali pojęcia wierzchołka „dominującego" inny, aby zdefi niować „pętle naturalne" i ważną, specjalną, klasę „redukowalnych" grafów przepływu. Algorytmy wyszukiwania dominatorów i sprawdzania redukowalności grafu podaliśmy w p. 10.9. Dominatory Mówimy, że wierzchołek d grafu przepływu dominuje nad wierzchołkiem n, co zapi sujemy d dom n, jeśli każda ścieżka z początkowego wierzchołka grafu przepływu do n przechodzi przez d. Według tej definicji każdy wierzchołek dominuje nad sobą sa mym i każde wejście do pętli (zdefiniowanej jak w p. 9.4) dominuje nad wszystkimi wierzchołkami w pętli. P r z y k ł a d 10.6. Rozważmy graf przepływu z rys. 10.13, z wierzchołkiem początkowym 1. Ten wierzchołek dominuje nad wszystkimi pozostałymi wierzchołkami. Wierzchołek 2 dominuje tylko nad sobą samym, ponieważ sterowanie może dojść do dowolnego in nego wierzchołka ścieżką rozpoczynającą się od 1 —> 3. Wierzchołek 3 dominuje nad wszystkimi wierzchołkami z wyjątkiem 1 i 2. Wierzchołek 4 dominuje nad wszystkimi z wyjątkiem 1, 2 i 3, ponieważ wszystkie ścieżki z 1 muszą zaczynać się od 1 -> 2 —> 3 -» 4 lub od 1 -> 3 -> 4. Wierzchołki 5 i 6 dominują tylko nad sobą, ponieważ sterowanie może ominąć dowolny z nich, idąc przez drugi. Wierzchołek 7 dominuje nad 7, 8, 9, 10; 8 dominuje nad 8, 9, 10; 9 i 10 dominują tylko nad sobą. • Użyteczną metodą przedstawiania dominatorów jest drzewo, nazywane drzewem do minatorów, w którym początkowy wierzchołek jest korzeniem, a każdy wierzchołek d dominuje tylko nad swoimi potomkami w drzewie. Na rysunku 10.14 jest drzewo domi natorów dla grafu przepływu z rys. 10.13.
Rys. 10.13. Graf przepływu
Rys. 10.14. Drzewo dominatorów dla grafu przepływu z rys. 10.13
Istnienie drzewa dominatorów wynika z własności dominatorów; każdy wierzcho łek n ma jednego dominatem bezpośredniego m, który jest ostatnim dominatorem n na dowolnej ścieżce z wierzchołka początkowego do n. W języku relacji dom, dominator bezpośredni m ma następującą własność: jeżeli d ^ n oraz d dom n, to d dom m. Pętle naturalne Ważnym zastosowaniem informacji o dominatorach jest wyznaczanie pętli w grafie prze pływu nadających się do poprawienia. Takie pętle mają dwie podstawowe własności. 1.
2.
Musi istnieć dokładnie jeden punkt wejścia do pętli, nazywany „wejściem" (ang. header). Ten wierzchołek musi dominować nad wszystkimi innymi wierzchołkami w pętli, gdyż w innym przypadku istniałyby inne punkty wejścia do pętli. Musi istnieć co najmniej jedna metoda wywołania iteracji pętli, tj. co najmniej jedna ścieżka powrotna do wejścia.
Dobrą metodą wyszukania wszystkich pętli w grafie przepływu jest znalezienie wszystkich krawędzi w grafie przepywu, których koniec dominuje nad początkiem. (Jeśli a-^tb jest krawędzią, to b jest końcem, a a początkiem). Takie krawędzie nazywamy krawędziami „do tyłu" (zwrotnymi). Przykład 10.7. N a rysunku 10.13 istnieje krawędź 7 ->• 4 i 4 dom 7. Analogicznie, 10 —> 1 jest krawędzią i 7 dom 10. Inne krawędzie mające taką własność to 4 —> 3, 8 -> 3 i 9 -> 1. Zauważmy, że są to dokładnie te krawędzie, o których myślimy jako o tworzących pętle w grafie. • Mając daną krawędź do tyłu n —> d, definiujemy pętlę naturalną dla tej krawędzi jako sumę d i wszystkich wierzchołków, z których można dojść do n bez przechodzenia przez d. Zauważmy, że d jest wejściem pętli. Przykład 10.8. Pętla naturalna dla krawędzi 10 -» 7 składa się z wierzchołków 7, 8 i 10, ponieważ 8 i 10 to wszystkie wierzchołki, z których można dojść do 10 bez przechodzenia przez 7. Naturalną pętlą dla 9 -> 1 jest cały graf przepływu. (Nie zapominajmy o ścieżce 10->7->8->9!) • Algorytm 10.1.
Budowa pętli naturalnej dla krawędzi do tyłu.
Wejście. Graf przepływu G i krawędź do tyłu
n-^d.
Wyjście. Zbiór loop składający się ze wszystkich wierzchołków w pętli naturalnej dla n-¥d. Metoda. Począwszy od wierzchołka n, rozważamy każdy wierzchołek m ^ d, o którym wiemy, że jest w loop, aby upewnić się, że poprzedniki m również są umieszczone w loop. Algorytm jest podany na rys. 10.15. Każdy wierzchołek w loop, z wyjątkiem d, jest jeden raz umieszczany na stosie stack, aby jego poprzedniki zostały sprawdzone. Zauważmy, że ponieważ d od razu jest umieszczone w zbiorze loop, nie sprawdzamy jego poprzedników i znajdujemy tylko te wierzchołki, z których możemy dojść do n bez przechodzenia przez d. • procedurę insert(m); if m nie jest w loop then begin loop:— loopU{m}; połóż m na stos stack
end; /* główna część procedury */ stack := pusty stos; loop := {d}; insert(n); while stack nie jest pusty do begin zdejmij m, pierwszy element stack ze stosu stack', for each p — poprzednika m do insert(p) end
Rys. 10.15. Algorytm budowania pętli naturalnej
Pętle wewnętrzne Jeśli będziemy traktować pętle naturalne jako „pętle", to będziemy mogli wykorzystać własność, że gdy dwie pętle mają różne wejścia, to są albo rozłączne, albo jedna z nich jest całkowicie zawarta (zanurzona) w drugiej. Wobec tego, zapominając o pętlach ze wspólnym wejściem, otrzymujemy naturalne pojęcie pętli wewnętrznej — pętli, w której nie jest zawarta żadna inna pętla. Jeżeli dwie pętle mają to samo wejście, jak na rys. 10.16, trudno jest powiedzieć, która z nich jest pętlą wewnętrzną. Przykładowo, gdyby test na końcu B wyglądał tak {
if
a = 10 g o t o
B
2
prawdopodobnie pętla {B , B B } byłaby pętlą wewnętrzną. Jednak nie możemy być te go pewni bez szczegółowej analizy kodu. Być może a jest prawie zawsze równe 10 i nor malne jest wielokrotne wykonanie pętli {B B B } przed przejściem do B W związku z tym musimy przyjąć, że gdy dwie pętle naturalne mają to samo wejście, to żadna z nich nie jest zawarta w drugiej i trzeba j e połączyć i traktować jak jedną pętlę. 0
V
3
Qi
v
2
v
Rys. 10.16. Dwie pętle z tym samym wejściem
Prologi Pewne przekształcenia wymagają przesunięcia instrukcji „przed wejście". Wobec tego zaczynamy obsługę pętli L od utworzenia nowego bloku, nazywanego prologiem. Na stępnikiem prologu jest tylko wejście, a wszystkie krawędzie, które dochodziły do L spoza pętli, są podłączane do prologu. Krawędzie z wnętrza pętli L do wejścia nie są zmieniane. Takie przekształcenie jest pokazane na rys. 10.17. Początkowo prolog jest pusty, ale przekształcenia L mogą wpisać do niego instrukcje. Redukowalne grafy przepływu Grafy przepływu występujące w praktyce często należą do zdefiniowanej poniżej klasy redukowalnych grafów przepływu. Używanie wyłącznie strukturalnych instrukcji kontro li przepływu sterowania, takich jak instrukcje if-then-else, while-do, continue i break, generuje programy, których grafy przepływu zawsze są redukowalne. Nawet programy
Prolog
Wejście
Wejście
Pętla L
Pętla L
(a) Przed
(b)Po
Rys. 10.17. Wprowadzenie prologu
napisane przy użyciu instrukcji goto przez programistów nie znających strukturalnej bu dowy programów prawie zawsze mają redukowalny graf przepływu. Istnieje wiele definicji „redukowalnego grafu przepływu". Przyjęta przez nas de finicja podkreśla jedną z najważniejszych własności redukowalnych grafów przepływu — nie m a skoków d o wnętrza pętli z zewnątrz; jedyna droga do pętli prowadzi przez wierzchołek, który nazwaliśmy wejściem. W ćwiczeniach i uwagach bibliograficznych jest zawarta krótka historia tego pojęcia. Graf przepływu G jest redukowalny wtedy i tylko wtedy, gdy możemy podzielić jego krawędzie na dwie rozłączne grupy, często nazywane krawędziami „do przodu" i krawędziami „do tyłu" (zwrotnymi), o następujących własnościach: 1.
Krawędzie d o przodu tworzą graf acykliczny, w którym każdy wierzchołek może być osiągnięty z początkowego wierzchołka G.
2.
Krawędzie do tyłu to wyłącznie krawędzie, których koniec dominuje nad począt kiem.
P r z y k ł a d 10.9. Graf przepływu z rys. 10.13 jest redukowalny. Na ogół, jeśli znamy relację dom dla grafu przepływu, możemy znaleźć i usunąć wszystkie krawędzie do tyłu. Pozostałe krawędzie muszą być krawędziami do przodu, jeśli graf jest redukowalny; aby wiedzieć, czy graf jest redukowalny, wystarczy sprawdzić, że krawędzie do przodu tworzą graf acykliczny. Dla przypadku z rys. 10.13 łatwo jest sprawdzić, że po usunięciu pięciu krawędzi do tyłu, 4 —> 3, 7 -> 4, 8 -> 3, 9 -¥ 1 i 10 -> 7, których koniec dominuje nad początkiem, otrzymany graf jest acykliczny. • P r z y k ł a d 10.10. Rozważmy graf przepływu z rys. 10.18, którego wierzchołkiem po czątkowym jest 1. Ten graf nie m a krawędzi do tyłu, gdyż koniec żadnej krawędzi nie dominuje nad jej początkiem. Wobec tego, graf byłby redukowalny jedynie, gdyby cały był acykliczny. Ale ponieważ graf zawiera cykl, ten graf przepływu nie jest redukowalny. Intuicyjnie, graf ten nie jest redukowalny, ponieważ do cyklu 2 - 3 można wejść w dwóch miejscach, przez wierzchołki 2 i 3. • Kluczową własnością redukowalnych grafów przepływu podczas analizowania pętli jest to, że w takich grafach każdy zbiór wierzchołków, który nieformalnie określilibyśmy
mianem pętli, musi zawierać krawędź do tyłu. Aby znaleźć wszystkie pętle w programie, którego graf przepływu jest redukowalny, musimy sprawdzać tylko pętle naturalne dla krawędzi do tyłu. Graf przepływu z rys. 10.18 wydaje się mieć „pętlę" składającą się z wierzchołków 2 i 3, ale nie istnieje krawędź do tyłu, dla której ta pętla byłaby naturalną. Rzeczywiście, ta „pętla" m a dwa wejścia, 2 i 3, co uniemożliwia bezpośrednie zastoso wanie wielu technik optymalizacji kodu, takich jak przemieszczenie kodu i usuwanie zmiennych indukcyjnych (patrz p. 10.2).
Rys. 10.18. Nieredukowalny graf przepływu
Nieredukowalne struktury sterowania przepływem, jak ta z rys. 10.18, występują, na szczęście, na tyle rzadko w większości programów, że badanie pętli z więcej niż jednym wejściem nie jest istotne. Istnieją nawet języki, takie jak Bliss i Modula 2, które pozwalają pisać wyłącznie programy o redukowalnych grafach przepływu, a w wielu innych językach można to osiągnąć przez nieużywanie instrukcji goto. P r z y k ł a d 10.11. Rozpatrując ponownie rys. 10.13, zauważmy, że jedyną „pętlą we wnętrzną", czyli pętlą bez podpętli jest {7, 8, 10}, naturalna pętla krawędzi do tyłu 10 -4- 7. Zbiór {4, 5, 6, 7, 8, 10} jest naturalną pętlą dla 7 -> 4. (Zauważmy, że 8 i 10 mogą dostać się do 7 przez krawędź 10 —> 7). Nasza intuicja, że {4, 5, 6, 7} tworzą pętlę jest błędna, gdyż 4 i 7 jednocześnie byłyby wejściami, co naruszałoby nasz wa runek o istnieniu jednego wejścia do pętli. Inaczej rzecz ujmując, nie m a powodu, dla którego należałoby przyjąć, że sterowanie spędza dużo czasu, przechodząc przez zbiór wierzchołków {4, 5, 6, 7 } ; możliwe jest, że sterowanie przechodzi z 7 do 8 częściej niż do 4. Przez włączenie 8 i 10 do pętli, z większym prawdopodobieństwem izolujemy często wykonywany fragment programu. Rozsądnie jest jednak zauważyć niebezpieczeństwo związane z przyjmowaniem za łożeń dotyczących częstotliwości wybierania krawędzi. Jeśli, na przykład, przesuniemy instrukcję niezmienniczą z 8 albo 10 w pętli {7, 8, 10} i sterowanie częściej wybiera łoby krawędź 7 —> 4 niż 7 —> 8, to zwiększymy liczbę wykonań przesuniętej instrukcji. W podrozdziale 10.7 opisaliśmy metody rozwiązywania tego problemu. Kolejną dużą pętlą jest {3, 4, 5, 6, 7, 8, 10}, która jest naturalną pętlą krawędzi 4 —> 3 i 8 —> 3. Tak jak poprzednio, przypuszczenie, że {3, 4 } jest pętlą, nie jest prawdziwe — naruszony byłby warunek istnienia jednego wejścia. Ostatnia pętla, dla krawędzi do tyłu 9 —> 1, zawiera wszystkie wierzchołki grafu przepływu. • Istnieją inne pożyteczne własności redukowalnych grafów przepływu, które wpro wadziliśmy podczas opisywania przeszukiwania w głąb i analizy przedziałów w p. 10.9.
10.5
Wprowadzenie do globalnej analizy przepływu danych
Do wykonywania optymalizacji kodu i generowania dobrego kodu konieczne jest, aby kompilator zebrał informacje o programie jako całości i rozesłał tę informację do każdego bloku w grafie przepływu. W podrozdziale 9.7, na przykład, pokazaliśmy, jak znajomość informacji o tym, które zmienne są żywe przy wyjściu z bloku, może poprawić przy dział rejestrów. W podrozdziale 10.2 zasugerowaliśmy, jak używać wiedzy o globalnych podwyrażeniach wspólnych do eliminacji niepotrzebnych obliczeń. Podobnie w podroz działach 9.9 i 10.3 opisaliśmy, jak kompilator może wykorzystać informacje o „zasięgu definicji" — czyli o tym, gdzie zmienna d e b u g została zdefiniowana ostatni raz przed wejściem do danego bloku — do wykonania zwijania stałych czy eliminacji kodu mar twego. Te zastosowania to tylko kilka przykładów wykorzystania informacji o przepływie danych, które kompilator optymalizujący zbiera podczas wykonywania analizy przepływu danych. Informacje o przepływie danych mogą być zebrane przez stworzenie i rozwiązanie układów równań, które wiążą informacje z różnych miejsc programu. Typowe równanie m a postać out[S] = gen[S] U (in[S] - kill[S])
(10.5)
i może być odczytane: „informacje na końcu instrukcji są albo generowane przez daną instrukcję, albo są wejściem dla niej i nie są zabijane podczas wykonywania instrukcji". Takie równania są nazywane równaniami przepływu danych. Szczegóły dotyczące tego, jak budować równania przepływu danych i jak j e rozwią zywać zależą od trzech czynników. 1.
2.
3.
Pojęcia tworzenia i zabijania zależą od informacji, którą chce się otrzymać, tj. od roz wiązywanego problemu związanego z analizą przepływu danych. C o więcej, w wy padku niektórych problemów, zamiast podążać zgodnie z przepływem sterowania i definiować e>wf[S], posługując się in[S], musimy iść w drugą stronę i definiować in[S] przy użyciu OMf[S]. Ponieważ dane przepływają wzdłuż ścieżek sterowania, na analizę przepływu da nych mają wpływ instrukcje sterujące programu. Faktycznie, gdy piszemy out[S), niejawnie zakładamy, że istnieje jeden punkt końcowy, w którym sterowanie opusz cza instrukcję; zazwyczaj, równania są budowane na poziomie bloków bazowych, a nie instrukcji, ponieważ bloki mają pojedyncze punkty końcowe. Istnieją subtelności związane z takimi instrukcjami, jak wywołanie funkcji, przypisa nie z użyciem zmiennej wskaźnikowej, a nawet przypisanie do zmiennej tablicowej.
W tym podrozdziale zajęliśmy się problemem ustalenia zbioru definicji widocznych w danym punkcie programu i użycia ich do ewentualnego wykonania składania stałych. W podanych dalej algorytmach dla przemieszczenia kodu i usuwania zmiennych induk cyjnych również skorzystaliśmy z tych informacji. Rozważmy programy napisane z użyciem instrukcji if i do-while. Przewidywalny przepływ sterowania w tych instrukcjach pozwala nam skoncentrować się na pojęciach potrzebnych do tworzenia i rozwiązywania równań przepływu danych. Przypisania w tym
podrozdziale są albo instrukcjami kopiowania, albo mają postać a : = b + c . W tym roz dziale często używamy „ + " jako typowego operatora. Wszystko, co odnosi się do niego, odnosi się bezpośrednio do innych operatorów, włącznie z jednoargumentowymi i wieloargumentowymi. P u n k t y i ścieżki W bloku bazowym mówimy o punkcie między dwiema sąsiednimi instrukcjami oraz o punkcie przed pierwszą i po ostatniej instrukcji. Wobec tego, blok B z rys. 10.19 ma cztery punkty: jeden przed instrukcjami przypisania, i po jednym po każdym z przypisań. x
B, m-1 n = ul
i j a
drd: d: 2
3
B
1 d:
i
d:
j
A
5
2
i+1
= j-1
J
B
4
Rys. 10.19. Graf przepływu Spójrzmy na to ogólnie i rozważmy wszystkie punkty we wszystkich blokach. Ścież ką z p do p nazywamy ciąg punktów p , p ,..., p, taki, że dla każdego i z zakresu 1... n — 1 prawdziwa jest jedna z sytuacji: {
1. 2.
n
{
2
u
p jest punktem bezpośrednio przed instrukcją, a p jest punktem bezpośrednio za tą instrukcją, w tym samym bloku, p- jest na końcu pewnego bloku, a p jest na początku następnika tego bloku. i
i+{
t
i+l
P r z y k ł a d 10.12. Na rysunku 10.19 mamy ścieżkę z początku bloku B do początku bloku B . Przechodzi ona przez punkt końcowy # , a następnie, kolejno, przez wszystkie punkty w Z? , B, i B , aż do początku B . • 5
6
5
2
Ą
6
Definicje osiągające Definicją zmiennej x nazywamy instrukcję, która wiąże lub może wiązać x z wartością. Najczęściej spotykanymi formami definicji są przypisanie do x i instrukcje, które czytają wartość z urządzenia wejścia/wyjścia i zapisują ją w x. Te instrukcje na pewno definiują
wartość dla x i są nazywane definicjami pewnymi. Niektóre inne instrukcje mogą defi niować x i są nazywane niepewnymi. Najpopularniejsze formy definicji niepewnych x to: 1.
2.
Wywołanie procedury z x jako argumentem (innym niż argument przekazywany przez wartość) lub procedura, która może uzyskać dostęp do x, gdyż x jest w zasię gu tej procedury. Musimy również rozważać możliwość istnienia „synonimów", czyli przypadków, gdy x nie jest w zasięgu procedury, lecz jest tożsame z inną zmien ną, przekazywaną jako parametr bądź będącą w zasięgu funkcji. Tymi problemami zajęliśmy się w p. 10.8. Przypisanie przez wskaźnik, który może odnosić się do x . Przykładowo, przypisanie * q : = y jest definicją x, jeżeli jest możliwe, że q wskazuje x. Metody sprawdza nia, co wskazuje wskaźnik, są opisane w p . 10.8, lecz w przypadku braku innej informacji, musimy przyjąć, że przypisanie przez wskaźnik jest definicją wszystkich zmiennych.
Mówimy, że definicja d osiąga punkt p, jeśli istnieje ścieżka z punktu bezpośrednio po d do p taka, że d nie jest „zabijane" na tej ścieżce. Intuicyjnie, jeśli definicja d osiąga punkt /?, to d może być miejscem, w którym wartość używana przez p była ostatnio definiowana. Zabijamy definicję zmiennej a, jeśli między dwoma punktami na ścieżce jest definicja a. Zauważmy, że tylko pewne definicje a zabijają inne definicje a. Wobec tego, dany punkt może być osiągnięty przez definicję pewną i niepewną tej samej zmiennej występującą dalej na ścieżce. Przykładowo, definicje i : = m - l i j : = n w bloku B z rys. 10.19 osiągają początek bloku B , tak samo jak definicja j : = pod warunkiem, że nie m a przypisań do lub odczytów j w 5 , B lub części £ po tej definicji. Przypisanie do j w B zabija definicję j : = n i nie osiąga ona B , B ani B . Definiując definicje osiągające, tak jak to zrobiliśmy, nie zawsze będziemy dokładni. Jednakże, zmierzają one w „bezpiecznym", „konserwatywnym" kierunku. Zwróćmy na przykład uwagę na to, że przyjęliśmy, iż można przejść przez wszystkie krawędzie grafu przepływu. To w praktyce nie musi być prawdą. Przykładowo, nie istnieją wartości a i b , dla których wykonana zostałaby instrukcja a : =4 z następującego programu: {
2
4
5
3
Ą
i f a=b t h e n a:=2 e l s e i f a=b t h e n
5
3
6
a:=4
W ogólności, sprawdzenie, czy każda ścieżka w grafie przepływu może zostać wybrana, jest problemem nierozstrzygalnym, więc nie będziemy próbowali go rozwiązać. Powracającym tematem w projektowaniu przekształceń poprawiających kod jest to, że muszą one podejmować „konserwatywne" decyzje w przypadku jakichkolwiek wątpli wości, mimo że takie podejście może spowodować pominięcie niektórych przekształceń, które akurat można było wykonać bezpiecznie. Decyzja jest konserwatywna, jeżeli nigdy nie prowadzi do zmiany wartości obliczanych przez program. W zastosowaniach defini cji osiągających, założenie, że definicja może osiągnąć punkt, nawet jeśli nie jesteśmy tego pewni, jest zazwyczaj konserwatywne. Wobec tego, rozważamy ścieżki, które mo gą nigdy nie zostać wybrane podczas wykonywania programu i rozpatrujemy również definicje zasłonięte przez definicje niepewne tej samej zmiennej.
Analiza przepływu danych w programach strukturalnych Grafy przepływu dla instrukcji sterujących, takich jak do-while, mają pożyteczną wła sność: jest jeden punkt początkowy, w którym sterowanie wchodzi do instrukcji, i jeden punkt końcowy, w którym opuszcza tę instrukcję, gdy jej wykonanie się skończy. Wy korzystujemy tę własność, gdy mówimy o definicjach osiągających początek i koniec instrukcji o następującej składni: 5 -> id := E \ S ; S | if E then S else S | do S while E E -> id + id | id Wyrażenia w tym języku są podobne do kodu pośredniego, ale grafy przepływu dla instrukcji mogą przybierać tylko kształty sugerowane na rys. 10.20. Podstawowym zada niem tego punktu jest analiza równań przepływu danych wymienionych na rys. 10.21.
S ;S x
2
if E then S else S x
2
do S
x
while E
Rys. 10.20. Kilka strukturalnych konstrukcji sterujących
Definiujemy fragment grafu przepływu, nazywany regionem, jako zbiór wierzchoł ków N, który zawiera wierzchołek nazywany wejściem, dominujący nad wszystkimi in nymi wierzchołkami w regionie. Wszystkie krawędzie między węzłami w /V są w regio nie, poza (być może) pewnymi krawędziami wchodzącymi do wejścia . Fragment grafu przepływu odpowiadający instrukcji S jest regionem, w którym jest spełnione dodatko we ograniczenie, mówiące że sterowanie, opuszczając region, może wychodzić tylko do jednego bloku zewnętrznego. Jako techniczne udogodnienie przyjmiemy, że istnieją bloki fikcyjne, bez żadnych in strukcji (puste okręgi na rys. 10.20), przez które sterowanie przechodzi tuż przed wejściem do i tuż przed wyjściem z regionu. Mówimy, że punkty początkowe bloków fikcyjnych przy wejściu i wyjściu z regionu instrukcji są, odpowiednio, początkowymi i końcowymi punktami instrukcji. Równania z rysunku 10.21 są indukcyjną, czy inaczej — sterowaną składnią, defini cją zbiorów in[S], out[S], gen[S] i kill[S] dla wszystkich instrukcji S. Zbiory i kill[S] są atrybutami syntezowanymi; są one obliczane wstępująco, od najmniejszych instrukcji 1
1
Pętla jest specjalnym przypadkiem regionu, który jest silnie spójny i zawiera wszystkie krawędzie wracające do wejścia.
gen [S\ = {d} kill[S]=D -{d} a
out [S] = gen [S] u (in [S] - kill [S])
gen [S] = gen [S ] u (gen [S ] - kill [S ]) kill [S] = kill [S ] u (fc7/ [SJ - gen [S ]) 2
x
2
2
2
in [S ] = in [S] in [S ] = out[S ] out [S] = out [S ] x
2
{
2
gen [S]=gen [S ]ugen[S ] kill [S\ = kill [S ]n kill [S ] x
2
{
2
in [S ] = in [S\ i * [S ] = w i [5] o w ? [S] = o u x
2
r [5 ! ] U O M ? [ Ą ] 1
«»i [5]= gen [S ] kill [S]~ kill [S ] x
x
in[Si\ = in[S\yjgen out [S] = out [S ]
[S ] x
}
Rys. 10.21. Równania przepływu danych dla definicji osiągających
do największych. Naszym celem jest, aby definicja d była w gen[S\, jeśli d osiąga koniec S, niezależnie od tego, czy osiąga początek S, czy nie. Inaczej rzecz ujmując, d musi wystąpić w S i osiągać koniec S przez ścieżkę, która nie wychodzi z S. Tak uzasadniamy stwierdzenie, że jest zbiorem definicji „generowanych przez S". Podobnie, chcemy, żeby kill [S] był zbiorem definicji, które nigdy nie osiągają końca S, nawet jeśli osiągają jej początek. Wobec tego, sensownie jest patrzeć na te definicje jak na „zabite (ang. kill) przez S". Aby definicja d była w kill[S], na każdej ścieżce od początku do końca S musi być definicja pewna tej samej zmiennej, co definiowana przez d, a jeśli d występuje w S, to, p o każdym wystąpieniu d na ścieżce, na tej ścieżce musi znajdować się inna definicja tej zmiennej . Reguły dla gen i kill, będące translacjami syntezowanymi (ang. synthesized translations), są stosunkowo proste do zrozumienia. Na początku przyjrzyjmy się regułom z rys. 10.21(a) dla pojedynczego przypisania do zmiennej a. To przypisanie jest na pew no definicją a, więc nazwijmy ją definicją d. Wówczas d jest jedyną definicją, która z pewnością osiąga koniec instrukcji, niezależnie od tego, czy osiąga jej początek. Stąd 1
gen[S] = {d}
1
W tym punkcie przyjmiemy, że wszystkie definicje są pewne. W punkcie 10.8 zajmujemy się zmianami koniecznymi do obsługi definicji niepewnych.
Definicja d „zabija" także wszystkie inne definicje a, więc piszemy kill[S] = D
-
a
{d}
gdzie D to zbiór wszystkich definicji zmiennej a w programie. Reguła dla kolejnych instrukcji, przedstawiona na rys. 10.21(b), jest trochę subtelniejsza. W jakich okolicznościach definicja d jest generowana przez 5 = 5j ; S ? Jeśli jest generowana przez S , to jest z pewnością generowana przez S. Jeśli definicja d jest generowana przez S p to osiąga koniec S, pod warunkiem, że nie jest zabijana przez S . Mamy więc a
2
2
2
gen[S] = gen[S ] U (gen[S ] 2
kill[S ])
{
2
Podobne rozumowanie odnosi się do definicji zabijania, stąd kill[S] = kill[S ] U (fci/ZfSj 2
gen[S ]) 2
Dla instrukcji if, pokazanej na rys. 10.21(c), zauważamy, że jeśli dowolna z gałęzi „if' generuje definicję, to definicja ta osiąga koniec instrukcji S. Wobec tego gen[S] = gen[S ]
Ugen[S ]
x
2
Jednakże, aby „zabić" definicję d, zmienna musi być zabijana wzdłuż każdej ścieżki od początku do końca S. W szczególności musi być zabijana w obu gałęziach, więc kill[S\=kill[S ]nkill[S ] {
2
Wreszcie, rozpatrzmy reguły dla pętli z rys. 10.21(d). Mówiąc najprościej, pętle nie zmieniają gen ani kill. Jeśli definicja d jest generowana w S p to osiąga koniec S i S. Na odwrót, jeśli d jest generowana przez S, to może być generowana jedynie przez S . Jeśli d jest zabijana przez S p to powrót do początku pętli jej nie pomoże; zmienna z d jest ponownie definiowana w S przy każdym obrocie pętli. Na odwrót, jeśli d jest zabijana przez S, to z pewnością musi być zabijana przez S p Podsumowując x
{
{
gen[S] = gen[S ] kill[S]=kill[S ] {
{
Konserwatywne szacowanie informacji o przepływie danych W regułach do obliczania gen i kill z rys. 10.21 znajduje się subtelny błąd. Założyliśmy, że wyrażenie warunkowe E z instrukcji if i do nie jest „interpretowane", czyli, że istnieją wejścia, dla których program przejdzie po jednej ścieżce i takie, dla których przejdzie po drugiej. Mówiąc inaczej, przyjmujemy, że każda grafowa ścieżka w diagramie przepływu jest również ścieżką wykonania, tj. ścieżką, która jest wybierana podczas wykonania programu dla co najmniej jednego możliwego wejścia. To nie zawsze jest prawdą i w praktyce nie możemy ocenić, czy dana gałąź kodu będzie wykonywana. Załóżmy, na przykład, że wyrażenie E z instrukcji if ma zawsze war tość „prawda". Wówczas ścieżka S z rys. 10.21(c) nigdy nie będzie wybrana. Wywołuje to dwie konsekwencje. Po pierwsze, definicje generowane przez S nie są rzeczywiście generowane przez S, ponieważ nie ma sposobu, by dojść od początku S do instrukcji S . Po drugie, żadna z definicji z kill[S ] nie może osiągać końca S. Wobec tego, logiczne jest, że wszystkie takie definicje powinny być w kill[S], nawet jeśli nie są w kill[S ]. 2
2
2
{
2
Porównując obliczone gen z „prawdziwym" gen, odkryjemy, że prawdziwy gen za wsze jest podzbiorem obliczonego gen, a prawdziwy kill jest zawsze nadzbiorem obliczo nego kill. Te zależności są prawdziwe nawet po rozważeniu pozostałych reguł z rys. 10.21. Przykładowo, jeżeli wyrażenie E z instrukcji do-S-while-E nigdy nie może być fałszywe, to nie możemy opuścić pętli. Wówczas prawdziwe gen jest równe 0 , a wszystkie defini cje są „zabijane" przez pętlę. Przypadek kolejnych instrukcji z rys. 10.21(b), gdzie trzeba rozważyć niemożność wyjścia z S lub S z powodu pętli nieskończonej, pozostawiamy Czytelnikowi jako ćwiczenie. Naturalne jest pytanie: czy różnice między prawdziwym a obliczonym gen i kill stanowią poważną przeszkodę w analizie przepływu danych. Odpowiedź jest związana z zastosowaniem otrzymanych danych. W szczególnym przypadku definicji osiągających używamy zazwyczaj obliczonych informacji, aby wywnioskować, że możliwe wartości zmiennej x w danym punkcie programu są ograniczone do małego zbioru. Na przykład, gdy sprawdzimy, że wszystkie definicje osiągające dany punkt mają postać x : =1, może my wnioskować, że wartością x w tym punkcie jest 1. Możemy więc zastąpić wszystkie odwołania do x odwołaniami do 1. Wobec tego, przyjęcie zbyt dużego zbioru definicji osiągających punkt nie wyda j e się poważne — uniemożliwia wykonanie optymalizacji, którą moglibyśmy wykonać bez problemu. Niedoszacowanie, natomiast, zbioru definicji jest poważnym błędem; mo że prowadzić do wprowadzenia zmian w programie zmieniających wartości obliczanych przez program. Możemy sądzić, na przykład, że wszystkie definicje osiągające nada ją zmiennej x wartość 1 i zastąpić x przez 1; może jednak istnieć niewykryta defini cja osiągająca, która zmiennej x nadaje wartość 2. Wobec tego, w przypadku definicji osiągających, zbiór definicji nazwiemy bezpiecznym lub konserwatywnym, jeśli jest on nadzbiorem (niekoniecznie właściwym) rzeczywistego zbioru definicji. Wyliczony zbiór nazwiemy niebezpiecznym, gdy nie musi on być nadzbiorem zbioru rzeczywistego. Każdy problem związany z przepływem danych musimy rozpatrzyć, oceniając wpływ niedokładnego obliczania zbiorów na rodzaje zmian w programie, które mogą zostać wprowadzone. Zazwyczaj akceptujemy różnice, które są bezpieczne, czyli takie, z po wodu których możemy zabronić optymalizacji, które można normalnie wykonać. Nie akceptujemy różnic, które są niebezpieczne, czyli pozwalają wykonać „optymalizacje", które nie zachowują obserwowalnego z zewnątrz zachowania programu. Dla większości problemów związanych z przepływem danych bezpieczny jest podzbiór lub nadzbiór (ale nie oba jednocześnie) prawdziwego zbioru. Wracając do wpływu bezpieczeństwa na obliczanie gen i kill dla definicji osiągają cych, zauważmy, że nasze różnice, wyliczanie nadzbiorów dla gen i podzbiorów dla kill, są bezpieczne. Intuicyjnie, zwiększenie gen dodaje nowe definicje do zbioru definicji, które mogą osiągać punkt, i nie może przeszkodzić definicji w osiągnięciu punktu, któ ry rzeczywiście jest osiągany. Podobnie, zmniejszenie kill może tylko zwiększyć zbiór definicji osiągających dany punkt. x
2
Obliczanie in i out Wiele problemów związanych z przepływem danych może być rozwiązanych przy użyciu translacji syntezowanych, podobnych do stosowanych przy obliczaniu gen i kill. Możemy, na przykład, chcieć sprawdzić, dla każdej instrukcji S, zbiór zmiennych zdefiniowanych
w S. Takie informacje mogą być obliczone przy zastosowaniu równań analogicznych do równań używanych w obliczaniu gen, nawet bez potrzeby definiowania zbiorów ana logicznych do kill. Informacji tych można użyć na przykład do wyznaczenia obliczeń niezmienniczych w pętli. Są jednak inne rodzaje informacji o przepływie danych — takie jak definicje osią gające, których używaliśmy wcześniej jako przykładu — do wyznaczenia których po trzebujemy obliczyć pewne atrybuty dziedziczone. Okazuje się, że in jest atrybutem dziedziczonym, a out jest atrybutem syntezowanym zależnym od in. Chcemy, żeby in[S] był zbiorem definicji osiągających początek S, biorąc pod uwagę przepływ sterowania w całym programie, łącznie z instrukcjami na zewnątrz S oraz tymi, w których S jest zagnieżdżone. Zbiór out[S] jest definiowany analogicznie dla końca S. Istotna jest różni ca między out[S] a Ten drugi jest zbiorem definicji, które osiągają koniec S bez przechodzenia po ścieżkach z krawędziami na zewnątrz S. Chcąc poznać różnicę, rozważmy kolejne instrukcje z rys. 10.21(b). Definicja d może być generowana w S i, w związku z tym, osiągać początek S . Jeśli d nie jest w kill[S ], d osiągnie koniec S i, oczywiście, będzie w owf[S ]; d nie jest jednak w gen[S ]. Po wstępującym obliczeniu i kill[S] dla wszystkich instrukcji 5 możemy ob liczyć in i out, zaczynając od instrukcji reprezentującej cały program, zakładając, że in[S ] — 0 , gdy S jest całym programem. To znaczy, że żadna definicja nie osiąga początku programu. Dla każdego z czterech rodzajów instrukcji z rys. 10.21 możemy przyjąć, że in[S] jest znane. Musimy go użyć do obliczenia in dla każdej z podinstrukcji S (co jest trywialne w przypadkach (b)-(d) i nie występuje w przypadku (a)). Wtedy re kurencyjnie (zstępująco) obliczamy out dla podinstrukcji 5 i S i używamy tych zbiorów do obliczenia out[S]. Najprostszym przypadkiem jest ten z rys. 10.21(a), gdzie instrukcja jest przypisa niem. Przyjmując, że znamy in[S], obliczamy out z równania (10.5), tj. x
2
2
Q
2
2
2
0
}
2
out[S] = gen[S] U (in[S] - kill[S}) Czyli definicja osiąga koniec S, jeśli jest generowana przez S (tzn. jest definicją d z tej instrukcji) albo jeśli osiąga początek instrukcji i nie jest w niej zabijana. Przypuśćmy, że obliczyliśmy in[S] dla S będącego kolejnymi instrukcjami S \S z rys. 10.21(b). Zauważmy najpierw, że in[Sy] = in[S], Następnie, obliczamy rekurencyjnie out[S ], które daje nam in[S ], gdyż definicja osiąga początek S wtedy i tylko wtedy, gdy osiąga koniec S . Możemy wreszcie obliczyć rekurencyjnie out[S ], a ten zbiór jest równy <9Mć[S]. Rozważmy teraz instrukcję if z rys. 10.2 l(c). Ponieważ konserwatywnie przyjęliśmy, że sterowanie może wejść do obu gałęzi, definicja osiąga początek S lub S wtedy, gdy osiąga początek 5. Stąd X
x
2
2
2
{
2
{
2
in[S ] = in[S ] = in[S] x
2
Z rysunku 10.21(c) wynika również, że definicja osiąga koniec S wtedy i tylko wtedy, gdy osiąga koniec jednej lub obu podinstrukcji, tj. out[S] — out[S ] x
Uout[S ] 2
Możemy więc użyć tych równań do obliczenia m [ S j i in[S ] z in[S], obliczenia rekurencyjnego o w ^ S j i out[S ] i, w końcu, użyć ich do obliczenia out[S]. 2
2
Pętle Ostatni przypadek (rys. 10.21(d)) jest wyjątkowo problematyczny. Przyjmijmy znowu, że mamy dane g e n f S j i kill[S ], które obliczyliśmy wstępująco, oraz, że mamy dany in[S], ponieważ jesteśmy w trakcie przechodzenia w głąb drzewa wyprowadzenia. W przeci wieństwie do przypadków (b) i (c) nie możemy po prostu użyć in[S] jako m [ S j , bo definicje w S p które osiągają koniec S p mogą wrócić po krawędzi do początku S p i, w związku z tym, również są w m [ S j . Mamy więc {
in[S ] = in[S] Uout[S ] x
(10.6)
{
Mamy również oczywiste równanie dla out[S] OUt[S] = 0Wf[Sj] którego możemy użyć po obliczeniu o w r [ S j . Wydaje się jednak, że nie możemy obliczyć in[S ] w (10.6) przed obliczeniem cwffSj, a nasze podejście zakładało wyliczanie out dla instrukcji przy użyciu wcześniej obliczonego in dla tej instrukcji. Szczęśliwie, istnieje bezpośrednia metoda opisu out z użyciem in; wynika ona z (10.5), w tym szczególnym przypadku {
owf [ S J = gen[S ] U ( m [ S j - Ml[S ]) x
(10.7)
x
Ważne jest dokładne zrozumienie sensu tego wzoru. Nie wiemy, czy (10.7) jest prawdziwe dla dowolnej instrukcji S ; podejrzewamy tylko, że powinno być prawdziwe, bo „jest sensowne", że definicja powinna osiągać koniec instrukcji wtedy i tylko wtedy, gdy jest generowana w tej instrukcji albo gdy osiąga początek instrukcji i nie jest w niej zabijana. Jednakże jedyną znaną nam metodą obliczania out dla instrukcji jest użycie równań podanych na rys. 10.21(a)-(c). Przyjmiemy, że równanie (10.7) jest prawdziwe i wyprowadzimy równania na in i out przedstawione na rys. 10.2l(d). Wtedy będzie my mogli użyć równań z rys. 10.21(a)-(d) do wykazania, że (10.7) jest prawdziwe dla dowolnego S . Następnie możemy użyć tych dowodów do przeprowadzenia poprawne go dowodu indukcyjnego, względem długości instrukcji S , pokazującego, że równanie (10.7) i wszystkie równania z rys. 10.21 są prawdziwe dla S i wszystkich podinstrukcji. Nie zrobimy tego jednak — pozostawimy dowód jako ćwiczenie, ale rozumowanie tu przedstawione powinno pomóc w jego przeprowadzeniu. Przyjmując nawet, że (10.6) i (10.7) są prawdziwe, ciągle mamy problem. Te dwa równania definiują rekurencję jednocześnie dla in[S ] i ouf^Sj. Zapiszmy j e jako x
x
x
I = 0 =
JUO GU(I-K)
(10.8)
gdzie /, O, /, G i K odpowiadają m [ S j , o w r j S j , m[S], gen[S ] i / ^ / / [ S j . Pierwsze dwa zbiory to zmienne, pozostałe są stałymi. Aby rozwiązać (10.8), przyjmijmy, że 0 = 0. Możemy wówczas użyć pierwszego równania z (10.8) do obliczenia przybliżenia / , tj. x
l
I =J Następnie możemy użyć drugiego równania, aby otrzymać lepsze przybliżenie O 1
0
l
=GU{I
-K)
=
GU{J-K)
Stosując pierwsze równanie do nowego przybliżenia O, otrzymamy 2
y
I = JUO
=JUGU(J-K)=JUG
Gdy ponownie zastosujemy drugie równanie, kolejnym przybliżeniem O będzie O = G U (7 - K) = G U (J U G - K) = G U (J - K) 2
2
2
1
Zauważmy, że O = O . Jeśli więc obliczymy następne przybliżenie /, będzie ono równe / , to da nam kolejne przybliżenie O równe O i tak dalej. Wobec tego, gra niczne wartości I i O to wyliczone powyżej Z i O . Wyprowadziliśmy więc równania z rys. 10.21(d), czyli l
1
1
1
mfSj] = m[S]Ugert[S,] 'ow^S] = owffSj] Pierwsze z tych równań pochodzi z powyższych obliczeń, drugie wynika z analizy grafu z rys. 10.21(d). Pozostaje pytanie, czemu mogliśmy zacząć od przybliżenia O = 0 . Przypomnij my, że w naszym opisie konserwatywnego szacowania sugerowaliśmy, że zbiory, takie jak o w f f S j , któremu odpowiada O, powinny być przeszacowane, a nie niedoszacowane. Istotnie, gdybyśmy zaczęli od O = {d}, gdzie d jest definicją niewystępującą w 7, G ani K, to d znalazłoby się w końcowych wartościach / oraz O. Musimy przypomnieć zamierzone znaczenie in i out. Jeśli taka definicja d rzeczy wiście powinna być w in[S ], to powinna istnieć ścieżka z miejsca, w którym jest d, do początku 5 pokazująca, jak d osiąga ten punkt. Jeśli d byłaby na zewnątrz 5, to d powinna być w in[S], a gdyby d była w S (i, w związku z tym, w Sj), powinna być w gćnfSj]. W pierwszym przypadku d jest w J i z równań (10.8) — również w /. W dru gim przypadku, d jest w G i, znowu z (10.8), za pośrednictwem O jest w /. Wnioskujemy więc, że rozpoczęcie ze zbyt małym zbiorem i powiększanie go przez dodawanie definicji do / i O jest bezpieczną metodą przybliżania in[S ]. x
p
{
R e p r e z e n t a c j a zbiorów Zbiory definicji, takie jak gen[S] i kill[S], mogą być zwięźle reprezentowane przy użyciu jednowymiarowych tablic bitów. Każdej interesującej nas definicji z grafu przepływu przypisujemy numer. Tablica reprezentująca zbiór definicji będzie miała 1 na pozycji i wtedy i tylko wtedy, gdy definicja o numerze i będzie w zbiorze. Numerem instrukcji z definicją może być indeks tej instrukcji w tablicy przechowu jącej wskaźniki instrukcji. Jednak nie wszystkie definicje muszą być interesujące podczas globalnej analizy przepływu danych. Definicje zmiennych tymczasowych, na przykład, które są używane tylko w obrębie jednego bloku — tak jak większość zmiennych tymcza sowych używanych przy wyliczaniu wartości wyrażenia — nie muszą być numerowane. W związku z tym, numery interesujących definicji będą zazwyczaj zapamiętane w od dzielnej tablicy. Taka reprezentacja zbiorów pozwala również na efektywną implementację operacji na zbiorach. Suma i przecięcie zbiorów mogą być zaimplementowane przy użyciu lo gicznych operatorów o r i a n d , będących standardowymi operacjami w większości syste-
mowych języków programowania. Różnica zbiorów, A — B, może być zaimplementowana przez dopełnienie B, a następnie użycie operatora a n d do obliczenia A A -^B. P r z y k ł a d 10.13. Na rysunku 10.22 przedstawiono program z siedmioma definicjami, oznaczonymi d do d w komentarzach po lewej stronie definicji. Tablice bitów reprezen tujące zbiory gen i kill dla instrukcji z rys. 10.22 przedstawiono są na rys. 10.23 po lewej stronie wierzchołków drzewa wyprowadzenia. Zbiory te zostały wyznaczone przy zasto sowaniu równań przepływu danych z rys. 10.21 do instrukcji reprezentowanych przez wierzchołki drzewa. x
7
/* di */ /* d */ /* */
i := m-1; j := n; a := ul; do i := i+1; j j-i; if el then a : = u2 else i : = u3 while e2
2
/* d */ /* <*5 */ 4
/* 6 */ d
/* d-j */
Rys. 10.22. Program przedstawiający definicje osiągające
110 0000 000 1101 100 0000 000 1001
111 0000 000 1111
001 1111 110 0000
010 0000 000 0100
000 1000 100 0001
000 0100 010 0000
di
de
000 0010 001 0000
000 0001 100 1000
Rys. 10.23. Zbiory gen i kill w wierzchołkach drzewa wyprowadzenia
Rozważmy wierzchołek odpowiadający d w prawym dolnym rogu rys. 10.23. Zbiór gen, {d }, jest reprezentowany przez 000 0 0 0 1 , a zbiór kill, {d d }, przez 100 1000. To znaczy, że d zabija wszystkie inne definicje i swojej zmiennej. v
7
v
7
Ą
Drugie i trzecie dziecko wierzchołka if przedstawiają, odpowiednio, część po then i po else instrukcji warunkowej. Zauważmy, że zbiór gen, 000 0 0 1 1 , w wierzchołku if jest sumą zbiorów 000 0010 i 000 0001 z drugiego i trzeciego dziecka. Zbiór kill jest pusty, ponieważ zbiory definicji zabijanych przez drugie i trzecie dziecko są rozłączne. Równania przepływu danych dla kolejnych instrukcji są zastosowane w rodzicu wierzchołka if. Zbiór kill jest obliczany jako 0 0 0 0 0 0 0 U (110 0 0 0 1 - 0 0 0 0011) =
110 0000
Innymi słowy, nic nie jest zabijane przez instrukcję warunkową, a d — zabijane przez instrukcję d — jest generowane przez instrukcję warunkową, więc tylko d i d są w zbiorze kill dla rodzica wierzchołka if. Możemy teraz obliczyć in i out, zaczynając od góry drzewa wyprowadzenia. Przyj mujemy, że zbiór in w korzeniu drzewa jest pusty. Wobec tego out dla lewego dziecka korzenia jest równe gen dla tego wierzchołka, czyli 111 0000. Jest to również wartość zbioru in dla wierzchołka do. Z równań przepływu danych z rys. 10.21 związanych z in strukcją do, zbiór in dla instrukcji wewnątrz pętli do otrzymujemy jako sumę zbioru in dla wierzchołka do, 111 0000, i zbioru gen dla tej instrukcji, 000 1111. Suma to 111 1111, więc wszystkie definicje mogą osiągnąć początek treści pętli. Jednakże w punkcie tuż przed definicją d , zbiór in jest równy 011 1110, ponieważ definicja d zabija d i d . Dokończenie obliczania zbiorów in i out pozostawiamy czytelnikowi jako ćwiczenie. • 7
4
x
5
Ą
2
x
7
Lokalne definicje osiągające Informacje o przepływie danych mogą zajmować mniej miejsca, gdy na dostęp do nich poświęcimy więcej czasu, poprzez zapamiętywanie informacji tylko w pewnych punk tach i, gdy jest to konieczne, wielokrotne wyliczanie informacji w punktach pośrednich. Bloki bazowe są zazwyczaj traktowane jako jednostki podczas globalnej analizy prze pływu, a uwagę zwraca się tylko na punkty początkowe bloków. Ponieważ zazwyczaj jest o wiele więcej punktów niż bloków, ograniczenie naszych wysiłków do bloków jest istotną oszczędnością. Gdy jest to potrzebne, definicje osiągające dla wszystkich punktów w bloku mogą być obliczone z definicji osiągających dla początku bloku. Dokładniej, rozważmy ciąg przypisań S ; S ; • • • ; S w bloku bazowym B. Niech punkt na początku B nazywa się p , punkt między instrukcjami S i S nazywa się p a punkt na końcu bloku — p . Definicje osiągające punkt p • można otrzymać z in[B], rozpatrując instrukcje S ; S ; • • • ; S-, począwszy od S i stosując równania przepływu danych z rys. 10.21 dla kolejnych instrukcji. Początkowo niech D = in[B]. Gdy rozważamy instrukcję S , usuwamy z D definicje zabijane przez S i dodajemy definicje generowane przez S}. Po tym procesie D składa się z definicji osiągających p {
2
n
0
t
i+l
it
n
{
t
2
{
Ł
Łańcuchy użycie-definicja Często wygodnie jest pamiętać informacje o definicjach osiągających jako łańcuchy użycie-definicja, czyli ud-lańcuchy, które są listami, dla każdego użycia zmiennej, wszyst kich definicji, które osiągają to miejsce użycia. Jeśli wykorzystanie zmiennej a w bloku B nie jest poprzedzone definicją pewną a, to ud-łańcuch dla tego użycia a jest zbiorem in[B] złożonym z definicji a. Jeśli w B są definicje pewne a przed danym użyciem a,
wtedy w ud-łańcuchu będzie tylko ostatnia definicja pewna a i in[B] nie będzie umiesz czany w ud-łańcuchu. Ponadto, jeśli istnieją definicje niepewne a, to te wszystkie, dla których nie ma definicji pewnej a między nimi a badanym użyciem a, są w ud-łańcuchu dla tego użycia a. Kolejność obliczeń Metody oszczędzania miejsca podczas obliczania atrybutów, opisane w rozdz. 5, stosu j e się również do obliczania informacji o przepływie danych przy użyciu specyfikacji podobnych do tych z rys. 10.21. W szczególności, jedyne ograniczenie narzucone na ko lejność obliczeń dla zbiorów gen, kill, in i out dla instrukcji wynika z zależności między tymi zbiorami. Gdy wybierzemy już kolejność obliczeń, możemy zwalniać pamięć zajętą przez zbiór, który nie będzie j u ż wykorzystywany. Równania przepływu danych różnią się od reguł semantycznych dla atrybutów z rozdz. 5 pod jednym względem: cykliczne zależności między atrybutami nie były dozwolone, a wiemy już, że równania przepływu danych mogą mieć zależności cyklicz ne, np. in[S ] i out[S ] zależą od siebie w (10.8). Dla definicji osiągających równania przepływu danych mogą być tak przepisane, aby uniknąć cyklu — można porównać acy kliczne równania z rys. 10.21 z (10.8). Gdy otrzymamy już opis acykliczny, możemy wykorzystać techniki z rozdz. 5, aby otrzymać efektywne rozwiązania równań przepływu danych. x
x
Ogólny przepływ sterowania Analiza przepływu danych musi uwzględniać wszystkie ścieżki sterowania. Jeśli ścież ki przepływu sterowania są widoczne dzięki składni programu, to równania przepływu danych mogą zostać utworzone i rozwiązane metodą sterowaną składnią, tak jak w tym podrozdziale. Gdy jednak programy zawierają instrukcję goto albo nawet instrukcje bre ak lub continue to stosowane podejście musimy tak zmodyfikować, by uwzględniało rzeczywiste ścieżki sterowania. Istnieje kilka podejść. Metoda iteracyjna, opisana w następnym podrozdziale, działa dla dowolnych grafów przepływu. Ponieważ grafy przepływu otrzymane z programów, w których są instrukcje break i continue, są redukowalne, instrukcje te mogą być obsługi wane systematycznie przy użyciu metod opartych na przedziałach, opisanych w p. 10.10. Podejście sterowane składnią nie musi jednak być odrzucane, gdy pozwalamy na użycie instrukcji break i continue. W przykładzie 10.14 sugerujemy, j a k można obsługi wać instrukcję break; pomysły te rozwinęliśmy w p. 10.10. Przykład 10.14. Instrukcja break w pętli do-while z rys. 10.24 jest odpowiednikiem skoku do końca pętli. W jaki sposób zdefiniować zbiór gen dla następującej instrukcji?
if e3 then a := u2 else begin i := u3; break end Definiujemy zbiór gen jako {d }, gdzie d jest definicją a : = u 2 , gdyż d jest jedyną de finicją wygenerowaną wzdłuż ścieżki sterowania od początkowego do końcowego punktu instrukcji. Definicją d , i :=u3, zajmiemy się, rozpatrując całą pętlę do-while. 6
7
6
6
/* d /* d
*/ */
x
2
/ *
ćfg * /
/* d /* d
*/ */
4
5
/* d /*
dj
6
*/ */
= m-1; j - n; a = ul; do i := i+1; j := j-i; if e3 then a : = u2 else begin i : = u3 break end while e2
Rys. 10.24. Program zawierający instrukcję break
Istnieje trik programistyczny, który pozwala nam zignorować skok spowodowany przez instrukcję break podczas przetwarzania instrukcji w treści pętli: przyjmujemy, że zbiory gen i kill dla instrukcji break to odpowiednio zbiór pusty i U, zbiór wszystkich definicji. Pokazano to na rys. 10.25. Pozostałe zbiory gen i kill z tego rysunku są wyzna czone przy użyciu równań przepływu danych z rys. 10.21; zbiory gen są przedstawione nad zbiorami kill. Instrukcje S i S reprezentują sekwencje przypisań. Do wyznaczenia pozostają zbiory gen i kill dla wierzchołka do. {
2
break
Rys. 10.25. Wpływ instrukcji break na zbiory gen i kill
Punkt końcowy jakiejkolwiek sekwencji instrukcji kończącej się na break nie może być osiągnięty, więc możemy przyjąć, że zbiór gen dla tej sekwencji to 0 , a kill to U\ wynik i tak będzie konserwatywnym szacowaniem in i out. Podobnie, punkt końcowy
instrukcji if może być osiągnięty tylko przez gałąź then, i zbiory gen i kill w wierzchołku if z rys. 10.25 rzeczywiście są takie same, jak te w jego drugim dziecku. Zbiory gen i kill dla wierzchołka do muszą uwzględniać wszystkie ścieżki od po czątku do końca instrukcji do, ma więc na nie wpływ instrukcja break. Obliczmy dwa zbiory, G i K, początkowo puste, podczas przechodzenia ścieżki zaznaczonej linią prze rywaną, od wierzchołka do do wierzchołka break. Intuicyjnie, G i K będą reprezentować definicje wygenerowane i zabite podczas przepływu sterowania od początku treści pętli do instrukcji break. Zbiór gen dla instrukcji do-while może być wtedy obliczony jako suma G i zbioru gen dla treści pętli, ponieważ sterowanie może dojść do końca do albo przez instrukcję break, albo przez przejście przez treść pętli. Z tego samego powodu, zbiór kill dla wierzchołka do jest obliczany jako iloczyn K i zbioru kill dla treści pętli. Tuż przed osiągnięciem wierzchołka if mamy G = gen[S ] = {d , d } i K — — kill[S ] ~ {ć/p d , d }. Rozpatrując wierzchołek if, jesteśmy zainteresowani przy padkiem, w którym sterowanie przepływa do instrukcji break, czyli gałąź then nie ma wpływu na G i K. Następny wierzchołek na ścieżce zaznaczonej linią przerywaną jest sekwencją instrukcji, więc obliczamy nowe wartości G i K. Nazywając instrukcje re prezentowane przez lewe dziecko wierzchołka sekwencji (oznaczone d ) S , używamy 2
2
2
4
5
7
7
3
G:=gen[S ]U(G-kill[S ]) 3
3
K:=kill[S ]U(K-gen[S ]) 3
3
Wartości G i K po osiągnięciu instrukcji break są więc odpowiednio równe {d , i {d , d d }. 5
x
29
10.6
Ą
d} • 7
Iteracyjne rozwiązywanie równań przepływu danych
Metody z poprzedniego podrozdziału są proste i wydajne (wtedy, gdy można j e stosować), lecz dla języków takich jak Pascal czy Fortran, w których dozwolone są dowolne grafy przepływu, nie są one wystarczająco ogólne. W podrozdziale 10.10 opisaliśmy „analizę przedziałów", metodę pozwalającą wykorzystać zalety podejścia sterowanego składnią do ogólnych grafów przepływu, kosztem znacznie większego skomplikowania. Poniżej przedstawiliśmy inne ważne podejście do rozwiązywania problemów zwią zanych z przepływem danych. Zamiast próbować stosować drzewo wyprowadzenia do sterowania obliczeniami zbiorów in i out, najpierw budujemy graf przepływu, a na stępnie obliczamy zbiory in i out jednocześnie dla wszystkich wierzchołków. Podczas opisywania tej nowej metody przedstawiamy Czytelnikowi wiele różnych problemów związanych z przepływem danych, omawiamy pewne ich zastosowania i różnice między tymi problemami. Równania dla wielu problemów związanych z przepływem danych są do siebie po dobne w tym sensie, że informacje są „generowane" i „zabijane". Są jednak dwie ważne cechy, którymi te równania się różnią. 1.
Równania dla definicji osiągających z poprzedniego podrozdziału to równania do przodu, tzn. zbiory out oblicza się przy użyciu zbiorów in; istnieją również problemy
2.
związane z przepływem danych, które są problemami do tylu, gdyż zbiory in są wyliczane przy użyciu out. Gdy więcej niż jedna krawędź wchodzi do bloku B, definicje osiągające początek B są sumą zbiorów definicji przychodzących po wszystkich krawędziach. Mówimy więc, że suma jest operatorem łączącym. Będziemy rozważali problemy, takie jak globalne wyrażenia dostępne, gdzie przecięcie jest operatorem łączącym, ponieważ wyrażenie jest dostępne na początku B tylko, jeśli było dostępne na końcu każdego z poprzedników B. W podrozdziale 10.11 podaliśmy inne przykłady operatorów łączących.
W tym podrozdziale przedstawiliśmy przykłady równań do przodu i do tyłu, z sumą i przecięciem jako operatorami łączącymi.
Iteracyjny algorytm dla definicji osiągających Dla każdego bloku bazowego B możemy zdefiniować out[B], gen[B], kill[B] i in[B], wiedząc, że na każdy blok B można patrzeć jak na instrukcję, która jest połączeniem jednej lub wielu instrukcji przypisania. Przyjmując, że gen i kill zostały obliczone dla wszystkich bloków, możemy stworzyć dwie grupy równań, przedstawionych poniżej jako (10.9), które wiążą ze sobą in i out. Pierwsza grupa równań wynika z obserwacji, że in[B] jest sumą definicji przychodzących ze wszystkich poprzedników B. Druga grupa to szczególne przypadki ogólnego prawa (10.5), o którym twierdzimy, że jest prawdziwe dla wszystkich instrukcji. Te dwie grupy to in[B] =
U
out[P]
P poprzednik B
out[B] =
(10.9)
gen[B]U(in[B]-kill[B})
Jeśli graf przepływu ma n bloków bazowych, to z (10.9) otrzymujemy 2n równań. Rów nania te mogą być rozwiązane poprzez traktowanie ich jako rekurencji do obliczenia zbiorów in i out, tak jak w równaniach przepływu danych (10.6) i (10.5) dla instrukcji do-while. Wtedy rozpoczynaliśmy od zbioru pustego definicji jako początkowego przy bliżenia wszystkich zbiorów out. Teraz zaczniemy od zbiorów pustych in, gdyż z (10.9) wynika, że zbiory in, będące sumą zbiorów out, będą puste, jeśli puste będą zbiory out. Chociaż uprzednio stwierdziliśmy, że do rozwiązania równań (10.6) i (10.7) wystarczy jedna iteracja, w przypadku bardziej złożonych równań nie możemy a priori ograniczyć liczby iteracji.
Algorytm 10.2.
Definicje osiągające.
Wejście. Graf przepływu, w którym kill[B] i gen[B] zostały obliczone dla każdego bloku B. Wyjście. in[B] i out[B] dla każdego bloku B. Metoda. Używamy podejścia iteracyjnego, rozpoczynając od „oszacowania" in[B] = 0 dla wszystkich B i zbiegając do pożądanych wartości in i out. Jako że musimy iterować, aż zbiory in (i w związku z tym również out) zbiegną, używamy zmiennej logicznej
zmiana, aby podczas każdego przejścia przez bloki zapamiętywać, czy jakiś zbiór in się zmienił. Algorytm pokazany jest na rys. 10.26. •
/* inicjuj out z założenia, że in[B] = 0 dla wszystkich B */ ( 1 ) for każdego bloku B do out[B] := gen[B}\ (2) zmiana := true; /* aby wejść do pętli while */ (3) while zmiana do begin (4) zmiana := false; (5) for każdego bloku B do begin (6) in[B] := U out[P)\ P poprzednik B
(7) (8) (9)
stareout := out[B}\ out[B] := gen[fl]U(ui[fl]-*i7/[jB]); if out[B] ^ stareout then zmiana := true end end
Rys. 10.26. Algorytm obliczający m i owf Intuicyjnie, algorytm 10.2 rozsyła definicje tak daleko, j a k jest to możliwe, aż d o miejsca ich zabicia, w pewnym sensie symulując wszystkie możliwe wykonania pro gramu. W uwagach bibliograficznych są informacje o pozycjach zawierających formalne dowody poprawności tego i innych rozwiązań problemów związanych z przepływem danych. Łatwo zauważyć, że algorytm m a własność stopu, b o wielkość out[B] nigdy się nie zmniejsza; gdy dodajemy definicje, zostaje ona w out na zawsze. (Indukcyjny dowód tego faktu pozostawiamy Czytelnikowi jako ćwiczenie). Ponieważ zbiór wszystkich defi nicji jest skończony, musi w końcu nastąpić iteracja pętli while, w której w wierszu (9) stareout = out[B] dla wszystkich B. Wtedy zmiana pozostanie fałszywa (false) i algo rytm skończy działanie. Zakończenie w takim momencie jest bezpieczne, ponieważ jeśli zbiory out nie zmieniły się, to zbiory in w następnym przebiegu pętli też się nie zmienią. A gdy nie zmienią się zbiory in, zbiory out nie mogą się zmienić, więc w kolejnych przebiegach nie będą zachodzić zmiany. Można wykazać, że górne ograniczenie liczby iteracji pętli while to liczba wierz chołków w grafie przepływu. Intuicyjnie, jeśli definicja osiąga punkt, może to zrobić po ścieżce bez cykli, a liczba wierzchołków w grafie będzie górnym ograniczeniem liczby wierzchołków na takiej ścieżce. Przy każdej iteracji pętli while rozpatrywana definicja posuwa się po danej ścieżce do przodu o co najmniej jeden wierzchołek. Okazuje się, że gdy odpowiednio określimy kolejność bloków w pętli for z wier sza (5), to średnia liczba iteracji przy rozpatrywaniu rzeczywistych programów będzie mniejsza niż 5, co ustalono empirycznie (patrz p. 10.10). Ponieważ zbiory mogą być reprezentowane przez tablice bitów, a operacje na takich zbiorach mogą być zaimple mentowane przy użyciu wbudowanych operacji logicznych, algorytm 10.2 jest w praktyce zaskakująco efektywny. P r z y k ł a d 10.15. Graf przepływu z rysunku 10.27 został wyprowadzony z programu z rys. 10.22. Zastosujemy algorytm 10.2 do tego grafu, aby móc porównać podejścia z tego i poprzedniego podrozdziału.
d: i d: j dy a x
2
= m-1 = n = ul
^/2[5 ]={^ ,J ,rf } 1
6
5,
gen [B ] = {d } kiU[B ]={d }
2
4 )
2
x
5
2
3
r
6
3
5,
3
^[5 ]={rf c/ } kill[B ]={d ,d ,d
5
d : a := u2
2
d : i := i+1 d: j j-1 Ą
B*
1
3
gen [B ] = {rf } A
d : i := u3
7
7
Rys. 10.27. Graf przepływu pokazujący definicje osiągające
Tylko definicje d , d , . . . , d określające i , j oraz a z rys. 10.27 są interesujące. Tak jak poprzednio, zbiory definicji będziemy przedstawiali jako tablice bitów, w których i-ty bit od lewej reprezentuje definicję d . Pętla z wiersza (1) z rys. 10.26 inicjuje out[B] = gen[B] dla wszystkich B i te wartości początkowe out[B] są pokazane w tabelce na rys. 10.28. Wartości początkowe ( 0 ) każdego z in[B] nie są obliczane ani używane, lecz są pokazane dla pełności. Przypuśćmy, że pętla for z wiersza (5) jest wykonywana z B = B B , By B , w takiej kolejności. Przy B = B nie ma poprzedników dla wierzchołka początkowego, więc in[B ] pozostaje zbiorem pustym, reprezentowanym przez 000 0000; w rezultacie out[B ] pozostaje równe gen[B ]. Ta wartość nie różni się od stareout, obliczonego w wierszu (7), nie zmieniamy więc wartości zmiana na t r u e . Następnie rozpatrujemy B = B '\ obliczamy x
2
7
t
v
2
Ą
x
x
x
x
2
in[B ] = out[B ]Uout[B ]Uout[B ] = 111 0000 + 000 0010 + 000 0001 = 111 0011 2
x
3
Ą
out[B ] = gen[B ) U {in[B } - kill[B ]) = 000 1 1 0 0 + ( 1 1 1 0 0 1 1 - 1 1 0 0001) = 001 1110 2
2
2
2
Podsumowanie tych obliczeń jest na rys. 10.28. Na końcu pierwszego przebiegu out[B ] = 001 0 1 1 1 , co pokazuje, że generowane jest d , a d , d i d osiągają B i nie są w B zabijane. Od drugiego przebiegu nie ma zmian w zbiorach out, więc algorytm się zatrzymuje. • Ą
7
3
5
6
Ą
Ą
POCZĄTKOWO BLOK B BI B
2
*1 *2
PRZEBIEG I
PRZEBIEG 2
in[B]
out[B)
in[B]
out[B]
in[B]
out[B]
0000000
1110000
0000000
1110000
0000000
1110000
0000000
0001100
1110011
001 1110
1111111
001 1110
0000000
0000010
001 1110
0001110
001 1110
0001110
0000000
0000001
001 1110
0010111
001 1110
0010111
Rys. 10.28. Obliczanie in i out
Wyrażenia dostępne Wyrażenie x+y jest dostępne w punkcie p , jeśli na wszystkich ścieżkach (niekoniecz nie acyklicznych) z wierzchołka początkowego do p jest obliczane x+y i po ostatnim takim obliczeniu przed p nie ma kolejnych przypisań do x ani y. Gdy zajmujemy się wyrażeniami dostępnymi, mówimy, że blok zabija wyrażenie x+y, jeśli jest w nim przy pisywana (lub może zostać przypisana) wartość do x lub y, a następnie nie jest obliczana po raz kolejny wartość x+y. Blok generuje wyrażenie x+y, jeśli na pewno oblicza x+y, a następnie nie przedefmiowuje x ani y. Zauważmy, że pojęcia „zabijania" i „generowania" wyrażeń dostępnych nie są do kładnie takie same, jak dla definicji osiągających. Niemniej, podane tu pojęcia „zabijania" i „generowania" podlegają tym samym prawom, co analogiczne pojęcia dla definicji osią gających. Możemy obliczać odpowiednie zbiory tak jak w p. 10.5, pod warunkiem, że zmienimy reguły z 10.21 (a) dla instrukcji przypisania. Podstawowym zastosowaniem wyrażeń dostępnych jest wykrywanie podwyrażeń wspólnych. Na rysunku 10.29, na przykład, wyrażenie 4*i w bloku Z? będzie wspólnym podwyrażeniem, jeśli 4*i jest dostępne w początkowym punkcie bloku B a będzie ono dostępne, jeśli do i nie jest przypisywana nowa wartość w bloku B lub jeśli, tak jak pokazano na rys. 10.29(b), 4*i jest ponownie obliczane po przypisaniu nowej wartości do i w bloku . 3
v
2
B*
B*
(a)
( b )
Rys. 10.29. Potencjalne wspólne podwyrażenia w różnych blokach Łatwo możemy obliczyć zbiór generowanych wyrażeń dla każdego punktu w bloku, przesuwając się od początku do końca bloku. Dla punktu przed blokiem przyjmujemy, że nie ma wyrażeń dostępnych. Jeśli w punkcie p zbiór wyrażeń dostępnych to A, a ą jest punktem po /?, z instrukcją x: = y + z między nimi, to tworzymy zbiór wyrażeń dostępnych w ą w następujących dwóch krokach: 1) 2)
dodajemy do A wyrażenie y + z , usuwamy z A wszystkie wyrażenia, w których występuje x.
Podkreślmy, że kroki te muszą być wykonane w podanej kolejności, gdyż x może być tym samym, co y lub z. Po dojściu do końca bloku, A jest zbiorem wyrażeń generowanych przez blok. Zbiór wyrażeń zabijanych składa się ze wszystkich wyrażeń, np. o postaci y + z , takich że y lub z są definiowane w bloku, a y + z nie jest w tym bloku generowane.
P r z y k ł a d 10.16. Rozważmy cztery instrukcje z rys. 10.30. Po b + c , po drugiej, dostępne staje się a-d, ale b + c nie jest j u ż ponownie zdefiniowane. Trzecia instrukcja nie udostępnia b + c , tychmiast zmieniana. Po ostatniej instrukcji, a - d przestaje być się d. Tak więc nie są generowane żadne wyrażenia, a zabijane w których występują a, b , c lub d.
INSTRUKCJE
pierwszej, dostępne jest dostępne, bo b zostało gdyż wartość c jest na dostępne, gdyż zmienia są wszystkie wyrażenia, •
WYRAŻENIA DOSTĘPNE żadne
a := b+c tylko b+c b := a-d
tylko a-d c := b+c
tylko a-d d := a-d ...żadne Rys. 10.30. Obliczanie wyrażeń dostępnych Możemy znaleźć wyrażenia dostępne metodą przypominającą obliczanie definicji osiągających. Przyjmijmy, że U jest „uniwersalnym" zbiorem wszystkich wyrażeń wy stępujących po prawej stronie jednej lub więcej instrukcji programu. Dla każdego bloku niech in[B] oznacza zbiór wyrażeń z U, które są dostępne w punkcie tuż przed początkiem B. Niech out[B] będzie tym samym dla punktu za końcem B. Zdefiniujmy e_gen[B] jako zbiór wyrażeń generowanych w B, a e„kill[B] jako zbiór wyrażeń z U zabijanych w B. Zauważmy, że in, out, e^gen i e-kill mogą być reprezentowane przez tablice bitowe. Następujące równania wiążą ze sobą nieznane in i out oraz znane e^gen i e-kill: out[B] = e„gen[B] U (in[B] in[B] =
n
eJdll[B])
out[P] dla B różnych od początkowego
^0
\Q)
P poprzednik B
in[B ] = 0 , gdzie B jest blokiem początkowym {
{
Równania (10.10) są prawie identyczne z równaniami (10.9) dla definicji osiągają cych. Pierwszą różnicą jest to, że in dla wierzchołka początkowego jest traktowany jako specjalny przypadek. Tłumaczymy to tym, że nic nie jest dostępne na początku dzia łania programu w wierzchołku początkowym, mimo że pewne wyrażenie mogłoby być dostępne wzdłuż wszystkich ścieżek do węzła początkowego z jakiegoś innego miejsca w programie. Gdybyśmy nie określili, że in[B ] jest pusty, moglibyśmy błędnie wywnio skować, że pewne wyrażenia były dostępne przed rozpoczęciem działania programu. x
Drugą, bardziej istotną różnicą jest to, że operatorem łączącym jest przecięcie, a nie suma. Operator ten jest właściwy, gdyż wyrażenie jest dostępne na początku bloku tyl ko pod warunkiem, że było dostępne we wszystkich jego poprzednikach. Odwrotnie, definicja osiąga początek bloku, gdy osiąga koniec jednego lub więcej poprzedników. Używanie f| zamiast |J powoduje, że równania (10.10) zachowują się inaczej niż (10.9). Chociaż żaden ze zbiorów nie jest tu jednoznaczny, w (10.9) najmniejsze możliwe
rozwiązanie odpowiadało definicji „osiągania", a dochodziliśmy do niego, zakładając, że nic nie osiąga niczego i zwiększając rozwiązanie. W naszej metodzie nigdy nie zakła daliśmy, że definicja d może osiągnąć punkt /?, jeśli nie znaleźliśmy ścieżki, po której d mogło osiągnąć p. Odwrotnie, w równaniach (10.10) poszukujemy największego możli wego rozwiązania, zaczynamy więc z przybliżeniem, które jest za duże i j e zmniejszamy. Może nie być oczywiste, że zakładając, iż „wszystko, tzn. zbiór U jest dostępne wszędzie" i usuwając tylko te wyrażenia, dla których potrafimy wskazać ścieżkę, wzdłuż której to wyrażenie nie jest dostępne, dochodzimy do zbioru prawdziwych wyrażeń do stępnych. W przypadku wyrażeń dostępnych, konserwatywnym zbiorem jest podzbiór prawdziwego zbioru wyrażeń dostępnych, a taki podzbiór właśnie obliczamy. Argumen tem przemawiającym za tym, że podzbiory są konserwatywne, jest to, że zamierzamy użyć obliczonych danych do zastąpienia obliczania wartości wyrażenia dostępnego od wołaniem do wcześniej obliczonej wartości (patrz algorytm 10.5), a brak wiedzy o tym, że pewne wyrażenie jest dostępne tylko powstrzymuje nas przed zmianą kodu. t
Przykład 10.17. Skoncentrujemy się na jednym bloku, B z rys. 10.31, aby pokazać wpływ początkowego przybliżenia in[B ] na out[B }. Niech G i K oznaczają, odpowied nio, gen[B ] i kill[B ]. Równania przepływu danych dla bloku B to 2
2
2
2
2
in[B ] = out[B ] =
2
out[B ]nout[B ] GU(in[B ]-K)
2
x
2
2
2
Te równania zostały na rys. 10.31 przepisane jako rekurencję, gdzie V i O* oznaczają j - t e przybliżenie, odpowiednio, in[B ] i out[B }. Na rysunku pokazane jest również, że zaczynając od 7° = 0 , otrzymujemy O = O = G, a zaczynając od 7° = U, otrzymujemy większy zbiór O . Okazuje się, że w obu przypadkach out[B ] równa się O , bo iteracje są zbieżne w pokazanych punktach. 2
2
1
2
2
2
2
B j
GU(I ~K)
= l
V+
7° = 0 0 = G
7° =
1
7
= out[B ]r\G
1
x
2
0
1
=
1
=
O
7
2
O
= G
l
out[B ]nOJ+
=
=
x
U U-K out[B ]-K x
GU(out[B ]-K) x
Rys. 10.31. Inicjowanie zbiorów in na 0 jest zbyt ograniczające
U
Intuicyjnie, rozwiązanie otrzymane, gdy zaczynamy od 7 = U, używając out[B ] = G U (out[B ] - K) 2
x
jest lepsze, b o prawidłowo oddaje fakt, że wyrażenia w out[B ], które nie są zabijane przez B , są dostępne na końcu B , tak samo jak wyrażenia generowane przez B . D x
2
2
2
A l g o r y t m 10.3.
Wyrażenia dostępne.
Wejście. Graf przepływu G, w którym e^kill[B] i e-gen[B] zostały obliczone dla każdego bloku B. Blokiem początkowym jest B . x
Wyjście. Zbiór in[B] dla każdego bloku B. Metoda. Wykonać algorytm z rys. 10.32. Objaśnienie kroków jest podobne d o tego z rys. 10.26. • infii]
:= 0 ;
out[B ) := e-gen[B ]\ /* in i out nigdy się nie zmieniają dla bloku początkowego B */ for B^B do out[B] \-JJ-e-kill[B]\ /* początkowe przybliżenie jest zbyt duże */ zmiana := true; while zmiana do begin zmiana :- false; for B ^ B do begin in[B] := fi out[P]\ x
x
x
X
x
P poprzednik B
stareout := out[B]\ out[B] := e-gen[B] U (in[B] - e-kill[B})\ if out[B) ^ stareout then zmiana := true end end
Rys. 10.32. Obliczanie wyrażeń dostępnych Analiza zmiennych żywych Wiele przekształceń poprawiających kod potrzebuje informacji obliczonych w kierunku przeciwnym niż przepływ sterowania w programie; rozważymy teraz kilka z nich. W ana lizie zmiennych żywych chcemy wiedzieć — dla zmiennej x i punktu p — czy wartość x w punkcie p może być użyta wzdłuż jakiejś ścieżki w grafie przepływu, rozpoczyna jącej się w p. Jeśli tak, mówimy, że x jest żywa w p\ w przeciwnym wypadku, x jest martwa w p. Jak wiemy j u ż z podrozdziału 9.7, ważne zastosowanie informacji o zmiennych żywych wiąże się z generowaniem kodu wynikowego. Po tym, jak wartość jest obliczana w rejestrze i, zapewne, używana wewnątrz bloku, nie trzeba jej zapamiętywać, jeśli na końcu bloku jest martwa. Ponadto, gdy wszystkie rejestry są zajęte, a my potrzebujemy kolejnego rejestru, powinniśmy używać rejestrów z wartościami martwymi, bo wartości te nie muszą być zapamiętywane. Zdefiniujmy in[B] jako zbiór zmiennych żywych w punkcie bezpośrednio przed blo kiem 5, a out[B] jako taki sam zbiór w punkcie bezpośrednio p o bloku. Niech def[B] będzie zbiorem zmiennych, do których wartość jest na pewno przypisywana przed uży ciem tej zmiennej w B, a use[B] — zbiorem zmiennych, które mogą zostać użyte w B przed dowolną definicją tej zmiennej. Wówczas równania wiążące def i use z nieznanymi in i out to in[B] = use[B) U (out[B] - def[B}) out[B] =
U S następnik B
in[S]
(
i
a
i
l
)
Z pierwszej grupy równań wynika, że zmienna jest żywa przy wejściu do bloku, jeśli jest używana przed przedefiniowaniem w bloku albo jeśli jest żywa przy wyjściu z bloku i nie jest w nim definiowana. Z drugiej grupy równań wynika, że zmienna jest żywa przy wyjściu z bloku wtedy i tylko wtedy, gdy jest żywa przy wejściu do jakiegoś następnika tego bloku. Łatwo zauważyć związek między (10.11) i równaniami dla definicji osiągających (10.9). Tu role in i out są zamienione, a use i def odpowiadają, odpowiednio, gen i kill. Tak jak w (10.9), rozwiązanie (10.11) nie musi być jednoznaczne, a my szukamy najmniejszego rozwiązania. Algorytm używany do znalezienia minimalnego rozwiązania jest właściwie wersją wsteczną algorytmu 10.2. Ponieważ metoda sprawdzania istnienia zmian w dowolnym ze zbiorów in jest bardzo podobna do używanej w algorytmach 10.2 i 10.3 dla zbiorów out, pomijamy tu szczegóły sprawdzania warunku stopu. Algorytm 10.4.
Analiza zmiennych żywych.
Wejście. Graf przepływu z def
i use obliczonym dla wszystkich bloków.
Wyjście. out[B], zbiór zmiennych żywych na końcu każdego bloku B w grafie przepływu.
Metoda. Wykonać program z rys. 10.33.
•
for każdego bloku B do in[B] := 0 ; while zachodzą zmiany w dowolnym in do for każdego bloku B do begin out[B] := U S następnik B in[B] := use[B]U(out[B]~def[B]) end Rys. 10.33. Obliczanie zmiennych żywych Łańcuchy definicja-użycie Tworzenie łańcuchów definicja-użycie (tworzenie du-łańcuchów) przebiega prawie tak samo, jak analiza zmiennych żywych. Mówimy, że zmienna jest używana w instrukcji s, gdy jej r-wartość może być potrzebna. W wyrażeniach a : = b + c i a [ b ] : = c , na przykład, są używane b i c (ale nie a ) . Problem tworzenia du-łańcuchów polega na obliczeniu dla danego punktu p zbioru punktów s — w których jest używana zmienna, powiedzmy x — takich, że istnieje ścieżka z p do s, na której zmienna x nie jest definiowana. Tak jak ze zmiennymi żywymi, możemy obliczyć out[B] — zbiór użyć osiągalnych z końca bloku B, a następnie policzyć definicje osiągalne z dowolnego punktu p w bloku B, przeglądając część bloku B występującą po p. W szczególności, jeśli w bloku jest de finicja zmiennej x, możemy obliczyć du-łańcuch dla tej definicji, listę wszystkich poten cjalnych użyć tej definicji. Metoda jest analogiczna do metody obliczania ud-łańcuchów opisywanej w p. 10.5 i szczegółowy opis pozostawiamy Czytelnikowi. Równania do obliczania informacji do du-łańcuchów wyglądają dokładnie tak, jak (10.11) z podstawieniami pod def i use. Zamiast use[B] weźmy zbiór użyć w B widocz nych z góry, tj. zbiór par (s, x ) , takich że s jest instrukcją w B, która używa zmiennej x,
i nie ma wcześniejszej definicji x w B. Zamiast def[B] weźmy zbiór takich par (5, x ) , że s jest instrukcją używającą x, s nie jest w B, a w B jest definicja x. Równania te można rozwiązać, korzystając z algorytmu analogicznego do tego z rys. 10.4, więc nie będziemy tego opisywali.
10.7
Przekształcenia poprawiające kod
Algorytmy wykonujące przekształcenia poprawiające kod, wprowadzone w p . 10.2, ko rzystają z informacji o przepływie danych. Z ostatnich dwóch podrozdziałów wiemy, jak takie informacje można zbierać. Teraz rozpatrzymy usuwanie wspólnych podwyra żeń, propagację kopii i przekształcenia usuwające obliczenia niezmiennicze z pętli oraz eliminujące zmienne indukcyjne. W wielu językach znaczącą poprawę czasu działania programów można osiągnąć przez poprawianie kodu w pętlach. Gdy takie przekształ cenia są implementowane w kompilatorze, możliwe jest wykonywanie pewnych prze kształceń jednocześnie. M y jednak oddzielnie przedstawimy pojęcia związane z różnymi przekształceniami. Zajmiemy się przede wszystkim przekształceniami globalnymi, które używają in formacji o programie jako całości. Jak j u ż wiemy, globalna analiza przepływu danych zazwyczaj nie zajmuje się punktami wewnątrz bloków bazowych. Przekształcenia glo balne nie mogą więc zastąpić przekształceń lokalnych; oba rodzaje optymalizacji muszą zostać wykonane. Przykładowo, gdy wykonujemy globalne usuwanie podwyrażeń wspól nych, zajmujemy się tylko tym, czy wyrażenie jest generowane przez blok, a nie, czy jest w tym bloku wielokrotnie obliczane. Globalne usuwanie podwyrażeń wspólnych Opisywany w poprzednim podrozdziale problem wyrażeń dostępnych pozwala sprawdzić, czy wyrażenie w punkcie p grafu przepływu jest podwyrażeniem wspólnym. Poniższy algorytm formalizuje intuicje związane z usuwaniem podwyrażeń wspólnych przedsta wione w p. 10.2. Algorytm 10.5.
Globalne usuwanie podwyrażeń wspólnych.
Wejście. Graf przepływu z informacjami o wyrażeniach dostępnych. Wyjście. Poprawiony graf przepływu. 1
Metoda. Dla każdej instrukcji s o postaci x : = y + z , takiej że y + z jest dostępne na początku bloku zawierającego s oraz y ani z nie były w tym bloku definiowane przed instrukcją J, zrób co następuje: 1.
1
Aby znaleźć wyliczenia y + z , które osiągają blok z instrukcją s> idź po krawędziach grafu przepływu, szukając wstecz od bloku z s. Nie przechodź jednak przez blok, w którym jest wyliczane y + z . Ostatnie wyliczenie y + z w każdym z napotkanych bloków jest wyliczeniem y + z , które osiąga s.
Przypomnijmy, że + jest uważany za dowolny operator.
2. 3.
Stwórz nową zmienną u. Zastąp każdą instrukcję w: = y + z znalezioną w kroku 1. przez u:=y+z w: =u
4.
Zastąp instrukcję s przez x : - u .
•
Ważnych jest kilka uwag dotyczących tego algorytmu. 1.
2.
3.
Poszukiwanie wyliczeń y + z w kroku 1. algorytmu może być sformułowane ja ko problem analizy przepływu danych. Nie ma jednak sensu rozwiązywać go dla wszystkich wyrażeń y + z i wszystkich instrukcji czy bloków, bo wynikiem byłaby wielka ilość niepotrzebnych informacji. Lepiej jest przeszukiwać graf przepływu dla każdej istotnej instrukcji i istotnego wyrażenia. Nie wszystkie zmiany wykonywane przez algorytm 10.5 są ulepszeniami. Możemy chcieć ograniczyć liczbę różnych wyliczeń osiągających s, wyszukiwanych w kroku 1., prawdopodobnie do jednego. Jednakże, opisywana dalej propagacja kopii często pozwala osiągnąć korzyści nawet, gdy s jest osiągane przez wiele wyliczeń y + z . Algorytm 10.5 nie zauważy, że a * z i c * z muszą mieć identyczną wartość w a:=x+y b:=a*z
c:=x+y cl:=c*z
ponieważ nasze proste podejście do obliczania podwyrażeń wspólnych rozważa tylko literalne wyrażenia, a nie wartości przez nie obliczane. Kildałl [1973] przedstawił metodę wyszukania takich równoważności w jednym przebiegu (patrz p. 10.11). Takie wyrażenia można jednak znaleźć, stosując wielokrotnie algorytm 10.5 i moż na rozważać wielokrotne powtarzanie tego algorytmu — do chwili, gdy nie będą zachodziły dalsze zmiany. Jeśli a i c są zmiennymi tymczasowymi, które nie są uży wane na zewnątrz bloku, w którym występują, to wspólne podwyrażenie ( x + y ) *z można znaleźć poprzez specjalne traktowanie zmiennych tymczasowych, co ilustruje następujący przykład. P r z y k ł a d 10.18. Przypuśćmy, że nie ma przypisań do tablicy a w grafie przepływu z rys. 10.34(a), możemy więc powiedzieć, że a [ t ] i a [ t ] są podwyrażeniami wspól nymi. Problemem jest usunięcie tego podwyrażenia wspólnego. 2
u t t
t t
:=
4*i
:=
a[t ]
t
:=
4*i
:=
a[t ]
t t
2
3
2
6
7
6
(a)
3
6
7
:=
6
4*i
:= u := a [ t ] 2
:= u := a [ t ] 6
(15) : = 4 * i (18) := a [(15)]
t
7
(b)
Rys. 10.34. Usuwanie podwyrażenia wspólnego 4*i
:= (15) 08) (c)
Wspólne podwyrażenie 4 * i z rys. 10.34(a) zostało usunięte na rys. 10.34(b). Aby stwierdzić, że a [t ] i a [t ] są również podwyrażeniami wspólnymi, możemy zastąpić t i t przez u przy użyciu propagacji kopii (opisywanej dalej); oba wyrażenia przyjmą wtedy postać a [u] i można j e usunąć poprzez ponowne zastosowanie algorytmu 10.5. Zauważmy, że ta sama zmienna u jest wstawiana w obu blokach z rys. 10.34(b), więc lokalna propagacja kopii wystarczy do przekształcenia zarówno a [ t ] , jak i a [ t ] w a[u]. Istnieje inna metoda, wykorzystująca fakt, że zmienne tymczasowe są tworzone przez kompilator i używane tylko w blokach, w których są tworzone. Przyjrzymy się bli żej reprezentacji wyrażeń podczas obliczania wyrażeń dostępnych, aby ominąć problem reprezentowania tego samego wyrażenia przez różne zmienne tymczasowe. Rekomendo wany sposób reprezentacji zbiorów wyrażeń to przypisanie numeru do każdego wyrażenia i używanie tablic bitów z bitem i reprezentującym wyrażenie o numerze /. Podczas nada wania numerów wyrażeniom można zastosować techniki numerowania wartości z p. 5.2, aby zmienne tymczasowe potraktować w specjalny sposób. 2
2
6
6
2
6
Dokładniej, załóżmy, że 4 * i jest wartością o numerze 15. Wyrażenia a [ t ] i a [ t ] będą miały ten sam numer wartości, jeśli użyją wartości o numerze 15 zamiast nazw zmiennych tymczasowych t i t . Przypuśćmy, że tym numerem wartości jest 18. Wów czas bit 18, podczas analizy przepływu danych, będzie reprezentował zarówno a [ t ] , jak i a [ t ] , co pozwoli zauważyć, że a [ t ] jest dostępne i może zostać wyelimino wane. Kod po przekształceniach jest pokazany na rys. 10.34(c). Używamy (15) i (18) jako oznaczeń zmiennych tymczasowych odpowiadających wyrażeniom o tych numerach wartości. Okazuje się, że t jest niepotrzebne i może zostać wyeliminowane podczas lo kalnej analizy zmiennych żywych. Również t , będące zmienną tymczasową, nie byłoby obliczane; zamiast tego, wystąpienia t zostałyby zastąpione wystąpieniami (18). • 2
2
6
6
2
6
6
6
7
7
P r o p a g a c j a kopii Przedstawiony algorytm 10.5 i inne algorytmy (np. usuwające zmienne indukcyjne) opi sane w dalszej części tego podrozdziału wprowadzają instrukcje kopiowania o postaci x : = y . Kopie mogą być również generowane przez generator kodu pośredniego, chociaż większość z nich dotyczy zmiennych tymczasowych używanych w jednym bloku. Kopie możemy wyeliminować, budując dag dla procedury (patrz p. 9.8). Czasem możliwe jest usunięcie instrukcji kopiowania s: x : = y , jeśli znajdziemy wszystkie miejsca użycia tej definicji x. Możemy wtedy w tych miejscach podstawić y za x, jeśli tylko wszystkie użycia u zmiennej x spełniają następujące warunki: 1) 2)
instrukcja s musi być jedyną definicją x osiągającą u (tj. ud-łańcuch dla użyć u składa się tylko z s), na każdej ścieżce z s do w, włączając w to ścieżki, które przechodzą przez u wiele razy (ale nie przechodzą drugi raz przez s), nie ma przypisań do y.
Warunek 1. można sprawdzić przy użyciu informacji z ud-łańcucha, ale co zrobić z warunkiem 2.1 Stworzymy nowy problem związany z przepływem danych, w którym in[B] jest zbiorem takich kopii s: x : = y , że każda ścieżka z wierzchołka początkowego do początku B zawiera instrukcję s i, po ostatnim wystąpieniu s, nie ma przypisań do y. Zbiór out[B] można zdefiniować podobnie, odnosząc się do końca B. Mówimy, że
instrukcja kopiowania s: x : = y jest generowana w bloku B, jeśli s występuje w B i w B nie ma późniejszych przypisań do y. Mówimy, że s: x : = y jest zabijana w B, jeśli do x lub y w B jest przypisywana wartość i s nie ma w B. Pojęcie „zabijania" x : = y przez przypisanie do x jest znane z definicji osiągających, ałe „zabijanie" przez przypisanie do y jest specyficzne dla tego problemu. Istotną konsekwencją tego faktu jest to, że różne przypisania x : = y zabijają się nawzajem; in[B] może zawierać tylko jedną instrukcję kopiowania z x po lewej stronie. Niech U będzie „uniwersalnym" zbiorem instrukcji kopiowania w programie. Ważne jest, że różne instrukcje x : = y są odróżniane w U. Zdefiniujmy c~gen[B] jako zbiór wszystkich kopii generowanych w bloku B i c-kill[B] jako zbiór kopii z U, które są zabijane w 5 . Wówczas poniższe równania wiążą ze sobą zdefiniowane wartości: out[B] = C-gen[B] in[B] —
U (in[B] -
fi
c_kill[B))
out[P] dla B różnych od początkowego
^Q ^ )
P poprzednik B
in[B ] = 0,
gdzie B jest blokiem początkowym
x
x
Równania 10.12 są identyczne z równaniami 10.10, jeśli c-kill zastąpimy przez e-kill i c„gen przez e„gen. Czyli, równania 10.12 można rozwiązać przy użyciu algo rytmu 10.3, więc nie będziemy tego dokładniej opisywać. Przedstawimy jednak przykład ilustrujący różne niuanse związane z optymalizacją kopiowania. P r z y k ł a d 10.19. Rozważmy graf z rys. 10.35, gdzie c-gen[B ] = { x : = y } , a C-gen[B ] = = { x : = z } . Ponadto c-.kill[B ] = { x : = y } , gdyż w B jest przypisana wartość do y. W końcu, c-kill[B ] = { x : = z } , gdyż w B jest przypisana wartość do x i, z tego same go powodu, C-kill[B ] = { x : = y } . x
2
3
2
x
x
3
5 Rys.
10.35.
Przykładowy graf przepływu
Pozostałe c^gen i c-kill są równe 0 . Również, na mocy równań 10.12, in[B ] — 0 . Algorytm 10.3 w jednym przebiegu oblicza, że x
in[B ] — in[B ] — out[B ] = { x : = y } 2
3
x
Podobnie, out[B ] = 0 i 2
out[B ] = in[B ] = out[B ] = { x : = z } 3
Ą
Ą
Ostatecznie, in[B ] = out[B ]f)out[B ] 5
2
Ą
= 0.
Zauważmy, że ani kopia x : = y , ani x : = z nie „osiąga" użycia x w B w sensie algo rytmu 10.5. Jest prawdą, choć jest to nieistotne, że te definicje x „osiągają" B w sensie definicji osiągających. Wobec tego, żadna z tych kopii nie może być rozpropagowana, bo nie jest możliwe podstawienie y (odpowiednio, z) za x we wszystkich użyciach x, które definicja x : = y (odpowiednio, x : = z ) osiąga. Moglibyśmy podstawić z za x w B , ale to nie poprawiłoby kodu. • 5
5
Ą
Opiszemy teraz szczegóły algorytmu usuwania instrukcji kopiowania. A l g o r y t m 10.6.
Propagacja kopii.
Wejście. Graf przepływu G z ud-łańcuchami wskazującymi użycia wszystkich definicji osiągających blok B i z c_in[B] reprezentującym rozwiązanie równań 10.12, tj. zbiór kopii x : = y osiągających blok B wzdłuż każdej ścieżki, bez przypisań do x ani y po ostatnim wystąpieniu x : = y na ścieżce. Potrzebujemy również du-łańcuchów opisujących użycia wszystkich definicji. Wyjście. Poprawiony graf przepływu. Metoda. Dla każdej instrukcji kopiowania s: x : = y zrób, co następuje. 1. 2.
Wyznacz użycia x, które są osiągane przez rozpatrywaną definicję x, czyli s: x : = y . Ustal, czy dla każdego użycia x znalezionego w kroku 1., s jest w c-in[B], gdzie B jest blokiem z rozpatrywanym użyciem, i, ponadto, w B nie ma definicji x ani y przed tym użyciem. Przypomnijmy, że jeśli s jest w c-in[B] to s jest jedyną definicją x osiągającą B. Jeśli s spełnia warunki z kroku 2., to usuń s i zastąp wszystkie użycia x znalezione w kroku 1. przez y. • 9
3.
W y k r y w a n i e obliczeń niezmienniczych w pętlach Użyjemy ud-łańcuchów do wykrywania w pętli tych obliczeń, które są w niej niezmien nikami, tzn. których wartość nie zmienia się, gdy sterowanie znajduje się w pętli. Jak ustaliliśmy w p. 10.4, pętla jest to fragment składający się ze zbioru bloków oraz wierz chołka nazwanego wejściem, dominującego nad wszystkimi pozostałymi blokami i bę dącego jedyną drogą wejścia do pętli; z dowolnego bloku pętli zawsze można wrócić do wejścia. Jeśli przypisanie x : = y + z jest w środku pętli, a wszystkie możliwe definicje y i z są na zewnątrz (włączając w to szczególny przypadek, w którym x i/lub y są stałymi), to y + z jest niezmiennikiem tej pętli, ponieważ w czasie, gdy sterowanie pozostaje w treści pętli, wartość tego wyrażenia będzie taka sama przy każdym napotkanym obliczeniu x : = y + z . Wszystkie takie przypisania mogą zostać wykryte za pomocą ud-łańcuchów, tj. listy wszystkich punktów, w których znajdują się definicje y i z osiągające przypisanie x: =y+z. Po sprawdzeniu, że wartość x obliczana przez x : = y + z nie zmienia się w pętli, załóżmy, że w pętli jest też instrukcja v : =x+w, w której w jest definiowane tylko na zewnątrz pętli. Wówczas x+w jest również niezmiennikiem pętli. Powyższych pomysłów możemy użyć do wykonania wielu przejść po treści pętli, wykrywając coraz więcej obliczeń, które są jej niezmiennikami. Jeśli dysponujemy za-
równo u d - jak i du-łańcuchami, to nie musimy nawet powtarzać przejść nad kodem. Z du-łańcucha dla definicji x : = y + z dowiemy się, gdzie można wykorzystać wartość x, a my musimy sprawdzić tylko te wystąpienia x, które są w pętli i nie używają innej definicji x. Takie niezmiennicze przypisania mogą zostać przeniesione do prologu, pod warunkiem, że ich pozostałe argumenty są również niezmiennikami pętli, co wykazaliśmy w następującym algorytmie. A l g o r y t m 10.7.
Wykrywanie obliczeń niezmienniczych w pętli.
Wejście. Pętla L składająca się ze zbioru bloków bazowych; każdy z bloków jest ciągiem instrukcji trójadresowych. Przyjmujemy, że ud-łańcuchy, takie jak obliczone w p. 10.5, są dostępne dla poszczególnych instrukcji. Wyjście. Zbiór instrukcji trójadresowych, które obliczają tę samą wartość za każdym razem, gdy są wykonywane — od chwili wejścia sterowania do pętli L aż do jej opusz czenia. Metoda.
Podamy uproszczoną specyfikację algorytmu, ufając, że zasady działania są
jasne. 1.
Zaznacz jako „niezmienniki" te instrukcje, których argumenty są stałymi albo mają wszystkie definicje osiągające poza L.
2. 3.
Powtarzaj krok 3. dopóty, dopóki w kolejnych powtórzeniach pojawiają się nowe instrukcje oznaczone „niezmiennik". Zaznacz jako „niezmienniki" wszystkie te instrukcje, które nie były zaznaczone wcześniej, i których wszystkie argumenty są stałymi albo mają wszystkie definicje osiągające poza L, albo mają dokładnie jedną definicję osiągającą, która znajduje się w L i jest zaznaczona jako niezmiennik. •
Wykonywanie przemieszczenia kodu Po znalezieniu instrukcji niezmienniczych w pętli, do niektórych z nich możemy zastoso wać przekształcenie, znane jako przemieszczenie kodu, w którym instrukcje są przesuwa ne do prologu pętli. Spełnienie poniższych trzech warunków gwarantuje, że przemiesz czenie kodu nie zmieni wyniku programu. Żaden z nich nie jest absolutnie konieczny; wybraliśmy te warunki, ponieważ są łatwe d o sprawdzenia i zastosowania w sytuacjach występujących w rzeczywistych programach. Później rozważymy możliwości osłabienia tych warunków. Warunki dla instrukcji s: x : = y + z to: 1. 2.
3.
Blok zawierający s dominuje nad wszystkimi wyjściami z pętli, gdzie wyjście jest wierzchołkiem w pętli z następnikiem poza pętlą. Nie ma innych instrukcji w pętli, które przypisują wartość do x. Jeśli x jest zmienną tymczasową, z jednokrotnie przypisaną wartością, ten warunek jest z pewnością spełniony i nie musi być badany. Żadne użycie x w pętli nie jest osiągane przez żadną inną definicję x niż s. Ten warunek też będzie zazwyczaj spełniony, gdy x będzie zmienną tymczasową. Następne trzy przykłady uzasadniają wybór powyższych warunków.
P r z y k ł a d 10.20. Rozważmy warunek 1. Przesunięcie instrukcji, która nie musi być wy konana w pętli, poza nią może zmienić wynik programu, co przedstawiamy na rys. 10.36. Instrukcja, która dominuje nad wszystkimi wyjściami, nie może nie zostać wykonana, przyjmując, że pętla nie będzie działała w nieskończoność.
i
if
i
:= 1
i
:= 2
:- 1 B<
u < v g o t o B-, Br
if
u < v goto
B v := v - l v <= 20 g o t o
1 •=
i
3
B
Ł
if
B
4
B
v : = v-l v <= 20 g o t o
if
5
B<
3 '.=
(a) Przed
B
5
B<
i
(b) Po
Rys. 10.36. Przykład nielegalnego przemieszczenia kodu
Rozpatrzmy graf przepływu z rys. 10.36(a). B B i B tworzą pętlę z wejściem B . Instrukcja i : =2 w B jest oczywiście niezmiennikiem. Jednakże B nie dominuje nad i ? , jedynym wyjściem z pętli. Jeśli przesuniemy i : =2 do nowo stworzonego prologu 5 , co pokazano na rys. 10.36(b), zmienimy — jeśli B nie zostanie wykonany — wartość przypisaną do j w Z? . Przykładowo, dla u = 3 0 i v = 2 5 przy pierwszym wejściu do B , kod z rys. 10.36(a) nadaje j wartość 1 w Z? , gdyż B nigdy nie jest wykonywane, a kod z rys. 10.36(b) nadaje j wartość 2. • v
3
Ą
2
3
3
4
6
3
5
2
5
3
P r z y k ł a d 10.21. Warunek 2. ważny jest wtedy, gdy w pętli jest więcej niż jedno przy pisanie do x. Przykładowo, struktura grafu przepływu z rys. 10.37 jest taka sama, jak na rys. 10.36(a), mamy więc możliwość stworzenia prologu B , jak na rys. 10.36(b). 6
Ponieważ B z rysunku 10.37 dominuje nad wyjściem Z? , spełnienie warunku 1. nie uniemożliwi przesunięcia i : = 3 do prologu B . Jeżeli jednak to zrobimy, będziemy nadawali i wartość 2 za każdym razem, gdy wykonamy B , i wtedy, po dojściu do Z? , i będzie miało wartość 2, nawet jeśli kolejno wykonywane bloki to B -> B -> B -> -¥ B -¥ B -¥ B . Rozważmy wynik programu przy v równym 22 i u równym 21 przy pierwszym wykonaniu B Jeśli i : = 3 jest w B , j w B będzie miało wartość 3, ale po przeniesieniu i : = 3 do prologu, nadajemy j wartość 2. • 2
4
6
3
5
2
2
Ą
5
v
2
5
3
4
P r z y k ł a d 10.22. Rozważmy teraz warunek 3. Użycie k : = i w bloku B z rys. 10.38 jest osiągane przez i : = 1 w bloku B oraz przez i : = 2 w 5 . Nie możemy więc przesunąć i : =2 do prologu, bo wartość k osiągającego B może się zmieniać w przypadku u > = v . Przykładowo, w grafie przepływu z rys. 10.38, jeśli u = v = 0 , to k nadajemy wartość 1, ale po przesunięciu i : = 2 do prologu, zawsze nadajemy k wartość 2. • Ą
x
3
5
Rys. 10.37. Ilustracja warunku 2. A l g o r y t m 10.8.
Rys. 10.38. Ilustracja warunku 3.
Przemieszczenie kodu.
Wejście. Pętla L z informacjami o ud-łańcuchach i o dominatorach. Wyjście. Poprawiona pętla z prologiem i (być może) pewnymi instrukcjami przesuniętymi do prologu. Metoda. 1. 2.
Użyj algorytmu 10.7 do znalezienia niezmienników pętli. Dla każdej instrukcji s definiującej x, znalezionej w kroku 1., sprawdź, że: i) jest ona w bloku, który dominuje nad wszystkimi wyjściami z L, ii) x nie jest definiowane w innym punkcie L, iii) wszystkie użycia x w L mogą być osiągnięte tylko przez definicję x z in strukcji s.
3.
Przesuń — w kolejności znalezionej przez algorytm 10.7 — wszystkie instrukcje s znalezione w kroku 1. i spełniające warunki 2i), 2ii) oraz 2iii) do utworzonego prologu, pod warunkiem, że definicje wszystkich argumentów s, które były defi niowane w pętli L (jeśli s zostało znalezione w kroku 3. algorytmu 10.7), zostały przeniesione do prologu. •
Wynik programu nie zmienia się, gdyż spełnienie warunków 2i) i 2ii) z algorytmu 10.8 powoduje, że wartość x obliczona w s musi być wartością x otrzymywaną po wyjściu
na zewnątrz z dowolnego bloku L. Gdy przesuniemy s do prologu, s cały czas będzie definicją x, która osiąga koniec dowolnego bloku wyjściowego L. Spełnienie warunku 2iii) gwarantuje, że dowolne użycia x w L korzystały, i będą nadal korzystać, z wartości x obliczonej przez s. Przekształcenia nie mogą zwiększyć czasu wykonywania programu, gdyż spełnienie warunku 2i) powoduje, że s jest wykonywana co najmniej raz zawsze wtedy, gdy stero wanie wchodzi do L. Po przemieszczeniu kodu, przy każdym wejściu sterowania do L będzie ona wykonywana dokładnie raz — w prologu pętli — i ani razu w L.
Inne strategie przemieszczenia kodu Możemy nieco osłabić warunek 1., jeśli podejmiemy ryzyko pewnego zwiększenia czasu działania niektórych programów; oczywiście, nigdy nie zmienimy wyniku programu. Z osłabionej wersji kroku 1. (tj. warunku 2i) z algorytmu 10.8) wynika, że możemy przemieścić instrukcję s przypisującą wartość do x tylko, gdy: 1'.
Blok zawierający s dominuje nad wszystkimi wyjściami z pętli lub x nie jest używa ne na zewnątrz pętli. Przykładowo, jeśli x jest zmienną tymczasową, możemy być pewni (w przypadku wielu kompilatorów), że będzie ona używana tylko w swoim własnym bloku. Zwykle należy wykonać analizę zmiennych żywych, aby wiedzieć czy x jest żywe w jakimś wyjściu z pętli.
Po takim zmodyfikowaniu algorytmu 10.8 (krok 1'. zamiast 1.), czas wykonania programów może się niekiedy trochę zwiększać, ale najczęściej możemy spodziewać się dobrych wyników. Zmodyfikowany algorytm może przesunąć do prologu pewne oblicze nia, które nigdy nie byłyby wykonane w pętli. Oprócz tego, że program może działać zdecydowanie wolniej, może się również zdarzyć, że pojawią się błędy. Wykonanie w pę tli obliczenia x/y może, np., być poprzedzone sprawdzeniem, czy y=0. Po przesunięciu x/y do prologu, może wystąpić dzielenie przez zero. Dlatego nie jest dobrym pomysłem stosowanie kroku 1'., chyba że optymalizacja może być zakazana przez programistę, albo gdy stosujemy mocniejszy krok 1. do instrukcji dzielenia. Nawet, jeśli żaden z warunków 2i), 2ii) oraz 2iii) z algorytmu 10.8 nie jest spełniony przez przypisanie x:=y+z, możemy przesunąć obliczenie y+z na zewnątrz pętli. Two rzymy nową zmienną tymczasową t i w prologu wykonujemy przypisanie t: =y+z. Na stępnie zastępujemy w treści pętli x: =y+z przez x : =t. W wielu przypadkach możemy później propagować instrukcję kopiowania x: =t, co opisaliśmy j u ż w tym podrozdzia le. Zauważmy, że jeśli jest spełniony warunek 2iii) z algorytmu 10.8, tj. jeśli wszystkie użycia x w pętli L są zdefiniowane w x: =y+z (teraz x: =t), to z pewnością możemy usunąć instrukcję x : = t przez zastąpienie użyć x w L przez użycia t i umieszczenie x: =t po każdym wyjściu z pętli.
Pielęgnowanie informacji o przepływie danych po przemieszczeniu kodu Przekształcenia z algorytmu 10.8 nie zmieniają informacji o ud-łańcuchach, gdyż — zgodnie z warunkami 2i), 2ii) oraz 2iii) — wszystkie użycia zmiennej, do której następuje przypisanie w przesuniętej instrukcji s, są osiągane przez s z jej nowego miejsca. Definicje
zmiennych używanych przez s są albo na zewnątrz L, i wówczas osiągają prolog, albo wewnątrz L i wtedy — zgodnie z krokiem 3. — są przenoszone do prologu przed s. Jeśli ud-łańcuchy są reprezentowane przez listę wskaźników do wskaźników in strukcji (a nie przez listę wskaźników instrukcji), możemy uaktualniać ud-łańcuchy, jeżeli przeniesiemy instrukcję s, dokonując prostej zmiany wskaźnika instrukcji s po jej przenie sieniu. Tak więc, dla każdej instrukcji s tworzymy wskaźnik p., który zawsze wskazuje s. Wówczas, niezależnie od miejsca, w które przesuniemy s, musimy tylko zmienić p,, bez względu na liczbę ud-łańcuchów, w których występuje s. Oczywiście, wprowadzenie dodatkowego poziomu pośredniego zabiera trochę czasu i pamięci podczas kompilacji. Jeśli reprezentujemy ud-łańcuchy przez listę adresów instrukcji (wskaźniki instruk cji), możemy również pielęgnować ud-łańcuchy podczas przemieszczania instrukcji. Wówczas jednak, dla efektywności, potrzebujemy również du-łańcuchów. Gdy prze mieszczamy s, możemy przejść po jej du-łańcuchu, zmieniając ud-łańcuchy wszystkich użyć, które odwołują się do s. Informacje o dominatorach są trochę zmieniane wskutek przemieszczania kodu. Pro log zostaje bezpośrednim dominatorem wejścia, a bezpośrednim dominatorem prologu jest wierzchołek, który wcześniej był bezpośrednim dominatorem wejścia. Oznacza to, że prolog jest wstawiany do drzewa dominatorów jako rodzic wejścia. v
v
Usuwanie zmiennych indukcyjnych Zmienna x jest nazywana zmienną indukcyjną w pętli L, jeśli za każdym razem, gdy wartość x się zmienia, jest ona zwiększana bądź zmniejszana o stałą. Często zmienna indukcyjna jest zwiększana o taką samą stałą przy każdej iteracji pętli, tak jak i w wejściu pętli for i :=1 to 10. Metoda, przedstawiona poniżej, działa również ze zmiennymi, które są zwiększane lub zmniejszane zero, jeden, dwa lub więcej razy przy każdej iteracji pętli. Liczba zmian wartości zmiennej indukcyjnej może być nawet różna w kolejnych iteracjach pętli. Zmiennej indukcyjnej, powiedzmy i, używamy często do indeksowania tablicy, a pewnej innej zmiennej, powiedzmy t, której wartość jest funkcją liniową zależną od i, do pamiętania przesunięcia używanego podczas dostępu do elementów tablicy. Często jedynym zastosowaniem i jest testowanie warunku wyjścia z pętli. Możemy wówczas usunąć i, zastępując ten warunek warunkiem zależnym od t. W celu uproszczenia prezentacji, przedstawione poniżej algorytmy obsługują ogra niczoną klasę zmiennych indukcyjnych. Niektórych rozszerzeń algorytmów można doko nać, dodając nowe przypadki, inne wymagają dowodów twierdzeń dotyczących wyrażeń ze zwykłymi operatorami arytmetycznymi. Będziemy szukać bazowych zmiennych indukcyjnych, czyli takich, do których przy pisania w pętli L mają postać i : ~ i ± c , gdzie c jest stałą . Następnie szukamy innych zmiennych indukcyjnych j , które w L są definiowane tylko jeden raz i których wartość jest funkcją liniową pewnej bazowej zmiennej indukcyjnej i z miejsca, gdzie j jest definiowane. 1
1
W opisie zmiennych indukcyjnych + oznacza operator dodawania, a nie dowolny operator; to samo dotyczy innych standardowych operatorów arytmetycznych.
A l g o r y t m 10.9.
Wykrywanie zmiennych indukcyjnych.
Wejście. Pętla L z informacjami o definicjach osiągających i obliczeniami niezmienników pętli (z algorytmu 10.7). Wyjście. Zbiór zmiennych indukcyjnych. Z każdą zmienną indukcyjną j jest skojarzona trójka (i,c,d), gdzie i jest bazową zmienną indukcyjną, a c i d to stałe, dla których wartość j w punkcie, w którym jest definiowane, to c*i+d. Mówimy, że j należy do rodziny i. Bazowa zmienna indukcyjna i należy do swojej własnej rodziny. Metoda. 1.
Znajdź wszystkie bazowe zmienne indukcyjne, sprawdzając instrukcje z L. Użyj informacji o niezmiennikach pętli. Z każdą bazową zmienną indukcyjną i zwiąż trójkę (i, 1,0).
2.
Szukaj zmiennych k z pojedynczym przypisaniem do k w L mającym postać: k:=j*6, k:=Z?*j, k:=j/fc, k:=j±6,
k:=b±j
gdzie b jest stałą, a j jest zmienną indukcyjną, bazową lub nie jest bazową. Jeśli j jest zmienną bazową, to k jest w rodzinie j. Trójka dla k zależy od instrukcji ją definiującej. Przykładowo, jeśli k jest definiowane przez k: = to trójką dla k jest ( j , & , 0 ) . Trójki dla pozostałych przypadków można wyznaczyć analogicznie. Jeśli j nie jest zmienną bazową, niech j będzie w rodzinie i. Wtedy nasze dodat kowe wymagania to: (a) nie ma przypisań do i między pojedynczym punktem przypisania do j w L i przy pisaniem do k, (b) żadna z definicji j spoza L nie osiąga k. Typowym przypadkiem jest taki, w którym definicje k i j są tymczasowe i w tym samym bloku, więc warunki są łatwe do sprawdzenia. W ogólności, informacje o definicjach osiągających pozwolą sprawdzić potrzebne warunki, jeśli przeanalizujemy graf przepływu dla pętli L, aby wyznaczyć bloki (i w związku z tym definicje) na ścieżkach między przypisaniami do j a przypisaniami do k. Obliczmy trójkę dla k z trójki dla j i instrukcji definiującej k. Przykładowo, definicja k:=fr*j prowadzi do (i,b*c,b*d) dla k. Zauważmy, że mnożenia b*c i b*d można wykonać podczas analizy, ponieważ b, c i d są stałymi. • Po znalezieniu rodzin zmiennych indukcyjnych modyfikujemy instrukcje obliczające zmienne indukcyjne tak, aby używały dodawania lub odejmowania zamiast mnożenia. Zastępowanie droższych instrukcji tańszymi nazywamy redukcją mocy.
P r z y k ł a d 10.23. Pętla na rysunku 10.39(a), składająca się z bloku B , ma bazową zmienną indukcyjną i, ponieważ jedyne przypisanie w pętli do i zwiększa jego wartość o 1. Rodzina i zawiera t , gdyż jest tylko jedno przypisanie do t , którego prawą stroną jest 4*i. Wobec tego, trójka dla t to (i,4,0). Podobnie, j jest jedyną bazową zmienną indukcyjną pętli składającej się z 5 , a t , z trójką ( j , 4 , 0 ) , jest w rodzinie j . 2
2
2
2
3
4
B,
m-1 = n = 4*n
i
=
j By : = m-1 j := n := 4*n i
V
:= a
c s s
a[t ]
2 4
4*i 4* j
2
.
i
[til
B
2
i+1 s +4 s t 2 = a[t ] t i f t 3< v g o t o B i
B
2
2
2
:= i + 1 t := 4*i := a [ t ] t if t < v goto
s
2
2
2
3
2
2
3
V
B
3
3
B,
j J -1 s := s -4
:= j -1 U := 4*j := a [ t ] t if t > v goto B
4
4
4
5
5
4
t := s t := a [ t ] i f t > \r g o t o B 4
5
4
5
3
3
B
4
i >= j g o t o B
if
if
6
B
B.
i >= j g o t o B
6
B<
B<
t
(b)Po
(a) Przed Rys. 10.39. Redukcja mocy
Możemy również szukać zmiennych indukcyjnych w zewnętrznej pętli z wejściem B i blokami B , B B i B . Zarówno i , jak i j są bazowymi zmiennymi indukcyjnymi tej większej pętli. Ponownie, t i t są zmiennymi indukcyjnymi z trójkami, odpowiednio, (i,4,0)i(j,4,0). Graf przepływu z rysunku 10.39(b) otrzymano z grafu z rys. 10.39(a) przez zasto sowanie następnego algorytmu. Poniżej opisaliśmy to przekształcenie. • 2
2
v
Ą
5
2
Algorytm 10.10.
4
Redukcja mocy zastosowana do zmiennych indukcyjnych.
Wejście. Pętla L z informacjami o definicjach osiągających i rodzinami zmiennych induk cyjnych obliczonymi przy zastosowaniu algorytmu 10.9. Wyjście. Poprawiona pętla. Metoda. Rozważmy po kolei wszystkie bazowe zmienne indukcyjne i . Dla każdej zmien nej indukcyjnej j z rodziny i z trójką ( i , c , d ) : 1.
Stwórz nową zmienną s (jeśli dwie zmienne j tylko jedną zmienną dla obu trójek).
t
i j
2
mają takie same trójki, stwórz
2.
Zastąp przypisanie do j przypisaniem j : = s.
3.
Bezpośrednio po każdym przypisaniu i : = i + n w L , gdzie n jest stałą, dopisz
4.
s: =s+c*n gdzie wyrażenie c*n jest stałe, bo c i n są stałymi. Umieść s w rodzinie i, z trójką (i,c,d). Pozostaje zapewnić, by s było inicjowane na c*i+
: = c*i
/ * tylko s : =i, jeśli c jest równe 1 * /
s
: = s+d
/* opuszczamy, jeśli d jest równe 0 * /
Zauważmy, że s jest zmienną indukcyjną w rodzinie i.
•
P r z y k ł a d 10.24. Rozważmy pętle z rysunku 10.39(a) od środka na zewnątrz. Ponieważ pętle wewnętrzne złożone z bloków B i B są bardzo podobne, zajmiemy się tylko pę tlą wokół B . W przykładzie 10.23 odkryliśmy, że bazową zmienną indukcyjną w pętli wokół B jest j oraz że jest inna zmienna indukcyjna —• t z trójką (j,4,0). W kroku 1. algorytmu 10.10 tworzymy nową zmienną s . W kroku 2. przypisanie t : = 4 * j jest zastępowane przypisaniem t : =s . Krok 4. wstawia przypisanie s : = s - 4 po przypi saniu j : = j - l , gdzie - 4 jest otrzymywane z pomnożenia - l w przypisaniu do j i 4 w trójce ( j , 4 , 0 ) dla t . 2
3
3
3
4
4
4
4
4
4
4
4
Ponieważ B jest prologiem pętli, możemy umieścić inicjowanie s na końcu bloku B zawierającego definicję j . Dodatkowe instrukcje są pokazane w rozszerzeniu bloku B zaznaczonym linią przerywaną. x
4
{
x
Gdy rozważamy pętlę zewnętrzną, graf przepływu wygląda jak na rys. 10.39(b). Są cztery zmienne: i, s , j oraz s , które można uważać za zmienne indukcyjne. Jednakże krok 3. algorytmu 10.10 umieszcza nowo utworzone zmienne w rodzinach, odpowiednio, i oraz j, co — za pomocą kolejnego algorytmu — umożliwia eliminację i oraz j. • 2
4
Po redukcji mocy okazuje się, że niektóre zmienne indukcyjne są używane tyl ko w testach; możemy zastąpić testowanie takich zmiennych indukcyjnych testowaniem innych zmiennych. Przykładowo, jeśli i oraz t są takimi zmiennymi indukcyjnymi, że wartość t jest zawsze czterokrotnością wartości i, to test i>=j jest równoważny t>=4*j. Po takim zastąpieniu możliwa jest eliminacja i. Zauważmy jednak, że gdy t=-4*i, to musimy zmienić również operator relacyjny, ponieważ i>=j jest równoważ ne t < = - 4 * j . W poniższym algorytmie rozpatrujemy przypadek dodatniej stałej multiplikatywnej, pozostawiając Czytelnikowi uogólnienie na stałe ujemne jako ćwiczenie.
Algorytm 10.11.
Eliminacja zmiennych indukcyjnych.
Wejście. Pętla L z informacjami o definicjach osiągających, informacjami o niezmienni kach (z algorytmu 10.7) i informacjami o zmiennych żywych. Wyjście. Poprawiona pętla.
Metoda. 1.
Rozważmy wszystkie bazowe zmienne indukcyjne i, które są używane wyłącznie do obliczania innych zmiennych indukcyjnych ze swojej rodziny oraz w skokach warunkowych. Weźmy jakąś zmienną j z rodziny i, najlepiej taką, żeby c i d w jej trójce były tak proste, j a k to jest możliwe (preferujemy c = 1 i d = 0), i zmodyfikuj my wszystkie testy, w których występuje i, tak aby używały j . Załóżmy również, że c jest dodatnie. Test o postaci if i relop x goto B, gdzie x nie jest zmienną indukcyjną, jest zastępowany testem r:=c*x / * r : = x , jeśli c jest równe 1 * / r : =r+d /* niepotrzebne, gdy d równa się 0 * / if j relop r goto B gdzie r jest nową zmienną tymczasową. Przypadek if x relop i goto B jest rozpatrywany analogicznie. Jeśli w teście występują dwie zmienne indukcyjne, i oraz i , jak w i f i j relop i goto 5 , to sprawdzamy, czy można zastąpić jednocześnie i j oraz i . W prostym przypadku mamy j z trójką (i^Cp^) oraz j z trójką (i ,c ,
2
2
{
2
2
2
1
2
2
{
2
{
2
{
2
2
2
Na koniec, usuń z pętli L wszystkie przypisania do usuniętych zmiennych indukcyj nych, gdyż są one bezużyteczne. 2.
Rozważmy teraz wszystkie zmienne indukcyjne j , dla których algorytm 10.10 wsta wił instrukcję j : = s. Po pierwsze, sprawdźmy, czy nie m a przypisań do s między wstawioną instrukcją j : = s a dowolnym użyciem j . W typowym przypadku, j jest używane w bloku, w którym jest zdefiniowane, upraszczając ten test; w innym przypadku do implementacji testu są potrzebne informacje o definicjach osiągają cych i analiza grafu. Następnie zastąpmy wszystkie użycia j użyciem s i usuńmy instrukcję j : = s . •
Przykład 10.25. Rozpatrzmy graf przepływu z rys. 10.39(b). Pętla wewnętrzna wokół B zawiera dwie zmienne indukcyjne, i oraz s , ale żadna z nich nie może zostać usunięta, ponieważ s jest używana jako indeks w tablicy a, natomiast i jest używana w teście na zewnątrz pętli. Podobnie, pętla wokół B zawiera zmienne indukcyjne j oraz s , których nie można usunąć. 2
2
2
3
4
Zastosujmy algorytm 10.11 do pętli zewnętrznej. Gdy nowe zmienne s oraz s były tworzone przez algorytm 10.10, co opisaliśmy w przykładzie 10.24, s została umiesz czona w rodzinie i , a s w rodzinie j . Przyjrzyjmy się rodzinie i — j e s t ona używana jedynie do sprawdzania warunku wyjścia z pętli w bloku B , więc i jest kandydatką do usunięcia w kroku 1. algorytmu 10.11. Test w bloku B korzysta z dwóch zmiennych indukcyjnych, i oraz j . Na szczęście, rodziny i oraz j zawierają s oraz s z takimi samymi stałymi w ich trójkach, które są równe, odpowiednio, ( i , 4 , 0 ) oraz ( j , 4 , 0 ) . Test i > = j może być więc zastąpiony testem s > = s , pozwalającym na eliminację i oraz j . 2
4
2
4
Ą
Ą
2
2
4
4
Krok 2. algorytmu 10.11 stosuje propagację kopii do nowo stworzonych zmiennych, zastępując t i t przez, odpowiednio, s i s . • 2
4
2
4
Zmienne indukcyjne a niezmienniki pętli W algorytmach 10.9 i 10.10 możemy używać wyrażeń niezmienniczych w pętli zamiast stałych. Wtedy jednak trójki ( i , c , dla zmiennej indukcyjnej j mogą zawierać nie zmienniki pętli, a nie stałe. Obliczanie takich wartości powinno być wykonywane na zewnątrz pętli L, w prologu. Co więcej, ponieważ kod pośredni pozwala na wykonanie co najwyżej jednej operacji w jednej instrukcji, musimy być przygotowani na koniecz ność wygenerowania kodu pośredniego wyliczającego wartość wyrażenia. Zastępowanie testów w algorytmie 10.11 wymaga znajomości znaku stałej multiplikatywnej c. Z te go powodu rozsądne może być ograniczenie rozważań do przypadków, w których c jest znaną stałą.
10.8
Obsługa synonimów
Jeśli dwa lub więcej wyrażeń oznacza ten sam adres w pamięci, mówimy, że wyrażenia te są swoimi synonimami. W tym podrozdziale zajęliśmy się analizą przepływu danych w obecności wskaźników i procedur, które mogą wprowadzać synonimy. Wprowadzenie wskaźników utrudnia analizę przepływu danych, ponieważ powodują one niepewność odnośnie do tego, co jest definiowane i używane. Jedynym bezpiecznym założeniem, które możemy przyjąć, nie wiedząc nic o miejscu, które może wskazywać wskaźnik jest to, że pośrednie przypisanie przez wskaźnik może potencjalnie zmienić (tj. zdefiniować) dowolną zmienną. Musimy również przyjąć, że użycie danych wskazy wanych przez wskaźnik, np. x: =*p, może być użyciem dowolnej zmiennej. Takie zało żenia skutkują większą niż rzeczywista liczbą żywych zmiennych i definicji osiągających i mniejszą niż rzeczywista liczbą wyrażeń dostępnych. Na szczęście, do ograniczenia liczby miejsc, które może wskazywać wskaźnik, możemy zastosować analizę przepły wu danych, co pozwala otrzymać dokładniejsze dane z innych naszych analiz przepływu danych. Tak jak przy przypisaniach do zmiennych wskaźnikowych, gdy dochodzimy do wy wołań procedur, nie musimy przyjmować naszych założeń dla najgorszego przypadku — że wszystko może zostać zmienione — pod warunkiem, że potrafimy wyliczyć zbiór zmiennych, które mogą zostać zmienione przez procedurę. Tak jak w przypadku wszyst kich optymalizacji, błędy możemy popełniać w bezpiecznym kierunku. To znaczy, że zbiór zmiennych, których wartości „mogą być" zmienione bądź użyte, może być właści wym nadzbiorem zbioru zmiennych, które rzeczywiście zostaną zmienione bądź użyte w jakimś wykonaniu programu. Jak zwykle, będziemy po prostu próbowali dojść jak naj bliżej do prawdziwych zbiorów zmiennych zmienionych lub użytych, bez nadmiernego wysiłku i bez wprowadzania błędów zmieniających wynik programu. Prosty język ze wskaźnikami Dla ustalenia uwagi, rozważmy język, w którym istnieją proste typy danych (np. liczby całkowite i zmiennopozycyjne), których wartości zajmują w pamięci jedno słowo, oraz
tablice tych typów. Niech w tym języku będą również wskaźniki wartości typów prostych i tablic, ale nie innych wskaźników. Wystarczy nam wiedza, że wskaźnik p wskazuje któ ryś z elementów tablicy a, i nie jest istotne, który konkretnie element a jest wskazywany. Takie grupowanie wszystkich elementów tablicy, jako celów wskaźników, jest rozsądne. Zazwyczaj wskaźniki są używane jako kursory do przechodzenia po całej tablicy, więc z dokładniejszej analizy przepływu danych, jeśli udałoby się ją przeprowadzić, i tak czę sto wynikałoby, że w danym punkcie programu p może wskazywać dowolny element tablicy a. Musimy również założyć, jakie operacje na wskaźnikach są semantycznie popraw ne. Po pierwsze, jeśli wskaźnik p wskazuje podstawowy (zajmujący jedno słowo) typ danych, to dowolna operacja arytmetyczna na p tworzy wartość, która może być liczbą, ale nie wskaźnikiem. Jeśli p wskazuje tablicę, to dodanie lub odjęcie liczby powoduje, że p wskazuje jakieś miejsce w tej samej tablicy, podczas gdy inne operacje arytme tyczne na wskaźniku tworzą wartość, która nie jest wskaźnikiem. Chociaż nie wszystkie języki zabraniają, na przykład, przesunięcia wskaźnika z tablicy a do innej tablicy b poprzez dodawanie do wskaźnika, taka operacja zależałaby od konkretnej implementa cji, która zapewniłaby, że b będzie położone w pamięci po a. Według nas, kompilator optymalizujący powinien podczas podejmowania decyzji, które optymalizacje wykonać, korzystać tylko z definicji języka. Każdy autor kompilatora musi jednak sam ocenić, które optymalizacje kompilator powinien móc wykonywać.
Skutki przypisań przy użyciu wskaźników Przy takich założeniach jedynymi zmiennymi, które mogą być używane jako wskaźniki, są te, które zostały zadeklarowane jako wskaźniki oraz zmienne tymczasowe, otrzymu jące wartość jakiegoś wskaźnika plus/minus stała. Będziemy mówić o tych wszystkich zmiennych jako o wskaźnikach. Nasze zasady określania, co może wskazywać wskaźnik p, są następujące. 1.
Jeśli jest instrukcja przypisania s: p : =&a, to, bezpośrednio po s, p wskazuje tylko a. Jeśli a jest tablicą, to p może wskazywać tylko a po dowolnym przypisaniu o postaci p : = & a ± c , gdzie c jest stałą . Jak zwykle, &a musi być wskaźnikiem odwołującym się do pierwszego elementu tablicy a. Jeśli w programie jest instrukcja przypisania s: p : = q ± c , gdzie c jest liczbą całko witą różną od zera, a p oraz q są wskaźnikami, to — bezpośrednio po s — p może wskazywać dowolną tablicę, na którą q mogło wskazywać przed wykonaniem s, ale nie może wskazywać nic innego. Jeśli w programie jest instrukcja s: p : = q , to — bezpośrednio po s — p może wskazywać to, co q mogło wskazywać przed s. Po dowolnym innym przypisaniu do p, p nie może wskazywać żadnego obiektu; takie przypisanie zapewne (w zależności od semantyki języka) nie ma sensu. Po dowolnym przypisaniu do innej zmiennej niż p, p wskazuje to, co przed przypisa niem. Zauważmy, że ta zasada zakłada, że wskaźnik nie może wskazywać wskaźnika. Osłabienie tego założenia nie ma większego znaczenia, a uogólnienie pozostawiamy Czytelnikowi. 1
2.
3. 4. 5.
1
W tym punkcie + oznacza dodawanie, a nie dowolny operator.
Zdefiniujmy in[B) dla bloku B jako funkcję, która przypisuje każdemu wskaźnikowi p zbiór zmiennych, które p może wskazywać na początku B. Formalnie, in[B] jest zbiorem par o postaci (p, a ) , gdzie p jest wskaźnikiem, natomiast a jest zmienną, co oznacza, że p może wskazywać a. W praktyce, in[B] może być reprezentowane jako lista dla każdego wskaźnika, z listą dla p oznaczającą zbiór a, takich że (p, a ) jest w in[B]. Analogicznie, dla końca B definiujemy out[B]. Określmy funkcję przeniesienia, trans , która definiuje efekty bloku B. To znaczy, że trans jest funkcją, która jako argument przyjmuje zbiór par 5 o postaci (p, a ) , ze wskaźnikiem p i zmienną niewskaźnikową a, zwraca natomiast inny zbiór T. Przyjmijmy, że trans będzie stosowane do zbioru in[B], a rezultatem tego będzie out[B], Poniżej wyjaśniliśmy, j a k obliczyć trans dla pojedynczych instrukcji; trans będzie złożeniem transs dla wszystkich instrukcji s bloku B. Zasady obliczania trans są następujące. B
B
B
B
1.
Jeśli s to p : =&a lub p : = a ± c , w przypadku, gdy a jest tablicą, to trans (S) s
2.
Jeśli s to p: =q±c dla wskaźnika q i niezerowej liczby całkowitej c, to trans (S) s
3.
s
= (5-{(p, b)| wszystkich zmiennych b}) U{(p, b)|(q, b) jest w S}
Jeśli s przypisuje do wskaźnika p dowolne inne wyrażenie, to trans {S) s
5.
= (S— {(p, b)| wszystkich zmiennych b}) U{(p, b)|(q, b) jest w S, natomiast b jest tablicą}
Zauważmy, że zasada ta ma sens również, gdy p = q. Jeśli s to p: =q, to trans (S)
4.
= (S— {(p, b)| wszystkich zmiennych b}) U {(p, a ) }
= S - {(p, b) | wszystkich zmiennych b}
Jeśli s nie jest przypisaniem do wskaźnika, to trans (S) s
= 5.
Możemy teraz napisać równania wiążące in, out i trans out[B] = trans (in[B]) in[B] = U out[P] B
(10.13)
P poprzednik B
gdzie, jeśli B składa się z instrukcji s , s , . . s , to {
trans (S) = trans {trans B
Sk
Ski
2
k
(• • • (trans {trans ^ Sl
s
(S))) • • •))
Równania (10.13) mogą być rozwiązane podobnie jak definicje osiągające z algorytmu 10.2. Nie będziemy więc szczegółowo opisywać algorytmu, lecz zadowolimy się przy kładem. P r z y k ł a d 10.26. Rozważmy graf przepływu z rys. 10.40. Załóżmy, że a jest tablicą, c liczbą całkowitą, a p i q to wskaźniki. Początkowo, nadajemy wartość 0 . Na stępnie, trans skutkuje usunięciem wszystkich par z pierwszą współrzędną q oraz dodaniem pary (q, c ) . To znaczy, że q musi wskazywać c . Wobec tego B
out[By] = trans ^(0) B
= {(q, c)}
Następnie, in[B ] — out[B ]. Skutkiem p : =&c jest zastąpienie wszystkich par z pierwszą współrzędną p parą (p, c). Skutkiem q:=& (a [2] ) jest zastąpienie par z pierwszą współrzędną q parą (q, a). Zauważmy, że q:=& {a [2] ) jest w istocie przypisaniem o postaci q:=&a+c dla stałej c. Możemy teraz obliczyć 2
out[B ]=
x
trans ({(ą,
2
c)}) = {(p, c), (q, a)}
B2
Podobnie, in[B ] = {(q, c)} i out[B ] — {(p, a), (q, c)}. 3
3
Następnie znajdujemy
in[B ] = out[B ] U out[B ] U out[B ], 4
2
3
5
Oczywiście,
out[B ] 5
było zainicjowane na 0 i w tym przejściu nie było jeszcze zmieniane. Jednakże out[B ] = {(p, c), (q, a)} i out[B ] = {(p, a), (q, c)}, więc 2
3
in[B ] = {(p, a), (p, c), (q, a), (q, c)} 4
Skutkiem p: =p+l w B jest odrzucenie możliwości, że p nie wskazuje tablicy. Wtedy Ą
out[B ] = trans (in[B ]) 4
BĄ
= {(p
4
a), (q, a), (q, c)}
i
c 0
Zauważmy, że za każdym razem, gdy wykonujemy Z?> powoduje, że p wskazuje c, jeśli p jest używane pośrednio po p:= p+1 w B , to wykonywana jest akcja bez semantycznego znaczenia. Wobec tego, ten graf przepływu nie jest „realistyczny", ale przedstawia możliwe wnioskowanie o wskaźnikach. Kontynuując, in[B ] = out[B ] a trans ^ kopiuje cele q i przypisuje je p. Ponieważ 2
Ą
5
4 9
B
q w in[B ] może wskazywać a lub c, to 5
out[B ] 5
= {(p, a), (p, c), (q, a), (q, c)}
W następnym przebiegu obliczamy in[B ] = out[B ], więc out[B ] = {(p, a ) , (q, c ) } . Ta wartość jest również nowym in[B ] i in[B ] ale te nowe wartości nie zmieniają ani 0wf[Zł]» i out[B ], ani in[B ]. Doszliśmy więc do oczekiwanej odpowiedzi. • {
2
a n
2
3
4
4
3 t
{
Wykorzystywanie informacji o wskaźnikach Załóżmy, że in[B] jest zbiorem zmiennych, które wskazują wszystkie wskaźniki na po czątku bloku B oraz że w bloku B odwołujemy się do wskaźnika p . Zaczynając od in[B], zastosujmy t r a n s do wszystkich instrukcji s bloku B poprzedzających odwołanie do p. Z takiego obliczenia dowiemy się, co może wskazywać p w instrukcji, w której jest to istotne. Przypuśćmy, że obliczyliśmy co może wskazywać każdy ze wskaźników w chwi li, gdy jest on używany w pośrednim odwołaniu, po lewej lub prawej stronie symbo lu przypisania. Jak możemy używać takich informacji do znajdowania dokładniejszych rozwiązań zwykłych problemów przepływu danych? W każdym przypadku musimy roz ważyć, w którym kierunku błędy są bezpieczne, i używać informacji o wskaźnikach w taki sposób, aby powstawały tylko takie błędy. Ułatwi nam to przeanalizowanie dwóch przykładów: definicji osiągających i analizy zmiennych żywych. Do obliczenia definicji osiągających możemy użyć algorytmu 10.2, ale musimy znać wartości kill i g e n dla bloku. Zbiory gen dla instrukcji, które nie są pośrednimi przy pisaniami dokonanymi przy użyciu wskaźników, są obliczane jak poprzednio. Pośrednie przypisanie *p: =a musi wygenerować definicje wszystkich zmiennych b takich, dla któ rych p może wskazywać b. Takie założenie jest konserwatywne, gdyż — j a k opisano w p. 10.5 — przyjęcie, że zmienna osiągnie punkt, podczas gdy w rzeczywistości go nie osiąga, jest zachowaniem konserwatywnym. B
Obliczając kill, przyjmujemy, że *p: =a zabija definicje b tylko, jeśli b nie jest tabli cą i jest jedyną zmienną, którą może wskazywać p. Jeśli p może wskazywać dwie lub wię cej zmiennych, przyjmujemy, że żadna z nich nie została zabita. Znowu zachowujemy się konserwatywnie, gdyż pozwalamy definicjom b przejść przez *p: = a i w związku z tym osiągnąć jak najdalsze punkty, chyba że możemy wykazać, iż *p: = a przedefiniowuje b. Innymi słowy, gdy mamy wątpliwości, zakładamy, że definicja osiąga dane miejsce. Do obliczenia żywych zmiennych możemy użyć algorytmu 10.4, ale musimy wcze śniej przemyśleć, jak powinny być zdefiniowane zbiory d e f i use dla instrukcji o postaci *p:=a i a:=*p. Instrukcja *p:=a używa tylko a i p. Mówimy, że definiuje ona b tylko wtedy, gdy b jest jedyną zmienną, którą może wskazywać p. To założenie pozwala użyciom b przejść przez definicję, chyba że są one na pewno blokowane przez przypi sanie *p: =a. Nie będziemy więc nigdy twierdzić, że b jest martwe w danym punkcie, gdy w rzeczywistości jest tam żywe. Instrukcja a : =*p z pewnością jest definicją a. Jest ona też użyciem p i użyciem wszystkich zmiennych, które może wskazywać p. Zawyża jąc liczbę używanych zmiennych, zawyżamy szacowanie zbioru zmiennych żywych, a to jest działanie konserwatywne. Możemy, na przykład, wygenerować kod, który zapamięta zmienną martwą, ale nie zdarzy się, że nie zapamiętamy zmiennej żywej. A n a l i z a p r z e p ł y w u danych w obecności wielu procedur Dotychczas mówiliśmy o „programach", które były pojedynczymi procedurami i, w związ ku z tym, pojedynczymi grafami przepływu. Teraz zajmiemy się zbieraniem informacji z wielu procedur oddziałujących na siebie. Podstawowe podejście polega na sprawdze niu, jak każda z procedur wpływa na zbiory g e n , kill, use i d e f pozostałych procedur, a następnie obliczeniu — niezależnym dla każdej procedury — informacji o przepływie danych, tak jak to robiliśmy wcześniej.
Podczas analizy przepływu danych musimy obsługiwać synonimy tworzone przez parametry w wywołaniach procedur. Ponieważ nie jest możliwe, aby dwie zmienne glo balne oznaczały ten sam obszar pamięci, co najmniej jeden z synonimów musi być para metrem formalnym. Ponieważ parametry formalne mogą być przekazywane do procedur, możliwe jest, że dwa parametry formalne będą synonimami.
P r z y k ł a d 10.27. Rozpatrzmy procedurę p z dwoma parametrami formalnymi, x i y, przekazywanymi przez referencję. Na rysunku 10.41 przedstawiona jest sytuacja, w której b + x jest obliczane w B i B Przypuśćmy, że wszystkie ścieżki z bloku B do £ prowadzą przez B i że wzdłuż żadnej z takich ścieżek nie ma przypisań do b ani x. Czy b + x jest więc dostępne w fl ? Odpowiedź zależy od tego, czy x i y mogą oznaczać ten sam adres w pamięci. Możliwe jest, na przykład, wywołanie p ( z , z ) albo wywołanie p (u, v ) , gdzie u i v są parametrami formalnymi innej procedury q (u, v) i możliwe jest wywołanie q { z , z ) . x
v
x
3
2
3
a := b+x
5 y := c
Bi
B^
5 d := b+x
5,
Rys. 10.41. Przykład problemów z synonimami
Podobnie jest możliwe, aby x i y były synonimami, jeśli x jest parametrem formal nym, na przykład p ( x , w ) , a y jest zmienną o zasięgu dostępnym dla pewnej procedury q, która wywołuje p, na przykład w ten sposób: p ( y , t ) . Są jeszcze bardziej skompli kowane sytuacje, w których x i y są synonimami, i poniżej podaliśmy ogólne zasady wyznaczania wszystkich par synonimów. • Okazuje się, że w niektórych sytuacjach zachowaniem konserwatywnym jest nietraktowanie zmiennych jako swoich synonimów. Przy definicjach osiągających, na przykład, jeśli chcemy przyjąć, że definicja a jest zabijana przez definicję b , musimy być pewni, że a i b są synonimami za każdym razem, gdy b jest definiowana. W innych sytuacjach, konserwatywne jest traktowanie zmiennych jako synonimów, mimo istniejących wątpli wości (patrz przykład 10.27). Jeśli wyrażenie dostępne b + x nie m a być zabijane przez definicję y, musimy być pewni, że ani b , ani x nie są synonimami y. Model k o d u z w y w o ł a n i a m i p r o c e d u r Musimy umieć radzić sobie z synonimami. Rozważmy więc język, w którym jest dopusz czalne rekurencyjne wywoływanie procedur odwołujących się do zmiennych lokalnych i globalnych. Dane dostępne dla procedury to wyłącznie zmienne globalne i jej własne
lokalne; znaczy to, że w takim języku nie ma struktury blokowej. Parametry przekazywa ne są przez referencję. Wszystkie procedury muszą mieć graf przepływu z pojedynczym wejściem (wierzchołkiem początkowym) i pojedynczym wierzchołkiem końcowym, który powoduje, że sterowanie wraca do procedury wywołującej. Dla ułatwienia przyjmijmy, że każdy wierzchołek leży na pewnej ścieżce od wejścia do końca. Załóżmy, że jesteśmy w procedurze p i trafiliśmy na wywołanie procedury q ( u , v ) . Jeśli jesteśmy zainteresowani obliczeniem definicji osiągających, wyrażeń dostępnych albo dowolnych innych analiz przepływu danych, musimy wiedzieć, czy q ( u , v ) może zmienić wartość jakiejś zmiennej. Podkreślmy: „może zmienić", a nie „zmieni". Tak jak przy wszystkich innych problemach związanych z przepływem danych, nie zawsze można stwierdzić, czy wartość danej zmiennej zmieni się, czy nie. Możemy tylko znaleźć zbiór, który zawiera wszystkie zmienne, których wartość się zmieni i, być może, trochę zmiennych, których wartość się nie zmieni. Jeśli będziemy ostrożni, możemy zmniejszyć liczbę zmiennych drugiego rodzaju, otrzymując dobre przybliżenie prawdziwego zbioru i myląc się tylko w bezpiecznym kierunku. Jedyne zmienne, których wartości można zdefiniować przez wywołanie q ( u , v ) , to zmienne globalne oraz zmienne u i v , które mogą być lokalne dla p . Definicje zmiennych lokalnych nie mają znaczenia po powrocie z wywołania. Nawet jeśli p = q, zmienione zostaną inne kopie zmiennych lokalnych procedury q i kopie te znikną po powrocie. Łatwo jest stwierdzić, które zmienne globalne są bezpośrednio definiowane przez q; wystarczy wiedzieć, które z nich mają definicję w q albo są definiowane w wywołaniu procedury wykonywanym przez q. Ponadto u i/lub v , które mogą być zmiennymi globalnymi, zmieniają się, jeśli w q jest definiowany jej, odpowiednio, pierwszy i/lub drugi parametr formalny, albo jeśli te parametry formalne są przekazywane przez q do innej procedury, która j e definiuje, jako parametry aktualne. Jednak nie wszystkie zmienne zmienione przez wywołanie q muszą być jawnie definiowane przez q albo jedną z wywoływanych przez nią procedur, ponieważ zmienne mogą mieć synonimy.
Obliczanie s y n o n i m ó w Zanim odpowiemy na pytanie, które zmienne mogą zostać zmienione w danej procedu rze, musimy opracować algorytm znajdowania synonimów. Podejście, którego użyliśmy, jest proste. Obliczamy relację równoważności ( ~ ) na zmiennych, formalizującą pojęcie „możliwości bycia synonimem". Podczas jej obliczania nie odróżniamy wystąpień zmien nej w różnych wywołaniach tej samej procedury, ale odróżniamy zmienne lokalne o tej samej nazwie w różnych procedurach. Chcąc uprościć zadanie, nie budujemy różnych zbiorów synonimów dla różnych punktów programu. Natomiast — jeśli dwie zmienne mogą kiedykolwiek być swoimi synonimami — przyjmujemy, że mogą być nimi zawsze. Przyjmujemy również kon serwatywne założenie, że = jest relacją przechodnią. Z tego wynika, że zmienne są grupowane w klasy abstrakcji i dwie zmienne mogą być swoimi synonimami tylko, jeśli są w tej samej klasie.
A l g o r y t m 10.12.
Proste obliczanie synonimów.
Wejście. Zestaw procedur i zmiennych globalnych.
Wyjście. Relacja równoważności = o następującej własności: jeśli w jakimkolwiek miej scu programu x i y są swoimi synonimami, to x = y; odwrotność tego nie musi być prawdą. Metoda. 1.
2.
Jeśli jest to konieczne, przemianuj zmienne tak, aby żadne procedury nie miały takich samych parametrów formalnych ani zmiennych lokalnych i aby nie było pa rametrów, zmiennych lokalnych i/lub globalnych o tej samej nazwie. Jeśli w programie jest procedura p ( X j , x , . . . , x ) i wywołanie tej proce dury p ( y j , y , . . . , y ) , ustal x,- = y dla wszystkich i. Znaczy to, że każdy parametr formalny może być synonimem dowolnego z odpowiadających mu para metrów aktualnych. Oblicz zwrotne i przechodnie domknięcie relacji odpowiedniości argumentów for 2
2
3.
r t
rt
t
malnych i aktualnych, dodając a) b)
x = y, gdy y = x, x = z, gdy x = y i y = z dla pewnego y.
•
P r z y k ł a d 1 0 . 2 8 . Rozważmy szkic trzech procedur z rys. 10.42, przy założeniu, że pa rametry są przekazywane przez referencję. Mamy dwie zmienne globalne, g i h, oraz dwie zmienne lokalne, i w procedurze m a i n oraz k w procedurze d r u g a . Procedu ra p i e r w s z a m a dwa parametry formalne, w i x, procedura d r u g a ma parametry y i z, a m a i n nie ma parametrów formalnych. Nie jest więc potrzebne przemianowywanie zmiennych. Obliczmy najpierw synonimy wynikające z odpowiedniości argumentów formalnych i aktualnych. Wywołanie p i e r w s z a z m a i n czyni h = w i i = x. Pierwsze wywołanie d r u g a przez p i e r w s z a czyni w = y i w = z. Drugie wywołanie czyni g = y i x = z.
g l o b a l g , h; procedurę main( ); local i; g := . . . ; pierwsza(h, i) end p r o c e d u r ę p i e r w s z a ( w , x) ; x := ... ; druga(w, w); d r u g a ( g , x) end; p r o c e d u r ę d r u g a ( y , z) ; l o c a l k; h := ... ; p i e r w s z a ( k , y) end
Rys. 10.42. Przykładowe procedury
Wywołanie p i e r w s z a przez d r u g a czyni k = w i y = x. Gdy obliczymy dom knięcie przechodnie relacji synonimów reprezentowanej przez = , okaże się, że w tym przykładzie każda zmienna może być synonimem dowolnej innej zmiennej. • Obliczanie synonimów przez algorytm 10.12 niezbyt często kończy się znalezie niem tak dużej liczby synonimów, jak w przykładzie 10.28. Intuicyjnie, nie oczekujemy, żeby dwie zmienne różnych typów były synonimami. Co więcej, programista niewątpli wie przypisuje swoim zmiennym jakieś znaczenie. Przykładowo, gdy pierwszy parametr formalny procedury p reprezentuje szybkość, to o pierwszym argumencie dowolnego wywołania p programista pewnie również będzie myślał jako o szybkości. W związku z tym oczekujemy, że większość programów będzie generowała małe grupy możliwych synonimów.
Analiza przepływu danych w obecności wywołań procedur Rozważmy, jako przykład, jak mogą być obliczone wyrażenia dostępne w obecności wy wołań procedur, których parametry są przekazywane przez referencję. Tak jak w podroz dziale 10.6, musimy określić, kiedy zmienna może być zdefiniowana, zabijając wyrażenie, oraz, kiedy wyrażenia są generowane (obliczane). Możemy zdefiniować, dla każdej procedury p, zbiór zmienianej], którego wartością będzie zbiór zmiennych globalnych i parametrów formalnych p, które mogą zostać zmie nione podczas wykonywania p. Nie uważamy, że zmienna została zmieniona, jeśli została zmieniona inna zmienna z klasy abstrakcji relacji równoważnościowej jej synonimów. Niech def[p) będzie zbiorem parametrów formalnych i zmiennych globalnych, które mają definicje jawne w p (nie wliczając tych, które są definiowane w procedurach wy woływanych przez p). Aby zapisać równania na zmieniane[p], musimy tylko powiązać zmienne globalne i parametry formalne p — używane jako parametry aktualne w wy wołaniach robionych przez p, z odpowiadającymi parametrami formalnymi procedur wy woływanych. Możemy napisać zmieniane[p]
= def[p] UAUG
(10.14)
gdzie: 1)
2)
A = { a | a jest zmienną globalną lub takim parametrem formalnym p, że dla pew nej procedury q i liczby całkowitej i, p wywołuje q z a jako i-tym parametrem aktualnym oraz i-ty parametr formalny q jest w zmieniane^}, G =• {g | g jest zmienną globalną ze zmieniane[q\ oraz p wywołuje q}.
Nie powinno nas dziwić, że równanie (10.14) można rozwiązać dla zbioru procedur, używając techniki iteracyjnej. Chociaż rozwiązanie nie jest jednoznaczne, potrzebujemy tylko rozwiązania najmniejszego. Możemy dochodzić do tego rozwiązania, zaczynając od zbyt małego przybliżenia i iterując. Oczywiście, zbyt małe przybliżenie, od którego mo żemy zacząć, to zmieniane[p] = def[p]. Opisanie szczegółów iterowania pozostawiamy Czytelnikowi jako ćwiczenie. Wart rozważenia jest porządek, w którym powinny być odwiedzane procedury w po wyższej iteracji. Przykładowo, jeśli procedury nie są wzajemnie rekurencyjne, możemy najpierw odwiedzić procedury, które nie wywołują żadnych innych (musi być co najmniej
jedna taka). Dla tych procedur zmieniane = def'. Następnie możemy obliczyć zmieniane dla tych procedur, które wywołują tylko procedury, nie wywołujące żadnych procedur. Możemy zastosować do nich bezpośrednio (10.14), bo zmieniane[q\ jest znane dla wszyst kich q potrzebnych w (10.14). Tę propozycję można uściślić w następujący sposób. Rysujemy graf wywołań, któ rego wierzchołki są procedurami, z krawędzią od p do q, gdy p wywołuje ą . Zbiór procedur, które nie są wzajemnie rekurencyjne, m a acykliczny graf wywołań, więc każdy z wierzchołków możemy odwiedzić tylko raz. Poniższy algorytm służy do obliczania zbioru zmieniane. l
Algorytm 10.13.
Międzyproceduralna analiza zmienianych zmiennych.
Wejście. Zbiór procedur p
p
p, 2
. . . , p . Jeśli graf wywołań jest acykliczny, przyjmuje n
my, że p- wywołuje p- tylko, jeśli j < i. W przeciwnym przypadku, nie wiemy, które (
;
procedury wywołują inne. Wyjście. Dla każdej procedury p produkujemy zmieniane[p],
zbiór zmiennych globalnych
i parametrów formalnych p, które mogą być jawnie zmienione przez p bez uwzględniania synonimów. Metoda. 1. 2.
Oblicz def[p] dla każdej procedury p. Wykonaj program z rys. 10.43, aby obliczyć zmieniane.
•
(1) for każdej procedury p do zmieniane[p] := def[p]\ /* inicjowanie */ (2) w h i l e zachodzą zmiany w dowolnym zmieniane[p] d o (3) f o r i := 1 t o n d o (4) f o r każdej procedury q wywoływanej przez p . d o b e g i (5) dodaj wszystkie zmienne ze zmieniane[ą] do zmieniane[p^\ (6) for każdego parametru formalnego x (y-tego) procedury q do (7) i f x jest w zmieniane[ą] t h e n (8) f o r każdego wywołania q przez p- d o (9) if a, j-iy parametr aktualny w wywołaniu jest zmienną globalną bądź parametrem formalnym p (10) t h e n dodaj a do zmieniane^}} f
n
(
(
end Rys. 1 0 . 4 3 . Algorytm iteracyjny obliczający zmieniane Przykład 10.29.
Rozważmy ponownie rys. 10.42. Po sprawdzeniu, def[main] = {g}, i def[druqa) Są to początkowe wartości zmieniane. wywołań jest na rys. 10.44. Rozpatrzymy procedury w kolejności
de/Ipierwsza] = {x} main. 1
— {h}.
druga, pierwsza,
Przyjmujemy, że nie ma zmilnnych typów proceduralnych. Komplikują one budowę grafu wywołań, bo podczas budowy grafu musimy wyznaczyć możliwe parametry aktualne, które odpowiadają parametrom formalnym typu proceduralnego.
Graf
Rys. 10.44. Graf wywołań
Rozważmy p - = d r u g a w programie z rys. 10.42. Wówczas q w wierszu (4) może być tylko procedurą p i e r w s z a . Ponieważ początkowo zmieniane[p ierw sza] = {x}, w wierszu (5) nic nie jest dodawane do zmieniane[druga]. W wierszach (6) i (7) musimy rozpatrywać tylko drugi parametr formalny procedury p i e r w s z a , bo pierwszy parametr aktualny jest zmienną lokalną dla d r u g a . W jedynym wywołaniu p i e r w s z a przez d r u g a drugim parametrem aktualnym jest y, a odpowiadający mu parametr formalny, x, jest zmieniany, więc druga[x] nadajemy wartość {h, y } w wierszu (10). Rozważmy teraz p- = p i e r w s z a . W wierszu (4) q może być tylko procedurą d r u g a . W wierszu (5), h jest zmienną globalną ze zmieniane[dr ug a], więc wykonuje my przypisanie zmieniane[p ierw sza] = {h, x } . W wierszach (6) i (7) tylko pierwszy parametr formalny procedury d r u g a jest w zmieniane[dr ug a], musimy więc dodać g i w do zmieniane[p ierw sza] w wierszu (10), bo są to pierwsze argumenty aktualne w dwóch wywołaniach procedury d r u g a . Mamy zmieniane[p ierw sza] — {g, h, w, x } . Rozważmy procedurę m a i n . Procedura p i e r w s z a zmienia oba swoje parametry formalne, więc h oraz i będą zmienione w wywołaniu p i e r w s z a przez m a i n . Jed nakże — ponieważ i jest zmienną lokalną — nie trzeba się nią zajmować. Wobec tego wykonujemy zmieniane[main] = {g, h } . Następnie powtarzamy pętlę while z wiersza (2). Rozpatrując d r u g a , zauważamy, że p i e r w s z a zmienia globalną wartość g. Czy li wywołanie p i e r w s z a ( k , y ) powoduje modyfikację g i zmieniane[dr ug a] - { g , h, y } . Nie m a dalszych zmian w trakcie iterowania. • (
Używanie informacji o zmianach Jako przykład użycia zmieniane rozpatrzmy globalne obliczanie podwyrażeń wspólnych. Przypuśćmy, że obliczamy wyrażenia dostępne dla procedury p i że chcemy obliczyć a_kill[B] dla bloku B. Definicja zmiennej a musi zabijać każde wyrażenie, w którym jest wykorzystywane a lub dowolne x, które może być synonimem a. Wywołanie z B procedury q nie może jednak zabić wyrażenia używającego a, chyba że a jest syno nimem (pamiętajmy, że a jest swoim synonimem) jakiejś zmiennej ze zmieniane[g\. Wobec tego, informacje zebrane za pomocą algorytmów 10.12 i 10.13 mogą być użyte do skonstruowania dopuszczalnego przybliżenia zbioru wyrażeń zabijanych. Do obliczenia wyrażeń dostępnych dla programów z wywołaniami procedur musimy dysponować metodą dopuszczalnego przybliżania zbioru wyrażeń generowanych przez wywołanie procedury. Aby zachowanie było bezpieczne, możemy przyjąć, że a + b jest generowane przez wywołanie q wtedy i tylko wtedy, gdy na każdej ścieżce od wejścia do q do wyjścia z niej znajduje się a + b i nie m a dalszych definicji a ani b. Gdy szukamy
wystąpień a + b , nie możemy akceptować x + y jako wystąpień, chyba że jesteśmy pewni, że w każdym wywołaniu q , x jest synonimem a oraz y jest synonimem b. Przyjmujemy to założenie, gdyż bezpiecznym błędem jest stwierdzenie, że wyraże nie nie jest dostępne, gdy w rzeczywistości jest. Wobec tego musimy przyjąć, że a + b jest zabijane przez definicję dowolnego z, które może być synonimem a albo b. W związku z tym, najprostszą metodą obliczenia wyrażeń dostępnych dla wszystkich wierzchołków wszystkich procedur jest przyjęcie, że wywołanie nic nie generuje, a zbiory a-kill[B] dla wszystkich bloków B są takie, jak obliczone powyżej. Ponieważ nie spodziewamy się, że typowa procedura będzie generować wiele wyrażeń, takie podejście jest wystarczające do większości zastosowań. Bardziej skomplikowanym i dokładniejszym podejściem do obliczania wyrażeń do stępnych jest iteracyjne obliczanie gerc[p] dla wszystkich procedur p. Możemy zainicjo wać gen[p] na zbiór wyrażeń dostępnych na końcu wierzchołka końcowego p , używając metody opisanej wcześniej. Oznacza to, że nie rozważamy istnienia synonimów wyrażeń i a + b reprezentuje tylko siebie, nawet jeśli a lub b mogą mieć synonimy. Obliczmy raz jeszcze dostępne wyrażenia dla wszystkich wierzchołków wszystkich procedur. Wywołanie q ( a , b ) generuje wyrażenia z gen[q] z a oraz b podstawionymi za odpowiednie parametry formalne q; a^kill pozostaje nie zmienione. Nową wartość gen[p] dla wszystkich procedur p można znaleźć, sprawdzając, które wyrażenia są dostępne na końcu powrotu z p. Taka iteracja może być powtarzana, aż przestaną zachodzić zmiany w zbiorach wyrażeń dostępnych.
10.9
Analiza przepływu danych w strukturalnych grafach przepływu
Programy bez instrukcji goto mają redukowalne grafy przepływu; takie są również gra fy przepływu programów pisanych zgodnie z wieloma metodologiami programowania. Różne analizy wykazały, że prawie wszystkie programy napisane przez ludzi mają redu kowalne grafy przepływu . Ta obserwacja ma znaczenie dla optymalizacji, gdyż możemy znaleźć algorytmy optymalizacji, które będą działały zdecydowanie szybciej na reduko walnych grafach przepływu (niż na nieredukowalnych). W tym podrozdziale wyjaśniliśmy wiele pojęć związanych z grafami przepływów, takich j a k np. „analiza przedziałów", które są najbardziej użyteczne do strukturalnych grafów przepływu. W istocie, zastosowaliśmy techniki sterowane składnią, opracowane w p. 10.5, do ogólniejszej sytuacji, w której składnia nie musi opisywać struktury, lecz robią to grafy przepływu. 1
P r z e s z u k i w a n i e w głąb Istnieje użyteczne uporządkowanie wierzchołków w grafie przepływu, znane jako upo rządkowanie w głąb, będące uogólnieniem opisanego w p. 2.3 przechodzenia drzewa
1
„Napisane przez ludzi" nie jest tu nieistotne, ponieważ znane są programy, które generują kod z wieloma instrukcjami goto. Nie przeszkadza nam to; wejście tych programów jest strukturalne.
w głąb. Uporządkowanie w głąb może być używane do wykrywania pętli w dowolnym grafie przepływu; może również pomóc przyspieszyć iteracyjne algorytmy przepływu da nych, takie jak opisywane w p. 10.6. Porządkowanie w głąb rozpoczyna się w wierzchołku początkowym, następnie przeszukuje się cały graf, próbując odwiedzać j a k najszybciej wierzchołki najdalsze od wierzchołka początkowego (w głąb). Droga, którą się przejdzie w trakcie takiego przeszukiwania, tworzy drzewo. Zanim podamy algorytm, rozważmy przykład. P r z y k ł a d 10.30. Jedno z możliwych przeszukan w głąb grafu przepływu z rys. 10.45 pokazano na rys. 10.46. Krawędzie ciągłe tworzą drzewo, krawędzie przerywane są in nymi krawędziami grafu przepływu. Przeszukanie grafu przepływu w głąb odpowiada przejściu drzewa w porządku preorder, 1 —• 3 —>4~+6 —>1 —> 8 —• 10, wstecz do 8 i do 9. Wracamy jeszcze raz d o 8, 7, 6 i 4 i przechodzimy d o przodu d o 5. Wracamy z 5 d o 4, dalej do 3 i 1. Z 1 do przodu do 2, a następnie z 2 wracamy do 1, co kończy nasze przejście drzewa w porządku preorder. Nie powiedzieliśmy jeszcze, jak wybrać drzewo z grafu przepływu. •
Rys. 10.45. Graf przepływu
Rys. 10.46. Przeszukanie grafu w głąb
Uporządkowanie w głąb wierzchołków jest odwrotnością porządku, w którym po raz ostatni odwiedzaliśmy wierzchołki podczas przechodzenia drzewa w porządku preorder. P r z y k ł a d 10.31.
W przykładzie 10.30 podczas przechodzenia drzewa odwiedzaliśmy
wierzchołki w następującej kolejności: 1, 3, 4, 6, 7, 8, 10, 8, 9, 8, 7, 6, 4, 5, 4, 3, 1, 2, 1 Zaznaczmy na tej liście ostatnie wystąpienia każdej z liczb, otrzymując 1, 3, 4, 6, 7, 8, JO, 8, 9, 8, 7, 6, 4, 5, 4, 3, 1, 2, 1 Uporządkowanie w głąb to odwrócony ciąg podkreślonych liczb. Okazuje się, że jest
to 1, 2 , . . . , 10. Widać więc, że od początku wierzchołki były ponumerowane zgodnie z uporządkowaniem w głąb. • Poniżej podaliśmy algorytm, który oblicza uporządkowanie w głąb grafu przepływu dzięki budowie i przejściu drzewa zakorzenionego w wierzchołku początkowym; ważne jest jak największe wydłużenie ścieżek w drzewie. Takie drzewo jest nazywane rozpinają cym drzewem przeglądania (ang. depth-first spanning tree dfst). Właśnie tego algorytmu użyliśmy do zbudowania drzewa z rys. 10.46 na podstawie rys. 10.45. y
Algorytm 10.14.
Rozpinające drzewo przeglądania i uporządkowanie w głąb.
Wejście. Graf przepływu G. Wyjście, dfst T dla grafu G i uporządkowanie na jego wierzchołkach. Metoda. Używamy rekurencyjnej procedury szukaj(n) z rys. 10.47; algorytm inicjuje wszystkie wierzchołki G na „nieodwiedzone", a następnie wywołuje szukaj(n ), gdzie n to wierzchołek początkowy. Gdy wywołujemy szukaj(n), najpierw zaznaczamy n jako „odwiedzony", aby uniknąć podwójnego umieszczenia n w drzewie. Używamy zmiennej / j a k o licznika, zmniejszającego swoją wartość od liczby wierzchołków G do 1, przypi sując numery w porządku w głąb, nwgjn], wierzchołkom n. Zbiór krawędzi T tworzy rozpinające drzewo przeglądania dla G, a krawędzie te są nazywane krawędziami drzewowymi. • Q
0
procedurę szukaj(n); begin
(1) (2)
zaznacz n jako „odwiedzony"; for każdego następnika s wierzchołka n do
(3)
if s jest „nieodwiedzony" then begin
(4) (5) (6)
(7)
dodaj krawędź n
s do T\
szukaj(s) end; nwg[n] := *';
i :=/-! end;
/* poniżej główny program */ (8)
T := empty\ /* zbiór krawędzi */
(9) for każdego wierzchołka n z G do zaznacz n jako „nieodwiedzony"; (10) i := liczba wierzchołków G; (11)
szukaj'(«Q)
Rys. 10.47. Algorytm przeszukiwania w głąb Przykład 10.32. Spójrzmy na rysunek 10.47. Nadajmy / wartość 10 i wywołajmy szukaj(l). W wierszu (2) procedury szukaj musimy rozpatrzyć każdy następnik wierz chołka 1. Przypuśćmy, że na początku rozpatrujemy 5 = 3. Dodajemy wtedy do drzewa krawędź 1 — 3 i wywołujemy szukaj(3). W szukaj(3) dodajemy do T krawędź 3 - f 4 i wywołujemy szukaj(4).
Przypuśćmy, że w szukaj(4) najpierw wybieramy s = 6. Wtedy do T dodajemy krawędź 4 —• 6 i wywołujemy szukaj(6). To powoduje, że dodajemy 6 —> 7 do T i wy wołujemy szukajij). Wierzchołek 7 ma dwa następniki, 4 i 8. Ponieważ wierzchołek 4 był już zaznaczony jako „odwiedzony", nie robimy nic, gdy s = 4. Gdy s — 8, dodaje my krawędź 7 - » 8 do T i wywołujemy szwfaj/(8). Przyjmijmy, że wówczas wybieramy s = 10. Dodajemy krawędź 8 —• 10 i wywołujemy szukaj(10). Wierzchołek 10 ma następnik, 7, ale 7 jest już zaznaczony jako „odwiedzone", więc w szukaj(10) przechodzimy do kroku (6) z rys. 10.47, przypisując rcwg[10] = 10 oraz / = 9. To kończy wywołanie szukaj(10), więc wracamy do szukaj(8). Ustalamy teraz s = 9 w szukaj(8), dodajemy krawędź 8 —>• 9 do T i wywołujemy szukaj(9). Jedyny następnik wierzchołka 9, wierzchołek 1, jest już „odwiedzony", ustalamy więc nwg[9] = 9 oraz i = 8. Wracamy do szukaj(%). Ostatni następnik 8, wierzchołek 3, jest „odwiedzony", nie robimy więc nic dla s — 3. Rozważyliśmy już wszystkie następniki 8, ustalamy więc nwg[8] = 8 oraz i = 1, wracając do szukajij). Wszystkie następniki 7 zostały już odwiedzone, ustalamy więc rtwg[7] = 7 oraz i = 6, wracając do szukaj(6). Podobnie, wszystkie następniki 6 też zostały odwiedzone, ustalamy więc nwg[6] = 6 oraz i = 5 i wracamy do szukaj(4). Wierzchołek 3, następnik 4, został już odwiedzony, ale 5 jeszcze nie, dodajemy więc 4 —>• 5 do T i wywołujemy szukaj(5), co nie generuje nowych wywołań, gdyż 7, następnik 5, był już „odwiedzony". Wobec tego nwg[5] = 5 oraz ustalamy i na 4 i wracamy do szukaj(4). Zakończyliśmy rozpatrywanie następników 4, ustalamy więc nwg[4] = 4 oraz i = 3, wracając do szukaj(3). Tam ustalamy nwg[3] = 3 oraz i = 2 i wracamy do szukaj(1). Ostatnie kroki to wywołanie szukaj(2) z szukaj(l), ustawienie nwg[2] = 2 oraz i = 1, powrót do szukaj(l), ustalenie nwg[l] = 1 oraz i = 0. Zauważmy, że numeracja wierzchoł ków w naszym grafie spowodowała, że nwg[i] = i, ale nie jest to prawdą dla dowolnego grafu ani nawet dla innego uporządkowania w głąb grafu z rys. 10.45. •
Krawędzie w prezentacji w głąb grafu przepływu Gdy budujemy dfst dla grafu przepływu, krawędzie tego grafu należą do jednej z trzech kategorii. 1.
Krawędzie prowadzące od wierzchołka m do przodka m w drzewie (być może do samego m). Takie krawędzie nazywamy krawędziami powrotnymi (np. krawędzie 7 - ł 4 i 9 -> 1 z rys. 10.46). Interesującym i przydatnym faktem jest to, że jeśli graf przepływu jest redukowalny, to krawędzie powrotne są dokładnie tymi samymi krawędziami, co krawędzie do tyłu , niezależnie od kolejności, w której odwiedza my następników w kroku (2) z rys. 10.47. Dla dowolnego grafu przepływu, każda krawędź do tyłu jest krawędzią powrotną, ale gdy graf nie jest redukowalny, istnieją krawędzie powrotne, które nie są krawędziami do tyłu. 1
2.
Krawędzie, nazywane krawędziami w głąb (ang. advancing edges), prowadzące od wierzchołka m do właściwego potomka m w drzewie. Wszystkie krawędzie z dfst są krawędziami w głąb. Na rysunku 10.46 nie ma innych krawędzi w głąb, ale, na przykład, jeśli istniałaby krawędź 4 8, należałaby ona do tej kategorii.
Przypomnijmy, że w grafie przepływu krawędzie do tylu to te, których koniec dominuje nad początkiem.
3.
Krawędzie m —¥ n, takie że ani m, ani n nie jest przodkiem drugiego w dfst. Na rysunku 10.46 są to krawędzie 2 —>• 3 i 5 - » 7. Nazywamy j e krawędziami poprzecz nymi. Ważną własnością krawędzi poprzecznych jest to, że jeśli narysujemy dfst tak, że dzieci wierzchołka są rysowane od lewej do prawej — w kolejności, w której były dodawane do drzewa — to wszystkie krawędzie poprzeczne prowadzą od strony prawej do lewej.
Musimy wiedzieć, że m —> n jest krawędzią powrotną wtedy i tylko wtedy, gdy nwg[m] ^ nwg[n]. Aby sprawdzić dlaczego, zauważmy, że jeśli m jest potomkiem n w dfst, to szukaj(m) kończy się przed szukaj(ń) albo /iwg[m] ^ Na odwrót, gdy nwg[/n] ^ «wg[n], to szukaj(m) kończy się przed szukąj(n) bądź m = n. Ale szukaj(n) musiało się rozpocząć przed szukaj(m), skoro istnieje krawędź m —> n, gdyż w przeciw nym przypadku to, że n jest następnikiem m spowodowałoby, że n byłoby potomkiem m w dfst. Wobec tego czas, w którym szukąj(m) jest aktywne, jest podprzedziałem czasu, w którym aktywne jest szukaj(n), z czego wynika, że n jest przodkiem m w dfst.
Głębokość grafu p r z e p ł y w u Istnieje ważny parametr grafu przepływu — głębokość. Dla rozpinającego drzewa prze glądania dla pewnego grafu głębokość jest największą liczbą krawędzi powrotnych na dowolnej ścieżce bez cykli. P r z y k ł a d 10.33.
Na rysunku 10.46 głębokość jest równa 3, ponieważ jest ścieżka
10->7->4->3 z trzema krawędziami powrotnymi, ale nie ma żadnej ścieżki bez cyklu, na której byłyby cztery lub więcej krawędzi powrotnych. Tylko przez przypadek „najgłębsza" ścieżka skła da się tylko z krawędzi powrotnych; na ogół „najgłębsza" ścieżka składa się z dowolnej kombinacji krawędzi powrotnych, w głąb i poprzecznych. • Możemy wykazać, że głębokość nie jest nigdy większa niż to, co intuicyjnie nazwa libyśmy głębokością zagnieżdżenia pętli w grafie przepływu. Jeśli graf przepływu jest redukowalny, to możemy zastąpić „powrotne" przez „do tyłu" w definicji „głębokości", ponieważ krawędzie powrotne w dowolnym dfst to to samo, co krawędzie do tyłu. Pojęcie głębokości jest wówczas niezależne od wybranego dfst.
Przedziały Podział grafu przepływu na przedziały służy dodaniu hierarchicznej struktury do grafu przepływu. Z kolei taka struktura pozwala nam zastosować reguły sterowanej składnią analizy przepływu danych, które zaczęliśmy omawiać w p. 10.5. „Przedział" w grafie przepływu jest naturalną pętlą z acykliczną strukturą dołączo ną do wierzchołków tej pętli. Ważną własnością przedziałów jest to, że mają wejście, czyli wierzchołek dominujący nad wszystkimi wierzchołkami w przedziale; stąd każdy przedział jest regionem. Formalnie, dla grafu G z wierzchołkiem początkowym n i dla wierzchołka nzG, przedział z wejściem n, oznaczany l(n), jest definiowany następująco: Q
1) 2) 3)
n jest w I(n), jeśli wszystkie poprzedniki pewnego wierzchołka m / n są w l(n), to m jest w I(«), w I(n) nie ma niczego innego. 0
Możemy więc budować I(n), zaczynając od n i dodając wierzchołki m zgodnie z re gułą 2). Nie ma znaczenia, w jakiej kolejności dodamy dwa możliwe m, bo gdy wszyscy przodkowie wierzchołka są w I(n), to w nim pozostaną, a każdy kandydat zostanie prę dzej czy później dodany zgodnie z regułą 2). W końcu, nie można j u ż dodać nowych wierzchołków do \(ń) i otrzymany zbiór wierzchołków jest przedziałem z wejściem n.
Podział n a przedziały Mając dany graf przepływu G, możemy go podzielić na rozłączne przedziały w nastę pujący sposób. A l g o r y t m 10.15.
Analiza przedziałów w grafie przepływu.
Wejście. Graf przepływu G z wierzchołkiem początkowym n . 0
Wyjście. Podział G na zbiór przedziałów rozłącznych. Metoda. Dla każdego wierzchołka n obliczamy I(n), używając metody opisanej powyżej: I(«) := { « } ; while istnieje wierzchołek m ^ n , którego wszystkie poprzedniki są w l(n) d o I(n) := l(n)U{m} 0
Konkretne wierzchołki, które są wejściami przedziałów w podziale, są wybierane następująco. Początkowo nie ma wierzchołków „wybranych". zbuduj I ( n ) i „wybierz" wszystkie wierzchołki z tego przedziału; while jest wierzchołek w, jeszcze nie „wybrany", ale z wybranym poprzednikiem d o zbuduj l(m) i „wybierz" wszystkie wierzchołki w tym przedziale 0
•
Gdy zostanie wybrany poprzednik p kandydata m, m nie może być dodany do przedziału, który nie zawiera p. Wobec tego, kandydaci m pozostają kandydatami, aż zo staną wybrani, żeby być wejściem swoich własnych przedziałów. Wobec tego, kolejność, w jakiej są wybierane wejścia przedziałów m w algorytmie 10.15, nie m a wpływu na ostateczny podział na przedziały. Ponadto, jeśli wszystkie wierzchołki są osiągalne z n , można wykazać przez indukcję względem długości ścieżki z n do n, że wierzchołek n zostanie ostatecznie albo dodany do jakiegoś przedziału, albo będzie wejściem własnego przedziału, ale nie to i to. Wobec tego, zbiór przedziałów zbudowanych w algorytmie 10.15 faktycznie jest podziałem G. 0
Q
P r z y k ł a d 10.34. Wyszukajmy podział na przedziały dla rys. 10.45. Zacznijmy od zbu dowania 1(1), gdyż 1 jest wierzchołkiem początkowym. Możemy dodać 2 do 1(1), ponie waż jedynym przodkiem 2 jest 1. Nie możemy jednak dodać 3, gdyż ma on przodków,
4 i 8, które nie należą jeszcze do 1(1), i, analogicznie, każdy wierzchołek— z wyjątkiem 1 i 2 — ma poprzedników, którzy nie należą jeszcze do 1(1). Stąd, 1(1) = { 1 , 2 } . Możemy teraz obliczyć 1(3), gdyż 3 ma pewnych „wybranych" przodków, 1 i 2, ale samo 3 nie jest w żadnym przedziale. Nie możemy jednak dodać żadnych wierzchołków do 1(3), więc 1(3) = { 3 } . Teraz 4 jest wejściem, gdyż jego poprzednikiem jest 3, które należy do przedziału. Do 1(4) możemy dodać 5 i 6, ponieważ ich poprzednikiem jest tylko 4, ale nie możemy dodać żadnych innych wierzchołków; np. 7 ma poprzednika 10. Następnie 7 zostaje wejściem i do 1(7) możemy dodać 8. Wówczas możemy dodać 9 i 10, gdyż ich poprzednikiem jest tylko 8. Stąd przedziały w podziale grafu z rys. 10.45 to: 1(1) = { 1 . 2}
1(4) = {4, 5, 6}
1(3) = {3}
1(7) = {7, 8, 9, 10}
•
Grafy przedziałowe Z przedziałów dla pewnego grafu przepływu G możemy zbudować nowy graf przepływu, I(G), korzystając z następujących reguł: 1. 2. 3.
Wierzchołki I(G) odpowiadają przedziałom w podziale na przedziały grafu G. Wierzchołek początkowy I(G) jest przedziałem G, który zawiera wierzchołek po czątkowy G. Krawędź od przedziału / do innego przedziału J jest w grafie wtedy i tylko wtedy, gdy w G jest krawędź od pewnego wierzchołka / do wejścia J. Zauważmy, że nie może być krawędzi wchodzącej z zewnątrz J do żadnego wierzchołka n z J poza wejściem, gdyż wtedy n nie mogłoby być dodane do J w algorytmie 10.15.
Możemy, na zmianę, stosować algorytm 10.15 i budować grafy przedziałowe, otrzymu jąc ciąg grafów G, I(G), I ( I ( G ) ) , . . . Ostatecznie, dojdziemy do grafu, którego wszystkie wierzchołki są same w sobie przedziałami. Taki graf nazywamy granicznym grafem prze pływu dla G. Interesujące jest, że graf przepływu jest redukowalny wtedy i tylko wtedy, gdy j e g o graniczny graf przepływu jest pojedynczym wierzchołkiem . 1
Przykład 10.35. Na rysunku 10.48 przedstawiono wynik kilkakrotnego zastosowania budowy przedziałów dla grafu przepływu z rys. 10.45. Przedziały grafu z tego ry sunku podaliśmy w przykładzie 10.34, a graf przedziałowy z nich zbudowany jest na rys. 10.48(a). Zauważmy, że krawędź 10 —11 z rys. 10.45 nie powoduje dodania krawę dzi z wierzchołka reprezentującego zbiór {7, 8, 9, 10} do tego zbioru, ponieważ metoda budowy grafów przedziałowych zdecydowanie nie pozwala na tworzenie takich pętli. Za uważmy również, że graf przepływu z rys. 10.45 jest redukowalny, gdyż jego graniczny graf przepływu jest pojedynczym wierzchołkiem. • Dzielenie wierzchołków Dochodząc do granicznego grafu przepływu, który jest czymś innym niż pojedynczy wierzchołek, możemy się posunąć do przodu jedynie wtedy, kiedy podzielimy jeden lub 1
Ta definicja jest pierwszą definicją grafu redukowalnego.
(a)
(b)
(c)
(d)
Rys. 10.48. Sekwencja grafów przedziałowych
więcej wierzchołków. Jeśli wierzchołek n ma k poprzedników, możemy zastąpić n przez k węzłów n , n , . . . , n . /-ty poprzednik n zostanie tylko poprzednikiem podczas gdy wszystkie następniki n zostaną następnikami wszystkich nx
2
k
r
Jeżeli zastosujemy algorytm 10.15 do grafu wynikowego, każdy n będzie miał pojedynczego poprzednika i w związku z tym na pewno zostanie częścią przedziału tego poprzednika. Wobec tego, jedno dzielenie wierzchołków i pojedynczy podział na przedziały dają nam graf z mniejszą liczbą wierzchołków. Wobec tego, budowa grafów przedziałowych, przeplatana z koniecznym dzieleniem wierzchołków, musi w końcu do prowadzić do powstania grafu z jednym wierzchołkiem. Znaczenie tej obserwacji stanie się jasne w następnym podrozdziale, w którym będziemy projektować algorytmy analizy przepływu danych, sterowane przez te dwie operacje na grafach. i
Przykład 10.36. Rozważmy graf przepływu z rys. 10.49(a), który jest swoim własnym granicznym grafem przepływu. Możemy podzielić wierzchołek 2 na wierzchołki 2a i 2b z poprzednikami, odpowiednio, 1 i 3 (rys. 10.49(b)). Gdy dwukrotnie wykonamy podział na przedziały, otrzymamy ciąg grafów jak na rys. 10.49(c) i (d), kończący się grafem z pojedynczym wierzchołkiem. •
(a)
(b)
(c)
(d)
Rys. 10.49. Podział wierzchołka, po którym następują dwa podziały na przedziały
Analiza T x
T
2
Wygodną metodą osiągnięcia efektów, jakie daje analiza przedziałowa, jest zastosowanie dwóch prostych transformacji do grafów przepływu. T : Jeśli n jest wierzchołkiem z pętlą, tj. krawędzią n-^ n, skasuj tę krawędź. T : Jeśli istnieje wierzchołek «, nie będący wierzchołkiem początkowym, który ma po jedynczy poprzednik m, to m może wchłonąć n przez skasowanie n i przyłączenie wszystkich następników n (włączając, być może, ni) jako następników m. {
0
Pewne interesujące obserwacje dotyczące transformacji T i T to: x
1.
2
Jeśli będziemy stosować T i T do grafu przepływu G, w dowolnej kolejności, aż do otrzymania grafu, na którym nie będzie już można zastosować T ani T , to zawsze dojdziemy do tego samego grafu przepływu. Powodem tego jest fakt, że gdy wierzchołek jest kandydatem do usunięcia pętli przy użyciu T bądź wchłonię cia przy użyciu 7 , to pozostaje on kandydatem, nawet jeśli najpierw wykonamy transformację T lub T na jakimś innym wierzchołku. x
2
x
2
x
2
x
2.
2
Graf przepływu pozostający po wszystkich możliwych zastosowaniach T i T do G jest granicznym grafem przepływu dla G. Dowód tego tylko pozornie jest prosty; pozostawiamy go Czytelnikowi jako ćwiczenie. W związku z tym, inna definicja „redukowalnego grafu przepływu" mówi, że graf jest redukowalny, gdy przy użyciu T i T może zostać przekształcony do grafu z pojedynczym wierzchołkiem. }
x
2
2
P r z y k ł a d 10.37. Na rysunku 10.50 widać sekwencję redukcji T i T , zastosowanych do grafu przepływu, który jest grafem z rys. 10.49(b) ze zmienionymi nazwami wierzchoł ków. Na rysunku 10.50(b), c wchłonęło d. Zauważmy, że pętla wokół cd (rys. 10.50(b)) powstaje z krawędzi d —» c (rys. 10.50(a)). Ta pętla jest usuwana przez T (rys. 10.50(c)). Zauważmy też, że gdy a pochłania b (rys. 10.50(d)), krawędzie z a i b do wierzchołka cd tworzą jedną krawędź. • x
2
x
(a)
(b)
(c)
(d)
(e)
Rys. 10.50. Redukcja przy użyciu T i T x
2
Regiony Przypomnijmy, z podrozdziału 10.5, że region w grafie przepływu to zbiór wierzchołków N który zawiera wejście, dominujące nad wszystkimi innymi wierzchołkami regionu. t
Wszystkie krawędzie między wierzchołkami z W są w regionie, z wyjątkiem (być może) niektórych wchodzących do wejścia. Przykładowo, każdy przedział jest regionem, ale istnieją regiony, które nie są przedziałami, gdyż np. nie zawierają wszystkich wierzchoł ków, które zawierałby przedział, lub nie zawierają niektórych krawędzi wracających do wejścia. Istnieją również, o czym poniżej, regiony dużo większe niż dowolne przedziały. Podczas redukowania grafu G przy użyciu T i T następujące zdania są zawsze prawdziwe. x
1. 2.
3.
2
Wierzchołek reprezentuje region grafu G. Krawędź od a do Z? reprezentuje zbiór krawędzi. Każda taka krawędź prowadzi od pewnego wierzchołka w regionie reprezentowanym przez a do wejścia regionu reprezentowanego przez b. Każdy wierzchołek i każda krawędź G są reprezentowane w aktualnym grafie przez dokładnie jeden wierzchołek lub krawędź.
Chcąc udowodnić, że te obserwacje są prawdziwe, zauważmy, że w oczywisty sposób są one prawdziwe dla samego grafu G. Każdy wierzchołek jest sam w sobie regionem, a każda krawędź reprezentuje tylko siebie. Przypuśćmy, że stosujemy T do pewnego wierzchołka n reprezentującego region /?, podczas gdy pętla n—tn reprezentuje pewien zbiór krawędzi £ , z których wszystkie muszą wchodzić do wejścia R. Jeśli dodamy krawędzie E do regionu /?, pozostanie on regionem, więc po usunięciu krawędzi n —)• n wierzchołek n reprezentuje R i krawędzie z £ , i warunki 1.-3. są zachowane. x
Możemy także zastosować transformację T , aby wierzchołek a wchłonął wierzcho łek b, gdzie a reprezentuje region /?, a b region S. Niech E będzie zbiorem krawędzi reprezentowanych przez krawędź a^tb. Twierdzimy, że /?, S i E tworzą razem region, którego wejściem jest wejście R. Aby tego dowieść, musimy sprawdzić, czy wejście R dominuje nad każdym z wierzchołków S. Jeśli nie, to musiałaby istnieć pewna ścieżka do wejścia S, która nie kończy się krawędzią z E. Wówczas ostatnia krawędź tej ścieżki musiałaby być reprezentowana w aktualnym grafie przepływu przez pewną inną krawędź wchodzącą do b. Ale taka krawędź nie może istnieć, bo nie moglibyśmy użyć T do wchłonięcia b. 2
0
P r z y k ł a d 10,38. Wierzchołek oznaczony cd (rys. 10.50(b)) reprezentuje region poka zany na rys. 10.51 (a), który został utworzony przez wchłonięcie d przez c. Zauważmy, że krawędź d —> c nie jest w tym regionie; na rys. 10.50(b) ta krawędź jest reprezento wana przez pętlę wokół cd. Jednakże na rys. 10.50(c) krawędź cd —> cd została usunięta i wtedy wierzchołek cd reprezentuje region pokazany na rys. 10.5 l(b).
(a)
(b)
(c)
Rys. 10.51. Pewne regiony
Na rysunku 10.50(d) wierzchołek cd ciągle reprezentuje region z rys. 10.51(b), podczas gdy wierzchołek ab reprezentuje region z rys. 10.51(c). Krawędź ab -> cd na rys. 10.50(d) reprezentuje krawędzie a —> c oraz b —> c z pierwotnego grafu przepływu z rys. 10.50(a). Stosujemy więc T , aby uzyskać stan jak na rys. 10.50(e), z jedynym pozostającym wierzchołkiem reprezentującym cały graf przepływu z rys. 10.50(a). • 2
Przedstawione powyżej własności redukcji T i T są również prawdziwe dla analizy przedziałów. Zostawiamy Czytelnikowi jako ćwiczenie udowodnienie faktu, że podczas budowania grafów I ( G ) , I ( I ( G ) ) itd. każdy wierzchołek w każdym z tych grafów re prezentuje region, a każda krawędź reprezentuje zbiór krawędzi spełniających opisany powyżej warunek 2. {
2
Wyszukiwanie d o m i n a t o r ó w Zakończyliśmy ten podrozdział efektywnym algorytmem dla pojęcia, którego używaliśmy często i którego będziemy dalej używać w rozwijaniu teorii grafów przepływu i analizy przepływu danych. Jest to prosty algorytm obliczający dominatory każdego wierzchołka n w grafie przepływu, korzystający z faktu, że jeśli p , p ,..., p są wszystkimi poprzed nikami n, a d ^ n, to d dom n wtedy i tylko wtedy, gdy d dom p dla każdego i. Ta metoda jest zbliżona do analizy przepływu danych do przodu z przecięciem jako opera torem łączącym (np. analizy wyrażeń dostępnych) — korzystamy z oszacowania zbioru dominatorów n i poprawiamy j e przez wielokrotne odwiedzanie wszystkich wierzchołków po kolei. W tym przypadku jako początkowe szacowanie przyjmujemy, że wierzchołek po czątkowy jest dominowany tylko przez siebie oraz że każdy z wierzchołków dominuje nad wszystkimi wierzchołkami z wyjątkiem wierzchołka początkowego. Intuicyjnie, po dejście to jest właściwe, gdyż kandydat na dominatora jest wykluczany tylko wtedy, gdy znajdziemy ścieżkę, która dowodzi, że, powiedzmy, m dom n jest fałszem. Jeśli nie możemy znaleźć takiej ścieżki, od wierzchołka początkowego do n, omijającej /n, to m rzeczywiście jest dominatorem n. x
2
k
t
Algorytm 10.16.
Wyszukiwanie dominatorów.
Wejście. Graf przepływu G ze zbiorem wierzchołków N, zbiorem krawędzi E i wierz chołkiem początkowym
n. 0
Wyjście. Relacja dom. Metoda. Obliczamy D(n), zbiór dominatorów n, iteracyjnie używając procedury z rys. 10.52. Na koniec, d jest w D(n) wtedy i tylko wtedy, gdy d dom n. Czytelnik może uzupełnić szczegóły dotyczące wykrywania zmian w D(ń)\ algorytm 10.2 może służyć jako przykład. Można wykazać, że D(n) wyliczane w wierszu (5) programu z rys. 10.52 jest zawsze podzbiorem aktualnego D(n). Ponieważ D(n) nie może się zmniejszać w nieskończoność, musimy kiedyś wyjść z pętli while. Dowód, że po zbiegnięciu, D(n) jest zbiorem do minatorów n, pozostawiamy zainteresowanym Czytelnikom jako ćwiczenie. Algorytm z rysunku 10.52 jest naprawdę efektywny, gdyż D(n) może być reprezentowane przez
(1) D(n ) := {n }; (2) for n z N - {n } do D{n) := N\ /* koniec inicjowania */ (3) while zachodzą zmiany w dowolnym D(n) do (4) for n z N-{n } do (5) D(n) :={*} U fi />(/>); Q
0
0
Q
/7 poprzednik n
Rys. 10.52. Algorytm obliczający dominatory
tablicę bitów, a operacje na zbiorach z wiersza (5) mogą być wykonywane przy użyciu operacji logicznych a n d i or. •
P r z y k ł a d 10.39. Przyjrzyjmy się jeszcze raz grafowi przepływu z rys. 10.45 i przyjmij my, że w pętli for z wiersza (4) wierzchołki są odwiedzane w kolejności numerowania. Wierzchołek 2 ma tylko 1 jako poprzednika, więc D(2) : = { 2 } U D ( 1 ) . Ponieważ 1 jest wierzchołkiem początkowym, D(l) zostało ustawione na {1} w wierszu (1). Wobec tego, do D(2) w wierszu (5) przypisujemy {1, 2 } . Następnie rozważamy wierzchołek 3 z poprzednikami 1, 2, 4 i 8. Z wiersza (5) wynika, że D(3) = { 3 } U ( { 1 } D { 1 , 2 } n { l , 2 , . . . , 10}) = {1, 3 } . Pozostałe obliczenia to: D(4) = { 4 } U ( D ( 3 ) n D ( 7 ) ) = { 4 } U ( { l , 3 } n { l , 2 , . . . , 10}) = {1, 3, 4} D(5) = { 5 } U D ( 4 ) = { 5 } U { 1 , 3, 4 } = {1, 3, 4, 5} D(6) = {6}UZ>(4) = { 6 } U { 1 , 3, 4 } = {1, 3, 4, 6} D(7) - {7}U(D(5)DZ)(6)nD(10)) = -
{ 7 } U ( { 1 , 3, 4, 5 } H { 1 , 3, 4, 6 } n { l
;
2,
10}) - { 1 , 3 , 4 , 7 }
Z>(8) = { 8 } U D ( 7 ) = { 8 } U { 1 , 3, 4, 7} = {1, 3, 4, 7, 8} £>(9) = {9}UD(8) = { 9 } U { 1 , 3, 4 7, 8} = { 1 , 3, 4, 7, 8, 9} ?
D(10) = {10}UD(8) = {10}U{1, 3, 4, 7, 8} = {1, 3, 4, 7, 8, 10} Drugi przebieg pętli while nie wprowadza żadnych zmian, więc powyższe wartości definiują relację dom. •
10.10
Efektywne algorytmy przepływu danych
W tym podrozdziale opisaliśmy dwa różne zastosowania teorii grafów do przyspieszenia analizy przepływu danych. Pierwsze to uporządkowanie wierzchołków grafu w głąb, zmniejszające liczbę przebiegów w algorytmach iteracyjnych z p. 10.6, a drugie to użycie przedziałów lub transformacji T i T do uogólnienia podejścia sterowanego składnią z p. 10.5. {
2
U p o r z ą d k o w a n i e w głąb w a l g o r y t m a c h iteracyjnych We wszystkich rozpatrywanych już problemach, takich jak definicje osiągające, wyrażenia dostępne czy zmienne żywe, każde zdarzenie interesujące dla wierzchołka będzie do niego dostarczone po ścieżce acyklicznej. Przykładowo, jeśli definicja d jest w in[B] to istnieje pewna acykliczna ścieżka z bloku zawierającego d do B, taka że d jest w zbiorach in i out wierzchołków na tej ścieżce. Podobnie, jeśli wyrażenie x + y nie jest dostępne przy wejściu do bloku B, to istnieje pewna acykliczna ścieżka, ilustrująca ten fakt: albo jest to ścieżka od wierzchołka początkowego, na której nie ma instrukcji generujących ani zabijających x + y , albo jest to ścieżka od bloku, który zabija x + y , i na ścieżce tej x + y nie jest ponownie generowane. W końcu, dla zmiennych żywych, jeśli x jest żywe przy wyjściu z bloku 5 , istnieje ścieżka acykliczna od B do użycia x, na której nie ma definicji x. Czytelnik powinien sprawdzić, że w żadnym z tych przypadków ścieżki z cyklami nic nie zmieniają. Przykładowo, jeśli użycie x jest osiągane z końca bloku B wzdłuż ścieżki z cyklem, możemy usunąć ten cykl i otrzymać krótszą ścieżkę, wzdłuż której użycie x jest osiągane z B. t
Jeśli wszystkie użyteczne informacje są propagowane wzdłuż ścieżek acyklicznych, możemy próbować poprawić kolejność, w której odwiedzamy wierzchołki w iteracyj nych algorytmach przepływu danych, tak że po stosunkowo niewielu iteracjach możemy być pewni, że informacje zostały przekazane wzdłuż wszystkich ścieżek acyklicznych. W szczególności, ze statystyk zgromadzonych przez Knutha [1971b] wynika, że typo we grafy przepływu mają bardzo małą głębokość przedziałową, czyli liczbę koniecznych zastosowań algorytmu podziału na przedziały, aż do otrzymania granicznego grafu prze pływu; średnio jest to 2,75. Co więcej, można wykazać, że głębokość przedziałowa grafu przepływu nigdy nie jest mniejsza niż wielkość, którą nazwaliśmy „głębokością", czyli maksymalna liczba krawędzi powrotnych na ścieżce acyklicznej. (Jeśli graf nie jest re dukowalny, głębokość może zależeć od wybranego rozpinającego drzewa przeglądania). Przypominając nasz opis rozpinającego drzewa przeglądania z poprzedniego pod rozdziału, zauważmy, że jeśli a —>• b jest krawędzią, to numer w porządku w głąb b jest mniejszy niż numer a tylko, jeśli rozpatrywana krawędź jest krawędzią powrotną. Zastąpmy więc wiersz (5) z rys. 10.26, nakazujący odwiedzić wszystkie bloki B grafu przepływu, dla którego obliczamy definicje osiągające, wierszem: for każdego bloku B w kolejności w głąb d o Przypuśćmy, że mamy ścieżkę, wzdłuż której propagowana jest definicja d, taką jak 3
5 -+ 19 -> 35 -» 16 -> 23 -> 45 -> 4 - » 10 -> 17
gdzie liczby reprezentują numery w porządku w głąb bloków wzdłuż ścieżki. Wówczas, przy pierwszym przebiegu pętli w wierszach (5)-(9) na rys. 10.26, d zostanie rozpropa gowane z out[3] do in[5], do out[5] itd., aż do out[35]. W tym przebiegu nie dojdzie ono do m[16], bo 16 poprzedza 35, i w związku z tym m[16] było już obliczone zanim d zostało wstawione do o«f[35]. Jednak przy następnym przebiegu pętli z wierszy (5)-(9), gdy będziemy obliczali m[16], d znajdzie się tam, bo jest w out[35]. Definicja d zostanie również rozpropagowana do o«f[16], in[23] itd., aż do awf[45], gdzie musi poczekać, bo
in[4] zostało obliczone wcześniej. W trzecim przebiegu, d idzie do m[4], owf[4], m[10], oużflO] i m[17], więc po trzech przebiegach wiemy, że d osiąga blok 1 7 . Wyprowadzenie ogólnej zasady na podstawie tego przykładu nie powinno być trud ne. Jeśli porządkowania w głąb użyjemy do algorytmu z rys. 10.26, to liczba przebiegów koniecznych do rozpropagowania dowolnej definicji osiągającej wzdłuż dowolnej ścieżki acyklicznej będzie co najwyżej o jeden większa niż liczba krawędzi na tej ścieżce, pro wadzących od bloku o wyższym numerze do bloku o numerze niższym. Te krawędzie to krawędzie powrotne, więc liczba koniecznych przebiegów równa jest jeden plus głębo kość. Oczywiście, algorytm 10.2 nie wykrywa faktu, że wszystkie definicje osiągnęły już najdalsze możliwe miejsce aż do kolejnego przebiegu, więc górne ograniczenie liczby przebiegów wykonanych przez ten algorytm z blokami uporządkowanymi w głąb jest równe dwa plus głębokość, lub 5, jeśli wierzyć, że wyniki Knutha [1971b] są reprezen tatywne. 1
Uporządkowanie w głąb jest również pożyteczne w przypadku wyszukiwania wyra żeń dostępnych (algorytm 10.3) oraz dowolnych innych problemów związanych z przepły wem danych, które rozwiązujemy przez propagację danych do przodu. Dla problemów — takich jak zmienne żywe — w których propagujemy informacje do tyłu, możemy analogicznie osiągnąć średnio pięć przebiegów, jeśli uporządkujemy bloki w kolejności odwrotnej do kolejności w głąb. Możemy więc propagować użycia zmiennej z bloku 17 do tyłu wzdłuż ścieżki 3
5
19 -¥ 35
16
23 -ł 45 -> 4 -> 10 - » 1 7
w jednym przebiegu do in[4], gdzie musimy poczekać na kolejny przebieg, aby dojść do owf[45]. W drugim przebiegu definicja dojdzie do m[16], a w trzecim przejdzie od owr[35] do o«r[3], W ogólności, jeden plus głębokość przebiegów wystarczy, aby prze kazać informację o użyciu zmiennej do tyłu wzdłuż dowolnej ścieżki acyklicznej, jeśli będziemy odwiedzali wierzchołki w kolejności odwrotnej do kolejności w głąb, ponie waż wtedy w jednym przebiegu użycie jest propagowane wzdłuż dowolnej malejącej sekwencji wierzchołków. Analiza przepływu danych oparta na strukturze Kosztem nieco większego wysiłku niż implementacja „prostych" algorytmów, możemy zaimplementować algorytmy przepływu danych, które odwiedzają wierzchołki (i stosują równania przepływu danych) co najwyżej tyle razy, ile wynosi głębokość przedziałowa grafu przepływu, a często przeciętny wierzchołek jest odwiedzany nawet mniej razy. Od powiedź na pytanie, czy ten dodatkowy wysiłek skutkuje istotną oszczędnością czasu, jest dyskusyjna, ale techniki zbliżone do tej, oparte na analizie przedziałowej, były używane w kilku kompilatorach. Co więcej, pomysły tu opisywane stosuje się do sterowanych składnią algorytmów przepływu danych do wszystkich rodzajów strukturalnych instrukcji sterowania, a nie tylko do konstrukcji if • • -then oraz do- • -while opisywanych w p. 10.5, i to było również wykorzystywane w pewnych kompilatorach. Nasz algorytm jest oparty na strukturze wprowadzonej do grafów przepływu przez transformacje 7} i T . Tak jak w p. 10.5, jesteśmy zainteresowani definicjami, które są 2
Definicja d osiąga również owf[17], ale nie ma to znaczenia z punktu widzenia rozpatrywanej ścieżki.
generowane i zabijane podczas przepływu sterowania przez region. W przeciwieństwie do regionów definiowanych przez instrukcje if i while, ogólne regiony mogą mieć wie le wyjść, więc dla każdego bloku B w regionie R obliczymy zbiory gen i kill , odpowiednio, definicji generowanych i zabijanych wzdłuż ścieżek w regionie od wej ścia do końca bloku B. Te zbiory będą używane do zdefiniowania funkcji przeniesienia (ang. transfer function) trans (S), która dla dowolnego zbioru definicji S określi, które z tych definicji osiągają koniec bloku B, przechodząc tylko po ścieżkach wewnątrz R, pod warunkiem, że wszystkie definicje z S, i tylko one, osiągają wejście R. Jak już wiemy z podrozdziałów 10.5 i 10.6, definicje osiągające koniec bloku B RB
R
RB
B
należą do dwóch kategorii: tych, które są generowane w R i propagowane do końca B niezależnie od S, tych, które nie są generowane w R, ale nie są również zabijane wzdłuż pewnej ścieżki od wejścia R d o końca B i, w związku z tym, są w trans (S) wtedy i tylko wtedy, gdy są w S.
1) 2)
R
B
Możemy więc zapisać trans w postaci trans
RB
- gen
U (S -
RB
kill ) RB
Głównym pomysłem w algorytmie jest metoda obliczania trans dla coraz więk szych regionów definiowanych przez pewne (T r ) - r o z k ł a d y grafu przepływu. Chwilo wo przyjmiemy, że graf przepływu jest redukowalny, chociaż prosta modyfikacja pozwoli mu działać również na grafach nieredukowalnych. Podstawą jest region składający się z jednego bloku, B. W tym przypadku funkcja przeniesienia regionu jest funkcją przeniesienia bloku, ponieważ definicja osiąga koniec bloku wtedy i tylko wtedy, gdy została w tym bloku wygenerowana albo jest w zbiorze S i nie została zabita. To znaczy, że R
{1
=
8^B,B
kill
B
B
B
2
8^{B]
— kill[B]
Rozważmy teraz budowę regionu R przy użyciu T , czyli przypadek, w którym R jest tworzone, gdy R wchłania R co przedstawiono na rys. 10.53. Zauważmy przede wszystkim, że w regionie R nie ma krawędzi od R z powrotem do R bo żadna krawędź z R do wejścia R nie jest w R. Czyli każda ścieżka całkowicie w R przechodzi (być może) najpierw przez R , potem (być może) przez R , ale nie może wracać do R . Zauważmy również, że wejściem R jest wejście R . Możemy wywnioskować, że w R, R nie ma wpływu na funkcję przeniesienia wierzchołków z R ; to znaczy, że 2
{
v
2
2
v
x
x
2
x
x
2
x
EN
=
kill
— kill
EN
8 R,B R
B
8 RB V
R
B
dla wszystkich B zR Dla B z / ? , definicja może osiągnąć koniec £ , jeśli spełniony jest dowolny z poniż v
szych warunków: 1) 2)
definicja jest generowana w R , definicja jest generowana w R , osiąga koniec pewnych poprzedników wejścia R i nie jest zabijana po drodze od wejścia R do B, 2
x
0
2
3)
definicja jest w zbiorze S dostępnym w wejściu R nie jest zabijana po drodze do pewnego poprzednika wejścia R i nie jest zabijana, przechodząc od wejścia R do B. xt
2
2
Wynika z tego, że definicje osiągające końce tych bloków z R , które są poprzednikami wejścia R odgrywają specjalną rolę. W istocie, oceniamy, co dzieje się ze zbiorem S wchodzącym do wejścia R , podczas gdy jego definicje próbują osiągnąć wejście R przez jeden z jego poprzedników. Zbiór definicji, które osiągają pewnego z poprzedników wej ścia R , zostaje zbiorem wejściowym dla R i stosujemy do niego funkcję przeniesienia dla R . x
v
x
2
2
2
2
Niech więc G będzie sumą gen dla wszystkich poprzedników P wejścia R i niech K będzie przecięciem kill dla tych wszystkich poprzedników. Wówczas, jeśli S jest zbiorem definicji, które osiągają wejście R , to zbiór definicji, które osiągają wej ście R wzdłuż ścieżek całkowicie zawartych w R to GL) (5 —AT). Wobec tego funkcja przeniesienia w R dla tych bloków B z R może być obliczona według R
R
p
2
P
x
2
t
2
gen
RB
kill
RB
=
gen yj{G~kill ) RiB
= kilimu
RiB
(K~gen ) R2B
Rozważmy teraz, co dzieje się, gdy region R jest budowany z regionu R przy użyciu transformacji T Ogólna sytuacja jest pokazana na rys. 10.54; zauważmy, że R składa się z R i pewnych krawędzi powrotnych do wejścia R (które, oczywiście, jest też wej ściem R). Ścieżka przechodząca przez wejście dwukrotnie musiałaby zawierać cykl i, jak ustaliliśmy wcześniej, nie musiałaby być rozpatrywana. Wobec tego wszystkie definicje generowane na końcu bloku B są generowane za pomocą jednego z dwóch sposobów: x
v
x
1) 2)
x
definicja jest generowana w R i nie potrzebuje krawędzi powrotnych włączonych do R, aby osiągnąć koniec 5 , definicja jest generowana w dowolnym miejscu w R , osiąga poprzednik wejścia, przechodzi po krawędzi powrotnej i nie jest zabijana po drodze od wejścia do B. x
x
Jeśli założymy, że G jest sumą gen
R
en
2 R$
= 8en
RvB
U
(G-kill^)
P
dla wszystkich poprzedników wejścia R to y
Rys. 10.54. Tworzenie regionu za pomocą jfj Definicja jest zabijana po drodze od wejścia do B wtedy i tylko wtedy, gdy jest zabijana na każdej ścieżce acyklicznej, czyli krawędzie powrotne włączone do R nie powodują śmierci nowych definicji. Stąd klll
R
klll
R
g
R
P r z y k ł a d 10.40. Rozpatrzmy ponownie graf przepływu z rysunku 10.50, którego T )-rozkład jest pokazany na rys. 10.55, z nazwami regionów z tego rozkładu. Na ry sunku 10.56 pokazaliśmy pewne hipotetyczne tablice bitów reprezentujące trzy definicje i to, czy są one generowane lub zabijane przez każdy z bloków z rys. 10.55. 2
Rys. 10.55. Rozkład grafu przepływu Dla regionów złożonych z pojedynczych wierzchołków, które nazwaliśmy A, B C i D, zbiory gen i kill są podane w tablicy na rys. 10.56. Zajmijmy się regionem R, który jest budowany, gdy C wchłania D, używając T . Zgodnie z podanymi wcześniej regułami dla T , zauważmy, że gen i kill dla C nie zmieniają się, czyli y
2
2
gen
R
kill
RC
c
— gen
c
= kill
cc
c
— 000 = 010
BLOK
gen
kill
A B C D
100 010 000 001
010 101 010 000
Rys. 10.56. Zbiory gen i kill dla bloków z rys. 10.55 Dla wierzchołka D musimy w regionie C znaleźć sumę gen dla wszystkich poprzedników wejścia regionu D. Oczywiście, wejście regionu D to wierzchołek D i ma on tylko jednego poprzednika w regionie C, mianowicie wierzchołek C. Stąd gen
RD
= gen
kill
RD
= kill [ U(kill -gen )
U {gen
DD
D
cc
D
cc
- kill ) DD
= 001 + (000 - 000) = 001 = 000 + (010 - 001) = 010
DD
Teraz budujemy region S z regionu R przy użyciu TJ. Zbiory kill nie zmieniają się, mamy więc kill
sc
— kill
kill
SD
— kill
= 010
RC
= 010
RD
Aby obliczyć gen dla S zauważmy, że jedyną krawędzią powrotną do wejścia 5, która jest dołączana podczas przejścia od R do S, jest krawędź D C. Stąd g
en
s c
= gen U{gen -kill )
= 0 0 0 + ( 0 0 1 - 010) = 001
= gen U(gen -kill )
= 001 + (001 - 010) = 001
RC
gen
SD
RD
RD
RC
RD
RD
Obliczenia dla regionu T są analogiczne do tych dla regionu R i otrzymujemy gen Wtt^ gen &// TA
TB
r f i
= 100 =010 — 010 =101
Na koniec obliczmy gen i fci// dla regionu U, całego grafu przepływu. Ponieważ U jest tworzone, gdy T wchłania S, używając transformacji T , wartości gen i kill dla wierzchołków A i B są takie same, jak podane powyżej. Dla C i D zauważmy, że wejście S, wierzchołek C, ma dwóch poprzedników w regionie T, mianowicie A i 5 . Możemy więc obliczyć 2
G = gen Ugen
=110
K = kill C\kill
= 000
TA
TB
TA
TB
Następnie obliczmy en
8u kill
uc
c
=
en
8 sc^fó
= kill
— kill )
= 101
sc
U (K — gen )
s
C
s
c
= 010
gen VJ{G-kill ) SD
101
kill U(K-gen )
010
SD
SD
SD
•
Obliczywszy gen i killy dla każdego bloku B, gdy U jest regionem obejmu jącym cały graf przepływu, obliczyliśmy właściwie out[B] dla każdego bloku B. To znaczy, jeśli przyjrzymy się definicji transy (S) — gen U (S — kill ), zauważymy, że transy (0) to dokładnie out[B]. Ale trans (0) ~ gen . Czyli, dokończeniem opartego na strukturze algorytmu obliczającego definicje osiągające jest wykorzystanie zbiorów gen jako out i obliczenie zbiorów in jako sumy zbiorów out poprzedników. Te kroki podsumowane są w poniższym algorytmie. v
B
B
B
vB
B
v
Algorytm 10.17.
vB
B
v
B
Definicje osiągające oparte na strukturze.
Wejście. Redukowalny graf przepływu G i zbiór definicji gen[B] i kill[B] dla każdego bloku B z G. Wyjście. in[B] dla każdego bloku B. Metoda. 1.
Znajdź ( 7 r ) - r o z k ł a d dla G.
2.
Dla każdego regionu R z tego rozkładu, zaczynając od środka, oblicz gen i kill dla każdego bloku B z R. Jeśli U jest nazwą regionu obejmującego cały graf, to dla każdego bloku Z?, niech in[B] będzie sumą, p o wszystkich poprzednikach P bloku 5 , zbiorów geny . •
3.
p
2
R
B
R
B
p
Pewne metody przyspieszenia algorytmu opartego na strukturze Po pierwsze, zauważmy, że jeśli mamy funkcję przeniesienia GU(S — K), to wartość funkcji nie zmienia się, jeśli usuniemy z K pewne elementy będące w G. Wobec tego, gdy stosujemy T zamiast używać równań 2>
gen
RB
=
gen U(G-kill )
kill
RB
= kilimu
R2jB
R2B
(K-gen^)
drugie równanie możemy zastąpić kill
R
= kill
B
R2
UK
B
oszczędzając w ten sposób operację na każdym z bloków regionu R . Drugim dobrym pomysłem jest spostrzeżenie, że T stosujemy tylko wtedy, gdy najpierw pewien region R został wchłonięty przez R i pozostały jakieś krawędzie po wrotne z R do wejścia R . Zamiast zmieniać najpierw R z powodu operacji T , a potem zmieniać R i R z powodu T możemy połączyć konieczne zmiany, robiąc co następuje: 2
x
2
2
{
{
1)
2
2
2
v
używając reguły T , obliczmy nową funkcję przeniesienia dla tych wierzchołków z R które są poprzednikami wejścia R używając reguły T obliczmy nową funkcję przeniesienia dla wszystkich wierzchoł ków R używając reguły T , obliczmy nową funkcję przeniesienia dla wszystkich wierzchoł ków R^; zauważmy, że informacje emitowane przez użycie T doszły do poprzed ników R i zostały przekazane d o całego R przez regułę T , nie trzeba stosować reguły T do R . 2
2>
2)
{
lt
v
v
3)
2
{
2
x
2
2
2
Obsługa nieredukowalnych grafów przepływu Jeśli ( 7 \ , r ) - r o z k ł a d grafu przepływu kończy się granicznym grafem przepływu, który nie jest pojedynczym wierzchołkiem, musimy wykonać podział wierzchołka. Dzielenie wierzchołka granicznego grafu przepływu odpowiada powieleniu całego regionu repre zentowanego przez ten wierzchołek. Przykładowo, na rys. 10.57 przedstawiliśmy skutki podziału wierzchołka w grafie, który początkowo miał dziewięć wierzchołków i za po mocą transformacji T i T został podzielony na trzy regiony połączone pewną liczbą krawędzi. 2
x
2
Rys. 10.57. Podział nieredukowalnego grafu przepływu
Jak wspomnieliśmy w poprzednim podrozdziale, stosując na przemian podziały i ciągi redukcji, na pewno zredukujemy graf przepływu do jednego wierzchołka. W wyniku podziałów niektóre wierzchołki grafu początkowego będą miały więcej niż jedną kopię w regionie reprezentowanym przez graf jednowierzchołkowy. Do takiego regionu możemy zastosować trochę zmieniony algorytm 10.17. Różnica polega na tym, że gdy dzielimy wierzchołek, zbiory gen i kill dla wierzchołków grafu początkowego z regionu reprezentowanego przez dzielony wierzchołek muszą zostać powielone. Przykładowo, wartości gen i kill dla wierzchołków w dwuwierz chołkowym regionie z lewej strony rys. 10.57 zostaną wartościami gen i kill dla każdego z odpowiadających wierzchołków w obu dwuwierzchołkowych regionach po prawej stronie. W końcowym kroku, gdy wyliczamy in dla wszystkich wierzchoł ków, te wierzchołki grafu początkowego, które mają wielu reprezentantów w regio nie końcowym, mają in obliczone jako sumę zbiorów in dla wszystkich swoich rep rezentantów. W najgorszym przypadku podział wierzchołków może spowodować wykładni czy wzrost liczby wierzchołków reprezentowanych przez wszystkie regiony. Jeśli więc oczekujemy, że wiele grafów przepływu będzie nieredukowalnych, nie powin niśmy raczej stosować metod opartych na strukturze. Szczęśliwie, nieredukowalne grafy przepływu bardzo rzadko występują i zwykle możemy pominąć koszt podziału wierzchołków.
10.11
Narzędzia do analizy przepływu danych
Jak już wiemy, istnieją duże podobieństwa między różnymi rozpatrywanymi problemami związanymi z przepływem danych. Między równaniami przepływu danych z p . 10.6 różnice dotyczyły: 1)
2) 3)
używanej funkcji przeniesienia, która w każdym rozpatrywanym problemie miała postać f(X) = A U (X — B)\ przykładowo, A = kill i B = gen dla definicji osiągają cych, operatora łączącego, który we wszystkich przypadkach był sumą lub przecięciem, kierunku propagacji informacji: do przodu lub do tyłu.
Ponieważ różnice te nie są wielkie, nie powinno nas dziwić, że problemy związane z przepływem danych można traktować w podobny sposób. Takie podejście opisał Kildall [1973], a narzędzia służące do uproszczenia implementacji problemów związanych z przepływem danych wykorzystał w trakcie tworzenia kilku kompilatorów. Nie są one powszechnie używane zapewne dlatego, że stosując j e , można zaoszczędzić mniej pra cy, niż stosując takie narzędzia, jak generatory analizatorów leksykalnych. Powinniśmy jednak wiedzieć, co można dzięki nim zrobić, i to nie tylko dlatego, że upraszczają pra cę autorom optymalizującym kompilatory, ale również dlatego, że pomagają powiązać różne pojęcia wprowadzone w tym rozdziale. Ponadto, w tym podrozdziale opisaliśmy skuteczniejsze metody analizy przepływu danych niż omówione wcześniej. Szkielety analizy p r z e p ł y w u danych Opiszemy szkielety, które są modelami problemów z propagacją do przodu. Jeśli rozpa trujemy tylko rozwiązania typu iteracyjnego, to kierunek przepływu informacji nie ma znaczenia; możemy odwrócić kierunek krawędzi i dokonać pewnych małych zmian, aby prawidłowo obsłużyć wierzchołek początkowy i traktować problem z propagacją do tyłu jak problem z propagacją do przodu. Algorytmy działające w oparciu na strukturze są trochę inne; problemy z propagacją do przodu i do tyłu nie są rozwiązywane w dokład nie taki sam sposób, bo graf odwrotny do redukowalnego grafu przepływu nie musi być redukowalny. Pozostawimy jednak Czytelnikowi rozwiązywanie problemów z propagacją do tyłu jako ćwiczenie i zajmiemy się wyłącznie problemami z propagacją do przodu. Szkielet analizy przepływu danych składa się z: 1) 2)
zbioru V wartości, które będą propagowane; wartości in i out są w zbiorze V, zbioru F funkcji przeniesienia z V do V,
3)
dwuargumentowej operacji spotkania
A, działającej na V, reprezentującej operator
łączący. P r z y k ł a d 10.41. Dla definicji osiągających, V składa się ze wszystkich podzbiorów zbioru definicji w programie. Zbiór F jest zbiorem wszystkich funkcji o postaci f(X) — = A U (X — B), gdzie A i B są zbiorami definicji, tj. elementami V; A i B nazwaliśmy, odpowiednio, gen i kill. Operator A to suma. Dla wyrażeń dostępnych, V składa się ze wszystkich podzbiorów zbioru wyrażeń obliczanych przez program, a F jest zbiorem wyrażeń o takiej postaci jak wyżej, ale z A i B oznaczającymi zbiory wyrażeń. Operacją spotkania jest, oczywiście, przecięcie. •
Przykład 10.42. Podejście Kildalla można stosować nie tylko do prostych przykładów, którymi do tej pory się zajmowaliśmy, lecz również do bardziej złożonych, zarówno ze względu na czas obliczeń, jak i trudność intelektualną. W ćwiczeniach na końcu roz działu proponujemy bardzo silny przykład, w którym obliczone informacje o przepływie danych w istocie mówią n a m o wszystkich parach wyrażeń, które mają tę samą wartość w punkcie. Aby przybliżyć problemy z tego ćwiczenia, podajemy metodę sprawdzania, które zmienne mają stałe wartości. Stosując tę metodę, zgromadzimy więcej informacji, niż stosując definicje osiągające. Używając naszego nowego szkieletu, możemy stwier dzić, na przykład, że gdy x jest zdefiniowane przez d: x : = x + l i x m a stałą wartość przed przypisaniem, to m a również stałą wartość po przypisaniu. Postępując odwrotnie niż opisaliśmy powyżej, czyli używając definicji osiągających do propagacji stałych, zobaczylibyśmy, że instrukcja d jest możliwą definicją x, i w związ ku z tym przyjęlibyśmy, że x nie ma stałej wartości. Oczywiście, w jednym przebie gu prawa strona d: x : = x + l może zostać zastąpiona przez stałą i w kolejnej rundzie propagacji stałych można by wykryć, że użycia x zdefiniowanego w d były faktycznie użyciami stałej. W nowym szkielecie, zbiór V jest zbiorem wszystkich odwzorowań zmiennych pro gramu do konkretnych zbiorów wartości. Ten zbiór składa się z: 1) 2)
3)
wszystkich stałych, wartości nonconst oznaczającej, że o zmiennej wiadomo, że nie ma stałej warto ści; wartość nonconst będzie przypisana do zmiennej x, jeśli, przykładowo, podczas analizy przepływu danych odkryjemy dwie ścieżki, wzdłuż których do x przypisy wane były wartości, odpowiednio 2 i 3, lub ścieżkę, na której ostatnia definicja x była instrukcją read, wartości undef oznaczającej, że o badanej zmiennej nie da się nic powiedzieć, zapewne dlatego, że jesteśmy na początku analizy przepływu danych i nie napotka liśmy jeszcze definicji tej zmiennej, która osiąga rozważany punkt.
Zauważmy, że nonconst i undef to nie te same wartości; właściwie są one przeciwne. Pierwsza z nich oznacza, że widzieliśmy tyle różnych możliwych definicji zmiennej, że wiemy, iż nie jest ona stałą. Druga oznacza, że o zmiennej wiemy tak mało, że nic nie możemy o niej powiedzieć. Operacja spotkania jest zdefiniowana w tablicy na rys. 10.58. Niech jl i V bę dą dwoma elementami V, czyli odwzorowują każdą zmienną na stałą, nonconst lub undef. Wartość p = /x A V jest zdefiniowana na rys. 10.58, gdzie podaliśmy wartość p ( x ) w zależności od wartości ju(x) i v ( x ) dla każdej zmiennej x. W tej tablicy c jest do wolną stałą, a d jest inną stałą, na pewno różną od c. Jeśli, na przykład, ju(x) = c oraz v ( x ) = d, to oczywiście x może przyjąć wartości c lub d, przechodząc po dwóch różnych ścieżkach, i przy połączeniu tych ścieżek x nie m a stałej wartości; stąd wy bór p ( x ) = nonconst. Jako inny przykład rozpatrzmy przypadek, gdy przechodząc wzdłuż jednej ścieżki, nic nie wiemy o x, co powoduje, że ju(x) = undef, a wzdłuż innej ścieżki — wiemy, że x m a wartość c. Po połączeniu tych ścieżek możemy przyjąć tylko, że x ma wartość c. Oczywiście, późniejsze wykrycie innej ścieżki, na której x m a przypisaną wartość inną niż c, zmieni wartość przypisaną do x po połącze niu na nonconst.
V(X)
nonconst c undef
x)
nonconst
c
nonconst nonconst nonconst
nonconst c c
undef nonconst nonconst d
nonconst c undef
Rys. 10.58. Wartość p ( x ) w zależności od ji(x) i v(x) Musimy jeszcze opracować zbiór funkcji F, które opisują przepływ informacji od początku d o końca każdego bloku. Opis tego zbioru funkcji jest skomplikowany, cho ciaż idea jest prosta. Podamy więc „podstawy" zbioru funkcji, opisując funkcje, które reprezentują pojedyncze instrukcje będące definicjami. Cały zbiór funkcji można zbudo wać przez złożenie funkcji ze zbioru podstawowego dla bloków z więcej niż jedną taką instrukcją. 1.
2.
3.
4.
Funkcja identycznościowa jest w F\ ta funkcja odpowiada każdemu blokowi, w któ rym nie m a definicji. Jeśli / to funkcja identycznościowa, a fi jest dowolnym od wzorowaniem zmiennych w wartości, to I{ji) = fi. Zauważmy, że samo fi nie musi być identycznością, jest ono dowolne. Dla każdej zmiennej x i stałej c istnieje taka funkcja / w F , że dla każdego odwzo rowania fi zV mamy / ( / i ) = v, gdzie v(w) = fi(w) dla wszystkich w różnych od x, i v ( x ) = c\ Te funkcje odpowiadają akcjom instrukcji przypisania o postaci x : =c. Dla dowolnych trzech (niekoniecznie różnych) zmiennych x, y i z w F jest taka funkcja / , że dla każdego odwzorowania fi z V mamy f(fx) = v. Odwzorowa nie v jest zdefiniowane następująco: dla każdego w oprócz x mamy v(w) = ju(w) oraz v ( x ) = fi(y) -hfi(z). Jeśli fi(y) lub ji(z) jest równe nonconst, to suma jest równa nonconst. Jeśli jU(y) lub fi(z) jest równe undef, ale żadne nie jest równe nonconst, to wynikiem jest undef. Ta funkcja opisuje efekt przypisania x : = y + z . Jak w całym tym rozdziale, + oznacza dowolny operator; potrzebne są oczywiste zmiany, jeśli operator jest jednoargumentowy, trój argumentowy lub m a więcej ar gumentów, a inne zmiany, również oczywiste, są potrzebne do obsłużenia efektów instrukcji kopiowania, x : = y . Dla każdej zmiennej x w F jest taka funkcja / , że dla każdego fi, / ( / i ) = v, gdzie v(w) = /i(w) dla wszystkich w innych niż x i v ( x ) = nonconst. Ta funkcja — poprzez wczytanie x — odpowiada definicji, gdyż po instrukcji read, z pewnością nie można założyć, że x ma jakąś ustaloną wartość. •
Aksjomaty szkieletów analizy przepływu danych Różne rodzaje algorytmów obliczania przepływu danych, które j u ż poznaliśmy, będą działały dla każdego szkieletu, jeżeli poczynimy pewne założenia co do zbioru V, zbioru funkcji F i operatora łączenia A. Nasze podstawowe założenia wymieniliśmy poniżej, ale niektóre algorytmy obliczania przepływu danych potrzebują dodatkowych założeń. 1. 2.
W F jest funkcja identycznościowa I, taka że I(fi) = fi dla wszystkich fi z V. Zbiór funkcji F jest zamknięty ze względu na złożenia; tj. dla dowolnych dwóch funkcji / oraz g z F, funkcja h zdefiniowana przez h(fi) — g(f{fi)) jest w F.
3.
A jest operacją łączną, przemienną i idempotentną. Te trzy własności opisujemy algebraicznie jako:
/iA(vAp) = (pAv) p A V = V Ap pAp
Ap
= p
dla dowolnych p, v oraz p z V. 4.
W V jest element największy,
T , spełniający
T Ap = p dla wszystkich p z V. Przykład 10.43. Rozważmy definicje osiągające. W F jest oczywiście identyczność, funkcja, dla której gen i kill są zbiorami pustymi. Aby wykazać zamknietość ze względu na składanie, przypuśćmy, że mamy dwie funkcje f (X)
=
G U{X-K )
f (X)
=
G U(X-K )
l
2
l
l
2
2
Wówczas f (f (X))=G U((G U(X-K ))-K ) 2
l
2
l
l
2
Możemy sprawdzić, że prawa strona powyższego równania jest algebraicznie równoważna równaniu (G U(G -K ))U(X-(K UK )) 2
l
2
l
2
Jeśli ustalimy, że K = K UK oraz G = (G U (G - K )), to wykażemy, że złożenie f z / , czyli f(X) = GU(X — K) ma postać taką, że należy do F. x
2
2
x
2
x
2
Łatwo można sprawdzić, że suma, która jest operatorem łączenia, jest łączna, prze mienna i idempotentną. Elementem „największym" okazuje się w tym przypadku zbiór pusty, bo 0 UX = X dla dowolnego zbioru X. Gdy rozważamy wyrażenia dostępne, okazuje się, że te same argumenty, których użyliśmy dla definicji osiągających, pozwalają nam wykazać, że w F jest identyczność i że jest on zamknięty ze względu na złożenia. Operatorem łączenia jest w tym przypadku przecięcie, ale ten operator również jest łączny, przemienny i idempotentny. Element „największy" jest tym razem bardziej zgodny z intuicją — jest to zbiór E wszystkich wyrażeń w programie, gdyż dla dowolnego zbioru wyrażeń X, EDX =X. •
Przykład 10.44. Rozpatrzmy szkielet do obliczania stałych z przykładu 10.42. Zbiór funkcji F był zaprojektowany tak, aby zawierał identyczność i był zamknięty ze względu na złożenia. Aby sprawdzić prawa algebraiczne dla A, wystarczy wykazać, że działają one dla każdej zmiennej x. Jako przykład sprawdzimy idempotentność. Niech V = p Ap, tj. dla wszystkich x, v ( x ) = p(x) A p(x). Łatwo jest sprawdzić, analizując wszystkie przypadki, że v ( x ) = / i ( x ) . Jeśli, na przykład, /x(x) = nonconst, to v ( x ) = nonconst, gdyż rezultatem połączenia nonconst z drugim nonconst jest nonconst (patrz rys. 10.58).
Ostatecznie, największym elementem jest odwzorowanie % zdefiniowane jako T ( X ) = = undef dla wszystkich zmiennych x. Możemy sprawdzić, za pomocą rys. 10.58, że dla dowolnego odwzorowania jx i dowolnej zmiennej x, jeśli v jest funkcją T A j U , to v ( x ) = = M( )> wynikiem połączenia undef z dowolną wartością jest ta druga wartość. • x
0
0
Monotoniczność i d y s t r y b u t y w n o ś ć Potrzebujemy dodatkowego warunku, aby działały iteracyjne algorytmy analizy przepły wu danych. Z takiego warunku, nazywanego monotonicznością, wynika nieformalnie, że jeśli weźmiemy dowolną funkcję / ze zbioru F i zaaplikujemy / do dwóch elementów V, z których jeden jest „większy" od drugiego, to rezultat aplikacji / do elementu większego nie jest mniejszy niż aplikacji / do elementu mniejszego. Chcąc precyzyjniej opisać pojęcie „większy", zdefiniujmy relację ^ na V następu jąco: ji < v
wtedy i tylko wtedy, gdy
ji A V — }x
P r z y k ł a d 10.45. W szkielecie dla definicji osiągających, gdzie łączenie jest sumą, a ele menty V są zbiorami definicji, X ^ Y oznacza X\JY = X, tj., że X jest nadzbiorem Y. Relacja ^ wygląda na „odwróconą"; mniejsze elementy V są nadzbiorami elementów większych. Dla wyrażeń dostępnych, gdzie łączenie jest przecięciem, relacja jest „normalna" i X ^ Y oznacza, że X fi Y = X ; czyli X jest podzbiorem Y. • Z przykładu 10.45 wynika, że nasza relacja ^ nie ma wszystkich właściwości relacji $C na liczbach całkowitych. Prawdą jest, że ^ jest przechodnia; Czytelnik może dowieść, jako ćwiczenie z używania aksjomatów dla A, że fi ^ V i V ^ p implikuje U j ^ p . Jednakże nasza relacja ^ nie jest porządkiem liniowym. W szkielecie dla wyrażeń dostępnych, na przykład, możemy mieć dwa zbiory, X i Y, z których żaden nie jest podzbiorem drugiego, a wtedy ani X ^ Y, ani Y ^ X nie jest prawdą. Często wygodnie jest narysować zbiór V na diagramie kratowym, będącym grafem, którego wierzchołkami są elementy zbioru V, SL krawędzie są skierowane w dół, od X do Y, jeśli Y ^ X. Przykładowo, na rys. 10.59 mamy zbiór V dla problemu definicji osiągających, z trzema definicjami, d d \ d Ponieważ ^ oznacza „nadzbiór", krawędź jest skierowana w dół od dowolnego podzbioru tych trzech definicji do każdego z jego nadzbiorów. Ponieważ relacja ^ jest przechodnia, j a k zwykle pomijamy krawędź od X do 7 , jeśli istnieje inna ścieżka od X do Y, która jest na diagramie. Dlatego, mimo że {d^ d i d ) ^ {dy}, nie rysujemy tej ścieżki, bo jest ona reprezentowana, na przykład, przez ścieżkę prowadzącą przez {d d }. Warto również zauważyć, że z takich diagramów możemy odczytywać łączenia, gdyż X AK jest zawsze największym takim Z, dla którego są ścieżki od X i Y w dół do Z. Jeśli, na przykład, X to {d }, a Y to {d }, to Z (patrz rys. 10.59) to {Ć/J, d }, co jest rozsądne, gdyż operatorem łączenia jest suma. Jest również prawdą, że element największy pojawi się na górze kraty; tj. istnieje ścieżka w dół od T do każdego elementu. Możemy teraz powiedzieć, że szkielet (F, V, A) jest monofoniczny, jeśli v
2
2
y
3
v
x
implikuje
2
/(/i) ^ /(v)
dla wszystkich fi oraz v z V i / z F .
2
2
(10.15)
0 Wi)
Wl)
i
i Wl
{d ,
d)
2
d} 3
2
{d , x
d, 2
d} 3
Rys. 10.59. Krata podzbiorów definicji
(10.16) dla wszystkich /i oraz v z V i / z F. Ponieważ wygodnie jest korzystać raz z jednej, a raz z drugiej z tych definicji, naszkicujemy więc dowód ich równoważności, pozostawiając Czytelnikowi pewne proste do sprawdzenia fakty przy użyciu definicji ^ i praw łączności, przemienności i idempotentności dla A. Przyjmijmy założenie (10.15) i wykażmy, dlaczego prawdziwe jest (10.16). Przede wszystkim, zauważmy, że dla każdego jU i v prawdą jest, że jj. A V ^ jU oraz jU A V ^ ^ v; dowód tego jest prosty i pozostawiamy Czytelnikowi jego przeprowadzenie poprzez wykazanie, że dla dowolnych x i y, (xAy) Ay = x A y . Stąd, korzystając z (10.15), f(fiA Av) ^ f([x) i f(fi A v ) ^ / ( v ) , Pozostawiamy Czytelnikowi sprawdzenie ogólnego prawa mówiącego, że x ^y
oraz
x ^ z
implikuje
x^yAz
Podstawiając f(fi A v) za x, y = f([i) oraz z = / ( v ) , otrzymujemy (10.16). Na odwrót, załóżmy, że mamy (10.16) i dowiedźmy (10.15). Przypuśćmy, że jU ^ v i skorzystajmy z (10.16), aby stwierdzić, że f(fi) $ / ( v ) , dowodząc w ten sposób (10.15). Z równania (10.16) wiemy, że f(p A v ) ^ / ( / i ) A / ( v ) . Ale ponieważ przyjęliśmy, że jU ^ V, to z definicji fi A V = ji. Czyli z (10.16) wynika, że / ( / i ) ^ f(ji) A / ( v ) . Czytelnik może wykazać prawdziwość ogólnego prawa jeśli
x^yAz,
to
Czyli (10.16) implikuje / ( / z ) ^ / ( v ) i udowodniliśmy (10.15). Często w szkielecie jest spełniony warunek mocniejszy niż (10.16), który nazywamy warunkiem dystrybutywności /(MAv)=/(M)A/(v) dla wszystkich jU oraz v zV \ f z F. Oczywiście, jeśli x = y, to x Ay = i n a mocy prawa idempotentności, czyli x ^ y. Wobec tego dystrybutywność implikuje monotoniczność. P r z y k ł a d 10.46. Rozważmy szkielet dla definicji osiągających. Niech X i Y będą zbio rami definicji, a / — funkcją określoną następująco: f(Z) = GU(Z — K) dla pewnych zbiorów definicji G i K. Możemy wówczas stwierdzić, że szkielet dla definicji osiągają cych spełnia warunek dystrybutywności, sprawdzając, że
GU{(X\JY)-K)
=
(GU(X-K))\J(G\J(Y-K)
Diagram Venna, choć dla tych zbiorów jest dość skomplikowany, pozwala dowieść po wyższą równość.
E]
P r z y k ł a d 10.47. Wykażmy, że szkielet do obliczania stałych jest monofoniczny, ale nie dystrybutywny. Po pierwsze, wygodnie będzie zastosować operację A i relację ^ do elementów występujących w tablicy z rys. 10.58. Zdefiniujmy więc nonconst A c cAd c A undef nonconst A undef xAx
= nonconst = nonconst —c = nonconst = x
dla dowolnej stałej c dla stałych c^d dla dowolnej stałej c dla dowolnej wartości x
Wówczas, rysunek 10.58 można interpretować jako stwierdzenie, że p ( a ) = ju(a) A v ( a ) . Możemy określić jak wygląda relacja ^ , korzystając z operacji A. Otrzymujemy nonconst
^ c c ^ undef nonconst ^ undef
dla dowolnej stałej c dla dowolnej stałej c
Ta relacja jest przedstawiona na diagramie kratowym na rys. 10.60, gdzie c- są wszystkimi możliwymi stałymi. Podkreślmy, że rysunek ten nie przedstawia relacji ^ na elementach V; jest to relacja na zbiorach wartości fi (a) dla pojedynczych zmiennych a. O elemen tach V można myśleć, jak o wektorach takich wartości, po jednej składowej dla każdej zmiennej, a diagram kratowy dla V można otrzymać z rys. 10.60, jeśli będziemy pa miętali, że fi ^ V jest prawdziwe wtedy i tylko wtedy, gdy / i ( a ) ^ v ( a ) dla wszystkich a; tzn., jeśli wektory reprezentujące fi i v mają wszystkie elementy związane relacją ^ i relacja m a ten sam kierunek dla wszystkich elementów.
undef
nonconst
Rys. 10.60. Diagram kratowy dla wartości zmiennych
Czyli stwierdzenie, że fi ^ V oznacza, że kiedy fi ( a ) jest stałą c, / i ( a ) jest albo tą stałą, albo undef, a kiedy ju(a) jest równe undef, to v ( a ) również. Uważne spraw dzenie różnych funkcji / , które są związane z różnymi rodzajami instrukcji definiują cych, pozwala n a m stwierdzić, że gdy fi ^ v, to f(fi) ^ / ( v ) , w ten sposób dowodząc (10.15) i wykazując monotoniczność. Jeśli, na przykład, / jest związane z przypisaniem a : = b + c , zmieniają się tylko ju(a) i v ( a ) , więc musimy sprawdzić, czy jeśli fi ^ V —
ł
czyli ju(x) ^ v ( x ) dla wszystkich x — to [/(/x)](a) ^ [ / ( v ) ] ( a ) . Musimy rozpatrzyć wszystkie możliwe wartości p(b), p(c), ( t > ) i v ( c ) , zgodne z warunkami mówiącymi, że ii(b) ^ v ( b ) i p(c) < v ( c ) . Jeśli, na przykład v
p(b)
=
nonconst
v(b)
2
M(c)
3
v(c) =
undef
a
to [ / ( M ) ] ( ) — nonconst i [ / ( v ) ] ( a ) = undef. Ponieważ nonconst ^ undef, sprawdzili śmy warunek dla jednego przypadku. Pozostałe przypadki zostawiamy do sprawdzenia Czytelnikowi jako ćwiczenie. Musimy jeszcze sprawdzić część naszego twierdzenia mówiącą, że szkielet d o ob liczania stałych nie jest dystrybutywny. Dalej niech / będzie funkcją związaną z przy pisaniem a : =b+c i niech p(b) = 2, fi(c) = 3, v(b) = 3 i v(c) = 2. Niech p = p A V. Wówczas p(b) A v ( b ) = 2 A 3 = nonconst. Podobnie, p(c) A v ( c ) = nonconst. Równo ważnie, p ( b ) — p ( c ) = nonconst. Mamy więc [ / ( p ) ] ( a ) = nonconst, bo o sumie dwóch wartości, które nie są stałe, zakładamy, że nie jest stałą. Jednakże, [/"(//)] ( a ) — 5, gdyż mieliśmy b = 2 i c = 3, więc przypisanie a : =b+c nadaje a wartość 5. Podobnie, [ / ( v ) ] ( a ) = 5. Czyli [f(p) A / ( v ) ] ( a ) = 5. Widzimy teraz, że p ( a = [ / ( / i A v ) ] ( a ) ^ [/(M) A / ( v ) ] ( a ) , czyli warunek dystrybutywności nie jest spełniany. Intuicyjnie, powodem, dla którego naruszony jest warunek dystrybutywności, jest to, że szkielet do obliczania stałych nie jest wystarczająco silny, żeby pamiętać wszystkie niezmienniki, w szczególności to, że wzdłuż ścieżek, których wpływ na zmienne jest opisywany przez p lub v, prawdziwe jest równanie b + c = 5, mimo że ani b ani c nie są stałe. Możemy opracować bardziej skomplikowany szkielet, aby przezwyciężyć ten problem, ale zysk z tej pracy nie jest oczywisty. Szczęśliwie, jak zobaczymy wkrótce, iteracyjnym algorytmom obliczania przepływu danych wystarczy monotoniczność. • Rozwiązywanie problemów przepływu danych metodą „spotkań ścieżek" Wyobraźmy sobie, że w grafie przepływu z każdym wierzchołkiem związana jest funkcja przeniesienia, jedna z funkcji ze zbioru F. Dla dowolnego bloku B niech f oznacza funkcję przeniesienia dla B. B
Rozważmy dowolną ścieżkę P = B ~> B —> > B , od wierzchołka początkowego BQ do pewnego bloku B . Możemy zdefiniować funkcję przeniesienia dla P jako złożenie Q
{
K
K
/fl > /B > • • •' fB j * Podkreślmy, że funkcji f 0
1
k
nie ma w tym złożeniu, co odzwierciedla
B
fakt, że ścieżka m a dojść do początku bloku B , a nie do jego końca. Przyjęliśmy, że wartości z V reprezentują informacje o danych używanych przez program, a operator łączenia A mówi nam, jak informacje są łączone, gdy ścieżki się zbiegają. Rozsądnie jest myśleć, że element największy reprezentuje „brak informacji", ponieważ ścieżka przenosząca element największy ustępuje każdej innej ścieżce, jeśli K
' Musimy uważać, czytając wyrażenie typu [ / ( u ) ] ( a ) . Mówi ono, że aby otrzymać pewne odwzorowanie f(łi), które nazywamy u', aplikujemy / do u. Następnie aplikujemy u' do a, a wynik jest jedną z wartości z diagramu z rys. 10.60.
chodzi o informację przenoszoną po połączeniu ścieżek. Jeśli więc B jest blokiem w grafie przepływu, informacja wchodząca do B powinna być obliczalna poprzez rozpatrzenie wszystkich możliwych ścieżek od wierzchołka początkowego do B, i sprawdzanie, co dzieje się wzdłuż ścieżki, gdy zaczynamy od braku informacji. Czyli dla każdej ścieżki P od B do B obliczamy f (T) i łączymy wszystkie wyniki. Teoretycznie, możemy musieć wykonać łączenie nieskończonej liczby różnych war tości, bo istneje nieskończenie wiele różnych ścieżek. W praktyce, często wystarcza roz ważać tylko ścieżki acykliczne, a nawet jeśli nie, tak jak w opisanym j u ż szkielecie do obliczania stałych, możemy znaleźć inne powody, dla których w dowolnych grafach możemy rozpatrywać łączenie skończone zamiast nieskończonego. Formalnie, rozwiązanie „spotkania ścieżek" (nazywanego również łączeniem) dla 0
P
grafu przepływu definiujemy jako mop(B)
=
/\
f (T) p
ścieżki P od B do B Q
To, że rozwiązanie mop w grafie przepływu ma sens, widać, gdy zauważymy, że rozwa żając informacje docierające do bloku B, graf przepływu również może wyglądać tak, jak graf z rys. 10.61, gdzie każda funkcja przeniesienia związana z jedną ze ścieżek (być może z nieskończenie wielu) P P ,--, z pierwotnego grafu przepływu otrzymuje ścieżkę do B całkowicie rozłączną z innymi. Na rysunku 10.61 informacje docierające do B to łączenie po wszystkich ścieżkach. v
2
B
Rys. 10.61. Graf przedstawiający zbiór wszystkich możliwych ścieżek do B Bezpieczne rozwiązania problemów przepływu Próbując rozwiązywać równania przepływu danych, pochodzące z dowolnych szkieletów, czasem łatwo, a czasem trudno jest dojść do rozwiązania mop. Szczęśliwie, tak jak w naszych przykładach szkieletów przepływu danych z p. 10.5 i 10.6, istnieje bezpieczny kierunek, w którym można robić błędy, i iteracyjne algorytmy przepływu danych, opisane w tych podrozdziałach, dawały bezpieczne rozwiązanie. Mówimy, że rozwiązanie in[B] jest rozwiązaniem bezpiecznym, jeśli in[B] ^ mop(B) dla wszystkich bloków B. Mimo że Czytelnik może tak sądzić, nasza definicja nie została wyjęta z rękawa. Przypomnijmy, że w dowolnym grafie przepływu zbiór ścieżek możliwych do wierzchoł ka (tych, które są ścieżkami w grafie przepływu) jest właściwym podzbiorem zbioru ścieżek prawdziwych, tych które są wybierane podczas pewnego wykonania programu odpowiadającego omawianemu grafowi przepływu. Aby wyniki analizy przepływu da nych były przydatne, dane muszą pozostać bezpieczne po zmodyfikowaniu grafu poprzez
skasowanie pewnych ścieżek, gdyż nie możemy na ogół odróżnić ścieżek prawdziwych od możliwych, które nie są prawdziwe. Przypuśćmy, że dla nieskończonego zbioru ścieżek, przedstawionego na rys. 10.61, x jest łączeniem f (T) obliczonym po wszystkich ścieżkach prawdziwych P , wybiera nych w pewnym wykonaniu. Dalej, niech y będzie łączeniem po wszystkich ścieżkach P. Wówczas, mop(B) jest równy xAy. Prawdziwym rozwiązaniem naszego problemu przepływu danych w wierzchołku B jest x, ale rozwiązanie mop jest równe x Ay. Przypo mnijmy, że x Ay ^ y, gdyż (JC Ay) Ay = x Ay. Czyli rozwiązanie mop jest ^ rozwiązaniu prawdziwemu. Chociaż możemy preferować „prawdziwe" rozwiązanie problemu przepływu danych, to prawie na pewno nie znajdziemy skutecznej metody pozwalającej stwierdzić, które ścieżki są prawdziwe, a które możliwe, i w związku z tym musimy przyjąć rozwiązanie mop jako najlepsze rozwiązanie osiągalne. Czyli, używając informacji o przepływie da nych, musimy brać pod uwagę, że rozwiązanie, które otrzymamy, będzie ^ prawdziwemu rozwiązaniu. Jeśli tak będzie, powinniśmy również akceptować rozwiązania, które będą ^ mop (czyli również ^ prawdziwemu rozwiązaniu). W tych szkieletach, które są m o notoniczne, ale nie są dystrybutywne, takie rozwiązania są łatwiejsze do uzyskania niż mop. W szkieletach dystrybutywnych, takich jak w p. 10.6, prosty algorytm iteracyjny oblicza rozwiązanie mop. P
Algorytm iteracyjny dla szkieletów ogólnych Istnieje oczywiste uogólnienie algorytmu 10.2, które działa dla wielu różnych szkie letów. Algorytm iteracyjny wymaga, aby szkielet był monotoniczny. Wymaga również skończoności, czyli tego, aby łączenie p o nieskończonym zbiorze ścieżek, przedstawione na rys. 10.61, było równoważne łączeniu po skończonym podzbiorze. Poniżej przedstawi liśmy algorytm, a następnie metody, które można stosować, aby zapewnić skończoność. Jest pewien znany warunek zapewniający skończoność, który już wcześniej stosowaliśmy: badanie propagacji po ścieżkach acyklicznych jest wystarczające. Algorytm 10.18.
Iteracyjne rozwiązanie dla szkieletów ogólnych przepływu danych.
Wejście. Graf przepływu danych, zbiór „wartości" V, zbiór funkcji F, operacja spotkania A i przyporządkowanie elementu z F do każdego wierzchołka grafu przepływu. Wyjście. Wartość in[B] należąca do V, dla każdego bloku grafu przepływu. t
Metoda. Algorytm jest podany na rys. 10.62. Tak jak w znanych nam iteracyjnych al gorytmach przepływu danych, obliczamy — przez kolejne przybliżenia — in i out dla każdego wierzchołka. Przyjmujemy, że f jest funkcją z F związaną z blokiem B, ta funkcja odgrywa rolę gen i kill z p. 10.6. • B
Narzędzia do analizy przepływu danych Poniżej przedstawiliśmy zastosowanie pomysłów z tego podrozdziału w narzędziach do analizy przepływu danych. Algorytm 10.18 potrzebuje do działania następujących podprocedur:
1) 2) 3) 4) 5) 6) end Rys. 10.62. Iteracyjny algorytm dla szkieletów ogólnych 1. 2. 3.
Procedury, która aplikuje dane f z F do podanej wartości z V\ jest ona używana w wierszach (2) i (6) na rys. 10.62. Procedury, która aplikuje operator spotkania do dwóch wartości z V; jest ona po trzebna zero lub więcej razy w wierszu (5). Procedury sprawdzającej, czy dwie wartości są równe. Ten test nie jest widoczny na rys. 10.62, ale jest wykonywany niejawnie podczas sprawdzania, czy zmieniły się jakieś wartości out. B
Potrzebujemy również specjalnych deklaracji typów dla F i V, żeby móc przeka zywać argumenty do wymienionych wyżej procedur. Wartości in i out z rys. 10.62 są również typu zadeklarowanego dla V. Potrzebujemy jeszcze procedury, która pobierze zwykłą reprezentację zawartości bloku bazowego, czyli listę instrukcji, i wygeneruje ele ment F , funkcję przeniesienia dla tego bloku. P r z y k ł a d 10.48. Dla szkieletu do definicji osiągających możemy najpierw zbudować tablicę, która wiąże każdą instrukcję danego grafu przepływu z liczbą całkowitą, od 1 z pewnym maksymalnym m. Wtedy, typem V mogą być tablice bitów o długości m. F może być reprezentowane jako pary tablic tej wielkości, czyli zbiory gen i kill. Procedura budująca tablice gen i kill, która otrzymuje instrukcje z bloku, oraz tablicę wiążącą instrukcje definiujące z pozycjami w tablicach bitów jest oczywista, tak samo jak procedura obliczająca spotkania (logiczne or na tablicach bitów), sprawdzanie równości tablic bitów i aplikacja funkcji definiowanych przez parę gen-kill do tablic bitów. • Tak więc, narzędzie do analizy przepływu danych to niewiele więcej niż implemen tacja kodu z rys. 10.62, z odwołaniami do podanych procedur, gdy potrzebne jest łączenie, aplikacja funkcji bądź porównanie. Narzędzie obsługiwałoby pewną stałą reprezentację grafów przepływu i w związku z tym mogłoby wykonywać czynności, takie jak wyszu kiwanie wszystkich poprzedników wierzchołka, porządkowanie grafu przepływu w głąb czy aplikowanie do każdego bloku funkcji obliczającej funkcję z F związaną z tym blo kiem. Używanie takiego narzędzia jest korzystne, gdyż operacje na grafie i sprawdzanie zbieżności z algorytmu 10.18 nie muszą być pisane od nowa dla każdej analizy przepływu danych, którą wykonujemy. Własności a l g o r y t m u 10.18 Powinniśmy określić założenia, które muszą być spełnione, aby działał algorytm 10.18, oraz napisać, do czego są zbieżne wartości podawane przez algorytm. Przede wszystkim,
jeśli szkielet jest monofoniczny i zbieżny, to twierdzimy, że w wyniku algorytmu in[B] ^ ^ mop(B) dla wszystkich bloków B. Przypuszczalnie, powodem jest fakt, że dla dowolnej ścieżki P — B , B ,..., B od wierzchołka początkowego do B = B możemy wykazać przez indukcję względem i, że skutki ścieżki od B do B są widoczne po co najwyżej * iteracjach pętli while z rys. 10.62. Czyli, jeśli P jest ścieżką 5 , . . B , to po i rundach < f ,0~)- Stąd, jeśli algorytm jest zbieżny i tylko wtedy, in[B] będzie ^ f (T) dla 0
{
k
k
Q
i
i
0
t
P
P
1
każdej ścieżki P od B do B. Korzystając z reguły, że gdy x ^ y oraz x ^ z, to x ^ y A z , możemy wykazać, że ^ mop(B). Jeżeli szkielet jest dystrybutywny, możemy wykazać, że algorytm 10.18 rzeczywiście zbiega do rozwiązania mop. Należy przede wszystkim udowodnić, że przez cały czas wykonywania algorytmu, każde z in[B] i out[B] jest równe łączeniu f (T) dla pewnego zbioru ścieżek P do, odpowiednio, początku i końca B. Niestety, z następnego przykładu wynika, że gdy szkielet jest monotoniczny, ale nie dystrybutywny, nie musi to być prawdą. Q
P
P r z y k ł a d 10.49. Wykorzystajmy przykład niedystrybutywności ze szkieletu do oblicza nia stałych, opisanego w przykładzie 10.47; odnośny graf przepływu jest pokazany na rys. 10.63. Odwzorowania p i V wychodzące zB '\B są takie, jak w przykładzie 10.47. Odwzorowanie p , wchodzące do B , to p A v, a a jest odwzorowaniem wychodzącym z B , ustalonym jako nonconst, mimo że na każdej ścieżce prawdziwej (i każdej ścieżce możliwej) a = 5 po przejściu bloku B . 2
Ą
5
5
5
B,
B-,
Br
B,
p(b) = p{c) = nonconst
a := b+c o* (a) = nonconst
Rys. 10.63. Przykład rozwiązania mniejszego niż rozwiązanie mop
Problemem, w uproszczeniu, jest to, że algorytm 10.18 działający na szkielecie niedystrybutywnym zachowuje się tak, jakby pewne sekwencje wierzchołków, które nie są nawet ścieżkami możliwymi (ścieżkami w grafie przepływu), były ścieżkami prawdzi-
Teoretycznie, w dowodzie trzeba rozpatrzyć nie tylko przypadek dwóch wartości y i z (z którego wynika reguła, że jeśli x ^ y dla dowolnego skończonego zbioru elementów y-, to x ^ y ) , ale również przypadek nieskończenie wielu y Jednakże w praktyce, gdy algorytm 10.18 jest zbieżny, możemy znaleźć skończony zbiór ścieżek, taki że łączenie po wszystkich ścieżkach jest równoważne łączeniu po tym skończonym zbiorze ścieżek. t
(
r
wymi. W związku z tym (rys. 10.63), algorytm zachowuje się tak, jakby ścieżki, takie jak B —> B —>• B -> B czy B —> B -¥ B —> Z? , były ścieżkami prawdziwymi, nadając b i c wartości, które w sumie nie dają pięciu. • 0
x
Ą
5
0
3
2
5
Zbieżność algorytmu 10.18 Istnieje kilka metod, za pomocą których moglibyśmy dowieść, że algorytm 10.18 jest zbieżny dla danego szkieletu. Prawdopodobnie najczęstszym przypadkiem jest taki, w któ rym są potrzebne tylko ścieżki acykliczne, czyli możemy wykazać, że łączenie po ścież kach acyklicznych jest takie samo, jak rozwiązanie mop po wszystkich ścieżkach. Jeśli tak jest, to algorytm nie tylko jest zbieżny, ale jest zbieżny bardzo szybko, w dwóch przebiegach więcej niż wynosi głębokość grafu, co stwierdziliśmy w p. 10.10. Jednakże, szkielety, takie jak w naszym przykładzie obliczającym stałe, wymagają rozważenia ścieżek nie tylko acyklicznych. Przykładowo, na rys. 10.64 widać prosty graf przepływu, w którym musimy rozważyć ścieżkę B —> B —• B -> B aby stwierdzić, że x nie ma stałej wartości przy wejściu do B x
2
2
v
y
x :- 2
1 -
x : -x+l
B
2
Bi
Rys. 10.64. Graf przepływu, dla którego ścieżki z cyklami muszą być w mop
Mamy jednak następujące uzasadnienie zbieżności algorytmu 10.18 dla naszego obliczania stałych. Łatwo jest wykazać dla dowolnego monotonicznego szkieletu, że in[B] i out[B] dla każdego bloku B tworzą ciąg nierosnący, w takim sensie, że nowa wartość jednej z tych zmiennych jest zawsze ^ starej. Jeśli przypomnimy sobie rys. 10.60, diagram kratowy dla wartości odwzorowań zastosowanych do zmiennych, spostrzeżemy, że dla każdej zmiennej wartości in[B] i out[B] mogą zmniejszyć się tylko dwukrotnie, raz z undef do stałej i raz ze stałej do nonconst. Przypuśćmy, że mamy n wierzchołków i v zmiennych. Wówczas, przy każdej iteracji pętli while z rys. 10.62, co najmniej jedna zmienna musi zmniejszyć wartość w którymś z out[B], gdyż algorytm już byłby zbieżny i nawet nieskończenie wiele iteracji pętli while nie zmieniłoby wartości in ani out. Wobec tego, liczba iteracji jest ograniczona przez 2nv; jeśli wystąpi taka liczba zmian, to każda zmienna w każdym bloku grafu przepływu musi mieć wartość nonconst.
Poprawianie inicjowania W pewnych problemach przepływu danych istnieją różnice między tym, co algorytm 10.18 podaje jako rozwiązanie, i tym, czego byśmy oczekiwali. Przypomnijmy, że przy
wyrażeniach dostępnych A jest przecięciem, więc T musi być zbiorem wszystkich wy rażeń. Ponieważ algorytm 10.18 początkowo zakłada, że in[B] jest równe T dla każdego bloku B, łącznie z blokiem początkowym, więc rozwiązanie mop generowane przez algo rytm 10.18 jest faktycznie zbiorem wyrażeń, które, przyjmując że są dostępne w wierz chołku początkowym (gdzie nie są dostępne), byłyby dostępne przy wejściu do bloku B. Różnica, oczywiście, wynika z tego, że mogą istnieć ścieżki od wierzchołka począt kowego do B, wzdłuż których wyrażenie x + y nie jest generowane ani zabijane. Z algo rytmu 10.18 wynikałoby, że x + y jest dostępne, chociaż faktycznie nie jest, gdyż wzdłuż tej ścieżki nie ma zmiennej przechowującej wartość tego wyrażenia. Poprawka jest pro sta. Możemy albo tak zmodyfikować algorytm 10.18, aby w szkielecie do obliczania wyrażeń dostępnych in[B ] było od początku, przez cały czas, równe zbiorowi pustemu, albo możemy wprowadzić sztuczny wierzchołek początkowy, poprzednik prawdziwego wierzchołka początkowego, który zabija wszystkie wyrażenia. Q
10.12
Wykrywanie typów
Doszliśmy teraz do problemów przepływu danych bardziej skomplikowanych niż szkie lety. W różnych językach, począwszy od APL, przez SETL, do wielu dialektów Lisp, nie jest wymagane deklarowanie typów zmiennych, a nawet jest dozwolone, żeby ta sama zmienna przechowywała w różnym czasie wartości różnych typów. W poważnych próbach kompilowania takich języków do wydajnego kodu używano analizy przepływu danych do wyprowadzenia typów zmiennych, gdyż kod służący do, powiedzmy, dodania dwóch liczb całkowitych jest dużo wydajniejszy niż wywołanie ogólnej procedury, która może dodać dwa obiekty dowolnego typu (np. całkowitego, rzeczywistego, wektorowego). W pierwszej chwili może nam się wydawać, że wyznaczanie typów zmiennych jest czymś podobnym do obliczania definicji osiągających. Możemy związać zbiór typów możliwych z każdą zmienną w każdym punkcie. Operatorem łączenia jest suma zbiorów typów, ponieważ gdy na jednej ścieżce zbiór typów możliwych zmiennej x to S p a na in nej tym zbiorem jest S , to po połączeniu ścieżek, x może być dowolnego typu ze zbioru Sj U S . Gdy sterowanie przechodzi przez instrukcję, czasem możemy wyciągnąć pewne wnioski co do typów zmiennych, opierając się na operatorach w danej instrukcji, typach możliwych ich argumentów i typach ich wyników. Przykład 6.6, w którym zajmowali śmy się operatorem, mnożącym zarówno liczby całkowite, jak i liczby zespolone, był przykładem wnioskowania tego rodzaju. 2
2
Niestety, z podejściem takim wiążą się co najmniej dwa problemy. 1. 2.
Zbiór typów możliwych zmiennej może być nieskończony. Wyznaczanie typów zazwyczaj wymaga propagacji informacji zarówno do przodu, jak i do tyłu, aby otrzymać dokładne oszacowanie typów możliwych. W związku z tym, nawet szkielet z p. 10.11 nie jest wystarczająco ogólny do rozwiązania tego problemu.
Zanim zajmiemy się punktem 1., przyjrzyjmy się pewnym wnioskom dotyczącym typów, które można wyciągnąć dla znanych języków.
Przykład 10.50.
Rozpatrzmy instrukcje
i : =a [ j ] k:=a[i] Załóżmy początkowo, że nie wiemy nic o typach zmiennych a, i , j oraz k. Przypuśćmy jednak, że operator dostępu do elementów tablicy, [ ] , wymaga argumentu całkowitego. Sprawdzając pierwszą instrukcję, możemy wywnioskować, że j jest liczbą całkowitą, natomiast a jest tablicą elementów pewnego typu. Z drugiej instrukcji wynika, że i jest liczbą całkowitą. Możemy teraz przesłać wnioski do tyłu. Jeśli ustalimy, że w pierwszej instrukcji i musi być liczbą całkowitą, to typem wyrażenia a [ i ] musi być liczba całkowita, co oznacza, że a jest tablicą liczb całkowitych. Możemy znowu wnioskować do przodu, i odkryć, że wartość przypisana do k w drugiej instrukcji również musi być liczbą całkowitą. Zauważmy, że wnioskując wyłącznie do przodu bądź wyłącznie do tyłu, nie jest możliwe odkrycie faktu, że elementy a są liczbami całkowitymi. •
Obsługa nieskończonych zbiorów typów Istnieje wiele przykładów patologicznych przypadków, w których zbiór typów możliwych dla zmiennej jest rzeczywiście nieskończony. SETL, na przykład, pozwala instrukcjom, takim jak x:={x} by były wykonywane w pętli. Jeśli wykrywanie typów zaczniemy, wiedząc tylko, że x może być liczbą całkowitą, to po rozpatrzeniu jednej iteracji pętli zauważymy, że x może być albo liczbą całkowitą, albo zbiorem liczb całkowitych. Po rozpatrzeniu drugiej iteracji okaże się, że x może być także zbiorem zbiorów liczb całkowitych, i tak dalej. Podobny problem może wystąpić w beztypowej wersji zwykłego języka, takiego jak C, gdzie instrukcja x=&x — wiedząc początkowo, że x może być liczbą całkowitą — prowadzi nas do odkrycia, że x może mieć dowolny typ o postaci wskaźnik do wskaźnika do • • • wskaźnika do liczby całkowitej Takie problemy rozwiązuje się zwykle poprzez zmniejszenie zbioru typów możli wych do zbioru skończonego. Ogólnym pomysłem jest grupowanie nieskończonej licz by typów możliwych w skończoną liczbę klas, zazwyczaj przechowując prostsze typy oddzielnie, a grupując najbardziej skompilowane i, miejmy nadzieję, najrzadsze typy, w większe klasy. Gdy to zrobimy, musimy osądzić, co wiemy o interakcjach między typami i operatorami. Następujący przykład sugeruje, co w tym celu możemy zrobić.
Przykład 10.51. Kontynuujmy przykład z rozdz. 6, w którym używaliśmy operatora —• jako konstruktora typów dla funkcji. Niech zbiór typów zawiera typ podstawowy int oraz wszystkie typy o postaci T -» c , reprezentujące typ funkcji z dziedziną typu %
i przeciwdziedziną typu o, gdzie x i a są typami z tego zbioru. Taki zbiór typów jest więc nieskończony i zawiera typy, takie jak (int
int) -¥ ((int -> int) ->• int)
Aby zmniejszyć ten zbiór do skończonej liczby klas, ograniczmy wyrażenia typowe do tych, które zawierają tylko jeden konstruktor typu funkcyjnego, zastępując podwy rażenia w wyrażeniu typowym zawierającym co najmniej jedno wystąpienie - » nazwą func. Mamy więc pięć różnych typów int int int int -» func func —• int func —» func Będziemy reprezentowali zbiory typów jako tablice bitowe długości pięć, z pozycjami odpowiadającymi pięciu typom w kolejności wymienionej powyżej. 01111 reprezentu j e więc typ dowolnej aplikacji funkcji, czyli każdy oprócz int. Zauważmy, że jest to w pewnym sensie typ func, gdyż func nie może być liczbą całkowitą. Podstawową instrukcją przypisania w naszym modelu jest x:=f(y) Jeżeli znamy typy możliwe f i y, to możemy wyznaczyć typy możliwe x, sprawdzając typ w tabeli z rys. 10.65. Jeśli f może być dowolnego typu ze zbioru S , a y dowolnego typu ze zbioru 5 , weźmy każdą parę x z S i a z S i sprawdźmy wartość w rzędzie % kolumny a, zapisując to jako T(CT). Obliczmy następnie sumę wyników tych wszystkich sprawdzeń, aby otrzymać zbiór typów możliwych x. {
2
T
int int —> int int —> func func —> int func —> func
x
int
int -¥ int
00000 10000 01111 00000 00000
00000 00000 00000 10000 01111
2
G int —> func func —• int func —> func
00000 00000 00000 10000 01111
00000 00000 00000 10000 01111
00000 00000 00000 10000 01111
Rys. 10.65. Wartości x(a)
Jeśli, na przykład x = int -> func, a a — int, to T(CT) = 0 1 1 1 1 . Oznacza to, że wynikiem aplikacji funkcji typu int —>• func do int jest func, czyli funkcja dowolnego z czterech typów poza int. Nie możemy stwierdzić którego, ponieważ nasze przekształ cenie nieskończonej liczby typów w pięć klas nie pozwala nam tego sprawdzić. W drugim przykładzie, niech x będzie takie, jak poprzednio, a a = int -> int. Wów czas x(o) = 00000, gdyż dziedzina typu x z pewnością nie jest równa typowi a, czyli takiego odwzorowania nie można wykonać. •
Prosty system typów Chcąc przedstawić pomysły związane z naszymi algorytmami wnioskowania o typach, wprowadziliśmy prosty system typów i język oparty na przykładzie 10.51. W naszym systemie jako typów będziemy używać pięciu typów z tego przykładu. Instrukcje naszego języka należą do trzech rodzajów: 1) 2) 3)
read x — wartość x jest odczytywana z wejścia i, przypuszczalnie, nic nie wia domo o jej typie, x:=f ( y ) — wartością x jest wynik aplikacji funkcji f do wartości y; możliwe informacje o typie x po przypisaniu są przedstawione na rys. 10.65, use x as X — mając taką instrukcję, możemy przyjąć, że program jest poprawny i w związku z tym typem x może być jedynie x, zarówno przed, jak i po tej instrukcji; wartość i typ x nie są zmieniane przez tę instrukcję.
Wnioski o typach możemy wyciągnąć, wykonując analizę przepływu danych na gra fie przepływu programu składającego się z instrukcji tych trzech typów. Dla uproszczenia zakładamy, że wszystkie bloki składają się z pojedynczych instrukcji. Wartości in i out dla bloków to odwzorowania ze zmiennych w zbiory pięciu typów z przykładu 10.51. Początkowo każdy in i out odwzorowuje każdą zmienną na zbiór wszystkich pięciu typów. Gdy propagujemy informacje, zmniejszamy zbiory typów związane z pewnymi zmiennymi w pewnych punktach, aż w pewnej chwili nie jesteśmy w stanie dalej zmniej szać żadnego z tych zbiorów. Dla zbiorów wynikowych przyjmiemy, że wskazują typy możliwe wszystkich zmiennych we wszystkich punktach. Takie założenie jest konserwa tywne, ponieważ typ jest eliminowany tylko wtedy, gdy potrafimy dowieść (zakładając, że program jest poprawny), że ten typ nie jest możliwy. W normalnej sytuacji oczekuje my wykorzystania informacji, że pewne typy są niemożliwe, a nie, że są możliwe, czyli „zbyt duży" zbiór typów jest bezpiecznym kierunkiem dla błędów. Używamy dwóch metod do modyfikacji zbiorów in i out: metody „do przodu" i metody „do tyłu". W metodzie do przodu używamy instrukcji w bloku B i wartości in[B], aby ograniczyć out[B] , a w metodzie do tyłu działamy odwrotnie. W każdej metodzie operatorem łączenia jest „suma po zmiennych", co znaczy, że łączenie dwóch odwzorowań a i j3 jest takim odwzorowaniem y, że dla wszystkich zmiennych x x
y[x] = a[x]uj3[x] Metoda do przodu Załóżmy, że mamy blok B z in[B], odwzorowaniem ji oraz out[B], odwzorowaniem V. Metoda do przodu pozwala nam ograniczyć v. Reguły ograniczające v naturalnie zależą od instrukcji znalezionej w bloku B. 1
Warto zauważyć, że w tradycyjnych metodach przepływu danych do przodu nie ograniczaliśmy out, lecz za każdym razem obliczaliśmy je od nowa, korzystając z in. Mogliśmy tak robić, gdyż zbiory in i out zawsze zmieniały się w jednym kierunku — zawsze rosnąc lub zawsze się zmniejszając. Jednakże w problemie, takim jak wnioskowanie o typach, gdzie na zmianę wykonujemy przejścia w przód i w tył, może zaistnieć sytuacja, w której przejście do tyłu pozostawi zbiór out dużo mniejszy, niż może to być uzasadnione przez stosowanie reguł do przodu do zbioru in. Wobec tego, nie możemy przypadkowo zamazać out w przejściu do przodu, tylko po to, by je ponownie zmniejszyć (ale, być może, nie tak bardzo) w przejściu do tyłu. Podobna uwaga dotyczy przejść w tył; musimy ograniczać in, a nie obliczać je od nowa.
1.
Jeśli instrukcją jest r e a d x, to wczytana wartość może być dowolnego typu. Jeśli już coś wiemy o typie x po wczytaniu, to nie możemy o tym zapomnieć podczas przechodzenia do przodu, tak więc po prostu nie zmieniamy v ( x ) . Dla wszystkich innych zmiennych y przyjmujemy v(y):=v(y)n/i(y)
2.
Przyjmijmy teraz, że instrukcją jest u s e x a s x. Po tej instrukcji, x jest jedynym możliwym typem x . Jeśli j u ż wiemy, że typ x jest niemożliwy dla x, to po tej instrukcji nie m a możliwego typu dla x. Podsumowując te obserwacje, otrzymujemy v ( x ) := v ( x ) D { r } v(y)
3.
:= V ( y ) f 1 / i ( y )
dla y ^
x
Sprawdźmy teraz przypadek, w którym instrukcją jest x : = f ( y ) . Możliwe typy dla x po instrukcji to te, które i) są możliwe zgodnie z obecną wartością V, ii) są wynikiem aplikacji funkcji pewnego typu x d o typu er, oraz % i a są typami, które, odpowiednio, f i y mogły mieć przed wykonaniem instrukcji. Formalnie, v ( x ) : = v ( x ) n { p | p = T((T),
x jest w \i{£)
oraz a jest w ju(y)}
Możemy również wyciągnąć pewne wnioski co do typów f i y, ponieważ — zgodnie z założeniem o poprawności programu — f nie może być typu, którego nie można zastosować do któregoś z możliwych typów y, a y nie może być typu, który nie może służyć jako argument dla pewnego możliwego typu f. Czyli, jeśli f ^ x , to v ( f ) : = v ( f ) n { r z n(f)
| dla pewnego a z ji(y), x(a) ^ 0 }
jeśli y 7^ x , to v ( y ) : = v ( y ) n { a z /x(y) | dla pewnego Tzfi(f), x(o) ^
0}
a dla pozostałych z
v(z):=v(z)D^(z)
Metoda do tyłu Zobaczmy teraz, jak w przejściu do tyłu możemy ograniczać fi, opierając się na tym, co mówi v oraz instrukcja. 1.
2.
Jeśli instrukcją jest r e a d x, łatwo zauważyć, że nie da się wyciągnąć nowych wniosków o typach niemożliwych przed wykonaniem instrukcji, więc / i ( x ) się nie zmienia. Jednak dla wszystkich innych y ^ x możemy propagować informacje do tyłu, ustalając ju(y) := ju(y)nv(y). Jeśli mamy instrukcję u s e x a s T, to możemy wyciągnąć wnioski tego samego rodzaju, c o w metodzie d o przodu; przed instrukcją, x może mieć tylko typ T, a typy innych zmiennych to te, które mogły one mieć zarówno przed, jak i po instrukcji.
Czyli /i(x) : = M ( x ) n { T } M ( Y ) : = M ( y ) n v ( y ) dla y ^ x 3.
Tak jak poprzednio, najbardziej skomplikowanym przypadkiem jest instrukcja o po staci x : = f ( y ) . Po pierwsze, nie można wywnioskować nic nowego na temat typu x przed instrukcją, chyba że x to f albo y . Czyli / i ( x ) nie jest zmieniane, oprócz ewentualnych zmian wprowadzonych przez reguły dotyczące f i y . Zauważmy także, że tak j a k w metodzie do przodu, możemy wyciągnąć wnioski wynikające z faktu, że typy f i y musiały być zgodne przed instrukcją. Jednakże, jeśli f ^ x, możemy również ograniczyć fi(f) do typów z v ( f ) oraz zrobić to samo dla y . Po drugie, jeśli f = x, to możliwe typy f po instrukcji nie mają związku z typami f przed instrukcją, więc nie można wprowadzać takich ograniczeń. To samo dotyczy y, jeśli y = x . Widać więc, że wygodnie jest zdefiniować specjalne odwzorowanie, tylko dla f i y. Definiujemy więc jeśli jeśli
f = x, t o M i ( f ) : = M ( f ) y = x, to/ii(y):=M(y)
wpp W
PP
^ ( f ) :=/i(f)nv(f) Mi(y) := M(y)n v(y)
Możemy teraz ograniczyć f i y do tych typów, które są zgodne ze zbiorem typów drugiej zmiennej. Jednocześnie możemy ograniczyć typy f i y, opierając się na fakcie, że nie tylko muszą być zgodne, ale typ wyniku funkcji musi być typem dozwolonym przez v dla x. Czyli definiujemy jU(f) := { T Z jUi(f)l
dla pewnego a z Mi(y)> t ( a ) n v ( x ) ^ 0 }
ju(y) := {a z ^ ( y ) !
dla pewnego T z / ^ ( f ) , x(a)C\ v ( x ) ^ 0 }
fi (z) := j U ( z ) f ) v ( z )
dla z różnych od x, y oraz f
Zanim przejdziemy do algorytmu wyznaczania typów, przypomnijmy opis defini cji osiągających z p. 10.5. Zauważyliśmy, że jeśli przyjmiemy nieprawdziwe zało żenie, że pewna definicja d jest dostępna w jakimś punkcie pętli, możemy tę błędną informację rozpropagować wokół pętli, co spowoduje, że zbiór definicji osiąga jących będzie większy niż jest to konieczne. Podobny problem może wystąpić przy wyznaczaniu typów, gdzie założenie, że zmienna może mieć pewien typ, „dowodzi" się po obejściu pętli. Wprowadzimy więc 33. wartość, oprócz 32 zbiorów typów z przykładu 10.51, którą odwzorowanie \i będzie przypisywało zmiennym, wartość undef. Z undef będziemy korzystać podobnie, jak w szkieletach do obliczania stałych z poprzedniego podrozdziału. Podczas łączenia wartość undef podporządkowuje się dowolnej innej wartości, tj. zachowuje się jak typ 00000. Natomiast, gdy obliczamy przecięcie typów, np. ju(x)n D v ( x ) , wartość undef również podporządkowuje się każdemu innemu zbiorowi typów, czyli zachowuje się j a k typ 11111. W związku z tym, na przykład, gdy wczytujemy wartość zmiennej x, unieważniamy przypuszczenie, iż „typem" x było undef, a typem x zostaje 11111.
A l g o r y t m 10.19. Wejście. Graf przepływu, którego blokami są pojedyncze instrukcje trzech opisanych wcześniej typów (wczytanie, przypisanie i nadanie typu).
Wyjście. Zbiór możliwych typów każdej zmiennej w każdym punkcie. Zbiór ten jest konserwatywny w takim sensie, że każde wykonane obliczenie musi prowadzić do typu z tego zbioru. Metoda. Obliczamy odwzorowanie in[B] i odwzorowanie out[B] dla każdego bloku B. Każde odwzorowanie przypisuje zmiennym programu zbiory typów z systemu typów wprowadzonego w przykładzie 10.51. Początkowo, wszystkie odwzorowania przypisują wszystkim zmiennym wartość undef. Na przemian wykonujemy przejścia po grafie przepływu do przodu i do tyłu, aż na stępujące po sobie przejścia do przodu i do tyłu nie wprowadzą żadnych zmian. Przejście do przodu jest wykonywane następująco: for każdego bloku B w kolejności w głąb do begin in[B] := U out[P]\ P poprzednik B
out[B] := zdefiniowana wyżej funkcja zależna od in[B] i out[B] end Przejście do tyłu to for każdego bloku B w odwrotnej kolejności w głąb do begin out[B] . -
U
in[S\\
S następnik B
in[B] := zdefiniowana wyżej funkcja zależna od in[B] i out[B] end
•
Przykład 10.52. Rozpatrzmy prosty program bez pętli z rys. 10.66. Jesteśmy zaintereso wani czterema odwzorowaniami, które oznaczamy /ij do jU . Każde z jx jest jednocześnie out[Bj] i Formalnie rzecz biorąc, B nie powinno składać się z dwóch instrukcji, gdyż w tym podrozdziale przyjęliśmy, że bloki są pojedynczymi instrukcjami. Nie bę dziemy jednak przejmować się zdarzeniami przed końcem B gdyż wszystkie zmienne mogą tam mieć dowolny typ. 4
i
x
v
Bi
read a read b jij
Br
= out[B{\ = in[B ] 2
c := b(a) \x = out[B ] = in[B ] 2
B,
ź
3
use a as int H
B
2
3
= out[B ] = in[B ] 3
Ą
c := b (c; m = out[B ] 4
Rys. 10.66. Przykładowy program
Okazuje się, że potrzebujemy pięciu przejść zanim algorytm zbiegnie i kolejnych dwóch, aby wykryć, że algorytm j u ż doszedł d o wartości granicznych. Te przejścia są przedstawione na rys. 10.67(a)-(e). Pierwsze przejście jest do przodu. Gdy rozpatrujemy B , odkrywamy, że b nie może być typu int, bo jest używane jako funkcja. Zauważamy też, że a jest używane jako liczba całkowita w B , i — w związku z tym — może być odwzorowana przez fi i fi tylko na int. Te wnioski są przedstawione na rys. 10.67(a). 2
3
3
Mi \h M3
Ą
b 11111 01111 01111 01111
a 11111 11111 10000 10000
c undef 11111 11111 11111
Mi M2
M M
3
a 10000 10000 10000 10000
Mi M2
M3 M
b 01100 01100 01100 01100
c undef 11111 11111 11111
4
(b) Do tyłu
(a) Do przodu
a 10000 10000 10000 10000
b 01100 01111 01111 01111
c undef 11111 11111 11111
Mi M2
M M
4
3
a 10000 10000 10000 10000
b 01000 01100 01100 01100
c undef 10000 10000 11111
4
(d) Do tyłu
(c) Do przodu
Mi M2
M M
3
a 10000 10000 10000 10000
b 01000 01000 01000 01000
c undef 10000 10000 10000
4
(e) Do przodu Rys. 10.67. Symulacja algorytmu 10.19 dla grafu przepływu z rys. 10.66
Drugie przejście, pokazane na rys. 10.67(b), jest do tyłu. W tym przejściu, gdy rozpatrujemy B , wiemy, że a musi być liczbą całkowitą, gdy aplikujemy d o niej b . Czyli typem b może być tylko int —> int albo int —> func. W trzecim przejściu, do przodu, ograniczenie na typ b jest propagowane aż do dołu grafu przepływu, co widać na rys. 10.67(c). 2
Czwarte przejście, do tyłu, jest pokazane na rys. 10.67(d). Z faktu, że c jest argu mentem b w 5 możemy wywnioskować, że c może być tylko liczbą całkowitą. Dalej, gdy rozpatrujemy B , odkrywamy, że wynik b ( a ) może być tylko takiego typu jak c, które jest typu int. Ta informacja pozwala odrzucić przypuszczenie, że b jest typu int —• func. W końcu, na rys. 10.67(e) widzimy, j a k w piątym przejściu, do przodu, są propagowane informacje o b i c . W dalszych przejściach nie możemy j u ż wyciągnąć nowych wniosków. W tym przypadku zmniejszyliśmy zbiór typów możliwych do poje3
2
dynczych typów dla każdej zmiennej w każdym punkcie; a i c są liczbami całkowitymi, a b jest odwzorowaniem liczb całkowitych w liczby całkowite. W innych przypadkach może nam zostać kilka możliwych typów dla danej zmiennej w danym punkcie. •
10.13
Symboliczny program uruchomieniowy dla zoptymalizowanego kodu
Symboliczny program uruchomieniowy (ang. symbolic debuggef) to program, który po zwala oglądać dane programu podczas jego wykonywania. Jest on zazwyczaj wywoły wany, gdy występuje błąd programu, taki jak przekroczenie zakresu, albo gdy pewne instrukcje, zaznaczone przez programistę w kodzie źródłowym, właśnie mają się wy konać. Po uruchomieniu, symboliczny program uruchomieniowy pozwala programiście oglądać, i w razie potrzeby zmieniać, dowolne zmienne, które są w danej chwili dostępne w uruchamianym programie. Aby polecenia użytkownika, takie jak „pokaż mi aktualną wartość a", były zrozu miałe dla takiego programu, musi mieć on dostęp do pewnych informacji. 1.
2.
3.
4.
Musi istnieć metoda związania identyfikatora, takiego jak a, z lokacją przez nie go reprezentowaną. Czyli część tablicy symboli, która przypisuje każdej zmiennej jej lokację, tj. np. miejsce w obszarze danych globalnych albo w rekordzie akty wacji pewnej procedury, musi być zapamiętana przez kompilator i zachowana do wykorzystania przez program uruchomieniowy. Taką informację można na przykład zakodować w module ładującym program. Muszą być dostępne informacje o widzialności, aby odwołanie do identyfikatora zadeklarowanego w wielu miejscach było jednoznaczne i abyśmy wiedzieli, będąc w pewnej procedurze p , dane których procedur są dostępne i jak j e znaleźć na stosie lub w innej strukturze danych. Tak jak poprzednio, te informacje muszą zostać pobrane z tablicy symboli kompilatora i przechowane do późniejszego wykorzystania przez program uruchomieniowy. Musimy wiedzieć, w którym miejscu programu jesteśmy w chwili wywołania pro gramu uruchomieniowego. Te informacje są wstawiane przez kompilator w wywoła niu tego programu, gdy kompilator przetwarza jego wywołanie przez użytkownika. Kiedy powodem wywołania programu uruchomieniowego jest błąd w programie, są one pobierane z procedury obsługi wyjątku. Aby informacje o miejscu w programie opisane w p. 3. były przydatne dla użyt kownika, potrzebna jest tablica wiążąca każdą instrukcję w języku maszynowym z instrukcją źródłową, która spowodowała jej wygenerowanie. Taka tablica może być sporządzona przez kompilator podczas generowania kodu.
Mimo że budowa symbolicznego programu uruchomieniowego jest bardzo ciekawa, my ograniczymy się wyłącznie do opisania problemów, które powstają, gdy próbujemy napisać taki program dla kompilatora optymalizującego. Na pierwszy rzut oka może się wydawać, że nie ma takiej potrzeby. W normalnym cyklu produkcji, podczas poprawiania programu przez użytkownika, używany jest szybki, nieoptymalizujący kompilator, aż do
chwili, gdy użytkownik przekona się, że program źródłowy jest poprawny. Dopiero wtedy korzysta się z kompilatora optymalizującego. Niestety, program może działać poprawnie podczas kompilacji kompilatorem nieoptymalizującym, a ulegać awarii — przy tych samych danych wejściowych — gdy jest kompilowany kompilatorem optymalizującym. Błąd może być na przykład w kompi latorze optymalizującym, który wskutek zmiany kolejności operacji może wprowadzać nadmiar lub niedomiar wartości numerycznej. Ponadto, nawet „nieoptymalizujące" kom pilatory mogą wykonywać proste transformacje, takie jak usuwanie lokalnych podwy rażeń wspólnych czy zmiana kolejności instrukcji w bloku bazowym, które powodują duże utrudnienia w projektowaniu symbolicznego programu uruchomieniowego. Musi my więc rozważyć, jakich algorytmów i struktur danych używać w takim programie dla kompilatora optymalizującego, który dowolnie przekształca bloki bazowe. Wyprowadzanie wartości zmiennych w blokach bazowych Dla uproszczenia przyjmijmy, że zarówno kod źródłowy, jak i wynikowy to sekwencje instrukcji pośrednich. Traktowanie kodu źródłowego jako kodu pośredniego nie stanowi problemu, gdyż ten drugi jest ogólniejszy niż pierwszy. Przykładowo, użytkownik może mieć możliwość wstawienia przerwania (wywołanie programu uruchomieniowego) tylko między instrukcje źródłowe, a my pozwalamy na wstawianie przerwań po każdej in strukcji kodu pośredniego. Używanie kodu pośredniego zamiast kodu wynikowego może budzić wątpliwości tylko wtedy, gdy optymalizator dzieli pojedyncze instrukcje kodu po średniego na wiele instrukcji kodu maszynowego i te instrukcje są następnie rozdzielane. Przykładowo, z jakiegoś powodu możemy skompilować dwie instrukcje pośrednie
u: -v+w x: =y+z do kodu, w którym dwa dodawania zostaną wykonane w różnych rejestrach i będą ze sobą przeplatane. W takim przypadku możemy traktować odczyty i zapisy rejestrów tak, jakby rejestry były zmiennymi tymczasowymi w kodzie pośrednim, na przykład
rl: = v r2 : = y rl:=rl+w r2:=r2+z u: =rl x: =r2 Kilka problemów może wystąpić w trakcie interakcji programu uruchomieniowego z użytkownikiem, w związku z blokiem, o którym użytkownik myśli, że jest blokiem źródłowym, podczas gdy wykonywana jest jego zoptymalizowana wersja: 1.
Przypuśćmy, że wykonujemy program, który jest wynikiem „optymalizacji" pew nych bloków bazowych programu źródłowego i że w trakcie wykonywania instrukcji a : = b + c dochodzi do przekroczenia zakresu. Musimy poinformować użytkownika, że wystąpił błąd w jednej z instrukcji źródłowych. Ponieważ b + c może być podwyrażeniem wspólnym znajdującym się w dwóch lub więcej instrukcjach źródłowych, powstaje pytanie: której z tych instrukcji przypisać błąd?
2.
Trudniejszy problem występuje, gdy użytkownik debugera chce zobaczyć „aktual ną" wartość pewnej zmiennej d. W zoptymalizowanym programie d mogło po raz ostatni zmieniać wartość w pewnej instrukcji s. Ale w kodzie źródłowym s może następować po instrukcji, w trakcie której został wywołany program uruchomie niowy, więc wartość d, dostępna dla niego, nie musi być tą, o której użytkownik myśli, że jest „aktualną" wartością d zgodnie z treścią kodu źródłowego. Podobnie, s może poprzedzać instrukcję wywołującą program uruchomieniowy, ale w kodzie źródłowym między nimi może być inne przypisanie do d, tak że wartość d dostęp na dla niego jest nieaktualna. Czy możliwe jest udostępnienie właściwej wartości d użytkownikowi? Czy może ona, na przykład, być wartością jakiejś innej zmien nej w kodzie zoptymalizowanym albo czy może być wyliczona z wartości innych zmiennych?
3.
W końcu, jeśli użytkownik wstawi przerwanie po pewnej instrukcji kodu źródłowe go, to w którym momencie powinien zostać wywołany program uruchomieniowy podczas wykonywania kodu zoptymalizowanego?
Pewnym rozwiązaniem mogłoby być wykonywanie niezoptymalizowanej wersji blo ku razem z wersją zoptymalizowaną, aby właściwe wartości każdej zmiennej były do stępne przez cały czas. Odrzucamy to „rozwiązanie", bo najsubtelniejsze błędy, w szcze gólności błędy wprowadzone przez kompilator, mogą zniknąć, gdy instrukcje, które j e powodowały, zostaną od siebie oddzielone w czasie lub przestrzeni. Rozwiązanie, które przyjmiemy, to zapewnienie programowi uruchomieniowemu in formacji o każdym z bloków, które pozwolą przynajmniej odpowiedzieć na pytanie: czy możliwe jest dostarczenie właściwej wartości zmiennej a, a jeśli tak, to jak to zrobić? Struktura, której użyjemy do przechowywania tych informacji, to dag dla bloku bazo wego, z dodatkowymi informacjami o tym, które zmienne i w jakim czasie przechowują wartości odpowiadające wierzchołkom daga w kodzie źródłowym i w programach zop tymalizowanych. Zapis a:
i-j
dołączony do wierzchołka oznacza, że wartość reprezentowana przez ten wierzchołek jest przechowywana w zmiennej a od początku instrukcji i aż do miejsca w instrukcji y, które jest tuż przez przypisaniem. Jeśli j — oo, to a przechowuje tę wartość aż do końca bloku. P r z y k ł a d 10.53. Na rysunku 10.68(a) widzimy blok bazowy kodu źródłowego, a na rys. 10.68(b) — możliwą „zoptymalizowaną" wersję tego kodu. Przedstawiliśmy dag dla obu bloków, ze wskazaniami zakresów, w których zmienne przechowują dane warto ści zarówno w kodzie źródłowym, jak i zoptymalizowanym. Wartości z apostrofami są używane do wskazania zasięgu instrukcji dotyczącego kodu zoptymalizowanego. Przy kładowo, wierzchołek oznaczony + jest wartością c z kodu źródłowego od początku instrukcji (2) aż do przypisania w instrukcji (3). Jest on również wartością d z kodu źródłowego od początku instrukcji (3) aż do końca. Ponadto, ten sam wierzchołek jest wartością d w kodzie zoptymalizowanym od instrukcji (2') aż do końca. • Możemy teraz odpowiedzieć na pierwsze z postawionych powyżej pytań. Przypu śćmy, że błąd, taki jak przekroczenie zakresu, wystąpi podczas wykonywania instrukcji
(1) (2) (3) (4) (5) (6)
c a+b d') d = a+b (2') t = b*e d c ( 3') a - d-e c c-e (4') b := = d/t a := d-e (5') c := a b := b*e b := d/b (a) (b) Rys. 10.68. Kod źródłowy i zoptymalizowany / z kodu zoptymalizowanego. Ponieważ ta sama wartość zostałaby obliczona przez do wolną instrukcję kodu źródłowego, wyliczającą ten sam wierzchołek daga, co instrukcja / , rozsądne jest zgłoszenie użytkownikowi, że wystąpił błąd w pierwszej instrukcji kodu źródłowego obliczającego ten wierzchołek. Czyli w przykładzie 10.53, jeśli błąd wystąpił w instrukcjach (1'), (2'), (3') albo (4'), to zgłosilibyśmy, że wystąpił on w, odpowied nio, instrukcjach (1), (5), (3) albo (6). Żaden błąd nie może wystąpić w instrukcji (5'), gdyż nie jest tam obliczana żadna wartość. Odkładamy opis, jak obliczać odpowiadające instrukcje, aż do przykładu 10.54. Możemy również odpowiedzieć na drugie pytanie. Przypuśćmy, że jesteśmy w in strukcji / kodu zoptymalizowanego, a użytkownik sądzi, że sterowanie jest w instrukcji i kodu źródłowego, gdzie wystąpił błąd. Jeśli użytkownik chce poznać wartość zmiennej x, musi znaleźć taką zmienną y (często, ale nie zawsze, y jest x), że wartość x w instrukcji / źródła jest tym samym wierzchołkiem daga, co y w instrukcji / kodu zoptymalizo wanego. Oglądamy dag, aby zobaczyć, który wierzchołek reprezentuje wartość x w /, i możemy z tego wierzchołka odczytać nazwy wszystkich zmiennych programu zoptyma lizowanego, kiedykolwiek przechowujących tę wartość, aby sprawdzić, czy któraś z nich przechowuje tę wartość w trakcie wykonywania instrukcji / . Jeśli tak, to już koniec; jeśli nie, ciągle możemy obliczyć wartość x w i z innych zmiennych w / . Niech n będzie wierzchołkiem dla x w chwili i. Możemy wówczas obejrzeć dzieci i, powiedzmy, m i p, aby zobaczyć, czy oba te wierzchołki reprezentują wartość pewnej zmiennej w chwili / . Jeśli, na przykład, jest wartość dla m, ale nie ma dla p, to rekurencyjnie oglądamy dzieci p. Ostatecznie, albo znajdziemy metodę obliczenia x w chwili i, albo dojdziemy do wniosku, że nie ma takiej metody. Jeśli znajdziemy metodę obliczenia wartości m i p, to obliczamy j e i stosujemy operator z n, aby obliczyć wartość x w chwili i . 1
Przykład 10.54. Przypuśćmy, że podczas wykonywania kodu z rys. 10.68(b) występuje błąd w instrukcji (2'). Instrukcja ta obliczała wierzchołek oznaczony * z rys. 10.69, a pierwszą instrukcją źródłową obliczającą tę wartość jest instrukcja (5). Zgłaszamy więc błąd w instrukcji (5). Na rysunku 10.70 przedstawiliśmy w tabeli wierzchołki daga odpowiadające wszyst kim zmiennym programu źródłowego i kodu zoptymalizowanego, na początku instrukcji (2') i (5); wierzchołki są oznaczone swoją etykietą, czyli symbolem operacji albo symbo lem wartości początkowej, np. A . Pokazaliśmy również, jak obliczyć wartość w chwili 5 Q
Subtelnym przypadkiem jest wystąpienie kolejnego błędu, spowodowanego obliczaniem n. Musimy wtedy zgłosić użytkownikowi, że błąd ten wystąpił wcześniej, w pierwszej instrukcji źródłowej obliczającej war tość n.
a: 5-oo c: 4-oo a: 4-oo'
b: oo
Rys. 10.69. Dag z dodatkowymi informacjami z wartości zmiennych w chwili 2'. Jeśli, na przykład, użytkownik chce poznać wartość a, trzeba podać wartość wierzchołka etykietowanego - . Żadna ze zmiennych nie prze chowuje tej wartości w chwili 2', ale szczęśliwie, są zmienne d i e, które w chwili 2' przechowują wartości każdego z dzieci wierzchołka - ; możemy więc podać wartość a, obliczając wartość d - e . •
WARTOŚĆ W ZMIENNA
a b c d e t
OBLICZANA PRZEZ CHWILI 2'
CHWILI 5
A)
-
niezdefiniowane
-
+
d-e b d-e d e
niezdefiniowane Rys. 10.70. Wartości zmiennych w chwilach 2' i 5
Odpowiedzmy teraz na trzecie pytanie: jak obsłużyć wywołania programu urucho mieniowego wstawione przez użytkownika. Odpowiedź może być trywialna. Jeśli użyt kownik chce zatrzymać program po instrukcji i programu źródłowego, możemy wstrzy mać wykonywanie programu na początku bloku, a jeśli użytkownik chce zobaczyć war tość pewnej zmiennej x po instrukcji /, możemy sprawdzić w naszym opisanym dagu, który wierzchołek reprezentuje żądaną wartość x i obliczyć tę wartość, korzystając z po czątkowych wartości zmiennych w bloku. Z drugiej strony, jeśli opóźnimy wywołanie programu uruchomieniowego tak bar dzo, jak to jest możliwe, to możemy zmniejszyć nakład pracy wykonywanej przez niego, jak również uniknąć pewnych sytuacji, w których próby obliczenia wartości prowadzą do błędów, które muszą być zgłoszone użytkownikowi. Łatwo jest znaleźć ostatnią in strukcję / z programu zoptymalizowanego, taką że będziemy wywoływali program uru chomieniowy po instrukcji / i udawali, że wywołanie zostało wykonane po instrukcji i programu źródłowego. Aby znaleźć / , niech S będzie zbiorem wierzchołków daga, które odpowiadają wartościom jakichś zmiennych z programu źródłowego tuż po instrukcji u Użytkownik może zażądać dowolnej wartości z S. Możemy więc zatrzymać program po
instrukcji / kodu zoptymalizowanego tylko wtedy, gdy dla każdego wierzchołka n z S istnieje takie k! > / , że jakaś zmienna jest związana z wierzchołkiem n w chwili Ić w ko dzie zoptymalizowanym. Wówczas wiemy, że wartość n jest albo dostępna bezpośrednio po instrukcji / , albo że będzie obliczona jakiś czas po instrukcji / . W pierwszym przy padku obliczenie wartości n po zatrzymaniu po instrukcji / jest trywialne, a w drugim przypadku wiemy, że wartości dostępne po / wystarczają do wyliczenia n. Przykład 10.55. Przyjrzyjmy się ponownie kodowi źródłowemu i zoptymalizowanemu z rys. 10.68 i załóżmy, że użytkownik wstawia przerwanie po instrukcji (3) kodu źródło wego. Aby znaleźć zbiór 5, oglądamy dag z rys. 10.69 i sprawdzamy, które wierzchołki są związane ze zmiennymi w chwili 4. Te wierzchołki to A , B E , + i - . Następnie, ponownie oglądamy dag, aby znaleźć takie największe / , że każdy z wierzchołków z S ma pewną zmienną z kodu zoptymalizowanego przypisaną do sie bie w czasie ostro większym od / . Wierzchołki oznaczone +, - i E nie przedstawiają problemu, ponieważ ich wartości są przenoszone przez zmienne, odpowiednio, d, a i e , w chwili Wierzchołki A i B ograniczają wartość / , a najwcześniej wartość swoją traci A , który jest niszczony przez instrukcję 3'. Czyli, / = 2' jest największą możli wą wartością / ; jeśli więc użytkownik zażąda przerwania po instrukcji źródłowej (3), możemy zatrzymać program po instrukcji (2'). • Q
QI
Q
Q
Q
0
0
Czytelnik powinien zdawać sobie sprawę z delikatnej kwestii związanej z przykła dem 10.55, dla której nie ma dobrego rozwiązania. Jeśli przed wywołaniem programu uruchomieniowego wykonamy instrukcję (2'), może tam wystąpić błąd podczas oblicza nia b * e (np. niedomiar), co spowoduje uruchomienie tego programu przed zamierzonym wywołaniem. Ponieważ obliczenia odpowiadające instrukcji (2') nie są wykonywane aż do instrukcji (5) programu źródłowego, to poinformujemy użytkownika, że błąd nastą pił w instrukcji (5). Użytkownik będzie zapewne nieco zdziwiony, że wykonywaliśmy instrukcję (5), a nie wywołaliśmy programu uruchomieniowego w instrukcji (3). Naj lepszym, prawdopodobnie, rozwiązaniem jest takie ograniczenie / , aby istniała taka instrukcja k! kodu zoptymalizowanego, z k ^ / , dla której w kodzie źródłowym wartość obliczana przez k nie jest obliczana aż do instrukcji i, po której zostało umieszczone przerwanie. 1
f
Skutki optymalizacji globalnej W trakcie wykonywania przez kompilator optymalizacji globalnych, powstają trudniejsze problemy, które muszą być rozwiązane przez symboliczny program uruchomieniowy, a często nie ma metody znalezienia właściwej wartości zmiennej w danym punkcie. Dwa ważne przekształcenia, które nie powodują znaczących problemów, to usuwanie zmiennych indukcyjnych i globalne usuwanie podwyrażeń wspólnych; w obu przypadkach problem można ograniczyć do kilku bloków i obsłużyć sposobem opisanym powyżej. Usuwanie zmiennych indukcyjnych Jeśli z programu źródłowego usuniemy zmienną i , pozostawiając innego reprezentanta rodziny i , powiedzmy t , to istnieje pewna funkcja liniowa wiążąca i oraz t . Ponadto,
jeśli użyjemy metod opisanych w p. 10.7, kod zoptymalizowany będzie zmieniał t do kładnie w tych blokach, w których było zmieniane i, czyli liniowy związek między i oraz t będzie zawsze prawdziwy. Możemy więc, uwzględniając reorganizację instrukcji w bloku, w którym następuje przypisanie do t (i w kodzie źródłowym również do i ) , dostarczyć użytkownikowi „aktualną" wartość i przez liniowe przekształcenie t. Musimy być ostrożni, jeśli i nie jest zdefiniowane przed pętlą, gdyż t z pewnością będzie miało wartość przed wejściem do pętli, i możemy w związku z tym podawać wartość i w punkcie, w którym użytkownik spodziewa się, że i nie jest zdefiniowane. Szczęśliwie, zmienne w programie źródłowym, które są zmiennymi indukcyjnymi, są zazwyczaj inicjowane przed wejściem do pętli, a tylko zmienne wygenerowane przez kompilator (o wartość których użytkownik nie może pytać) będą niezdefiniowane przy wejściu do pętli. Jeśli tak nie jest dla pewnej zmiennej indukcyjnej i , to mamy problem podobny do problemu z przemieszczeniem kodu, opisanego poniżej.
Globalne usuwanie podwyrażeń wspólnych Wykonywanie globalnego usuwania podwyrażeń wspólnych dla wyrażenia a+b wpły wa również na ograniczoną liczbę bloków, zmieniając j e w prosty sposób. Jeśli t jest zmienną, w której przechowujemy wartość a+b, to w pewnych blokach, w których jest obliczane a+b, możemy zastąpić instrukcję c:=a+b instrukcjami t:=a+b c:=t Takie zmiany można obsłużyć opisanymi wcześniej metodami dla bloków bazowych. W innych blokach użycie d:=a+b można zastąpić użyciem d:=t. Aby obsłużyć taką sytuację, korzystając z wcześniej podanych metod, musimy tylko zauważyć w dagu dla tego bloku, że wartość t pozostaje przez cały czas w wierzchołku przechowującym wartość a+b (który występuje w dagu dla kodu źródłowego, ale w innym przypadku nie wystąpiłby w dagu dla kodu zoptymalizowanego).
Przemieszczenie kodu Inne przekształcenia nie są tak proste w obsłudze. Załóżmy, na przykład, że przemiesz czamy instrukcję s: a: =b+c poza pętlę, gdyż jest ona niezmiennikiem pętli. Jeśli wywołamy program uruchomieniowy w pętli, to nie wiemy, czy instrukcja s byłaby już wykonana w programie źródłowym, i w związku z tym nie wiemy także, czy aktualna wartość a jest tą, której użytkownik spodziewał się po lekturze kodu źródłowego. Pewnym sposobem jest wstawienie do kodu zoptymalizowanego nowej zmiennej, która w pętli wskazuje, czy nastąpiło już w niej przypisanie do a (co mogło nastąpić tylko w poprzednim miejscu instrukcji s). Ta strategia nie zawsze znajduje zastosowanie,
gdyż — aby mieć absolutną pewność, że kod jest poprawny — możemy korzystać tylko z kodu rzeczywistego, a nie jego wersji stworzonej specjalnie do usuwania błędów. Istnieje jednak pewien często spotykany przypadek specjalny, w którym możemy sobie lepiej poradzić. Przypuśćmy, że blok fi, w kodzie źródłowym zawierający instrukcję s, dzieli pętlę na dwa zbiory wierzchołków: te, które dominują nad B, i te, które są przez B dominowane. Przyjmijmy ponadto, że wszyscy poprzednicy wejścia są zdominowani przez B, tak jak to pokazano na rys. 10.71. Wówczas, przy pierwszym przejściu przez bloki, które dominują nad B, możemy przyjąć, że do a nie było przypisania w pętli, a przy pierwszym przejściu przez bloki zdominowane przez B wiemy, że nastąpiło przypisanie w instrukcji s. Oczywiście, przy drugim i kolejnych przebiegach pętli, do a na pewno nastąpiło przypisanie w s.
Rys. 10.71. Blok dzielący pętlę na dwie części
Jeśli wywołanie programu uruchomieniowego następuje w wyniku błędu wykonania, to z dużym prawdopodobieństwem możemy powiedzieć, że błąd ten wystąpił w pierwszej iteracji pętli. Jeśli tak rzeczywiście było, to musimy tylko sprawdzić, czy jesteśmy nad czy pod B na rys. 10.71. Wówczas wiemy, czy wartość a to wartość definiowana w instrukcji s, kiedy to możemy podać wartość a z kodu zoptymalizowanego, czy też wartością a jest wartość z wejścia do pętli w źródłowej wersji programu. W drugim przypadku nie możemy zrobić wiele, chyba że 1) 2) 3)
program uruchomieniowy ma informacje o definicjach osiągających, zarówno dla programu źródłowego, jak i dla programu zoptymalizowanego, jest tylko jedna definicja a, która osiąga wejście w programie źródłowym, ta definicja jest również jedyną definicją jakiejś zmiennej x, która osiąga punkt wywołania programu uruchomieniowego.
Jeśli spełnione są wszystkie te warunki, to możemy podać wartość x i powiedzieć, że jest to wartość a. Czytelnik powinien zdawać sobie sprawę z tego, że takie rozumowanie nie sprawdza się, jeśli program uruchomieniowy został wywołany przez wstawione przez użytkownika przerwanie, bo wtedy nie mamy żadnych podstaw do przypuszczeń, że jesteśmy w pierw szej iteracji pętli. Jeśli jednak w programie umieszczamy wywołania programu urucho mieniowego, to rozsądne może być również wstawienie do zoptymalizowanego programu kodu, który pomoże programowi uruchomieniowemu ustalić, czy jesteśmy w pierwszej iteracji pętli, czyli zastosować rozwiązanie, o którym wspomnieliśmy wcześniej, ale od rzuciliśmy je, gdyż wymagało zmian w kodzie zoptymalizowanym.
ĆWICZENIA 10.1 Rozważmy program mnożący macierze z rys. 10.72.
begin for i := 1 to n do for j := 1 to n do c[i,j] := 0; for i :- 1 to n do for j := 1 to n do for k := 1 to n do c[i,j] := c[i,j] + a[i,k] * b[k,j] end Rys. 10.72. Program mnożący macierze a) Zakładając, że a, b i c mają pamięć przydzieloną statycznie, słowo maszyny składa się z czterech bajtów, a maszyna może adresować pojedyncze bajty, napisz instrukcje trójadresowe odpowiadające programowi z rys. 10.72. b) Wygeneruj kod wynikowy z instrukcji trójadresowych. c) Zbuduj graf przepływu dla instrukcji trójadresowych. d) Usuń wspólne podwyrażenia z każdego bloku bazowego. e) Znajdź pętle w grafie przepływu. f) Przenieś obliczenia niezmiennicze w pętlach na zewnątrz tych pętli. g) Znajdź zmienne indukcyjne we wszystkich pętlach i usuń je, gdy jest to mo żliwe. h) Wygeneruj kod wynikowy z grafu przepływu powstałego w punkcie g). Porów naj ten kod z kodem z punktu b). 10.2 Oblicz definicje osiągające i ud-łańcuchy dla pierwotnego grafu przepływu z ćwi czenia lO.lc) i wynikowego grafu z lO.lg). 10.3 Program z rys. 10.73 oblicza liczby pierwsze od 2 do n, korzystając z metody sita dla dostatecznie dużej tablicy. a) Przetłumacz program z rys. 10.73 na instrukcje trójadresowe, zakładając, że a ma statycznie przydzieloną pamięć. b) Wygeneruj kod wynikowy z instrukcji trójadresowych.
begin read n; for i :- 2 to n do a[i] := true; /* inicjowanie */ count := 0; for i := 2 to n ** .5 do if a[i] then /* i jest liczbą pierwszą */ begin count := count + 1; f o r j := 2 * i t o n by i do a[j] := false /* j jest podzielne przez i */ end; print count end
Rys. 10.73. Program obliczający liczby pierwsze c) Stwórz graf przepływu z instrukcji trójadresowych. d) Narysuj drzewo dominatorów dla grafu przepływu z punktu c). e) Na grafie przepływu z punktu c) wskaż krawędzie powrotne i ich naturalne pętle. f) Przenieś obliczenia niezmiennicze poza pętle, korzystając z algorytmu 10.7. g) Usuń, tam gdzie jest to możliwe, zmienne indukcyjne. h) Wykonaj propagację kopii. i) Czy możliwe jest ściśnięcie pętli (ang. loop jamming)!
Jeśli tak, to zrób to.
j) Przy założeniu, że n zawsze będzie parzyste, wykonaj jednokrotne rozwinięcie pętli wewnętrznych. Jakie nowe optymalizacje są teraz możliwe? 10.4 Powtórz ćwiczenie 10.3, zakładając, że a ma dynamicznie przydzielaną pamięć, z p t r będącym wskaźnikiem pierwszego słowa a. 10.5 Dla grafu przepływu z rys. 10.74 oblicz: a) u d - i du-łańcuchy, b) zmienne żywe na końcu każdego bloku, c) wyrażenia dostępne. 10.6 Czy dla grafu z rys. 10.74 można wykonać zwijanie stałych? Jeśli tak, to j e wy konaj. 10.7 Czy na rysunku 10.74 istnieją jakieś podwyrażenia wspólne? Jeśli tak, to j e wy eliminuj. 10.8 O wyrażeniu e mówimy, że jest bardzo zajęte w punkcie p , jeśli dla każdej ścieżki prowadzącej z /?, wyrażenie e jest wyliczane przed definicją któregokolwiek ze swoich argumentów. Podaj algorytm przepływu danych podobny do tych z p. 10.6, wyszukujący wszystkie bardzo zajęte wyrażenia. Jakiego operatora łączenia użyłeś i czy propagacja przebiega do przodu czy do tyłu? Zastosuj swój algorytm do grafu przepływu z rys. 10.74.
B
A
(6) (7)
a := b*d b := a-d
(10) (11)
a+b e+1
Rys. 10.74. Graf p r z e p ł y w u
*10.9 Jeśli wyrażenie e jest bardzo zajęte w punkcie />, możemy je złapać przez wyli czenie w punkcie p i zapamiętanie jego wartości do późniejszego wykorzystania. (Zauważmy, że optymalizacja ta zazwyczaj nie skraca czasu, ale może zmniejszyć ilość wymaganej pamięci). Podaj algorytm łapania wyrażeń bardzo zajętych. 10.10 Czy na rysunku 10.74 są jakieś wyrażenia, które mogą być złapane? Jeśli tak, to j e złap. 10.11 Tam, gdzie to możliwe, rozpropaguj wszystkie kroki kopiujące wprowadzone przez modyfikacje grafu z ćwiczeń 10.6, 10.7 i 10.10. 10.12 Rozszerzonym blokiem bazowym nazywamy taką sekwencję bloków B ..., B , że dla 1 ^ i < k jedynym poprzednikiem fi- jest fi -, a B nie ma pojedynczego po przednika. Znajdź rozszerzone bloki bazowe kończące się w każdym wierzchołku: v
+1
(
k
{
a) grafu z rys. 10.39, b) grafu przepływu zbudowanego w ćwiczeniu lO.lc), c) grafu z rys. 10.74. * 10.13 Podaj algorytm działający dla grafu o n wierzchołkach w czasie 0(n), wyszukujący rozszerzone bloki bazowe, kończące się w każdym z wierzchołków. 10.14 Możemy wykonywać pewne międzyblokowe optymalizacje bez przeprowadzania jakiejkolwiek analizy przepływu danych, jeśli na każdy z rozszerzonych bloków bazowych będziemy patrzyli, jak na zwykły blok bazowy. Podaj algorytmy wy konujące poniżej wymienione optymalizacje dla rozszerzonych bloków bazowych. W każdym przypadku określ, jaki wpływ na inne rozszerzone bloki bazowe może mieć zmiana w którymś bloku. a) Usuwanie wspólnych podwyrażeń. b) Zwijanie stałych. c) Propagacja kopii.
10.15
Dla grafu przepływu z rys. 10.14(c): a) znajdź sekwencję redukcji T i T , {
2
b) znajdź ciąg grafów przedziałowych, c) co jest grafem granicznym? czy graf przepływu jest redukowalny? 10.16
Powtórz ćwiczenie 10.15 dla grafu przepływu z rys. 10.74.
10.17
Wykaż, że poniższe warunki są równoważne (są różnymi definicjami „redukowalnego grafu przepływu"). a) Granicą T -T x
2
redukcji jest pojedynczy wierzchołek.
b) Granicą w analizie przedziałów jest pojedynczy wierzchołek. c) Krawędzie grafu przepływu należą do dwóch klas; krawędzie jednej z tych klas tworzą graf acykliczny, a końce drugich, nazywanych krawędziami „powrotny mi", dominują nad ich początkami. d) Graf przepływu nie ma podgrafu o postaci takiej, j a k n a rys. 10.75. n jest wierzchołkiem początkowym, n , a, b i c powinny być rozłączne parami z wy jątkiem a i n , tzn. możliwe jest a = n . Strzałki przedstawiają ścieżki rozłączne wierzchołkowo (z wyjątkiem końców, oczywiście). 0
Q
0
Q
Rys. 10.75. Zabroniony podgraf dla redukowalnych grafów przepływu 10.18
Podaj algorytm obliczania (a) wyrażeń dostępnych i (b) zmiennych żywych dla opi sanego w p . 10.8 języka ze wskaźnikami. Upewnij się, że stosujesz konserwatywne założenia do gen, kill,
use i def
w (b).
10.19 Podaj algorytm obliczający definicje osiągające między procedurami, korzystając z modelu z p. 10.8. Ponownie, upewnij się, że stosujesz założenia konserwatywne. 10.20 Przypuśćmy, że parametry przekazywane są przez wartość, a nie przez referen cję. Czy dwie nazwy mogą być synonimami? Co będzie, jeśli użyjemy metody kopiowanie-przy wrócenie? 10.21 Jaka jest głębokość grafu przepływu w ćwiczeniu 10. l c ) ? ** 10.22
Udowodnij, że głębokość redukowalnego grafu przepływu nigdy nie jest mniejsza niż liczba zastosowań analizy przedziałów, konieczna do otrzymania pojedynczego wierzchołka.
* 10.23
Uogólnij oparty na strukturze algorytm analizy przepływu danych z p. 10.8 do ogólnego szkieletu analizy przepływu danych z p . 10.11. Jakie założenia o F i A musisz przyjąć, aby Twój algorytm zadziałał?
* 10.24 Interesujący i silny szkielet analizy przepływu danych otrzymujemy, wyobrażając sobie, że „wartości" propagowane to wszystkie wyrażenia z podziałów wykona nych tak, że dwa wyrażenia są w tej samej klasie tylko wtedy, gdy mają taką samą wartość. Aby uniknąć konieczności pamiętania wszystkich, nieskończenie wielu, wyrażeń, możemy takie wartości reprezentować, wyliczając tylko wyrażenia mi nimalne, które są równoważne pewnym innym wyrażeniom. Przykładowo, jeśli wykonamy instrukcje
A:=B C:=A+D to otrzymamy następujące minimalne równoważności: A = B oraz C = A-f-D. Można stąd wyprowadzić inne równoważności, np. C = B + D czy A -j- E = B + E, ale nie musimy ich jawnie wypisywać. a) Jaki jest właściwy operator spotkania czy łączenia dla tego szkieletu? b) Przedstaw strukturę danych do reprezentowania wartości i algorytm implemen tujący operator spotkania. c) Jakie funkcje należy związać z instrukcjami? Opisz skutki, jakie funkcja zwią zana z przypisaniami o postaci A: =B+C powinna wywołać dla podziałów. d) Czy ten szkielet jest dystrybutywny czy monotoniczny? 10.25 Jak użyłbyś danych zebranych przez szkielet z ćwiczenia 10.24 do wykonania: a) usuwania podwyrażeń wspólnych? b) propagacji kopii? c) zwijania stałych? * 10.26 Podaj formalne dowody poniższych własności relacji ^ w kratach: a) b) c) d)
a ^ b i a ^ c implikuje a a^(bAc) implikuje a ^ a ^b i b ^ c implikuje a a^b i b implikuje a
^ (bAc), b, ^ c, — b.
**10.27 Wykaż, że poniższy warunek jest konieczny i wystarczający do zbieżności iteracyjnego algorytmu przepływu danych z porządkowaniem w kolejności w głąb w 2 plus głębokość przebiegach: dla wszystkich funkcji / i g oraz wartości a f(g(a))^f(a)Ag(a)Aa 10.28 Wykaż, że szkielety do analizy definicji osiągających i wyrażeń dostępnych spełnia ją warunek z ćwiczenia 10.27. Uwaga: te szkielety są zbieżne w 1 plus głębokość przebiegach. ** 10.29 Czy warunek z ćwiczenia 10.27 jest implikowany przez monotoniczność, przez dystrybutywność? A na odwrót? 10.30 Na rysunku 10.76 widzimy dwa bloki bazowe: „początkowy" oraz jego zoptyma lizowaną wersją. a) Zbuduj dagi dla bloków z rys. 10.76(a) i (b). Sprawdź, że przy założeniu, że J jest żywe przy wyjściu, te dwa bloki są równoważne. b) Opisz dag, dodając informacje o czasie, w którym wartości zmiennych są znane w wierzchołkach.
c) Zaznacz, dla błędu występującego w każdej z instrukcji (1') do (4') z rys. 10.76, dla której instrukcji powinniśmy zgłosić błąd. d) Dla każdego z błędów z punktu c) sprawdź, wartości których zmiennych z rys. 10.76 możemy wyliczyć; jak to zrobić? e) Przypuśćmy, że możemy stosować algebraicznie poprawne prawa, takie jak „je śli a + b — c, to a = c — b'\ Czy zmieniłaby się Twoja odpowiedź w punkcie d)?
1) 2) 3) 4) 5) 6)
E F G H I J
= = = = = =
A+A E-C F*D A+B I-C I+G
(a) Początkowy
E := A+B
2') E := E-C 3') F := E*D 4') J := E+F
(b) Zoptymalizowany
Rys. 10.76. Kod początkowy i zoptymalizowany 1 0 3 1 Uogólnij przykład 10.14 tak, aby dopuszczalny był dowolny zbiór instrukcji break. Dopuść również używanie instrukcji continue, które nie wychodzą z pę tli wewnętrznej, lecz przechodzą od razu do jej kolejnej iteracji. Podpowiedz: skorzystaj z technik opracowanych w p. 10.10 dla redukowalnych grafów prze pływu. 10.32 Wykaż, że w algorytmie 10.3 zbiory definicji in i out nigdy nie są zmniejsza ne. Analogicznie, wykaż, że w algorytmie 10.4 te zbiory wyrażeń nigdy nie są zwiększane. 10.33 Uogólnij algorytm 10.9 eliminacji zmiennych indukcyjnych tak, żeby dopuszczalne były ujemne stałe multiplikatywne. 10.34 Uogólnij algorytm, aby wyznaczał, co może wskazywać wskaźnik z p. 10.8 w przy padku, w którym wskaźniki mogą wskazywać inne wskaźniki. * 10.35 Czy podczas szacowania każdego z poniższych zbiorów konserwatywne są zbyt małe czy zbyt duże przybliżenia? Wyjaśnij swoją odpowiedź w kontekście zamie rzonego używania tych informacji dla: a) b) c) d) e)
wyrażeń dostępnych, zmiennych modyfikowanych przez procedurę, zmiennych nie zmienianych przez procedurę, zmiennych indukcyjnych należących do danej rodziny, instrukcji kopiujących osiągających dany punkt.
* 10.36 Popraw algorytm 10.12 tak, aby wyliczał synonimy podanej zmiennej w podanym punkcie. *10.37 Zmodyfikuj algorytm 10.12 tak, by działał, gdy parametry są przekazywane: a) przez wartość, b) przez kopiowanie-przywrócenie. * 10.38 Wykaż, że algorytm 10.13 zbiega do nadzbioru (niekoniecznie właściwego) fak tycznie modyfikowanych zmiennych.
* 10.39 Uogólnij algorytm 10.13 tak, aby wyznaczał modyfikowane zmienne w przypadku, gdy dopuszczalne są zmienne proceduralne. * 10.40 Dowiedź, że w każdym grafie przedziałowym każdy wierzchołek reprezentuje re gion z pierwotnego grafu przepływu. 10.41 Wykaż, że algorytm 10.16 poprawnie oblicza zbiór dominatorów każdego wierz chołka. * 10.42 Zmodyfikuj algorytm 10.17 (definicji osiągających opartych na strukturze) tak, żeby obliczał definicje osiągające tylko dla podanych małych regionów, nie wymagając, żeby cały graf przepływu był jednocześnie przechowywany w pamięci. Upewnij się, że Twoje wyniki są konserwatywne. Przystosuj swój algorytm do obliczania wyrażeń dostępnych. Który z algorytmów będzie dostarczał bardziej użytecznych informacji? * 10.43 W podrozdziale 10.10 zaproponowaliśmy przyspieszenie algorytmu 10.17, stosując łączenie 7} z redukcją T . Wykaż poprawność tej modyfikacji. 10.44 Uogólnij iteracyjną metodę z p. 10.11 na problemy z przepływem do tyłu. **10.45 Udowodnij, że gdy algorytm 10.18 zbiega, to otrzymane rozwiązanie jest ^ rozwią zaniu mop, wykazując, że dla każdej ścieżki P długości i po i iteracjach zachodzi 2
in[B ]śf [T]. t
P
10.46 Na rysunku 10.77 jest graf przepływu dla programu w hipotetycznym języku wpro wadzonym w p. 10.12. Znajdź najlepsze oszacowanie możliwych typów wszystkich zmiennych, korzystając z algorytmu 10.19.
Rys. 10.77. Przykładowy program do wyprowadzania typów
UWAGI
BIBLIOGRAFICZNE
Dodatkowe informacje o optymalizacji kodu można znaleźć u Cocke'a i Schwartza [1970], Abela i Bella [1972], Schaefera.[1973], Hechta [1977] oraz Muchnicka i Jonesa [1981]. Allen [1975] przedstawił bibliografię dotyczącą optymalizacji programów. Wiele kompilatorów optymalizujących opisano w literaturze. Ershov [1966] przed stawił jeden z pierwszych, który używał złożonych technik optymalizacji. Lowry i Medlock [1969] oraz Scarborough i Kolsky [1980] szczegółowo omówili budowę kompi latora optymalizującego Fortranu. Busam i Englund [1969] oraz Metcalf [1982] zade monstrowali dodatkowe techniki optymalizacji programów w Fortranie. Wulf i in. [1975] opisali budowę kompilatora optymalizującego języka Bliss, na którym opierali się autorzy innych kompilatorów. Allen i in. [1980] omówił system zbudowany do eksperymentów z optymalizacją programów. Cocke i Markstein [1980] przedstawili wyniki badań nad efektywnością róż nych optymalizacji dla języka podobnego do PL/I. Anklam, Cutler, Heinen i MacLaren [1982] opisali implementację przekształceń optymalizujących, których używano w kom pilatorach PL/I oraz C. Auslander i Hopkins [1982] przedstawili kompilator dla wariantu PL/I, używający prostego algorytmu do generowania niskopoziomowego kodu pośred niego, który jest następnie poprawiany przez globalne przekształcenia optymalizujące. Freudenberger, Schwartz i Sharir [1983] opisali doświadczenia z optymalizatorem dla języka SETL. Chow [1983] omówił eksperymenty z przenośnym, niezależnym od ma szyny, kompilatorem optymalizującym języka Pascal. Powell opisał przenośny, niezależny od maszyny, optymalizujący kompilator języka Modula-2. Systematyczne badanie technik analizy przepływu danych rozpoczęli Allen [1970] i Cocke [1970], którzy później wspólnie napisali książkę (Allen i Cocke [1976]), chociaż różne były stosowane j u ż wcześniej metody analizy przepływu danych. Sterowana składnią analiza przepływu danych, wprowadzona w p, 10.5, była używa na w kompilatorze Bliss (Wulf i in. [1975], Geschke [1972]), SIMPL (Zelkowitz i Bail [1974]) i Moduli-2 (Powell [1984]). Inne opisy tej rodziny algorytmów podali Hecht i Schaffer [1975], Hecht [1977] oraz Rosen [1977]. Omówione w podrozdziale 10.6 iteracyjne podejście do analizy przepływu da nych pochodzi od Vyssotsky'ego (Vyssotsky i Wegner [1963]), który używał tej me tody w kompilatorze Fortranu z roku 1962. Używanie porządkowania w głąb w celu polepszenia wydajności pochodzi od Hechta i Ullmana [1975]. Analiza przedziałów jako podejście do analizy przepływu danych zapoczątkował Cocke [1970]. Kennedy [1971] korzystał z analizy przedziałów do rozwiązania proble mów z przepływem do tyłu, takich jak analiza zmiennych żywych. Istnieje powód, by przypuszczać, co opisał Kennedy [1976], że metody oparte na analizie przedziałów są nieco bardziej efektywne niż metody iteracyjne, jeśli optymalizowany język generuje ma ło lub nie generuje żadnych nieredukowalnych grafów przepływu. Wariant, którego my użyliśmy, oparty na T i T , pochodzi od Ullmana [1973]. Nieco szybszą wersję, która wykorzystuje fakt, że większość regionów m a pojedyncze wyjście, przedstawili Graham i Wegman [1976]. {
2
Oryginalna definicja redukowalnego grafu przepływu, mówiąca, że jest to graf, który po iterowanej analizie przedziałów staje się jednym wierzchołkiem, pochodzi od Allena [1970]. Równoważne oceny można znaleźć u Hechta i Ullmana [1972, 1974], Kasy-
anova [1973] i Tarjana [1974b]. Dzielenie wierzchołków dla nieredukowalnych grafów przepływu przedstawili Cocke i Miller [1969]. Pomysł modelowania strukturalnego przepływu sterowania za pomocą redukowal nych grafów przepływu przedstawili Kosaraju [1974], Kasami, Peterson i Tokura [1973] oraz Cherniavsky, Henderson i Keohane [1976]. Baker [1977] opisał ich wykorzystanie w algorytmie strukturalizacji programu. Teoriokratowe podejście do iteracyjnej analizy przepływu danych jako pierwszy opisał Kildall [1973]. Tennenbaum [1974] i Wegbreit [1975] przedstawili to analogicznie. Efektywna wersja algorytmu Kildalła, w której wykorzystał uporządkowanie w głąb, pochodzi od Kama i Ullmana [1976]. Chociaż Kildall zakłada warunek dystrybutywności (którego szkielety, takie jak szkielet do obliczania stałych z przykładu 10.42, wcale nie spełniają), adekwatność monotoniczności została wykazana w wielu pracach przedstawiających algorytmy przepływu danych, takich jak Tennenbaum [1974], Schwartz [1975a, b ] , Graham i Wegeman [1976], Jones i Muchnick [1976], Kam i Ullman [1977] oraz Cousot i Cousot [1977]. Różne algorytmy wymagają różnych założeń dotyczących danych, dlatego Kam i Ull man [1977], Rosen [1980] oraz Tarjan [1981] opracowali teorię dotyczącą własności dla wielu algorytmów. Innym kierunkiem, który powstał po opublikowaniu pracy Kildalla, jest poprawianie algorytmów obsługujących konkretne problemy przepływu danych (patrz przykład 10.42) przez niego wprowadzonych. Pewnym ważnym pomysłem jest to, że elementów kraty nie trzeba rozpatrywać jako niepodzielnych, ale że możemy korzystać z faktu, że są one w istocie odwzorowaniami zmiennych w wartości. Przedstawili to Reif i Lewis [1977] WCgman 1 [l?85]t K°U RflWmiąęt badał ten pomysł w Kontekście bardziej klasycznych problemów. Praca Kennedy'ego [1981] to przegląd technik analizy przepływu danych, a Cousot [1981] przedstawił przegląd metod teoriokratowych. Gear [1965] wprowadził podstawowe optymalizacje pętli, tj. przemieszczenie kodu i ograniczoną formę eliminacji zmiennych indukcyjnych. Allen [1969] napisał fundamen talną pracę o optymalizacji pętli, Allen i Cocke [1972] oraz Waite [1976b] zaprezentowali szerszy przegląd technik z tej dziedziny. Morel i Renvoise [1979] opisali algorytm, który jednocześnie usuwa niepotrzebne i niezmiennicze obliczenia z pętli.
0ra£
ZaaSSK
[1?77]
Eliminacja zmiennych indukcyjnych z p. 10.7 oparta jest na pracy L o w r y ' e g o i Medlocka [1969]. Allen, Cocke i Kennedy [1981] przedstawili silniejsze algorytmy. Pewne problemy związane z pętlami, o których tu szczegółowo nie pisaliśmy, takie jak sprawdzanie czy istnieje ścieżka od a do b nie przechodząca przez c, mogą być rozwiązane przez wydajny algorytm Wegmana [1983]. Wykorzystywanie dominatorów zarówno do wykrywania pętli, jak i do wykony wania przemieszczenia kodu, zapoczątkowali Lowry i Medlock [1969], chociaż ogólny pomysł przypisują oni Prosserowi [1959]. Algorytm 10.16, służący do wyszukiwania do minatorów, opracowali niezależnie Purdom i Moore [1972] oraz Aho i Ullman [1973a]. Wykorzystanie porządkowania w głąb do przyspieszenia działania tego algorytmu po chodzi od Hechta i Ullmana [1975], podczas gdy asymptotycznie optymalna metoda rozwiązania tego problemu została przedstawiona przez Tarjana [1974a]. Lengauer i Tar jan [1979] opisali wydajny algorytm wyszukiwania dominatorów, który nadaje się do praktycznego wykorzystania.
Synonimy i analizę przepływu danych dla wielu procedur jako pierwsi badali Spiłlman [1971] i Allen [1974]. Opracowali metody silniejsze niż te z p. 10.8; posłużyli się w nich relacją bycia synonimem w każdym z punktów programu, aby pominąć pewne pary zmiennych, które nasz prosty algorytm „wykrywa" jako synonimy. Metody te opisali także Barth [1978], Banning [1979] i Weihl [1980]. Ryder [1979] przedstawił budowę grafów wywołań. Problem podobny do analizy przepływu danych dla wielu procedur — wpływ wy jątków na analizę przepływu danych — opisał Hennessy [1981]. Autorem najważniejszej pracy o wyznaczaniu typów za pomocą analizy przepły wu danych, na której oparliśmy nasz opis z p. 10.12, jest Tennenbaum [1974]. Kapłan i Ullman [1982], przedstawili silniejszy algorytm wykrywania typów. Opis symbolicznego programu uruchomieniowego dla zoptymalizowanego kodu z p. 10.13 pochodzi od Hennessy'ego [1982]. W wielu pracach szacowano zyski pochodzące z różnych optymalizacji. Wydaje się, że wartość optymalizacji istotnie zależy od kompilowanego języka programowania. Czytelnik może przejrzeć klasyczny opis optymalizacji w Fortranie w pracy Knutha [1971b] lub Gajewskiej [1975], Palmy [1975], Cocke'a i K e n n e d y e g o [1976], Cocke'a i Marksteina [1984], Chowa i Hennessy'ego [1984] oraz Powella [1984]. Innym, nie opisywanym w tej książce, tematem związanym z optymalizacją jest optymalizacja języków „bardzo wysokiego poziomu", takich jak związany z teorią zbio rów SETL, w których faktycznie zmieniamy używane algorytmy i struktury danych. Jedną z głównych optymalizacji stosowanych w takim przypadku jest uogólnione usu wanie zmiennych indukcyjnych, co opisali Earley [1975b], Fong i Ullman [1976], Paige i Schwartz [1977] oraz Fong [1979]. Inną metodą optymalizacji języków bardzo wysokiego poziomu jest wybór struk tur danych; problem ten przedstawili Schwartz [1975a, b ] , Low i Rovner [1976] oraz Schonberg, Schwartz i Sharir [1981]. Nie zajmowaliśmy się również przyrostową optymalizacją kodu, w której małe mo dyfikacje programu nie powodują konieczności optymalizowania programu od początku. Ryder [1983] opisał przyrostową analizę przepływu danych, a Pollock i Soffa [1985] — przyrostową optymalizację bloków bazowych. Na końcu powinniśmy wspomnieć o wielu innych zastosowaniach analizy przepływu danych. Backhouse [1984] wykorzystał ją do obsługi błędów w grafach przejść związa nych z analizatorami składniowymi, a Harrison [1977] oraz Suzuki i Ishihata [1977] — do sprawdzania indeksów tablicy w trakcie kompilacji. Jednym z ważniejszych zastosowań analizy przepływu danych poza optymalizacją kodu jest statyczne sprawdzanie poprawności programu (w trakcie kompilacji). Podstawo wą pracę na ten temat napisali Fosdick i Osterweil [1976], a nowsze wyniki przedstawili Osterweil [1981], Adrion, Bronstad i Cherniavsky [1982] oraz Freudenberger [1984].
ROZDZIAŁ
Chcesz napisać kompilator?
W poprzednich rozdziałach poznaliśmy zasady, techniki i narzędzia używane przy two rzeniu kompilatorów. Załóżmy, że chcemy napisać kompilator. Planując pewne rzeczy, implementacja może przebiegać szybciej i łatwiej. W tym krótkim rozdziale omówi liśmy pewne kwestie implementacji, które pojawiają się podczas budowy kompilatora, a większość rozważań dotyczy pisania kompilatorów w systemie U N I X i przy użyciu jego narzędzi.
11.1
Zaplanowanie kompilatora
Nowy kompilator może być przeznaczony do kompilowania nowego języka, do produkcji nowego kodu wyjściowego albo obu tych rzeczy. Używając metod przedstawionych w tej książce, otrzymujemy projekt kompilatora, który składa się z kilku modułów. Na projekt i implementację tych modułów oddziałuje kilka różnych czynników.
Język źródłowy „Rozmiar" języka oddziałuje na wielkość i liczbę modułów. Chociaż nie m a żadnych dokładnych definicji rozmiaru języka, wiadomo, na przykład, że realizacja kompilato ra języka Ada lub PL/I jest trudniejsza niż kompilatora małego języka, j a k n a przykład Ratfor (preprocesor „racjonalnego" Fortranu, Kernighan [1975]) lub E Q N (język do skła dania wzorów matematycznych). Innym ważnym czynnikiem jest zmienianie języka źródłowego podczas procesu kon strukcji kompilatora. Chociaż specyfikacja języka źródłowego wygląda na niezmienną, niewiele jest języków, które pozostają takie same w czasie istnienia kompilatora. Nawet w pełni rozwinięte języki zmieniają się, aczkolwiek wolno. Obecny Fortran, na przykład, zmienił się znacznie od roku 1957. Pętle, stałe tekstowe i instrukcje warunkowe w For tranie 77 są całkiem inne niż te z pierwotnego języka. Rosler [1984] natomiast opisał ewolucję języka C. Nowy, eksperymentalny język może przejść dramatyczne zmiany również w trakcie implementacji. Jednym ze sposobów tworzenia nowego języka jest rozwijanie kompilatora
dla działającego prototypu języka i stworzenie takiego, który zaspokoi potrzeby właściwej grupy użytkowników. Wiele „małych" języków opracowanych w systemie UNIX, jak AWK i EQN, stworzono w ten właśnie sposób. W związku z powyższym, twórca kompilatora może spodziewać się pewnych zmian w definicji języka źródłowego w czasie istnienia kompilatora. Budowa modularna i uży cie odpowiednich narzędzi pomaga radzić sobie z tymi zmianami. Przykładowo, uży cie generatorów do stworzenia analizatora leksykalnego i parsera umożliwa łatwiejsze wprowadzenie zmian syntaktycznych w definicji języka niż, gdyby analizator leksykalny i parser były zakodowane bezpośrednio. Język wynikowy Budując kompilator, należy uwzględnić również charakter i ograniczenia języka wyniko wego. Te czynniki mają duży wpływ na projekt kompilatora i na wybór strategii użytych do generacji kodu. Jeśli język wynikowy jest nowy, twórca kompilatora powinien się upewnić, że jest on poprawny i że jego sekwencje synchronizacji (ang. timing seąuences) są dobrze zrozumiałe. Nowy komputer lub nowy asembler mogą mieć błędy, które zo staną ujawnione przez pisany kompilator. A błędy w języku wynikowym mogą utrudnić wykrywanie błędów w samym kompilatorze. Jeżeli język jest udany, może być implementowany na wielu różnych maszynach. Jeśli język będzie długo w użyciu, to jego kompilatory będą musiały generować kod dla kilku generacji maszyn docelowych. Ponieważ dalszy rozwój sprzętu jest prawie pewny, kompilatory pisane z myślą o wielu platformach docelowych będą miały przewagę. W związku z tym, ważne jest dobre zaprojektowanie języka pośredniego, który umożliwi umieszczenie elementów zależnych od danej maszyny w niewielkiej liczbie modułów. Kryteria wydajności Wydajność kompilatora można określić, uwzględniając: szybkość kompilacji, jakość ko du, opisy błędów, przenośność, łatwość przeróbek kompilatora. Kompromis między tymi kryteriami nie jest ściśle ustalony i w specyfikacji kompilatora nie musi być uwzględ niony. Przykładowo, czy szybkość kompilacji jest ważniejsza od szybkości kodu wyni kowego? Jak ważne są dobre komunikaty o błędach i usuwanie błędów? Dużą szybkość działania kompilatora można osiągnąć, zmniejszając maksymalnie liczbę modułów i przebiegów kompilacji, generując kod maszynowy nawet już po jednym przebiegu. Oczywiście, stosując takie podejście, otrzymamy kompilator, który nie będzie generował kodu wysokiej jakości i nie będzie można go łatwo poprawiać i przerabiać. Istnieją dwa aspekty przenośności: przenośność języka wynikowego i przenośność samego kompilatora. Kompilator mający pierwszą cechę można łatwo przystosować do tworzenia kodu przeznaczonego dla innej maszyny. Druga cecha oznacza łatwość przero bienia kompilatora, tak aby działał na innej maszynie. Oczywiście kompilator przenośny może nie być tak wydajny, jak kompilator stworzony z myślą o konkretnej maszynie. Spowodowane jest to tym, że w drugim przypadku można robić pewne założenia co do platformy docelowej.
11.2
Metody tworzenia kompilatorów
Istnieje kilka głównych metod, które można dostosować do pisania kompilatora. Najprost szą jest zmiana kodu wynikowego, jaki generuje istniejący kompilator, lub zmiana ma szyny, na której on działa. Jeśli nie istnieje odpowiedni kompilator, można przystosować strukturę jakiegoś znanego kompilatora podobnego języka, po czym zaimplementować odpowiednie składniki ręcznie lub przy użyciu narzędzi do ich generacji. Bardzo rzadko zdarza się, że potrzebna jest całkowicie nowa organizacja kompilatora. Pisanie kompilatora, nieważne, której metody się użyje, jest ćwiczeniem z inżynierii oprogramowania. Umiejętność pisania innych rodzajów oprogramowania (patrz np. Brooks [1975]) może się przydać do poprawienia niezawodności oraz zwiększenia łatwości modyfikacji i poprawiania końcowego produktu. Projekt, który łatwo może być modyfi kowany, umożliwi j e g o ewolucję razem z rozwojem języka. Użycie narzędzi do budowy kompilatora może być bardzo pomocne.
Wciąganie kompilatorów (butstraping) Kompilatory są na tyle skomplikowanymi programami, że lepiej jest pisać j e w języ kach przyjaźniej szych niż język maszynowy, W środowisku programistycznym UNIX-a kompilatory są zwykle pisane w C. Nawet kompilatory języka C są pisane w C. Istotą wciągania kompilatorów (ang. bootstrapping) właśnie jest użycie języka do skompilowa nia siebie samego. Przyjrzyjmy się zastosowaniu tej metody do tworzenia kompilatorów i do przenoszenia ich z jednej maszyny na drugą przez modyfikację ich wyjścia. Podstawy butstrapingu są znane od połowy lat pięćdziesiątych (Strong i inni [1958]). Istnienie techniki wciągania kompilatorów może wywołać pytanie: „jak powstał pierwszy kompilator?", które brzmi podobnie do: „co było wcześniej — jajko, czy ku ra?", ale odpowiedź jest prostsza. Żeby jej udzielić, poznajmy sposób, w jaki Lisp stał się językiem programowania. McCarthy [1981] zauważył, że pod koniec 1958 roku Lisp był używany jako notacja przy pisaniu funkcji. Następnie funkcje te były ręcznie tłuma czone na język maszynowy i uruchamiane. Implementacja interpretera Lispu pojawiła się niespodziewanie. McCartchy chciał wykazać, że Lisp był notacją do opisu funkcji „znacznie zgrabniejszą niż maszyny Turinga lub ogólne definicje rekurencyjne używane w teorii funkcji rekurencyjnych". Napisał on funkcję eval[e, a] w Lispie, która jako ar gument przyjmowała wyrażenie Lispu e. S. R. Russell zauważył, że eval m o ż e działać jako interpreter Lispa, zakodował więc ją ręcznie i w ten sposób stworzył język progra mowania z interpreterem. Jak wspomniano w p. 1.1, interpreter, zamiast tworzenia kodu wynikowego, przeprowadza operacje na kodzie źródłowym. Podczas wciągania, kompilator charakteryzowany jest trzema językami: językiem źródłowym S, który jest kompilowany, językiem wynikowym T, który jest generowa ny i językiem implementacji I, w którym jest napisany. Te trzy języki przedstawiono w postaci diagramu, nazwanego od swojego kształtu T-diagramem (Bratman [1961]). S
T
I
Powyższy diagram będziemy przedstawiać w postaci SjT. Języki S, I i T mogą być całko wicie różne. Przykładowo, kompilator może działać na jednej maszynie i produkować kod dla innej. Taki kompilator jest nazywany kompilatorem skrośnym lub kros-kompilatorem (ang. cross-compiler). Załóżmy, że piszemy kompilator skrośny dla nowego języka L, w języku implemen tacji S, generujący kod dla maszyny N, czyli tworzymy L$N. Jeśli obecny kompilator języka S działa na maszynie M i generuje dla niej kod, to jest opisywany przez S]y$M. Jeśli teraz L § N zostaje skompilowany przez SjyrM, otrzymujemy kompilator L^/rN, z j ę zyka L do języka N i działający na M. Na rysunku 11.1 przedstawiono ten proces jako złożenie T-diagramów poszczególnych kompilatorów.
L S
N
L
S
M
N
M
M Rys. 11.1. Kompilacja kompilatora Podczas takiego składania T-diagramów, należy zwrócić uwagę na to, że język im plementacji S kompilatora L § N musi być taki sam, jak język źródłowy kompilatora Sty[M, oraz że język wynikowy drugiego z tych kompilatorów musi być taki sam, jak język implementacji nowego kompilatora hy\H. Trójkę T-diagramów z rys. 11.1 można przedstawić jako równanie L$N + SMM =
LMN
P r z y k ł a d 11.1. Pierwsza wersja kompilatora języka E Q N (patrz p . 12.1) była imple mentowana w C i generowała polecenia dla formatera tekstu TROFF. Z poniższego dia gramu wynika, jak otrzymano kompilator języka E Q N działający na PDP-11 po prze puszczeniu przez kompilator C Cnil działający na PDP-11.
EQNcTROFF EQN
c
TROFF
EQN
c
11 11
TROFF 11
•
Jedna z form wciągania polega na stopniowym tworzeniu kompilatorów dla coraz większych podzbiorów nowego języka. Załóżmy, że nowy język L należy zaimplemen tować na maszynę M. Najpierw możemy napisać mały kompilator tłumaczący S, pewien podzbiór języka L, na kod maszyny M, w skrócie kompilator S ^ M . Następnie S można użyć do napisania kompilatora L § M dla całego języka L. Kiedy L $ M zostanie prze puszczony przez Sjy^M, otrzymamy implementację L, czyli L^jM. Jednym z pierwszych języków, który został zaimplementowany w ten sposób, był Neliac (Huskey, Halstead i McArthur [19r30]).
Według Wirtha [1971] Pascal był pierwszym językiem, którego kompilator napisano właśnie w Pascalu. Kompilator ten był „ręcznie", bez żadnych optymalizacji, przetłuma czony na jeden z niskopoziomowych języków i rozumiał tylko podzbiór ( > 60 procent) Pascala. Po kilku krokach wciągania otrzymano kompilator dla całego Pascala. Lecarme i Peyrolle-Thomas [1978] opisali metody, których używano do pisania kompilatorów Pascala przy użyciu wciągania. Zalety wciągania są widoczne, gdy kompilator jest napisany w tym języku, który kompiluje. Załóżmy, że piszemy kompilator L L N dla języka L w L generującym kod na N . Proces budowy przebiega na maszynie M, na której istnieje kompilator LjyjM dla L, generujący kod dla M. Przez kompilację Lj^N przy użyciu Ly[M otrzymamy kompilator skrośny Ljy[N działający na M, ale produkujący kod dla N
L L
N
L
L
M
N M
M Teraz Lj^N można skompilować drugi raz, tym razem przy użyciu ostatnio wygenerowa nego kompilatora skrośnego L L
N
L
L
N
N N
M Rezultatem drugiego kroku jest kompilator L ^ N , działający na N i generujący kod dla N. Istnieje wiele użytecznych zastosowań tego dwuetapowego procesu, który graficznie przedstawiono na rys. 11.2. Przykład 11.2. Przykład ten jest opisem ogólnej metody zastosowanej podczas pisania kompilatora Fortranu H (patrz p. 12.4). „Ten kompilator napisano w tym samym Fortranie i wciąganie zastosowano trzy razy. Po raz pierwszy użyto go do przekształcenia z działa jącego na I B M 7094 do działającego na Systemie/360 — było to bardzo żmudne. Drugi raz był optymalizacją siebie samego, dzięki czemu zredukowano rozmiar kompilatora z około 550 KB do około 400 K B " (Lowry i Medlock [1969]).
L L
N
L
L
L
N
L
L
N
M
N N
M
M
Rys. 11.2. Wciąganie kompilatora
Przy użyciu techniki wciągania, kompilator z optymalizacją może zoptymalizować siebie samego. Załóżmy, że rozwój odbywał się na maszynie M . Możemy posiadać S g M , kompilator S z dobrą optymalizacją napisany w S, a potrzebujemy S j ^ M , optymalizujący kompilator S napisany w M. Możemy utworzyć S ^ M t — szybki i uproszczony kompilator S działający na M — który nie tylko generuje słaby kod, ale również robi to długo. (M:f oznacza słabą implementację M. S ^ j ^ M t jest słabą implementacją kompilatora, która generuje słaby kod). Kompilatora SjyijM$ można użyć, aby w dwóch krokach otrzymać dobry kom pilator S.
S s
M S
S
M
S
S
M M
M
Mt Mt
S Mt
Najpierw kompilator z optymalizacją S $ M jest przekształcany przez uproszczony kom pilator w nowy S M | M — słabą implementację kompilatora optymalizującego — który generuje wydajny kod. Następnie dobry optymalizujący kompilator S M M jest otrzymy wany przez przekompilowanie S $ M za pomocą S j ^ M . •
P r z y k ł a d 11.3. A m m a n n [ 1 9 8 1 ] opisał, jak otrzymano czystą implementację Pascala w procesie podobnym jak w przykładzie 1 1 . 2 . Poprawki do Pascala doprowadziły do powstania nowego kompilatora dla maszyn z serii CDC 6 0 0 0 . Na poniższym diagramie O oznacza „starego" Pascala, a P język z poprawkami.
P P
6000 0
P
6000
P
P
6000
6000 6000
6000J 6000^
O 6000
Kompilator poprawionego Pascala napisano w starym Pascalu i przetłumaczono na PóOOO^OOO. Tak jak w przykładzie 1 1 . 2 , symbol $ oznacza brak wydajności. Stary kompilator nie generował dostatecznie wydajnego kodu. „Dlatego kompilator [PftOOO^óOOO] j } dość przeciętną szybkość i całkiem duże wymagania pa mięciowe" (Ammann [ 1 9 8 1 ] ) . Poprawki do Pascala były na tyle małe, że kom pilator P Q 6 0 0 0 można było z niewielkim wysiłkiem przekształcić ręcznie w PpóOOO i potem przepuścić przez mało wydajny kompilator PóOOO^OOO w celu otrzymania czy stej implementacji. • m
a
11.3
Środowisko budowy kompilatora
Kompilator jest po prostu programem. Środowisko, w którym jest tworzony, wpływa na to, j a k szybko i wiarygodnie jest implementowany. Równie ważny jest język, w któ rym jest pisany. Chociaż kompilatory były kiedyś pisane w takich językach j a k Fortran, lepszym językiem dla większości z nich jest język ukierunkowany systemowo, jak na przykład C. Jeśli sam język jest nowym językiem ukierunkowanym systemowo, to rozsądnie jest napisać kompilator w języku, który sam kompiluje. Kompilowanie kompilatora przy użyciu technik wciągania omówionych w poprzednim podrozdziale pomaga w usuwaniu błędów z niego samego. Narzędzia w środowiskach programistycznych mogą bardzo ułatwić tworzenie wy dajnego kompilatora. Pisząc kompilator, cały program często dzieli się na moduły, z któ rych każdy może być przetwarzany w różny sposób. Program zarządzający przetwarza niem tych modułów stanowi nieodzowną pomoc dla twórcy kompilatora. System U N I X zawiera program make (Feldman [1979a]), który zarządza i utrzymuje spójność modułów składających się na program komputerowy. Make utrzymuje i śledzi zależności między modułami programu, wykonując tylko polecenia niezbędne do zachowania spójności pro gramu p o dokonaniu w nim zmian.
P r z y k ł a d 11.4. Polecenie make wczytuje specyfikację zawierającą informację o tym, jakie zadania trzeba wykonać, z pliku nazywanego m a k e f i l e . W podrozdziale 2.9 skonstruowaliśmy translator, kompilując siedem plików kompilatorem C, z których każdy był zależny od wspólnego pliku nagłówkowego g l o b a l . h . Zademonstrujemy teraz, jak zadanie budowy kompilatora może być przeprowadzone przez program make. Plik wynikowy nazwijmy t r a n s . Plik m a k e f i l e może, na przykład, wyglądać następująco: OBJS = l e k s e r . o p a r s e r . o e m i t e r . o i n i t . o blecly.o main. o trans:
$(OBJS) c c $(OBJS) -o
tabsym.o\
trans
lekser.o p a r s e r . o emiter.o tabsym.o\ i n i t . o b l e c l y . o m a i n . o: g l o b a l . h Znak równości w pierwszym wierszu przypisuje OBJS siedem plików wyjściowych z pra wej strony. (Długie linie mogą być złamane po umieszczeniu n a końcu znaku \ ) . Dwu kropek w drugim wierszu oznacza, że plik t r a n s p o lewej stronie zależy od wszystkich plików z OBJS. Z a takim wierszem zależności może znajdować się polecenie tworzące plik znajdujący się p o lewej stronie dwukropka. Trzeci wiersz mówi o tym, że pro gram t r a n s jest tworzony wskutek konsolidacji plików l e k s e r . o, p a r s e r . o, m a i n . o. Jednak make wie, że najpierw musi stworzyć pliki z rozszerzeniem . o. Robi to automatycznie, poszukując odpowiednich plików źródłowych l e k s e r . c , p a r s e r . c , . . . , m a i n . c i kompilując każdy plik kompilatorem C. Ostatni wiersz pliku m a k e f i l e
oznacza, że wszystkie siedem plików wynikowych zależy od wspólnego pliku nagłówko wego global. h. Translator jest tworzony po napisaniu polecenia make, które spowoduje wykonanie następujących poleceń:
cc cc cc cc cc cc cc cc
-c lekser.c -c parser.c -c emiter.c -c tabsym.c -c init.c -c biedy.c -c main.c lekser.o parser.o emiter.o tabsym.o\ init.o biedy.o main.o -o trans
Kompilację przeprowadza się ponownie tylko wtedy, gdy od czasu ostatniej kompilacji zmienił się zależny plik źródłowy. Kernighan i Pike [1984] przedstawili przykłady użycia make, ułatwiające konstrukcję kompilatora. • Równie użytecznym narzędziem do pisania kompilatorów jest program profilujący. Od chwili napisania kompilatora, programu profilującego można użyć do znalezienia tego miejsca w programie, w którym spędza on najwięcej czasu w trakcie kompilacji programu źródłowego. Identyfikacja i modyfikacja najbardziej aktywnych miejsc może przyspieszyć kompilator dwa do trzech razy. Oprócz ogólnych narzędzi ułatwiających pisanie programów, istnieje wiele narzędzi stworzonych specjalnie dla kompilatorów. W podrozdziale 3.5 opisaliśmy generator Lex, którego można użyć do automatycznego tworzenia analizatorów leksykalnych ze specyfi kacji w postaci wyrażeń regularnych. W podrozdziale 4.9 przedstawiliśmy program Yacc używany do tworzenia parsera L R z opisu gramatyki danego języka. Omówione powy żej polecenie make automatycznie wywołuje, w razie potrzeby, programy Lex i Yacc. Oprócz generatorów analizatorów leksykalnych i składniowych, w celu ułatwienia bu dowy kompilatorów, stworzono generatory gramatyk atrybutywnych i generatorów kodu. Wiele z tych narzędzi ma przydatną własność wychwytywania błędów w specyfikacji kompilatora. Odbyło się wiele dyskusji n a temat wydajności i wygody generatorów programów w konstruowaniu kompilatorów (Waite i Carter [1985]). Zaobserwowano, że dobrze za implementowane generatory programów stanowią znaczną pomoc w produkcji nieza wodnych modułów, z których składają się kompilatory. Znacznie prościej jest stworzyć poprawny analizator składniowy, używając opisu gramatyki języka i generatora analiza torów składniowych, niż implementując go bezpośrednio ręcznie. Ważną kwestią jest to, jak generatory współpracują ze sobą i z innymi programami. Popularnym błędem w pro jektowaniu generatora jest założenie, że jest on najważniejszą częścią projektu. Lepszym rozwiązaniem jest generator produkujący podprogramy z wyraźnymi interfejsami, które mogą być wywoływane przez inne programy (Johnson i Lesk [1978]).
11.4
Testy i pielęgnowanie kompilatorów
Kompilator musi generować poprawny kod. Zakładamy, że komputer będzie mechanicznie weryfikował, czy kompilator w pełni implementuje swoją specyfikację. Kwestie popraw ności różnych algorytmów kompilacji są omówione w wielu artykułach. Kompilatory, niestety, rzadko są specyfikowane w ten sposób, aby dowolna implementacja mogła być mechanicznie zweryfikowana w stosunku do jej poprawnej specyfikacji. Ponieważ kom pilatory zwykle są skomplikowanymi funkcjami, istnieje dodatkowo kwestia weryfikacji, czy sama specyfikacja jest poprawna. W praktyce, musimy stosować pewne systematyczne metody testowania kompila tora, tak, aby mieć coraz większą pewność, że będzie on później pracował poprawnie. Jednym z podejść, z powodzeniem używanym w wielu kompilatorach, jest tzw. test „re gresji". Utrzymujemy zestaw programów testowych i gdy kompilator jest modyfikowany, programy te są kompilowane przy użyciu nowej i starej wersji kompilatora. Informacje o wszystkich różnicach w programach wynikowych są zgłaszane twórcy kompilatora. Polecenie make z systemu UNIX może być wykorzystane do automatycznego przepro wadzenia takich testów. Wybór programów, które powinny być włączone do zestawu testowego, jest trud ny — programy testowe muszą przebadać każdą instrukcję w kompilatorze co najmniej jeden raz. Znalezienie odpowiedniego zestawu testowego wymaga wiele sprytu. Kom pletne zestawy testów stworzono dla kilku języków (np. Fortran, 1 Ę X , C ) . Wielu autorów kompilatorów dodaje do testów regresji programy, które ujawniły błędy w poprzednich wersjach kompilatora. Bardzo nieprzyjemne jest ponowne pojawianie się starych błędów po dodaniu nowych poprawek. Równie ważne są testy wydajności. Niektórzy autorzy kompilatorów, jako element testu regresji, przeprowadzają pomiary czasu, aby sprawdzić, czy nowe wersje kompila tora generują kod, który jest w przybliżeniu tak dobry jak w wersjach poprzednich. Pielęgnowanie kompilatora jest kolejnym ważnym problemem, szczególnie, jeśli kompilator działa w różnych środowiskach lub ludzie pracujący nad kompilatorem często się zmieniają. Decydującą kwestią w pielęgnowaniu kompilatora jest dobry styl progra mowania i dobra dokumentacja. Autorzy znają jeden kompilator, który napisano przy użyciu tylko siedmiu komentarzy, a jeden z nich brzmiał: „Ten kod jest przeklęty". Nie trzeba mówić, że taki program trudno jest pielęgnować komukolwiek, może z wyjątkiem jego autora. Knuth [1984b] stworzył system, zwany W E B , który rozwiązuje problem dokumen towania ogromnych programów napisanych w Pascalu. WEB ułatwia programowanie, w którym dokumentacja jest rozwijana razem z kodem, a nie później. Wiele pomysłów z systemu W E B można z powodzeniem zastosować w innych językach.
ROZDZIAŁ
Kilka kompilatorów
W tym rozdziale opisaliśmy strukturę pewnych działających kompilatorów dla języka for matowania tekstów, Pascala, C, Fortranu, Blissa oraz Moduli-2. Naszym zamiarem nie jest wskazywanie użytych w nich rozwiązań jako jedynych możliwych, tylko przedstawienie różnych podejść do implementacji kompilatora. Wybraliśmy kompilatory Pascala, ponieważ ich konstrukcja miała wpływ na pro jekt języka, kompilatory C — ponieważ C jest podstawowym językiem programowania w systemie UNIX, kompilator Fortran H — ponieważ wpłynął na rozwój technik opty malizacji, a Bliss-11 — aby pokazać kompilator, którego celem jest optymalizowanie wielkości kodu. Omówiliśmy kompilator D E C Modula-2, gdyż jest stosunkowo prosty, produkuje dobry kod i został napisany przez jedną osobę w ciągu kilku miesięcy.
12.1
EQN — preprocesor do składania wzorów matematycznych
Zbiór możliwych tekstów wejściowych dla pewnych programów można rozpatrywać jako niewielki język. Strukturę takiego zbioru można opisać przy użyciu gramatyki, a za pomocą translacji sterowanej składnią można dokładnie określić, co program ma robić. D o pisania takich programów można używać technik stosowanych do budowy kompilatorów. Jednym z pierwszych kompilatorów dla niewielkich języków z systemu U N I X był E Q N stworzony przez Kernighana i Cherry'ego [1975]. Jak już wspomnieliśmy w p. 1.2, E Q N czyta teksty, takie jak E sub 1, i generuje instrukcje dla programu TROFF, ge nerujące napis o postaci E . {
Szkic budowy programu E Q N przedstawiono na rys. 12.1. Przetwarzanie makroin strukcji (patrz p. 1.4) i analiza leksykalna są wykonywane jednocześnie. Strumień leksemów po analizie leksykalnej jest tłumaczony podczas analizy składniowej na instrukcje dla programu formatującego. Analizator składniowy jest napisany przy użyciu generatora parserów Yacc, opisanego w p. 4.9.
Tekst wejściowy
J_
.
.
Makroprocesor Analizator leksykalny Strumień leksemów j
_
Translator sterowany składnią wygenerowany przez Yacca
T Instrukcje dla programu formatującego TROFF
Rys. 12.1. Implementacja EQN Traktowanie wejścia programu E Q N jako języka i zastosowanie technik budowy kompilatorów do napisania programu tłumaczącego ma kilka zalet. 1. 2.
Łatwość implementacji. „Stworzenie działającego systemu wystarczającego do teks tów na znaczących przykładach wymagało około osobomiesiąca". Rozwój języka. Translacja sterowana składnią umożliwia łatwe zmiany języka wej ściowego. Od czasu napisania pierwszej wersji, E Q N ewoluował, odpowiadając na potrzeby użytkowników.
Podsumowujemy ten opis obserwacją: „definiowanie języka wejściowego i pisanie kom pilatora przy użyciu kompilatora kompilatorów wydaje się być jedynym rozsądnym po dejściem".
12.2
Kompilatory Pascala
Projekt języka Pascal i rozwój jego pierwszego kompilatora „były od siebie zależne", powiedział Wirth [1971]. Wobec tego, pouczające jest poznanie struktury kompilatora języka napisanego przez Wirtha i jego kolegów. Pierwszy (Wirth [1971]) i drugi kompi lator (Ammann [1981, 1977]) generował kod wewnętrzny maszyn CDC 6000. Ekspery menty z przenoszeniem drugiego kompilatora doprowadziły do stworzenia kompilatora Pascal-P, który generował kod, zwany P-kodem, dla abstrakcyjnej maszyny stosowej (Nori i in. [1981]). Każdy z powyższych kompilatorów jest jednoprzebiegowy, używa analizatora skła dniowego napisanego metodą zejść rekurencyjnych, tak jak przód kompilatora z rozdz. 2. Wirth [1971] wspomina, że „stosunkowo łatwo było przystosować język [do ograniczeń narzuconych przez konstrukcję analizatora leksykalnego]". Budowę kompilatora Pascal-P pokazano na rys. 12.2. Główne operacje maszyny stosowej używanej przez kompilator Pascal-P odzwier ciedlają potrzeby Pascala. Pamięć maszyny jest podzielona na cztery części: 1) 2)
kod procedur, stałe,
Kod źródłowy i Analizator leksykalny zaznacza błędy w kopii kodu źródłowego
i Strumień leksemów
ł
Translator przewidujący sprawdzenie zgodności typów
P-kod Rys. 12.2. Kompilator Pascal-P
3)
stos dla rekordów aktywacji (wywołania),
4)
sterta dla danych przydzielanych przy użyciu operatora n e w .
1
Ponieważ procedury w Pascalu mogą być zagnieżdżone, rekord aktywacji dla procedur za wiera zarówno wiązanie sterowania, jak i dostępu. Wywołanie procedury jest tłumaczone na instrukcję „zaznacz na stosie" maszyny abstrakcyjnej, z wiązaniami sterowania i do stępu jako argumentami. Kod procedury odwołuje się do pamięci przydzielonej zmiennej lokalnej, używając przesunięcia względem końca rekordu aktywacji. Do pamięci dla po zostałych zmiennych odwołuje się przez parę, składającą się z liczby wiązań dostępu, które należy przejść, i przesunięcia, jak w p. 7.4. Pierwszy z kompilatorów używa tablic display do efektywnego dostępu do zmiennych nielokalnych. Ammann [1981] wyciągnął następujące wnioski z doświadczeń zdobytych przy pi saniu drugiego kompilatora: z jednej strony kompilator jednoprzebiegowy jest prosty w implementacji i generuje umiarkowaną liczbę odwołań do procedur wejścia/wyjścia (kod procedury jest kompilowany w pamięci i zapisywany w całości do pamięci pomoc niczej), z drugiej zaś „poważnie ogranicza jakość generowanego kodu i cierpi z powodu stosunkowo dużych wymagań dotyczących pamięci".
12.3
Kompilatory C
Język C jest językiem ogólnego przeznaczenia zaprojektowanym przez D. M. Ritchiego i jest używany jako główny język programowania w systemie operacyjnym UNIX (Ritchie i Thompson [1974]). Sam UNIX jest napisany w C i został przeniesiony na wiele maszyn, począwszy od komputerów osobistych, a skończywszy na dużych komputerach (ang. mainframeś). W tym podrozdziale krótko opisaliśmy strukturę kompilatora dla komputera PDP-11 napisanego przez Ritchiego [1979] i PCC — rodzinę przenośnych kompilatorów C stworzoną przez Johnsona [1979]. Trzy czwarte kodu P C C nie zależy od maszyny, dla której jest generowany program. Wszystkie opisywane kompilatory są
Wciąganie jest możliwe dzięki temu, że kompilator, napisany w podzbiorze języka, używa sterty jako stosu, co początkowo pozwala na użycie prostego modułu zarządzającego stertą.
dwuprzebiegowe; kompilator dla PDP-11 ma opcjonalny trzeci przebieg, w trakcie któ rego optymalizuje uprzednio wygenerowany kod w asemblerze, jak widać na rys. 12.3. Ta faza optymalizacji lokalnej eliminuje nadmiarowe lub nie używane rozkazy.
.
Kod źródłowy
_±_
.
Analiza leksykalna i składniowa Generowanie kodu pośredniego Postfiksowy lub prefiksowy typ dla wyrażeń W innym przypadku — kod asemblera
Ł Generacja kodu Asembler
_E
Optymalizacja końcowa
T Asembler Rys. 12.3. Przebiegi kompilatora C W pierwszym przebiegu kompilator wykonuje analizę leksykalną, analizę składnio wą i generację kodu pośredniego. Kompilator PDP-11 używa metody zejść rekurencyj nych do analizy składniowej tekstu, z wyjątkiem wyrażeń, do analizy których używa się metody pierwszeństwa operatorów. Kod pośredni składa się z postfiksowo zapisanych wyrażeń oraz kodu w asemblerze dla instrukcji sterowania przepływem. PCC używa anali zatora L A L R ( l ) wygenerowanego przez Yacca. Jego kod pośredni składa się z prefiksowo zapisanych wyrażeń i kodu w asemblerze dla innych konstrukcji języka. W obu przypad kach alokacja pamięci dla nazw lokalnych odbywa się podczas pierwszego przebiegu, dzięki czemu nazw tych można używać jako przesunięć względem rekordu aktywacji. W tyle kompilatora wyrażenia są reprezentowane przez drzewa składni. W kompi latorze PDP-11 kod jest generowany podczas przechodzenia drzewa, przy użyciu metody podobnej do algorytmu etykietowania z p . 9.10. Algorytm ten jest tak zmodyfikowany, aby zapewnić dostępność par rejestrów dla operacji, które ich potrzebują, i aby korzystać z faktu, że niektóre argumenty są stałymi. Johnson [1978] opisał wpływ teorii na budowę kompilatora PCC. W P C C i PCC2 — kolejnej wersji kompilatora — kod dla wyrażeń jest generowany przez przepisywanie drzew. Generator kodu w PCC przegląda program źródłowy po jednej instrukcji, powta rzając wyszukiwanie maksymalnych poddrzew, które mogą zostać wyliczone bez użycia dodatkowej pamięci, używając dostępnych rejestrów. Etykiety wyliczone jak w p. 9.10 identyfikują podwyrażenia, które będą wyliczone i pomocniczo zapamiętane w pamięci. Kod wyliczający i zapamiętujący te wartości jest generowany przez kompilator po wy braniu poddrzewa. Przepisywanie jest bardziej widoczne w PCC2, gdzie generacja kodu jest oparta na algorytmie programowania dynamicznego z p. 9.11.
Johnson i Ritchie [1981] opisali wpływ architektury maszyny docelowej, dla któ rej jest generowany kod, na strukturę rekordów aktywacji i sposób wywoływania/ /wracania z procedur. Standardowa funkcja p r i n t f może przyjmować zmienną licz bę parametrów, więc projektowanie metody wywoływania funkcji jest na niektó rych maszynach zdominowane przez potrzebę przekazywania listy argumentów o zmien nej długości.
12.4
Kompilatory Fortran H
Pierwszy kompilator Fortran H napisany przez Lowry'ego i Medlocka [1969] był dużym i dobrze optymalizującym kompilatorem zbudowanym przy użyciu technik wcześniej szych niż opisane w tej książce. Podejmowano kilka prób polepszenia wydajności tego kompilatora; wersję „rozszerzoną" (ang. e x t e n d e d ) napisano dla IBM/370, a „posze rzoną" (ang. e n h a n c e d ) napisali Scarborough i Kolsky [1980]. Fortran H pozwala nie używać optymalizacji, optymalizować użycie rejestrów lub przeprowadzać pełną opty malizację. Szkic kompilatora w przypadku używania pełnej optymalizacji przedstawiono na rys. 12.4. Tekst źródłowy jest przekształcany przez cztery przebiegi. Dwa pierwsze wykonują analizę leksykalną i składniową, produkując czwórki. W kolejnym przebiegu kod jest
Kod źródłowy
i Analiza leksykalna z obsługą
COMNMON i EQUIVALENCE Pary operator-argument
i Analiza składniowa Analiza przepływu danych Przypisanie adresów do nazw
i Czwórki
i
Optymalizacja kodu Optymalizacja przydziału rejestrów Optymalizacja skoków Czwórki z przypisaniami rejestrów
ł Generacja kodu
ł Relokowalny kod wynikowy Rys.
12.4. Szkic kompilatora Fortran H
optymalizowany i są przydzielane rejestry, a ostatni przebieg generuje kod wynikowy z czwórek i informacji o przypisaniu rejestrów. Analiza leksykalna jest trochę nietypowa, ponieważ jej wynikiem nie jest strumień leksemów, tylko strumień „par operator-argument", mniej więcej odpowiadających leksemowi argumentu wraz z poprzedzającym go leksemem nie będącym argumentem. Należy zauważyć, że w Fortranie, podobnie jak w większości języków, nigdy obok siebie nie ma dwóch leksemów odpowiadających argumentom, takim jak identyfikatory czy stałe; między dwoma takimi leksemami zawsze jest co najmniej jeden leksem innego typu. Instrukcja przypisania, na przykład
A - B(I) + C zostanie przetłumaczona na następujące pary: „instrukcja przypisania"
A
(S
B I
)
+
C
W trakcie analizy leksykalnej rozróżniany jest lewy nawias, który służy do wprowa dzenia listy parametrów lub indeksów, od nawiasu służącego grupowaniu argumentów; (s oznacza lewy nawias użyty jako operator indeksowania. Po prawym nawiasie nigdy nie występuje argument i w związku z tym prawe nawiasy nie są rozróżniane. Z analizą leksykalną związana jest obsługa instrukcji C O M M O N i EQUIVALENCE. Już w trakcie analizy jest możliwe przydzielenie bloku pamięci dla każdego C O M M O N i bloków związanych z procedurami oraz wyznaczenie adresu każdej zmiennej z tych bloków używanej przez program. Ponieważ Fortran nie ma strukturalnych instrukcji sterowania przepływem, takich jak instrukcja while, to analiza składniowa, z wyjątkiem analizy wyrażeń, jest łatwa. Do analizy wyrażeń używa się metody pierwszeństwa operatorów. Pewne bardzo proste opty malizacje lokalne są wykonywane podczas generowania czwórek. Przykładowo, operacje mnożenia przez potęgi dwójki są zastępowane przesunięciami w lewo.
Optymalizacja kodu w Fortanie H Każdy podprogram dzielony jest na bloki bazowe, a pętle są wykrywane przez wyszu kanie krawędzi grafu, których początki dominują nad końcami, jak opisano w p. 10.4. Kompilator wykonuje następujące optymalizacje. 1.
Usuwanie podwyrażeń wspólnych. Kompilator szuka lokalnych podwyrażeń wspól nych oraz wyrażeń, które są wspólne dla bloku B i jednego lub więcej bloków dominowanych przez B. Inne wystąpienia podwyrażeń wspólnych nie są wykry wane. Ponadto, wykrywanie podwyrażeń wspólnych jest wykonywane przy użyciu kolejnych wyrażeń, a nie metody tablic bitów opisanej w p. 10.6. Podczas opraco wywania wersji „poszerzonej" autorzy zauważyli, że użycie metody tablic bitowych pozwala na zwiększenie szybkości działania kompilatora.
2. 3. 4.
Przemieszczenie kodu. Wyrażenia, których wartość nie zmienia się w kolejnych iteracjach pętli, są z niej usuwane, tak jak opisano w p. 10.7. Propagacja kopii Wykonywane po jednej instrukcji kopiowania naraz. Usuwanie zmiennych indukcyjnych. Ta optymalizacja jest wykonywana tylko dla zmiennych, których wartość jest zmieniana tylko raz w iteracji pętli. Zamiast uży wać podejścia „rodziny", opisanego w p. 10.7, kod jest oglądany wiele razy w celu wykrycia zmiennych indukcyjnych należących do rodziny innej zmiennej indukcyj nej.
Mimo że analiza przepływu danych jest wykonywana metodą „jedna-na-raz", warto ści odpowiadające in i out są zapisywane jako tablice bitów. W pierwszym kompilatorze taka tablica mogła mieć tylko 127 pozycji, więc w dużych programach tylko najczęściej używane zmienne brały udział w optymalizacji. W kolejnych wersjach tablica mogła być większa, ale wciąż jej wielkość była ograniczona.
Optymalizacje algebraiczne Ponieważ Fortran jest często używany do obliczeń matematycznych, optymalizacje al gebraiczne są niebezpieczne, bo przekształcenie wyrażenia może, w arytmetyce kom putera, wprowadzić nadmiary lub utratę dokładności nie występującą przy obliczeniach w „normalnej" arytmetyce. Jednak przekształcenia algebraiczne wykonywane na liczbach całkowitych są zazwyczaj bezpieczne i poszerzona wersja kompilatora wykonuje pewne optymalizacje przy odwołaniach do tablic. Na ogół, odwołanie do tablicy, takie jak A (I, J, K ) , powoduje konieczność wy liczenia przesunięcia, do czego konieczne jest obliczenie wartości wyrażenia o postaci al + bJ' + cK -f d, gdzie wartości stałych zależą od położenia A i rozmiarów tablicy. Je żeli, na przykład, I oraz K są stałymi bądź nie są zmieniane w danej pętli, kompilator stosuje prawo przemienności i łączności, otrzymując wyrażenie o postaci bJ 4- e, gdzie e = al + cK + d.
Przydział rejestrów Fortran H dzieli rejestry na trzy rodzaje. Te zestawy rejestrów są używane w optymalizacji rejestrów lokalnych, optymalizacji rejestrów globalnych i optymalizacji skoków. Liczba rejestrów w każdej z klas może być w pewnych granicach zmieniana przez kompilator. Rejestry globalne są przydzielane w każdej z pętli do zmiennych najczęściej uży wanych w danej pętli. Zmienna, której przydzielony jest rejestr w pętli L, ale nie w pętli zawierającej L, jest ładowana do rejestru przed wejściem do L i zapisywana przy wyjściu. Rejestry lokalne są przydzielane w ramach bloków bazowych, aby zapamiętać wynik pewnej instrukcji do użycia przez późniejsze instrukcje. Wartość tymczasowa jest zapi sywana w pamięci tylko w przypadku, gdy nie ma już rejestrów lokalnych. Kompilator stara się obliczać nowe wartości w rejestrach zawierających jeden z argumentów, jeżeli ten nie jest już później używany. W rozszerzonej wersji kompilator próbuje rozpozna wać sytuacje, w których rejestr globalny może zostać zamieniony z innym rejestrem, aby zwiększyć liczbę przypadków, w których operacja może zapisać swój wynik w rejestrze zawierającym jeden z argumentów.
Optymalizacja skoków jest pozostałością wynikającą ze zbioru instrukcji maszyny IBM/370, gdzie skoki są wykonywane szybko, jeżeli są do miejsca, którego adres wy licza się z zawartości rejestru i stałej z zakresu 0...4095. W związku z tym Fortran H przeznacza niektóre rejestry do pamiętania adresów z przestrzeni kodu, w odstępach 4096 bajtów, aby pozwolić na efektywne skoki we wszystkich, oprócz bardzo dużych, programach.
12.5
Kompilator Bliss-11
Kompilator ten implementuje język Bliss na maszynach PDP-11 (Wulf i in. [1975]). W pewnym sensie jest to kompilator optymalizujący z epoki, która przestała istnieć, w której pamięć była tak droga, że opłacało się robić optymalizację mającą na celu tylko zmniejszenie rozmiaru programu. Jednak większość z wykonywanych operacji zmniejsza również czas wykonania, a następcy tego kompilatora są w użyciu do dziś. Jest on wart naszej uwagi z kilku powodów. Po pierwsze, wykonuje dobrą optymali zację i robi kilka transformacji kodu niespotykanych w większości innych kompilatorów. Co więcej, ten kompilator był jednym z pierwszych stosujących optymalizację sterowaną składnią, opisaną w p. 10.5. Oznacza to, że język Bliss został zaprojektowany tak, aby produkować tylko redukowalne grafy przepływu (nie ma w nim instrukcji goto). Wobec tego, analizę przepływu danych można wykonywać bezpośrednio na drzewie pochodzą cym z analizy składniowej, a nie na specjalnym grafie przepływu. Kompilator działa w jednym przebiegu, który przetwarza całą procedurę, zanim jest wczytywana następna. Projektanci podzielili kompilator na pięć modułów pokaza nych na rys. 12.5. L E X S Y N F L O wykonuje analizę leksykalną i składniową. Używany jest analizator działający metodą zejść rekurencyjnych. Ponieważ w Bliss nie ma instrukcji goto, wszyst kie grafy przepływu procedur są redukowalne. Składnia języka pozwala od razu budować graf przepływu oraz wyszukiwać pętle i ich wejścia podczas analizy składniowej. Oprócz tego, L E X S Y N F L O wyszukuje podwyrażenia wspólne i oblicza coś w rodzaju u d i du-łańcuchów, wykorzystując strukturę redukowalnych grafów przepływu. Kolejnym ważnym zadaniem L E X S Y N F L O jest wykrywanie grup podobnych wyrażeń. Są one kan dydatami do zastąpienia jedną procedurą. Zauważmy, że taka transformacja powoduje, że program działa wolniej, ale zajmuje mniej miejsca. Moduł DELAY bada drzewo składniowe, aby wyznaczyć miejsca, w których za stosowanie zwykłych optymalizacji, takich jak przesunięcie kodu niezmienniczego czy usunięcie podwyrażeń wspólnych, może przynieść zysk. Kolejność wyliczania wyrażeń jest określana przy użyciu strategii z p. 9.10, zmienionej tak, by uwzględniała rejestry, które są niedostępne, ponieważ przechowują wartości podwyrażeń wspólnych. D o określe nia, czy można zmienić kolejność obliczeń, są stosowane prawa algebraiczne. Wyrażenia warunkowe są wyliczane albo liczbowo, albo przez przepływ sterowania, jak opisano w p. 8.4, a DELAY w każdym przypadku decyduje, która metoda jest tańsza. T N B I N D sprawdza, które zmienne tymczasowe powinny być przypisane do reje strów. Przydzielane są rejestry i miejsca w pamięci. Użyta metoda najpierw grupuje
Kod źródłowy
t
Analiza leksykalna i składniowa Analiza przepływu Zbieranie informacji dla potencjalnych optymalizacji
LEXSYNFLOW
Drzewo składniowe
i Określ kolejność wyliczania Wybierz optymalizacje, które zostaną wykonane
DELAY
Drzewo składniowe, kolejność
i Połącz węzły, które mają być wyliczone w tym samym rejestrze Przydziel rejestry zmiennym tymczasowym
TNBIND
Drzewo składniowe, kolejność, przydział rejestrów
i Generacja kodu
I CODE
Relokowalny kod wynikowy
i Optymalizacja lokalna
FINAŁ
ł Relokowany kod wynikowy
Rys. 12.5. Kompilator Bliss-11
węzły drzewa składniowego, które powinny być przydzielone do tego samego rejestru. Jak opisano w p. 9.6, pożyteczne jest wyliczanie węzła w tym samym rejestrze, co jedne go z jego rodziców. Następnie jest oceniany zysk z przechowywania wartości w rejestrze. Faworyzowane są wartości używane wielokrotnie w niewielkich odstępach. Po wykonaniu tej analizy rejestry są przydzielane w kolejności od najlepszej zmiennej do najgorszej. CODE przekształca drzewo z informacjami o kolejności obliczeń i przydziale rejestrów w relokowalny kod maszynowy. Ten kod jest wielokrotnie przeglądany przez moduł FINAŁ, wykonujący optymali zacje lokalne, do czasu gdy nie będzie już można wykonać żadnej optymalizacji. Wyko nane poprawki to m.in. eliminacja (warunkowych i bezwarunkowych) skoków do skoków i, opisane w p. 9.9, dopełnienie warunków. Nadmiarowe bądź niewykonywane rozkazy są usuwane (mogły powstać w wyniku innych optymalizacji w fazie FINAŁ). Podejmowane są próby połączenia podobnego kodu w różnych gałęziach kodu oraz lokalnej propagacji stałych. Są wykonywane również
inne, lokalne optymalizacje. Jedną z nich, ważną, jest zamiana rozkazu skoku przez „rozgałęzienie" — rozkaz PDP-11, który zajmuje tylko jedno słowo, ale zasięg którego jest ograniczony do 128 bajtów.
12.6
Kompilator optymalizujący Modula-2
Kompilator napisany przez Powella [1984] produkuje dobry kod, używając optymaliza cji, które dają oczekiwany efekt niewielkim kosztem; autor opisał swoją strategię jako poszukiwanie „najlepszych prostych" optymalizacji. Taka filozofia nie jest łatwa do za stosowania — bez eksperymentów i pomiarów trudno jest zdecydować, które optymali zacje są „najlepszymi prostymi", i niektóre decyzje podjęte podczas pisania kompilatora Modula-2 prawdopodobnie nie były dobre. Niemniej, użycie opisanej strategii doprowa dziło do napisania przez jedną osobę, w ciągu kilku miesięcy, kompilatora generującego dobry kod. Pięć przebiegów kompilatora pokazanych jest na rys. 12.6.
Analiza składniowa
Rozwiązywanie odwołań do identyfikatorów
Optymalizacja kodu pośredniego
Obliczanie liczby odwołań i przydział rejestrów
Generowanie P-kodu
Rys. 12.6. Przebiegi kompilatora Modula-2 Analizator składniowy został wygenerowany przy użyciu Yacca i produkuje drzewo składniowe w dwóch przebiegach, gdyż Modula nie wymaga deklarowania zmiennych przed ich użyciem. Podjęto wysiłek uczynienia kompilatora zgodnym z istniejącymi pro gramami — kodem pośrednim jest P-kod, zgodny z kodem generowanym przez kom pilatory Pascala, a składnia wywołań procedur jest zgodna ze składnią używaną przez kompilatory Pascala i C działające pod kontrolą systemu Berkeley UNIX, dzięki czemu procedury napisane w tych językach mogą być łatwo używane jednocześnie. Kompilator nie wykonuje analizy przepływu danych. Zamiast tego, Modula-2, tak jak Bliss, jest językiem, w którym powstają wyłącznie redukowalne grafy przepływu,
dzięki czemu można użyć metod opisanych w p. 10.5. Składnia jest wykorzystywana w większym stopniu niż w kompilatorze Bliss-11. Pętle są identyfikowane na podstawie składni, tj. kompilator poszukuje konstrukcji while i for. Wyrażenia niezmiennicze są najpierw rozpoznawane dzięki temu, że żadna z wykorzystywanych w nich zmiennych nie jest definiowana w pętli, a następnie są przenoszone przed treść pętli. Jedyny ro dzaj zmiennych indukcyjnych, który jest wykrywany, to indeksy w pętlach for. Globalne podwyrażenia wspólne są wykrywane, gdy jedno z nich jest w bloku dominującym nad blokiem zawierającym drugie, lecz ta analiza jest wykonywana wyrażenie po wyrażeniu, a nie przy użyciu tablic bitów. Strategia przydzielania rejestrów także jest opracowana tak, aby działała rozsądnie, ale nie jest doskonała. W szczególności, jako kandydatów do przydzielenia rejestrów rozważa jedynie: 1) 2) 3) 4) 5)
wartości tymczasowe używane podczas wyliczania wyrażenia (z najwyższym prio rytetem), wartości podwyrażeń wspólnych, indeksy i wartości końcowe pętli for, adresy E w wyrażeniach o postaci w i t h E d o , zmienne proste (znaki, liczby całkowite itp.) lokalne dla danej procedury.
Należy oszacować wartość przechowywania każdej zmiennej z klas (2)-(5) w reje strach. Przyjmuje się, że instrukcja zostanie wykonana \0 razy, jeżeli znajduje się ona na (i-tym poziomie zagnieżdżenia. W trakcie analizy nie rozpatruje się zmiennych, do których odwołanie następuje co najwyżej dwa razy. Pozostałe są oceniane opisaną meto dą, a rejestry są im przydzielane po przydzieleniu rejestrów wartościom tymczasowym, w obliczonej kolejności. d
DODATEK
Projekt programistyczny
A.l
Wstęp
W tym dodatku przedstawiliśmy propozycje ćwiczeń programistycznych, które m o żna wykorzystać podczas zajęć z laboratorium programistycznego, towarzyszących kursowi tworzenia kompilatorów, bazującemu na tej książce. Ćwiczenia polegają na implementacji podstawowych składników kompilatora dla podzbioru Pascala. Pod zbiór jest minimalny, ale umożliwia wyrażenie programów takich, jak rekurencyjna procedura sortowania z p. 7.1. Wykorzystanie podzbioru istniejącego języka ma taką zaletę, że znaczenie programów w nim napisanych jest wyznaczone przez se mantykę samego języka, w tym przypadku Pascala (Jensen i Wirth [1975]). Jeśli dostępny jest kompilator Pascala, może go wykorzystać do sprawdzenia zachowa nia kompilatora pisanego jako ćwiczenie. Konstrukcje znajdujące się w tym pod zbiorze występują w większości języków, więc odpowiednie ćwiczenia można sformu łować przy użyciu innego języka, jeśli w danej chwili nie ma dostępu do kompilatora Pascala.
A.2
Struktura programu
Program składa się z sekwencji deklaracji danych globalnych, sekwencji deklaracji procedur i funkcji oraz pojedynczej złożonej instrukcji, która stanowi „program główny". Pamięć na dane globalne jest rezerwowana statycznie, a na dane lokalne pro cedur i funkcji — na stosie. Rekurencją jest dozwolona, a argumenty są przekazywa ne przez referencję. Zakłada się również, że kompilator dostarcza procedur r e a d i write. Na rysunku A . l przedstawiono przykładowy program. Nazwą programu jest p r z y k ł a d , nazwy i n p u t i o u t p u t dotyczą plików używanych przez r e a d i w r i t e .
program przykład(input, output); var x, y: integer; function nwd(a, b: integer): integer; begin if b = 0 then nwd := a else nwd := nwd{b, a mod b) end; begin read(x, y ) ; write(nwd(x, y)) end.
Rys. A.l. Przykładowy program
A.3
Składnia podzbioru Pascala
Poniżej znajduje się gramatyka L A L R ( l ) dla podzbioru Pascala. Można ją przystoso wać dla analizatora stosującego metodę zejść rekurencyjnych przez eliminację lewo stronnej rekurencji (patrz p. 2.4 i 4.3). Analizator uwzględniający priorytety operatorów można skonstruować dla wyrażeń po zastąpieniu relop, addop i mulop oraz eliminacji e-produkcji. Dołączenie produkcji instrukcja
~¥ if wyrażenie
then
instrukcja
wprowadza niejednoznaczne „wiszące else", które można wyeliminować (patrz p . 4.3 oraz przykład 4.19 dla przewidującego analizatora składniowego). Nie ma rozróżnienia składniowego między prostą zmienną a wywołaniem procedury bezparametrowej. Każde jest generowane przez tę samą produkcję czynnik —> id Zatem, w przypisaniu a : = b , a otrzymuje wartość zwracaną przez funkcję b , jeśli b zostało zadeklarowane jako funkcja. program —> program id ( lista- identyfikatorów deklaracje deklaracje^ podprogramów instrukcjazłożona
lista- identyfikatorów -* id | lista-identyfikatorów
,
id
)
;
deklaracje —> deklaracje
var
lista-identyfikatorów
:
typ
of
typ-
;
Ic typ typ- standardowy | array [ liczba typ-standardowy integer | real
.
.
liczba
]
standardowy
-»
deklaracje ^podprogramów —> deklaracje-podprogramów
deklaracja-podprogramu
;
Ic deklaracja—podprogramu —> nagłówek^ podprogramu
deklaracje
instrukcja-złożona
nagłówek-podprogramu —» function id argumenty : typ- standardowy | procedurę id argumenty ; argumenty —» ( lista-parametrów
;
)
Ić lista_ parametrów —> lista- identyfikatorów : ( pa rametró w ; instrukcja- złożona begin opcjonalneend
ryp ty fika to ró w :
—• instrukcje
opcjonalne ^instrukcje —> lista- instrukcji i c lista-instrukcji —• instrukcja | instrukcji
;
instrukcja
instrukcja —>• * en/za przypisanieop | wywołanie-p ro cedu ry
wyraża
typ
|
instrukcja-
| |
if wyrażenie then instrukcja else while wyrażenie do instrukcja
zmienna
złożona instrukcja
->
id |
id
[
wyrażenie
wywołanie-procedury
]
—•
id |
id
(
lista-wyrażeń
lista- wyrażeń -> wyrażenie | lista-wyrażeń wyrażenie —> proste|
proste- wyrażenie
relop proste-
wyrażenie
—>
zna/:
składnik
| proste-wyrażenie składnik —> |
wyrażenie
wyrażenie
proste-wyrażenie |
,
)
składnik
mulop
addop
składnik
czynnik
czynnik —>
id
znak
1
id
|
liczba
| I
( wyrażeme ) not czynnik
Usta-wyrażeń
)
-» +
A.4
(
I
"
Konwencje leksykalne
Notacja specyfikacji symboli leksykalnych pochodzi z p. 3.3. 1.
Komentarze są ograniczane znakami { i }. Nie mogą one zwierać {. Komentarze mogą być umieszczane po każdym symbolu leksykalnym.
2.
3.
Odstępy między symbolami leksykalnymi są opcjonalne, z wyjątkiem słów klu czowych, które muszą znajdować się między odstępami, znakami nowych wierszy, początkiem programu lub kropką końcową. Symbol leksykalny id dla identyfikatora pasuje d o napisu utworzonego z litery i występujących po niej liter i cyfr: litera ->• [a-zA-Z] cyfra -> [0-9] id litera ( l i t e r a | cyfra ) *
4.
Możliwe jest ograniczenie długości identyfikatorów. Symbol leksykalny liczba pasuje do liczb całkowitych bez znaku (patrz przy kład 3.5): cyfry o p c j o n a l n y , ułamek opcjonalny-wykładnik liczba
5. 6. 7. 8. 9.
- » cyfra cyfra* - » . cyfry | e — > ( E ( + | - | e ) cyfry ) | e -> cyfry opcjonalny, ułamek opcjonalny-wykładnik
Słowa kluczowe są zarezerwowane i pojawiają się w opisie gramatyki złożone czcionką półgrubą. Operatory relacyjne (relop) są następujące: =, <>, <, <=, > - oraz >. Zauważmy, że <> oznacza 7^. Operatory addytywne (addop) są następujące: +, - oraz or. Operatory multiplikatywne (mulop) są następujące: *, / , c l i v , mod oraz and. Leksemem dla symbolu przypisanieop jest : =.
A.5
Propozycje ćwiczeń
Odpowiednim ćwiczeniem programowym dla jednosemestralnego kursu jest napisanie interpretera dla języka zdefiniowanego powyżej lub dla podobnego podzbioru innego języka wysokopoziomowego. Projekt polega na translacji programu źródłowego d o re prezentacji pośredniej, takiej jak czwórki lub kod maszyny stosowej, i następnie interpre tacji tej reprezentacji pośredniej. Poniżej proponujemy kolejność konstrukcji modułów, inną niż ta, w której moduły są wykonywane w kompilatorze, ponieważ wygodnie jest posiadać działający interpreter do testowania innych składników kompilatora. 1. Zaprojektować mechanizm tablicy symboli. Należy ustalić organizację tablicy symbo li. Musi istnieć możliwość zbierania różnych informacji na temat nazw, ale rekordy tablicy symboli powinny być w tym momencie dostatecznie elastyczne. Należy napisać proce dury do: i) przeszukiwania tablicy symboli dla danej nazwy i tworzenia nowego wpisu dla tej nazwy, jeśli nie zostanie znaleziony; w obu przypadkach powinien być zwracany wskaźnik do rekordu dla tej nazwy; ii) usuwania z tablicy symboli wszystkich nazw lokalnych dla danej procedury.
2. Napisać interpreter czwórek. Dokładny zbiór czwórek może być jeszcze otwarty, ale powinien zawierać instrukcje arytmetyczne oraz skoków odpowiadające zbiorowi ope ratorów w języku. Powinien zawierać również operacje logiczne, jeśli warunki są obli czane arytmetycznie, a nie pozycję w programie. Może być również potrzebne istnienie „czwórek" do konwersji z liczb całkowitych do rzeczywistych, do zaznaczania początków i końców procedur oraz do przekazywania parametrów i wywołań procedur. W tym czasie trzeba zaprojektować sekwencję wywołującą i organizację przetwa rzania dla interpretowanych programów. Prosta organizacja stosu omówiona w p. 7.3 jest odpowiednia dla przykładowego języka, ponieważ nie są dozwolone zagnieżdżone deklaracje procedur w tym języku, czyli zmienne są albo globalne (zadeklarowane na poziomie całego programu), albo lokalne dla prostej procedury. Dla uproszczenia, w miejscu interpretera można użyć innego języka wysokopoziomowego. Każda czwórka może być instrukcją języka wysokopoziomowego, takiego jak C lub Pascal. Wynikiem kompilatora może być sekwencja instrukcji C, które mogą być kompilowane istniejącym kompilatorem C. To podejście umożliwia implementatorowi skoncentrowanie się na organizacji przetwarzania. 3. Napisać analizator leksykalny. Należy wybrać kody wewnętrzne dla leksemów. Za decydować, jak stałe będą reprezentowane w kompilatorze. Należy liczyć wiersze w ce lu późniejszego użycia przez procedury obsługi błędów. Jeśli jest to potrzebne, należy utworzyć wydruk programu źródłowego. Napisać program do wprowadzania zarezerwo wanych słów do tablicy symboli. Zaprojektować analizator leksykalny jako podprogram wywoływany przez analizator składniowy, zwracający parę (symbol leksykalny, wartość atrybutu). Na początku, po wykryciu błędu przez analizator leksykalny, może on być wypisywany i kompilator może się zatrzymywać. 4. Napisać akcje semantyczne. Należy zapisać procedury semantyczne generujące czwór ki. Gramatykę należy zmodyfikować w celu uproszczenia translacji. W podrozdziałach 5.5 i 5.6 są przykłady właściwego modyfikowania gramatyki. W tym czasie należy wy konywać analizę semantyczną, konwertując — w razie potrzeby — liczby całkowite na rzeczywiste. 5. Napisać analizator składniowy. Jeśli generator analizatorów LALR jest dostępny, może znacząco uprościć to zadanie. Jeśli dostępny jest generator analizatorów obsługu jący gramatyki niejednoznaczne, jak Yacc, to nieterminale oznaczające wyrażenia mogą zostać połączone. Ponadto, niejednoznaczność „wiszącego else" może zostać usunięta za pomocą operacji przesunięcia za każdym razem, gdy pojawia się konflikt przesunię cie/redukcja. 6. Napisać procedury obsługi błędów. Trzeba być przygotowanym na odzyskiwanie kon troli po usunięciu błędów leksykalnych i składniowych. Należy wypisywać diagnostykę dla błędów leksykalnych, składniowych i semantycznych. 7. Testowanie. Program z rys. A . l może służyć jako prosty program testowy. Inny pro gram do testów może bazować na programie w Pascalu z rys. 7.1. Kod dla funkcji p o d z i a ł na rysunku odpowiada zaznaczonemu fragmentowi w programie w C z rys. 10.2. Utworzony kompilator można przepuścić przez program profilujący, jeśli taki jest dostęp ny, i wyznaczyć procedury, w których spędza się najwięcej czasu. Które moduły powinny zostać zmodyfikowane w celu zwiększenia szybkości kompilatora?
A.7
ROZSZERZENIA
A.6
Ewolucja interpretera
Innym podejściem przy konstrukcji interpretera dla języka jest rozpoczęcie od implemen tacji kalkulatora, czyli interpretera dla wyrażeń. Stopniowo należy dodawać konstrukcje do języka, aż zostanie uzyskany interpreter dla całego języka. Podobne podejście stoso wali Kernighan i Pike [1984]. Proponowana kolejność dodawania konstrukcji jest nastę pująca: 1. Translacja wyrażeń do notacji postfiksowej. Używając albo metody zejść rekurencyj nych, jak w rozdz. 2, albo generatora analizatorów składniowych, należy zapoznać się ze środowiskiem programowym, pisząc translator dla prostych wyrażeń arytmetycznych do notacji postfiksowej. 2. Dodanie analizatora leksykalnego. W translatorze skonstruowanym powyżej powinna istnieć możliwość użycia słów kluczowych, identyfikatorów i liczb. Należy tak przerobić translator, aby produkował kod dla maszyny stosowej albo czwórki. 3. Napisanie interpretera dla reprezentacji pośredniej. Jak omówiono w p. A.5, za miast interpretera można użyć języka wysokopoziomowego. Na razie interpreter może obsługiwać jedynie operacje arytmetyczne, przypisania i wejście-wyjście. Należy roz szerzyć język o deklaracje zmiennych globalnych, przypisań, wywołania procedur r e a d i w r i t e . Konstrukcje te umożliwią przetestowanie interpretera. 4. Dodanie instrukcji. Program w tym języku składa się z głównego programu, bez deklaracji podprogramów. Należy przetestować zarówno translator, jak i interpreter. 5. Dodanie procedur i funkcji. Tablica symboli musi uwzględniać ograniczanie widzial ności identyfikatorów do treści procedur. Należy zaprojektować sekwencję wywołania. Ponownie odpowiednia jest prosta organizacja stosu z p. 7.3. Interpreter należy rozszerzyć o sekwencję wywołania.
A.7
Rozszerzenia
Istnieje wiele możliwości rozszerzenia języka bez dużego zwiększania złożoności kom pilacji, między innymi o: 1) 2) 3) 4)
tablice wielowymiarowe, instrukcje for i case, struktury blokowe, rekordy.
Jeśli czas pozwala, można dodać jedno lub kilka tych rozszerzeń do pisanego kompilatora.
Bibliografia
A B E L N. E. A N D J. R. B E L L [1972]. „Global optimization in compilers", Proc. USA-Japan Computer Conf, AFIPS Press, Montvale, N. J. ABELSON H . A N D G. J. SUSSMAN [1985]. Structure Programs, M I T Press, Cambridge, Mass*.
and Interpretation
of
First
Computer
A D R I O N W. R . , M . A. BRANSTAD A N D J. C . CHERNIAVSKY [1982]. „Validation, verifica-
tion, and testing of computer software", Computing
Surveys 14:2, 159-192.
AHO A. V. [1980]. „Pattern matching in strings", in Book [1980], 325-347. AHO A. V. A N D M. J. CORASICK [1975]. „Efficient string matching: an aid to bibliographic search", Comm. ACM 18:6, 333-340. AHO A. V. A N D M. GANAPATHI [1985]. „Efficient tree pattern matching: an aid to code generation", Twelfth Annual ACM Symposium on Principles of Programming Languages, 334-340. AHO A. V., J. E. HOPCROFT A N D J. D. ULLMAN [1974]. Projektowanie rytmów komputerowych, PWN, Warszawa. AHO A. V., J. E. HOPCROFT AND J. D. ULLMAN [1983]. Data Structures Addison-Wesley, Reading, Mass. AHO A. V. A N D S. C JOHNSON [1974]. 99-124.
„LR
parsing",
Computing
i analiza
algo
andAlgorithms,
Surveys
6:2,
A H O A. V. A N D S. C . JOHNSON [1976]. „Optimal code generation for expression trees", J. ACM 23:3, 4 8 8 - 5 0 1 . A H O A. V., S. C . JOHNSON A N D J. D. ULLMAN [1975]. „Deterministic parsing of ambiguous grammars", Comm. ACM 18:8, 441-452.
Nakładem Wydawnictw Naukowo-Technicznych ukaże się w 2001 roku polskie tłumaczenie 2. wydania tej książki: Struktura i interpretacja programów komputerowych (przyp. red.).
A H O A. V., S. C . JOHNSON AND J . D. U L L M A N [1977a]. „Code generation for expressions with common subexpressions", J. ACM 24:1, 146-160. A H O A. V., S. C. J O H N S O N A N D J . D . U L L M A N [1977b]. „Code generation for machines with multiregister operations", Fourth ACM Symposium on Principles of Programming Languages, 21-28. A H O A. V , B . W. KERNIGHAN AND P. J . W E I N B E R G E R [1979]. „Awk — a pattern scanning and processing language", Software — Practice and Experience 9:4, 267-280. A H O A. V. AND T. G. P E T E R S O N [1972]. „A minimum distance error-correcting parser for context-free languages", SIAM J. Computing 1:4, 305-312. A H O A. V. AND R. SETHI [1977]. „How hard is compiler code generation?" Lecture Notes in Computer Science 52, Springer-Verlag, Berlin, 1-15. A H O A. V. AND J . D. U L L M A N [1972a]. „Optimization of straight line code", J. Computing 1:1, 1-19.
SIAM
A H O A. V. AND J . D . U L L M A N [1972b]. The Theory of Parsing, Translation piling, Vol. I: Parsing, Prentice-Hall, Englewood Cliffs, N. J .
and
Com-
A H O A. V. AND J . D. U L L M A N [1973a]. The Theory of Parsing, Translation piling, VoI. II: Compiling, Prentice-Hall, Englewood Cliffs, N. J .
and
Com-
A H O A. V. AND J . D. U L L M A N [1973b]. „A techniąue for speeding up LR(k) parsers", SIAM J. Computing 2:2, 106-127. A H O A. V. AND J . D. U L L M A N [1977].
Principles
of
Compiler
Design,
Addison-
-Wesley, Reading, Mass. A I G R A I N R, S. L. G R A H A M , R. R. H E N R Y , M. K . M C K U S I C K A N D E. P E L E G R I L L O P A R T
[1984]. „Experience with a Graham-Glanville style code generator", ACM Notices 19:6, 13-24. A L L E N F . E. [1969]. „Program optimization", Annual Review in Automatic
SIGPLAN
Programming
5, 239-307. A L L E N F . E. [1970]. „Control flow analysis", ACM SIGPLAN
Notices 5:7, 1-19.
A L L E N F . E. [1974]. „Interprocedural data flow analysis", Information North-Holland, Amsterdam, 398-402. A L L E N F . E. [1975]. „Bibliography on program optimization", T. J . Watson Research Center, Yorktown Heights, N. Y.
Processing
RC-5767,
A L L E N F . E., J . L. C A R T E R , J . F A B R I , J . F E R R A N T E , W. H. H A R R I S O N , P. G.
74,
IBM
LOEWNER
AND L. H. TREVILLYAN [1980]. „The experimental compiling system", IBM. J. Re search and Development 24:6, 695-715. A L L E N F . E. AND J . C O C K E [1972]. „A in Rustin [1972], 1-30.
catalogue
of
optimizing
transformations",
A L L E N F. E. AND J. C O C K E [1976]. „ A program data flow analysis procedurę", Comm. ACM 1 9 : 3 , 137-147. A L L E N F. E., J. C O C K E A N D K . K E N N E D Y [1981]. „Reduction of operator strength", in
Muchnick and Jones [1981], 79-101. A M M A N N U . [1977]. „On code generation in a Pascal compiler", Software and Experience 7 : 3 , 391-423.
— Practice
A M M A N N U . [1981]. „The Zurich implementation", in Barren [1981], 63-82. A N D E R S O N J. P. [1964]. „A note on some compiling algorithms", Comm. ACM 7 : 3 , 149-150. A N D E R S O N T , J. E V E A N D J. J. HORNING [1973]. Informatica 2 : 1 , 12-39.
„Efficient
LR(1) parsers",
Acta
A N K L A M P , D . C U T L E R , R. H E I N E N , Jr. A N D M . D . M A C L A R E N [1982]. Engineering
Compiler,
a
Digital Press, Bedford, Mass.
A R D E N B . W., B . A. G A L L E R A N D R. M. G R A H A M [1961]. „An algorithm for equiva-
lence declarations", Comm. ACM 4 : 7 , 310-314. A U S L A N D E R M. A. A N D M . E. HOPKINS [1982]. „An overview of the PL.8 compiler", ACM SIGPLAN Notices 1 7 : 6 , 22-31. B A C K H O U S E R. C [1976]. „An alternative approach to the improvement of LR parsers", Acta Informatica 6 : 3 , 277-296. BACKHOUSE R. C. [1984]. „Global data flow analysis problems arising in locally leastcost error recovery", TOPLAS 6 : 2 , 192-214. B A C K U S J. W. [1981]. „Transcript of presentation on the history of Fortran I, II, and III", in Wexelblat [1981], 45-66. B A C K U S J. W., R. J. B E E B E R , S . B E S T , R. G O L D B E R G , L. M . H A I B T , H . L. H E R R I C K , R. A. N E L S O N , D . S A Y R E , P. B . S H E R I D A N , H . S T E R N , I. Z I L L E R , R. A. H U G H E S A N D
R. N U T T [1957]. „The Fortran automatic coding system", Western Joint Conference, 188-198. Reprinted in Rosen [1967], 29-47.
Computer
B A K E R B . S . [1977]. „An algorithm for structuring programs", J. ACM 2 4 : 1 , 98-120. B A K E R T. P. [1982]. „ A one-pass algorithm for overload resolution in Ada", TOPLAS 4 : 4 , 601-614. BANNING J. P. [1979]. „An efficient way to find the side effects of procedurę calls and aliases of variables", Sixth Annual ACM Symposium on Principles of Programming Languages, 29-41. B A R R O N D . W. [1981]. Pascal Chichester.
— The Language
and its Implementation,
Wiley,
B A R T H J. M. [1978]. „A practical interprocedural data flow analysis algorithm", Comm. ACM 2 1 : 9 , 724-736.
B R O S G O L B. M. [1974]. Deterministic Translation Harvard Univ., Cambridge, Mass.
Grammars,
Ph. D . Thesis, T R 3-74,
B R U N O J. A N D T. LASSAGNE [1975]. „The generation of optimal code for stack machi¬ nes", J. ACM 2 2 : 3 , 382-396. B R U N O J. A N D R . SETHI [1976]. J. ACM 2 3 : 3 , 502-510.
„Code
generation
for
a
one-register
machinę",
B U R S T A L L R . M., D . B . M A C Q U E E N A N D D . T. S A N N E L L A [1980]. „Hope: an experi-
mental applicative language", Lisp Conference, Calif. 95044, 136-143.
P.O. Box 487, Redwood Estates,
B U S A M V . A. A N D D . E. E N G L U N D [1969]. „Optimization of expressions in Fortran", Comm. ACM 1 2 : 1 2 , 666-674. CARDELLI L. [1984]. „Basic polymorphic typechecking", Computing Science Technical Report 112, AT&T Bell Laboratories, Murray Hill, N. J. CARTER L. R. [1982]. An Analysis of Pascal Programs, U M I Research Press, Ann Arbor, Michigan. CARTWRIGHT R. [1985], „Types as intervals", Twelfth Annual ACM Symposium ciples of Programming Languages, 22-36.
on Prin
C A T T E L L R. G . G . [1980]. „Automatic derivation of code generators from machinę descriptions", TOPLAS 2:2, 173-190. CHAITIN G . J. [1982]. „Register allocation and spilling via graph coloring", ACM SIGPLAN Notices 1 7 : 6 , 201-207. C H A I T I N G . J., M . A. A U S L A N D E R , A. K . C H A N D R A , J. C O C K E , M. E. H O P K I N S A N D
P. W. M A R K S T E I N [1981]. „Register allocation via coloring", Computer 6, 47-57. CHERNIAVSKY J. C , P. B . H E N D E R S O N A N D J. K E O H A N E
[1976]. „On the equivalence of
URE flow graphs and reducible flow graphs", Proc. 1976 Conference on Sciences and Systems, Johns Hopkins Univ., 423-429. CHERRY L. L. [1982]. „Writing tools", IEEE 100-104.
Languages
Trans, on Communications
Information
COM-30:1,
C H O M S K Y N. [1956]. „Three models for the description of language", IRE Trans, on Information Theory I T - 2 : 3 , 113-124. C H O W F. [1983]. A Portable Machine-Independent Global Optimizer, Computer System Lab., Stanford Univ., Stanford, Calif.
Ph. D . Thesis,
C H O W F. A N D J. L. H E N N E S S Y [1984]. „Register allocation by priority-based coloring", ACM SIGPLAN Notices 1 9 : 6 , 222-232. C H U R C H A. [1941]. The Calculi of Lambda Conversion, No. 6, Princeton University Press, Princeton, N. J.
Annals of Math. Studies,
CHURCH A . [1956]. Introduction
to Mathematical
Logic, VoI. I, Princeton University
Press, Princeton, N. J. ClESlNGER J. [1979]. „A bibliography of error handling", ACM SIGPLAN
Notices 14:1,
16-26. COCKE J. [1970]. „Global common subexpression elimination", ACM SIGPLAN
Notices
5:7, 20-24. COCKE J. A N D K . KENNEDY [1976]. „Profitability computations on program graphs", Computers and Mathematics with Applications 2:2, 145-159.
flow
COCKE J. A N D K . KENNEDY [1977]. „An algorithm for reduction of operator strength", Comm. ACM 20:11, 850-856. COCKE J. A N D J. MARKSTEIN [1980]. „Measurement of code improvement algorithms", Information Processing 80, 221-228. COCKE J. AND J. MILLER [1969]. „Some analysis techniąues for optimizing computer programs", Proc. 2nd Hawaii Intl. Conf. on Systems Sciences, 143-146. COCKE J. AND J. T . SCHWARTZ [1970]. Programming Languages and Their Compilers: Preliminary Notes, Second Revised Version, Courant Institute of Mathematical Scien ces, New York. COFFMAN E. G., Jr. AND R. SETHI [1983]. „Instruction sets for evaluating arithmetic expressions", J. ACM 30:3, 457-478. COHEN R. AND E. HARRY [1979]. „Automatic generation of near-optimal linear-time translators for non-circular attribute grammars", Sixth ACM Symposium on Prin ciples of Programming Languages, 121-134. CONWAY M. E. [1963]. „Design of a separable transition diagram compiler",
Comm.
ACM 6:7, 396-408. CONWAY R. W . AND W . L. M A X W E L L [1963]. „CORC — the Cornell computing lan guage", Comm. ACM 6:6, 317-321. CONWAY R. W . AND T . R. WILCOX [1973]. „Design and implementation of a diagnostic compiler for PL/I", Comm. ACM 16:3, 169-179. CORMACK G. V. [1981]. „An algorithm for the selection of overloaded functions in Ada", ACM SIGPLAN Notices 16:2 (February), 48-52. CORMACK G. V., R. N. S. HORSPOOL A N D M. KAISERSWERTH [1985]. „Practical perfect
hashing", Computer J. 28:1, 54-58. COURCELLE B . [1984]. „Attribute grammars: definitions, analysis of dependencies, proof methods", in Lorho [1984], 81-102. COUSOT P. [1981]. „Semantic foundations of program analysis", in Muchnick and Jones [1981], 303-342.
C O U S O T P. A N D R. C O U S O T [1977]. „Abstract interpretation: a unified lattice model for static analysis of programs by construction or approximation of flxpoints", Fourth ACM Symposium on Principles of Programming Languages, 238-252. CURRY H. B . AND R. F E Y S [1958]. Amsterdam.
Combinatory
Logic,
Vol.
1,
North-Holland,
D A T E C. J. [1986]. An Introduction to Database Systems, 4th Ed., Addison-Wesley, Reading, Mass*. DAVIDSON J. W. AND C W. F R A S E R [1980]. „The design and application of a retargetable peephole optimizer", TOPLAS 2:2, 191-202. Errata 3:1 (1981), 110. DAVIDSON J . W. A N D C. W. FRASER [1984a]. „Automatic generation of peephole optimizations", ACM SIGPLAN Notices 19:6, 111-116. DAVIDSON J. W. AND C. W. FRASER [1984b]. „Code selection through object code opti mization", TOPLAS 6:4, 505-526. D E R E M E R F . [1969]. Practical Cambridge, Mass.
Translators for LR(k) Languages,
Ph. D . Thesis, M.I.T.,
D E R E M E R F . [1971]. „Simple L R ( * ) grammars", Comm. ACM 14:7, 453-460. D E R E M E R F . A N D T. P E N N E L L O [1982].
-ahead sets", TOPLAS
„Efficient
computation
of
LALR(/)
look-
4:4, 615-649.
A. J. [1975]. „Elimination of single productions and merging of nonterminal syrribó\s m " L R ( l ) grammars", J. Computer Languages 1:2, 105-119.
DEMERS
R, K. D U R R E A N D J. H E U F T [1984]. „Optimization of parser tables for portable compilers", TOPLAS 6:4, 546-572.
DENCKER
D E R A N S A R T P , M. J O U R D A N A N D B . L O R H O [1984]. „Speeding up circularity tests for
attribute grammars", Acta Informatica
2 1 , 375-391.
DESPEYROUX T [1984]. „Executable specifications MacQueen and Plotkin [1984], 215-233.
of static semantics", in Kahn,
E. W. [1960], „Recursive programming", Numerische Reprinted in Rosen [1967], 221-228.
DlJKSTRA
Math.
E. W. [1963]. „An Algol 60 translator for the X l " , Annual Automatic Programming 3, Pergamon Press, New York, 329-345.
DlJKSTRA
2, 312-318. Review
in
H. R. M C L E L L A N [1982]. „Register allocation for free: the C machinę stack cache", Proc. ACM Symp. on Architectural Supportfor Programming Langua ges and Operating Systems, 48-56.
DlTZEL D . AND
D O W N E Y P. J. A N D R. SETHI [1978]. „Assignment commands with array references", J. ACM 25:4, 652-666.
* Nakładem Wydawnictw Naukowo-Technicznych ukazało się w 2000 roku polskie tłumaczenie 6. wydania tej książki: Wprowadzenie do systemów baz danych. Wyd. 2. (przyp. red.).
D O W N E Y R J., R. S E T H I A N D R. E. TARJAN [1980], „Variations on the common subex-
pression problem", J. ACM 27:4, 758-771. EARLEY J. [1970]. „An efficient context-free parsing algorithm", Comm. ACM 13:2, 94-102. EARLEY J. [1975a]. „Ambiguity and precedence in syntax description", Acta
Informatica
4:2, 183-192. EARLEY J. [1975b]. „High level iterators and a method of data structure choice", J. Computer Languages 1:4, 321-342. ELSHOFF J. L. [1976]. „An analysis of some commercial PL/I programs", IEEE Software Engineering SE2:2, 113-120.
Trans.
ENGELFRIET J. [1984]. „Attribute evaluation methods", in Lorho [1984], 103-138. ERSHOV A. P. [1958]. „On programming of arithmetic operations", Comm. ACM 1:8 (August) 3-6. Figures 1-3 appear in 1:9 (September 1958), 16. ERSHOV A. P. [1966]. „Alpha — an automatic programming system of high efficiency", J. ACM 13:1, 17-24. ERSHOV A. P. [1971]. The Alpha Automatic
Programming
System, Academic Press, New
York. ERSHOV A. P. AND C. H. A. K O S T E R [1977]. Methods of Algorithmic Language Implementation, Lecture Notes in Computer Science 47, Springer-Verlag, Berlin. F A N G I. [1972]. „FOLDS, a declarative formal language definition system", STAN-CS-72-329, Stanford Univ. FARROW R. [1984]. „Generating a production compiler from an attribute grammar", IEEE Software 1 (October), 77-93. FARROW R. AND D . Y E L L I N [1985]. „A comparison of storage optimizations automatically-generated compilers", manuscript, Columbia Univ.
in
FELDMAN S. I. [1979a]. „Make — a program for maintaining computer programs", Software — Practice and Experience 9:4, 255-265. FELDMAN S. I. [1979b]. „Implementation of a portable Fortran 77 compiler using mo dern tools", ACM SIGPLAN Notices 14:8, 98-106. FISCHER M. J. [1972]. „Efficiency of equivalence algorithms", in Miller and Thatcher [1972], 153-168. FLECK A. C . [1976]. „The impossibility of content exchange through the by-name parameter transmission techniąue", ACM SIGPLAN Notices 11:11 (November), 38-41. FLOYD
R. W. [1961]. „An algorithm for coding efficient arithmetic ex.pressions", Comm.
ACM 4:1, 42-51. FLOYD R. W. [1963]. „Syntactic analysis and operator precedence", J. ACM *0:3, 316-333.
F L O Y D R. W. [1964]. „Bounded context syntactic analysis", Comm. ACM 7:2, 62-67. F O N G A. C . [1979]. „Automatic i m p i w e m e n t of programs in very high-level languages", Sixth Annual ACM Symposium on Principles of Programming Languages, 21-28.
FONG A.
C . A N D J. D. U L L M A N [1976]. „Induction variables in very high-level langua ges", Third Annual ACM Symposium on Principles of Programming Languages, 104-112.
FOSDICK L. D. AND L. J. OSTERWEIL [1976]. „Data flow analysis in software reliability", Computing Surveys 8:3, 305-330. FoSTER J. M. [1968]. „ A syntax improving program", Computer J. 11:1, 31-34. FRASER C . W. [1977]. Automatic Univ., New Haven, Conn.
Generation
of Code Generators,
Ph. D . Thesis, Yale
FRASER C . W. [1979]. „A compact, machine-independent peephole optimizer", Annual ACM Symposium on Principles of Programming Languages, 1-6.
Sixth
FRASER C . W. AND D. R. H A N S O N [1982]. „A machine-independenthnker", Software — Practice and Experience 12, 351-366. FREDMAN M. L., J. KOMLOS AND E. SZEMEREDI [1984]. „Storing a sparse table with 0(\) worst case access time", J. ACM 31:3, 538-544. F R E G E G . [1879]. „Begriffsschrift, a formuła language, modeled upon that of arithmetic, for pure thought", in Heijenoort [1967], 1-82. FREIBURGHOUSE R. A. [ 1969]. „The Multics PL/I compiler", AF1PS Fali Joint Conference 35, 187-208.
Computer
FREIBURGHOUSE R. A. [1974]. „Register allocation via usage counts", Comm. ACM 17:11, 638-642. FREUDENBERGER S. M. [1984]. „On the use of global optimization algorithms for the detection of semantic programming errors", NSO-24, New York Univ. FREUDENBERGER S. M., J. T. SCHWARTZ AND M. SHARIR [1983]. „Experience with the SETL optimizer", TOPLAS 5:1, 26-45. GAJEWSKA H. [1975]. „Some statistics on the usage of the C language", A T & T Bell Laboratories, Murray Hill, N. J. GALLER B . A. A N D M . J. FISCHER [1964]. „An improved equivalence algorithm", Comm. ACM 7:5, 301-303. GANAPATHi M. [1980]. Retargetable Code Generation and Optimization Grammars, Ph. D . Thesis, Univ. of Wisconsin, Madison, Wis.
using
Attribute
GANAPATHI M. AND C N. FISCHER [1982]. „Description-driven code generation using attribute grammars", Ninth ACM Symposium on Principles of Programming Lan guages, 108-119.
G R A U A . A . , U. H I L L A N D H. L A N G M A A C K [3967]. Translation
of Algol
60,
Sprin-
ger-Verlag, New York. H A N S O N D. R. [1981]. „Is błock structure necessary?", Software — Practice and Experience 11, 853-866. HARRISON M. C. [1971]. „Implementation of the substring test by hashing", Comm. ACM 14:12, 777-779. HARRISON W. [1975]. „ A class of register allocation algorithms", RC-5342, I B M T. J. Watson Research Center, Yorktown Heights, N. Y. HARRISON W. [1977]. „Compiler analysis of the value ranges for variables", IEEE Software Engineering 3:3. H E C H T M. S. [1977]. Flow Analysis
Trans.
of Computer Programs, North-Holland, New York.
H E C H T M. S. AND J . B. SHAFFER [1975]. „Ideas on the design of a 'quad improver' for SIMPL-T, part I: overview and intersegment analysis", Dept. of Computer Science, Univ. of Maryland, College Park, Md. H E C H T M. S. AND J . D. U L L M A N [1972]. „Flow graph reducibility", SIAMJ.
Computing
1, 188-202. H E C H T M. S. AND J . D. U L L M A N [1974]. „Characterizations of reducible flow graphs", J. ACM 2 1 , 367-375. H E C H T M. S. AND J . D. U L L M A N [1975]. „A simple algorithm for global data flow ana lysis programs", SIAM J. Computing 4, 519-532. HEUENOORT J. VAN [1967]. From Frege to Godeł,
Harvard Univ. Press, Cambridge,
Mass. H E N N E S S Y J . [1981]. „Program optimization and exception handling", Eighth ACM Symposium on Principles of Programming Languages, 200-206. H E N N E S S Y J . [1982]. „Symbolic debugging of optimized code", TOPLAS HENRY R. R. [1984]. Graham~Glanville
Code Generators,
Annual
4:3, 323-344.
Ph. D. Thesis, Univ. of Cali-
fornia, Berkeley. H E X T J. B. [1967]. „Compile time type-matching", Computer J. 9, 365-369. H I N D L E Y R. [1969]. „The principal type-scheme of an object in combinatory logie", Trans. AMS 146, 29-60. HOARE C. A. R. [1962a]. „Quicksort", Computer J. 5:1, 10-15. H O A R E C. A. R. [1962b]. „Report on the Elliott Algol translator", Computer
J. 5:2,
127-129. H O F F M A N C. M. AND M. J . 0 ' D O N N E L L [1982]. „Pattern matching in trees", J. ACM 29:1, 68-95.
H O P C R O F T J . E. A N D R. M . K A R P [1971]. „An algorithm for testing the equivalence of finite automata", TR-71-114, Dept. of Computer Science, Cornell Univ. See A h o , Hopcroft and Ullman [1974], 143-145. H O P C R O F T J . E. AND J . D. U L L M A N [1969]. Formal Languages Automata, Addison-Wesley, Reading, Mass.
and Their Relation
to
H O P C R O F T J . E. AND J . D. U L L M A N [1973]. „Set merging algorithms", SIAM J. Compu ting 2:3, 294-303. H O P C R O F T J . E. AND J . D. U L L M A N [1994]. Wprowadzenie ków i obliczeń, P W N , Warszawa.
do teorii automatów,
języ
HORNING J . J . [1976]. „What the compiler should tell the user", in Bauer and Eickel [1976]. H O R W I T Z L. R, R. M . K A R P , R. E. M I L L E R A N D S. W I N O G R A D
[1966]. „Index
register
allocation", / . ACM 13:1, 43-61. H U E T G . A N D G . K A H N (EDS.) [\915].Provingand
Improving Programs,
CoMoąuzIUlA,
Arc-et-Senans, France. H U E T G . A N D J . - J . L E V Y [1979]. „Call-by-need computations in nonambiguous linear term rewriting systems", Rapport de Recherche 359, INRIA Laboria, Rocąuencourt. H U F F M A N D. A. [1954]. „The synthesis of seąuential machines", / . Franklin Inst. 257, 3-4, 161, 190, 275-303. W. A N D M. D. MclLROY [1976]. „An algorithm for differential file comparison", Computing Science Technical Report 4 1 , AT&T Bell Laboratories, Murray Hill, N. J .
H U N T J.
H U N T J . W. A N D T. G . SZYMAŃSKI [1977]. „A fast algorithm for computing longest common subseąuences", Comm. ACM 20:5, 350-353. H U S K E Y H. D., M. H. H A L S T E A D A N D R. M C A R T H U R [1960]. „Neliac — a dialect of
Algol", Comm. ACM 3:8, 463-468. ICHBIAH J . D. A N D S. P. M O R S E [1970]. „A techniąue for generating almost optimal Floyd-Evans productions for precedence grammars", Comm. ACM 13:8, 501-508. INGALLS D. H. H. [1978]. „The Smalltalk-76 programming system design and implementation", Fifth Annual ACM Symposium on Principles of Programming Languages, 9-16. INGERMAN P. Z . [1967]. „Panini-Backus form suggested", Comm. ACM 10:3, 137. IRONS E. T. [1961]. „A syntax directed compiler for Algol 60", Comm. ACM 4 : 1 , 51-55. IRONS E. T. [1963]. „An error 669-673.
correcting
IYERSON K . [1962]. A Programming
parse
algorithm",
Language, Wiley, New York.
Comm.
ACM 6:11,
JANAS J. M . [ 1 9 8 0 ] . „ A comment on 'Operator identification in Ada' by Ganzinger and Ripken", ACM SIGPLAN Notices 15:9 (September), 3 9 - 4 3 . JARVIS J. F. [ 1 9 7 6 ] . „Feature recognition in line drawings using regular expressions", Proc. 3rd Intl. Joint Conf. on Pattern Recognition, 189-192. JAZAYERI M., W . R O G D E N A N D W . C. R O U N D S [ 1 9 7 5 ] . „The intrinsicexponential com-
plexity of the circularity problem for attribute grammars", Comm. ACM 18:12, 697-706.
JAZAYERI M. A N D D . POZEFSKY [ 1 9 8 1 ] .
„Space-efficient
storage management
in an
attribute grammar evaluator", TOPLAS 3 : 4 , 3 8 8 - 4 0 4 . JAZAYERI M. A N D K . G. W A L T E R [ 1 9 7 5 ] . „Alternating semantic evaluator", Proc. ACM
Annual Conference,
230-234.
JENSEN K . A N D N. W I R T H [ 1 9 7 5 ] . Pascal New York.
User Manuał
and Report,
Springer-Verlag,
JOHNSON S. C. [ 1 9 7 5 ] . „Yacc — yet another compiler compiler", Computing Science Technical Report 3 2 , AT&T Bell Laboratories, Murray Hill, N. J. JOHNSON S. C. [ 1 9 7 8 ] . „A portable compiler: theory and practice", Fifth Annual ACM Symposium on Principles of Programming Languages, 9 7 - 1 0 4 . JOHNSON S. C. [ 1 9 7 9 ] . „ A tour through the portable C compiler", AT&T Bell Labora tories, Murray Hill, N. J. JOHNSON S. C. [ 1 9 8 3 ] . „Code generation for silicon", Tenth Annual ACM Symposium Principles of Programming Languages, 1 4 - 1 9 . JOHNSON S. C. AND M . E. L E S K [ 1 9 7 8 ] . „Language development tools", Bell
on
System
Technical J. 5 7 : 6 , 2 1 5 5 - 2 1 7 5 .
JOHNSON S. C. A N D D . M . RITCHIE [ 1 9 8 1 ] .
„The
C
language
calling
seąuence",
Computing Science Technical Report 1 0 2 , AT&T Bell Laboratories, Murray Hill, N. J. JOHNSON W . L., J. H. P O R T E R , S. I. A C K L E Y A N D D . T R o s s
[ 1 9 6 8 ] . „Automaticgene
ration of efficient Iexical processors using finite state techniąues", Comm. ACM 11:12, 8 0 5 - 8 1 3 .
JOHNSSON R. K . [ 1 9 7 5 ] . An Approach to Global Register Carnegie-Mellon Univ., Pittsburgh, Pa.
Allocation,
Ph. D . Thesis,
JOLIAT M. L. [ 1 9 7 6 ] . „A simple techniąue for partial elimination of unit productions from LR(fc) parser tables", IEEE Trans, on Computers C-25:7, 7 6 3 - 7 6 4 . JONES N. D . [ 1 9 8 0 ] . Semantics Directed Compiler puter Science 94, Springer-Verlag, Berlin.
Generation,
Lecture Notes in Com
JONES N. D . A N D C. M . M A D S E N [ 1 9 8 0 ] . „Attribute-influenced L R parsing", in Jones [1980], 393-407.
JONES N. D . AND S. S. M U C H N I C K [1976]. „Binding time optimization in programming languages", ThirdACM Symposium on Principles of Programming Languages, 77-94. JOURDAN M. [1984], „Strongly noncircular attribute grammars and their recursive evaluation", ACM SIGPLAN Notices 19:6, 81-93. K A H N G., D . B. M A C Q U E E N AND G. PLOTKIN [1984]. Semantics ofData Notes in Computer Science 173, Springer-Verlag, Berlin.
Types, Lecture
K A M J. B. AND J. D . U L L M A N [1976]. „Global data flow analysis and iterative algorithms", J. ACM 23:1, 158-171. K A M J. B. AND J. D . U L L M A N [1977]. „Monotone data flow analysis frameworks", Acta Informatica 7:3, 305-318. KAPŁAN M. AND J. D . U L L M A N [1980]. „A generał scheme for the automatic inference of variable types", J. ACM 27:1, 128-145. KASAMI T. [1965]. „An efficient recognition and syntax analysis algorithm for context-free languages", AFCRL-65-758, Air Force Cambridge Research Laboratory, Bedford, Mass. K A S A M I T . , W . W . P E T E R S O N A N D N. T O K U R A [1973]. „On the capabilities of while,
repeat, and exit statements", Comm. ACM 16:8, 503-512. KASTENS U . [1980]. „Ordered attribute grammars", Acta Informatica K A S T E N S U . , B. H U T T A N D E. Z I M M E R M A N N [1982].
Generator,
GAG:
A
13:3, 229-256.
Practical
Compiler
Lecture Notes in Computer Science 141, Springer-Verlag, Berlin.
KASYANOV V. N. [1973]. „Some properties of fully reducible graphs", Information cessing Letters 2:4, 113-117.
Pro
KATAYAMA T. [1984]. „Translation of attribute grammars into procedures", TOPLAS 6:3, 345-369. KENNEDY K . [1971]. „A global flow analysis algorithm", Intern. Section A 3, 5-15.
J. Computer
Maik.
KENNEDY K . [1972]. „Index register allocation in straight line code and simple loops", in Rustin [1972], 51-64. KENNEDY K . [1976]. „A comparison of two algorithms for global flow analysis", SIAM J. Computing 5:1, 158-180. KENNEDY K . [1981]. „A survey of data flow analysis techniąues", in Muchnick and Jones [1981], 5-54. KENNEDY K . AND J. RAMANATHAN [1979]. „A deterministic attribute grammar evaluator based on dynamie sequencing", TOPLAS 1:1, 142-160. K E N N E D Y K . AND S. K . W A R R E N [1976]. „Automatic generation of efficient evaluators for attribute grammars", Third ACM Symposium on Principles of Programming Lan guages, 32-49.
KERNIGHAN B . W. [1975]. „Ratfor — a preprocessor for a rational Fortran", Software — Practice and Experience 5:4, 395-406. KERNIGHAN B. W. [1982]. „PIC — a language for typesetting graphics", — Practice and Experience 12:1, 1-21.
Software
KERNIGHAN B. W. A N D L. L. CHERRY [1975]. „ A system for typesetting mathematics",
Comm. ACM 18:3, 151-157.
PiKE [1984]. The UNIX Programming ce-Hall, Englewood Cliffs, N. J.
KERNIGHAN B . W. A N D R.
KERNIGHAN B. W. A N D D . M .
Environment,
Prenti
RlTCHlE [1978]. T h e C Programming Language, Prenti
ce-Hall, Englewood Cliffs, N. J.* KILDALL G . [1973]. „ A unified approach to global program optimization", ACM Sympo sium on Principles of Programming Languages, 194-206. KLEENE S. C. [1956]. „Representation of events in nerve nets", in Shannon and McCar thy [1956], 3-40. KNUTH D . E. [1962]. „A history of writing compilers", Computers and Automation (December) 8-18. Reprinted in Pollack [1972], 38-56. KNUTH D. E. [1964]. „Backus Normal Form vs. Backus Naur Form", Comm. ACM 7:12, 735-736. KNUTH D. E. [1965]. „On the translation of languages from left to right", and Control 8:6, 607-639. KNUTH D . E. [1968]. „Semantics of context-free languages", Mathematical ory 2:2, 127-145. Errata 5:1 (1971) 95-96. K N U T H D . E. [1971a]. „Top-down syntax analysis", Acta Informatica
Information
Systems
The
1:2, 79-110.
KNUTH D. E. [197 l b ] . „An empirical study of FORTRAN programs", Software — Prac tice and Experience 1:2, 105-133. KNUTH D . E. [1973a]. The Art of Computer Programming: Vol. 1, 2nd. Ed., Fundamental Algorithms, Addison-Wesley, Reading, Mass**. KNUTH D. E. [1973b]. T h e Art of Computer Programming: Vol. 3, Sorting and Searching, Addison-Wesley, Reading, Mass***. KNUTH D. E. [1977]. „A generalization of Dijkstra's algorithm", Information Letters 6, 1-5.
Processing
K N U T H D. E. [1984a]. The TgKbook, Addison-Wesley, Reading, Mass.
* Nakładem Wydawnictw Naukowo-Technicznych ukazało się w 2000 roku polskie tłumaczenie 2. wydania tej książki: Język ANSIC. Wyd. 5 (przyp. red.). ** Nakładem Wydawnictw Naukowo-Technicznych ukazało się w 2001 roku polskie tłumaczenie 3. wydania tej
książki: Sztuka programowania. T. 1. Algorytmy podstawowe (przyp. red.). *** Nakładem Wydawnictw Naukowo-Technicznych ukazało się w 2001 roku polskie tłumaczenie 2. wydania tej
książki: Sztuka programowania. T. 3. Sortowanie i wyszukiwanie (przyp. red.).
KNUTH D. E. [1984b]. „Literate programming", Computer J. 2 8 : 2 , 97-111. KNUTH D. E. [1985, 1986]. Computers and Typesetting, Vol. 1: T$C Addison-Wesley, Reading, Mass. A preliminary version has been published under the title, TgX: The Program. t
K N U T H D . E., J . H . MORRIS AND V. R. PRATT [1977]. „Fast pattern matching in strings", SIAM J. Computing 6 : 2 , 323-350. KNUTH D. E. AND L . T R A B B PARDO [1977]. „Early development of programming lan guages", Encyclopedia of Computer Science and Technology 7 , Marcel Dekker, New York, 419-493. KORENJAK A. J . [1969]. „A practical method for constructing LR(fc) processors", Comm. ACM
1 2 : 1 1 , 613-623.
KOSARAJU S. R . [1974]. „Analysis of structured programs", J. Computer Sciences 9 : 3 , 232-255.
and
System
KOSKIMIES K. AND K.-J. RAIHA [1983]. „Modelling of space-efficient one-pass transla tion using attribute grammars", Software — Practice and Experience 1 3 , 119-129. KOSTER C. H . A. [1971]. „Affix grammars", in Peck [1971], 95-109. Kou
L . [1977]. „On live-dead analysis for global data flow problems", J. ACM 2 4 : 3 , 473-483.
KRISTENSEN B . B . AND O. L . M A D S E N [1981]. „Methods for computing LALRf/cJ lookahead", TOPLAS 3 : 1 , 60-82. KRON H . [1975]. Tree Templates and Subtree Transformational Univ. of California, Santa Cruz.
Grammars, Ph. D. Thesis,
L A L O N D E W. R. [1971]. „An efficient LALR parser generator", Tech. Rep. 2, Computer Systems Research Group, Univ. of Toronto. L A L O N D E W. R. [1976]. „On directly constructing LRffc) parsers without chain reductions", Third ACM Symposium on Principles of Programming Languages, 127-133. L A L O N D E W. R., E. S. L E E AND J . J . HORNING [1971]. „An LALRffcJ parser generator", Proc. IFIP Congress 71 T A - 3 , North-Holland, Amsterdam, 153-157. LAMB D. A. [1981]. „Construction of a peephole optimizer", Software — Practice Experience 1 1 , 638-647. LAMPSON B . W. [1982]. „Fast procedurę calls", ACM SIGPLAN
Notices
and
1 7 : 4 (April),
66-76. LANDIN P. J . [1964]. „The mechanical evaIuation of expressions", Computer
J. 6 : 4 ,
308-320. LECARME O. AND M.-C. PEYROLLE-THOMAS [1978]. „Self-compiling compilers: an appraisal of their implementation and portability", Software — Practice and Experience 8 , 149-170.
L E D G A R D H. F . [1971]. „Ten mini-languages: a study of topical issues in programming languages", Computing Surveys 3:3, 115-146. LEINIUS R . R [1970]. Error Detection and Recovery for Syntax Systems, Ph. D. Thesis, University of Wisconsin, Madison.
Directed
Compiler
LENGAUER T . AND R. E. TARJAN [1979]. „A fast algorithm for finding dominators in a flowgraph", TOPLAS 1, 121-141. L E S K M. E. [1975]. „Lex — a lexical analyzer generator", Computing Science Technical Report 39, A T & T Bell Laboratories, Murray Hill, N . J . LEVERETT B. W. [1982]. „Topics in code generation and register allocation", C M U CS-82-130, Computer Science Dept., Carnegie-Mellon Univ., Pittsburgh, Pennsylvania. L E V E R E T T B. W., R. G . G . C A T T E L L , S. O . H O B B S , J. M. N E W C O M E R , A. H. R E I N E R ,
B. R. SCHATZ AND W. A. W U L F [1980]. „An overview of the production-ąuality compiler-compiler project", Computer 13:8, 38-40. L E V E R E T T B. W. AND T G . SZYMAŃSKI [1980]. „Chaining span-dependentjump instructions", TOPLAS 2:3, 274-289. L E V Y J . P. [1975]. „Automatic correction of syntax errors in programming languages", Acta Informatica 4, 271-292. L E W I S P. M., I I , D. J . R O S E N K R A N T Z A N D R. E. S T E A R N S [1974].
lations", J. Computer
„Attributed
trans-
Compiler
Design
and System Sciences 9:3, 279-307.
L E W I S P. M., I I , D. J . R O S E N K R A N T Z AND R. E. S T E A R N S [1976].
Theory, Addison-Wesley, Reading, Mass. L E W I S P. M., I I AND R. E. STEARNS [1968]. „Syntax-directed transduction", J. ACM 15:3, 465-488. L O R H O B. [1977]. „Semantic attribute processing in the system Delta", in Ershov and Koster [1977], 21-40. L O R H O B. [1984]. Methods and Tools for Compiler Construction,
Cambridge Univ. Press.
L O R H O B. AND C. P A I R [1975]. „Algorithms for checking consistency of attribute gra mmars", in Huet and Kahn [1975], 29-54. Low
J. AND P. R0VNER [1976]. „Techniąues for the automatic selection of data structures", Third ACM Symposium on Principles of Programming Languages, 58-67.
LOWRY E. S. A N D C. W. M E D L O C K [1969]. „Object code optimization", Comm. ACM 12, 13-22. LUCAS P. [1961]. „The structure of formuła translators", Elektronische 159-166.
Rechenanlagen
3,
L U N D E A. [1977]. „Empirical evaluation of some features of instruction set processor architectures", Comm. ACM 20:3, 143-153.
L U N E L L H. [ 1 9 8 3 ] . Code Generator sity, Linkoping, Sweden.
Writing Systems,
Ph. D . Thesis, Linkoping Univer-
M A C Q U E E N D . B., G. P. PLOTKIN A N D R. S E T H I [ 1 9 8 4 ] . „An ideał model of recursive
polymorphic types", Eleventh mming Languages, 1 6 5 - 1 7 4 .
Annual
ACM Symposium
on Principles
of
Progra
M A D S E N O. L. [ 1 9 8 0 ] . „On defining semantics by means of extended attribute gra mmars", in Jones [ 1 9 8 0 ] , 2 5 9 - 2 9 9 .
M A R I L L T. [ 1 9 6 2 ] . „Computational chains and the simplification of computer programs", IRE Trans. Electronic Computers E C - 1 1 : 2 , 1 7 3 - 1 8 0 . M A R T E L L I A. A N D U. M O N T A N A R I [ 1 9 8 2 ] . TOPLAS
„An
efficient
unification
algorithm",
4:2, 2 5 8 - 2 8 2 .
M A U N E Y J. A N D C. N. FISCHER [ 1 9 8 2 ] . „A forward move algorithm for L L and LR par
sers", ACM SIGPLAN M A Y O H B. H. [ 1 9 8 1 ] , J. Computing
Notices
„Attribute
17:4, 7 9 - 8 7 . grammars
and
mathematical
semantics",
MCCARTHY J. [ 1 9 6 3 ] . „Towards a mathematical science of computation", Processing 1962, North-Holland, Amsterdam, 2 1 - 2 8 . M C C A R T H Y J. [ 1 9 8 1 ] . „History of Lisp", in Wexelblat [ 1 9 8 1 ] ,
Information
173-185.
MCCLURE R. M. [ 1 9 6 5 ] . „TMG — a syntax-directed compiler", Proc. National
SIAM
10:3, 5 0 3 - 5 1 8 .
20th
ACM
Conf, 2 6 2 - 2 7 4 .
McCRACKEN N. J. [ 1 9 7 9 ] . An Investigation of a Programming Language with a Poly morphic Type Structure, Ph. D . Thesis, Syracuse University, Syracuse, N. Y. McCULLOUGH W. S. A N D W. PlTTS [ 1 9 4 3 ] . „A logical calculus of the ideas immanent in nervous activity", Bulletin of Math. Biophysics
5, 1 1 5 - 1 3 3 .
MCKEEMAN W. M. [ 1 9 6 5 ] . „Peephole optimization", Comm. ACM 8:7, 4 4 3 - 4 4 4 . M C K E E M A N W. M. [ 1 9 7 6 ] .
„Symbol
table
access",
in Bauer
and Eickel
[1976],
253-301.
MCKEEMAN W. M., J. J. H O R N I N G AND D . B. W O R T M A N
[ 1 9 7 0 ] . A Compiler
Genera
tor, Prentice-Hall, Englewood Cliffs, N. J. M C N A U G H T O N R. AND H. Y A M A D A [ 1 9 6 0 ] . „Regular expressions and state graphs for
automata", IRE Trans, on Electronic
Computers
EC-9:1, 38-47.
MEERTENS L. [ 1 9 8 3 ] . „Incremental polymorphic type checking in B " , Tenth ACM Sym posium on Principles of Programming Languages, 2 6 5 - 2 7 5 . M E T C A L F M. [ 1 9 8 2 ] . Fortran Optimization,
Academic Press, New York.
MILLER R. E. A N D J. W. THATCHER (EDS.) [ 1 9 7 2 ] . Complexity
tions, Academic Press, New York.
of Computer
Computa-
M I L N E R R. [1978]. „A theory of type polymorphism in programming", J. Computer System Sciences 17:3, 348-375. M I L N E R R. [1984]. „A proposal for standard ML", ACM Symposium tional Programming, 184-197.
and
on Lisp and Func-
M I N K E R J. AND R. G. M I N K E R [1980]. „Optimization of boolean expressions — historical developments", A. of the History of Computing 2:3, 227-238. M I T C H E L L J. C. [1984]. „Coercion and type inference", Eleventh ACM Symposium Principles of Programming Languages, 175-185.
on
M O O R E E. F. [1956], „Gedanken experiments in seąuential machines", in Shannon and McCarthy [1956], 129-153. M O R E L E. AND C. RENVOISE [1979]. „Global optimization by suppression of partial redundancies", Comm. ACM 22, 96-103. M O R R I S J. H. [1968a]. Lambda-Calculus Thesis, MIT, Cambridge, Mass.
Models
of Programming
Languages,
Ph. D.
M O R R I S R. [1968b]. „Scatter storage techniąues", Comm. ACM 11:1, 38-43. M O S E S J. [1970]. „The function of F U N C T I O N in Lisp", SIGSAM 13-27.
Bulletin
15 (July),
M O U L T O N P. G. A N D M. E. M U L L E R [1967]. „DITRAN — a compiler emphasizing diagnostics", Comm. ACM 10:1, 52-54. M U C H N I C K S. S. A N D N. D. JONES [1981]. Program Flow Applications, Prentice-Hall, Englewood Cliffs, N. J.
Analysis:
Theory
and
NAKATA I. [1967]. „On compiling algorithms for arithmetic expressions", Comm. ACM 10:8, 492-494. N A U R P. (ED.) [1963]. „Revised report on the algorithmic language Algol 60", Comm. ACM 6:1, 1-17. N A U R P. [1965]. „Checking of operand types in Algol compilers", BIT 5, 151-163. NAUR P. [1981]. „The European side of the last phase of the development of Algol 6 0 " , in Wexelblat [1981], 92-139, 147-161. N E W E Y M. C , P. C. P O O L E A N D W. M. W A I T E [1972]. „Abstract machinę modelling to
produce portable software — a review and evaluation", Software Experience 2:2, 107-136.
— Practice
and
N E W E Y M. C. AND W. M. W A I T E [1985]. „The robust implementation of seąuence-controlled iteration", Software — Practice and Experience 15:7, 655-668. NiCHOLLS J. E. [1975]. The Structure and Design of Programming -Wesley, Reading, Mass.
Languages,
Addison-
NIEVERGELT J. [1965]. „On the automatic simplification of computercode", Comm. ACM 8:6, 366-370.
N O R I K. V . , U. A M M A N N , K. JENSEN, H. H. N A G E L I A N D C H . JACOBI [1981]. „Pascal P
implementation notes", in Barron [1981], 125-170. OSTERWEIL L. J. [1981]. „Using data flow tools in software engineering", in Muchnick and Jones [1981], 237-263. PAGER D . [1977a]. „A practical generał method for constructing LR(k) parsers", Acta Informatica 7, 249-268. PAGER D . [1977b]. „Eliminating unit productions from LR(k) parsers", Acta 9, 31-59.
Informatica
PAI A. B . AND R. B . KlEBURTZ [1980]. „Global context recovery: a new strategy for syntactic error recovery by table-driven parsers", TOPLAS 2:1, 18-41. PAIGE R. AND J. T. SCHWARTZ [1977]. „Expression continuity and the formal differentiation of algorithms", Fourth ACM Symposium on Principles of Programming Lan guages, 58-71. PALM R. C , JR. [1975]. „A portable optimizer for the language C", M . Sc. Thesis, MIT, Cambridge, Mass. P A R K J. C. H., K. M . C H O E A N D C. H. C H A N G [1985]. „A new analysis of L A L R for-
malisms", TOPLAS 7:1, 159-175. PATERSON M . S. AND M . W E G M A N [1978]. „Linear unification", J. Computer tem Sciences 16:2, 158-167. P E C K J. E. L. [1971]. Algol 68 Implementation,
and Sys
North-Holland, Amsterdam.
PENNELLO T. AND F . D E R E M E R [1978]. „A forward move algorithm for L R error recovery", Fifth Annual ACM Symposium on Principles of Programming Languages, 241-254. P E N N E L L O T , F . D E R E M E R A N D R. M E Y E R S [1980]. „A simplified operator identifica¬
tion scheme for Ada", ACM SIGPLAN
Notices 15:7 (July-August), 82-87.
P E R S C H G., G. W I N T E R S T E I N , M . D A U S S M A N N A N D S. D R O S S O P O U L O U [1980].
loading 47-56.
in preliminary
Ada", ACM SIGPLAN
Notices
„Over-
15:11 (November),
PETERSON W . W . [1957]. „Addressing for random access storage", IBM J. Research and Development 1:2, 130-146. POLLACK B . W . [1972]. Compiler
Techniąues,
Auerbach Publishers, Princeton, N. J.
POLLOCK L. L. A N D M . L. SOFFA [1985]. „Incremental compilation of locally optimized code", Twelfth Annual ACM Symposium on Principles of Programming Languages, 152-164. POWELL M. L. [1984]. „A portable SIGPLAN Notices 19:6, 310-318.
optimizing
compiler
for Modula-2", ACM
PRATT T. W . [1984]. Programming Languages: Prentice-Hall, Englewood Cliffs, N. J.
Design
and Implementation,
PRATT V. R . [1973]. „Top down operator precedence", ACM Symposium of Programming Languages, 41-51. PRICE C. E. [1971]. „Table lookup techniąues", Computing
2nd Ed.,
on
Principles
Surveys 3:2, 49-65.
PROSSER R. T. [1959]. „Applications of boolean matrices to the analysis of flow diagrams", AFIPS Eastern Joint Computer Confi, Spartan Books, Baltimore, Md., 133-138. PURDOM P. AND C. A. B R O W N [1980]. „Semantic routines and LR(k) Informatica 14:4, 299-315.
parsers",
PURDOM P. W . AND E. F. M O O R E [1972]. „Immediate graph", Comm. ACM 15:8, 777-778.
in
predominators
a
Acta
directed
RABIN M. O. AND D. S C O T T [1959]. „Finite automata and their decision problems", 7 # M J. Research and Development 3:2, 114-125. RADIN G. AND H. P. ROGOWAY [1965]. „NPL: Highlights language", Comm. ACM 8:1, 9-17.
of a new
programming
RAIHA K.-J. [1981]. A Space Management Techniąue for Multi-Pass Attribute Evaluators, Ph. D. Thesis, Report A-1981-4, Dept. of Computer Science, University of Helsinki. RAIHA K.-J. AND M. SAARINEN [1982]. „Testing attribute grammars for circularity", Acta Informatica 17, 185-192. R A I H A K.-J., M. S A A R I N E N , M. SARJAKOSKI, S.
SIPPU,
E.
SOISALON-SOININEN AND
M. TlENARl [1983]. „Revised report on the compiler writing system HLP78", Report A-1983-1, Dept. of Computer Science, University of Helsinki. RANDELL B. AND L. J. RUSSELL [\96A]. Algol 60 Implementation, York.
Academic Press, New
REDZIEJOWSKI R. R. [1969]. „On arithmetic expressions and trees", Comm. ACM 12:2, 81-84. R E I F J. H. AND H. R. LEWIS [1977]. „Symbolic evaluation and the global value graph", Fourth ACM Symposium on Principles of Programming Languages, 104-118. REISS S. P. [1983]. „Generation of compiler symbol processing mechanisms from specifications", TOPLAS 5:2, 127-163. REPS T. W . [1984]. Generating Mass.
Language-Based
Environments,
M I T Press, Cambridge,
R E Y N O L D S J. C. [1985]. „Three approaches to type structure", Mathematical Foundations of Software Development, Lecture Notes in Computer Science 185, Springer-Yerlag, Berlin, 97-138.
RlCHARDS
M. [1971]. „The portability of the BCPL compiler", Software — Practice and
Experience
1 : 2 , 135-146.
M. [1977]. „The implementation of the BCPL compiler", in P. J . Brown (ED.). Software Portability: An Advanced Course, Cambridge University Press.
RlCHARDS
RIPKEN K. [1977]. „Formale beschreibun von maschinen, implementierungen und optimierender maschinen-codeerzeugung aus attributierten programmgraphe", TUM-INFO-7731, Institut fur Informatik, Universitat Munchen, Munich. RlPLEY
G. D.
AND
F. C.
puter Languages RlTCHlE
DRUSEIKIS
[1978]. „ A statistical analysis of syntax errors", Com
3 , 227-240.
D. M. [1979]. „A tour through the UNIX C compiler", AT&T Bell Laboratories,
Murray Hill, N. J .
RlTCHlE D. M .
AND
K.
THOMPSON
[1974]. „The UNIX time-sharing system",
Comm.
ACM 1 7 : 7 , 365-375. ROBERTSON E. L. [1979]. „Code generation and storage allocation for machines with span-dependent instructions", TOPLAS
1 : 1 , 71-83.
ROBINSON J . A. [1965]. „A machine-oriented logie based on the resolution principle", J. ACM 1 2 : 1 , 23-41. ROHL J . S. [1975]. An Introduction
to Compiler
Writing, American Elsevier, New York.
ROHRICH J . [1980]. „Methods for the automatic construction of error correcting parsers", Acta Informatica
1 3 : 2 , 115-139.
ROSEN B. K. [1977]. „High-level data flow analysis", Comm. ACM 2 0 , 712-724. ROSEN B. K. [1980]. „Monoids for rapid data flow analysis", SIAM J. Computing 9 : 1 , 159-196. ROSEN S. [1967]. Programming Systems and Languages, McGraw-Hill, New York. ROSENKRANTZ D . J . AND R. E. STEARNS [1970]. „Properties of deterministic top-down grammars", Information
and Control 1 7 : 3 , 226-256.
ROSLER L. [1984]. „The evolution of C — past and futurę", AT&T Bell Labs
Technical
Journal 6 3 : 8 , 1685-1699. RUSTIN R. [1972], Design
and Optimization
of Compilers,
Prentice-Hall, Englewood
Cliffs, N. J. RYDER B . G. [1979]. „Constructing the cali graph of a program", IEEE Trans. Engineering
Software
S E - 5 : 3 , 216-226.
RYDER B . G. [1983]. „Incremental data flow analysis", Tenth ACM Symposium ciples of Programming
Languages,
on Prin
167-176.
SAARINEN M. [1978]. „On constructing efficient evaluators for attribute grammars", Automata, Languages and Programming, Fifth Colloąuium, Lecture Notes in Com puter Science 6 2 , Springer-Yerlag, Berlin, 382-397.
S A M E L S O N K . A N D F . L. B A U E R [1960]. „Seąuential formuła translation", Comm. ACM 3:2, 76-83. S A N K O F F D. AND J. B . K R U S K A L (EDS.) [1983]. Time Warps, String Edits, and Macromolecules: The Theory and Practice of Seąuence Comparison, Addison-Wesley, Reading, Mass. SCARBOROUGH R. G. A N D H. G. K O L S K Y [1980]. „Improved optimization of Fortran object programs", IBM J. Research and Development 24:6, 660-676. SCHAEFER M. [1973]. A Mathematical Hall, Englewood Cliffs, N. J.
Theory of Global Program Optimization,
Prentice
SCHONBERG E., J. T. S C H W A R T Z A N D M. S H A R I R [1981]. „An automatic techniąue for
selection of data representations in SETL Programs", TOPLAS
3:2, 126-143.
SCHORRE D. V. [1964]. „Meta-II: a syntax-oriented compiler writing language", Proc. 19th ACM National Conf, D l . 3 - 1 - D l . 3 - 1 1 . SCHWARTZ J. T. [1973]. On Programming: Courant Inst., New York.
An Interim
Report
on the SETL
Project,
SCHWARTZ J. T. [1975a]. „Automatic data structure choice in a language of very high level", Comm. ACM 18:12, 722-728. SCHWARTZ J. T. [1975b]. „Optimization of very high level languages", Computer Languages. Part I : „Value transmission and its corollaries", 1:2, 161-194; part I I : „Deducing relationships of inclusion and membership", 1:3, 197-218. SEDGEWICK R. [1978]. 847-857.
„Implementing
Quicksort
programs",
Comm.
SETHI R. [1975]. „Complete register allocation problems", SIAM 226-248.
ACM
J. Computing
21,
4:3,
SETHI R. AND J. D. U L L M A N [1970]. „The generation of optimal code for arithmetic expressions", J. ACM 17:4, 715-728. S H A N N O N C . A N D J . M C C A R T H Y [1956]. Press.
Automata
Studies,
Princeton
University
SHERIDAN P. B. [1959]. „The arithmetic translator-compiler of the I B M Fortran automa tic coding system", Comm. ACM 2:2, 9 - 2 1 . SHIMASAKI M., S . FUKAYA, K . IKEDA A N D T K I Y O N O [1980]. „An analysis of Pascal
programs in compiler 149-157.
writing", Software
— Practice
and
Experience
10:2,
SHUSTEK L. J. [1978]. „Analysis and performance of computer instruction sets", SLAC Report 205, Stanford Linear Accelerator Center, Stanford University, Stanford, California. SIPPU S . [1981]. „Syntax error handling in compilers", Rep. A-1981-1, Dept. of Com puter Science, Univ. of Helsinki, Helsinki, Finland.
SIPPU S . AND E. SOISALON-SOININEN [1983]. „A syntax-error-handling and its experimental analysis", TOPLAS 5 : 4 , 656-679.
techniąue
SOISALON-SOININEN E. [1980]. „On the space optimizing efFect of eliminating single productions from LR parsers", Acta Informatica 1 4 , 157-174. SoiSALON-SorNiNEN E . A N D E. U K K O N E N [1979]. „A method for transforming grammars into LL(/c) form", Acta Informatica 1 2 , 339-369. SPILLMAN T . C . [1971]. „Exposing side effects in a PL/I optimizing compiler", tion Processing 71, North-Holland, Amsterdam, 376-381. STEARNS R. E. [1971]. „Deterministic top-down parsing", Proc. 5th Annual Conf. on Information Sciences and Systems, 182-188. S T E E L T . B . , Jr. [1961]. „A first version of Uncol", Western Joint Computer 371-378.
Informa
Princeton
Conference,
STEELE G. L., Jr. [1984]. Common LISP, Digital Press, Burlington, Mass. STOCKHAUSEN P. F. [1973], „Adapting optimal code generation for arithmetic expressions to the instruction sets available on present-day computers", Comm. ACM 1 6 : 6 , 353-354. Errata: 1 7 : 1 0 (1974), 5 9 1 . STONEBRAKER M., E. W O N G , P. K R E P S A N D G. H E L D [1976]. „The design and imple-
mentation of INGRES", ACM Trans. Database
Systems 1 : 3 , 189-222.
S T R O N G J . , J. W E G S T E I N , A. T R I T T E R , J. O L S Z T Y N , O. M O C K A N D T . S T E E L [1958].
„The problem of programming communication with changing machines: a proposed solution", Comm. ACM 1 : 8 (August), 12-18. Part 2: 1 : 9 (September), 9-15. Report of the Share Ad-Hoc committee on Universal Languages. S T R O U S T R U P B . [1986]. T h e C + + Programming Language, Addison-Wesley, Reading, Mass*. SUZUKI N. [1981]. „Inferring types in Smalltalk", Eighth ACM Symposium of Programming Languages, 187-199.
on
Principles
S U Z U K I N. A N D K. ISHIHATA [1977]. „Implementation of array bound checker", Fourth ACM Symposium on Principles of Programming Languages, 132-143. SZYMAŃSKI T . G. [1978]. „Assembling code for machines with span-dependent instructions", Comm. ACM 2 1 : 4 , 300-308. T A I K. C. [1978]. „Syntactic error correction in programming languages", IEEE Software Engineering S E - 4 : 5 , 414-425.
Trans.
T A N E N B A U M A . S., H. VAN STAVEREN, E. G. K E I Z E R A N D J. W. S T E V E N S O N [1983].
„A practical tool kit for making portable compilers", Comm. ACM 2 6 : 9 , 654-660.
* Nakładem Wydawnictw Naukowo-Technicznych ukazało się w 2000 roku polskie tłumaczenie 2. wydania tej książki: Język C++. Wyd. 5 (przyp. red.).
T A N E N B A U M A. S., H. VAN STAVEREN A N D J. W. S T E V E N S O N [1982]. „Using peephole
optimization on intermediate code", TOPLAS 4 : 1 , 21-36. T A N T Z E N R. G. [1963]. „Algorithm 199: Conversions between calendar date and Julian day number", Comm. ACM 6:8, 4 4 3 . T A R H I O J. [1982]. „Attribute evaluation during LR parsing", Report A-1982-4, Dept. of Computer Science, University of Helsinki. TARJAN R. E. [1974a]. „Finding dominators in directed graphs", SIAMJ. 62-89.
Computing 3:1,
TARJAN R. E. [1974b]. „Testing flow graph reducibility", J. Computer and System ces 9:3, 355-365.
Scien
TARJAN R. E. [1975]. „Efrlciency of a good but not linear set union algorithm", JACM 22:2, 215-225. TARJAN R. E. [1981], „A unifled approach to path problems", J. ACM 28:3, 577-593. And „Fast algorithms for solving path problems", J. ACM 28:3, 594-614. TARJAN R. E. AND A. C. Y A O [1979]. „Storing a sparse table", Comm. ACM 22:11, 606-611. TENNENBAUM A. M. [1974]. „Type determination in very high NSO-3, Courant Institute of Math. Sciences, New York Univ. T E N N E N T R. D. [1981]. Principles nal, Englewood Cliffs, N. J.
of Programming
level
languages",
Languages, Prentice-Hall Internatio
T H O M P S O N K. [1968]. „Regular expression search algorithm", Comm. A C M 11:6, 419-422. T J I A N G S. W. K. [1986]. „Twig language manuał", Computing Science Technical Report 120, A T & T Bell Laboratories, Murray Hill, N. J. TOKUDA T . [1981]. „Eliminating unit reductions from LR(£) parsers using minimum contexts", Acta Informatica 15, 447-470. TRICKEY H. W. [1985]. Compiling Pascal Programs into Silicon, Ph. D. Thesis, Stanford Univ. U L L M A N J. D. [1973]. „Fast algorithms for the elimination of common subexpressions". Acta Informatica 2, 191-213. U L L M A N J. D. [1982]. Principles RockviIle, Md.
ofDatabase
U L L M A N J. D. [1984]. Computational Rockville, Md.
Systems, 2nd Ed., Computer Science Press,
Aspects
of VLSI,
Computer
Science
Press,
VYSSOTSKY V . AND P. W E G N E R [1963]. „A graph theoretical Fortran source language analyzer", manuscript, AT&T Bell Laboratories, Murray Hill, N. J.
W A G N E R R . A . [1974]. „Order-n correction for regular languages", Comm. ACM 16:5, 265-268. W A G N E R R . A. A N D M . J . FISCHER [1974]. „The stxing-to-string correction problem", J. ACM 21:1, 168-174. W A I T E W. M . [1976a]. „Code generation", in Bauer and Eickeł [1976], 302-332. W A I T E W. M . [1976b]. „Optimization", in Bauer and Eickel [1976], 549-602. W A I T E W. M . AND L. R . C A R T E R [1985]. „The cost of a generated parser", ware — Practice and Experience 15:3, 221-237.
Soft
W A S I L E W S. G . [1971]. A Compiler Writing System with Optimization Capabilities Complex Order Structures, Ph. D. Thesis, Northwestern Univ., Evanston, 111. W A T T D. A. [1977]. „The parsing problem for affix grammars", Acta Informatica
for
8, 1-20.
W E G B R E I T B. [1974]. „The treatment of data types in E L I " , Comm. ACM 17:5, 251-264. W E G B R E I T B. [1975]. „Property extraction in well-founded property sets", IEEE on Software Engineering 1:3, 270-285.
Trans,
W E G M A N M . N. [1983]. „Summarizing graphs by regular expressions", Tenth ACM Symposium on Principles of Programming Languages, 203-216.
Annual
W E G M A N M . N. A N D F . K . Z A D E C K [1985]. „Constant propagation with conditional branches", Twelfth Annual ACM Symposium on Principles of Programming Lan guages, 291-299. W E G S T E I N J . H. [1981]. „Notes on Algol 60", in Wexelblat [1981], 126-127. W E I H L W. E. [1980]. „Interprocedural data How analysis in the presence of pointers, procedurę variables, and label variables", Seventh Annual ACM Symposium on Prin ciples of Programming Languages, 83-94. WEINGART S. W. [1973]. An Efficient and Systematic Method Ph. D. Thesis, Yale University, New Haven, Connecticut.
of Code
Generation,
W E L S H J . , W. J. SNEERINGER A N D C. A. R . H O A R E [1977]. „Ambiguities and insecuri-
ties in Pascal", Software — Practice and Experience W E X E L B L A T R . L. [1981]. History York.
of Programming
7:6, 685-696.
Languages,
Academic Press, New
W I R T H N. [1968]. „PL 360 — a programming language for the 360 computers", J. ACM 15:1, 37-74. WlRTH N. [1971]. „The design of a Pascal compiler", Software perience 1:4, 309-333.
— Practice
and Ex-
WlRTH N. [1981]. „Pascal-S: A subset and its implementation", in Barron [1981], 199-259.
WiRTH N. AND H . W E B E R [1966]. „Euler: a generalization of Algol and its formal definition: Part I " , Comm. ACM 9 : 1 , 13-23. WOOD D . [1969]. „The theory of left factored languages", Computer J. 12:4, 349-356. W U L F W. A.,
R. K. J O H N S S O N ,
C. B . W E I N S T O C K ,
C. M . G E S C H K E [1975]. The Design of an Optimizing New York.
S. O. H O B B S
AND
Compiler, American Elsevier,
YANNAKAKIS M . [1985]. Private communication. Y O U N G E R D . H . [1967]. „Recognition and parsing of time n " , Information and Control 10:2, 189-208.
context-free
languages
in
3
Z E L K O W I T Z M . V. AND W. G . B A I L [1974]. „Optimization Software — Practice and Experience 4 : 1 , 51-57.
of
structured
programs",
Skorowidz
Abelson H. 436 Ada 325,341,344-345,389 Adres powrotu 385, 492-497 - względny, zob. Przesunięcie Adresowanie indeksowane 490 - pośrednie 491 - rejestrowe 490 Adrion W. R. 682 Aho A. V. 148, 172, 194, 262-264, 278, 371, 420-421, 437, 536, 541, 551-552, 556, 681 Aigrain P. 552 Akceptacja 109-110 Akceptowanie 189 Akcja semantyczna 36, 247 Aktywacja 368 Alfabet 88 - binarny 88 Algol 20, 22, 76, 77, 82, 148, 262, 366, 405, 436, 483, 530 Algorytm Cocke' a-Youngera-Kasamiego 151, 262 - Earleya 151, 262 - KMP 143-144, 149 - Knutha-Morrisa-Pratta, zob. Algorytm KMP Allen F. E. 679-681 Alternatywa 158 Ammann U. 78, 551, 688, 693-694 Analiza 2-9, zob. także: Analiza leksykalna, Analiza składniowa - hierarchiczna 5, zob. także: Analiza składniowa - LALR 205, 224-232, 241, 263, zob. także: Yacc - leksykalna 5, 11, 25, 51-57, 67, 79-149, 151, 162-163, 247, 250-251, 697
Analiza liniowa 5, zob. także: Analiza leksykalna - LR 204-253, 547 - przedziałów 590, 624, 632, 680, zob. także: Analiza T1-T2 - przepływu danych 555, 576-682 w obecności wielu procedur 617-624 -przewidująca 41-46, 173-179, 183-185, 205, 286-293 -redukująca 188-193, 196, zob. także: Analiza LR, Metoda pierwszeństwa operatorów - semantyczna 5, 7-8 - składniowa 5-7, 13, 28, 38-46, 54, 68, 80-81, 150-264, 159-278, zob. także: Analiza wstępująca, Analiza zstępująca, Metoda ograniczonego kontekstu - sterowana tablicami 176, 180-182, 205-209, zob. także: Analiza LALR, Analiza SLR, Kanoniczna analiza LR, Metoda pierwszeństwa operatorów - S L R 205, 210-218, 241, 263 - T 1 - T 2 632, 637-642 - wstępująca 39, 185, 276, 279-281, 293-300, 438, zob. także: Analiza LR, Analiza redukująca, Metoda pierwszeństwa operatorów - zstępująca 39-46, 166, 172-195, 286, 438, zob. także: Analiza przewidująca, Metoda zejść rekurencyjnych Anderson J. P. 552 Anderson T. 263 Anklam P. 680 APL 3,366,389,657 Arden B . W. 437 ASCII 56
Asembler dwuprzebiegowy 17 Atrybut 10, 31, 83, 247, 266, zob. także: Atrybut dziedziczony, Atrybut syntezowany, Definicja sterowana składnią, Wartość leksykalna - dziedziczony 32, 266, 268-269, 284, 293-300, 308, 323, zob. także: Atrybut - syntezowany 32, 266-268, 283-284, 300, 308, zob. także: Atrybut Auslander M. A. 551, 680 Automat skończony 107-136, zob. także: Diagram przejść A W K 79, 149, 684
Branstad M. A. 682 Bratman H. 685 Brooker R. A. 323 Brooks F. P. 685 Brosgol B. M. 323 Brown C. A. 323 Bruno J. L. 536, 552 Bufor 54, 58, 84-87, 122 -wejściowy 85, 106-107, 127 Burstall R. M. 364 Busam V. A. 680
C
49, 99-100, 153, 310, 339-340, 345, 347, 374-376, 389, 391-392, 402, 418, 447, 456, 482, 512, 530, 557, 658, 685, 694-696 Cardelli L. 367 Backhouse R. C. 263, 682 Carter J. L. 690 Backus J. W. 2, 148, 366 Carter L. R. 551 Bail W. G. 680 Cartwright R. 367 Bajt 377 Cattell R. G. G. 552 Baker B. S. 680 CDC 6600 552 Baker T. P. 366 Chaitin G. J. 516, 551 Banning J. 681 Barron D. W. 78 Chang C. H. 263 Barth J. M. 681 Cherniavsky J. C. 680, 682 Bauer A. M. 366 Cherry L. L. 9, 149, 239, 692 Bauer F. L. 78, 323, 366 Choe K. M. 263 BCPL 482 Chomsky N. 77 Beatty J. C. 551-552 Chow F. 551, 680, 682 Begriffsschrift 437 Church A. 366, 437 Belady L. A. 551 Ciesinger J. 263 Bell J. R. 679 Cleveland W. S. 437 Bentley J. L. 341, 437, 556 CNF, zob. Postać normalna Chomsky'ego Biblioteka 4 Cocke J. 151, 262, 551, 679-682 Birman A. 263 Coffman E. G. 552 Bit przemieszczenia 17-18 Cohen R. 323 Bliss 462, 512, 530, 551, 575, 680, 699-701Common Lisp 436 Blok, zob. Blok bazowy, Blok Common, Conway M. E. 264 Struktura blokowa Conway R. W. 155, 263 - bazowy 498-503, 559, 566-570, 577, 666,Corasick M. J. 149 Cormack G. V. 148, 366 zob. także: Rozszerzony blok bazowy Courcelle B. 324 - Common 408, 422-424, 430 Cousot P. 681 Błąd leksykalny 83-84, 152 Cousot R. 681 - logiczny 152 CPL 366 - semantyczny 152, 330 -składniowy 152-156, 183-185, 189, 196, Curry H. B. 366-367 200-204, 207, 241-244, 251-253, 261, 263 Cutler D. 680 Cykl 167 Bochmann G. V. 323 - w grafie typu 339-340 Boyer R. S. 149 Czas istnienia aktywacji 370, 388 Branąuart P. 324, 483
Domknięcie zbioru sytuacji 211-212, 213, 219-220 edomknięcie 112-113, 213 Dopasowywanie wzorców 81, 122-125, 547-548, 552 Dostęp głęboki 400 Dag, zob. Skierowany graf acykliczny - płytki 400 DAS, zob. Deterministyczny automat Downey P. J. 367, 552 skończony Dane o zmiennej długości 384, 386-387, 391 Druseikis F. C. 153 Drzewo aktywacji 370-371 Date C. J. 4 - dominatorów 570 Davidson J. W. 482, 552 - rozpinające przeglądania w głąb 626 Decyzja konserwatywna 578 - składni abstrakcyjnej 46-47, Definicja 499, 577, 598 zob. także: Drzewo składniowe - L-atrybutowana 266, 282-303, 323 konkretnej 46-47 - niepewna 578 zob. także: Drzewo składniowe - osiągająca 578, 591-593, 617, 638-642, 647 składniowe 2, 7, 47, 273-276, 439, 445, - pewna 578 zob. także: Drzewo składni - procedury 368 abstrakcyjnej, Drzewo składni - regularna 91, 102 konkretnej, Drzewo wyprowadzenia - S-atrybutowana 267, 279-281 - trie 142, 144-145 - sterowana składnią 32, 265-272, - wyprowadzenia 6-7, 27-29, 40-41, 47, 151, zob. także: Drzewo wyprowadzenia 160-162, 186, 265, 282, z przypisami, Translacja sterowana zob. także: Drzewo składniowe, Szkieletowe składnią drzewo wyprowadzenia cykliczna 272, 317-319, 323, 324 - z przypisami 32, 266 silnie acykliczna 315-319, 323 Du-łańcuch 598-599 Deklaracje 255, 372-373, 447-451, 481 Durre K. 148 Dekorowanie, zob. Drzewo wyprowadzenia Dystrybuty wność 681 z przypisami Dziecko 27 DELTA 323 Dzielenie wierzchołków 630-631, 643 Demers A. J. 263 Dziura w widzialności 390 Dencker P. 148 Deransart P. 324 DeRemer F. 263, 366 Earley J. 172, 262-263, 682 Deskryptor adresów 507 EBCDIC 57 - rejestru 507 Edytor strukturalny 3 Despeyroux T. 367 - tekstowy 148 Deterministyczny automat skończony Efekt uboczny 266 107-108, 110-114, 121-122, 125-129, e g r e p 149 134-138, 142, 170-171, 206, 210, ELI 366 213-215 Element największy 647 - diagram przejść 95 Diagram przejść 94-100, 108, 173-176, 214, Elshoff J. L. 551 Engelfriet J. 324 zob. także: Automat skończony Englund D. E. 680 Display 397-399 EQN 9, 239-241, 285, 683-684, 686, 692-693 Ditzel D. 551 Equel 15 Dominator 570, 634-635, 681 Ershov A. P. 323, 551-552, 680 - bezpośredni 571 Etykieta 63-64, 441, 478, 486 Domkniecie 89-91, 117 Eve J. 263 - dodatnie 92, zob. także: Domknięcie
Czas istnienia atrybutu 304-306, 308-312 - życia zmiennej tymczasowej 453-454 Czwórki 445, 446-447
Galler B. A. 437 Faktoryzacja lewostronna 168-169 Ganapathi M. 551-552 Fang I. 323 Gannon J. D. 153 Farrow R. 324 Faza 9-10, zob. także: Analiza leksykalna, Ganzinger H. 324, 366 Analiza semantyczna, Analiza składniowa, Garey M. R. 552 GearC. W. 681 Generacja kodu, Kod pośredni, Obsługa Gen 576, 579-581, 593, 602 błędów, Optymalizacja kodu, Tablica Generacja kodu 14-15, 484-553, 695 symboli Generator analizatorów leksykalnych 21-22, Feldman S. I. 148, 482, 689 zob. także: Lex Feys R. 366 - składniowych 21, 690, fgrep 149 zob. także: Yacc FIRST 43, 179-180, 183 - kodu automatyczny 22 Fischer C. N. 263, 551-552 - wyniku translacji 64, 68 Fischer M. J. 149, 437 Generowanie odruchowe symbolu Fleck A. C. 405 podglądanego 229 Floyd R. W. 262, 552 Geschke C. M. 680 FOLDS 323 Giegerich R. 323, 483, 552 FOLLOW 179-180, 183, 218-219 Glanville R. S. 548, 552 Fong A. C. 682 Globalny przydział rejestrów 512 Forma zdaniowa 159 Głębokość grafu przepływu 628, 636, 676 - lewostronna 160 - przedziałowa 636 - prawostronna 160, 186 - zagnieżdżenia 393 Formater kodu programu 3 GNF, zob. Postać normalna Greibach - tekstu 3, 8-9 Graf, zob. Graf kolizji rejestrów, Graf Fortran 2, 82, 106-107, 148, 198, 366, 374, przedziałowy, Graf przejścia, Graf 379-382, 404, 408, 422-430, 455, 570, przepływu, Graf typu, Redukowalny graf 680, 683 przepływu, Skierowany graf acykliczny Fortran H 512, 551, 687, 696 - acykliczny, zob. Skierowany graf Fosdick L. D. 682 acykliczny Foster J. M. 78, 263 - kolizji rejestrów 515 Fragmentaryczność 420 - przedziałowy 630 Fraser C. W. 482, 552 przejścia 108 Fredman M. 148 - przepływu 498-499,502-504,516,559,570, Frege G. 437 zob. także: Redukowalny graf przepływu Freiburghouse R. A. 483, 551 - graniczny 630, 632 Freudenberger S. M. 680, 682 Funkcja, zob. Procedura - nieredukowalny 575, 643 - identycznościowa 646 - typu 329, 335, 338-340 - zależności powiększony 317 - mieszająca 411-416 Graham R. M. 262, 437 - podstawowa 345, Graham S. L. 263, 551-552, 680-681 zob. także: Funkcja polimorficzna Gramatyka afiksowa 323 - polimorficzna 326, 345-356 - atrybutywna 266, 548 - porażki 143, 145 - bezkontekstowa 24-28, 38-39, 77-78, - przejścia 108, 145 156-171, 266, zob. także: Gramatyka - przeniesienia 638, 644, 651 LL, Gramatyka LR, Gramatyka Funkcje priorytetów 198-199 operatorowa -LALR 226 - LL 151, 153, 181-182, 210, 256, 258, 263, GAG 324 291-293 Gajewska H. 682
Gramatyka LR 151, 153, 191-192, 209-210, Idempotentność 91, 647 Identyfikacja operatorów 342, 234, 258, 263, 293 zob. także: Przeciążanie - LR(1) 223 Identyfikator 53, 81-82, 170 - operatorowa 193-194, 257 Iloczyn, zob. Produkt kartezjański - SLR 216 I ngalls D. H. H. 367 - wolna od cykli 256 I nstancja typu polimorficznego 351 - wzbogacona 221 I n strukcja 25, 27, 31, 63-65, 333 - e-wolna 256 - break 588-590 Grau A. A. 483 - case 469-472 grep 149 - Data 380-382 - do 82, 106 - Equivalen.ce 408, 424-430 Haley C. B. 263 - goto 478 Halstead M. H. 482, 686 - if 106-107, 464-465, 476-477 Hanson D. R. 483 - kopiująca 442, 562 Harrison M. A. 263 - przypisania 62, 442, 452-461 Harrison M. C. 149 -Save 381-382 Harrison W. H. 551, 682 - Switch, zob. Instrukcja Case Harry E. 323 - while 464-465, 476-477 hashpjw 413-414 Interpreter 3-4 HechtM. S. 679-681 Heinen R. 680 - zapytań 4 Irons E. T. 78, 263, 322 Helsinki Language Processor, zob. HLP Ishihata K. 682 Henderson P. B. 680 Iteracyjne rozwiązywanie problemów Hennessy J. L. 551, 682 przepływu danych 590-599, Henry R. R. 551-552 636-637, 653-657 Heuft J. 148 Werson K. 366 Hext J. B. 366 Hill U. 483 Hindley R. 367 Jacobi Ch. 734 HLP 263,324 Janas J. M. 366 Hoare C. A. R. 78, 366 Jarvis J. F. 79, 149 Hoffman C. M. 552 J azayeri M. 324 Hopcroft J. E. 134, 148, 262, 278, 367, 371, Jądro zbioru sytuacji 212, 229 420-421, 437, 552, 556 LALR(l) 225 Hope 364 Jensen K. 704, 734 Hopkins M. E. 551, 680 Język 26, 88, 109, 159, 193 Horning J. J. 78, 153, 262 - bezkontekstowy 158, 162-163, 169-171 Horspool R. N. S. 148 - programowania, zob. Ada, Algol, APL, Horwitz L. P. 551 BCPL, Bliss, C, Cobol, CPL, ELI, Huet G. 552 Fortran, Lisp, ML, Modula, Neliac, Pascal, Huffman D. A. 148 PL/I, SETL, SIMPL, Snobol Hunt J. W. 149 - ściśle typowany 329 Huskey H. D. 482, 686 - wynikowy 1 HuttB. 324 - źródłowy 1 Johnson D. S. 552 Johnson S. C. 4, 148, 244, 263, 336, 363, IBM-370 488, 538-539, 552, 696, 699 437, 482, 536, 541, 552, 690, 694-696 IBM-7090 552 Johnson W. L. 148 Ichbiah J. D. 262
Johnsson R. K. 551 Joliat M. 263 Jones N. D. 323, 366, 679, 681 Jourdan M. 324 Joy W. N. 263
Kompilator kompilatorów 21 - krzemowy 4 - optymalizujący, zob. Optymalizacja kodu - skrośny 686 Kompresja, zob. Kodowanie typów - tabeli przejść 137-138, 142, 232-234 Kaiserwerth M. 148 Komunikaty o błędach 184, 200-204, 243-244 Kam J. B. 681 Konfiguracja 206 Kanoniczna analiza LR 218-224, 241 Konflikt, zob. Konflikt przesunięcie/redukcja, - rodzina zbiorów sytuacji 211, 213, 219-220 Konflikt redukcja/redukcja, - tablica analizatora LR 218-224 Reguła usuwania Kapłan M. A. 366, 682 niejednoznaczności Karp R. M. 367 - przesunięcie/redukcja 191, 202-204, 225, Kasami T. 151, 262, 680 249, 547 Kastens U. 324 - redukcja/redukcja 191, 225, 249, 547 Kasyanov V. N. 680 Koniec (krawędzi) 572 Katayama T. 324 Konsolidator 18 Kennedy K. 323, 551, 680-682 Konstrukcja podzbiorów 111-114, 127 Keohane J. 680 - stanów leniwa 121, 149 Kernighan B. W. 9, 21, 78, 149, 239, 436, Konstruktor typu 327 683, 690, 692, 709 Kontrola dynamiczna 325, 329 Kieburtz R. B. 263 - przepływu sterowania 325 Kildall G. A. 600, 644-645, 681 - statyczna 3, 325, 329, 682 Kill 576, 579-581, 593, 602 - typów 7-8, 325-326, 329, 485 Kleene S. C. 148 - unikalności 325 Knuth D. E. 8, 22, 77, 148-149, 263, 323,- związana z nazwą 325-326 367, 420, 437, 551-552, 637, 682, 691 Konwersja typów 340, 459-461, Kod asemblera 4, 14-17, 85, 485 zob. także: Wymuszanie typów - martwy 501, 524, 560, 563-564 - jawna 341 - maszynowy 4, 17-18, 527, 539 - niejawna 341 - bezwzględny 4, 18 Korekta błędów globalna 155-156 - z adresami bezwzględnymi 485 - na poziomie frazy 155-156, 184-185, - nieosiągalny, zob. Kod martwy 242 - niepotrzebny 524 Korenjak A. J. 263 - pośredni 12-14, 438-483, 485, 557, 666, Korzeń 28 zob. także: Kod trójadresowy, Maszyna Kosaraju S. R. 680 abstrakcyjna, Wyrażenie postfiksowe Koskimies K. 323 - skaczący 464 Koster C. H. A. 323 - trójadresowy 13, 441 KouL. 681 Kodowanie typów 334 Krawędzie poprzeczne 628 Kolejka 479-480 - powrotne 627 Kolejność obliczeń dla drzew 530-548 - w głąb 627 - - w translacji sterowanej składnią 271-272, Krawędź do przodu 574 283-284, 300-319 - do tyłu 572, 574, 627 - przechodzenia w głąb 282 Kristensen B. B. 263 Kolorowanie grafów 515-516 Kron H. 552 Kolsky H. G. 680, 696 Kruskal J. B. 149 Komentarz 80-81 Kubełek 411, zob. także: Mieszanie Komlos J. 148 Kwantyfikator ogólny 348-349
LaLonde W. R. 263 Lamb D. A. 552 Lampson B. W. 437 Landin P. J. 437 Langmaack H. 483 Lassagne T. 552 Lecarme O. 687 Ledgard H. F. 367 Leinius R. R 263 Leksem 11, 54, 58, 81 Lengauer T. 681 Lesk M. E. 148, 690 Leverett B. W. 482, 551-553 Levy J. -J. 552 Levy J. P. 264 Lewi J. 324 Lewis H. R. 681 Lewis R M. 263, 323 Lex 79, 100-107, 122, 140-141, 148, 690 Licznik odwołań 421 Liczniki użyć 512-514, 551 Lider 499 LINGUIST 324 Lint 329 Lisp 389, 417, 418, 436, 657, 685 Lista powiązana 409-410, 415-416 - sąsiedztwa 109 Liść 28 - lewostronny 530 - prawy 530 Literał 102 Literały znakowe 82 Lorho B. 323 Low J. 682 Lowry E. S. 512, 551, 680-681, 687, 696 Lucas R 78 Lunde A. 551 Lunnel H. 551 L-wartość 61, 217, 373, 401-406, 516
Łańcuch definicja-użycie, zob. Du-łańcuch - pusty 26, 43, 88, 90, 92 - użycie-definicja, zob. Ud-łańcuch Łapanie kodu 675 Łączenie ciągów 33, 88-92, 116-117 Łączność 29, 30, 90-91, 196, 235-237, 249-250, 647
Łączność lewostronna 29, 196, 249 - prawostronna 29, 196, 249
MacLaren M. D. 680 MacQueen D. B. 364, 367 Madsen C. M. 323 Madsen O. L. 263, 324 Make 689-690 Makropolecenie 15-16, 80, 405, 431 Mapa pamięci 423 Marill T. 324, 551 Markstein J. 680, 682 Martelli A. 367 Maszyna abstrakcyjna 60, zob. także: Maszyna stosowa - docelowa 684 - stosowa 60-65, 439, 552 Mauney J. 263 Maxwell W. L. 264 Mayoh B. H. 323 McArthur R. 482, 686 McCarthy J. 78, 436, 685 McClure R. M. 263 McCracken N. J. 367 McCulloch W. S. 148 Mcllroy M. D. 148 McKeeman W. M. 78, 262, 437, 552 McLellan H. R. 551 McNaughton R. 148 Medlock C. W. 512, 551, 680-681, 687, 696 Meertens L. 367 META 263 Metcalf M. 680 Metoda ograniczonego kontekstu 262 - pierwszeństwa operatorów 193-204, 262-263, 695 - zejść rekurencyjnych 41, 78, 172-173, 695, 699 Meyers R. 366 Mieszane wyrażenie logiczne 468-469 Mieszanie 278, 410-416, 434-435 Miller R. E. 680 Milner R. 346 Minimalizacja liczby stanów 134-136 Minker J. 483 Minker R. G. 483 Mitchell J. C. 367 M L 346, 354, 367 Modula 575, 680, 701-702
Monotoniczność 681 Montanari U. 367 Moore E. F. 148, 681 Moore J. S. 149 Morel E. 681 Morris D. 323 Morris J. H. 149, 367 Morris R. 437 Morse S. R 262 Moses J. 437 Moulton R G. 264 Muchnick S. S. 366, 679, 681 MUG 324 Muller M. E. 264
Nageli H. H. 734 Najbardziej ogólny unifikator 351-352, 357 Najdłuższy wspólny podciąg 146 Nakata I. 552 Napis 88 - Fibonacciego 144 -Holleritha 93 Narzędzia 684, zob. także: Generator kodu automatyczny, Generator analizatorów leksykalnych, Generator analizatorów składniowych, Kompilator kompilatorów, System przepływu danych, System translacji sterowanej składnią NAS, zob. Niedeterministyczny automat skończony Następnik 502 Naur P. 77, 262, 366, 436 Nawracanie 172-173 Nazwa 368 - lokalna 372-373, 376-379, 389 - nielokalna 374, 389-401, 498 - typu 327-328, 337 NEATS 324 Neliac 482, 686 Newey M. C. 482 Niedeterministyczny automat skończony 107-109, 111-121, 122-125, 128 - diagram przejść 174 Niejednoznaczność 28, 162, 164-166, 174, 181-182, 191, 217-218, 234-241, 247-250, 547 Nieterminal 25, 156-158, 194-195 - znacznika 293, 296-299
Nievergelt J. 552 Nori K. V. 78, 482, 693 Notacja Backusa-Naura 24, 77, 150, 262
Obiekt 368, 373 Obliczenia niezmiennicze w pętli, zob. Przemieszczenie kodu Obsługa błędów 11, 69, 83-84, 152-153, zob. także: Błąd leksykalny, Błąd logiczny, Błąd semantyczny, Błąd składniowy Obszar danych 422, 430 Odległość edycji 147 - między napisami 146 0'Donnell M. J. 552 Odwołania do tablic 192, 442, 454-459, 522, 550, 557, 614 Odwołanie, zob. Użycie Odzyskiwanie danych bezużytecznych 417-418 OgdenW. F. 324 Okres napisu 144 Operacja spotkania 644 Operator arytmetyczny 342 - łączący 591, 644, 657 Operatory jednoargumentowe 197 Optymalizacja globalna 559, 599 - kodu 14, 438, 446, 453, 484, 500, 523-527, 554-682, 697-699 -lokalna 559,599 - pętli 564, zob. także: Osłabienie mocy, Przemieszczenie kodu, Zmienna indukcyjna - przez szparkę 523-527, 552, 556 - rozgałęzień 700 - skoków 699 Organizacja pamięci 374-379 Osłabienie mocy 526 Osterweil L. J. 682
Pager D. 263 Pai A. B. 263 Paige R. 682 Pair C. 324 Pakiet wspomagania przetwarzania 368, zob. także: Rezerwacja stertowa, Rezerwacja stosowa Palm R. C 682 Pamięć 373
Porządek częściowy 316-318 Panini 77 - topologiczny 271, 520 Parametr aktualny 369, 377 Post E. 77 - formalny 369 Postać Backusa-Naura, - procedury 392, 396-397 zob. Notacja Backusa-Naura Park J. C. H. 263 Pary rejestrów 488, 535 - kolumnowa 455-456 Pascal 49, 81, 154, 329, 331, 337-338, 346, - normalna Chomsky'ego 262 447, 455, 483, 551, 680, 687-688, 734-735- - Greibach 258 Paterson M. S. 367 - wierszowa 455-456 PCC 489, 541, 552, 694-695 Powell M. L. 680, 682, 701 P-code 701 Powłoka systemowa 141 Pennello T. 263, 366 Pozefsky D. 324 Persch G. 366 Pożeranie maksymalne 547 Peterson T. G. 264 Pratt T. W. 436 Peterson W. W. 437, 680 Pratt V. R. 149, 263 Peyrołle-Thomas M. -C. 687 Prefiks 88 Pętla 503-504, 515, 570-575, 584-585, 625 - żywotny 190, 206, 212-213, 219-220 Preprocesor 4, 15 - naturalna 571-572 - wewnętrzna 504, 573 Priorytet 30, 90, 196, 235-237, 250 Pic 431 - operatorów 30 Pierwszeństwo proste 262 Problem „wiszącego else" 164-166, 181, 191, Pike R. 78, 436, 690, 709 237-239, 249, 254 Pitts W. 148 Procedura 368 P-kod 693-694 Procedury zagnieżdżone 393-399, 448-451 PL/C 155,485 Produkcja 25, 157 PL/I 20, 76, 83, 153, 363, 366, 461, 481, - pojedyncza 235, 256 c-produkcja 167, 179, 256 483, 680 Produkcje dla błędów 155, 251 Plankalkul 366 Produkt kartezjański 327 Plotkin G. 367 Program ładujący 18 Początek (krawędzi) 572 Podciąg 88, zob. także: Najdłuższy wspólny - rozpoznający język 107 - uruchomieniowy 384 podciąg - spójny 88 - symboliczny 665-673 Programowanie dynamiczne 262, 537-541, Podstawienie 351-352, 356-359 Podwyrażenie wspólne 276, 501, 516, 520, 552 535, 561-563, 566-569, 599-601, 671, Projekt programowania 703-709 697, 702, zob. także: Wyrażenie Prolog 573 dostępne Propagacja kopii 560, 562-563, 601-603 Prosser R. T. 681 Podział 134 Przebieg 19-21 - na przedziały 629-630 Pole rekordu 451, 461 - sterowania 461-478 Pollack B. W. 22 Przechodzenie drzewa 35, 300-303, Pollock L. L. 682 zob. także: Przechodzenie drzewa w głąb Połącz 359 - - w głąb 35, 308, 372 Poole P. C. 482 - symboli podglądanych 229 Pop 62 Przeciążanie 313, 326, 342-345, 364, 366 Poprawa błędów ze względu na minimalną Przedział 628-631 odległość 84 Przejście dla zbioru sytuacji 211-213, Poprawianie 20, 473-478, 486 219-220, 226 Poprzednik 502 - w kolejności postorder 531
Referencja zewnętrzna 18 Region 579, 632-634, 637-642 Reguła kopiowania 304-306, 308, 405-406 - najbliższego zagnieżdżenia 390, 393 - semantyczna 32, 265-272 - translacji 102 - ujednoznaczniająca 235-241 - usuwania niejednoznaczności 162, 166, 247-250 Reif J. H. 681 Reiss S. P. 437 Rejestr symboliczny 515 Rekord aktywacji 376-388, 492-497 Rekurencją 6-7, 156, 300-302, 313-315, 371, 379, zob. także: Rekurencją końcowa, Rekurencją lewostronna, Rekurencją prawostronna - końcowa 50 -lewostronna 44-46, 166-168, 172, 181-182, 287-290 - bezpośrednia 166 - prawostronna 45 Relacje priorytetów operatorów 193-194 Renvoise C. 681 Reps T. W. 323 Reynolds J. C. 367 Rezerwacja dynamiczna 379, 417-422 -jawna 417, 419-420 - niejawna 417, 421-422 - pamięci 379-389, 408, 417-422 - statyczna 379-381, 492-495, 498 - stertowa 379, 388-389, 417-422 - stosowa 379, 383-389, 492, 495-497 Rhodes S. P. 263 Quicksort 369, 557 Richards M. 482, 553 Ripken K. 366, 552 Ripley G. O. 153 Rabin M. O. 148 Ritchie D. M. 336, 437, 482, 694-696 Rachunek lambda 366 Robinson J. A. 367 Radin G. 366 Raiha K. -J. 263, 323-324 Rodzina zbiorów sytuacji LALR 226 Ramanathan J. 323 - zmiennej indukcyjnej 609 Ramka, zob. Rekord aktywacji Rogoway H. P. 366 Randell B. 22, 78, 436, 483 Rohl J. S. 437 Ratfor 683 Rohrich J. 264 Rosen B. K. 680-681 Redukcja 185, 189, 200-202, 206, 242 Rosen S. 22 - mocy 564-566, 569, 609, 610 Redukowalny graf przepływu 573-575, 628, Rosenkrantz D. J. 263, 323 Rosler L. 683 630, 632, 676-677, 699 Rounds W. C. 324 Redziejowski R. R. 552 Rovner P. 682 Referencja wisząca 387, 418-419 e-przejście 109-110, 127 Przekazywanie metodą skopiuj-przywróć 404 - parametrów 392, 401-406, 618 - przez nazwę 405-406 - referencję 403-404 - - wartość 402-403, 405-406 Przekształcenia algebraiczne 502, 526, 535, 569-570, 698 Przemienność 91, 647 Przemieszczał ny kod maszynowy 4, 17-18, 485-486 Przemieszczenie kodu 564, 603-608, 671, 681, 700 Przenośność 81, 684 Przepisywanie drzew 541-548, 552 Przepływ sterowania 63, 443-444, 525-526, 573-574, 579, 588, 651-653, 680 Przesunięcie 189, 206, 376, 378, 425, 447, 495 Przeszukiwanie w głąb 624-628 Przód kompilatora 19, 60 Przybliżenie bezpieczne, zob. Przybliżenie konserwatywne - konserwatywne 617-618, 652-653 Przycinanie uchwytów 187-188 Przydział rejestrów 488-489, 511-516, 532-535, 698 Przypisywanie rejestrów 14, 488, 507-509 Punkt 577 Purdom P. W. 323, 681 Push 62
Rozszerzony blok bazowy 675 Rozwiązanie mop 651-652, 655, 657 Rozwijanie w miejscu wywołania 405-406, zob. także: Makropolecenie Równania dla problemów do przodu 590, 660-665 do tyłu 591, 661-665 Równanie przepływu danych 576, 591, 644 Równoważność automatów skończonych 367 - bloków bazowych 500 - definicji sterowanych składnią 286-290 - gramatyk 159 - nazw wyrażeń określających typy 337-339 - po podstawieniu 352, 358-360 - strukturalna wyrażeń określających typy 356, 361 - wyrażeń określających typy 334-340, zob. także: Strukturalna równoważność wyrażeń określających typy, Unifikacja - regularnych 91, 142 Russell L. J. 22, 78, 436, 483 Russell S. R. 685 Ruzzo W. L. 263 R-wartość 61, 217, 373, 401-406, 516 Ryder B. G. 681
Saal H. J. 366 Saarinen M. 263, 324 Samelson K. 323 Sankoff D. 149 Sannella D. T. 364 Sarjakoski M. 263 Scarborough R. G. 680, 696 Schaefer M. 679-680 Schaffer J. B. 680 Schemat translacji 36, 283-286 — drzewa 542-547 Schonberg E. 682 Schorre D. V. 263 Schwartz J. T. 366, 551, 679-682 Scott D. 148 Sedgewick R. 557 Sekwencja powrotu 384-386 - wywołująca 383-386, 479-480 Semantyka 24 Sethi R. 324, 367, 437, 536, 551-552 SETL 366, 657, 680 Sharir M. 680, 682 Sheridan P. B. 262, 366
Shimasaki M. 551 Shustek L. J. 551 Siatka typów 366 SIMPL 680 Sippu S. 263 Skanowanie, zob. Analiza leksykalna Skierowany graf acykliczny 276-278, 329, 439-441, 445, 516, 527-530, 550, 552, 566-570, 574, 667-670 Składnia 24, zob. także: Gramatyka bezkontekstowa Słabe pierwszeństwo 262 Słownik 88, 92, 140, zob. także: Alfabet Słowo 88 -kluczowe 53, 81-82, 407 - zarezerwowane 54, 82 Sneeringer W. J. 366 Snobol 389 Soffa M. L. 682 Soisalon-Soininen E. 263 Solidny system typów 329 Spillman T. C. 681 Sprowadzanie do zgodności typów 341-342 Stan 95, 108, 144, 206, 279-280 - akceptujący 108 - końcowy, zob. Stan akceptujący -NAS ważny 127-128 - pamięci 373 - początkowy 95 - procesora 377, 384-386 Staveren H. van 552 stdio.h 55 Stearns R. E. 263, 323 Steel T. B. 483 Steele G. L. 436 Sterta 375, 694 Stevenson J. W. 552 Stockhausen P. F. 552 Stonebraker M. 15 Stos 119, 176, 188, 206, 242, 261, 276, 279-281, 294-299, 308-312, 372, 375, 450, 532, 694, zob. także: Stos sterowania - sterowania 372, 375 Strategia mieszanych priorytetów 262 Strong J. 78, 482, 685 Stroustrup B. 414 Struktura blokowa 389, 415-416 Strukturalna równoważność wyrażeń określających typy 334-337 Sufiks 88
Suma zbiorów 89-91, 116 Sussman G. J. 436 Suzuki N. 367, 682 Sygnatura węzła grafu dag 277 Symbol bezużyteczny 256 - bieżący 39 - leksykalny 5, 11, 25, 53-54, 80-82, 91, 156, 170 - podstawowy 90 - startowy 25, 28, 157, 267 - synchronizujący 183-184 - wejściowy 108 Symbole podglądane 204, 219 Synonim 613-624 System pisania kompilatorów, zob. Kompilator kompilatorów - przepływu danych 22, 653-657 - translacji sterowanej składnią 22, zob. także: GAG, HLP, LINGUIST, MUG, NEATS - typów 329, 660, zob. także: Solidny system typów Sytuacja, zob. Jądro zbioru sytuacji, Sytuacja LR(0), Sytuacja LR(1) - LR(0) 210 -LR(1) 219-220 - możliwa 215, 219 Szacowanie konserwatywne 581-582, 596 Szemeredi E. 148 Szkielet analizy przepływu danych 644-657 - dystrybutywny 648-651, 655 - monotoniczny 648-651, 655 Szkieletowe drzewo wyprowadzenia 196 Szymański T. G. 149, 553
Środowisko 373, 432-433 - aktywacji 432-433 - leksykalne 432-433 - przekazywane 432-433 Świńska łacina 75-76
Tabela przejścia 109 Tablica 326, 329, 404 - akcji 205 - analizatora LALR 224-232 - mieszająca 470 - napisów 408 - przejść 205
Tablica symboli 10, 57-60, 80, 151, 445, 447, 449-452 Tablice analizatorów SLR 215-218 Tai K. C. 264 Tanenbaum A. S. 482, 552 Tantzen R. G. 78 Tarhio J. 323 Tarjan R. E. 148, 367, 437, 680-681 T-diagram 685-688 Tennenbaum A. M. 366, 681-682 Tennent R. D. 436 Terminal 25, 156-158, 267 Test regresji 691 Testowanie 691 TgX 8, 16, 78, 691 Thompson K. 115, 149, 569, 694 Thunk 406 Tienari M. 263 Tjiang S. 552 T M G 263 Tokuda T. 263 Tokura N. 680 Trabb Pardo L. 22 Translacja sterowana składnią 7, 24, 31-38, 44-51, 265-324, 439, 443-444 prosta 37-38 , prosty schemat 283 Translator przewidujący 291-293 Treść procedury 368 Trickey H. W. 4 TROFF 686,692 Trójki 445-446 - pośrednie 446-447 Tryb adresowania 17-18, 490, 548 -paniki 84, 155, 183-184, 241 Tyl kompilatora 19, 60 Typ 325-367 - funkcyjny 328, 333, 342-345, zob. także: Funkcja polimorficzna - możliwy 344 - podstawowy 327 - polimorficzny 349 - rekordowy 328, 340, 451 - void 327 - wskaźnikowy 328
Uchwyt 186-188, 189, 194-195, 200, 215 Ud-łańcuch 587-588, 607-608 Ukkonen E. 263
Układ danych 377-378 Ullman J. D. 4, 134, 148, 172, 194, 263, 278, 366, 371, 420-421, 437, 536, 552, 556, 680-682 UNCOL 78, 482 Unifikacja 351-353, 356-361, 367 UNIX 141, 148, 244, 685, 694 Upakowanie danych 378 - pamięci 422 Uporządkowanie w głąb 625-626, 635-637 Uruchamianie 524, zob. także: Program uruchomieniowy symboliczny Usuwanie zmiennych lokalnych 382 Użycia widoczne z góry 598 Użycie 499, 504-505, 598
Yyssotsky V. 680
Wagner R. A. 148 Waite W M. 482, 551-552, 681, 690 Ward P. 323 Warren S. K. 324 Wartość domyślna 469-470 - drzewa 28 -leksykalna 11, 105, 267 - zwracana 384-385, 399 Wartownik 86-87 Wasilew S. G. 552 WATFIV 485 Watt D. A. 323 Wciąganie kompilatorów 685-688 WEB 691 Weber H. 262 Wegbreit B. 366, 681 Wegman M. N. 367, 680-681 Wegner P. 680 Wegstein J. H. 148 Weihl W. E. 681 Weinberger P. J. 148, 413 Weingart S. 552 Wejście do pętli 504, 571, 579, 628 - pętli, zob. Wejście do pętli Welsh J. 366 Wexelbiat R. L. 22, 77 Węzły przystające 365, 367 Wiązanie dostępu 376-377, 394-397, 400 - nazw 373-374 - sterowania 376-377, 384-387, 400
Widzialność dynamiczna 389, 399-401 - leksykalna 389-399 - statyczna, zob. Widzialność leksykalna Wierzchołek dzielony 535 - początkowy 502 Wilcox T. R. 155, 264 Wilhelm R. 323, 483 Wirth N. 78, 262-263, 436, 483, 687, 693, 703 Wnioskowanie typów 347-348, 354, 657-702 Wood D. 263 Wortman D. B. 78, 262 Wóssner H. 366 Wskaźnik 329, 387, 442, 510, 523, 550-551, 613-617 Wulf W. A. 462, 551, 680, 699 Wybór kolejności obliczeń dla bloków bazowych 489, 527-530 - rozkazów 486-487 Wydajność 81, 84, 120-121, 137-138, 228-232, 265, 341-342, 367, 487, 585-587, 684, zob. także: Optymalizacja kodu Wygenerowanie napisu 28 Wykrywanie typów 657-658 Wyliczanie po kolei 538-539 Wymuszanie typów 326 Wypełnienie 378 Wyprowadzenie 28, 158-162 - kanoniczne 160 - lewostronne 159 - prawostronne 160, 185-187 Wyrażenie 6, 30-31, 157, 276-278, 331-332, zob. także: Wyrażenie postfiksowe - bardzo zajęte 674-675 - dostępne 594-597, 623, 647, 657 - infiksowe 31 - logiczne 310-311, 461-469, 473-476, zob. także: Mieszane wyrażenie logiczne - określające typ 327-329 - postfiksowe 24, 32-33, 439, 481 - prefiksowe 481 -regularne 79, 89-92, 102, 108, 115-119, 122, 128-134, 140, 163, 254 - warunkowe, zob. Wyrażenie logiczne Wyrównanie danych 378-379, 447 Wywoływanie procedur 192, 374, 376-377, 383-389, 442, 478-480, 492-497, 522-523, 613, zob. także: Analiza przepływu danych w obecności wielu procedur Wyznaczanie rejestrów 515
Yacc 244-253, 690, 695, 701 Yamada H. 148 Yannakakis M. 553 Yao A. C. 148 Yellin D. 324 Younger D. H. 151, 262
Złożenie 646 Zmiana maszyny wynikowej 438 - nazw 501 Zmienna, zob. Identyfikator, Zmienna typu - indukcyjna 564-566, 608-613, 670, 681, 698 - bazowa 608 Zachowywanie zmiennych lokalnych - tymczasowa 377, 445, 453-454, 505, 379-381, 388 600-601, 604 Zadeck F. K. 681 - typu 347 Zagnieżdżenie aktywacji 370, - zmieniana 621 -624 zob. także: Struktura blokowa - żywa 504-505, 512-513, 520, 567-568, Zakres widzialności 372-373, 389, 415-416, 597-598, 607, 617 434, 448-453 Znaczniki 511 Zbiór nieregularny 170-171, Znajdź 359 zob. także: Zbiór regularny Znaki odstępu 53, 80, 94 - pusty 88 Zrzut symboliczny 506 - regularny 93 Zuse K. 366 Zdanie 88, 159 Zwijanie stałych 560, 564, 569, 645-648, Zelkowitz M. V. 680 650-651 Zimmermann E. 324
WNT. Warszawa 2002. Wyd. I Ark. wyd. 59,5. Ark. druk. 48,0. Symbol Et/83579/WNT Cieszyńska Drukarnia Wydawnicza