Pobierz
Transkrypt
Pobierz
Programowanie za pomocą kontraktów Design by Contract Przzypa adek k rakiety Aria ane erwca 1996, 1 12:34 G GMT; miejsce startu: s kkosmod drom 4 cze Kourrou (EL LA3), Gujana F Francus ska Uwag gi: starrt nieud dany - rakieta a zeszła a z kurssu i zos stała zniszzczona przez oficera o bezpie eczeństtwa kossmodro omu. Przycczyną katastro k ofy było o użycie e niezm mienion nego op program mowania a z rakiiety Aria ane 4. Straty 500 5 00 0$. Poprawne współużywanie modułów programowych Brak formalizacji semantyki modułów jest przyczyną niepoprawnego używania modułów programowych. At the time of the failure, the software in the two SRIs was doing a data conversion from 64-bit floating point to 16-bit integer. The floating point number had a value greater than what could be represented by a 16-bit signed integer; this resulted in an overflow software exception. Code was reused from the Ariane 4 guidance system. The Ariane 4 has different flight characteristics in the first 30 seconds of flight and exception conditions were generated on both inertial guidance system (IGS) channels of the Ariane 5. Kod będący przyczyną wypadku rakiety Ariane 5: convert (horizontal_bias: DOUBLE): INTEGER Procedura ta działała poprawnie w rakiecie Ariane 4, której parametry lotne ograniczały wartości parametru: horizontal_bias, do przedziału, który gwarantował poprawne działanie procedury. Powyższa procedura została przeniesiona do oprogramowania rakiety Ariane 5, której parametry lotne powodowały przekroczenie poprawnych wartości parametru horizontal_bias. Poszukiwanie nowej metodyki programowania Poprawność składniowa współużywalnych modułów może być zweryfikowana przez kompilator. Poprawność semantyczna ujawnia się dopiero w trakcie działania programu. Przyczyną katastrofy Ariane 5 była wykorzystanie procedury, o nieznanych własnościach semantycznych. W związku z tym, niespełniony został niejawny kontrakt na sposób korzystania z kodu. W wyniku analizy systemowej i podczas projektowania powstaje specyfikacja, która koncentruje się na składni programów, a nie ich semantyce. X27 # element1_st # element2_sz # element3_tp + ps(El) + pp() : El Poszukiwanie nowej metodyki programowania Poprawność składniowa współużywalnych modułów może być zweryfikowana przez kompilator. Poprawność semantyczna ujawnia się dopiero w trakcie działania programu. Przyczyną katastrofy Ariane 5 była wykorzystanie procedury, o nieznanych własnościach semantycznych. W związku z tym, niespełniony został niejawny kontrakt na sposób korzystania z kodu. W wyniku analizy systemowej i podczas projektowania powstaje specyfikacja, która koncentruje się na składni programów, a nie ich semantyce. X27 # element1_st # element2_sz # element3_tp + ps(El) + pp() : El Stos # storage # size # top + push(Element) + pop() : Element Specyfikacja semantyki programów Kontrakt: • Warunki poprawnego korzystania z obiektów • Zobowiązania obiektów – semantyka programów Kontrakt na metodę push() klasy Stos: Zobowiązania Gwarancje Klient Musi spełnić warunki początkowe Stos nie jest pełny, wstawiany element nie jest pusty Korzysta z warunków końcowych Na szczycie stosu pojawi się nowy element Dostawca Musi spełnić warunki końcowe Może założyć spełnienie warunków początkowych Nie musi weryfikować przepełnienia stosu i poprawności elementu Zapamiętuje podany element na szczycie stosu Analiza systemowa za pomocą kontraktów 1. Specyfikacja ADT (Abstract Data Type) z językiem formalnej specyfikacji (VDM, Z, logika Hoare'a, itp) 2. UML z językiem OCL (Object Constraint Language) Formalizacja specyfikacji ADT Semantyki algebraiczne Formalny zapis pełnej specyfikacji abstrakcyjnego typu danych z zastosowaniem semantyk algebraicznych, obejmuje pięć elementów: ¾ nazwę ADT z opcjonalną listą parametrów generycznych; ¾ predefiniowane ADT niezbędne do definicji semantyki ADT; ¾ interfejs ADT zdefiniowany jako zbiór sygnatur operacji; ¾ dziedziny poszczególnych operacji zdefiniowane jako warunki początkowe operacji; ¾ semantykę ADT wyrażoną w postaci aksjomatów; Nazwa ADT ADT name: STACK [G] gdzie G - jest formalnym parametrem generycznym Specyfikacja ADT z parametrami generycznymi reprezentuje cały zbiór specyfikacji ADT dla wszystkich potencjalnych wartości parametru generycznego. W powyższym przypadku specyfikacja reprezentuje zbiór ADT: STACK[int], STACK[float], STACK[String], STACK[Osoba], itd. Predefiniowane ADT Domains: boolean określają ADT o predefiniowanej semantyce, które będą wykorzystane do definicji semantyki danego ADT. Interfejs ADT Functions: • • • • • put: STACK[G]×G → STACK[G] remove: STACK[G] → STACK[G] item: STACK[G] → G empty: STACK[G] → BOOLEAN new: → STACK[G] - modifier modifier accessor accessor modifier Interfejs ADT jest wyspecyfikowany jako zbiór sygnatur wszystkich operacji właściwych dla ADT. Operacje ADT są podzielone na dwie klasy: operacji modyfikujących stan ADT (ang. modifier) i operacji realizujących niemodyfikujący dostęp do stanu ADT (ang. accessor) (dla uproszczenia w dalszych rozważaniach przyjęto, że te dwa zbiory operacji są rozłączne, a operacje odczytujące stan zwracają jedynie pojedyncze wartości). Typami parametrów wejściowych operacji są: • definiowany ADT, dla wszystkich operacji za wyjątkiem konstruktora; • predefiniowane ADT; • parametry generyczne. Typami zwrotnymi operacji są: • definiowany ADT, dla operacji klasy modifier; • predefiniowane ADT, dla operacji klasy accessor; • parametry generyczne, dla operacji klasy accessor. Oznaczenie: → służy do wskazania operacji o ograniczonej dziedzinie parametrów wejściowych. Operacje o ograniczonej dziedzinie Preconditions: • remove(s) require not empty(s) • item(s) require not empty(s) Dla każdej operacji o ograniczonej dziedzinie parametrów wejściowych należy zdefiniować warunek początkowy określający warunki niezbędne dla poprawnego wykonania operacji. Warunki początkowe odwołują się do stanu wystąpień ADT lub do wartości parametrów wejściowych. Warunki początkowe są definiowane jako wyrażenia logiczne, które muszą być prawdziwe w momencie wywoływania operacji. Definicje warunków początkowych opierają się na składni operacji ADT. Semantyka ADT Axioms: • • • • item(put(s,x)) = x remove(put(s,x)) = s empty(new) not empty(put(s,x)) Semantyka ADT jest definiowana w postaci zbioru aksjomatów definiujących zależności między operacjami ADT. Aksjomaty są definiowane w postaci wyrażeń logicznych, które muszą być spełnione dla wszystkich stanów potencjalnych wystąpień ADT. Definicje aksjomatów wykorzystują składnię operacji ADT zdefiniowaną w sekcji Functions. Zbiór aksjomatów powinien opisywać kompletną semantykę ADT. To znaczy dla każdej operacji modyfikującej stan ADT powinno się określić zbiór wartości wszystkich operacji odczytujących stan ADT. W sumie dla n operacji modyfikujących i m odczytujących stan ADT należałoby zdefiniować m∗n aksjomatów. Jednak, nie zawsze jest to możliwe. Aksjomaty powinny być formułowane w sposób jak najbardziej ogólny. Na przykład: not empty(put(s,x)) vs. not empty(put(new,x)) Pełna specyfikacja ADT Specyfikacja ADT STACK ADT NAME • STACK [G] DOMAINS • Boolean FUNCTIONS • put: STACK[G]×G → STACK[G] • remove: STACK[G] → STACK[G] • item: STACK[G] → G • empty: STACK[G] → BOOLEAN • new: → STACK[G] AXIOMS • item(put(s,x)) = x • remove(put(s,x)) = s • empty(new) • not empty(put(s,x)) PRECONDITIONS • remove(s) require not empty(s) • item(s) require not empty(s) Kompletność definicji ADT Po czym rozpoznań, że specyfikacja semantyki ADT jest kompletna. Reguła mówiąca, że dla m operacji modyfikujących i n odczytujących stan ADT należy zdefiniować m∗n aksjomatów ma ograniczony zasięg stosowalności. Niektórych aksjomatów nie można zdefiniować ze względu na niespełnienie warunków początkowych. Z tego powodu nie można określić wartości aksjomatu: item(new) Dla innych aksjomatów nie można bezpośrednio określić ich wartości: item(remove(s))=? empty(remove(s))=? Specyficzne relacje między operacjami ADT: na przykład przemienność lub komutatywność, pozwalają ograniczyć zbiór aksjomatów. Poprawność wyrażeń definiowanych na ADT Składnia operacji ADT określa sposób konstruowania poprawnych składniowo wyrażeń na ADT. put(new,x) empty(remove(put(put(new,x1),x2))) item(new) Poprawność składniowa nie gwarantuje poprawności semantycznej. Definicja semantycznej poprawności wyrażeń Niech f(x1, …, xn) będzie poprawnym składniowo wyrażeniem, odwołującym się do jednej lub więcej operacji jakiegoś ADT. Wyrażenie to będzie semantycznie poprawne wtedy i tylko wtedy, gdy wszystkie wartości xi są (rekurencyjnie) poprawne poprzez spełnienie warunków początkowych operacji. Zapytania - wyrażenia, których wartości zwrotne nie są typu ADT: item(put(new,x)) empty(remove(put(put(new,x1),x2))) item(new) Kompletność definicji ADT Definicja kompletności specyfikacji ADT Specyfikacja ADT typu T jest kompletna wtedy i tylko wtedy, gdy zdefiniowany zbiór aksjomatów pozwala dla dowolnego poprawnego składniowo wyrażenia e: • stwierdzić semantyczną poprawność wyrażenia; • jeżeli wyrażenie e jest poprawne i jest zapytaniem, wyznaczyć wartość tego wyrażenia. Definicja spójności ADT Specyfikacja ADT typu spójna wtedy i tylko wtedy, gdy dla dowolnego poprawnego składniowo zapytania e, aksjomaty ADT pozwalają na wyznaczenie, co najwyżej jednej wartości e. Możliwe jest formalne dowodzenie kompletności i spójności specyfikacji poszczególnych ADT. Programowanie za pomocą kontraktów - Asercje Język specyfikacji semantyki oprogramowania Asercje są wyrażeniami logicznymi opisującymi semantykę klas. Asercje są wykorzystywane do definiowania: • warunków początkowych – określających poprawne wartości parametrów wejściowych metody i stanu obiektu, niezbędnych dla poprawnego działania metody; • warunków końcowych – określających poprawne wartości parametrów wyjściowych metody i stanu obiektu gwarantowanych po zakończeniu działania metody; • niezmienników klas – określających dopuszczalne stany wystąpień klasy przez cały czas ich życia. Specyfikacja poprawności oprogramowania Formuła poprawności oprogramowania – logika Hoare’a: {V}S{P} Jeżeli warunek początkowy V (hipoteza) jest spełniony bezpośrednio przed wykonaniem programu S, wtedy warunek końcowy P (teza) będzie spełniony po wykonaniu programu S. Przykład: Warunek początkowy: Program: Warunek końcowy: { x >= 0 } x := x + 5 { x >= 5 } Warunki początkowe i końcowe związane z metodami klasy opisują kontrakt miedzy klasą (modułem) i jej klientami. Kontrakt ten wiąże klasę tak długo, jak wywołania metod klasy spełniają warunki początkowe. Wtedy klasa powinna zagwarantować, że jej stan końcowy i parametry wyjściowe są zgodne warunkami końcowymi. • Niespełnienie warunków początkowych oznacza błąd po stronie klienta klasy. • Niespełnienie warunków końcowych oznacza błąd po stronie dostawcy klasy. Użyteczność formuły poprawności Użyteczność formuły poprawności jest zależna od jej siły. Siła formuły poprawności oprogramowania jest odwrotnie proporcjonalna do siły warunku początkowego i wprost proporcjonalna do siły warunku końcowego. 1. { False } S { … } Warunek początkowy False jest najsilniejszą możliwą asercją. Warunek ten nigdy nie jest spełniony, niezależnie od stanu początkowego. Każde wywołanie S będzie niepoprawne. W związku z tym, każdy program jest poprawny z powyższą specyfikacją. { False } null { … } { False } for i =1 to 100 do y := y + y i ; end; { … } 2. { True} S { … } Wszystkie wywołania modułu S są poprawne. 3. { … } S { True } Warunek końcowy True jest najsłabszą możliwą asercją. Każde pomyślne zakończenie programu S jest poprawne niezależnie od jego wyniku. 4. { x >= 9 } y := x + 5 { y = x + 5 } Powyższy warunek końcowy jest przykładem bardzo silnej asercji. Dla danej wartości początkowej x istnieje tylko jedno poprawne rozwiązanie. Asercje w języku Eiffel Asercje umożliwiają na deklaratywną specyfikację warunków poprawności semantyczne kodu programów: class STACK [G] – składnia Eiffel item: G is require -- warunki początkowe not_empty: not empty do … end put (x: G) is require -- warunki początkowe not_full: not full -- wymaganie implementacji do … ensure -- warunki końcowe not_empty: not empty added_to_top: item = x one_more_item: count = old count + 1 end remove is require -- warunki początkowe not_empty: not empty do … ensure -- warunki końcowe not_full: not full one_fewer_item: count = old count – 1 end end Asercje a defensywny styl programowania Deklaratywna definicja warunków poprawnego wykonania: class STACK [G] … remove is require -- warunki początkowe not_empty: not empty do … ensure -- warunki końcowe not_full: not full one_fewer_item: count = old count – 1 end end zamiast proceduralnej implementacji wykrywania i obsługi błędów: class STACK [G] … remove is do if empty then print (“Błąd: próba pobrania z pustego stosu”) else count := count – 1 end end Niezmienniki klas Warunki początkowe i końcowe są cechami poszczególnych metod klasy. Niezmienniki klas są cechami całej klasy, to znaczy muszą być spełnione przez wszystkie metody klasy. class STACK [G] … invariant count_non_negative: count >= 0 count_bounded: count <= capacity consistent_with_array_size: capacity = array.size empty_if_no_elements: empty = (count = 0) item_at_top: (count>0) implies (array(count) = item) end s.new s.put(x) S1 s.put(y) S2 s.item S3 cykl życia obiektu s.remove S4 Asercje w Javie Asercja jest instrukcją języka służącą do testowania założeń programisty, co do stanu programu w określonym momencie jego działania. Każda asercja zawiera wyrażenie logiczne, które w poprawnym stanie programu powinno być prawdziwe. Niespełnienie asercji jest zgłaszane jako specjalny wyjątek. Składnia: assert wyrażenie_logiczne; lub assert wyrażenie_logiczne: wyrażenie; Drugi argument (wyrażenie) umożliwia przekazanie dodatkowych informacji do procedury obsługi błędu. Typowe zastosowania asercji • Niezmienniki wewnętrzne – kontrola wewnętrznej poprawności programów • Niezmienniki przepływu sterowania – kontrola poprawności • Warunki początkowe, końcowe i niezmienniki klas – programowanie przez kontrakt Niezmienniki wewnętrzne Zamiast: if (i % 3 == 0) { ... } else if (i % 3 == 1) { ... } else { // tu wiemy, że (i % 3 == 2) ... } Powinno być: if (i % 3 == 0) { ... } else if (i % 3 == 1) { ... } else { assert i % 3 == 2 : i; ... } Niezmienniki przepływu sterowania Zastosowanie asercji false w miejscu programu, do którego nigdy nie powinno znaleźć się sterowanie. void funkcja() { for (...) { if (...) return; } // Sterowanie nigdy nie powinno osiągnąć // tego punktu assert false; } Warunki początkowe, końcowe i niezmienniki klas class Stos { static final int PUSTY = -1; private Object magazyn [ ]; private int rozmiar; private int szczyt_stosu; ... public Object push (Object element) { assert szczyt_stosu < rozmiar – 1; magazyn[++szczyt_stosu]=element; return element; assert szczyt_stosu != PUSTY; } public Object pop ( ) { assert szczyt_stosu != PUSTY return magazyn[szczyt_stosu--]; } } Włączanie i wyłączanie asercji Ze względów wydajnościowych asercje domyślnie nie są weryfikowane w trakcie działania programu. Programista musi explicite zażyczyć sobie weryfikacji asercji podczas kompilacji programu za pomocą opcji: -enableassertions lub –ea Argument tej opcji określa zasięg weryfikacji asercji: • brak argumentów – weryfikacja asercji we wszystkich klasach programu (za wyjątkiem klas systemowych) • nazwa pakietu – we wszystkich klasach danego pakietu • nazwa klasy – w danej klasie java -ea:com.wombat.fruitbat... BatTutor Weryfikacja asercji w klasach systemowych jest odblokowywana za pomocą przełącznika: -enablesystemassertions lub -esa Stosowanie asercji języku Java • Asercje powinny być używane głównie w czasie debugowania kodu, ponieważ aerscje zgłaszają predefiniowany typ wyjątku AssertionError. • Można zablokować wyłączenie asercji static { boolean assertsEnabled = false; assert assertsEnabled = true; if (!assertsEnabled) throw new RuntimeException("Asserts must be enabled!!!"); } • W ramach asercji nie wolno wykonywać fragmentów użytkowego kodu // akcja jest zawarta w asercji assert names.remove(null); // akcja wyciągnięta przed asercję // działa niezależnie od uaktywnienia asercji boolean nullsRemoved = names.remove(null); assert nullsRemoved; • Poprawna redefinicja asercji w łańcuchu dziedziczenia nie jest wspierana przez kompilator, odpowiedzialność spoczywa na programiście. Asercje w języku C++ W języku C++ dostępne jest makro: assert(). Służy ono do diagnostyki działania programów. W przypadku niespełnienia warunku logicznego podanego jako argument makra, do standardowego strumienia stderr wysłany będzie odpowiedni komunikat i następnie zostanie wywołana funkcja abort(). Przykład: #include <stdio.h> #include <assert.h> int main () { FILE * datafile; datafile=fopen ("file.dat","r"); assert (datafile);//czy udało się otworzyć plik // jakieś działania na pliku ... fclose (datafile); return 0; } Asercje w języku C# W języku C# w czasie testowania programów można korzystać z klasy systemowej Debug. Jedną z metod tej klasy jest metoda Assert. public static void MyMethod(Type type, Type baseType) { Debug.Assert(type != null, "Type parameter is null", "Can't get object for null type"); // Perform some processing. }