Design by Contract W Java | Part 1

Błędy w kodzie się zdarzają każdemu bez względu na użytą technologię, język, framework czy posiadane zdolności. Część błędów jest łatwa do zdebugowania i poprawienia, są jednak te mniej przyjemne, trudne do powtórzenia, objawiające się tylko w pewnych warunkach. Na pewno większość z czytających ten tekst deweloperów spędziła niejedną godzinę, zarwała niejedną noc żeby znaleźć błąd czy chociażby próbować go tylko odtworzyć w środowisku deweloperskim. Zapewniam, że nic w tym przyjemnego :)

Rozwój inżynierii oprogramowania przyniósł jednak pewne techniki i podejścia ułatwiające radzenie sobie z błędami. Podejścia te przyśpieszają wykrywanie defektów kodu oraz ułatwiają ich powtarzanie. Przez wiele lat wypracowano wiele takich technik m.in. Fail-fast, Defensive Programming, Test-Driven Development czy Design by Contract. W artykule tym chciałbym zaprezentować nieco bliżej podejście Design by Contract. W pierwszej części skupię się mocno na aspektach teoretycznych, kod pojawi się dopiero na samym końcu. Kolejne wpisy będą już miały charakter bardziej praktyczny. Osobom zaznajomionym z tematyką DbC a nielubiącym teorii proponuję od razu przejść do przykładów z tej części :)

Jeszcze taka drobna uwaga na sam początek - wpisem tym nie chciałbym wywołać burzy na temat tego, które z podejść jest słuszne i najlepsze. Każde z nich jest dobre, jeżeli spełnia swoje zadanie - jeżeli któreś z nich stosujesz z powodzeniem, rób to nadal.

Design by Contract - teoria

Design by Contract (DbC) jest to podejście do projektowania oprogramowania, w którym komponenty komunikują się ze sobą za pomocą ściśle określonych reguł. Reguły te tworzą tzw kontrakt będący zbiorem wymagań stawianych klientom komponentu oraz samym komponentom. Koncepcja DbC została wprowadzona przez Bertranda Meyera, została zaimplementowana w języku Eiffel i jest ściśle związana z regułą LSP (Liskov Substitution Principle) oraz podejściem Component-Based Development. Głównym celem DbC jest zwiększenie niezawodności oraz stabilności wytwarzanego oprogramowania. W oryginalnej koncepcji Design by Contract zastosowanej w języku Eiffel zdefiniowane zostały trzy główne grupy wymagań. Każda z grup składa się ze zbioru assercji oznaczających pojedynczy warunek.

  • Preconditions - czyli warunki, które muszą być spełnione bezpośrednio przed wejściem do metody. Za spełnienie tych warunków odpowiada klient. Warunki mogą dotyczyć zarówno wartości przekazywanych do metody parametrów (np. argument “name” musi mieć długość nie większą niż 30), stanu obiektu a nawet pewnego fragmentu stanu systemu (np. przed wejściem do metody wysyłającej dane klient jest zobligowany do nawiązania połączenia).

  • Postconditions - zbiór warunków, które muszą być spełnione bezpośrednio po wyjściu z uruchamianej metody. Warunki mogą dotyczyć ogólnego stanu obiektu lub zwracanej przez metodę wartości. Za spełnienie tych warunków odpowiada wywoływany komponent. Jako przykład mogę podać metodę dodającą użytkownika do grupy. Po wyjściu z metody stan grupy musi być powiększony o nowego użytkownika, tylko i wyłącznie w przypadku gdy użytkownik jeszcze nie należał do grupy. Za spełnienie tego warunku odpowiada komponent.

  • Invariants - zbiór warunków, które muszą być spełnione przez obiekt w dowolnym momencie jego życia. Nawiązując do przykładu dla postconditions w tym przypadku moglibyśmy nałożyć warunek na liczebność grupy - w dowolnym momencie nie mogłaby przekroczyć pewnej ustalonej wartości.

Oprócz preconditions, postconditions i invariants kontrakt może dotyczyć także wielu innych aspektów - formatu przekazywanych argumentów, formatu danych wyjściowych czy konieczności zapewnienia pewnych atrybutów jakościowych (np. wydajności) przez komponent świadczący usługę.

