1. Заглиблюємося у функціональний інтерфейс
Функціональний інтерфейс — це такий інтерфейс, у якому є РІВНО один абстрактний (тобто нереалізований) метод. Саме завдяки цьому Java розуміє: «отже, сюди можна підставити лямбду!» або посилання на метод.
Щоб ніхто не помилився, у таких інтерфейсах зазвичай стоїть анотація @FunctionalInterface. Вона необов’язкова, але якщо ви її додали та випадково написали другий абстрактний метод, компілятор відразу повідомить про помилку.
Приклад:
@FunctionalInterface
interface MyAction {
void run();
}
MyAction action = () -> System.out.println("Привіт із лямбди!");
action.run(); // Виведе: Привіт із лямбди!
Навіщо це потрібно?
- Дозволяє використовувати лямбда-вирази та посилання на методи замість створення анонімних класів (менше бойлерплейту).
- Дає компілятору зрозуміти, що інтерфейс призначений для функціонального програмування.
Цікавий факт: У стандартній бібліотеці Java вже є десятки таких інтерфейсів — не потрібно винаходити велосипед!
2. Огляд стандартних функціональних інтерфейсів
У пакеті java.util.function містяться десятки функціональних інтерфейсів. Розглянемо чотири найпопулярніші (у них найвища «відвідуваність» серед усіх Java-інтерфейсів).
| Інтерфейс | Що приймає | Що повертає | Для чого зазвичай використовується |
|---|---|---|---|
|
|
|
Перевірка умови (фільтрація) |
|
|
|
Виконати дію над об’єктом |
|
нічого | |
Отримати/згенерувати об’єкт |
|
|
|
Перетворити T на R |
Predicate<T>
Опис: Функція, яка приймає об’єкт типу T і повертає true або false. Типовий приклад: фільтрація списку. Ключовий метод — test.
Predicate<String> isLong = s -> s.length() > 5;
System.out.println(isLong.test("Java")); // false
System.out.println(isLong.test("Functional")); // true
Consumer<T>
Опис: Приймає об’єкт типу T і виконує з ним дію, нічого не повертає. Ключовий метод — accept.
Consumer<String> printer = s -> System.out.println("Друкую: " + s);
printer.accept("Hello, world!"); // Друкую: Hello, world!
Supplier<T>
Опис: Нічого не приймає, повертає об’єкт типу T. Його можна уявити як «генератор» значень. Ключовий метод — get.
Supplier<Double> randomSupplier = () -> Math.random();
System.out.println(randomSupplier.get()); // Наприклад, 0.1234567
Function<T, R>
Опис: Приймає об’єкт типу T і повертає об’єкт типу R. Типовий приклад: перетворення даних. Ключовий метод — apply.
Function<String, Integer> stringToLength = s -> s.length();
System.out.println(stringToLength.apply("Java")); // 4
Коротко: UnaryOperator, BinaryOperator, BiFunction
- UnaryOperator<T> — те саме, що Function<T, T>: приймає і повертає один і той самий тип.
- BinaryOperator<T> — те саме, що BiFunction<T, T, T>: приймає два T, повертає один T.
- BiFunction<T, U, R> — приймає два різні типи, повертає третій.
UnaryOperator<Integer> square = x -> x * x;
BinaryOperator<Integer> sum = (a, b) -> a + b;
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
3. Приклади використання
Подивімося, як ці інтерфейси зустрічаються в реальних задачах і особливо — у колекціях і Stream API.
Передавання в методи колекцій і Stream API
Приклад 1: Predicate і фільтрація
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]
Приклад 2: Consumer і forEach
words.forEach(word -> System.out.println("Слово: " + word)); // Consumer<String>
Приклад 3: Function і map
List<Integer> lengths = words.stream()
.map(word -> word.length()) // Function<String, Integer>
.toList();
System.out.println(lengths); // [4, 6, 6, 4]
Приклад 4: Supplier і генерація значень
Supplier<String> greetingSupplier = () -> "Привіт, Java!";
System.out.println(greetingSupplier.get()); // Привіт, Java!
Порівняння з анонімними класами
Раніше доводилося писати так:
Predicate<String> isShort = new Predicate<String>() {
@Override
public boolean test(String s) {
return s.length() < 5;
}
};
З лямбдами стало набагато приємніше:
Predicate<String> isShort = s -> s.length() < 5;
4. Практика: пишемо лямбда-вирази для кожного інтерфейсу
Реалізуємо невеликий застосунок — список користувачів. Кожного користувача буде представлено класом 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 + ")";
}
}
Створимо список користувачів:
List<User> users = List.of(
new User("Анна", 23),
new User("Борис", 17),
new User("Віка", 31),
new User("Гоша", 15)
);
Predicate: фільтрація дорослих
Predicate<User> isAdult = user -> user.getAge() >= 18;
List<User> adults = users.stream()
.filter(isAdult)
.toList();
System.out.println("Дорослі: " + adults); // Дорослі: [Анна (23), Віка (31)]
Consumer: друк користувачів
Consumer<User> printUser = user -> System.out.println("Користувач: " + user);
adults.forEach(printUser);
Supplier: генерація користувачів
Supplier<User> randomUserSupplier = () -> {
String[] names = {"Діма", "Катя", "Льоша"};
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("Випадковий користувач: " + randomUser);
Function: отримання імені користувача
Function<User, String> getName = user -> user.getName();
List<String> names = users.stream()
.map(getName)
.toList();
System.out.println("Імена: " + names); // Імена: [Анна, Борис, Віка, Гоша]
5. Корисні нюанси
Використання у Stream API: filter, map, forEach тощо
Зберемо все докупи й напишемо ланцюжок перетворень:
users.stream()
.filter(user -> user.getAge() >= 18) // Predicate<User>
.map(user -> user.getName().toUpperCase()) // Function<User, String>
.forEach(name -> System.out.println("Дорослий: " + name)); // Consumer<String>
Результат:
Дорослий: АННА
Дорослий: ВІКА
Таблиця-шпаргалка: що куди підставляти
| Де використовується | Який інтерфейс потрібен | Приклад використання |
|---|---|---|
| filter (Stream) | |
|
| map (Stream) | |
|
| forEach (Stream, List) | |
|
| generate (Stream) | |
|
Чому важливо знати про функціональні інтерфейси?
- Вони лежать в основі всіх лямбда-виразів у Java.
- Дозволяють писати універсальний, повторно використовуваний і лаконічний код.
- Спрощують роботу з колекціями, потоками, асинхронними завданнями.
Коли який інтерфейс використовувати?
- Predicate — коли потрібно перевірити умову (фільтрувати, шукати).
- Consumer — коли потрібно щось зробити з об’єктом (вивести, записати, надіслати).
- Supplier — коли потрібно отримати або згенерувати об’єкт (фабрики, генератори).
- Function — коли потрібно перетворити об’єкт з одного типу в інший.
6. Типові помилки при роботі з функціональними інтерфейсами
Помилка № 1: Неправильний вибір інтерфейсу. Іноді новачки плутають Predicate і Function — наприклад, намагаються повертати boolean із Function, а не з Predicate. Запам’ятайте: Predicate завжди повертає boolean, Function — будь-який інший тип.
Помилка № 2: Невикористання стандартних інтерфейсів. Часто пишуть свої інтерфейси на кшталт «Checker» з методом boolean check(T t) замість використання Predicate. Краще використовувати стандартні — вони всюди підтримуються і роблять код зрозумілішим для інших програмістів.
Помилка № 3: Лямбда надто складна. Якщо лямбда перетворюється на міні-роман на 10 рядків, її варто винести в окремий метод або клас. Лямбда — це про стислість і читабельність.
Помилка № 4: Забута анотація @FunctionalInterface. Якщо ви пишете свій функціональний інтерфейс — не забудьте про анотацію. Вона підстрахує від випадкових помилок (на кшталт додавання другого абстрактного методу).
Помилка № 5: Використання змінного стану всередині лямбди. Якщо лямбда змінює зовнішні змінні або колекції, це може призвести до неочікуваних багів, особливо під час роботи з потоками. Краще уникати побічних ефектів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