1. Wchodzimy głębiej w interfejs funkcyjny
Interfejs funkcyjny to interfejs, w którym jest DOKŁADNIE jedna metoda abstrakcyjna (czyli niezaimplementowana). Dzięki temu Java rozumie: „aha, tutaj można wstawić lambdę!” albo odwołanie do metody.
Aby nikt się nie pomylił, w takich interfejsach zwykle umieszcza się adnotację @FunctionalInterface. Nie jest obowiązkowa, ale jeśli ją dodasz i przypadkowo napiszesz drugą metodę abstrakcyjną, kompilator od razu zaprotestuje.
Przykład:
@FunctionalInterface
interface MyAction {
void run();
}
MyAction action = () -> System.out.println("Pozdrowienia z lambdy!");
action.run(); // Wypisze: Pozdrowienia z lambdy!
Po co to jest potrzebne?
- Pozwala używać wyrażeń lambda i odwołań do metod zamiast tworzenia klas anonimowych (mniej boilerplate’u!).
- Daje kompilatorowi do zrozumienia, że interfejs jest przeznaczony do programowania funkcyjnego.
Ciekawostka: W standardowej bibliotece Javy jest już kilkadziesiąt takich interfejsów – nie trzeba na nowo wymyślać koła!
2. Przegląd standardowych interfejsów funkcyjnych
W pakiecie java.util.function znajdziesz dziesiątki interfejsów funkcyjnych. Omówimy cztery najpopularniejsze (mają najwyższą „frekwencję” wśród interfejsów Javy).
| Interfejs | Co przyjmuje | Co zwraca | Do czego zwykle służy |
|---|---|---|---|
|
|
|
Sprawdzanie warunku (filtrowanie) |
|
|
|
Wykonanie działania na obiekcie |
|
nic | |
Pobranie/wygenerowanie obiektu |
|
|
|
Przekształcenie T w R |
Predicate<T>
Opis: Funkcja, która przyjmuje obiekt typu T i zwraca true lub false. Typowy przykład: filtrowanie listy. Kluczowa metoda — test.
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("Java")); // false
System.out.println(isLong.test("Functional")); // true
Consumer<T>
Opis: Przyjmuje obiekt typu T i wykonuje na nim działanie, nic nie zwraca. Kluczowa metoda — accept.
Consumer<String> printer = s -> System.out.println("Drukuję: " + s);
printer.accept("Hello, world!"); // Drukuję: Hello, world!
Supplier<T>
Opis: Nic nie przyjmuje, zwraca obiekt typu T. Można go traktować jako „generator” wartości. Kluczowa metoda — get.
Supplier<Double> randomSupplier = () -> Math.random();
System.out.println(randomSupplier.get()); // Na przykład: 0.1234567
Function<T, R>
Opis: Przyjmuje obiekt typu T i zwraca obiekt typu R. Typowy przykład: przekształcanie danych. Kluczowa metoda — apply.
Function<String, Integer> stringToLength = s -> s.length();
System.out.println(stringToLength.apply("Java")); // 4
W skrócie: UnaryOperator, BinaryOperator, BiFunction
- UnaryOperator<T> – to to samo, co Function<T, T>: przyjmuje i zwraca ten sam typ.
- BinaryOperator<T> – to to samo, co BiFunction<T, T, T>: przyjmuje dwa T, zwraca jedno T.
- BiFunction<T, U, R> – przyjmuje dwa różne typy, zwraca trzeci.
UnaryOperator<Integer> square = x -> x * x;
BinaryOperator<Integer> sum = (a, b) -> a + b;
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
3. Przykłady użycia
Zobaczmy, jak te interfejsy pojawiają się w prawdziwych zadaniach, a szczególnie – w kolekcjach i Stream API.
Przekazywanie do metod kolekcji i Stream API
Przykład 1: Predicate i filtrowanie
List<String> words = List.of("java", "stream", "lambda", "code");
List<String> longWords = words.stream()
.filter(word -> word.length() > 4) // Predicate<String>
.toList();
System.out.println(longWords); // [stream, lambda]
Przykład 2: Consumer i forEach
words.forEach(word -> System.out.println("Słowo: " + word)); // Consumer<String>
Przykład 3: Function i map
List<Integer> lengths = words.stream()
.map(word -> word.length()) // Function<String, Integer>
.toList();
System.out.println(lengths); // [4, 6, 6, 4]
Przykład 4: Supplier i generowanie wartości
Supplier<String> greetingSupplier = () -> "Cześć, Java!";
System.out.println(greetingSupplier.get()); // Cześć, Java!
Porównanie z klasami anonimowymi
Kiedyś trzeba było pisać tak:
Predicate<String> isShort = new Predicate<String>() {
@Override
public boolean test(String s) {
return s.length() < 5;
}
};
Z lambdami jest o wiele przyjemniej:
Predicate<String> isShort = s -> s.length() < 5;
4. Praktyka: piszemy wyrażenia lambda dla każdego interfejsu
Zaimplementujemy małą aplikację – listę użytkowników. Każdy użytkownik będzie reprezentowany przez klasę User:
public class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public String toString() {
return name + " (" + age + ")";
}
}
Utwórzmy listę użytkowników:
List<User> users = List.of(
new User("Anna", 23),
new User("Boris", 17),
new User("Vika", 31),
new User("Gosha", 15)
);
Predicate: filtrowanie dorosłych
Predicate<User> isAdult = user -> user.getAge() >= 18;
List<User> adults = users.stream()
.filter(isAdult)
.toList();
System.out.println("Dorośli: " + adults); // Dorośli: [Anna (23), Vika (31)]
Consumer: drukowanie użytkowników
Consumer<User> printUser = user -> System.out.println("Użytkownik: " + user);
adults.forEach(printUser);
Supplier: generowanie użytkowników
Supplier<User> randomUserSupplier = () -> {
String[] names = {"Dima", "Katya", "Lyosha"};
int randomAge = 10 + (int)(Math.random() * 30);
String randomName = names[(int)(Math.random() * names.length)];
return new User(randomName, randomAge);
};
User randomUser = randomUserSupplier.get();
System.out.println("Losowy użytkownik: " + randomUser);
Function: pobieranie imienia użytkownika
Function<User, String> getName = user -> user.getName();
List<String> names = users.stream()
.map(getName)
.toList();
System.out.println("Imiona: " + names); // Imiona: [Anna, Boris, Vika, Gosha]
5. Przydatne niuanse
Użycie w Stream API: filter, map, forEach i inne
Złóżmy wszystko w całość i napiszmy łańcuch przekształceń:
users.stream()
.filter(user -> user.getAge() >= 18) // Predicate<User>
.map(user -> user.getName().toUpperCase()) // Function<User, String>
.forEach(name -> System.out.println("Dorosły: " + name)); // Consumer<String>
Wynik:
Dorosły: ANNA
Dorosły: VIKA
Tabela-ściągawka: co gdzie przekazać
| Gdzie używane | Jaki interfejs jest potrzebny | Przykład użycia |
|---|---|---|
| filter (Stream) | |
|
| map (Stream) | |
|
| forEach (Stream, List) | |
|
| generate (Stream) | |
|
Dlaczego warto znać interfejsy funkcyjne?
- Stanowią podstawę wszystkich wyrażeń lambda w Javie.
- Pozwalają pisać uniwersalny, wielokrotnego użytku i zwięzły kod.
- Ułatwiają pracę z kolekcjami, strumieniami i zadaniami asynchronicznymi.
Kiedy używać którego interfejsu?
- Predicate – gdy trzeba sprawdzić warunek (filtrować, wyszukiwać).
- Consumer – gdy trzeba coś zrobić z obiektem (wypisać, zapisać, wysłać).
- Supplier – gdy trzeba otrzymać lub wygenerować obiekt (fabryki, generatory).
- Function – gdy trzeba przekształcić obiekt z jednego typu na inny.
6. Typowe błędy przy pracy z interfejsami funkcyjnymi
Błąd nr 1: Niewłaściwy wybór interfejsu. Czasem początkujący mylą Predicate i Function – na przykład próbują zwracać boolean z Function, a nie z Predicate. Zapamiętaj: Predicate zawsze zwraca boolean, Function – dowolny inny typ.
Błąd nr 2: Nieużywanie standardowych interfejsów. Często tworzy się własne interfejsy w rodzaju „Checker” z metodą boolean check(T t) zamiast używać Predicate. Lepiej korzystać ze standardowych – są powszechnie wspierane i czynią kod bardziej zrozumiałym dla innych programistów.
Błąd nr 3: Zbyt skomplikowana lambda. Jeśli lambda zamienia się w miniopowiadanie na 10 linii, warto wynieść ją do osobnej metody lub klasy. Lambda to zwięzłość i czytelność.
Błąd nr 4: Zapomniana adnotacja @FunctionalInterface. Jeśli piszesz własny interfejs funkcyjny – nie zapomnij o adnotacji. Zabezpieczy przed przypadkowymi błędami (np. dodaniem drugiej metody abstrakcyjnej).
Błąd nr 5: Używanie zmiennego stanu wewnątrz lambdy. Jeśli lambda zmienia zewnętrzne zmienne lub kolekcje, może to prowadzić do nieoczekiwanych błędów, zwłaszcza podczas pracy z wątkami. Lepiej unikać efektów ubocznych.
GO TO FULL VERSION