Semantyka i weryfikacja programów/Ćwiczenia 1: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Sl (dyskusja | edycje)
Nie podano opisu zmian
Mengel (dyskusja | edycje)
(Brak różnic)

Wersja z 09:23, 24 lip 2006

Ćwiczenia 1: Semantyka operacyjna wyrażeń

Zadanie 1

Semantyka języka Tiny z wykładu używała funkcji semantycznych B,E:StateState dla określenie znaczenia wyrażeń boolowskich i arytmetycznych. Zdefiniuj znaczenie wyrażeń za pomocą semantyki operacyjnej, w stylu dużych kroków (semantyka naturalna) i małych kroków.

Rozwiązanie

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

b::=true|false|e1e2|¬b|b1b2|b1b2

e::=0|1||x|e1+e2|e1*e2|e1e2|


Zacznijmy od dużych kroków.

Semantyka naturalna

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

e,snb,sl,

gdzie sState, n jest liczbą całkowitą, nInt, a lBool={true,false}. Tranzycja taka oznacza, że wyrażenie e w stanie s wylicza się do wartości n, oraz że wyrażenie logiczne b w stanie s wylicza się do l. Zauważmy, że zakładamy tu, iż obliczenie wyrażenia nie zmienia stanu (nie ma efektów ubocznych).

W tym celu rozszerzamy zbiór konfiguracji Γ następująco:

Γ=(Instr×State)(Expr×State)(BExpr×State)StateIntBool

gdzie Instr oznacza zbiór instrukcji (jedna z kategorii syntaktycznych języka Tiny), Expr zbiór wyrażeń arytmetycznych a BExpr zbiór wyrażeń boolowskich. State=VarInt. Konfiguracje końcowe pozostają bez zmian (State). Tranzycje dla instrukcji pozostają zasadniczo bez zmian, z tym, że odwołania do funkcji semantycznych dla wyrazżen zastępujemy przez odpowiednie tranzycje. Np. dla instrukcji pętli będziemy mieć następujące reguły:

b,strueI;𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss

b,sfalse𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss

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

n,sn,dla nInt

Zauważmy, iż celowo nie odróżniamy liczby nInt od stałej reprezentującej tę liczbę, która może pojawić się w wyrażeniach zgodnie z przyjętą przez nas składnią. Czyli zakładamy, że Int jest podzbiorem zbioru wyrażeń. W powyższej tranzycji, n po lewej stronie to stała reprezentująca liczbę, która widnieje po prawej stronie.

Analogiczne tranzycje dla stałych boolowskich to:

true,struefalse,sfalse

Analogicznie, czynimy tu założenie, że Bool jest podbiorem wyrażen boolowskich.

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 stanie s, a następnie dodać obliczone wartości. Zauważmy, że nie specyfikujemy kolejności, w jakiej mają się obliczać e1 i e2. I choć tutaj nie ma to żadnego znaczenia, w przyszłości będzie inaczej, gdy jezyk będzie umożliwiał efekty uboczne podzas 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

Oczywiście jeśli b1 oblicza się do false, wartość całego wyrażenia jest false niezależnie od wartości wyrażenia b2. Czyli jeśli zaczniemy od obliczenia b1 i wynikiem będzie false, to nie ma wogóle potrzeby obliczania b2. Oto odpowiednie reguły (nazwijmy je regułami lewo-stronnymi, ponieważ rozpoczynamy od lewego koniunktu):

b1,sfalseb1b2,sfalseb1,strueb2,slb1b2,sl

Wybraliśmy następującą kolejność obliczania wyrażeń: najpierw b_1, potem b_2. Pozostawiamy Czytelnikowi napisanie analogicznych reguł dla kolejności odwrotnej (reguły prawo-stronne).

Rozważmy też następującą kombinację obydwu semantyk (reguły równoległe):

b1,sfalseb1b2,sfalseb2,sfalseb1b2,sfalse

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

b1,strueb2,strueb1b2,strue

Zauważmy, że powyższych reguł nie da sie zaimplementować sekwencyjnie: nie wiadomo czy najpierw obliczać b1 czy b2. Reguły te odpowiadają raczej strategii ,,równoległej: obliczaj ,,jednocześnie b_1 i b_2 do momentu, gdy jedno z nich obliczy się do wynikiem false, albo aż obydwa się zakończą z wynikiem true.

W naszym prostym języku wszystkie cztery warianty są równoważne. Różnice pomiędzy nimi zobaczymy jednak już w następnym zadaniu, w którym pojawi się prosta odmiana efektów ubocznych (błąd wykonania).


Reguły dla pozostałych spójników logicznych oraz dla negacji pozostawiamy jako ćwiczenie. A teraz małe kroki.

Strukturalna semantyka operacyjna (małe kroki)