Notacja i monitoring kontraktu

Język Eiffel, w którym po raz pierwszy została zastosowana koncepcja Design by Contract, posiada wbudowane konstrukcje umożliwiające definiowanie kontraktu oraz narzędzia do automatycznego generowania dokumentacji na jego podstawie. Poniżej zamieściłem prosty przykład - definicję klasy BANK_ACCOUNT napisanej w jezyku Eiffel wraz z pełnym kontraktem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BANK_ACCOUNT feature
    id: INTEGER
    name: STRING
    balance: INTEGER

  deposit (cash: INTEGER) is
      -- Add cash to the account
    require
      cash > 0
    do
      balance := balance + cash
    ensure
      balance = old balance + cash
    end

  invariant
    id /= Void
    name /= Void

end -- class BANK_ACCOUNT

Sekcje require oraz ensure oznaczają odpowiednio preconditions oraz postconditions. Jak widzimy kontrakt jest częścią implementacji co znacznie ułatwia stosowanie tej koncepcji.

Monitoring kontraktu oznacza sprawdzanie w czasie runtime warunków jego dotrzymywania przez klienta jak i dostawcę komponentu. Eiffel, oprócz konstrukcji definiujących kontrakt posiada także wbudowane mechanizmy monitoringu umożliwiające jego weryfikację. Java nie posiada niestety takich udogodnień. Jedynym miejscem, w którym możemy zdefiniować kontrakt jest więc dokumentacja metody lub klasy, której kontrakt dotyczy a za sam monitoring kontraktu odpowiadać może fragment kodu metody. Jak pokażę za chwile stosowane koncepcji Design by Contract przy użyciu standardowych mechanizmów języka Java ze względu na dość spory przyrost kodu może prowadzić do pogorszenia jego przejrzystości i modyfikowalności. Dostępne są jednak biblioteki ułatwiające stosowanie tego podejścia.

Design by Contract vs Liskov Substitution Principle

Część teoretyczną warto zakończyć nawiązaniem do znanej zasady podstawiania Liskov (LSP - Liskov Substitution Principle), jednej z podstawowych zasad obiektowości należącą do zbioru zasad SOLID. Dla przypomnienia - reguła LSP mówi o tym, że metody używające pewnych typów bazowych muszą być w stanie używać typów pochodnych bez ich znajomości. Zapewnienie zgodności z tą zasadą jest możliwe poprzez spełnienie szeregu wymagań składniowych i semantycznych. Wymagania semantyczne ściśle nawiązują do koncepcji Design by Contract a właściwie do zbioru warunków w niej zdefiniowanych:

  • preconditions nie mogą być wzmacniane w klasie potomnej

  • postconditions nie mogą być osłabiane w klasie potomnej

  • invariants klasy bazowej muszą być zachowane w klasie potomnej

Naruszenie tych wymagań może spowodować niezgodność kodu z zasadą LSP co z kolei może prowadzić do wielu błędów w kodzie i wpływać negatywnie na jego niezawodność. Dodatkowo naruszanie zasady LSP zazwyczaj implikuje naruszanie zasady OCP (Open-Close Principle) co dodatkowo prowadzi zazwyczaj do zmniejszenia modyfikowalności kodu poprzez wprowadzenie efektu domina - każde dodanie nowej klasy potomnej prawdopodobnie będzie prowadziło do zmiany w klasie bazowej.

Koncepcja Design by Contract w Java

I tak od teorii dochodzimy do sedna tego wpisu czyli praktycznego zastosowania Design by Contract w Java ;) Poniżej zamieściłem przykładowe rozwiązania umożliwiające monitoring warunków kontraktu wraz z krótką, subiektywaną oceną.

Instrukcje warunkowe

Pierwszym i najprostszym sposobem na wprowadzenie koncepcji DbC do kodu Javy jest zastosowanie instrukcji warunkowych. Mogą być one używane do sprawdzania warunków wejściowych (preconditions) jednak ich stosowanie powoduje spory przyrost kodu i jego komplikację. Przeanalizujmy poniższy przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.util.HashSet;
import java.util.Set;

public class Group {

    private final Integer id;

