Java HR: interfejsy funkcyjne
Interfejsy funkcyjne, czyli fundament wyrażeń lambda
Czemu interfejsy funkcyjne są na tyle istotnym zagadnieniem, że często pada o nie pytanie na rozmowie rekrutacyjnej?
Otóż interfejsy funkcyjne są fundamentem wyrażeń lambda, które pojawiły się wraz z Javą 8. O samych lambdach i ich użyteczności jeszcze napiszę w osobnym artykule, natomiast teraz nieco przybliżę ich realizację techniczną.
Java jako język zorientowany obiektowo (OOP) opiera się na koncepcji obiektów, czyli klas i ich instancji. Nie inaczej jest w przypadku wyrażeń lambda – nie jest to żaden nowy twór żyjący poza obowiązującymi do tej pory mechanizmami.
f(x) -> y
jednak pod spodem zapis ten tłumaczony jest na typowo obiektowe konstrukcje.
Funkcje bez lambd
Żeby wyobrazić sobie co tam się właściwie dzieje, zastanówmy się – jak by to wyglądało bez użycia lambd?
1 2 3 4 5 |
class PowerCalculator { public int square(int n) { return n*n; } } |
1 2 |
PowerCalculator powerCalculator = new PowerCalculator(); powerCalculator.square(2); |
Za każdym razem trzeba pisać nową metodę, czasami w nowej klasie. Na dłuższą metę będzie to uciążliwe, zwłaszcza jeśli potrzebujemy wiele takich pojedynczych funkcji, które będą użyte tylko raz, w jednym miejscu.
A co jeśli chcielibyśmy taką funkcję przekazać jako argument gdzieś dalej, do innej metody? Jest to powszechna praktyka gdy korzysta się chociażby ze wzorca strategii.
Załóżmy, że chcielibyśmy przekazać dalej dowolną funkcję, która przyjmuje jedną liczbę i zwraca inną. Przykładem takiej funkcji była właśnie nasza metoda PowerCalculator.square.
1 2 3 |
interface SingleIntegerFunction { int calculate(int n); } |
1 2 3 4 5 6 |
class PowerCalculator implements SingleIntegerFunction { @Override public int calculate(int n) { return n*n; } } |
1 2 3 4 5 |
class SomeImportantClass { public int runSomeCalculation(SingleIntegerFunction function) { return function.calculate(); } } |
Dzięki temu do runSomeCalculation możemy przekazać dowolną funkcję, która przyjmuje i zwraca wartość typu int. Łatwo sobie wyobrazić użyteczność takiego mechanizmu. Niestety, za każdym razem trzeba napisać nową klasę i zaimplementować metodę calculate. Na wszystkie wymienione problemy lekarstwem są wyrażenia lambda, które działają analogicznie do powyższych przykładów, ale znacznie skracają zapis.
Można krócej
(x) -> {foo(); return y}
(x) -> y
Żeby taka metoda miała sens, potrzebne jest mimo wszystko zdefiniowanie gdzieś jej sygnatury – liczby argumentów i ich typu, typu zwracanej wartości oraz nazwy metody – tak, żeby można było się dalej do niej odnieść, w tym również wywołać. Dlatego wymagany jest interfejs, który taką sygnaturę posiada.
1 |
SingleIntegerFunction function = (n) -> n*n; |
Teraz zamiast pisania kilku osobnych klas, możemy od razu definiować wiele różnych implementacji tej metody, co jest bardzo wygodne:
1 2 3 |
SingleIntegerFunction square = (n) -> n*n; SingleIntegerFunction double = (n) -> n*2; SingleIntegerFunction increment = (n) -> n+1; |
Tylko skąd kompilator wie, że to co napisaliśmy to definicja akurat metody calculate? Stąd, że interfejs SingleIntegerFunction posiada tylko tę jedną metodę.
I to jest kluczowe – żeby opisać wyrażenie lambda, interfejs może posiadać tylko jedną abstrakcyjną, czyli niezaimplementowaną metodę. Może natomiast posiadać dodatkowe metody, które posiadają swoją implementację. Jest to możliwe w intefrejsach od Javy 8, dzięki słowom metodom typu static i default.
Metody default w interfejsach powstały głównie w celu zachowania kompatybilności wstecznej – twórcy chcieli dodać nowe metody do interfejsów z biblioteki standardowej i żeby nie wymuszać zmiany kodu we wszystkich aplikacjach, gdzie te interfejsy były wykorzystane, postanowili nowe metody już domyślnie zaimplementować. Taką metodę można wciąż nadpisać w swojej klasie implementującej, ale nie jest to wymagane.
Możemy go dodatkowo oznaczyć adnotacją @FunctionalInterface:
1 2 3 4 |
@FunctionalInterface interface SingleIntegerFunction { int calculate(int n); } |
Co to daje? Tylko tyle, że kompilator zawczasu zwróci nam uwagę, jeśli złamiemy ten warunek i niechcący dopiszemy kolejną abstrakcyjną metodę. Rzuci nam wtedy wiadomość „Unexpected @FunctionalInterface annotation”. Jest to jednak opcjonalna adnotacja i nie jest konieczna do napisania interfejsu funkcyjnego.
1 2 |
SingleIntegerFunction power = (n) -> n*n; SomeImportantClass.runSomeCalculation(power); |
1 |
SomeImportantClass.runSomeCalculation((n) -> n*n); |
I właśnie w takim zapisie zwykle widuje się lambdy, np. wewnątrz metod map() czy filter() z obiektu Stream.
Standardowe interfejsy funkcyjne
Wyżej definiowaliśmy własny interfejs funkcyjny, jednak większość przypadków pokrywają istniejące już w bibliotece standardowej interfejsy, takie jak:
- Function
– przyjmuje jedną wartość typu T, zwraca wartość typu R; - Predicate
– przyjmuje jedną wartość i zwraca boolean true/false. - Supplier
– nie przyjmuje żadnej wartości, ale zwraca wartość typu T. - Consumer
– przyjmuje jedną wartość typu T, nie zwraca nic (wykonuje tylko efekty uboczne, np. zapis do pliku). - UnaryOperator
– analogiczny do Function, ale zapewnia że typ przyjmowanej i zwracanej wartości jest taki sam. - Comparable
– przyjmuje dwa argumenty tego samego typu i zwraca boolean.
Oraz ich dwuargumentowe odpowiedniki:
BiFunction, BiPredicate, BiConsumer, BinaryOperator itp.
A także wiele istniejących już wcześniej interfejsów zaczęło wpisywać się w tę definicję, np. Runnable, Callable (wykorzystywane przy współbieżności).
Przepiszę wcześniejszy przykład, pozbywając się własnego SingleIntegerFunction:
1 |
Function<Integer, Integer> power = (n) -> n*n; |
1 |
UnaryOperator<Integer> power = (n) -> n*n; |
Nie można użyć typów prostych w miejscach gdzie używane są typy generyczne, nie można więc użyć przykładowo Function. Powstały do tego celu osobne interfejsy, takie jak IntFunction, ToIntFunction itd.
Kompozycja funkcji
Tak jak już wspomniałem, interfejs funkcyjny może posiadać dodatkowe, zaimplementowane już metody (static lub default). To oznacza, że można zdefiniować w nich różne przydatne funkcjonalności. Intefejsy z biblioteki standardowej wykorzystują tę możliwość i udostępniają metody, które pozwalają funkcje komponować, czyli składać je ze sobą, tworząc nowe funkcje.
Konkretne przykłady
- compose() – f1.compose(f2) oznacza, że najpierw wykona się f2, a następnie f1
- andThen() – odwrotnie – f1.andThen(f2) oznacza, że najpierw wykona się f1, a następnie f2
- and() – p1.and(p2) oznacza logiczną koniunkcję obu predykatów, zwraca nowy Predicate który zwróci p1.test() && p2.test()
- or() – analogicznie, alternatywa
- negate() – analogicznie, negacja
1 2 3 |
UnaryOperator<Integer> power = (n) -> n*n; Function<Integer, Float> half = (n) -> n/2f; Float result = power.andThen(half).apply(8); |
Jak widać, interfejsy funkcyjne są nieocenioną pomocą w pisaniu zwięzłego kodu.
Informatyk, programista. Obecnie Java Developer (Web Fullstack), właściciel studia Berrygames oraz prezes koła TK Games na Politechnice Wrocławskiej.