czwartek, 6 maja 2010

Płynne interfejsy

Ten post powstał na wyraźną prośbę Jacka Laskowskiego i Bartka Zdanowskiego, którzy zastanawiali się (w komentarzach poprzedniego posta) nad kawałkiem przykładu z dokumentacji Tumbler'a. Przykład wygląda tak:
Zaciekawiła ich konkretnie linijka
library.lend(sampleBook).to(reader);
No i faktycznie, nie dziwię się, że ich zaciekawiła, bo nie jest to wyrażenie typowe dla Javy. Napisałem je w dokumentacji dość bezmyślnie, po prostu tak myślałem o tym kawałku kodu. To bardzo czytelne i jasne wyrażenie.  Z drugiej strony jest ono dość trudne do zrealizowania i jego implementacja może sama nie być już taka czytelna, głównie dlatego, że logika pożyczania książki musi być wywołana w metodzie to a nie lend.

Ale zacznijmy od początku. Płynne interfejsy (fluent interfaces) to technika skupiająca się na takim zaprojektowaniu klas, by możliwe było ich wywoływanie w możliwie najczytelniejszy sposób. Żeby czytało się je prawie jak zwykłe zdania (to jest łatwiejsze w językach o elastyczniejszej składni jak ruby, groovy czy scala - w javie składnia jest bardzo sztywna). Powyższy przykład dość dobrze to obrazuje. W prostym przypadku płynny interfejs to tylko ciąg metod z których każda zwraca this. Tak na przykład tworzy się builder'y:
new Person()
.withName("Paweł")
.withHeight(178)
.withBirthdayOn(OCTOBER, 23);
Takie podejście pozwala obejść problem istnienia wielu różnych konstruktorów dla różnych wersji parametrów. Co więcej pozwala rozwijać klasę (dodawać nowe pola) bez zmiany dotychczasowego kodu. Ryzyko tylko jest takie, że możemy w jakichś metodach bazować na nowo dodanych polach, ale od czego są testy ;-)
Problem z płynnymi interfejsami jest taki, że języki umożliwiające wyłącznie statyczne definiowanie metod raczej wspierają bardziej 'funkcyjne' podejście, gdzie metoda to funkcja operująca na swoich (pewnie paru) parametrach. W przypadku płynnych interfejsów na wykonanie jednej funkcji składa się często parę wywołań metod, z których więkość tylko zmienia stan (magiczne słowo) obiektu/wywołania a dopiero ostatnia w łańcuchu (method chaining) faktycznie wykonuje jakąś operację. W powyższym przykładzie więc metoda lend wyłącznie ustawi w jakimś polu klasy library sampleBook, które będzie użyte potem w metodzie to jako parametr dla faktycznej operacji pożyczenia książki.

No dobra, ale metoda o nazwie to może pewnie obsługiwać dużo wiecej operacji - np:
library.send(someBook).to(emailAddress);
albo
library.move(otherBook).to(poetryShelf);
Jak obsłużyć takie sytuacje?
Najprostsze rozwiązanie to przeciążenie metody to. W zależności od typu parametru będziemy wywoływać tak naprawdę różne to, dzięki czemu możemy oddzielić ładnie te operacje.

No dobra, wcale nie ładnie - nazwa metody to nic nam nie mówi o tym co ta metoda naprawdę będzie robić, więc rozróżnianie jej typem parametru zmniejsza tylko czytelność tego kodu. Trochę lepszym rozwiązaniem jest delegacja z metody to do jakiejś metody o nazwie konkretnie definiującej funkcjonalność (np. przez doSend(someBook, emailAddress)). To trochę lepsze rozwiązanie, ale dalej niewiele daje czytelnikowi API. Posiadanie w klasie library trzech różnych metod to nie jest czystym rozwiązaniem tego problemu. Tak czy siak pierwsza implementacja naszej biblioteki wygląda następująco:
Metoda to powinna mieć jeszcze weryfikację stanu - nie można wywołać jej samej, bo to nie ma sensu. Dorzucę więc zaraz walidację stanu (sprawdzenie czy ustawiona została operacja).
Sam kod zbyt śliczny nie jest - już na pierwszy rzut oka widać, że metoda to będzie puchła - przyda się zatem mały refactoring:
No, teraz dużo lepiej. Metoda to jest już dość abstrakcyjna - jedyne co robi to weryfikacja stanu (czy może być wywołana), dodanie jej parametru (celu operacji) i wywołanie odpowiedniej metody. Nie mamy warunków, a cała logika odpowiedzialna za operacje jest w podklasach operacji (tu magia enumów, ale można było zastosować oczywiście klasyczną strategię). Teraz dodanie kolejnych metod to (dla innych parametrów) oraz innych podobnych metod będzie tylko podobną delegacją do odpowiedniej logiki. Będzie jasne jak i gdzie je dodawać. Jestem zadowolony.

