czwartek, 13 marca 2014

SOLIDny kod cz. 5 - Interface segregation principle

segregacja interfejsów czyli co?

Czyli:
Klasa udostępnia tylko te interfejsy, które są niezbędne do zrealizowania konkretnej operacji. [link]

albo:
Klasy nie powinny być zmuszane do zależności od metod, których nie używają. [link]

no chyba, że tak:
Klasa powinna udostępniać drobnoziarniste interfejsy dostosowane do potrzeb jej klienta. Czyli, że klienci nie powinni mieć dostępu do metod których nie używają. [link]

I są to tylko niektóre z definicji, które udało mi się znaleźć w internecie po bardzo krótkich poszukiwaniach. I oczywiście pomimo tego, że brzmią odrobine inaczej, to sprowdzają się one do tego samego.

to teraz tłumaczenie na nasze

Zasada segregacji interfejsów polega na tym, że zależności pomiędzy klasami powinny się opierać na minimalnych kontraktach (które zazwyczaj "zawierane" są właśnie za pomocą interfejsów).

I teraz pozwolę sobie na oderwany od rzeczywistości przykład, który mam nadzieję, że odrobinę przybliży temat.
Załóżmy, że klasa A ma trzy metody:
public class A {
    public void foo() { /** some code */ }
    public void bar() { /** some code */ }
    public void baz() { /** some code */ }
}
i jej instancje są wykorzystywane w metodzie xyz klasy B:
public class B {
    public void xyz(A a) {
        // something
        a.foo();
    }

    // more code
}
Jak widać, wewnątrz wykorzystujemy jedynie metodę foo() obiektu. Oznacza to mniej więcej tyle, że warto się zastanowić czy deklaracja xyz powinna zawierać parametr, który jest instacją klasy A?

Możliwe, że nic nie stracilibyśmy na braku tej wiedzy, a co za tym idzie, może rozsądniej byłoby zadeklarować jako parametr obiekty, które implementują odpowiedni interfejs zapewniający istnienie tylko i wyłącznie metody foo():
public interface HasFoo {
    public void foo();
}

public class A implements HasFoo {
 
    @Override
    public void foo() { /** some code */ }
    public void bar() { /** some code */ }
    public void baz() { /** some code */ }
}

public class B {
    public void xyz(HasFoo withFoo) { /** some code */ }
    // more code
}

Tyle słowem wstępu. Teraz czas na jakiś konkretny przykład :)

pozwólcie przedstawić Wam problem

Wyobraźcie sobie, że mamy aplikację wykorzystywaną przez firmę, która dzieli się na departamenty, każdy departament na zespoły, a każdy zespół jest złożony z pracowników (oczywiście skupię się tylko na tym, co nas interesuję, a resztę pominę wymownym milczeniem :)
Tak czy inaczej, powyższe daje nam podstawy do stworzenia takich klas:
public class Department { 
   /** some code */ 
}

public class Team { 
   /** some code */ 
}
oraz:
public class Employee {

    private String login;
    private Password password;
    private Set<Team> teams = new HashSet<Team>();

    public Employee(String login, Password password) {
        changeCredentials(login, password);
    }

    public void changeCredentials(String login, Password password) {
        this.login = login;
        this.password = password;
    }
 
    public void memberOf(Team team) throws EmployeeInTeamException {
        if (isMemberOf(team))
            throw new UserInTeamException("Employee already belongs to given team.");
  
        teams.add(team);
    }

    public boolean isMemberOf(Team team) {
        return teams.contains(team);
    }
}
I aplikacja nasza sobie żyje własnym życiem, a wszyscy, którzy jej używają na codzień są niezmiernie zadowoleni z tego, że mają przyjemność pracy z tak świetnie działającym oprogramowaniem :)

zmian nie unikniesz

Oczywiście, prędzej czy później każdą aplikację to spotyka - nasi klienci chcą czegoś więcej i musimy rozszerzyć funkcjonalność. Owo "coś więcej" to możliwość wysyłania maili do konkretnych pracowników i przechowywania historii tych korespondencji.
Założenia są proste, jeżeli kogoś najdzie ochota, to może sobie znaleźć drugą osobę w systemie i napisać do niej wiadomość.

