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» c методом boolean check(T t) вместо использования Predicate. Лучше использовать стандартные — они везде поддерживаются и делают код понятнее для других программистов.
Ошибка №3: Лямбда слишком сложная. Если лямбда превращается в мини-роман на 10 строк, её стоит вынести в отдельный метод или класс. Лямбда — это про краткость и читаемость.
Ошибка №4: Забытая аннотация @FunctionalInterface. Если вы пишете свой функциональный интерфейс — не забудьте про аннотацию. Она подстрахует от случайных ошибок (вроде добавления второго абстрактного метода).
Ошибка №5: Использование изменяемого состояния внутри лямбды. Если лямбда меняет внешние переменные или коллекции, это может привести к неожиданным багам, особенно при работе с потоками. Лучше избегать побочных эффектов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