Chcemy, aby tranzycje dla wyrażeń były postaci: e,se,s i podobnie dla wyrażeń boolowskich: b,sb,s gdzie sState. Przyjmijmy na razie takie same konfiguracje i konfiguracje końcowe jak dla semantyki naturalnej.


Zacznijmy od wyrażeń boolowskich.

true,struefalse,sfalse

Przejdźmy do spójników logicznych, powiedzmy b1b2. Ponieważ opisujemy teraz pojedyncze (małe) kroki składające się na wykonanie programu, musimy podać w jakiej kolejności będą się obliczać b1 i b2. Zacznijmy od strategii lewostronnej:

b1,sb'1,sb1b2,sb'1b2,sb2,sb'2,sl1b2,sl1b2,sl1l2l, o ile l=l1l2

Podobnie jak poprzednio, możemy zaniechać obliczania b2 jeśli b1 oblicza się do false. Oto odpowiednio zmodyfikowane reguły:

b1,sb'1,sb1b2,sb'1b2,sfalseb2,sfalsetrueb2,sb2,s

Analogicznie reguły prawostronne to:

b2,sb'2,sb1b2,sb1b'2,sb1false,sfalseb1true,sb1,s

Reguły równoległe otrzymujemy jako sumę reguł lewo- i prawostronnych (w sumie 6 reguł). Zauważmy, że obliczanie wyrażeń b1 i b2 odbywa się teraz w twz. przeplocie: Pojedynczy krok polega na wykonaniu jednego kroku obliczenia b1 albo jednego kroku obliczenia b2. Zwróćmy też uwagę, że po raz pierwszy nasze tranzycje nie posiadają własności determinizmu: wyrażenie b1b2 może wyewoluować w pojedyńczym kroku albo do b'1b2 albo do b1b'2. Na szczęście, końcowy wynik, do jakiego oblicza się wyrażenie jest zawsze taki sam, niezależnie od przeplotu.

Oto reguła dla negacji:

¬true,sfalse,s¬false,strue,sb,sb,s¬b,s¬b,s

Reguły dla e1e2 są następujące:

e1,se'1,se1e2,se'1e2,se2,se'2,se1e2,se1e'2,sn1n2,strue,s o ile n1n2n1n2,sfalse,s o ile n1>n2

Reguły powyższe zależą od semantyki wyrażen arytmetycznych. Zauważmy, że ponownie pozostawiliśmy dowolność jeśli chodzi o kolejność obliczania wyrażeń arytmetycznych e_1 i e_2.

Rozważmy teraz instrukcję warunkową i instrukcję pętli. Najpierw obliczamy wartość dozoru:

b,sb,s𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,s𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sb,sb,s𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,s𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,s

a gdy dozór jest już obliczony, podejmujemy decyzję. W przypadku instrukcji warunkowej reguły są oczywiste:

𝐢𝐟true𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sI1,s𝐢𝐟false𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sI2,s

Gorzej jest w przypadku instukcji pętli. Reguła mogłaby wyglądać tak:

𝐰𝐡𝐢𝐥𝐞true𝐝𝐨I,sI;𝐰𝐡𝐢𝐥𝐞?𝐝𝐨I,s

ale nie wiemy już, jaki był dozór pętli (widzimy tylko wynik obliczenia tego dozoru w stanie s, true). Możemy odwołać się do tranzycji dużych kroków:

b,strue𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sI;𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sb,sfalse𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss

Takie rozwiązanie nie jest zatem czystą semantyką małych kroków. Istnieją inne możliwe rozwiązania, w stylu małych kroków, których znalezienie pozostawiamy dociekliwemu czytelnikowi.

Na koniec podajemy reguły dla operacji arytmetycznych, na przykładzie dodawania. Przyjmijmy, dla przykładu, strategię lewostronną:

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

Niektóre konfiguracje nie były wogóle przez nas używane. Które?

Zadanie 2

Rozważ dodatkowo operację dzielenia: e::=|e1/e2 i rozszerz semantyki 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, tak w semantyce naturalnej jak i w semantyce małych kroków. 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 w semantyce naturalnej:

e2,s0e1/e2,sBlad

a tak w semantyce małych kroków:

e1/0,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}×State.

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. Zacznijmy od sementyki naturalnej:

e1,sBlade1e2,sBlade2,sBlade1e2,sBlad

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

bBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBladbBlad𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBlad

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

I1,sBladI1;I2,sBladI1,sBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBladI2,sBlad𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,sBladI,sBlad𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,sBlad

I tak dalej.

Pytanie dla Czytelnika: jak napisać tę semantykę w podejściu mało-krokowym?

(Powyżej traktowaliśmy tranzycję I,sBlad jak duży krok. Ale tak naprawdę pojawienie się błędu powinno spowodować natychmiastowe zatrzymanie programu, więc może tranzycję taką można również traktować jak mały krok, I,sBlad?)