To zabieramy się do pracy. Skoro mamy wysyłać wiadomości, to potrzeba nam czegoś, co będzie realizowało to funkcjonalność. Tak powstaje interfejs Notifier i klasa go rozszerzająca Mailer:
public interface Notifier {}

public class Mailer implements Notifier {}
Teraz zaczynamy zastanawiać się nad tym, jakie metody nasz interfejs powinien posiadać.
Szybki rzut oka na wymagania i wiemy, że potrzebujemy metody, która pozwoli nam na wysyłanie wiadomości bezpośrednio do pracownika:
void send(Employee employee, Message message);
Oczywiście warto byłoby też dać możliwość wysłania tej samej wiadomości do kilku osób:
void add(Employee employee);
void send(Message message);
Użytkownik może jednak pomylić się i wybrać przez przypadek nie tą osobę, którą chciał więc musimy mu zapewnić możliwość wycofania osoby z puli:
void removeTo(Employee employee);
W sumie to, może dojść do wniosku, że chce całkiem wyczyścić listę i określić ją na nowo:
void clear();
No i nie można zapomnieć o metodzie, która pozwoli nam dowiedzieć się, czy wiadomość została poprawnie wysłana:
boolean wasSent();

Po przemyśleniu wszystkiego i przeanalizowaniu wymagań, powstaje nam taki interfejs:
public interface Notifier {

    void clear();
 
    void add(User user);
 
    void removeTo(User user);
 
    void send(Message message);
 
    void send(User user, Message message);
 
    boolean wasSent();
}
A z nią klasa:
public class Mailer implements Notifier {

    // some code

    @Override
    public void send(User user, Message message) {
        Mail mail = new Mail(message);
        user.fillNotification(mail);
  
        // set all other required things
        // store history
  
        send(mail);

        // finalize everything
    }
}

a gdzie w tym wszystkim pracownik?

No właśnie, może zwróciliście uwagę, że finalny interfejs nie wykorzystuje klasy Employee. Dlaczego? Ponieważ stworzyliśmy interfejs User, który będzie implementowany przez klasę Employee. Wszystko to ma na celu zmniejszenie zależności pomiędzy klasami. Dodatkowo ułatwi nam to implementację ewentualnych rozszerzeń oraz testowanie :)
public interface User {

    void setEmail(EMailAddress email);

    void changeCredentials(String login, Password password);
 
    void fillNotification(Mail mail);
 
    void memberOf(Team team) throws EmployeeInTeamException;
 
    boolean isMemberOf(Team team);
}

public class Employee implements User {

    // some code
    private EMailAddress email;
 
    // more code
    public void setEmail(EMailAddress email) {
        this.email = email;
    }

    @Override
    public void fillNotification(Notification notification) {
        notification.to(login, email);
    }

    // code is everywhere
}

Jeszcze krótkie uzasadnienie skąd się wzięła metoda fillNotification().
W jakiś sposób musieliśmy zapewnić wstrzyknięcie do notyfikacji (w tym wypadku maila) informacji o jej adresacie. Mogliśmy oczywiście zrobić dwie metody getLogin() oraz getMail(), ale ze względu na enkapsulację zdecydowałem się na takie rozwiązanie.

Kod zaimplementowany, wszystko działa, a my siadamy i cieszymy się z zadowolenia naszego klienta.

wyślijmy wiadomość do zespołu

Po jakimś czasie klient ponownie się do nas zgłasza. Chciałby rozszerzyć nową funkcjonalność o możliwość wysyłania wiadomości do wszystkich osób należących do danego zespołu bądź nawet do departamentu. Oczami wyobraźni widzimy już dwie nowe metody:
void send(Team team, Message message);

void send(Department department, Message message);
Oczywiście jeszcze zostaje możliwość wyciągnięcia wszystkich pracowników należących do departamentu/zespołu, iteracja po nich i dodawanie po kolei, w końcu mamy już metody add(Message message) i send(Employee employee)?

