Semantyka i weryfikacja programów/Ćwiczenia 1
Ćwiczenia 1: 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ą:
Wynikiem wyrażenienia warunkowego jest wartość wyrażenia , o ile wyrażenie oblicza się do wartości różnej od zera; w przeciwnym przypadku wynikiem jest wartość wyrażenia .
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, . Podobnie, niech oznacza zbiór identyfikatorów, które mogą być nazwami zmiennych; . Wreszcie, niech oznacza zbiór wyraż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 .
W naszej semantyce będziemy potrzebowac tranzycji dwóch postaci. Po pierwsze, tranzycja
oznaczająca mały krok w trakcie obliczania wyrażenia w stanie , w wyniku którego wyewoluowało do . Stan nie ulega zmiania podczas obliczania wyrażenia, więc to samo figuruje po lewej i prawej stronie strzałki.
Po drugie, tranzycja
będzie oznaczaczać, że wyrażenie jest już policzone, a jego wartością jest .
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: 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:
Zmienna oblicza się do swojej wartości w bieżącym stanie:
Teraz zajmiemy się dodawaniem . Ponieważ semantyka jest w stylu małych kroków, musimy zdecydować się czy najpierw obliczyć pierwszy (lewy) składnik czy drugi? Jeśli wybierzemy lewy (strategia lewostronna), otrzymamy regułę:
Czyli mały krok w stanowi też mały krok w . Po zakończeniu obliczania przechodzimy do :
A na końcu dodajemy:
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 moglibyśmy równie dobrze pisać np. .
Inna możliwą strategią obliczania jest strategia prawostronna, którą otrzymujemy zastępując pierwsze dwie z trzech powyższych reguł przez:
Ponadto, jeśli przyjmiemy regułę pierwszą (dla ), trzecią i czwartą (dla ), otrzymamy strategię równoległą, polegającą na obliczaniu jednocześnie i :
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
Zauważmy natomiast, że kolejność przeplatania się małych kroków obliczających i nie wpływa w tym przypadku na końcową wartość całego wyrażenia.
Na koniec reguły dla wyrażenia warunkowego.
Zadanie 2
Rozszerzmy język wyrażeń z poprzedniego zadania o jedną konstrukcję
Wyrażenie zawiera w sobie deklarację , która stanowi mechanizm wiązania identyfikatorów w naszym języku. Deklaracja wprowadza nową zmienną oraz przypisuje jej wartość. Wartość wyrażenia obliczamy następująco: najpierw oblicza się wartość , podstawia ją na zmienna , a następnie oblicza wyrażenie . Zakresem zmiennej jest wyrażenie , czyli wewnątrz można odwoływać się (wielokrotnie) do zmiennej ; 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 występuje podwyrażenie to odwołania do wewnątrz odnoszą się do najbliższej deklaracji zmiennej .
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
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 }
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: . Naturalnym stanem początkowym jest stan pusty, tzn. pusta funkcja częściowa, który będziemy oznaczać . A wartość wyrażenia w stanie początkowym wynosi o ile zachodzi:
Będziemy potrzebowac tranzycji dwóch postaci, podobnie jak poprzednio, ale pierwsza postać będzie trochę ogólniejsza:
Tranzycja ta oznacza mały krok w trakcie obliczania wyrażenia w stanie , w wyniku którego wyewoluowało do a nowym stanem jest . 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.
Następnie dodajemy reguły dla wyrażenia . Gdy jest już obliczne, wyatarczy reguła:
Notacja oznacza stan , który zmodyfikowano przypisując zmiennej wartość , niezależnie od tego, czy było określone, czy nie, i pozostawiając niezmienione wartości dla pozostałych zmiennych. Formanie
Dla zachodzi
(w szczególności, jest określone wtedy i tylko
wtedy, gdy jest określone).
Natomiast aby obliczyc potrzebujemy reguły:
Zwróćmy uwagę, że stan może być różny od , np. dlatego, że wewnątrz znajduje się podwyrażenie .
Pytanie: czy taka semantyka jest poprawna?
Niestety nie, gdyż nie uwzględniamy ograniczonego zasięgu zmiennej. Rzućmy okiem na przykład:
Według naszych intencji to wyrażenie nie ma wartości, gdyż ostatnie odwołanie do jest błędne. Natomiast według powyższych reguł mamy
Nasz błąd polega na tym, że po zakończeniu obliczania podwyrażenia zapominamy przywrócić zmiennej poprzednią wartość (a właściwie brak wartości w przykładzie powyżej).
Wygodne i eleganckie rozwiązanie tego problemu jest możliwe, jeśli rozszerzymy składnię naszego języka. Intuicyjnie, reguła
powinna zostać zastąpiona przez
czyli potrzebujemy konstrukcji składniowej, która polega na obliczeniu wyrażenia a następnie na przypisaniu zmiennej danej wartości. Rozszerzmy zatem składnię następujaco:
Zauważmy, że wyrażenie jest w pewnym sensie dualne do , gdyż jedyna (choc niewątpliwie istotna) różnica między nimi to kolejność obliczenia i przypisania wartości na zmienną . Oto nowa reguła
Pewna trudność pojawiają się w sytuacji, gdy jest nieokreślone, czyli gdy zmienna jest niezainicjowana -- reguła powyższa nie obejmuje wogóle takiej sytuacji. Najprostszym sposobem rozwiązania tej trudności jest rozszerzenie konstrukcji :
gdzie symbol oznacza brak wartości. Dodajemy również regułę:
Uwaga: Inny sposób rozwiązania omawianej trudności polega na rozszerzeniu zbioru o dodatkowy element :
Wtedy nie musimy dodawać osobnego wariantu ostatniej reguły, ale za to może sie pojawić w wyrażeniach. Dodatkowo, w tym rozwiązaniu warto poczynić umowę, że reprezentuje brak wartości zmiennej . Wtedy stany są funkcjami całkowitymi z w przyjmującymi wartość różną od tylko dla skończenie wielu elementów. (koniec uwagi)
Na zakończenie scalamy semantykę dla z semantyką pozostałych wyrażeń:
Zadanie 3
Zmodyfikuj semantykę z poprzedniego zadania tak, aby uzyskać leniwą ewaluację wyrażeń, zgodnie z dyrektywą: nie obliczaj wyrażenia o ile jego wynik nie jest potrzebny (albo: obliczaj wartość wyrażenia dopiero wtedy, gdy jego wynik jest naprawdę potrzebny). Spójrzmy na przykład:
Według semantyki z poprzedniego zadania wyrażnie to nie ma wartości, bo w deklaracji jest odwołanie do niezainicjowanej zmiennej. Natomiast w semantyce leniwej wyrażenie to obliczy się do wartości , gdyż wyrażenie nie będzie wogóle obliczane. Będzie tak dlatego, że w wyrażeniu nie ma odwołań do zmiennej .
Rozwiązanie
Semantyka będzie bardzo podobna do tej z poprzedniego zadania. Zasadnicza różnica dotyczy informacji przechowywanej w stanie. Dotychczas , gdyż podwyrażenie w obliczało sie natychmiast. Jeśli chcemy opóżnic obliczenie tego podwyrażenia, to w powinniśmy zapamiętać całe (nieobliczone) wyrażenie wraz ze stanem bieżącym. Czyli
Odpowiednia reguła dla wyrażenia to
Uważnego czytelnika zapewne zaniepokoił fakt, że stoi zarówno po lewej jak i po prawej stronie równania Również zapis może wzbudzić niepokój, gdyż sugeruje on, iż zawiera, jako jeden z elementów pary, obiekt tego samego typu co . Formalnego rozwiązania tego typu dylematów dostarcza teoria dziedzin. Natomiast na użytek semantyki operacyjnej wystarczy, jeśli uznamy, iż równanie stanowi skrótowy zapis następującej definicji. Zdefiniujmy następująco:
....
Zadania domowe
Zadanie 4
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 w stanie jest niemożliwe bo wystąpił błąd, to
Zadanie 5
Rozważ rozszerzenie języka z zadania 2 o wyrażenia boolowskie:
Zaproponuj semantykę małych kroków dla tego języka.