Semantyka i weryfikacja programów/Ćwiczenia 1

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania


Zawartość

Tematem tych zajęć jest semantyka operacyjna wyrażeń (małe kroki).


Zadania z rozwiązaniami

Zadanie 1 (przygotowawcze)

Rozważmy bardzo prosty język wyrażeń, którego składnia opisana jest następującą gramatyką:

n::=0|1|

x::=(identyfikatory)

e::=n|x|e1+e2|𝐢𝐟e1𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3

Wynikiem wyrażenienia warunkowego 𝐢𝐟e1𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3 jest wartość wyrażenia e2, o ile wyrażenie e1 oblicza się do wartości różnej od zera; w przeciwnym przypadku wynikiem jest wartość wyrażenia e3.

Zaproponuj semantykę operacyjną (małe kroki) dla tego języka.


Rozwiązanie

Zacznijmy od ustalenia notacji i dziedzin syntaktycznych. Niech 𝐍𝐮𝐦 oznacza zbiór stałych liczbowych, n𝐍𝐮𝐦={0,1,}. Podobnie, niech 𝐕𝐚𝐫 oznacza zbiór identyfikatorów, które mogą być nazwami zmiennych; x𝐕𝐚𝐫. Wreszcie, niech 𝐄𝐱𝐩 oznacza zbiór wyrażeń; e𝐄𝐱𝐩. Dla ułatwienia zapisywania reguł zakładamy, ze stałe liczbowe sa wyrażeniami, czyli 𝐍𝐮𝐦𝐄𝐱𝐩.

Będziemy potrzebować zbioru stanów, opisujących wartości przypisane zmiennym. Najprostszym rozwiązaniem jest przyjąc, że stan to funkcja z 𝐕𝐚𝐫 do 𝐍𝐮𝐦. Oznaczmy przez 𝐒𝐭𝐚𝐭𝐞 zbiór wszystkich takich funkcji; stany oznaczac będziemy przez s,s1,s,𝐒𝐭𝐚𝐭𝐞.

W naszej semantyce będziemy potrzebowac tranzycji dwóch postaci. Po pierwsze, tranzycje postaci

e,se,s

oznaczające mały krok w trakcie obliczania wyrażenia e w stanie s, w wyniku którego e wyewoluowało do e. Stan nie ulega zmiania podczas obliczania wyrażenia, więc to samo s figuruje po lewej i prawej stronie strzałki.

Po drugie, tranzycje postaci

e,sn

będą oznaczaczać, że wyrażenie e jest już policzone, a jego wartością jest n.

Zatem przyjmijmy, że zbiór konfiguracji to

(𝐄𝐱𝐩×𝐒𝐭𝐚𝐭𝐞)𝐍𝐮𝐦

a konfiguracje końcowe to 𝐍𝐮𝐦.

Uwaga: Tranzycje pierwszej postaci mogłyby również wyglądać następująco: e,se. Wtedy zbiorem konfiguracji byłby zbiór (𝐄𝐱𝐩×𝐒𝐭𝐚𝐭𝐞)𝐄𝐱𝐩 a konfiguracje końcowe pozostałyby bez zmian. (koniec uwagi)


Najprostsze są tranzycje prowadzące do konfiguracji końcowej:

n,sn

Zmienna oblicza się do swojej wartości w bieżącym stanie:

x,sn,s o ile s(x)=n.

Teraz zajmiemy się dodawaniem e1+e2. Ponieważ semantyka jest w stylu małych kroków, musimy zdecydować się czy najpierw obliczyć pierwszy (lewy) składnik e1 czy drugi? Jeśli wybierzemy lewy (strategia lewostronna), otrzymamy regułę:

e1,se'1,se1+e2,se'1+e2,s.

Czyli mały krok w e1 stanowi też mały krok w e1+e2. Po zakończeniu obliczania e1 przechodzimy do e2:

e2,se'2,sn+e2,sn+e'2,s.

A na końcu dodajemy:

n1+n2,sn,s o ile n=n1+n2.

