Zadanie 1
W jezyku Pascal pętla 'for' wygląda następująco
for i := k to m do instr
gdzie 'k' i 'm' są dowolnymi wyrażeniami a 'instr' jest (zwykle
złożoną) instrukcją. Taka pętla ma wykonać 'instr' dla zmieniających
się wartości 'i'. Próba zmiany wartości 'i' przez 'instr' jest
zabroniona. Wymaga się by powyższa pętla działała również gdy
'k' przyjmuje minimalną możliwą wartość zaś 'm' przyjmuje
maksymalną wartość. Jeśli 'k' jest większe od 'm' to pętla
się nie wykonuje (jest 0 iteracji). Jak można przetłumaczyć
powyższą pętle do kodu maszynowego, tak by uniknąć problemów
z przepełnieniem. Np. przetłumaczenie powyższej pętli na
pętlę w C
for(i = k; i <= m; i++) instr
nie jest poprawne, bo dla 'i' równego 'm' oblicznie 'i++' może
prowadzić do przepełnienia.
Zadanie 2
Do obliczenia wyrażenia 'x+y*(z + 2)' rekursywna metoda naszkicowana
na wykładzie (najpierw obliczająca wyrażenie lewe) wymaga 4 komórek
stosu. Jeśli będziemy najpierw obliczać wyrażenie prawe to
wystarczą dwie komórki. Ile komórek potrzeba do obliczenia
wyrażenia 'x*t+y*(z + 2)'?
--------------------------------------------------------------
Niektóre języki programowania dopuszczają przeciążanie funkcji,
tzn. można zadeklarować wiele wersji funkcji które różnią się
tylko typami argumentów (czy zwracanych wartości). Inne
dopuszczają niejawne konwersje, np. liczby z mniejszego zakresu
można użyć wszędzie tam gdzie dopuszczalne są liczby z większego
zakresu. Celem zadań niżej jest przypatrzenie się
temu jak tego typu konstrukcje wpływają na budowę kompilatora.
Zadanie 3
W języku który dopuszcza konwersje domyślne (np. liczby z
mniejszego zakresu można użyć wszędzie tam gdzie dopuszczalne
są liczby z większego zakresu) i przeciążanie funkcji przy
sprawdzaniu poprawności wywołania trzeba uwzględniać potencjanie
dużą ilość konwersji. Jedna taktyka polega na przeglądaniu
wszystkich możliwych konwersji i sprawdzaniu czy dla danych
typów argumentów dostępna jest odpowiednia wersja funkcji.
Inna taktyka polega na przeglądaniu listy dostępnych wersji
funkcji i sprawdzaniu czy można skonwertować argumenty tak
by pasowały dla danej wersji. Porównać te taktyki, w szczególności
oczekowaną liczbę operacji.
Zadanie 4
Niektóre języki programowania dopuszczają przeciążanie funkcji,
tzn. można zadeklarować wiele wersji funkcji które różnią się
tylko typami argumentów (czy zwracanych wartości). W kompilatorze
dla pojedynczego wywołania zwykle stosuje się taktykę pełnego
przeglądania dostępnych (zadeklarowanych) możlowości. Dla
większych fragmentów mozliwe są taktyki rekursywnego dopasowania
z nawracaniem (najpierw próbujemy dobrać typ najbardzej zewnętrznie
występującej funkcji, potem dobieramy typy dla wyrażeń dających
argumentym jak argumenty nie pasują to wracamy do najwyższego
poziomu) i strategia "z dołu do góry" (najpierw patrzymy na
możliwości dla najprostszych podwyrażeń, potem dla bardziej
skomplikowanych, w szczególności możliwe typy dla wywołania
funkcji badamy po zbadaniu możliwości dla argumentów).
Jakie są wady a jakie zalety obu tych podejść. Oszacować
złożoność obliczeniową.
----------------------------------------------------------
Zadanie 5
W kompilatorze zachodzi często potrzeba wyszukiwania informacji
związanej z identyfikatorami (np. jeśli w wyrażeniu występuje
zmienna to potrzebujemy sprawdzić czy była ona wcześniej
zadeklarowana). Odpowiednią strukturę danych nazywamy
tablicą symboli. Abstrakcjnie wymaga to pamięci asocjacyjnej,
np. kontenera 'map' z C++. Klasycznie w kompilatorach
tablicę symboli realizowno przy pomocy tabel mieszających
(choć obecnie jest tendencja do używania struktur danych
wbudowanych w język w którym piszemy kompilator).
Tabela mieszająca oparta jest na tzw. funkcji mieszającej,
odwzorowującej zbiór kluczy (w przypadku tabeli symboli
łańcuchy znaków) w przedział liczb całkowitych [0,N] dla
pwenego N. Funkcji mieszająca powinna być tak dobrana
by jej wartości zachowywały się podobnie jak liczby
przypadkowe. Oczywiście, przy zadanym ciągu kluczy
i ustalonej funkcji mieszającej otrzymany ciąg liczb
całkowitych jest deterministyczny, ale chcemy dobrać
funkcję tak by dla "regularnch" ciągów wejściowch
wyjście nie było tak regularne. Dokładniej, przy
teoretycznej analizie przyjmujemy że wartości funkcji
mieszającej są losowe, zaś w praktyce staramy się
unikać takich regularności na wyjściu które by
unieważniły wynik analizy (daje się znaleźć w miarę
dobre i łatwe do obliczenia funkcje mieszające).
Mając zadaną funkcję mieszającą tablea mieszająca
działa następująco:
- Mamy tablicę t indeksową liczbami z [1,N] w której
przechowujemy listy par (klucz, wartość). Listy te
na początku są puste.
- Mając słowo w najpierw obliczamy wartość funkcji mieszającej
h = f(w)
- Nastepnie wykonujemy wyszukiwanie na liście
przechowywanej w t[h]. Jeśli nie znajdziemy w to dodajemy
je do tej listy.
Uwaga: w praktyce tablica t zawiera zwykle wskaźnik na początek
odpowiedniej listy. Są warianty gdzie elementy list przechowuje
się w tabeli t, ale ich w tym zadaniu nie rozważamy
Oszacować średnią długość list po wstawieniu m elementów,
zakładając że f(w) przyjmuje losowe wartości, niezależne
i równo rozłożone w przedziale 1..N. Porównać oczekiwaną
liczbę operacji i zajętość pamięci przy wyszukiwaniu w tabeli mieszającej
i w drzewie binarnym.
Zadanie 6
W celu powiększenia wydajności programów staramy się by
dostęp do zmiennych był wyrównany, tzn. jeśli potrzebna
jest zmienna 2 bajtowa to przechowujemy ją pod adresem
podzielnym przez 2, jeśli 4-bajtowa to pod adresem podzielnym
przez 4 i tak dalej aż do długości słowa komputera. Aby
to osiągnąc w struktórach wprowadza się dodatkowe nieużywane
miejsce pomiędzy końcem jednej składowej a początkiem następnej.
Ponadto na końcu struktury może być potrzeba dodatkowa
nieużywana przestrzeń, tak by jeśli struktury są umieszczone
w pomięci po kolei (np. jako elementy tablicy) pola
następnej struktury znalazły się we właściwym miejscu.
Mamy następujące deklaracje struktur:
struct { char c; long l; short s; int i;} st1;
struct { long l; short s; char c;} st2;
Jak będzie wyglądało ich rozmieszczenie w pamięci zakładając
że 2-bajtowe short i 4-bajtowe int i long oraz że short,
int i long muszą być przechowywane pod adresami podzielnymi przez 2.
Jak to się zmieni jeśli int i long muszą być przechowywane pod
adresami podzielnymi przez 4. Jak to się zmieni jeśli long jest
8-bajtowy i musi być przechowywany pod adresami podzielnymi
przez 8.
Zadanie 7
Rozważmy następującą deklarację w C:
struct {
struct {
int i1;
char c1;
} s1[3];
int i2;
struct {
int i2;
char * str1;
} s2;
} s3[5];
Jak będzie ona rozmieszczona w pamięci, zakładając 4-bajtowe int,
8-bajtowe wskaźniki i wyrównywanie int do 4 zaś wskaźników do 8.
Jak by się ona zmnieniła gdyby C miało semantykę referencyjną
dla struktur i tablic (tzn. struktur i tablic jako elementy większych
części byłby reprezentowane przez adresy, zaś faktyczne dane
byłyby pamiętane oddzielnie). Jak by wyglądało rozmieszczenie
w pamięci jeśli oprócz semantyki referencyjnej elementy struktur
i tablic zajmowałyby pełne 8-bajtowe słowa (czyli in i char zastałyby
powiększone do słowa), przy tym struktury dostały nagłówek z
jednego słowa zaś tablice nagłówek z dwu słów (pierwsze identyfikowałoby
typ tablicy a drugie dawałoby jej rozmiar). Zakładamy że maszyna
posiada wystarczająco dużo dostępnych (wolnych) rejestrów r1, r2, r3, ... i
instrukcje 'ldb', 'ldi', 'ldw' o składni 'ldw [ri], rj' które
odczytują zawartość dane pod adresem z rejestru ri i umieszczają
go w rejestrze rj. Przy tym 'ldw' odczytuje słowo, 'ldi' odczytuje
4-bajtową liczbę całkowitą zaś 'ldb' odczutuje znak (bajt). Dodatkowo
maszyna ma instruję 'add stała, ri' która dodaje stałą do rejestru ri.
Jak wyglądają sekwencje instrukcji umieszczające w rejestrze r2 wartość
s3[2].s1[1].c1 dla poszczególnych wariantów (C, referencyjne C,
referencyjne C z wartościami zajmującymi słowa i z nagłówkami).
We wszystkich przypadkach zakładamy że w rejestrze r1 znajduje się
adres początku struktury s3 i że można go nadpisać w trakcie
obliczenia.
Zadanie 8
Jednym z klasycznych usprawnień programów jest wynoszenie
stałych podwyrażeń poza pętle. Jednakże wymaga to
ustalenia które podwyrażenia są stałe. Podwyrażania stałe
definiujemy jako takie podwyrażenia których wartości po
obliczeniu w pierwszej iteracji nie zmieniają się w kolejnych
iteracjach pętli. Może się zdażyć że ponowne wykonanie pętli
da nam inną wartość tekiego podwyrażenia, istotne jest tylko
by wartość nie zmieniała w takcie każdego pojedyńczego wykonania
pętli. Rozważmy poniższą pętlę:
int z = 0;
void foo() { z++;}
....
{
s1 = 0;
s2 = 0;
b = random();
for(i = 0; i < 100; i++) {
foo();
k = 5;
c = b;
d = c + k;
f = random();
g = d + z;
s1 += f;
s1 += g;
s2 += d;
}
}
Które podwyrażenia w tej pętli są stałe. Jakie podwyrażenia
zostaną uznane za stałe przez następujące metody:
- jawne stałe i zmienne do których nie ma przypisania
wewnątrz pętli
- co wyżej plus wartości funkcji ze stałymi argumentami
- jawne stałe i zmienne lokalne (niewidoczne dla innych funkcji)
do których nie ma przypisania wewnątrz pętli
- jak wyżej plus uznajemy za stałe wyrażenia arytmetyczne ze
stałymi argumentami
Które z powyższych metod są poprawne (tzn. znalezione podwyrażenia
faktycznie są stałe)? Podać przykłady pętli z podwyrażniami
stałymi których nie znajdą te z powyższych metod które są
poprawne.
Zadanie 9
Zakładamy że program napisamy w klasycznym języku algorytmicznym
w 80% wykonuje operacje na zmiennych prostych zaś w 20%
wywołania funkcji. Dodatkowo zakładamy że operacja na zmiennej
prostej wymaga przeciętnie dwu instrukcji maszynowych, zaś
wywołanie funkcji pięciu. Jak się zmieni ilość wykonywanych
instukcji jeśli do języka dodamy wsparcie obiektowe, tak że
wywołamie metody będzie wymagało trzech dodatkowych instrukcji
w porównaniu z wywołaniem funkcji i zamienimy wszystkie
wywołania funkcji na wywołania metod. Jak się zmieni ilość
wykonywanych instrukcji jeśli chcemy przejść do "czysto
obiektowego" języka tak że każda operacja oryginalnego
programu stanie się wywołaniem metody, przy tym jeśli operacja
dotyczyła zmiennej prostej to zakładamy że treść metody wykona
tę operację, tak że narzut składa się z kosztu wywołania metody,
zaś jeśli operacja była wywołaniem funkcji to zamieniamy ją
na wywołanie metody.
Uwaga: powyżej piszę o wykonywanych operacjach ponieważ
proporcje instukcji w treści programu często znacznie
się różnia od proporcji w trakcie wykonania. Po prostu
niektóre (zwykle małe) fragmenty programu są znacznie częściej
wykonywane niż inne części programu.
Zadanie 10
Zakładamy że przeciętnie interpreter na wykonanie prostej
operacji potrzebuje 200 instrukcji maszynowych. Ze względu
na duży czas wykonania prostych operacji do języków interpretowanych
zwykle dodaje się funkcje wbudowane które w miarę wydajnie
wykonują bardziej złożone operacje. Zakładamy że 80% wykonywanych
operacji to operacje proste, zaś 20% wywołania funkcji wbudowanych.
Aby przyspieszyć wykonanie programu chcemy go skompilować. Przy
tym zakładamy że proste operacje da się przetłumaczyć przeciętnie
do 3 instrukcji maszynowych, zaś funkcje wbudowane musimy dalej
wywoływać. Przy tym przecietne wywołanie funkcji wbudowanej
przez interpreter wymaga 1000 instrukcji, zaś kompilator może
oszczędzić 200 instrukcji z kosztu wywołania. Jak
kompilacja zmieni liczbę wykonywanych instrukcji?