JavaRush /Курси /JAVA 25 SELF /Функціональні інтерфейси: Predicate, Consumer, Supplier, ...

Функціональні інтерфейси: Predicate, Consumer, Supplier, Function

JAVA 25 SELF
Рівень 49 , Лекція 0
Відкрита

1. Заглиблюємося у функціональний інтерфейс

Функціональний інтерфейс — це такий інтерфейс, у якому є РІВНО один абстрактний (тобто нереалізований) метод. Саме завдяки цьому Java розуміє: «отже, сюди можна підставити лямбду!» або посилання на метод.

Щоб ніхто не помилився, у таких інтерфейсах зазвичай стоїть анотація @FunctionalInterface. Вона необов’язкова, але якщо ви її додали та випадково написали другий абстрактний метод, компілятор відразу повідомить про помилку.

Приклад:

@FunctionalInterface
interface MyAction {
    void run();
}
MyAction action = () -> System.out.println("Привіт із лямбди!");
action.run(); // Виведе: Привіт із лямбди!

Навіщо це потрібно?

  • Дозволяє використовувати лямбда-вирази та посилання на методи замість створення анонімних класів (менше бойлерплейту).
  • Дає компілятору зрозуміти, що інтерфейс призначений для функціонального програмування.

Цікавий факт: У стандартній бібліотеці Java вже є десятки таких інтерфейсів — не потрібно винаходити велосипед!

2. Огляд стандартних функціональних інтерфейсів

У пакеті java.util.function містяться десятки функціональних інтерфейсів. Розглянемо чотири найпопулярніші (у них найвища «відвідуваність» серед усіх Java-інтерфейсів).

Інтерфейс Що приймає Що повертає Для чого зазвичай використовується
Predicate<T>
T
boolean
Перевірка умови (фільтрація)
Consumer<T>
T
void
Виконати дію над об’єктом
Supplier<T>
нічого
T
Отримати/згенерувати об’єкт
Function<T, R>
T
R
Перетворити 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)
Predicate<T>
filter(u -> u.getAge() > 18)
map (Stream)
Function<T, R>
map(u -> u.getName())
forEach (Stream, List)
Consumer<T>
forEach(u -> System.out.println(u))
generate (Stream)
Supplier<T>
Stream.generate(() -> ...)

Чому важливо знати про функціональні інтерфейси?

  • Вони лежать в основі всіх лямбда-виразів у 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: Використання змінного стану всередині лямбди. Якщо лямбда змінює зовнішні змінні або колекції, це може призвести до неочікуваних багів, особливо під час роботи з потоками. Краще уникати побічних ефектів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