Semantyka i weryfikacja programów/Ćwiczenia 5

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

Zawartość

Kontynuujemy ćwiczenie semantyki naturalnej, dodając pewne konstrukcje do języka Tiny. W szczególności rozszerzymy go o deklaracje zmiennych (bloki). Po raz pierwszy roszdzielimy informację przechowywaną w konfiguracji na środowisko określające wiązanie identyfikatorów i stan przechowujący wartości zmiennych. Będzie to przygotowanie do kolejnych zajęć.


Zadania z rozwiązaniami

Zadanie 1

Rozszerzamy język Tiny następująco:

I::=|𝐟𝐨𝐫x=e1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2|𝐟𝐚𝐢𝐥

Znaczenie instrukcji 𝐟𝐨𝐫x=e1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2 jest następujące. Obliczamy wartości wyrażeń e1 i e2. Jeśli pierwsza z nich jest mniejsza lub równa od drugiej, to podstawiamy pierwszą wartość (wartość wyrażenia e1) na zmienną x i uruchamiamy I1. Jeśli w I1 nie zostanie napotkana instrukcja 𝐟𝐚𝐢𝐥, kończymy instrukcję 𝐟𝐨𝐫 i przyracamy wartość zmiennej x sprzed tej instrukcji. Natomiast jeśli w I1 zostanie napotkana instrukcja 𝐟𝐚𝐢𝐥, podstawiamy na x kolejną, o jeden większą wartość, przywracamy wartości wszystkich pozostałych zmiennych sprzed instrukcji 𝐟𝐨𝐫, i ponownie wykonujemy I1. Powtarzamy w ten sposób aż I1 zakończy się nie napotkawszy 𝐟𝐚𝐢𝐥, albo wartość zmiennej x przekroczy wartość wyrażenia e2 obliczoną na początku. W pierwszym przypadku kończymy instrukcję 𝐟𝐨𝐫 i przyracamy wartość zmiennej x sprzed tej instrukcji. W drugim przywracamy wartości wszystkich zmiennych sprzed instrukcji 𝐟𝐨𝐫 i uruchamiamy I2.


Przykład

x:=0;y:=1;𝐟𝐨𝐫x:=1𝐭𝐨5𝐭𝐫𝐲y:=y+1;𝐢𝐟x4𝐭𝐡𝐞𝐧𝐟𝐚𝐢𝐥𝐞𝐥𝐬𝐞z:=x𝐞𝐥𝐬𝐞𝐬𝐤𝐢𝐩

Wartości zmiennych po zakończeniu programu to: x=0,y=2,z=5.


Rozwiązanie

Będziemy posługiwać się jak zwykle tranzycjami postaci:

I,sse,sn.

Zacznijmy od najprostszego przypadku, gdy nie ma wogóle potrzeby uruchamiania instrukcji wewnętrznej I1, ponieważ wartość wyrażenia e1 jest większa od e2:

e1,sn1e2,sn2I2,ss𝐟𝐨𝐫x=e1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2,ss o ile n1>n2.

Po prostu uruchamiamy instrukcję I2 w stanie s, ignorując I1.

Kolejny nietrudny przypadek to sytuacja, w której n1n2 a wykonanie instrukcji wewnętrznej I1 kończy się sukcesem, tzn. nie następuje wywołanie instrukcji 𝐟𝐚𝐢𝐥. Oto reguła dla tego przypadku:

e1,sn1e2,sn2I1,s[xn1]s𝐟𝐨𝐫x=e1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2,ss[xs(x)] o ile n1n2.

Tym razem uruchamiamy instrukcję I1, podstawiając pod zmienną x wartość n1. Zwróćmy uwagę, że po zakończeniu I1 przywracamy wartość zmiennej x sprzed jej wykonania.

Pozostał nam trzeci przypadek, gdy n1n2 a wykonanie instrukcji I1 zakończyło się wykonaniem instrukcji 𝐟𝐚𝐢𝐥 gdzieś wewnątrz I1. Aby poprawnie opisać takie zdarzenie, potrzebujemy dodatkowych tranzycji postaci:

I,sbyło-𝐟𝐚𝐢𝐥.

Mówiąc formalnie, poszerzamy zbiór konfiguracji o jeden element było-𝐟𝐚𝐢𝐥. Zauważmy, że po prawej stronie tranzycji dkrok nie ma wogóle stanu. Nie potrzebujemy go dla opisania semantyki instrukcji 𝐟𝐨𝐫: jeśli wystąpi 𝐟𝐚𝐢𝐥, powtarzamy I1 dla większej o 1 wartości zmiennej x, ale pozostałym zmiennym przywracamy wartość sprzed I1. A więc załóżmy, że

I1,s[xn1]było-𝐟𝐚𝐢𝐥.

