Co, gdzie, kiedy – czyli przykład refaktoryzacji w kierunku FP.
Dziś na warsztat idzie przykład refaktoryzacji, który nazywam „Co, gdzie, kiedy”. Nazwa nie jest przypadkowa, ponieważ celem jest takie przekształcenie kodu imperatywnego by jak najmocniej odseparować od siebie trzy elementy, które są stałe przy pracy z kolekcjami.
Co
Pierwszy element to określenie co chcemy zrobić. Zazwyczaj chodzi o weryfikację jakiegoś warunku albo o dokonanie obliczeń na jakiejś kolekcji. Za ten element w zwykłym programie odpowiada odpowiednio zdefiniowana metoda.
Gdzie
Jeżeli wiemy co chcemy osiągnąć to musimy też zdefiniować na jakiej kolekcji będziemy pracować. Gdzie jest tu rozumiane jako punkt w naszej domenie, w którym mamy dostęp do pewnej kolekcji z którą możemy pracować w określony sposób.
Kiedy
Ostatnim elementem pracy z kolekcją jest określenie w którym momencie chcemy dokonać naszej operacji na jakiejś kolekcji. W typowym programie odpowiada za to miejsce wywołania naszej metody z kolekcją jako parametrem (lub metody, która jakoś sobie „dociąga” kolekcję).
Przypadek praktyczny
W zasadzie kliniczny przykład kodu, który należy zrefaktoryzować.
Listing 1. Tradycyjne, imperatywne podejście
public boolean checkDomainObject(String objName, String objHash) {
Collection<DomainObject> domainObjects = getDomainObjects();
if (domainObjects.isEmpty()) {
return false;
}
for (DomainObject st : domainObjects) {
if (st.getName().equals(objName)) {
return !(objHash != null && !objHash.equals(st.getHash()));
}
}
return false;
}
Metoda przyjmuje dwa parametry nazwę obiektu i jego hash. Pobiera listę obiektów, po czym sprawdza czy istnieje na niej obiekt o podanej w parametrze nazwie. Jeżeli istnieje i podano też parametr hash to sprawdzana jest poprawność hasha. Dokonano tu „optymalizacji” w postaci dodatkowego ifa, który sprawdza czy kolekcja jest pusta. Jeżeli jest to od razu zwraca false. Poza zaśmieceniem kodu to nic nie da.
Kod ten jest dość prosty w testowaniu. metoda getDomainObjects woła pod spodem repozytorium, które możemy zamockować.
Jednak czy ten kod jest rzeczywiście czytelny?
Niektórzy powiedzą, że jest. I będą mieć rację. Przynajmniej w świecie programowania imperatywnego. Problem z tym kodem polega na tym, że miesza
- Co – czyli instrukcje warunkowe i stojącą z animi logikę biznesową.
- Gdzie – czyli kolekcję, którą do której „doszywa” sposób jej przetwarzania.
- Kiedy – czyli będzie to wywołane w momencie gdy wywołujemy metodę.
Jeżeli będziemy wstanie odseparować od siebie poszczególne elementy kodu to stanie się on czytelniejszy.
Refaktoryzacja krok po kroku
Prześledźmy zatem proces refaktoryzacji tego kodu krok po kroku. Należy tu zaznaczyć, że kod, który będziemy zmieniać ma napisane testy. Zatem możemy bezpiecznie dokonywać przekształceń za każdym razem uruchamiając testy by sprawdzić czy zachowanie pozostało bez zmian.
Uproszczenie kodu
Pierwszym etapem jest uproszczenie i oczyszczenie kodu z nadmiarowych elementów. Możemy z czystym sumieniem wyrzucić instrukcję sprawdzającą czy kolekcja jest pusta. Korzystając z praw De MorganaW możemy też uprościć warunek sprawdzający hash obiektu.
Listing 2. Tradycyjne, imperatywne podejście po oczyszczeniu
public boolean checkDomainObject(String objName, String objHash) {
Collection<DomainObject> domainObjects = getDomainObjects();
for (DomainObject st : domainObjects) {
if (st.getName().equals(objName)) {
return objHash == null || objHash.equals(st.getHash());
}
}
return false;
}
W obecnej formie nie możemy zrobić wiele więcej. Kolejnym przekształceniem może być zamiana pętli foreach na while, ale to tylko sztuka dla sztuki. Nie wniesie nam nic nowego. Kod choć prostszy nadal miesza w sobie różne elementy. Mamy tu zarówno logikę biznesową, pobieranie danych, sposób przetwarzania, a całość jest wykonywana od razu.
Opakowanie warunków
W powyższym kodzie mamy trzy warunki. Pierwszy dla nazwy, drugi dla parametru objHash i trzeci porównujący objHash z wartością z elementu kolekcji.
Zamieńmy te trzy warunki na predykaty.
Listing 3. Zamiana warunków na predykaty
public boolean checkDomainObject(String objName, String objHash) {
Collection<DomainObject> domainObjects = getDomainObjects();
Predicate<DomainObject> name = domainObject -> domainObject.getName().equals(objName);
Predicate<DomainObject> nullHash = domainObject -> objHash == null;
Predicate<DomainObject> hash = domainObject -> objHash.equals(domainObject.getHash());
Predicate<DomainObject> fullHash = nullHash.or(hash);
for (DomainObject st : domainObjects) {
if (name.test(st)) {
return fullHash.test(st);
}
}
return false;
}
Kod wydłużył się, ale udało nam się częściowo odizolować logikę od sposobu przetwarzania. Kod ten ma też inną ciekawą właściwość. Widać jakie warunki brzegowe potrzebne są do testów. Jeżeli uda nam się przygotować testy dla pierwszych trzech predykatów tak by obejmowały wszystkie możliwe warianty to mamy pokryty te wszystkie gałęzie kodu. Widać tu też dodatkowy warunek, którego jeszcze nie zamieniliśmy na predykat. Instrukcja if i następujący w jej bloku predykat można połączyć spójnikiem i.
To oczywiście w obecnej formie jest dość zagmatwane zamieńmy więc pętlę na coś bardziej do ludzi i wprowadźmy ten dodatkowy predykat.
Usunięcie pętli
Pętla w tym kodzie służy do wyszukania pierwszego elementu spełniającego warunek. Jeżeli w kolekcji jest taki element to gdy zostanie on odszukany to zwracamy true. Takie samo działanie ma metoda anyMatch ze strumieni. Zastąpmy więc pętlę wywołaniem tej metody.
Listing 4. usunięcie pętli i nowy predykat
public boolean checkDomainObject(String objName, String objHash) {
Predicate<DomainObject> name = domainObject -> domainObject.getName().equals(objName);
Predicate<DomainObject> nullHash = domainObject -> objHash == null;
Predicate<DomainObject> hash = domainObject -> objHash.equals(domainObject.getHash());
Predicate<DomainObject> domainObjectPredicate = name.and(nullHash.or(hash));
return getDomainObjects().stream().anyMatch(domainObjectPredicate);
}
W kodzie tym zastąpiliśmy jeden z predykatów innym, który jest bardziej ogólny. Całość jest już prawie dobra. Mamy odseparowaną logikę i sposób przetwarzania. Potrafimy powiedzieć co chcemy zrobić i gdzie będziemy to robić. Pozostaje nam zmiana kodu tak by nie wykonywał się zachłannie.
Przesunięcie momentu wywołania
Ostatnim krokiem jest sprawienie by nasza metoda tylko przygotowywała odpowiednie wywołanie, ale nie wykonywała go. Niech zrobi to ten, który tego potrzebuje.
Listing 5. Wprowadzenie leniwej ewaluacji zadania
public Callable<Boolean> checkDomainObject(String objName, String objHash) {
Predicate<DomainObject> name = domainObject -> objName.equals(domainObject.getName());
Predicate<DomainObject> nullHash = domainObject -> objHash == null;
Predicate<DomainObject> hash = domainObject -> objHash.equals(domainObject.getHash());
Predicate<DomainObject> domainObjectPredicate = name.and(nullHash.or(hash));
return ()-> getDomainObjects().stream().anyMatch(domainObjectPredicate);
}
Zmiana taka wiąże się ze zmianą sygnatury metody. Sam kod jest już odpowiednio posegregowany. Wiemy co chcemy zrobić. Wiem gdzie to chcemy zrobić i dajemy wolną rękę wywołującemu co do terminu wykonania.
Uogólnianie
Nadal jednak jest to kod silnie związany z pewną ściśle określoną logiką. Podobne konstrukcje będą przewijać się przez projekt w kontekście różnych warunków i różnych kolekcji. Pytanie brzmi czy możemy napisać kod, który uogólni nam rozwiązanie.
Uogólniona funkcja
Nasza uogólniona funkcja będzie musiała sobie poradzić z bardzo ogólnymi założeniami. Pierwszym z nich jest to, że musi zwracać Callable pasujące do tego co zrobimy z kolekcją. Drugim, że musi przyjmować jako argumenty pewną operację reprezentowaną przez funkcję dwuargumentową, której pierwszym argumentem będzie Stream, a drugim argument pasujący do operacji na strumieniu obiekt, jakiegoś dostawcę kolekcji z którego wyprodukujemy strumień, oraz pasujący obiekt.
Mówiąc prościej potrzebujemy czegoś takiego:
Listing 6. Funkcja uogólniona
interface Invoke {
default <T, U, R> Callable<R> invoke(BiFunction<Stream<T>, U, R> bf, Supplier<Collection<T>> c, U a) {
return () -> bf.apply(Optional.fromNullable(c.get()).or(Collections.emptyList()).stream(), a);
}
}
I można się zapytać co się tu odpierdala… Cała „magia” tego rozwiązania leży w tym w jaki sposób zamieniamy kolejne argumenty funkcji invoke na elementy wywołania naszej logiki. Przykładowa specjalizacja powyższego kodu gdy chcemy zastosować go w naszym przypadku.
Listing 7. Specjalizacja funkcji uogólnionej
interface AnyMatch extends Invoke {
default <T> Callable<Boolean> anyMatch(Supplier<Collection<T>> c, Predicate<T> a) {
return invoke((s, p) -> s.anyMatch(p), c, a);
}
}
Pierwszym argumentem jest to co chcemy wykonać. Drugim to na czym chcemy wykonać daną operację z, i trzecim, użyciem jakich argumentów.
Dodatkowo jeżeli dostaniemy kolekcję, która jest null (jak to w javie bywa), to zamiast niej użyjemy pustej, niemodyfikowalnej kolekcji. Jako, że nie wiemy co możemy podstawić pod domyślne parametry dla argumentów operacji (może być cokolwiek) zatem przekazujemy je tak jak są. Jak będą null to co najwyżej coś się wywali.
Finalnie nasz kod będzie wyglądał tak:
Listing 8. Finalne rozwiązanie
public Callable<Boolean> checkDomainObject(String objName, String objHash) {
Predicate<DomainObject> name = domainObject -> objName.equals(domainObject.getName());
Predicate<DomainObject> nullHash = domainObject -> objHash == null;
Predicate<DomainObject> hash = domainObject -> objHash.equals(domainObject.getHash());
Predicate<DomainObject> domainObjectPredicate = name.and(nullHash.or(hash));
return anyMatch(this::getDomainObjects, domainObjectPredicate);
}
Odseparowaliśmy to co chcemy zrobić od tego gdzie to jest robione. Zwracamy zaś coś co pozwala wywołującemu na zdecydowanie kiedy wykonać daną operację.