Programowanie funkcyjne z Google Guava – Funkcje i Predykaty
Skoro już zaczęliśmy omawiać Guavę od fragmentów związanych z programowaniem funkcyjnym, a tak prawdę z próbą wprowadzenia do javy pewnych idiomów funkcyjnych.
Przydługi wstęp teoretyczny
Głównym elementem biblioteki wykorzystywanym przez programistów są interfejsy Funciton, Predicate oraz klasy narzędziowe do pracy z kolekcjami ze szczególnym uwzględnieniem Collections2 pozwalającej na aplikację ww. interfejsów do kolekcji. Zazwyczaj na tym też kończy się wykorzystanie możliwości Guavy w zakresie programowania „funkcyjnego”.
Programowanie „funkcyjne”
W Javie (nie mylić z JVM) jako takiej nie ma możliwości eleganckiego programowania funkcyjnego. Co oznacza „eleganckiego”? Otóż można wyprowadzić, i robi to właśnie Guava, pewne elementy dające możliwość tworzenia kodu w stylu w jakim piszemy programy funkcyjne. Jest to jednak jedynie proteza w dodatku mało wygodna.
Kolejnym elementem utrudniającym stworzenie takiej protezy jest brak domknięć. W sumie nie tyle co brak, co bardzo nieefektywne ich emulowanie za pomocą interfejsów, a dokładniej za pomocą tworzenia klas anonimowych implementujących te interfejsy. Tu sztandarowym przykładem jest model obsługi zdarzeń występujący w Swingu czy Vaadin. Zazwyczaj programiści tworzą listenery właśnie jako klasy anonimowe. W efekcie dostają trudny do testowania kod.
To co daje Guava to spójny zestaw interfejsów pozwalający na w miarę szybkie tworzenie kodu przypominającego kod funkcyjny. Przy czym należy pamiętać, że ani Guava ani tym bardziej kompilator nie są wstanie zagwarantować poprawności stworzonego kodu pod kątem m.in. braku efektów ubocznych, powtarzalności czy spójności. O to trzeba zatroszczyć się samemu.
Na co trzeba uważać
Zanim przejdziemy do przykładów należy poruszyć jeszcze jedną kwestię. Programowanie funkcyjne w Guavie jest podobne do pięknej łąki na której pasą się krówki. Można wdepnąć w niezłe gówno… i to ie raz i nie dwa.
Po pierwsze taki styl programowania wymaga bardzo dobrego warsztatu w zakresie organizacji kodu. Dość szybko okaże się, że mamy do dyspozycji dziesiątki funkcji, które np. są porozrzucane po pakietach, albo dublują swoje zachowania.
Po drugie trzeba się przełamać i zacząć pisać testy. W przypadku funkcji jest to otyle przyjemne, że dość szybko odkryjemy kiedy funkcja jest przeładowana odpowiedzialnością. Napisanie testu takiej funkcji będzie koszmarnie trudne.
Po trzecie trzeba przeprosić się ze statycznymi importami. Inaczej skończymy jak w dowcipie, że Java wprowadziła do techniki programowania samoopisujące nazwy metod, a Ruby spowodował, że nazwy te mieszczą się w jednej standardowej linii.
Po czwarte warto zapoznać się z dostawcami jako sposobem tworzenia singletonów. Generalnie idealna sytuacja jest taka, że funkcja nie posiada stanu i pracuje na niezmiennikach. Wtedy nasze zadanie ogranicza się do składania funkcji.
Interfejs Function
Interfejs ten jest podstawą dla wielu funkcjonalności dostarczanych przez Guavę. Należy rozumieć go tak jak rozumiemy funkcję matematyczną. Dla danego argumentu X należącego do zbioru możliwych argumentów (dziedziny) funkcja zwraca wartość Y. Przy czym:
- Dla danego X zawsze zwracany jest ten sam Y (nie oznacza to, że dla danego Y zawsze jest jeden X).
- Funkcja nie powoduje efektów ubocznych.
O ile pierwsze stwierdzenie jest jasne i znane ze szkoły podstawowej to już drugie wymaga małego wyjaśnienia. Brak efektów ubocznych w przypadku programowania funkcyjnego należy rozumieć w następujący sposób. Funkcja nie ma efektów ubocznych jeżeli nie powoduje modyfikacji obiektów innych niż zwrócona wartość. W szczególności nie modyfikuje obiektu będącego argumentem funkcji.
Stwórzmy zatem bardzo prostą funkcję, która jako argument będzie przyjmować String, a efektem jej działania będzie napis wielkimi literami. Chcąc napisać kod jak Omnissiah przykazał należałoby zacząć od testów. Dla niedowiarków sądzących, że nie jest to takie proste drobny przykład.
Listing 1. Prosta funkcja
public class ToUpperCase implements Function<String, String> {
@Override
public String apply(String input) {
return input.toUpperCase();
}
}
Kod wydaje się poprawny. Osoby bardziej doświadczone powinny jednak już bez podpowiedzi wyłapać co jest źle. Dla wszystkich poniżej test.
Listing 2. Test prostej funkcji
public class ToUpperCaseTest {
Function<string string=""> testFunction = new ToUpperCase();
@Test
public void testApplyForString() throws Exception {
assertThat(testFunction.apply("ala")).isEqualTo("ALA");
}
@Test
public void testApplyForNull() throws Exception {
assertThat(testFunction.apply(null)).isNull();
}
}</string>
Pierwszy test przechodzi, ale drugi już nie. Oczywiście naprawienie kodu nie jest trudne.
przykład.
Listing 3. Prosta funkcja po naprawie
public class ToUpperCase implements Function<String, String> {
@Override
public String apply(String input) {
if (input == null)
return null;
return input.toUpperCase();
}
}
Jest to jednak doskonały przykład na tworzenie się niezamierzonych efektów ubocznych. Alternatywnym rozwiązaniem, w pewnych zastosowaniach nawet zdecydowanie lepszym, jest pozostawienie kodu bez zmian i dodanie odpowiedniej informacji. Oznacza to, że dokonujemy ograniczenia dziedziny funkcji i należy o tym poinformować użytkowników naszego kodu. Należy w takim przypadku rzucić NPE zgodnie z dokumentacją interfejsu.
Mi osobiście nie podoba się jeszcze jedna rzecz. By wywołać tą funkcję muszę się naklepać kodu. Można sobie ułatwić życie przepisując ją w następujący sposób:
Listing 4. Prosta funkcja w wersji przyjaznej dla klawiatury
public class ToUpperCase implements Function<String, String> {
private static ToUpperCase function = new ToUpperCase();
public static String toUpperCase(String input) {
return function.apply(input);
}
@Override
public String apply(String input) {
if (input == null)
return null;
return input.toUpperCase();
}
}
Wywołanie takiej funkcji przy wykorzystaniu statycznych importów jest już całkiem fajne.
Słowo o metodzie equals
Interfejs Function zawiera też metodę equals. Taką samą jak w Object. Po co? Otóż należy sobie przypomnieć definicję równości funkcji. Dwie funkcje są równe jeżeli ich dziedziny są równe oraz kiedy dla każdego argumentu X należącego do dziedziny wyniki działania funkcji są równe.
W przypadku Guavy nie ma konieczności wychodzenia poza standardową implementację tej metody, ale założę się, że znajdziemy przypadki kiedy implementacja rozbudowana o ww. warunek okaże się niezbędna.
Interfejs Predicate
Interfejs ten jest specyficzną odmianą funkcji. Przy czym nie zależy w żaden sposób od interfejsu Function. W tym przypadku mamy do czynienia z „funkcją”, która na podstawie argumentu zwraca wartość logiczną. Oczywiście Guava dostarcza metod transformujących predykaty na funkcje i odwrotnie. Cała reszta pozostaje taka sama jak w przypadku funkcji. Piszemy testy, unikamy efektów ubocznych itd.
Dlaczego predykat nie jest funkcją
Zdawać by się mogło, że jest to naturalne podejście. Za chwilę przyjrzymy się klasom narzędziowym Functions i Predicates. Analizując ich API można jednak dojść do wniosku, że takie odseparowanie funkcji i predykatów jest lepsze. W praktyce predykaty i funkcje nie posiadają wspólnych metod w ramach tych klas. Tym samym nie ma sensu tworzenie jednej hierarchii dla tych bytów. Szczególnie, że wymagało by to stworzenia klasy, której część metod musiałaby przyjmować zamiast ogólniejszej funkcji bardziej szczegółowy predykat.
Klasa Functions
Klasa ta jest klasą narzędziową wspomagającą zarządzanie funkcjami. Nie służy jednak do aplikowania funkcji lecz do tworzenia nowych, dekorowanych rozwiązań.
Metoda compose
Metoda ta przyjmuje jako argumenty dwie funkcje. Pierwsza to G(Y)>Z, a druga F(X)>Y. Wynikiem jej działania jest funkcja H(X)>Z. Jest to podstawowa metoda pozwalająca na tworzenie prostych potoków przetwarzania.
Listing 5. Prosty potok w oparciu o dwie funkcje
public class FunctionExample {
public static void main(String[] args) {
Function<String, Sex> sexFromString =
compose(_GetSexFromLastChar(),
_LastCharacter());
System.out.println(sexFromString.apply("Ala"));
}
public enum Sex {
M, F;
}
}
W tym przypadku tworzymy funkcję, która ma za zadanie określić płeć na podstawie imienia. Przy czym posiadamy zdefiniowane dwie proste funkcje. Pierwsza wybiera ostatni znak ze stringa. Druga na podstawie znaku określa płeć – kobiece imiona kończą się na a. Co jednak jak znajdzie się żartowniś i napisze imię wielkimi literami? Mamy dwie drogi. Pierwsza to być miłym dla użytkownika i w funkcji GetSexFromLastChar pozwolić na użycie wielkich i małych liter. Jest ona zdecydowanie lepsza. Kolejna to użycie „po drodze” funkcji, która zamieni przekazany string na pisany małymi literami. Należy oczywiście użyć kompozycji. Jest to dobre rozwiązanie jedynie wtedy gdy koniecznie chcemy pozbyć się warunku z naszej funkcji. Trzecia opcja, która jest najbardziej „funkcyjnie zajebista” to użycie kompozycji predykatu w funkcji GetSexFromLastChar. Wrócimy jeszcze do tego tematu.
Metoda constant
Jest to metoda, która stworzy funkcję stałą, czyli taką która dla dowolnego argumentu zawsze zwróci ten sam wynik. Wykorzystanie takiej funkcji jest dość specyficzne. Generalnie najbardziej przydatna będzie w testach oraz tam gdzie musimy „coś” zwrócić w kodzie. Sól internetów – startupowcy na pewno polubią tą metodę. Idealna do stubowania 😉
Metoda forMap
Występuje w dwóch odmianach. Pierwsza jako jedyny argument przyjmuje mapę i zwraca funkcję, która jako argument przyjmuje klucz i zwraca wartość z mapy. W przypadku braku klucza w mapie rzuca IllegalArgumentException. Druga wersja przyjmuje dodatkowy argument w postaci domyślnej wartości zwracanej w przypadku braku klucza.
Do czego może posłużyć nam taka funkcja? Niewątpliwie znajdzie ona zastosowanie wszędzie tam gdzie chcemy użyć funkcji na wartościach, a mamy „gołą” mapę. Oczywiście samodzielnie nie daje ona żadnych bonusów, ale gdy zostanie wykorzystana wraz z omówioną wcześniej metodą compose daje naprawdę dużo możliwości.
Metoda forPredicate
Metoda ta jest adapterem dla przekazanego predykatu. Powróćmy tu na chwilę do przykładu z listingu 5. Jeżeli zamiast enuma chcemy zwracać wartość logiczną można napisać to w następujący sposób:
Listing 6. Zastosowanie funkcyjnego adaptera predykatu
public class FunctionExample {
public static void main(String[] args) {
Function<Character, Boolean> isFemaleFromChar =
forPredicate(_IsFemaleLowerCase());
Function<String, Boolean> isFemaleFormString =
compose(isFemaleFromChar, _LastCharacter());
System.out.println(isFemaleFormString.apply("Ala"));
}
}
Efekt podobny do poprzedniego. Co więcej jeżeli zamkniemy takie wywołanie w ramach osobnej klasy to uzyskujemy całkiem sprawne narzędzie do tworzenia rozwiązań, których zadaniem jest np. walidacja danych.
Metoda toStringFunction
Kolejna metoda produkująca funkcję do użycia tam, gdzie nie można użyć standardowego wywołania toString. Nic szczególnego.
Metoda forSupplier
Ostatnia metoda z klasy Functions jest bardzo podobna do wcześniej omówionej metody constant. Z tą różnicą, że zamiast predefiniowanej wartości przekazujemy jej obiekt klasy Supplier, który będzie już w swej mądrości dostarczał wyniki.
Na tym kończy się klasa Functinos.
Klasa Predicates
Podobnie jak w przypadku klasy Functions jest to klasa narzędziowa służąca do łączenia predykatów oraz tworzenia nowych.
Metody alwaysTrue i alwaysFalse
Obie te metody jak wskazują nazw dostarczają predykatów zwracających zawsze true albo false.
Metoda and
Metoda ta ma kilka zestawów parametrów lecz wszystkie dają ten sam rezultat jakim jest predykat, który zwraca wynik operacji i
dla wszystkich rezultatów wyprodukowanych przez przekazane predykaty. Ważną wiadomością jest to, że predykat zwraca false w momencie niespełnienia warunku przez pierwszy z napotkanych predykatów. Można zatem zastosować pewną sztuczkę przekazując do tej metody predykaty od najmniej zasobożernych do najbardziej zasobożernych.
Metoda assignableFrom
Metoda ta dostarcza nam predykat sprawdzający czy dany obiekt może zostać rzutowany na podaną klasę. Jest to w rzeczywistości wywołanie metody o tej samej nazwie z klasy Class.
Metoda compose
Metoda ta przyjmuje dwa parametry. Drugim jest funkcja, która z A tworzy B, a pierwszy predykat przyjmujący B jako argument. Wynikiem działania jest predykat przyjmujący jako argument A.
Wracając do przykładu z listingu 6. możemy go zapisać za pomocą predykatu, a nie funkcji zwracającej wartość logiczną w następujący sposób
Listing 7. Zastosowanie kompozycji predykatu i funckji
public class PredicateExample {
public static void main(String[] args) {
Predicate<String> isFemaleLCFromString
= compose(_IsFemaleLowerCase(), _LastCharacter());
System.out.println(isFemaleLCFromString.apply("Ala"));
}
}
Różnica polega tu na tym co otrzymujemy w wyniku działania metody. Tam była to funkcja tu jest to predykat. Ponad to metoda ta pozwala na tworzenie predykatów dla klas dla których mamy już stworzoną transformację za pomocą funkcji.
Metody contains i containsPattern
Otrzymany predykat przyjmuje jako parametr sekwencję znaków i sprawdza czy zawiera ona wzorzec przekazany do tej metody fabrykującej. Metody różnią się tylko sposobem w jaki przekazany jest wzorzec.
Metoda equalTo
Metoda otacza wywołanie equals obiektu przekazanego jako argument, który będzie porównywany z obiektami przekazanymi do predykatu. Predykat ten sprawdza się gdy zostanie połączony z funkcjami. Pozwoli to na budowanie filtrów. Co w efekcie pozwala na stworzenie bardziej skomplikowanych potoków przetwarzania.
Metoda in
Predykat sprawdza czy dany obiekt znajduje się w kolekcji.
Metoda instanceOf
Kolejny predykat obudowujący często wykorzystywany idiom z warunkiem. Podobnie jak predykat uzyskany za pomocą assignableFrom prawdziwa siła tkwi w odpowiedniej kompozycji tego predykatu z funkcją.
Metody isNull i notNull
j.w.
Metoda not
Na podstawie predykatu tworzy predykat przeciwny.
Metoda or
Podobna do and tyle tylko, że tworzy złożony predykat wykonujący operację lub
. Tu wracamy do naszego problemu wielkiej i malej litery.
Przykładowa kompozycja predykatów i funkcji
Naszym celem jest stworzenie funkcji, która na podstawie przekazanego napisu rozpozna czy mamy do czynienia z mężczyzną czy kobietą i zwróci nam odpowiedni enum. Można by to co prawda zrobić za pomocą jednej prostej instrukcji, ale… po pierwsze jest to przykład, a po drugie trzeba pamiętać, że każdy IF którego się pozbędziemy z naszego kodu to dwa problemy do przetestowania mniej.
Co my tam mamy?
Na chwilę obecną dysponujemy już wszystkimi potrzebnymi predykatami i funkcjami oraz mamy do dyspozycji wszystkie metody służące do ich łączenia w bardziej złożone wywołania.
Naszym celem jest uzyskanie czegoś na kształt:
Listing 8. Co chcemy uzyskać
public class FunctionsAndPredicatesCompositionExample {
public static void main(String[] args) {
System.out.println(getSexFromName("Ala"));
System.out.println(getSexFromName("Jaś"));
}
}
W tym celu tworzymy sobie funkcję GetSexFromName, która będzie kompozycją znanych już GetSexFromLastChar i LastCharacter.
Listing 9. Funkcja GetSexFromName
public class GetSexFromName implements Function<String, Sex> {
private static final GetSexFromName f = new GetSexFromName();
private final Function<String,Sex> compose;
private GetSexFromName() {
compose = compose(_GetSexFromLastChar(), _LastCharacter());
}
public static GetSexFromName _GetSexFromName() {
return f;
}
public static Sex getSexFromName(String input) {
return f.apply(input);
}
@Override
public Sex apply(String input) {
return compose.apply(input);
}
}
Funkcja LastCharacter jest bardzo prosta:
Listing 10. Funkcja LastCharacter
public class LastCharacter implements Function<String, Character> {
private static final LastCharacter f = new LastCharacter();
public static LastCharacter _LastCharacter() {
return f;
}
public static Character lastCharacter(String input) {
return f.apply(input);
}
@Override
public Character apply(String input) {
checkNotNull(input);
checkArgument(input.length() > 0);
return input.charAt(input.length() - 1);
}
}
W przeciwieństwie do GetSexFromLastChar gdzie nie chcemy używać IFów.
Listing 11. Funkcja GetSexFromLastChar
public class GetSexFromLastChar implements Function<Character, Sex> {
private static final GetSexFromLastChar f = new GetSexFromLastChar();
private final Map<Boolean,Sex> trueFalseMap;
private final Function<Character,Sex> compose;
private GetSexFromLastChar() {
trueFalseMap = new HashMap<Boolean, Sex>();
trueFalseMap.put(Boolean.TRUE, Sex.F);
trueFalseMap.put(Boolean.FALSE, Sex.M);
compose = compose(forMap(trueFalseMap),
forPredicate(
_IsFemaleCase()
)
);
}
public static GetSexFromLastChar _GetSexFromLastChar() {
return f;
}
public static Sex getSexFromLastChar(Character input) {
return f.apply(input);
}
@Override
public Sex apply(Character input) {
checkNotNull(input);
return compose.apply(input);
}
}
Mamy tu tak naprawdę złożenie dwóch funkcji. Pierwsza pochodzi z predykatu IsFemaleCase i na podstawie przekazanego przekazanego do niego znaku zwraca informację czy mamy do czynienia z imieniem żeńskim czy męskim. Druga jest stworzona z mapy i na podstawie otrzymanej wartości logicznej zwraca odpowiedni enum. Te dwie funkcje po złożeniu przyjmują jako wejście pojedynczy znak i zwracają odpowiedni enum.
Sam predykat IsFemaleCase też jest złożeniem dwóch prostych predykatów odpowiedzialnych za wielkie i małe a
.
Listing 12. Predykat IsFemaleCase
public class IsFemaleCase implements Predicate<Character> {
private static final IsFemaleCase f = new IsFemaleCase();
private Predicate<Character> or = or(_IsFemaleLowerCase(), _IsFemaleUpperCase());
public static IsFemaleCase _IsFemaleCase() {
return f;
}
public static boolean isFemaleCase(Character input) {
return f.apply(input);
}
@Override
public boolean apply(Character input) {
return or.apply(input);
}
}
Znowuż te predykaty są podstawowymi jednostkami logiki niezawierającymi rozgałęzień. Inaczej mówiąc napisaliśmy program bez użycia instrukcji IF.
ps. zapewne czytają to osoby, z którymi na początku rozmawiałem o współpracy. Tu pragnę im podziękować ponieważ wasze zadanie praktyczne stało się motywatorem do stworzenia tego przykładu.