Java HR: interfejsy funkcyjne

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. Samą semantyką (i sposobem użycia) co prawda przypomina standardową funkcję, znaną z paradygmatu programowania funkcyjnego:

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?

Chcąc użyć jakiejś pojedynczej funkcji, musielibyśmy zdefiniować nową klasę i metodę wewnątrz niej. Napiszmy naprawdę prostą funkcję do podnoszenia liczby do drugiej potęgi:

I dopiero używamy jej w kodzie:

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.

Trzeba w takim razie opisać tego typu funkcję bardziej ogólnie, określić nadtyp przekazywanej funkcji. Napiszmy więc interfejs, który będą mogły implementować poszczególne konkretne metody:

Jak widać, nasza funkcja square wpisuje się w ten interfejs, teraz należy ją odpowiednio zmodyfikować żeby faktycznie implementowała interfejs SingleIntegerFunction:

Teraz możemy z powodzeniem przekazać naszą funkcję do innej metody, która sama wywoła już 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

Można o lambdzie pomyśleć jako o implementacji metody w anonimowej klasie. Zapisujemy po prostu od razu jej kod – argumenty i ciało metody:

(x) -> {foo(); return y}

Albo nawet jeszcze krócej, jeśli od razu możemy zwrócić jakąś wartość:

(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. Wykorzystując poprzedni przykład, napiszę dokłądnie tę samą funkcję, ale już w nowym zapisie:

Pozbyłem się tym samym zupełnie klasy PowerCalculator, od razu w miejscu definiuję metodę calculate.
Teraz zamiast pisania kilku osobnych klas, możemy od razu definiować wiele różnych implementacji tej metody, co jest bardzo wygodne:

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.

Tym samym zdefiniowaliśmy interfejs funkcyjny. Nie różni się niczym od innych interfejsów poza faktem, że musi posiadać tylko jedną abstrakcyjna metodę.
Możemy go dodatkowo oznaczyć adnotacją @FunctionalInterface:

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.

Teraz funkcje możemy jeszcze łatwiej przekazywać jako argument innej metody. Na poprzednim przykładzie:

A nawet jeszcze zwięźlej:

I właśnie w takim zapisie zwykle widuje się lambdy, np. wewnątrz metod map() czy filter() z obiektu Stream.

Standardowe interfejsy funkcyjne

Czy może być jeszcze prościej? Okazuje się, że tak – dostajemy też paczkę gotowych interfejsów funkcyjnych z pakietu java.util.function.
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).

Dzięki wykorzystaniu w nich typów generycznych, interfejsy te są bardzo uniwersalne i może się okazać, że naprawdę rzadko kiedy będzie potrzeba pisania własnych interfejsów funkcyjnych.
Przepiszę wcześniejszy przykład, pozbywając się własnego SingleIntegerFunction:

A właściwie nawet:


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

Interfejs Function:
  • 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
Interfejs Predicate:
  • 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
Jest to bardzo wygodne, gdy chcemy ponownie wykorzystać zdefiniowane już funkcje, bez potrzeby definiowania całości na nowo, tworząc z nich łańcuchy operacji:

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.

Wszystkie artykuły autora>>

Dodaj komentarz