Kontrakty

edytuj

Komputery oraz języki programowania mają służyć zwykle rozwiązywaniu realnych problemów. Czy jest to wykonanie jakichś obliczeń, czy obsługa systemu bankowego, gry komputerowej, czy przetwarzanie dokumentów, zależy nam na tym, aby operacje te zostały wykonane prawidłowo. Niestety, mimo usilnych starań i stworzenia wielu potężnych mechanizmów automatycznej weryfikacji, oprogramowanie ma błędy.

Język D, nauczony doświadczeniami wielu innych języków i dodatkowych narzędzi, wprowadza programowanie kontraktowe (ang. DBC, design by contract - czyli zaprojektowane przez kontrakty), które znacznie zmniejsza ryzyko utworzenia błędnego programu oraz dokumentuje, co program robi.

Składnikami programowania kontraktowego są kontrakty, testy jednostkowe oraz niezmienniki.

Kontrakt w języku D to dodatkowy kod przy funkcji, którego głównym celem jest weryfikacja przekazanych argumentów oraz weryfikacja tego, czy funkcja zwróciła prawidłowy wynik. Ważne jest, aby sam program w żaden sposób nie zależał od postaci kontraktu (tzn. aby kontrakty wykonywały jedynie sprawdzenia lub takie operacje które nie zmienią reszty systemu), ponieważ kiedy uznamy, że program przechodzi wszystkie testy i kontrakty są spełnione, możemy je automatycznie usunąć przy pomocy odpowiedniej flagi kompilatora (-release), zyskując w ten sposób na prędkości.

Bardzo ważne jest to, że kod ten znajduje się w tym samym pliku i zaraz obok definicji funkcji, ponieważ np. w języku C gdzie nie ma tego typu kontraktów (a przynajmniej są utrudnione). Często poprawność funkcji sprawdza się tylko raz, a następnie przechodzi dalej. Problemem jest sytuacja, kiedy funkcja współpracuje z resztą systemu w skomplikowany sposób i proste, ręczne testy nie są wstanie sprawdzić poprawności funkcji (z natury rzeczy testy wykonuje się na małej próbce danych). Nawet jeśli raz udowodnimy (używając logiki matematycznej itp), że funkcja działa prawidłowo, to musimy to powtarzać po każdej zmianie w tej funkcji. Opieranie się na fałszywym przekonaniu, że funkcja działa poprawnie może prowadzić do godzin spędzonych nad poszukiwaniem możliwego błędu, albo - jeszcze gorzej - do przeniknięcia błędu do ostatecznej wersji programu.

Dzięki wsparciu dla kontraktów bezpośrednio w języku uniknięcie tego typu kłopotów jest to znacznie ułatwione.

Przejdźmy do przykładów.

Załóżmy, że mamy funkcję usuwającą białe znaki (spacje, tabulatory) na początku i na końcu łańcucha znaków:

/// Usuwa białe znaki na początku i końcu
char[] usunbiale(char[] x) {
    int i = 0;
    for (; i < x.length && iswhite(x[i]); i++);   // pomin znaki na poczatku
    int j = x.length-1;
    for (; j >= 0 && iswhite(x[j]); j--);   // pomin znaki na koncu
    return x[i .. j];
}

Na pierwszy rzut oka kod robi to co trzeba, na przykład:

writefln("'%x'", usunbiale("   tekst  "));

wyświetli prawidłowo tylko

'tekst'

bez spacji po lewej i prawej stronie.

Aby dopisać kontrakt do tej funkcji, używa się specjalnych bloków: in, out oraz body. Kontrakt in jest wykonywany natychmiast po wejściu do funkcji i powinien sprawdzać dane wejściowe. Kontrakt out jest wykonywany natychmiast przed wyjściem z funkcji i powinien sprawdzać poprawność wyniku.

W naszym przypadku nie będziemy mieli kontraktu in, ponieważ operacja usunbiale powinna byc zdefiniowana dla wszystki mozliwych łańcuchów. Warto natomiast sprawdzić co nam funkcja zwraca oraz dodać kilka testów jednostkowych przy pomocy specjalnego bloku unittest. Testy te wykonaują się na samym początku uruchomienia programu, jeszcze przed wejściem do funkcji main. Domyślnie nie są wkompilowywane do programu - aby je wkompilować, należy użyć flagi kompilatora -unittest.

