W3docs

Java BinaryOperator и UnaryOperator

Специализированные функциональные интерфейсы Java для операций над операндами одного типа — BinaryOperator и UnaryOperator.

Два последних функциональных интерфейса в части 12 завершают четырёхугольную таксономию специализациями одного типа:

  • UnaryOperator<T> расширяет Function<T, T> — один вход, один выход, тот же тип. Форма, лежащая в основе List.replaceAll, Map.replaceAll и любых вызовов «преобразование на месте».
  • BinaryOperator<T> расширяет BiFunction<T, T, T> — два входа и один выход, все одного типа. Форма, лежащая в основе Stream.reduce, Map.merge и параллельного шага «объединить два частичных результата в один».

Ни один из интерфейсов не добавляет новых SAM — они наследуют apply от своего родителя. Что они добавляют — это два коротких статических метода в BinaryOperator: minBy и maxBy, которые достаточно часто встречаются, чтобы знать их по имени.

UnaryOperator<T> — преобразование одного типа

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
  static <T> UnaryOperator<T> identity();          // returns t -> t
}

Это полное объявление. Всё остальное (apply, andThen, compose) наследуется от Function<T, T>.

UnaryOperator<T> также является Function<T, T>, поэтому везде, где принимается Function<String, String>, подойдёт и UnaryOperator<String>. Обратное неверно: Function<String, Object> не является UnaryOperator<String>. Разница важна, когда API специально требует гарантии одного типа:

List<String> names = new ArrayList<>(List.of("alice", "bob"));
names.replaceAll(String::toUpperCase);                    // UnaryOperator<String>
// names.replaceAll(String::length);                       // would not compile — String -> Integer

List.replaceAll(UnaryOperator<E>) перезаписывает каждый элемент на месте. Поскольку параметр — это UnaryOperator<E>, компилятор отклоняет любое преобразование, которое меняло бы тип элемента — что именно и нужно при мутации на месте.

Примитивные специализации существуют там, где они полезны в стриминговом коде:

IntUnaryOperator    doubleIt = i -> i * 2;
LongUnaryOperator   biggify  = n -> n + 1_000_000L;
DoubleUnaryOperator halve    = d -> d / 2.0;

IntStream.map(IntUnaryOperator) — версия без боксинга по сравнению с Stream<Integer>.map(Function<Integer, Integer>).

BinaryOperator<T> — объединение двух значений одного типа

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
  static <T> BinaryOperator<T> minBy(Comparator<? super T> c);
  static <T> BinaryOperator<T> maxBy(Comparator<? super T> c);
}

BinaryOperator<T> — это «объедини эти два T в один T.» Форма существует потому, что объединение — это операция, которая нужна параллельной редукции:

BinaryOperator<Integer> sum     = Integer::sum;
BinaryOperator<String>  concat  = String::concat;
BinaryOperator<List<String>> merge = (a, b) -> { var c = new ArrayList<>(a); c.addAll(b); return c; };

Каждый принимает два одинаковых значения и возвращает одно такого же типа. Это единственное требование.

Где используется BinaryOperator<T>

int total = nums.stream().reduce(0, Integer::sum);          // Stream.reduce(identity, BinaryOperator)
Optional<Integer> max = nums.stream().reduce(Integer::max);  // Stream.reduce(BinaryOperator)
Optional<Integer> max2 = nums.stream()
    .reduce(BinaryOperator.maxBy(Integer::compare));         // same thing, named
scores.merge("alice", 1, Integer::sum);                       // Map.merge(K, V, BinaryOperator<V>)

Stream.reduce — главное место применения. Передаваемый BinaryOperator<T> вызывается многократно, чтобы свернуть поток значений T в одно T. В параллельном стриме частичные результаты из разных потоков объединяются тем же оператором — поэтому оператор должен быть ассоциативным: (a ⊕ b) ⊕ c и a ⊕ (b ⊕ c) должны давать одинаковый результат, независимо от того, как JVM разбивает работу.

Map.merge(key, value, remapping) — ещё одно место, где BinaryOperator<V> встречается в повседневном коде, и это самый чистый способ реализовать «увеличить счётчик в map»:

Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);

Если ключ отсутствует, значение сохраняется как есть; если ключ присутствует, BinaryOperator<V> перемапинга объединяет старое и новое значения.

minBy и maxBy — именование очевидной редукции