Zauważmy tutaj pewną subtelność, dotyczącą dwóch wystąpień symbolu +: pierwsze wystąpienie oznacza jedną z konstrukcji składniowych języka, a drugie oznacza operację dodawania w zbiorze 𝐍𝐮𝐦. Pozwalamy sobie na taką kolizję oznaczeń, gdyż nie powinna ona prowadzić do niejednoznaczności. Pamiętajmy, że składnia języka jest składnią abstrajkcyjną, więc zamiast e1+e2 moglibyśmy równie dobrze pisać np. add(e1,e2).

Inna możliwą strategią obliczania e1+e2 jest strategia prawostronna, którą otrzymujemy zastępując pierwsze dwie z trzech powyższych reguł przez:

e2,se'2,se1+e2,se1+e'2e1,se'1,se1+n,se'1+n,s.

Ponadto, jeśli przyjmiemy regułę pierwszą (dla e1), trzecią i czwartą (dla e2), otrzymamy strategię równoległą, polegającą na obliczaniu jednocześnie e1 i e2:

e1,se'1,se1+e2,se'1+e2,se2,se'2,se1+e2,se1+e'2n1+n2,sn,s o ile n=n1+n2.

Bardziej precyzyjnie mówiąc, małe kroki obliczające obydwa podwyrażenia przeplatają się, i to w dowolny sposób. Ta dowolność prowadzi do niedeterminizmu, czyli do sytuacji, gdy kolejna (następna) konfiguracja nie jest wyznaczona jednoznacznie. Jest tak, gdyż możemy mieć do wyboru dwie różne tranzycje

e1+e2,se'1+e2,se1+e2,se1+e'2,s.

Zauważmy natomiast, że kolejność przeplatania się małych kroków obliczających e1 i e2 nie wpływa w tym przypadku na końcową wartość całego wyrażenia.

Na koniec reguły dla wyrażenia warunkowego.

e1,se'1,s𝐢𝐟e1𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3,s𝐢𝐟e'1𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3,s
𝐢𝐟n𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3,se2,s o ile n0
𝐢𝐟n𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3,se3,s o ile n=0


Zadanie 2

Rozszerzmy język wyrażeń z poprzedniego zadania o jedną konstrukcję

e::=|𝐥𝐞𝐭x=e1𝐢𝐧e2

Wyrażenie 𝐥𝐞𝐭x=e1𝐢𝐧e2 zawiera w sobie deklarację x=e1, która stanowi mechanizm wiązania identyfikatorów w naszym języku. Deklaracja x=e1 wprowadza nową zmienną x oraz przypisuje jej wartość. Wartość wyrażenia 𝐥𝐞𝐭x=e1𝐢𝐧e2 obliczamy następująco: najpierw oblicza się wartość e1, podstawia ją na zmienna x, a następnie oblicza wyrażenie e2. Zakresem zmiennej x jest wyrażenie e2, czyli wewnątrz e2 można odwoływać się (wielokrotnie) do zmiennej x; Ogólniej, odwołania do zmiennej w wyrażeniu odnoszą się do najbliższej (najbardziej zagnieżdzonej) deklaracji tej zmiennej. Taki mechanizm wiązania identyfikatorów nazywamy wiązaniem statycznym. Przyjmujemy zwykłe reguły przesłaniania zmiennych. Np., jeśli w e2 występuje podwyrażenie 𝐥𝐞𝐭x=𝐢𝐧e to odwołania do x wewnątrz e odnoszą się do najbliższej deklaracji zmiennej x.

Zakładamy, że na początku wartości wszystkich zmiennych są nieokreślone, czyli zmienne są niezainicjowane, a odwołanie do niezainicjowanej zmiennej jest uważane za niepoprawne.


Przykłady

𝐥𝐞𝐭x=z+z𝐢𝐧𝐥𝐞𝐭y=7𝐢𝐧𝐥𝐞𝐭x=y+3𝐢𝐧x+x+ywynik=24 𝐥𝐞𝐭y=5𝐢𝐧𝐥𝐞𝐭x=(𝐥𝐞𝐭y=3𝐢𝐧y+y)𝐢𝐧x+ywynik=11 Parser nie mógł rozpoznać (błąd składni): {\displaystyle \mathbf{let}\, z = 5 \,\mathbf{in}\, x+z \quad \quad \mapsto \quad \quad \mbox{brak wyniku; odwołanie do niezainicjowanej zmiennej}\, x } 𝐥𝐞𝐭x=1𝐢𝐧𝐥𝐞𝐭x=x+x𝐢𝐧x+xwynik=4