Ale zaraz, powiedziałby Bartek Zdanowski, a co z równoległym dostępem do klasy library?
Hm, no to nie takie trudne chyba. Wystarczy wsadzić operację i parametry do ThreadLocal'a i tak operować na nich. Spróbujmy (klasę Operations sie nie zmieniła, więc skonwertowałem ją do top-level i jej nie widać, za to widać resztę):

Jak widać specjalnie nam to kodu nie skomplikowało, a mamy pewny równoległy dostęp. Dorzuciłem przy okazji czyszczenie stanu wywołania, tak, żeby następujące po sobie wywołania na pewno nie miały na siebie wpływu.


Jak widać płynne interfejsy nie są takie straszne, za to mają ogromny wpływ na czytelność kodu. Tak jak każdej innej techniki nie ma ich co nadużywać, ale w przypadku często używanego i/lub złożonego API są one bardzo przydatne. W bardziej skomplikowanych przypadkach może się okazać, że będziemy musieli wprowadzić wzorzec state by ładnie obsługiwać różne stany obiektu, choć zwykle jeśli to jest niezbędne warto się zastanowić, czy nasza klasa nie zaczyna odbiegać trochę od swojej głównej funkcjonalności.

12 komentarzy:

  1. Bardzo ładny "rzeczywisty" przykład zastosowania takich interfejsów znajduje się w bibliotece FEST Assert (asercje dla testów jednostkowych): http://fest.easytesting.org/assert

    OdpowiedzUsuń
  2. Można by jeszcze zrobić to w ten sposób, że send() oraz lend() wcale nie zwracają obiektu klasy Library a już obiekt wykonujący daną operację z ustawionymi parametrami. Mogłyby tworzyć te obiekty za każdym razem nowe, dzięki czemu nie musielibyśmy nawet używać ThreadLocal obsługiwać wiele równoległych operacji. czyli coś w stylu:

    public class Library {

    public SendOperation send(Book book) {
    return new SendOperation(book);
    }

    public LendOperation lend(Book book) {
    return new LendOperation(book);
    }

    class LendOperation {

    public LendOperation(Book book) {
    this.book = book;
    }

    public void to(Reader reader) {
    //real implementation
    }

    }

    class SendOperation {

    public SendOperation(Book book) {
    this.book = book;
    }

    public void to(Reader reader) {
    //real implementation
    }

    }

    }

    Nie wiem, czy spełnia to wszystkie zasady Fluent Interface, skoro zwraca obiekty innego typu, ale wydaje się bardziej przejrzystym rozwiązaniem, zatem nieważna teoria a pragmatyka ;-)

    OdpowiedzUsuń
  3. @Tomasz
    Cały pic polega na tym żeby zwracać Library. Dyskusja o zwracaniu "czegoś innego" już była przy okazji poprzedniego postu Tubmler. Też miałem taki pomysł, ale Paweł obiecał pokazać jak pozostać przy Library. Stąd ten post.

    OdpowiedzUsuń
  4. Jestem usatysfakcjonowany. Bardzo krótki i rzeczowy wpis, a dodatek Tomasza Bartczaka jeszcze bardziej go uatrakcyjnił. Super! Chcę więcej.

    OdpowiedzUsuń
  5. Ten komentarz został usunięty przez autora.

    OdpowiedzUsuń
  6. kurcze jeszcze raz, bo blogspot traktuje nawiasy trójkątne jako html :-)

    @Bartek
    Dlaczego tak się upieracie przy robieniu wszystkiego w klasie Library? Nawet przy tym prostym przykładzie zaczynają się code smells jakieś enumy, haszmapy, threadlocals, brr, podczas gdy soluszyn z wieloma klasami jest prościutkie.

    Jest prosta metoda zdefiniowania składni dowolnego zdania angielskiego czy dowolnego DSL wprost w kodzie Javy.

    Budowę każdego zdania w języku jak angielski można mniej więcej sprecyzować gramatyką formalną (tak jak się robi opisując składnię Javy czy C++). Jak taką gramatykę przerobić na kod Javy? Zmiennymi gramatyki byłyby klasy Javowe, a tokenami - metody tych klas i ich parametry.

    Gramatyka do przykładziku:
    START ::= (lend | send) <book> to <reader&gt
    to jest to samo co:
    START ::= (lend | send) <book&gt DESTINATION
    DESTINATION ::= to <reader&gt

    START ::= (lend | send) <book&gt DESTINATION
    implementujemy metodami:
    DestinationBuilder Library.lend(Book book)
    DestinationBuilder Library.send(Book book)

    DESTINATION ::= to <reader&gt
    implementujemy metodami:
    void DestinationBuilder.to(Reader reader)

    Implementacja tych metod jest chyba oczywista.

    W ten sposób dosyć łatwo dałoby się zapisać każde zdanie angielskie za pomocą kodu Javy.

    OdpowiedzUsuń
  7. No z tym "każde" to przesadziłeś ;-)
    Rozwiązanie z pełną gramatyką jest oczywiście dużo bardziej elastyczne, ale też złożone, a więc kosztowne. Na potrzeby większości prostych API wystarcza takie podejście jak tu przedstawione.
    Za to gdybym miał pisać większy DSL, to oczywiście bez poprawnego rozdzielenia ról szybko bym się zakopał we własnym kodzie.

    OdpowiedzUsuń
  8. bardziej złożone?
    Tu i tu musisz utworzyć nową klasę: ja utworzyłem DestinationBuilder ty utworzyłeś enuma Operations. 0:0
    Twoje soluszyn zawiera mapy, threadlocale, moje nie. U mnie sporo roboty odwala kompilator (np. że 'to' nie może być wywołane przed 'send' i 'lend'). 1:0 dla mnie. :-)

    Pełen kod:
    class Library {
    interface DestinationBuilder {
    void to(Reader reader);
    }

    DestinationBuilder send(Book book) {
    return new DestinationBuilder(){
    void to(Reader reader) { doSend(book, reader);
    };
    }

    DestinationBuilder lend(Book book) {
    return new DestinationBuilder(){
    void to(Reader reader) { doLend(book, reader);
    };
    }

    private void doSend(Book book, Reader reader) { ... }
    private void doLend(Book book, Reader reader) { ... }
    }

    OdpowiedzUsuń
  9. Dodam, że Paweł jeszcze nie czytał "Growing Object-Oriented Software, Guided by Tests" i pewnie stąd dał Ci się ograć :-)

    OdpowiedzUsuń
  10. No akurat tego to się nauczyłem nie z żadnej książki tylko analizując jak ludzie to robią w bibliotekach typu EasyMock, FEST, JEquel

    OdpowiedzUsuń
  11. Oczywiście Irek ma rację - może enum robi na mnie wrażenie lżejszego i stąd napisałem, że te wyraźnie oddzielne klasy są bardziej skomplikowane.
    W poście wyszedłem od buildera, którego konstruuje się w oparciu o budowaną klasę. Plan był pójść od tego posta dalej, przez konstrukcje oparte o dodatkowe klasy (z resztą Tomek we wcześniejszym komentarzu już pokazał prawie to samo) do zewnętrznych DSLi, ale ciągle brakuje czasu. Wcześniej pewnie Fowler książkę wyda... ;-)
    Dzięki Irek za komentarze!

    OdpowiedzUsuń
  12. Stworzyliśmy plugin do eclipse'a który ułatwia tworzenie takich zabawek:

    http://code.google.com/p/fluent-builders-generator-eclipse-plugin/

    mam nadzieję że ci się spodoba

    OdpowiedzUsuń