Ale zaraz, zaraz, przecież my już jesteśmy na takie ewentualności przygotowani, przecież właśnie z myślą o prostocie w rozszerzaniu został stworzony interfejs User! Teraz wystaczy jedynie, aby zarówno Team jak i Department go zaimplementowały i po sprawie :)
Tylko, że, no właśnie, niektóre metody (np. changeCredentials()) jakoś niespecjalnie pasują do tych klas, a sprawdzanie czy instancje klasy Team są członkami zespoły wydaje się już pozbawione jakiegokolwiek sensu.

Dobra, w tym momencie już wiemy, że coś poszło nie tak.

naprawa złego kontraktu

Jak cytowałem na samym początku: klasy nie powinny być zmuszane do zależności od metod, których nie używają co niestety nie do końca udało się w tym przypadku.

Stworzony interfejs (User) jest odzwierciedleniem klasy Employee i to z nią przede wszystkim jest powiązany, w mechanizmie służącym do wysyłania powiadomień jest jedynie wykorzystywany, a prawda jest taka, że powinno być na odwrót. W końcu ten interfejs służy do umożliwienia uzupełnienia jakichś wartości niezbędnych do wysłania wiadomości.
Poza tym, jeżeli metoda przyjmuje jako parametr jakiś interfejs to warto zwrócić uwagę, czy jest on gwarancją, że obiekty go implementujące dostarczą wszystkie niezbędne metody. I tylko je. W naszym przypadku interfejs jest nadmiarowy, a co za tym idzie, jego ponowne użycie w przyszłości będzie wymagało od programisty implementacji niepotrzebnego kodu, czyli straty czasu. A że tym programistą niekoniecznie musi być nasz zaprzysiężony wróg, to lepiej takich praktyk unikać :)

Co prawda, zawsze może on oglądnąć kod metody, jeżeli nie jest do końca pewny czy dana metoda rzeczywiście powinna być w interfejsie, ale wymaga to dodatkowego czasu, a w przypadku rozbudowanej funkcjonalności może to nie być wcale takie proste.

czasem może SMSem?

Wyobraźmy sobie, że nasz klient po jakimś czasie doszedł do wniosku, że dobrze byłoby mieć możliwość wysłania wiadomości SMSem. W końcu wszystko tak świetnie działa, a kolejna funkcjonalność ułatwi życie w niektórych sytuacjach.

Pierwszy problem to konieczność zmiany ciała metody fillNotification() w klasie Employee, ponieważ do wysłania SMS'a nie jest konieczna znajomość adresu mailowego. Tak czy inaczej, wierzę że zgodzicie się, że tego typu zmiany są naturalną częścią procesu rozwoju aplikacji i nie zawsze da się je przewidzieć (na marginesie - tu się dało :).

Większy problem pojawia się natomiast z koniecznością implementacji wszystkich metod zadeklarowanych w interfejsie Notifier. My przecież potrzebujemy tylko jednej! Jasne, jak to pisaliśmy, to mieliśmy trochę czasu i dodaliśmy co nieco, a że niestety okazało się to nie wykorzystane?

I tutaj mamy sytuację, o której pisałem już wcześniej, a mianowicie pisanie kodu na przyszłość, z myślą, że się przyda, że pasuje. I o ile on sobie jedynie leży niepotrzebnie, to była to jednorazowa strata czasu, gorzej jest w takich przypadkach jak w przykładzie, ponieważ albo przeznaczymy dodatkowy czas na refaktoryzację albo zaimplementujemy zbędne metody (lub będziemy wyrzucali w nich wyjątki - Not implemented and never will be :P)

to tyle

Mam nadzieję, że przykład zademostrował Wam problemy wynikające ze stosowania przerośniętych interfejsów. Zdaję sobie sprawę, że wiele osób czuje wewnętrzy, irracjonalny strach, gdy interfejsy zaczynają się mnożyć, jednak pamiętajcie, że ilość klas/interfejsów, jeżeli są przemyślane, nie jest wprost proporcjonalna do stopnia skomplikowania kodu.

Brak komentarzy:

Prześlij komentarz