/// Usuwa białe znaki na początku i końcu
char[] usunbiale(char[] x)
out(res) {
    assert(res.length <= x.length);  // ciąg po usunięciu spacji nie może być dłuższy
    if (res.length > 0) {            // jeśli ciąg jest niepusty
         assert(!iswhite(res[0]));   // to pierwszy
         assert(!iswhite(res[$-1])); // i ostatni znak nie są białe
    }
    assert(find(res, x) >= 0);       // i zwrócony ciąg jest podciągiem początkowego
}
body {
    int i = 0;
    for (; i < x.length && iswhite(x[i]); i++);   // pomin znaki na poczatku
    int j = x.length-1;
    for (; j >= 0 && iswhite(x[j]); j--);   // pomin znaki na koncu
    return x[i .. j];
}
unittest {
    assert(usunbiale("test") == "test");
    assert(usunbiale("te st") == "te st");
    assert(usunbiale(" test") == "test");
    assert(usunbiale("  test") == "test");
    assert(usunbiale("test ") == "test");
    assert(usunbiale("test  ") == "test");
    assert(usunbiale(" test ") == "test");
    assert(usunbiale("  test  ") == "test");
    assert(usunbiale(" te st") == "te st");
    assert(usunbiale("  te st") == "te st");
    assert(usunbiale("te st ") == "te st");
    assert(usunbiale("te st  ") == "te st");
    assert(usunbiale(" te st ") == "te st");
    assert(usunbiale(" te st  ") == "te st");
    assert(usunbiale("") == "");
    assert(usunbiale(" ") == "");
    assert(usunbiale("  ") == "");
    assert(usunbiale(" \t ") == "");
    assert(usunbiale("\tte\tst\t") == "te\tst");
    assert(usunbiale("\t\tte st\t\t") == "te st");
    assert(usunbiale("\t\tte\nst\t\t") == "te\nst");
    assert(usunbiale(" \tte\tst \t") == "te\tst");
}

Używamy tutaj wielokrotnie funkcji assert. Dokładnie rzecz biorąc, jest to instrukcja, która w trybie debug (przekazana flaga -debug kompilatora) sprawdza prawdziwość swojego argumentu. Jeśli jest on prawdziwy, nic nie robi, w przeciwnym wypadku zgłasza wyjątek, który normalnie przerywa program. Instrukcja assert to inaczej stwierdzenie, że program w danym miejscu powinien spełnić pewien warunek i możemy w jego dalszej części się na tym opierać, np.

double x = moja_funkcja_do_pierwiastkowania(y);
assert(x >= 0.0);            // to powinno być zawsze prawdziwe, ale warto się upewnić
...

Należy jednak pamiętać, aby nie polegać na instrukcji assert jako teście warunków, które mogą się okazać fałszywe (między innymi poprawności danych wprowadzanych przez użytkownika), ponieważ ta instrukcja nie zostanie wykonana po skompilowaniu z flagą -release. Zamiast instrukcji assert należy sprawdzić warunek konstrukcją if i w przypadku błędnych danych rzucić wyjątek.

int podziel(int a, int b) {
  assert(b != 0);
  return a/b;
}

powinniśmy napiasć

int podziel(int a, int b) {
  if (b == 0) {
     throw new Error("dzielenie przez zero");
  }
  return a/b;
}

Jak widać, dodaliśmy test out z opisami, które sprawdzają dosyć oczywiste rzeczy. Gdyby jednak okazało się, że coś zrobiliśmy źle w sekcji body, to jest szansa, że któryś z tych warunków stanie się fałszywy i dowiemy się o tym w trybie debug. Dodatkowo dodaliśmy testy jednostkowe które testują tylko i wyłącznie tą funkcję. Użyliśmy tutaj wystarczającej ilości różnorodnych testów, aby mieć pewność, że rozpatrzyliśmy większość przypadków brzegowych.

Już po pierwszym uruchomieniu programu z tą funkcją (wraz z flaga -debug oraz -unittest przekazaną do kompilatora) znajdziemy błąd związany z asercją

     assert(usunbiale(" ") == "");

Rzeczywiście nasz program zakłada, że w łańcuchu x znajduje się jakiś niebiały znak i zatrzyma się na nim zmieniając indeksy i oraz j. W tym przypadku indeksy i oraz j "przechodzą" przez siebie i w instrukcji return x[i..j] otrzymamy return x[1..0], czyli nonsens.

Aby to naprawić, zmieńmy sekcję body

body {
    int i = 0;
    for (; i < x.length && iswhite(x[i]); i++);   // pomin znaki na poczatku
    int j = x.length-1;
    for (; j >= i && iswhite(x[j]); j--);   // pomin znaki na koncu
    assert(i <= j);
    return x[i .. j];
}

...