Интерфейс UnaryOperator в Java
Такой код не компилируется, хотя выглядит абсолютно логично:
Function<String, String> trim = String::trim;
List<String> names = new ArrayList<>(List.of(" Иван ", " Пётр "));
names.replaceAll(trim); // error: incompatible types: Function<String,String>
// cannot be converted to UnaryOperator<String> Метод List.replaceAll() принимает не Function, а UnaryOperator. Оба описывают преобразование «строка в строку», но с точки зрения компилятора это разные типы. Разберёмся, зачем в JDK понадобился отдельный интерфейс и где он реально применяется.
Что такое UnaryOperator
UnaryOperator — это встроенный функциональный интерфейс Java из пакета java.util.function, добавленный в Java SE 8. Он описывает операцию над одним операндом, результат которой имеет тот же тип, что и операнд. Интерфейс UnaryOperator<T> расширяет Function<T, T> — то есть это частный случай функции, у которой тип аргумента и тип возвращаемого значения совпадают.
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity() {
return t -> t;
}
} Функциональный дескриптор — так называют сигнатуру единственного абстрактного метода интерфейса — здесь выглядит предельно просто: T -> T.
Обратите внимание: в теле интерфейса нет ни одного абстрактного метода. Единственный абстрактный метод apply(T t) унаследован от Function, а параметризация Function<T, T> «схлопывает» его сигнатуру до T apply(T t). Именно поэтому UnaryOperator остаётся корректным функциональным интерфейсом и помечен аннотацией @FunctionalInterface.
Простейший пример — приведение строки к верхнему регистру:
import java.util.function.UnaryOperator;
public class UnaryOperatorExample {
public static void main(String[] args) {
UnaryOperator<String> toUpper = s -> s.toUpperCase();
System.out.println(toUpper.apply("examclouds")); // EXAMCLOUDS
// то же самое через ссылку на метод
UnaryOperator<String> toUpperRef = String::toUpperCase;
System.out.println(toUpperRef.apply("java")); // JAVA
}
} Методы интерфейса UnaryOperator
Собственный метод у UnaryOperator ровно один — статический identity(). Всё остальное приходит по наследству от Function.
| Метод | Сигнатура | Откуда | Что делает |
|---|---|---|---|
apply | T apply(T t) | Наследуется от Function | Единственный абстрактный метод; его и реализует лямбда |
identity | static <T> UnaryOperator<T> identity() | Объявлен в UnaryOperator | Возвращает оператор, отдающий аргумент без изменений |
andThen | <V> Function<T, V> andThen(Function<? super T, ? extends V> after) | Наследуется от Function | Сначала текущая операция, затем after |
compose | <V> Function<V, T> compose(Function<? super V, ? extends T> before) | Наследуется от Function | Сначала before, затем текущая операция |
Важно
Интерфейс UnaryOperator не переопределяет andThen() и compose(), поэтому они возвращают Function, а не UnaryOperator. Строка UnaryOperator<String> both = trim.andThen(toUpper); не скомпилируется — нужен либо тип Function<String, String>, либо явная лямбда s -> toUpper.apply(trim.apply(s)).
Примеры использования
Массовое изменение элементов списка: List.replaceAll()
Это самое частое место, где UnaryOperator встречается в реальном коде. Метод заменяет каждый элемент списка результатом применения оператора — прямо на месте, без создания новой коллекции.
import java.util.ArrayList;
import java.util.List;
import java.util.function.UnaryOperator;
public class ReplaceAllExample {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of(" иван ", " пётр", "мария "));
UnaryOperator<String> normalize = s -> s.trim().toUpperCase();
names.replaceAll(normalize);
System.out.println(names); // [ИВАН, ПЁТР, МАРИЯ]
}
} Преобразование потока: Stream.map()
Stream.map() объявлен через Function, но UnaryOperator<T> является его подтипом, поэтому передаётся туда без каких-либо приведений.
UnaryOperator<Integer> square = n -> n * n;
List<Integer> squares = Stream.of(1, 2, 3, 4)
.map(square)
.toList();
System.out.println(squares); // [1, 4, 9, 16] Генерация последовательности: Stream.iterate()
Метод Stream.iterate(T seed, UnaryOperator<T> f) строит поток, каждый раз применяя оператор к предыдущему значению. Именно здесь совпадение типов принципиально: результат шага становится аргументом следующего шага.
// бесконечный поток, ограничиваем через limit
Stream.iterate(1, n -> n * 2)
.limit(6)
.forEach(n -> System.out.print(n + " ")); // 1 2 4 8 16 32
// начиная с Java 9 есть перегрузка с условием остановки
Stream.iterate(1, n -> n <= 32, n -> n * 2)
.forEach(n -> System.out.print(n + " ")); // 1 2 4 8 16 32 Атомарное обновление значения
Классы из java.util.concurrent.atomic используют UnaryOperator для потокобезопасного пересчёта текущего значения.
AtomicReference<String> ref = new AtomicReference<>("java");
ref.updateAndGet(s -> s + " 21");
System.out.println(ref.get()); // java 21 UnaryOperator.identity() как значение по умолчанию
Когда преобразование опционально, удобно подставить «пустой» оператор вместо проверок на null в каждой ветке.
public static List<String> format(List<String> source, UnaryOperator<String> formatter) {
UnaryOperator<String> safe = (formatter == null) ? UnaryOperator.identity() : formatter;
List<String> copy = new ArrayList<>(source);
copy.replaceAll(safe);
return copy;
} Обратите внимание
Список, созданный через List.of(...) или Collections.unmodifiableList(...), неизменяемый: вызов replaceAll() на нём бросит UnsupportedOperationException. Оборачивайте такой список в new ArrayList<>(...) или используйте stream().map(...) — этот вариант возвращает новую коллекцию и ничего не мутирует.
UnaryOperator, Function и BinaryOperator: в чём разница
Все эти интерфейсы описывают вычисление значения, а различаются количеством аргументов и соотношением типов.
| Интерфейс | Дескриптор | Абстрактный метод | Когда применять |
|---|---|---|---|
Function<T, R> | T -> R | R apply(T t) | Тип результата отличается от типа аргумента |
UnaryOperator<T> | T -> T | T apply(T t) (унаследован) | Один аргумент, результат того же типа |
BinaryOperator<T> | (T, T) -> T | T apply(T a, T b) (унаследован) | Два аргумента одного типа и такой же результат |
BiFunction<T, U, R> | (T, U) -> R | R apply(T t, U u) | Два аргумента разных типов, произвольный результат |
IntUnaryOperator | int -> int | int applyAsInt(int v) | Работа с int без упаковки в Integer |
Главный практический вывод: UnaryOperator<T> можно передать всюду, где ожидается Function<T, T>, но не наоборот. Наследование одностороннее — UnaryOperator является подтипом Function, поэтому обычную функцию нельзя подставить туда, где по контракту требуется оператор.
Примитивные варианты: IntUnaryOperator и другие
Для примитивов в java.util.function есть три отдельных интерфейса: IntUnaryOperator, LongUnaryOperator и DoubleUnaryOperator. Они не расширяют UnaryOperator и не являются обобщёнными.
IntUnaryOperator increment = x -> x + 1;
System.out.println(increment.applyAsInt(41)); // 42
// у примитивных вариантов andThen и compose возвращают тот же тип
IntUnaryOperator doubler = x -> x * 2;
IntUnaryOperator doubleThenIncrement = doubler.andThen(increment);
System.out.println(doubleThenIncrement.applyAsInt(10)); // 21
// атомарный счётчик
AtomicInteger counter = new AtomicInteger(10);
counter.updateAndGet(x -> x * 3); // 30 Совет по производительности
Запись UnaryOperator<Integer> на каждом вызове выполняет распаковку и обратную упаковку значения. В горячем коде и при работе с IntStream используйте IntUnaryOperator — он оперирует примитивом напрямую и не создаёт лишних объектов. В отличие от обобщённой версии, его методы andThen() и compose() возвращают IntUnaryOperator, поэтому цепочки собираются без потери типа.
На чём чаще всего ошибаются
- Передают
Function<T, T>вместоUnaryOperator<T>. Компилятор откажет: наследование идёт только в одну сторону. Либо объявляйте переменную сразу какUnaryOperator, либо оборачивайте ссылкой на метод:list.replaceAll(f::apply). - Ожидают, что
andThen()вернётUnaryOperator. Он возвращаетFunction, потому чтоUnaryOperatorне переопределяет этот метод. - Вызывают
replaceAll()на неизменяемом списке. Результат —UnsupportedOperationExceptionво время выполнения, а не ошибка компиляции. - Меняют состояние объекта внутри оператора. Оператор должен возвращать новое значение, а не мутировать аргумент; побочные эффекты особенно опасны в параллельных потоках.
- Возвращают
nullиз оператора. ПослеreplaceAll()список молча заполнитсяnull-ами, иNullPointerExceptionвыстрелит далеко от места настоящей ошибки. - Путают с
BinaryOperator. «Unary» — один операнд, «binary» — два. ВStream.reduce()нуженBinaryOperator, вStream.iterate()—UnaryOperator.
Часто задаваемые вопросы
Почему нельзя передать Function в List.replaceAll()?
Потому что UnaryOperator<T> наследует Function<T, T>, а не наоборот. Любой UnaryOperator является Function, но не любая функция — оператор, поэтому подстановка вверх по иерархии невозможна. Обходной путь — ссылка на метод: names.replaceAll(trim::apply), она создаёт новый оператор поверх существующей функции.
Что вернёт andThen() у UnaryOperator?
Тип Function<T, V>, а не UnaryOperator<T>. Интерфейс UnaryOperator не переопределяет andThen() и compose(), поэтому композиция двух операторов даёт обычную функцию. Если нужен именно оператор, объедините вызовы в лямбде вручную. Это частый вопрос на собеседовании.
Чем UnaryOperator.identity() отличается от Function.identity()?
Поведение одинаковое — обе возвращают лямбду t -> t, отдающую аргумент без изменений. Отличается только статический тип результата: UnaryOperator.identity() даёт UnaryOperator<T>, а Function.identity() — Function<T, T>. Выбирайте ту версию, тип которой ожидает принимающий метод.
Когда использовать IntUnaryOperator вместо UnaryOperator?
Всегда, когда работаете с примитивами int: IntUnaryOperator избегает автоупаковки и не создаёт объектов Integer на каждом вызове. Он обязателен в API примитивных потоков, например в IntStream.map() и AtomicInteger.updateAndGet(). Аналогично устроены LongUnaryOperator и DoubleUnaryOperator.
Video Explanation
Prefer video format? Watch this lesson with examples and explanations.
Comments