Rozwiązanie

Podobnie jak poprzednio, stan powinien opisywać wartości przypisane zmiennym, ale powinniśmy też uwzględnić niezainicjowane zmienne, czyli zmienne bez żadnej wartości. Przyjmijmy zatem, że stan to skończona funkcja częściowa z 𝐕𝐚𝐫 do 𝐍𝐮𝐦. Oznaczmy symbolem 𝐒𝐭𝐚𝐭𝐞 zbiór wszystkich takich funkcji: 𝐒𝐭𝐚𝐭𝐞=𝐕𝐚𝐫fin𝐍𝐮𝐦. Naturalnym stanem początkowym jest stan pusty, tzn. pusta funkcja częściowa, który będziemy oznaczać symbolem . Wartość wyrażenia e w stanie początkowym wynosi n o ile zachodzi:

e,*n.

Będziemy potrzebowac tranzycji dwóch postaci, podobnie jak poprzednio, ale pierwsza postać będzie trochę ogólniejsza:

e,se,s.

Tranzycja ta oznacza mały krok w trakcie obliczania wyrażenia e w stanie s, w wyniku którego e wyewoluowało do e a nowym stanem jest s. Stan może się teraz zmienić na skutek deklaracji zmiennych.

Spróbujmy rozszerzyc semantykę z poprzedniego zadania. Ponieważ stan jest funkcją częściową, musimy zmienić niektóre reguły, np.

x,sn,s o ile s(x) jest określone i s(x)=n

Następnie dodajemy reguły dla wyrażenia 𝐥𝐞𝐭x=e1𝐢𝐧e2. Gdy e1 jest już obliczne, wyatarczy reguła:

𝐥𝐞𝐭x=n𝐢𝐧e2,se2,s[xn].

Notacja s[xn] oznacza stan s, który zmodyfikowano przypisując zmiennej x wartość n, niezależnie od tego, czy s(x) było określone, czy nie, i pozostawiając niezmienione wartości dla pozostałych zmiennych. Formanie

