Semantyka i weryfikacja programów/Ćwiczenia 3

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania


Zawartość

Kończymy semantykę małych kroków i rozpoczynamy semantykę naturalną (duże kroki). Uzupełnimy semantykę naturalną języka Tiny o semantykę naturalną dla wyrażeń boolowskich i arytmetycznych i semantykę dla błędów wykonania. Wreszcie dodamy instrukcję pętli 𝐥𝐨𝐨𝐩 i instrukcje niestrukturalnego zakończenia pętli (instrukcje 𝐞𝐱𝐢𝐭 i 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞).


Rozszerzenia semantyki języka Tiny

Zadanie 1

Zdefiniuj znaczenie wyrażeń boolowskich i arytmetycznych w języku Tiny, w stylu dużych kroków (semantyka naturalna).

Rozwiązanie

Przypomnijmy składnię wyrażeń boolowskich i arytmetycznych jezyka Tiny:

b::=l|e1e2|¬b|b1b2|

l::=𝐭𝐫𝐮𝐞|𝐭𝐫𝐮𝐞

e::=n|x|e1+e2|

n::=0|1|

x::=(identyfikatory)

Chcemy, aby tranzycje wyrażen wyglądały następująco:

e,snb,sl,

gdzie s𝐒𝐭𝐚𝐭𝐞, n jest liczbą całkowitą, n𝐍𝐮𝐦, a l𝐁𝐨𝐨𝐥={𝐭𝐫𝐮𝐞,𝐭𝐫𝐮𝐞}. Tranzycja taka oznacza, że wyrażenie e w stanie s wylicza się do wartości n oraz analogicznie, wyrażenie logiczne b w stanie s wylicza się do l. Zatem zbiór konfiguracji dla semantyki całego języka Tiny to znów

((𝐄𝐱𝐩𝐁𝐄𝐱𝐩𝐒𝐭𝐦𝐭)×𝐒𝐭𝐚𝐭𝐞)𝐍𝐮𝐦𝐁𝐨𝐨𝐥𝐒𝐭𝐚𝐭𝐞 a konfiguracje końcowe to 𝐒𝐭𝐚𝐭𝐞 tak jak poprzednio. Tranzycje dla instrukcji pozostają zasadniczo bez zmian, z tym, że odwołania do funkcji semantycznych dla wyrażen zastępujemy przez odpowiednie tranzycje. Np. dla instrukcji pętli będziemy mieć następujące reguły:

b,s𝐭𝐫𝐮𝐞I;𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss
b,s𝐭𝐫𝐮𝐞𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss

Podobnie dla instrukcji warunkowej. Teraz zajmiemy się tranzycjami dla wyrażeń. Zacznijmy od stalych arytmetycznych i boolowskich:

n,snl,sl.

Operatory arytmetyczne definiujemy następująco:

e1,sn1e2,sn2n=n1+n2e1+e2,sn

Czyli aby obliczyć sumę e1+e2 w stanie s, trzeba najpierw obliczyć e1 i e2 w tym stanie, a następnie dodać obliczone wartości. Zauważmy, że nie specyfikujemy kolejności, w jakiej mają się obliczać obydwa podwyrażenia. I choć tutaj nie ma to żadnego znaczenia, w przyszłości będzie inaczej, gdy jezyk będzie umożliwiał efekty uboczne podczas obliczania wyrażeń.

Podobne reguły można napisać dla pozostałych operacji arytmnetycznych, oraz dla spójników logicznych:

b1,sl1b2,sl2l=l1l2b1b2,sl

Oto inny wariant, reguły lewo-stronne (ponieważ rozpoczynamy od lewego koniunktu):

b1,s𝐭𝐫𝐮𝐞b1b2,s𝐭𝐫𝐮𝐞b1,s𝐭𝐫𝐮𝐞b2,slb1b2,sl

Inny wariant to wariant prawo-stronny ( najpierw b2, potem b1). Wreszcie rozważmy kombinację obydwu semantyk (reguły równoległe):

b1,s𝐭𝐫𝐮𝐞b1b2,s𝐭𝐫𝐮𝐞b2,s𝐭𝐫𝐮𝐞b1b2,s𝐭𝐫𝐮𝐞