W takiej sytuacji powinniśmy przypisać n1+1 na zmienną x i spróbować ponownie wykonać I1 przy wartościach wszystkich pozostałych zmiennych takich, jak na początku instrukcji 𝐟𝐨𝐫. I powtarzać ten schemat aż do skutku, tzn. aż do mementu, gdy zaistnieje któraś z poprzednich dwóch (prostych) sytuacji. Oto odpowiednia reguła:

Parser nie mógł rozpoznać (nieznana funkcja „\drok”): {\displaystyle \frac{e_1, s \,\longrightarrow\, n_1 \quad \quad e_2, s \,\longrightarrow\, n_2 \quad \quad I_1, s[x \mapsto n_1] \,\longrightarrow\, \mbox{było-}\mathbf{fail} \quad \quad \mathbf{for}\, x = e_1 + 1 \,\mathbf{to}\, e_2 \,\mathbf{try}\, I_1 \,\mathbf{else}\, I_2, s \drok s'} {\mathbf{for}\, x = e_1 \,\mathbf{to}\, e_2 \,\mathbf{try}\, I_1 \,\mathbf{else}\, I_2, s \,\longrightarrow\, s'} \quad \mbox{ o ile } n_1 \leq n_2. }

Zwróćmy uwagę na pewnien niezwykle istotny szczegół: po zakończeniu wykonania instrukcji 𝐟𝐨𝐫x=e1+1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2 otrzymujemy stan s, w którym nie przywracamy wartości zmiennej x. Gdybyśmy tak zrobili, tzn. gdybyśmy zastąpili s przez s[xs(x)], nasze semantyka byłaby niepoprawna. Dlaczego? Dlatego, że nie wiemy tak naprawdę, czy powinniśmy przywracać wartość zmiennej x czy nie. Jeśli ostatecznie nasza instrukcja 𝐟𝐨𝐫 zakończyla się przez bezbłędne zakończenie instrukcji I1 (przypadek drugi), to powinniśmy to zrobić; ale jeśli zakończyła się wykonaniem instrukcji I2 (przypadek pierwszy), to powinniśmy pozostawić wartość zmiennej x taką, jaka jest ona po zakończeniu I2. A zatem w powyższej regule dla przypadku trzeciego nie przywracamy wartości zmiennej x; jeśli było to konieczne, to zostało już wykonane ,,głębiej", dzięki regule dla przypadku drugiego oraz dzięki temu, że instrukcja 𝐟𝐨𝐫x=e1+1𝐭𝐨e2𝐭𝐫𝐲I1𝐞𝐥𝐬𝐞I2 wykonywana jest w orginalnym stanie s, a nie w stanie, w którym kończy się I1 (tego ostatniego stanu nawet nie znamy).

Na zakończenie prezentujemy reguły niebędne do tego, aby wygenerować było-𝐟𝐚𝐢𝐥:

𝐟𝐚𝐢𝐥,sbyło-𝐟𝐚𝐢𝐥

oraz aby wystąpienie 𝐟𝐚𝐢𝐥 umiejscowione gdzieś ,,głęboko" zostało rozpropagowane do najbliższej otaczającej instrukcji 𝐟𝐨𝐫. Jeśli pojawił się 𝐟𝐚𝐢𝐥, powinniśmy zaniechać dalszego wykonania instrukcji a w przypadku pętli, powinniśmy zaniechać dalszego iterowania tej pętli. Oto odpowiednie reguły:

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-𝐟𝐚𝐢𝐥 i analogicznie gdy b,s𝐭𝐫𝐮𝐞

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-𝐟𝐚𝐢𝐥

Widać podobieństwo do analogicznych reguł dla pętli 𝐥𝐨𝐨𝐩I, którą zajmowaliśmy się na wcześniejszych zajęciach.

Zauważmy, że jesli 𝐟𝐚𝐢𝐥 zostało wykonane poza jakąkolwiek pętlą 𝐟𝐨𝐫, to program ,,zakończy się" w konfiguracji było-𝐟𝐚𝐢𝐥. Możemy zatem tę właśnie konfigurację uznać za konfigurację końcową, informującą o porażce wykonania programu.


Zadanie 2 (bloki i deklaracje zmiennych)

Rozszerz semantykę języka Tiny o deklarację zmiennej:

I::=|𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝

Zasięgiem zmiennej x jest instrukcja I czyli wnętrze bloku, w którym jest zadeklarowana. Zakładamy zwykłe (statyczne) reguły widoczności, przesłaniania, itp.


Rozwiązanie 1 (tylko stan)

Oczywiście powinniśmy odróżniać zmienne zadeklarowane (i zarazem zainicjowane) od tych niezadeklarowanych. Zatem przyjmijmy, że

