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: 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: 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?