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.
}