    private final String name;

    private final Set users = new HashSet();

    public Group(final Integer id, final String name) {
        this.id = id;
        this.name = name;
    }

    public void addUser(final User u) {
        if (u == null) {
            throw new NullPointerException("user must not be null");
        }

        if (u.getId() == null) {
            throw new NullPointerException("user id must not be null");
        }

        if (users.contains(u)) {
            throw new IllegalArgumentException("user must not exist in group " + id);
        }

        System.out.println("Adding user " + u.getId() + " to group " + id);
        users.add(u);
    }

    public void delUser(final User u) {
        if (u == null) {
            throw new NullPointerException("user must not be null");
        }

        if (u.getId() == null) {
            throw new NullPointerException("user id must not be null");
        }

        if (!users.contains(u)) {
            throw new IllegalArgumentException("user must exist in group " + id);
        }

        System.out.println("Removing user " + u.getId() + " from group " + id);
        users.remove(u);
    }
}

W powyższym fragmencie użyliśmy zwykłych instrukcji warunkowych co spowodowało dość sporą komplikację kodu. Oczywiście jest to dość prosty przykład i może tego nie widać zbyt klarownie ale przy bardziej skoplikowanym modelu może to znacząco wpływać na przejrzystość i łatwość modyfikacji. Sporym problemem mogą być także powtórzone bloki warunkowe lekko przeczące zasadzie DRY - to także może powodować gorszą modyfikowalność oraz łatwość zarządzania kodem.

Google Guava

Guava dostarcza klasy ułatwiające sprawdzanie warunków wejściowych. W celu użycia Guavy w projektach mavenowych należy dodać do pom.xml następującą zależność:

1
2
3
com.google.guava
   guava
   12.0.1

Za sprawdzanie warunków odpowiada klasa com.google.common.base.Preconditions posiadająca następujący interfejs:

  • checkArgument - sprawdza czy wyrażenie podane jako argument przyjmuje wartość “true”. Umożliwia uproszenie poniższego kodu:
1
2
3
if (!users.contains(u)) {
   throw new IllegalArgumentException("user must exist in group " + id);
}

do postaci

1
checkArgument(users.contains(u), "user must exist in group %d", id);
  • checkElementIndex - metoda sprawdzająca czy element o podanym indeksie jest poprawnym elementem listy, kolekcji, ciągu znaków itp. Metoda przyjmuje dwa argumenty - index oraz size i zwraca wyjątek jeżeli (size < 0) || (index < size) || (index >= size). Jednym słowem umożliwia uproszczenie poniższego fragmentu kodu:
1
2
3
4
5
6
7
8
9
if (size < 0) {
    throw new IllegalArgumentException("size must be greater than 0");
}

if (index < 0) {
    throw new IllegalArgumentException("index must be greater than 0");
} else if (index >= list.size()){
    throw new IllegalArgumentException("index must be less than size of the list");
}

do postaci

1
checkElementIndex(index, list.size(), "index must be greater than 0 and less than size of the list");
  • checkPositionIndex - metoda sprawdzająca czy pozycja o podanym indeksie jest poprawną pozycją listy, kolekcji, ciągu znaków itp. Metoda przyjmuje dwa argumenty - index oraz size i zwraca wyjątek jeżeli (size < 0) || (index < size) || (index > size). Jednym słowem umożliwia uproszczenie poniższego fragmentu kodu:
1
2
3
4
5
6
7
8
9
if (size < 0) {
    throw new IllegalArgumentException("size must be greater than 0");
}

if (index < 0) {
    throw new IndexOutOfBoundException("index must be greater than 0");
} else if (index > list.size()){
    throw new IndexOutOfBoundException("index must not be greater than size of the list");
}

do postaci

1
checkPositionIndex(index, list.size(), "index must be greater than 0 and not greater than size");
  • checkNotNull - sprawdza czy podana jako argument referencja nie jest wartością null. Umożliwia więc uproszczenie poniższego kodu:
1
2
3
if (u == null) {
    throw new NullPointerException("user must not be null");
}

do postaci

1
checkNotNull(u, "user must not be null");

Poniżej ten sam przykład tylko z wykorzystaniem Preconditions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkArgument;
import java.util.HashSet;
import java.util.Set;

