Интерфейс Function в Java
Что напечатает эта строка: f1.andThen(f2).compose(f3).compose(f4).apply("Compose"), если каждая функция дописывает к строке свою цифру? Интуиция подсказывает Compose1234, а на консоли появится Compose4312. Порядок вызова в цепочке — главный источник ошибок при работе с Function, и ниже мы разберём его по шагам.
java.util.function.Function<T, R> — это встроенный функциональный интерфейс из пакета java.util.function, добавленный в Java SE 8. Он принимает аргумент типа T и возвращает результат типа R, то есть описывает преобразование одного значения в другое — того же или другого типа.
Что такое интерфейс Function
Объявление интерфейса в JDK выглядит так:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
} У него ровно один абстрактный метод — apply(), поэтому интерфейс помечен аннотацией @FunctionalInterface и его можно реализовать лямбда-выражением или ссылкой на метод.
Функциональный дескриптор интерфейса:
T -> R Читается это так: «на вход — значение типа T, на выход — значение типа R». Типичные задачи: String в Integer, сущность в DTO, идентификатор в объект, число в отформатированную строку.
Актуально ли это сегодня
Интерфейс Function не менялся с Java 8: сигнатуры apply, andThen, compose и identity одинаковы в Java 8, 11, 17, 21 и 25. Всё, что описано в уроке, работает в любом актуальном LTS-релизе без оговорок.
Метод apply(): первый пример
apply() — единственный абстрактный метод интерфейса. Именно его тело вы пишете в лямбде, и именно он выполняет преобразование:
import java.util.function.Function;
public class FunctionExample1 {
public static void main(String[] args) {
Function<Double, Long> function = d -> Math.round(d);
System.out.println(function.apply(5.7)); // 6
}
} Здесь срабатывают сразу три неявных преобразования, о которых полезно знать:
- литерал
5.7(типdouble) автоматически упаковывается вDouble, потому чтоapply()принимает объект; - внутри лямбды
Doubleраспаковывается обратно вdoubleдля вызоваMath.round(double); - результат типа
longупаковывается вLong, так как второй параметр типа объявлен какLong.
Ту же лямбду можно записать ссылкой на метод — компилятор сам выберет перегрузку Math.round(double), потому что целевой тип требует возврата Long:
Function<Double, Long> function = Math::round;
System.out.println(function.apply(5.7)); // 6 andThen и compose: порядок вызова
Кроме apply() интерфейс объявляет два метода по умолчанию, которые склеивают функции в цепочку:
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before); Разница между ними только в порядке выполнения:
| Метод | Что выполняется первым | Эквивалентная запись | Направление чтения |
|---|---|---|---|
f.andThen(g) | сначала f, затем g | x -> g.apply(f.apply(x)) | слева направо, как читается код |
f.compose(g) | сначала g, затем f | x -> f.apply(g.apply(x)) | справа налево, как в математике |
Классический пример, который часто дают на собеседовании:
import java.util.function.Function;
public class FunctionExample2 {
public static void main(String[] args) {
Function<String, String> f1 = s -> s + "1";
Function<String, String> f2 = s -> s + "2";
Function<String, String> f3 = s -> s + "3";
Function<String, String> f4 = s -> s + "4";
System.out.println(f1.andThen(f2).compose(f3).compose(f4).apply("Compose")); // Compose4312
System.out.println(f1.andThen(f2).andThen(f3).apply("AndThen")); // AndThen123
}
} Первая строка разбирается по шагам так. Каждый compose «подкладывает» функцию в начало цепочки, поэтому последняя присоединённая функция срабатывает первой:
| Шаг | Функция | Почему именно она | Результат |
|---|---|---|---|
| 1 | f4 | последний compose — выполняется раньше всех | Compose4 |
| 2 | f3 | предыдущий compose | Compose43 |
| 3 | f1 | исходная функция цепочки | Compose431 |
| 4 | f2 | andThen — всегда после f1 | Compose4312 |
Вторая строка проще: andThen добавляет функции в конец, поэтому порядок совпадает с порядком записи — f1, f2, f3, и результат равен AndThen123.
Мнемоника
Две записи всегда дают одну и ту же функцию: g.compose(f) эквивалентно f.andThen(g). Если сомневаетесь в порядке — перепишите цепочку через andThen, её читать слева направо гораздо проще. Оба метода бросают NullPointerException, если передать им null: внутри стоит Objects.requireNonNull.
Function.identity()
У интерфейса есть один статический метод:
static <T> Function<T, T> identity() Он возвращает функцию, которая отдаёт свой аргумент без изменений — тождественное преобразование T -> T:
import java.util.function.Function;
public class FunctionExample3 {
public static void main(String[] args) {
Function<String, String> f = Function.identity();
System.out.println(f.apply("Some Value")); // Some Value
}
} Сам по себе такой вызов бесполезен. Смысл identity() появляется там, где API требует передать функцию, а вам нужен исходный объект «как есть» — например, когда сам объект становится ключом в Map:
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class IdentityExample {
public static void main(String[] args) {
List<String> words = List.of("Java", "Function", "Lambda");
Map<String, Integer> lengthByWord = words.stream()
.collect(Collectors.toMap(Function.identity(), String::length));
System.out.println(lengthByWord); // {Java=4, Lambda=6, Function=8}
}
} Где Function применяется на практике
В стандартной библиотеке Function встречается чаще любого другого функционального интерфейса. Самые частые точки входа:
Stream.map(Function<? super T, ? extends R>)— преобразование каждого элемента потока;Optional.map(Function<? super T, ? extends U>)— преобразование значения, если оно есть;Map.computeIfAbsent(K, Function<? super K, ? extends V>)— вычисление значения для отсутствующего ключа;Collectors.toMapиCollectors.groupingBy— извлечение ключа из элемента;Comparator.comparing(Function<? super T, ? extends U>)— сравнение по вычисляемому полю.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class FunctionInApi {
public static void main(String[] args) {
Function<String, Integer> length = String::length;
// 1. Stream.map принимает Function
List<Integer> lengths = List.of("Java", "Function", "Lambda")
.stream()
.map(length)
.toList(); // Stream.toList() доступен с Java 16
System.out.println(lengths); // [4, 8, 6]
// 2. Map.computeIfAbsent: Function вычисляет значение для нового ключа
Map<Character, List<String>> byFirstLetter = new HashMap<>();
for (String word : List.of("Java", "Jar", "Lambda")) {
byFirstLetter.computeIfAbsent(word.charAt(0), k -> new ArrayList<>()).add(word);
}
System.out.println(byFirstLetter); // {J=[Java, Jar], L=[Lambda]}
}
} BiFunction, UnaryOperator и примитивные варианты
Базовый Function<T, R> работает только с объектами и принимает ровно один аргумент. Для остальных случаев в пакете java.util.function есть готовые специализации:
| Интерфейс | Дескриптор | Абстрактный метод | Когда использовать |
|---|---|---|---|
Function<T, R> | T -> R | apply | обычное преобразование объекта в объект |
BiFunction<T, U, R> | (T, U) -> R | apply | нужны два аргумента |
UnaryOperator<T> | T -> T | apply | тип аргумента и результата совпадают |
BinaryOperator<T> | (T, T) -> T | apply | свёртка двух значений одного типа |
IntFunction<R>, LongFunction<R>, DoubleFunction<R> | int -> R | apply | аргумент — примитив, результат — объект |
ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T> | T -> int | applyAsInt и аналоги | аргумент — объект, результат — примитив |
IntUnaryOperator, IntToDoubleFunction и др. | int -> int, int -> double | applyAsInt, applyAsDouble | и аргумент, и результат — примитивы |
Совет по производительности
Примитивные специализации существуют не для красоты: Function<Integer, Integer> на каждом вызове упаковывает и распаковывает значение, а IntUnaryOperator работает с int напрямую. В горячих циклах и больших потоках данных разница заметна, поэтому для чисел выбирайте IntUnaryOperator, ToIntFunction и их родственников. Подробнее об упаковке — в уроке про автоупаковку.
Function, Supplier, Consumer и Predicate
Function — лишь один из четырёх базовых функциональных интерфейсов пакета java.util.function. Различать их проще всего по наличию входа и выхода:
| Интерфейс | Дескриптор | Метод | Задача |
|---|---|---|---|
Function<T, R> | T -> R | apply | принять значение и вернуть результат |
Supplier<T> | () -> T | get | только поставить значение, без входа |
Consumer<T> | T -> void | accept | только принять значение, без результата |
Predicate<T> | T -> boolean | test | проверить условие |
Формально Predicate<T> можно заменить на Function<T, Boolean>, но так делать не стоит: Predicate возвращает примитивный boolean без упаковки и даёт методы and(), or(), negate(). Полный перечень интерфейсов пакета собран в уроке про специализации функциональных интерфейсов.
На чём чаще всего спотыкаются
- Путают compose и andThen. Самая частая ошибка и в коде, и на собеседовании. Запомните:
composeвыполняет переданную функцию до текущей,andThen— после. - Ожидают, что Function изменит исходный объект. Функция ничего не меняет на месте — она возвращает новое значение. Строка
function.apply(x);без присваивания результата бессмысленна. - Пытаются бросить проверяемое исключение. Метод
apply()объявлен безthrows, поэтомуIOExceptionвнутри лямбды не скомпилируется — придётся ловить его и заворачивать в непроверяемое. - Берут объектную версию для чисел.
Function<Integer, Integer>в цикле на миллион итераций создаёт миллион упаковок;IntUnaryOperatorне создаёт ни одной. - Забывают про null.
Functionничего не знает оnull: еслиapply()получитnull, а лямбда вызовет метод на аргументе, будетNullPointerException. Проверку нужно писать самому либо использоватьOptional.map.
Официальное описание интерфейса со всеми сигнатурами доступно в Javadoc java.util.function.Function.
Часто задаваемые вопросы
Чем Function отличается от UnaryOperator?
UnaryOperator наследуется от Function, у которой тип аргумента и тип результата совпадают. Любой UnaryOperator строк можно передать туда, где ждут Function из String в String, но не наоборот. Берите UnaryOperator, когда преобразование не меняет тип: сигнатура метода становится короче и понятнее.
Как передать в Function два аргумента?
Есть два пути. Штатный - взять BiFunction с методом apply от двух параметров. Второй - карринг, когда одна функция возвращает другую: Function sum = a -> b -> a + b, и тогда sum.apply(2).apply(3) вернёт 5. Карринг удобен, если первый аргумент известен заранее, а второй появляется позже.
Можно ли бросить проверяемое исключение внутри Function?
Нет. Метод apply() объявлен без throws, поэтому проверяемое исключение внутри лямбды даст ошибку компиляции. Варианты решения: обработать исключение прямо в лямбде, обернуть его в RuntimeException либо объявить собственный функциональный интерфейс, чей метод объявлен с throws.
Зачем нужен Function.identity(), если можно написать t -> t?
Поведение одинаковое, но Function.identity() каждый раз возвращает один и тот же общий экземпляр, а лямбда t -> t порождает отдельный объект на каждом месте вызова. Плюс запись Collectors.toMap(Function.identity(), ...) читается однозначно, тогда как t -> t в длинной цепочке легко принять за забытое преобразование.
Что быстрее: Function с Integer или IntUnaryOperator?
Для чисел быстрее IntUnaryOperator. Объектная версия на каждом вызове упаковывает int в Integer и распаковывает обратно, создавая лишние объекты и нагрузку на сборщик мусора. Примитивная специализация работает с int напрямую. На десятке элементов разница незаметна, на миллионе - вполне ощутима.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии