Jak zaprzyjaźnić się z lambdą w Javie 8?
Nie każdy system może być zmigrowany do najnowszej wersji Javy pstryknięciem palców. Jednym z takich systemów jest Ericsson Network Manager - program dla operatorów sieci komórkowych, mający za zadanie zarządzać i sterować siecią oraz wchodzącymi w jej skład węzłami. To gigantyczne przedsięwzięcie - w skład projektu wchodzą tysiące bibliotek i programów zewnętrznych oraz ponad setka zespołów programistycznych z całego świata. Jedną z najbardziej fantastycznych rzeczy, jakie ja odczułem w Ericssonie, to dzielenie się wiedzą i doświadczeniem - rzetelne, klarowne, cierpliwe i merytoryczne.
Ericsson Network Manager jest właśnie w trakcie przechodzenia na Javę 8. W artykule opisuję przejście z zestawu w postaci Javy 7 + biblioteki zewnętrzne na czystą Javę 8, wyjaśniając po kolei wszystkie kroki, które popełniałem po drodze.
Migrowanie tak wielkiego projektu komercyjnego na nową wersję Javy - wliczając w to całe jego zaplecze ciągłej integracji i wielopoziomowych testów - jest sporym przedsięwzięciem logistycznym i technicznym. W jego trakcie miałem już okazję widzieć wysypujące się Jenkinsy, problemy związane z wersjonowaniem zależności, sypiące błędami serwery aplikacji JBOSS, które nie powinny się wydarzać, i - w końcu - programistów, zaczynających czuć się nieswojo na widok symbolu ->
w składni metody, którą akurat piszą.
Niniejszy artykuł dedykuję zarówno nowicjuszom, jak i wszystkim tym wspomnianym powyżej, którzy przez ostatnie dwa lata jeszcze nie zdążyli odnaleźć się w Javie 8 - a w szczególności w jednej z jej najważniejszych nowinek: w wyrażeniach lambda. Koncept funkcji anonimowej nie jest wbrew pozorom trywialny, szczególnie dla ludzi posiadających doświadczenie w językach C i C++98, w których funkcje anonimowe nie występują. Postaram się opisać lambdy zarówno od strony teoretycznej, jak i praktycznej, w postaci kodu javowego.
Jak to wyglądało w Javie 7
Załóżmy na początku, że jesteśmy w Javie 7, i korzystamy ze środowiska IntelliJ IDEA. Dysponujemy listą dziesięciu Integer
ów: [3, 1, 6, 2, 9, 0, 5, 4, 8, 7]
. Z tej listy chcemy wybrać tylko elementy parzyste.
Gdybyśmy chcieli skorzystać z narzędzi dostępnych w czystej Javie 7, to na dzień dobry mamy problem. Java 7 nie dysponuje abstrakcjami pozwalającymi na proste filtrowanie elementów kolekcji. Jeśli potrzebowalibyśmy funkcji, która odsiewa jakieś elementy z listy, to musielibyśmy ją sobie dodać jako bibliotekę albo napisać sami.
Z pomocą przychodzą nam autorzy bibliotek dodatkowych do Javy - takich jak między innymi Google Guava. Udostępniają oni potrzebne nam metody do operowania na listach, choćby tę potrzebną w naszym przypadku: Iterables.filter(Iterable, Predicate)
. Za Iterable
posłuży nam zdefiniowana wcześniej lista. Potrzebujemy jeszcze implementacji interfejsu Predicate
, przyjmowanego przez metodę filter
.
Predicate
- czyli predykat - to nieco strasznie brzmiące pojęcie z języka logiki formalnej. Jest to funkcja, która przyjmuje jakiś argument i odpowiada: „tak" lub „nie", w zależności od podanego jej elementu. Całe działanie metody filter
polega na wołaniu tego predykatu na każdym argumencie kolekcji. Jeśli predykat zwróci „tak", to metoda umieści ten element w kolekcji wynikowej. W przeciwnym wypadku, ten element się tam nie znajdzie.
Potrzebujemy zatem predykatu isEven
, który będzie zwracał wartość prawdziwą dla liczb parzystych oraz fałsz dla nieparzystych. Możemy zauważyć, że klasa Predicates
z Guavy posiada dość liczny zbiór domyślnych predykatów. Niemniej jednak pech chciał, że nie ma tam predykatu odsiewającego liczby parzyste od nieparzystych. Z tego powodu - oraz dla celów ćwiczeniowych - stwórzmy nową klasę implementującą interfejs Predicate
dla Integer
ów:
import com.google.common.base.Predicate;
public class IsEvenPredicate implements Predicate<Integer> {
@Override public boolean apply(Integer input) {
return input % 2 == 0;
}
}
W tym momencie wywołanie następującego kodu:
List<Integer> data = new LinkedList<>(Arrays.asList(3, 1, 6, 2, 9, 0, 5, 4, 8, 7));
System.out.println(Iterables.filter(data, new IsEvenPredicate()));
wypisze nam na ekran spodziewany wynik, [6, 2, 0, 4, 8]
.
Lepsze rozwiązanie, dostępne już w Javie 7
To rozwiązanie działa, ale możemy je polepszyć - i to w sposób niewymagający jeszcze podbicia wersji Javy. Po pierwsze, wymaga to stworzenia nowej klasy Javy, czyli nowego pliku w projekcie, a tych każdy projekt javowy i tak ma już wystarczająco dużo. Możemy rozwiązać ten problem, deklarując IsEvenPredicate
jako klasę wewnętrzną.
Skorzystajmy ze starego triku (z Javy 1.1!), jakim jest stworzenie klasy anonimowej. Składniowo wygląda to następująco:
new Predicate<Integer>() {
public boolean apply(Integer integer) {
return (integer % 2) == 0;
}
};
„Anonimowa" w tym kontekście oznacza, że ta klasa nie będzie nazwana. Tworzymy klasę posiadającą tylko jedną instancję. Zamiast nazywania tej klasy i korzystania potem z jej konstruktora, tworzymy instancję interfejsu (sic! Czytając składnię wprost, wywołujemy konstruktor new Predicate<Integer>()
, gdzie Predicate
jest interfejsem!) i w tym samym miejscu implementujemy wszystkie metody tego interfejsu (w przypadku Predicate
, mamy tylko jedną metodę).
Po przypisaniu tego anonimowego predykatu do zmiennej poprzez Predicate<Integer> isEven = ...
, wywołanie System.out.println(Iterables.filter(data, isEven));
da nam dokładnie ten sam efekt, co wcześniej.
Jeśli przypiszemy nasz predykat do zmiennej wewnątrz metody, to będzie on w tym momencie tworzony na nowo przy każdym jej wywołaniu, co niepotrzebnie obciąża procesor i pamięć. Możemy temu zapobiec, przenosząc jego deklarację poza metodę i dodatkowo deklarując go jako static, przez co zostanie on stworzony tylko raz, przy ładowaniu klasy do pamięci.
Przejście do funkcji anonimowej i Javy 8
Wewnątrz IntelliJa możemy zauważyć, że po lewej stronie stworzonego przez nas anonimowego Comparatora pojawiła się opcja zwinięcia tego fragmentu kodu.
Po skorzystaniu z niej otrzymujemy następującą postać kodu:
IntelliJ sugeruje nam składnię (o1, o2) -> { o1.compareTo(o2) }
- jest to dokładnie składnia funkcji anonimowej w Javie, a przejście ze składni rozwiniętej do zwiniętej pokazuje nam dokładnie przejście z klasy anonimowej do funkcji anonimowej, czyli wyrażenia lambda. Te dwa koncepty w Javie są równoważne: funkcja to po prostu obiekt z dokładnie jedną metodą publiczną (nie licząc metod odziedziczonych oraz domyślnych) - tak, jak interfejs funkcyjny to interfejs z dokładnie jedną abstrakcyjną metodą publiczną.
Składnia lambdy w Javie składa się z dwóch części oddzielonych strzałką - czyli symbolem ->
. Po lewej stronie mamy nazwy argumentów tej funkcji, zaś po prawej - ciało funkcji. Funkcję (x,y) → x² + y²
zapisaną w notacji matematycznej możemy przepisać na Javę jako (x, y) -> x * x + y * y
. Jeśli mamy tylko jeden argument, to nawiasy dookoła niego możemy pominąć: x -> x * x
. Jeśli w ciele funkcji chcemy wykonać więcej operacji, to możemy skorzystać z klamr: (x, y) -> { System.out.println("Squaring " + x + " and " + y); return x * x + y * y; }
.
Zaskakujące może być to, że wyżej wskazana notacja nie zawiera typów. Wynika to z tego, że wyrażenie lambda może być stosowane tylko w miejscach, w których oczekiwana jest implementacja interfejsu funkcyjnego. Java wykorzystuje ten fakt do wnioskowania typów przyjmowanych przez ten interfejs argumentów oraz zwracanej przez niego wartości. Przykładowo, jeśli nasz interfejs deklaruje metodę Float frobnicate(Integer x, String y, File z)
, to aplikowane do niego wyrażenie lambda musi przyjmować trzy argumenty o typach Integer
, String
i File
, oraz zwracać obiekt typu Float
.
Żeby skorzystać ze składni funkcji anonimowej w IntelliJu, otwórzmy okno File
> Project Structure
. Tam, w zakładkach Project
oraz Modules
przełączamy Language Level
z Javy 7 na Javę 8.
Od tej pory możemy korzystać w projekcie ze wszystkich możliwości, które daje nam Java 8 - i nie musimy już korzystać z Guavy. Interfejs Predicate
został częścią standardu Javy 8 i zamiast importować go z Guavy, możemy wziąć go z pakietu java.util.function
- co IntelliJ sam nam zaproponuje.
Krótsza składnia i wykorzystanie pełni możliwości
W momencie podmiany Predicate
z guavowego na javowy przestanie działać metoda Iterables.filter
, która oczekuje interfejsu z Guavy.
Zauważmy, że zarówno guavowy Predicate<Interface
>, jak i javowy Predicate<Interface>
deklarują metodę o podobnej sygnaturze - o jednym argumencie typu Integer
oraz zwracanej wartości boolean
. Oznacza to, że, chociaż te interfejsy funkcyjne różnią się między sobą, to ich pojedyncza metoda jest taka sama. Możemy w związku z tym stworzyć funkcję anonimową implementującą guavowy Predicate
, w postaci (integer) -> isEven.test(integer)
- gdzie metoda test
pochodzi z javowego Predicate
.
Pisanie kodu w ten sposób tworzy niepotrzebną składnię. Aby poprawić ten kod, możemy skorzystać z jeszcze jednej nowej funkcjonalności w Javie 8 - referencji do metod. IntelliJ powinien sam nam to zasugerować.
Ta technika wykorzystuje opisany powyżej fakt, że obie metody mają tę samą sygnaturę, mimo tego, że są umieszczone w różnych interfejsach. Funkcja anonimowa (integer) -> isEven.test(integer)
jest równoważna składni isEven::test
, gdzie isEven
jest nazwą zmiennej, do której przypisana jest implementacja naszego interfejsu, a test jest metodą, którą chcemy wywołać.
Oznacza to, że nasz kod przyjmuje postać:
List<Integer> data = new LinkedList<>(Arrays.asList(3, 1, 6, 2, 9, 0, 5, 4, 8, 7));
Predicate<Integer> isEven = (integer) -> integer % 2 == 0;
System.out.println(Iterables.filter(data, isEven::test));
W końcu, aby nasza funkcja stała się w pełni anonimowa, zrezygnujmy z tego jedynego miejsca, w którym jeszcze ją nazywamy - mianowicie, z przypisania jej do zmiennej isEven
.
List<Integer> data = new LinkedList<>(Arrays.asList(3, 1, 6, 2, 9, 0, 5, 4, 8, 7));
System.out.println(Iterables.filter(data, integer -> integer % 2 == 0));
Skorzystaliśmy z funkcji anonimowej i skróciliśmy nasz kod z siedmiu linijek do dwóch.
Wykorzystanie strumieni
Pomimo tego wszystkiego, IntelliJ jeszcze nie jest z nas w pełni zadowolony.
Mamy do dyspozycji również konstrukt zwany strumieniem, który pozwala na operowanie ciągami danych. Wskutek tego, IntelliJ mówi nam teraz, że wskazane użycie Iterables można osiągnąć za pomocą strumienia z Javy 8 - co niniejszym możemy zrobić.
Każda kolekcja z Javy 8 posiada metodę .stream()
, która zwraca strumień bazujący na tej kolekcji. Strumienie posiadają metody pozwalające na, między innymi: sortowanie, filtrowanie, mapowanie oraz leniwe przetwarzanie danych znajdujących się w strumieniu. W tym miejscu skorzystamy z dwóch z tych metod.
Najpierw potrzebujemy nasz strumień przefiltrować - z pomocą przyjdzie nam tu metoda .filter(Predicate)
, natomiast później pozostałe wartości będziemy chcieli odebrać ze strumienia w postaci listy - przyda się nam tu .collect(Collectors.toList())
. Zauważmy, że .collect(Collector)
przyjmuje implementację interfejsu Collector
- oznacza to, że jeśli będziemy chcieli odebrać elementy ze strumienia w bardziej egzotycznej postaci, to możemy zaimplementować naszego własnego Collectora
i przekazać go do strumienia w tym miejscu.
Składniowo nasze ostateczne rozwiązanie wygląda następująco:
List<Integer> data = new LinkedList<>(Arrays.asList(3, 1, 6, 2, 9, 0, 5, 4, 8, 7));
System.out.println(data.stream()
.filter(integer -> integer % 2 == 0)
.collect(Collectors.toList()));
A jeśli komuś się nie podoba, że właśnie z dwóch linijek kodu przeszliśmy do czterech:
List<Integer> data = new LinkedList<>(Arrays.asList(3, 1, 6, 2, 9, 0, 5, 4, 8, 7));
System.out.println(data.stream().filter(integer -> integer % 2 == 0).collect(Collectors.toList()));
Ładniejsze, niż rozwiązanie z Iterables? Polemizowałbym :) Natomiast pewne jest, że w tym momencie korzystamy tylko z narzędzi ze standardu Javy 8, nie posiłkując się żadnymi bibliotekami zewnętrznymi. W przypadku bardziej skomplikowanych projektów oznacza to, że nie musimy mediować między kawałkami kodu używającymi bibliotek Apache Commons, tymi używających Guavy i tymi, które korzystają z jeszcze bardziej niszowych rozwiązań. Mamy jedną standardową implementację operacji na kolekcjach, będącą dostępną wszędzie tam, gdzie korzystamy z Javy.
Po co nam interfejsy funkcyjne?
Na koniec zostawiłem sobie pytanie - po co w ogóle interfejsy funkcyjne?
Popatrzmy choćby na nieśmiertelną i (nad)używaną w bardzo wielu podręcznikach do Javy klasę Employee
:
public class Employee {
String name;
Gender gender;
int salary;
}
Posiadając interfejsy funkcyjne specjalizowane na klasie Employee
oraz strumień obiektów tej klasy, możemy wykonywać stosunkowo wiele operacji niewielkim nakładem kodu. Potrzebujemy posortować pracowników tak, by kobiety były przed mężczyznami? Tworzymy anonimowy Comparator
, (e1, e2) -> e1.gender == FEMALE ? e2.gender == FEMALE ? 0 : -1 : 1
. Potrzebujemy odsiać wszystkich, którzy zarabiają mniej, niż cztery tysiące monet? Anonimowy Predicate
, e -> e.salary <= 4000
. Potrzebujemy otrzymać listę wszystkich imion? Korzystamy ze strumieniowego .map(Function)
z anonimową funkcją, e -> e.name
. Przykłady możemy mnożyć, szczególnie, jeśli wejdziemy głębiej w nasze projekty i w klasy używane wewnątrz nich.
Korzystanie z możliwości języka zamiast zewnętrznych bibliotek = profit
Dodatkową korzyścią z zastosowania takiego podejścia jest zastąpienie zewnętrznych zależności metodami języka. Jest to szczególnie ważne w dużych i ogromnych projektach, których nie brakuje w Ericssonie; mnogość zależności powoduje w nich problemy. Jednym z nich jest konieczność pilnowania wersji bibliotek - jeśli ten sam moduł załączamy w wielu miejscach w skompilowanym kodzie, to niezgodność wersji między tymi punktami skutkuje w najlepszym razie zwiększeniem rozmiaru skompilowanego kodu, a w najgorszym ciężkimi do zdiagnozowania błędami. Dochodzą do tego problemy prawne, gdyż nawet poszczególne wersje tej samej biblioteki potrafią różnić się pod względem licencji, problemy ze znalezieniem ludzi potrafiących obsługiwać bardziej skomplikowane czy też egzotyczne frameworki, oraz w końcu trudności w modyfikacji i odpluskwianiu kodu, który nie pochodzi od nas i którego nie możemy w łatwy sposób zmienić pod nasze potrzeby.
Oczywiście, nie każdy problem jesteśmy w stanie rozwiązać bez bibliotek zewnętrznych - ale tam, gdzie to możliwe, warto jest używać standardów.