𝐒𝐭𝐚𝐭𝐞=𝐕𝐚𝐫fin𝐍𝐮𝐦.

Zachowuujemy wszystkie reguły semantyczne dla języka Tiny i dodajemy jedną nową:

e,snI,s[xn]s𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝,ss

mówiącą, że instrukcja wewnętrzna I ewaluowana jest w stanie, w którym dodatkow zaznaczono wartość zmiennej x równą wartości, do której oblicza się e. Oczywiście takie rozwiązanie jest niepoprawne, gdyż wartość zmiennej x pozostaje w stanie nawet po wyjściu z bloku (,,wycieka" z bloku). Musimy dodać ,,dealokację" zmiennej x:

e,snI,s[xn]s𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝,ss o ile s=s[xs(x)]

I znów napotykamy podobnie trudności jak na wcześniejszych zajęciach: powyższa reguła nie obejmuje przypadku, gdy s(x) jest nieokreślone. I choć moglibyśmy naprawić ten mankament podobnie jak kiedyś (co pozostawiamy Czytelnikowi), istnieje inne, bardzo eleganckie i elastyczne rozwiązanie tego problemu, które teraz omówimy.


Rozwiązanie 2 (stan i środowisko)

Podstawowy pomysł polega na rozdzielenie informacji przechowywanej dotychczas w stanie na dwa ,,etapy". Pierwszy z nich, odwzorowuje identyfikatory zmiennych na lokacje, a drugi lokacje na wartości. Mamy więc środowiska Parser nie mógł rozpoznać (błąd składni): {\displaystyle E \in \mathbf{Env}], będące funkcjami częściowymi z <math> \mathbf{Var} } do 𝐋𝐨𝐜, zbioru lokacji:

𝐄𝐧𝐯=𝐕𝐚𝐫fin𝐋𝐨𝐜

oraz stany, będące teraz funkcjami częściowymi z 𝐋𝐨𝐜 do 𝐍𝐮𝐦:

𝐒𝐭𝐚𝐭𝐞=𝐋𝐨𝐜fin𝐍𝐮𝐦.

Intuicyjnie można myśleć o lokacjach jak o lokacjach w pamięci operacyjnej maszyny. Środowisko zawiera informację o lokacji, w której przechowywana jest wartość danej zmiennej. A stan opisuje właśnie zawartość używanych lokacji.

Zakładamy przy tym, że mamy do dyspozycji nieskończony zbiór lokacji 𝐋𝐨𝐜={l0,l1,} i że w każdym momencie tylko skończenie wiele spośród nich jest wykorzystywane. Formalnie mowiąc, dziedzina funkcji częściowej s jest zawsze skończony. Daje nam to pewność, że zawsze jest jakaś nieużywana lokacja.

Środowisko początkowe, w którym uruchamiany będzie program, będzie z reguły puste. Ponadto obraz funkcji częściowej E będzie zawsze zawarty w zbiorze aktualnie używanych lokacji, czyli zawarty w dziedzinie funkcji częściowej s, oznaczanej Parser nie mógł rozpoznać (nieznana funkcja „\dom”): {\displaystyle \dom(s) } .

Pożytek z tego podziału na dwa etapy będzie taki, że będziemy umieli łatwo i elastycznie opisywać deklaracje zmiennych, przesłanianie identyfikatorów, itp. Tranzycje będą teraz postaci:

I,s,Es

czyli instrukcja I będzie modyfikować stan ale nie będzie zmieniać środowiska E. Dla czytelności będziemy zapisywać nasze reguły w następujący sposób:

EI,ss

podkreślając w ten sposób, że środowisko nie ulega zmianie. Ale należy pamiętać, że konfiguracja, w której ,,uruchamiamy" instrukcję I składa się naprawdę z pary (E,s).

Deklarując nową zmienną x dodamy po prostu do E parę (x,l), gdzie l jest nową, nieużywaną dotychczas lokacją. Dla wygody zapisu załóżmy, że mamy do dyspozycji funkcję funkcję

new:𝐒𝐭𝐚𝐭𝐞𝐋𝐨𝐜

która zwraca jakąś nową, nieużywaną lokację. Formalnie wymagamy, by

Parser nie mógł rozpoznać (nieznana funkcja „\dom”): {\displaystyle \mathtt{new}(s) \notin \dom(s). }

Dla ilustracji popatrzmy na przykładową regułę dla deklaracji.

Ee,snE[xl]I,s[ln]sE𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝,ss gdzie l=new(s)

Zauważmy, że stan s po zakończeniu instrukcji 𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝 zawiera informację o zmiennej lokalnej x, tzn. s(l) jest określone. Ale lokacja l jest ,,nieosiągalna" w środowisku E, gdyż para xl została dodana tylko do środowiska, w którym ewaluuje się wnętrze bloku, a środowisko E całego bloku nie jest modyfikowane.


Poniżej przedstawiamy reguły dla pozostałych instrukcji. Przede wszystkim złożenie sekwencyjne:

EI1,ssE,I2,ssEI1;I2,ss

Reguła ta uzmysławia nam różnicę pomiędzy środowiskiem a stanem: środowisko pozostaje to samo, gdy przechodzimy od jednej instrukcji do następn\ej, a stan oczywiście ewoluuje wraz ze zmieniającymi się wartościami zmiennych.

Reguły dla przypisania, instrukcji warunkowej i pętli nie przedstawiają żadnych nowych trudności. Musimy tylko najpierw ustalić postać reguł dla wyrażeń:

Ee,sn

która jest zupełnie naturalna w naszym podejściu opartym o środowiska i stany. Reguły mogą wyglądać np. tak:

Ee,snEx:=e,ss[xn]E𝐬𝐤𝐢𝐩,ss

Eb,s𝐭𝐫𝐮𝐞EI1,ssE𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,ssEb,s𝐭𝐫𝐮𝐞EI2,ssE𝐢𝐟b𝐭𝐡𝐞𝐧I1𝐞𝐥𝐬𝐞I2,ss

E𝐢𝐟b𝐭𝐡𝐞𝐧(𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I)𝐞𝐥𝐬𝐞𝐬𝐤𝐢𝐩,ssE𝐰𝐡𝐢𝐥𝐞b𝐝𝐨I,ss

Reguły dla wyrażeń są oczywiste:

En,snEx,sn o ile E(x)=l𝐋𝐨𝐜 i s(l)=n

i tak dalej -- pomijamy pozostałe reguły.


Wariant (dealokacja)

Przypomnijmy sobie semantykę bloku

Ee,snE[xl]I,s[ln]sE𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝,ss gdzie l=new(s)

i zastanówmy się, jak ,,posprzątać" po zakończeniu wykonania bloku, tzn. zwolnić lokację l, która była używana tylko w tym bloku i w związku z tym nie będzie już potrzebne. Oznaczałoby to przywrócenie lokacji l do puli wolnych (nieużywanych) lokacji.

Zmodyfikowana reguła dla instrukcji bloku powinna wyglądać mniej więcej tak:

Ed,s(E,s)EI,ssE𝐛𝐞𝐠𝐢𝐧d;I𝐞𝐧𝐝,ss¯

gdzie s¯ to stan s ,,okrojony" do dziedziny stanu s. Zatem powinniśmy po prostu przywrócić nieokreśloność stanu dla lokacji l. Natomiast oczywiście nie ma potrzeby dealokownia środowiska! Oto rozwiązanie:

Ee,snE[xl]I,s[ln]sE𝐛𝐞𝐠𝐢𝐧𝐯𝐚𝐫x=e;I𝐞𝐧𝐝,ss{(l,s(l))} gdzie l=new(s)


Zadania domowe

Zadanie 1

Napisz semantykę naturalną dla następującego rozszerzenia języka Tiny:

Parser nie mógł rozpoznać (nieznana funkcja „\ldotes”): {\displaystyle I \, ::= \,\, \ldotes \,\,|\,\, \mathbf{throw}\, x \,\,|\,\, \,\mathbf{try}\, I_1 \,\mathbf{catch}\, exc\, I_2 }

exc::=x|𝐚𝐧𝐲

Instrukcja 𝐭𝐡𝐫𝐨𝐰x oznacza podniesienie wyjątku o nazwie x. Instrukcja I=𝐭𝐫𝐲I1𝐜𝐚𝐭𝐜𝐡excI2 wykonuje I1. Jeśli podczas wykonania I1 zostanie podniesiony wyjątej x, i exc=x albo exc=𝐚𝐧𝐲, to następuje przerwanie I1 i sterowanie zostaje przeniesione do I2 (następuje obsługa wyjątku). Jeśli zaś podczas wykonania I1 zostanie podniesiony wyjątej x, i excx i exc𝐚𝐧𝐲, to obsługa wyjątku przekazana jest do najbliższej instrukcji 𝐭𝐫𝐲 otaczającej I. Umawiamy się, że 𝐭𝐫𝐲I1𝐜𝐚𝐭𝐜𝐡excI2 otacza I1 i wszystkie instrukcje wewnątrz I1, ale nie otacza I2.


Zadanie 2

Zaproponuj modyfikację semantyki języka Small, w której deklaracja jest wykonywana ,,równolegle\, analogicznie do przypisania równoległego. Przy takiej semantyce kolejność poszczególnych deklaracji nie powinna wpływać na wynik programu.