Czyli jeśli którekolwiek z podwyrażeń daje wynik 𝐭𝐫𝐮𝐞, to taki wynik zyskuje całe wyrażenie. Dodatkowo potrzebujemy jeszcze reguły:

b1,s𝐭𝐫𝐮𝐞b2,s𝐭𝐫𝐮𝐞b1b2,s𝐭𝐫𝐮𝐞

W naszym prostym języku wszystkie cztery warianty są równoważne. Reguły dla pozostałych spójników logicznych, dla negacji oraz dla instrukcji przypisania pozostawiamy jako proste ćwiczenie.


Zadanie 2

Rozważ dodatkowo operację dzielenia:

e::=|e1/e2

i rozszerz semantykę z poprzedniego zadania. Dzielenie przez zero jest błądem i kończy natychmiast wykonanie programu. Oprócz stanu wynikiem programu powinna byc informacja o błędzie, jeśli błąd wystąpił.

Rozwiązanie (szkic)

Dopóki nie wystąpi błąd dzielenia przez zero, semantyka programu powinna być identyczna jak w poprzednim zadaniu. Zatem pozostawiamy wszystkie reguły z poprzedniego zadania. Dodatkowo potrzebujemy reguł, które opiszą

  • kiedy powstaje błąd oraz
  • jak zachowuje się program po wystąpieniu błędu

Zaczynamy od pierwszego punktu. W tym celu dodajemy do konfiguracji jedną konfigurację końcową Blad. Reguła opisująca powstanie błędu może wyglądać np. tak:

e2,s0e1/e2,sBlad

Pomijamy tutaj reguły dla przypadku, gdy e2 oblicza się do wartości różnej od zera. Ponadto dla czytelności przyjęliśmy, że wynikiem tranzycji jest wyłącznie informacja o błędzie, a stan jest zapominany. Bez trudu możnaby wszystkie reguły (zarówno te powyżej jak i te poniżej) zmodyfikować tak, by wraz z informacją o błędzie zwracany był też stan, w którym błąd się pojawił. Np. ostatnia reguła wyglądałaby następująco:

e1/0,sBlad,s

i zamiast pojedyńczej konfiguracji końcowej Blad potrzebowalibyśmy oczywiście całego zbioru {Blad}×𝐒𝐭𝐚𝐭𝐞.

Przejdźmy teraz do drugiego punktu. Potrzebujemy dodatkowych reguł, które zagwarantują, że błąd, raz pojawiwszy się, propaguje się przez wszystkie konstrukcje składniowe, a normalne obliczenie wyrażenia jest wstrzymame.

e1,sBlade1e2,sBlade2,sBlade1e2,sBlad

Następnie, błąd w wyrażeniu powinien wstrzymać normalne wykonanie instrukcji:

bBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBlad
bBlad𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBlad
e,sBladx:=e,sBlad

I wreszcie błąd powinien propagować się do kolejnych instrukcji:

I1,sBladI1;I2,sBlad
b,s𝐭𝐫𝐮𝐞I1,sBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBladb,s𝐭𝐫𝐮𝐞I2,sBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBlad
b,s𝐭𝐫𝐮𝐞I,sBlad𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBladb,s𝐭𝐫𝐮𝐞I,ss𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBlad𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBlad


Zadanie 3

Rozszerz język Tiny o następującą instrukcję pętli:

I::=𝐥𝐨𝐨𝐩I|𝐞𝐱𝐢𝐭|𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞

𝐥𝐨𝐨𝐩I to instrukcja pętli, I stanowi instrukcję wewnętrzną. Instrukcja 𝐞𝐱𝐢𝐭 wychodzi z nabliższej pętli 𝐥𝐨𝐨𝐩 i kontynuuje wykonanie programu od pierwszej instrukcji za tą pętlą. Instrukcje 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 powraca na początek instrukcji wewnętrznej najbliższej pętli.

Pozważ zarówno semantykę naturalną jak i semantykę małych kroków.


Rozwiązanie 1 (semantyka naturalna)

Dodamy do reguł semantyki naturalnej dla języka Tiny kilka nowych reguł opisujących nową konstrukcję języka i jej interakcję z pozostałymi konstrukcjami.