Два коротких статических фабричных метода, которые оборачивают Comparator:

BinaryOperator<Person> oldest  = BinaryOperator.maxBy(Comparator.comparingInt(Person::age));
BinaryOperator<Person> shortest = BinaryOperator.minBy(Comparator.comparing(Person::name));

Optional<Person> winner = people.stream().reduce(oldest);

Лямбды можно написать вручную — (a, b) -> a.age() > b.age() ? a : b — но BinaryOperator.maxBy(cmp) читается как намерение и переиспользует существующий Comparator. Collectors.maxBy(cmp) — форма в виде коллектора; оба дают одинаковый ответ через разные API.

Ассоциативность — это контракт

Компилятор не может проверить, что ваш BinaryOperator<T> ассоциативен. JDK предполагает это. В последовательной reduce ошибка ассоциативности меняет результат только если оператор также не коммутативен; в параллельной reduce неассоциативные операторы дают недетерминированные ответы — одни и те же входные данные, разные суммы при разных запусках:

BinaryOperator<Integer> bad = (a, b) -> a - b;        // not associative
//  ((1 - 2) - 3) = -4
//  (1 - (2 - 3)) = 2
// In a parallel reduce, you get whichever the split happened to produce.

+, *, min, max, конкатенация списков, объединение множеств и конкатенация строк — всё это ассоциативно. Вычитание и деление — нет. Используйте их в BinaryOperator — и вы вносите баг параллелизма, который рано или поздно проявится.

Практический пример: replaceAll, reduce, merge и статики minBy/maxBy

Программа ниже использует UnaryOperator<String> для перевода списка в верхний регистр на месте, сворачивает IntStream с помощью BinaryOperator через ссылку на метод Integer::sum, строит гистограмму слов с Map.merge и находит самого старого человека в списке с помощью BinaryOperator.maxBy и Stream.reduce.

java— editable, runs on the server

Что следует вынести из запуска:

  • names.replaceAll(String::toUpperCase) перезаписал список на месте. Форма UnaryOperator<String> обеспечила типобезопасность — String::length не скомпилировался бы, поскольку не возвращает String.
  • Stream.reduce(0, Integer::sum) свернул пять целых чисел в одно с использованием ассоциативного BinaryOperator<Integer>. Элемент идентичности 0 сделал случай пустого стрима осмысленным: пустой стрим сворачивается к элементу идентичности.
  • Stream.reduce(BinaryOperator) без элемента идентичности вернул Optional<T> — нет разумного ответа для пустого стрима, если идентичность не задана.
  • counts.merge(w, 1, Integer::sum) — однострочный идиом подсчёта слов. Сохраняет 1, если ключ отсутствует, и прибавляет 1 к существующему значению, если ключ есть. BinaryOperator<Integer> — шаг объединения.
  • BinaryOperator.maxBy(Comparator.comparingInt(Person::age)) назвал редукцию как «сравнить по возрасту и оставить большее». Эквивалентная лямбда работает, но именованный статический метод читается как намерение.
  • Неассоциативная редукция (a, b) -> a - b вернула разные числа в последовательном и параллельном режимах — параллельный результат зависит от того, как разбилась работа. Ассоциативность — контракт, который не виден в типе, но от которого полностью зависит рантайм.

Что дальше

На этом часть 12 закрыта. Теперь вы изучили полный функциональный словарь JDK: функциональные интерфейсы и @FunctionalInterface, лямбды, ссылки на методы, пакет java.util.function от начала до конца, конвейер стримов (источники, промежуточные операции, терминальные операции, коллекторы, параллельность), Optional, и наконец Predicate, Function, Consumer/Supplier и семейство операторов по одному. Следующая часть, File and I/O, начинается с Java I/O Introduction — разделением байт и символов, буферизованным слоем стримов и тем, как java.io соотносится с более новым API java.nio.file. Многие паттерны из этой части — try-with-resources, формы Consumer/Supplier для чтения и записи, конвейер стримов для файлов построчно — появятся сразу.

Практика

Практика
Вам нужен однострочный идиом, который увеличивает счётчик на слово в `Map<String, Integer>`. Какой вызов делает это корректно с `BinaryOperator<Integer>`?
Вам нужен однострочный идиом, который увеличивает счётчик на слово в `Map<String, Integer>`. Какой вызов делает это корректно с `BinaryOperator<Integer>`?
Was this page helpful?