s[xn](y)={ny=xs(y)yx

W szczególności, dla yx, s[xn](y) jest określone wtedy i tylko wtedy, gdy s(y) jest określone.

Natomiast aby obliczyc e1 potrzebujemy reguły:

e1,se'1,s𝐥𝐞𝐭x=e1𝐢𝐧e2,s𝐥𝐞𝐭x=e'1𝐢𝐧e2,s

Zwróćmy uwagę, że stan s może być różny od s, np. dlatego, że wewnątrz e1 znajduje się podwyrażenie 𝐥𝐞𝐭y=.

Pytanie: czy taka semantyka jest poprawna?

Niestety nie, gdyż nie uwzględniamy ograniczonego zasięgu zmiennej. Rzućmy okiem na przykład:

𝐥𝐞𝐭x=(𝐥𝐞𝐭z=4𝐢𝐧z+z+z)𝐢𝐧z

Według naszych intencji to wyrażenie nie ma wartości, gdyż ostatnie odwołanie do z jest błędne. Natomiast według powyższych reguł mamy

𝐥𝐞𝐭x=(𝐥𝐞𝐭z=4𝐢𝐧z+z+z)𝐢𝐧z,𝐥𝐞𝐭x=z+z+z𝐢𝐧z,[z4]𝐥𝐞𝐭x=12𝐢𝐧z,[z4]12,[z4]12!

Nasz błąd polega na tym, że po zakończeniu obliczania podwyrażenia 𝐥𝐞𝐭z=4𝐢𝐧z+z+z zapominamy przywrócić zmiennej z poprzednią wartość (a właściwie brak wartości w przykładzie powyżej). Przedyskutujmy kilka wariantów.

Wariant 1

Wygodne i eleganckie rozwiązanie tego problemu jest możliwe, jeśli rozszerzymy składnię naszego języka. Intuicyjnie, reguła

𝐥𝐞𝐭x=n𝐢𝐧e2,se2,s[xn].

powinna zostać zastąpiona przez

𝐥𝐞𝐭x=n𝐢𝐧e2,se2𝐭𝐡𝐞𝐧,,przywróć wartość zmiennej x'',s[xn].

czyli potrzebujemy konstrukcji składniowej, która polega na obliczeniu wyrażenia e2 a następnie na przypisaniu zmiennej x danej wartości. Rozszerzmy zatem składnię następujaco:

e::=|e𝐭𝐡𝐞𝐧x:=n.

Zauważmy, że wyrażenie e𝐭𝐡𝐞𝐧x:=n jest w pewnym sensie dualne do 𝐥𝐞𝐭x=n𝐢𝐧e, gdyż jedyna (choc niewątpliwie istotna) różnica między nimi to kolejność obliczenia e i przypisania wartości na zmienną x. Oto nowa reguła

𝐥𝐞𝐭x=n𝐢𝐧e2,se2𝐭𝐡𝐞𝐧x:=n,s[xn] o ile s(x)=n.

Pewna trudność pojawiają się w sytuacji, gdy s(x) jest nieokreślone, czyli gdy zmienna x jest niezainicjowana -- reguła powyższa nie obejmuje wogóle takiej sytuacji. Najprostszym sposobem rozwiązania tej trudności jest rozszerzenie konstrukcji e𝐭𝐡𝐞𝐧x:=n:

e::=|e𝐭𝐡𝐞𝐧x:=n|e𝐭𝐡𝐞𝐧x:=

gdzie symbol oznacza brak wartości. Dodajemy również regułę:

𝐥𝐞𝐭x=n𝐢𝐧e2,se2𝐭𝐡𝐞𝐧x:=,s[xn] o ile s(x) jest nieokreślone.

Rozwiązanie to jest odrobinę nieeleganckie, gdyż prawie identyczne reguły musimy napisać dwukrotnie. Widać to np. w poniższych regułach, scalających semantykę dla e𝐭𝐡𝐞𝐧x:=n z semantyką pozostałych wyrażeń:

e,se,se𝐭𝐡𝐞𝐧x:=n,se𝐭𝐡𝐞𝐧x:=n,s
n𝐭𝐡𝐞𝐧x:=n,sn,s[xn]
n𝐭𝐡𝐞𝐧x:=,sn,s o ile s(x) jest określone i s=s{(x,s(x))}


Wariant 2

Zanim przejdziemy do kolejnego wariantu, zastanówmy się czy Iistnieje inny sposób rozwiązania trudności związanej z n=, który pozwalałby uniknąć wprowadzania dodatkowej konstrukcji e𝐭𝐡𝐞𝐧x:=. Pomysł może polegać na rozszerzeniu zbioru 𝐍𝐮𝐦 o dodatkowy element :

n::=|0|1|

Wtedy nie musimy pisać dwóch bardzo podobnych wariantów reguł. Dodatkowo, w tym rozwiązaniu warto poczynić umowę, że s(x)= reprezentuje brak wartości zmiennej x. Wtedy stany są funkcjami całkowitymi z 𝐕𝐚𝐫 w 𝐍𝐮𝐦 przyjmującymi wartość różną od tylko dla skończenie wielu elementów. Pewnym mankamentem jest to, że teraz n= może pojawiać sie w wyrażeniach podobnie jak stałe. Tym niemniej nie musimy adaptować reguł dla stałych tak, aby radziły one sobie z n=, ponieważ wyrażenia zawierające możemy również uważać za roszerzenie składni.

Jeśli jednak dopuścimy symbol w wyrażeniach, to możemy elegancko wybrnąć z sytuacji, rozszerzając operacje arytmetyczne na zbiór 𝐍𝐮𝐦{} tak, aby zachowywały one nieokreśloność:

n+=+n=


Wariant 3

Zrewidujmy teraz podstawowe założenia, które dotychczas poczyniliśmy. Jednym z nich było przyjęcie ogólnej postaci tranzycji:

e,se,s

pozwalającej na zmianę stanu podczas obliczania wyrażenia. Czy faktycznie był to dobry pomysł? Czy moglibyśmy poradzić sobie przy pomocy tranzycji postaci

s,ee,s?

Spróbujmy! Oto nowa wersja jednej z reguł dla 𝐥𝐞𝐭x=e1𝐢𝐧e2 dotycząca kroku wewnątrz e1:

e1,se'1,s𝐥𝐞𝐭x=e1𝐢𝐧e2,s𝐥𝐞𝐭x=e'1𝐢𝐧e2,s

Dotychczas nie ma problemu: podwyrażenie e1 jest prawidłowo obliczane w stanie s. Trudność pojawi się, gdy zakończymy obliczanie e1 i przejdziemy do e2. Oto możliwa reguła:

e,s[xn]e,s[xn]𝐥𝐞𝐭x=n𝐢𝐧e,s𝐥𝐞𝐭x=n𝐢𝐧e,s.

Okazuje się, że wszystko jest w porządku. Wyrażenie e obliczamy w prawidłowym stanie, tzn. z wartością n przypisaną zmiennej x. Mały krok w e daje przyczynek do małego kroku w całym wyrażeniu, a przy tym stan pozostaje niezmieniony. Przy tym wogóle nie potrzebujemy przywracać poprzedniej wartości zmiennej x, ponieważ x zyskuje nową wartość tylko na potrzeby obliczania podwyrażenia e! Można na to również spojrzeć inaczej: informacja o nowej wartości n dla zmiennej x nie jest jawnie dodawana do stanu s, ale jest przechowywana w składni wyrażenia 𝐥𝐞𝐭x=n𝐢𝐧... jako deklaracja x=n. Na końcu musimy oczywiście pozbyć się tej deklaracji za pomocą następującej tranzycji:

𝐥𝐞𝐭x=n𝐢𝐧n,sn,s

Podsumujmy. Okazuje się, że rozwiązanie nie było wcale łatwe, nawet dla tak prościutkiego języka. W przyszłości przekonamy się, że łatwiej jest poradzić sobie z zagadnieniem wiązania identyfikatorów w semantyce naturalnej (duże kroki). W wariancie 1 i 2 wprowadziliśmy do języka dodatkowe elementy, tak, by łatwiej było pisać reguły. W przyszłości będziemy czasem stosować takie podejście. Niekiedy jednak rozszerzanie języka będzie zabronione.


Zadania domowe

Zadanie 1

Zapisz wariant 2 semantyki z poprzedniego zadania.


Zadanie 2

Dotychczas wystąpienie błędu podczas obliczania wyrażenia, np. odwołanie do niezainicjowanej zmiennej, powodowało, że wyrażenie nie posiadało wartości (nie było ciągu tranzycji prowadzących do konfiguracji końcowej). Zmodyfikuj którąś z semantyk z poprzednich zadań tak, aby błąd był komunikowany jako jedna z konfiguracji końcowych. To znaczy: jeśli obliczenie wyrażenia e w stanie s jest niemożliwe bo wystąpił błąd, to

e,s*Blad


Zadanie 3

Rozważ rozszerzenie języka z zadania 2 o wyrażenia boolowskie:

n::=0|1|

x::=(identyfikatory)

b::=𝐭𝐫𝐮𝐞|𝐭𝐫𝐮𝐞|e1e2|¬b|b1b2

e::=n|x|e1+e2|𝐢𝐟b𝐭𝐡𝐞𝐧e2𝐞𝐥𝐬𝐞e3|𝐥𝐞𝐭x=e1𝐢𝐧e2

Zaproponuj semantykę małych kroków dla tego języka. Rozważ różne strategie obliczania wyrażeń boolowskich, oraz podejście leniwe. Na przykład w strategii lewostronnej dla b1b2, gdy b1 zostało obliczone do 𝐭𝐫𝐮𝐞, nie ma wogóle potrzeby obliczania b2.