Pomysł polega na dodaniu specjalnych konfiguracji zawierających informację o tym, że została wykonana instrukcja 𝐞𝐱𝐢𝐭 lub 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞. Oto odpowiednie reguły:

𝐞𝐱𝐢𝐭,ss,było-𝐞𝐱𝐢𝐭𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞.

Czyli instrukcje 𝐞𝐱𝐢𝐭 i 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 nie modyfikują stanu s, ale zostawiają po sobie \ślad". Zauważmy, że taki ślad zostaje pozostawiony tylko wtedy, jesli nie było dotychczas innego śladu, to znaczy jeśli 𝐞𝐱𝐢𝐭 (lub 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞) zostało wykonane w zwykłym stanie s. Oczywiście poszerzamy odpowiednio zbiór konfiguracji o:

</math>[ \mathbf{State} \times \{ \mbox{było-}\mathbf{exit}, \mbox{było-}\mathbf{continue} \} </math> , Pytanie: jakie powinno być zachowanie 𝐞𝐱𝐢𝐭 i 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 w konfiguracji s,było-𝐞𝐱𝐢𝐭 lub s,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞? Czy instrukcje 𝐞𝐱𝐢𝐭 i 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 będą faktycznie wykonywane w takich konfiguracjach?

Zapiszmy teraz jak inne instrukcje korzystają z dodatkowej informacji (śladu) zawartej w konfiguracjach. Oczywiście beneficjentem tej informacji jest instrukcji 𝐥𝐨𝐨𝐩:

I,ss,było-𝐞𝐱𝐢𝐭𝐥𝐨𝐨𝐩I,ssI,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞𝐥𝐨𝐨𝐩I,ss𝐥𝐨𝐨𝐩I,ss

Czyli w zależności od tego, jaki ślad został zarejestrowany podczas wykonania I, albo kończymy wykonanie pętli 𝐥𝐨𝐨𝐩, albo rozpoczynamy kolejną iterację. Zauważmy, że stan s może być różny od s, ponieważ zanim wykonała się ktoraś z instrukcji 𝐞𝐱𝐢𝐭 lub 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞, mogły zostać zmienione wartości niektórych zmiennych.

Oczywiście, jeśli instrukcja wewnętrzna I zakończyła się normalnie, kontynuujemy wykonanie pętli podobnie jak w przypadku wywołania 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞:

I,ss𝐥𝐨𝐨𝐩I,ss𝐥𝐨𝐨𝐩I,ss

Pytanie: czy potrzebujemy dodatkowo reguł postaci:

I,ss𝐥𝐨𝐨𝐩I,ss,było-𝐞𝐱𝐢𝐭𝐥𝐨𝐨𝐩I,ss,było-𝐞𝐱𝐢𝐭I,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞𝐥𝐨𝐨𝐩I,ss,było-𝐞𝐱𝐢𝐭𝐥𝐨𝐨𝐩I,ss,było-𝐞𝐱𝐢𝐭

Okazuje się że nie, ponieważ ślad powinien zostać zawsze wymazany przez pętlę 𝐥𝐨𝐨𝐩. Poza tym reguły te wykraczałyby poza klasyczna podejście semantyki naturalnej, w którym zwykle bezpośrednio definiujemy tranzycje postaci I,ss.

Teraz musimy określić zachowanie wszystkich instrukcji w sytuacji, gdy bieżąca konfiguracja zawiera już ślad. Zasadniczo, powinniśmy zaniechać wykonania instrukcji (w przypadku pętli, powinniśmy zaniechać dalszego iterowania tej pętli). Oto odpowiednie reguły dla było-𝐞𝐱𝐢𝐭:

I1,ss,było-𝐞𝐱𝐢𝐭I1;I2,ss,było-𝐞𝐱𝐢𝐭I1,ssI2,ss,było-𝐞𝐱𝐢𝐭I1;I2,ss,było-𝐞𝐱𝐢𝐭
b,s𝐭𝐫𝐮𝐞I1,ss,było-𝐞𝐱𝐢𝐭𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,ss,było-𝐞𝐱𝐢𝐭b,s𝐭𝐫𝐮𝐞I2,ss,było-𝐞𝐱𝐢𝐭𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,ss,było-𝐞𝐱𝐢𝐭
b,s𝐭𝐫𝐮𝐞I,ss,było-𝐞𝐱𝐢𝐭𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss,było-𝐞𝐱𝐢𝐭b,s𝐭𝐫𝐮𝐞I,ss𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss,było-𝐞𝐱𝐢𝐭𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss,było-𝐞𝐱𝐢𝐭

Pominęliśmy zupełnie analogiczne reguły dla konfiguracji s,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞. Zwróćmy szczególnie uwagę na ostatnią regułę dla pętli 𝐰𝐡𝐢𝐥𝐞: wyraża ona przypadek, gdy ślad 𝐞𝐱𝐢𝐭 został wygenerowany nie w pierwszym obiegu pętli, ale w którymś z kolejnych. Musieliśmy rozważyc również ten przypadek, ponieważ wybraliśmy podejście dużych kroków; w podejściu małych kroków nie byłoby to zapewne konieczne. Zauważmy też, że dla pętli 𝐰𝐡𝐢𝐥𝐞 nie rozpatrujemy przypadku, gdy dozór b oblicza się do 𝐭𝐫𝐮𝐞, gdyż w tym przypadku nie ma możliwości wygenerowania śladu.

Zauważmy, że nasze reguły nie pozwalają na dodanie drugiego (kolejnego) śladu!


Rozwiązanie 2 (małe kroki)

W semantyce naturalnej musieliśmy napisać wiele reguł aby zapewnić pomijanie instrukcji w sytuacji, gdy został juz zarejestrowany jakiś ślad. Było to dość uciążliwe. Okazuje się, że podejście mało-krokowe oferuje możliwość bardziej eleganckiego rozwiązania.

Punktem startowym sę teraz reguły mało-krokowe dla języka Tiny.

Podobnie jak poprzednio rozszerzymy zbiór konfiguracji i podobnie opiszemy, jak powstaje ślad:

𝐞𝐱𝐢𝐭,ss,było-𝐞𝐱𝐢𝐭𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞.

Ponadto musimy określić sposób, w jaki mały krok mniejszej instrukcji zostaje zamieniony na mały krok większej. Np.

I1,ss,było-𝐞𝐱𝐢𝐭I1;I2,ss,było-𝐞𝐱𝐢𝐭 i analogicznie dla było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞.

Zauważmy, że nie musimy zajmowac się przypadkiem, gdy ślad powstaje w I2, bo wybraliśmy podejście mało-krokowe. Ponadto, nie musimy opisywać instrukcji warunkowej i pętli 𝐰𝐡𝐢𝐥𝐞, ponieważ ślad nie może powstać podczas oblcizania dozoru!

Wreszcie zobaczmy jak zachowuje się pętla 𝐥𝐨𝐨𝐩:

I,ss,było-𝐞𝐱𝐢𝐭𝐥𝐨𝐨𝐩I,ssI,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞𝐥𝐨𝐨𝐩I,s𝐥𝐨𝐨𝐩I,s

Reguły te są prawie identyczne do reguł semantyki naturalnej dla tej sytuacji! Natomiast korzystamy z tego, że w podejściu mało-krokowym zmiana konfiguracji na s,było-𝐞𝐱𝐢𝐭 czy s,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 jest natychmiast widoczna w instrukcji 𝐥𝐨𝐨𝐩I, nawet jeśli 𝐞𝐱𝐢𝐭 czy CONT𝐢𝐧IE zostało wywołane głęboko wewnątrz I!

Niestety, powyzsze reguły nie są poprawne! Dlaczego? Jak zwykle w semantyce małych kroków, wykonując instrukcję wewnętrzną zapominamy stopniowo jaka była ona na początku. I w związku z tym nie potrafimy poprawnie powrócić do wykonywania pętli 𝐥𝐨𝐨𝐩 po wywołaniu 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞.

Prostym sposobem poprawienia naszego blędu jest rozszerzenie składni tak, aby możliwe było jednorazowe rozwinięcie pętli 𝐥𝐨𝐨𝐩:

𝐥𝐨𝐨𝐩I,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s.

Teraz możemy zapisać dwie powyższe reguły dla 𝐥𝐨𝐨𝐩 w poprawnej wersji, pamiętając o tym, że było-𝐞𝐱𝐢𝐭 i było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 pojawią się nie w instrukcji wewnętrznej, ale w jej kopii umieszczonej przed 𝐭𝐡𝐞𝐧:

I,ss,było-𝐞𝐱𝐢𝐭I𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,ssI,ss,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞I;𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s𝐥𝐨𝐨𝐩I,s

A ponieważ wewnątrz kopii mogą być zagnieżdzone instrukcje 𝐥𝐨𝐨𝐩 potrzebujemy dodatkowej reguły:

I,sI,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s

Na koniec zauważmy, że stan s w pierwszych dwóch z powyższych trzech reguł nigdy nie jest różny od s. Zatem równoważnie moglibyśmy zamienić s na s w powyższych dwóch regułach. Ale wtedy okazuje się , s w parze z było-𝐞𝐱𝐢𝐭 albo było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞 jest nadmiarowe i może zostać wyeliminowane.

Zatem ostatecznie nasze reguły mogą wyglądać tak:

𝐞𝐱𝐢𝐭,sbyło-𝐞𝐱𝐢𝐭𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞,sbyło-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞
I1,sbyło-𝐞𝐱𝐢𝐭I1;I2,sbyło-𝐞𝐱𝐢𝐭I1,sbyło-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞I1;I2,sbyło-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞
𝐥𝐨𝐨𝐩I,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s.
I,sbyło-𝐞𝐱𝐢𝐭I𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,ssI,sbyło-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞I;𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s𝐥𝐨𝐨𝐩I,s
I,sI,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,sI𝐭𝐡𝐞𝐧𝐥𝐨𝐨𝐩I,s

a zbiór konfiguracji poszerzamy tylko o {było-𝐞𝐱𝐢𝐭,było-𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞}.


Zadania domowe

Zadanie 1

Zaproponuj semantykę mało-krokową języka z zdania 2. Czy różni się ona istotnie od semantyki naturalnej?


Zadanie 2

Napisz semantyję naturalną dla nieznacznie rozszerzonej wersji instrukcji 𝐥𝐨𝐨𝐩:

I::=x:𝐥𝐨𝐨𝐩I|𝐞𝐱𝐢𝐭x|𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞x

Identyfikator x pełni tutaj rolę etykiety przypisanej instrukcji 𝐥𝐨𝐨𝐩, jest też parametrem dwóch pozostałych instrukcji. 𝐞𝐱𝐢𝐭x kończy teraz najbliższą otaczającą pętlę 𝐥𝐨𝐨𝐩 o etykiecie x. Podobnie 𝐜𝐨𝐧𝐭𝐢𝐧𝐮𝐞x wznawia najbliższą pętlę o etykiecie x. Np. program

Parser nie mógł rozpoznać (błąd składni): {\displaystyle x: \mathbf{loop}\,\\ \quad \quad a := 1; \quad \quad y: \mathbf{loop}\, \quad \quad \quad \quad \mathbf{exit} x; \quad \quad \quad \quad a := a-10; \quad \quad a := a+1; a := a+2; }

kończy działanie z wartością zmiennej a=3. Zauważmy, że musieliśmy jakoś określić, do wnętrza której pętli 𝐥𝐨𝐨𝐩 należą trzy ostatnie instrukcje. Użyliśmy to tego celu wcięć a niejednoznaczność bierze się oczywiście stąd, że pracujemy ze składnią abstrakcyjną. Składnia konkretna zawierałaby prawdopodobnie jakąś konstukcję zamykającą pętlę 𝐥𝐨𝐨𝐩, np. 𝐥𝐨𝐨𝐩I𝐞𝐧𝐝𝐥𝐨𝐨𝐩.


Zadanie 3

Napisz semantykę naturalną i mało-krokową dla rozszerzenia języka Tiny o wyrażenia z efektami ubocznymi:

e::=|𝐝𝐨I𝐭𝐡𝐞𝐧e|x:=e|x++|

Obliczenie wyrażenia 𝐝𝐨I𝐭𝐡𝐞𝐧e polega na wykonaniu I a potem na obliczeniu e. Wartość wyrażenia x:=e jest taka jak wartość wyrażenia e a efektem ubocznym jest podstawienie tej wartości na zmienną x.