public class Group {

    private final Integer id;

    private final String name;

    private final Set users = new HashSet();

    public Group(final Integer id, final String name) {
        this.id = id;
        this.name = name;
    }

    public void addUser(final User u) {
        checkNotNull(u, "user must not be null");
        checkNotNull(u.getId(), "user id must not be null");
        checkArgument(users.contains(u), "user must not exist in group %d", id);

        System.out.println("Adding user " + u.getId() + " to group " + id);
        users.add(u);
    }

    public void delUser(final User u) {
        checkNotNull(u, "user must not be null");
        checkNotNull(u.getId(), "user id must not be null");
        checkArgument(!users.contains(u), "user must exist in group %d", id);

        System.out.println("Removing user " + u.getId() + " from group " + id);
        users.remove(u);
    }
}

W przypadku niespełnienia jednego z warunków metoda sprawdzająca rzuci wyjątek pochodzący od RuntimeException. Szczegóły dotyczące klas rzucanych wyjątków w zależności od rodzaju błędu walidacji można znaleźć w dokumentacji guavy. Przykładowy wyjątek umieściłem poniżej.

1
2
3
4
5
6
7
8
9
Exception in thread "main" java.lang.IllegalArgumentException: user must not exist in group 1
  at com.google.common.base.Preconditions.checkArgument(Preconditions.java:92)
  at pl.wp.blogexamples.GroupGuava.addUser(GroupGuava.java:47)
  at pl.wp.blogexamples.Main.main(Main.java:30)
  at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
  at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
  at java.lang.reflect.Method.invoke(Method.java:597)
  at com.intellij.rt.execution.application.AppMain.main(AppMain.java:120)

Dzięki Guavie zamiast licznych instrukcji warunkowych mamy klarowne, proste, pooddzielane bloki sprawdzające preconditions. Dodatkowo metody sprawdzające mogą przyjmować komunikat o błędzie w stylu “printf” co znacznie upraszcza samo konstruowanie informacji o błędach. Niestety podejście to nadal charakteryzuje się powtórzonymi liniami sprawdzającymi warunki kontraktu.

Apache Commons Lang

Apache Commons Lang w wersji 3 oferuje podobne możliwości jak Guava. Aby skorzystać z biblioteki należy umieścić w pom.xml następujące zależność:

1
2
3
org.apache.commons
    commons-lang3
    3.1

Kod wygląda podobnie jak z wykorzystaniem Guavy, różnią się tylko sygnatury metod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.HashSet;
import java.util.Set;

import static org.apache.commons.lang3.Validate.isTrue;
import static org.apache.commons.lang3.Validate.notNull;

public class Group {

    private final Integer id;

    private final String name;

    private final Set users = new HashSet();

    public Group(final Integer id, final String name) {
        this.id = id;
        this.name = name;
    }

    public void addUser(final User u) {
        notNull(u, "user must not be null");
        notNull(u.getId(), "user id must not be null");
        isTrue(users.contains(u), "user must not exist in group %d", id);

        System.out.println("Adding user " + u.getId() + " to group " + id);
        users.add(u);
    }

    public void delUser(final User u) {
        notNull(u, "user must not be null");
        notNull(u.getId(), "user id must not be null");
        isTrue(!users.contains(u), "user must exist in group %d", id);

        System.out.println("Removing user " + u.getId() + " from group " + id);
        users.remove(u);
    }
}

Tak samo jako w poprzednim przykładzie kod znacznie się uprościł. Wersja 3 Apache Commons Lang oferuje praktycznie te same możliwości co Guava. Dużo mniej oferowała wersja 2 co też podkreślał na swoim blogu Piotr Jagielski. Od jakiegoś czasu jest już dostępna wersja 3 dlatego kwestię wyboru pomiędzy Guavą a Apache Commons Lang 3 pozostawiam do osobistej decyzji.

Podsumowanie

W kolejnych postach przyjrzymy się bliżej możliwościom biblioteki Cofoja umożliwiającej definiowanie i monitoring pełnego kontraktu - preconditions, postconditions oraz invariants. Zapraszam już wkrótce :)

Comments