Stream API. collect(), Collector, Collectors. Теория. Часть I
Дорогу осилит идущийДанный урок будет заключительным в теме Stream API и в нем мы поговорим о, возможно, самом сложном инструменте в рамках стримов – о терминальной операции collect(). Мы делали ее поверхностный разбор, сегодня мы постараемся изучить весь перечень стандартных Collector’ов. Как правило, их более чем достаточно для эффективного использования Stream’ов в коммерческой разработке. Но начнем мы не с них.
<R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner)
Как мы знаем, метод collect() у стрима перегружен и имеет две реализации: одна из них принимает параметром экземпляр интерфейса Collector, вторая – три лямбда-выражения: supplier, accumulator и combiner. Постараемся разобраться, что за они и с чем их кушать.
Обратите внимание, метод collect() является параметризованным и возвращает любой тип (обозначен как R). С учетом этой информации, рассмотрим параметры метода подробнее.
Supplier<R> supplier
Supplier как функциональный интерфейс содержит метод R get()*.
*Здесь и ниже – методы функциональных интерфейсов описаны с поправкой на название параметризованных типов в вызываемых методах. Смысловой разницы нет, но сами обозначения этих типов (T, R и т.д.) в статье могут отличаться от таковых в исходниках Java.
Т.е. не принимает параметров, но возвращает какое-то значение. К слову, этот интерфейс популярен в качестве параметра методов Optional, обрабатывающих отсутствие значения – orElseGet(), orElseThrow(), or().
В случае с Stream.collect(), данное лямбда-выражение создает пустой экземпляр «контейнера» - объект класса, который будет возвращен из метода collect(). Именно поэтому он параметризован тем же типом, что и возвращаемое значение метода.
Данное лямбда-выражение в узком смысле должно содержать конструктор, в широком – любой код, который возвращает какой-то объект. Чаще всего – коллекция, но, теоретически, может быть объект вообще любого типа – Object, String, SthYourObject. Все зависит от конкретной задачи.
Документация рекомендует возвращать изменяемый (mutable) объект, но никто не запрещает вернуть immutable-объект. Другой вопрос, что это не всегда имеет смысл.
Стоит понимать, что данное лямбда-выражение может быть вызвано не единожды в рамках одного метода collect(). Например, если у вас параллельный стрим – оно вполне может вызваться в каждом потоке исполнения. Как это обрабатывается – разберем чуть ниже.
BiConsumer<R, ? super T> accumulator
BiConsumer в данном случае содержит абстрактный метод
void accept(R r, ? super T t)
В нашем случае R – тип возвращаемого из collect() значения, а T – тип элемента в Stream’е.
Данное лямбда выражение описывает логику, по которой элемент t аккумулируется в контейнере r. Под аккумуляцией может подразумеваться добавление элемента t в коллекцию r, любое другое изменение содержимого r на основании t или вообще игнорирование t – любого или с учетом определенных критериев. В общем, все зависит от логики, которую вы опишете в лямбда-выражении.
BiConsumer<R, R> combiner
Функциональный интерфейс тот же, что и у предыдущего параметра, но смысл другой. В данном случае абстрактный метод интерфейса можно описать как
void accept(R r1, R r2)
где параметры r1 и r2 имеют тот же тип, что и возвращаемое значение collect().
Зачем это нужно?
Как было сказано выше, supplier() может быть вызван для одного collect() несколько раз. Т.е. будет создано несколько объектов-контейнеров, каждый из которых будет аккумулировать свои элементы стрима с помощью accumulator’а, разобранного выше. Но collect() должен вернуть лишь одно значение. И задача combiner – «подружить» или объединить все контейнеры в единое целое.
Описание выше звучит достаточно громоздко. И содержимое описанных лямбда-выражений может быть весьма нетривиальным. Но большинство реализаций достаточно простое и лаконичное. Рассмотрим пример.
Пример. Записать содержимое Stream’а Integer’ов в список. В качестве реализации списка возьмем ArrayList.
Supplier будет выглядеть так:
() -> new ArrayList<>()
Просто вызов конструктора. Можно упростить до method reference:
ArrayList::new
Теперь опишем accumulator. В нашем случае – добавление элементов в список:
(list, el) -> list.add(el)
Просто добавление элемента в список. И снова можно упростить до method reference:
List::add
Осталось описать combiner. В нашем случае – снова ничего сложного:
(l1, l2) -> l1.addAll(l2)
l1 и l2 – условное обозначение списков, созданных из вызова supplier. Конечно, на самом деле списков может быть создано больше двух, но и лямбду можно вызывать для каждого списка, включая их в первый. В любом случае, это забота метода collect().
К слову, combiner тоже можно упростить до method reference:
List::addAll
Таким образом, итоговый результат выглядит примерно так:
ArrayList<Object> list = Stream.of(1, 2) .collect(ArrayList::new, List::add, List::addAll);
Не очень удобно – предпочтительнее было бы получить List<Integer> – но это частности, выходящие за пределы урока. Как вариант для внутреннего перфекциониста – зададим явную параметризацию.
List<Integer> list = Stream.of(1, 2) .<List<Integer>>collect(ArrayList::new, List::add, List::addAll);
Полагаю, выше достаточно очевидно продемонстрирована ответственность каждого параметра данной перегрузки collect().
<R, A> R collect(Collector<? super T, A, R> collector)
collect(), разобранный выше, позволяет решать достаточно широкий спектр задач. Но он обладает одним крупным недостатком – каждое использование требует описание трех лямбда-выражений.
При этом, полагаю, очевидно, что для коллектора существует масса типовых ситуаций, которая покрывает большую часть практических задач. И описание трех лямбда-выражений для каждого использования – достаточно рутинная работа, которую легко оптимизировать. Чтобы уменьшить количество бойлерплейт-кода существует интерфейс Collector.
Collector
Внутри себя он содержит три метода, о назначении которых вы легко догадаетесь:
1. Supplier<A> supplier();
2. BiConsumer<A, T> accumulator();
3. BinaryOperator<A> combiner(). Сигнатура отличается от combiner’а, рассмотренного выше, но суть та же.
Также содержит методы:
· Function<A, R> finisher(). Позволяет описать лямбда-выражение, которое применится к результату «промежуточной» аккумуляции на основании трех методов выше. Делает функциональность collect() гибче;
Простой пример использования – представление Stream’а в качестве Optional<List<T>>. Если List из элементов мы уже собрали в примере выше, то обернуть его в Optional, не выходя за пределы Stream API без finisher() сложнее.
· Set<Characteristics> characteristics(). Возвращает набор характеристик Collector’а. Существуют характеристики CONCURRENT, UNORDERED и IDENTITY_FINISH. В целом, их смысл достаточно понятен интуитивно, но ниже скажем пару слов о каждой для расширения кругозора;
· of(). Перегруженный метод. Каждая из двух реализаций является оберткой над соответствующим (с заданным finisher() или без него) конструктором Collectors.CollectorImpl.
Характеристики Collector:
· CONCURRENT. Декларирует, что контейнер (созданный supplier’ом) допускает возможность применения к нему аккумулятора из нескольких потоков исполнения одновременно. Чем это чревато при некорректном использовании – разберемся в рамках раздела «Многопоточность»;
· UNORDERED. Декларирует, что порядок обработки элементов стрима в collect() не критичен. Например, результатом collect() является Set;
· IDENTITY_FINISH. Буквально, означает, что finisher() можно не вызывать.
Итак, подводя итог, интерфейс Collector преследует две цели:
1. Избавление от необходимости описывать три лямбда-выражения для каждого вызова collect();
2. Расширение возможностей collect(), по сравнению с реализацией, принимающей три лямбда-выражения.
Ниже познакомимся с классическими реализациями Collector, предлагаемыми классом Collectors. И заодно узнаем, что такое downstream.
Collectors
Collectors представляет собой утилитный класс, содержащий статические методы, возвращающие объекты типа Collector, покрывающие абсолютное большинство сценариев использования collect(), начиная от самых простых, заканчивая реализациями, позволяющими строить целую цепочку Collector’ов и получать в итоге достаточно сложные структуры данных.
В рамках текущего урока мы разберем их все.
toList()
!NB: не путайте с терминальной операцией Stream.toList().
Коллектор, создающий список – ArrayList.
toUnmodifiableList()
Коллектор, создающий неизменяемый список.
Де-факто итоговым типом будет один из наследников ImmutableCollections.AbstractImmutableList. Конечная реализация зависит от числа элементов, но, в целом, мало на что влияет.
toSet()
Коллектор, создающий сет – HashSet.
toUnmodifiableSet()
Коллектор, создающий неизменяемый сет. Будет использована одна из реализаций ImmutableCollections.AbstractImmutableSet.
toCollection(Supplier<C> collectionFactory)
Коллектор, результирующим типом которого можно задать любую коллекцию, наследующую Collection. Принимает параметром supplier, который должен создавать объект такой коллекции. По сути, вполне достаточно описать в лямбде вызов конструктора интересующей вас коллекции.
Хорошо подходит для использования в ситуациях, когда результатом Stream’а вы хотите видеть конкретную реализацию коллекции. Скажем, LinkedList, TreeSet, какая-то реализация Queue – все то, что не покрывается коллекторами, описанными выше.
toMap()
Коллектор, результатом которого является Map. В целом, может быть любая реализация, но в узком смысле логичнее подразумевать непотокобезопасную изменяемую мапу. Чуть позже станет понятно почему.
toMap() перегружен и существует в трех видах.
Самый примитивный принимает параметрами два лямбда-выражения типа Function – первое описывает, как из элемента получить ключ будущего Map.Entry, второе – как получить value для него же.
Пример:
class User {
private long id;
private String name;
private int age;
private List<String> tags;
…
// other fields, getters, setters, constructors…
}
…
Stream<User> userStream1 = …;
Map<Long, User> userById = userStream1.collect(
Collectors.toMap(User::getId, Function.identity()));
Stream<User> userStream2 = …;
Map<Long, String> userNameById = userStream2.collect(
Collectors.toMap(User::getId, User::getName));
В данном случае будет создана HashMap. Но если лямбда-выражение, описывающее получение ключа вернет неуникальное значение (дублирующийся ключ) или лямбда, описывающая получение value вернет null – возникнет исключение.
При этом в последующем в полученный объект HashMap можно без ограничений добавлять пары с дублирующимся ключом и/или value равным null. Это ограничения именно данного Collector’а, а не коллекции.
Второй вид toMap() также возвращает HashMap, но добавляет еще один параметр – лямбда-выражение, описывающее правило «слияния» значений для ситуаций, когда ключ не уникален. Избавляет от исключения в соответствующих ситуациях. В целом, напоминает метод merge() у Map. Мы с ним познакомимся в следующем уроке.
Доработаем пример выше. Будем считать, что элемент, расположенный в Stream’е позже, имеет более актуальное значение.
Stream<User> userStream1 = …;
Map<Long, User> userById = userStream1.collect(
Collectors.toMap(
User::getId,
Function.identity(),
(v1, v2) -> v2));
Stream<User> userStream2 = …;
Map<Long, String> userNameById = userStream2.collect(
Collectors.toMap(
User::getId,
User::getName,
(v1, v2) -> v2));
!NB: В реальных задачах можно было бы подвязаться под поле, хранящее дату обновления сущности или реализовать иную логику – как всегда, все зависит от задачи.
Третья версия toMap() добавляет четвертый параметр – типа Supplier (он называется mapFactory). Как вы уже догадались, с его помощью мы можем указать лямбду, описывающую создание мапы. В т.ч. выбрать реализацию, отличную от HashMap. Доработаем предыдущий пример, используя LinkedHashMap и TreeMap с дефолтной сортировкой – Long реализует Comparable.
Stream<User> userStream1 = …;
Map<Long, User> userById = userStream1.collect(
Collectors.toMap(
User::getId,
Function.identity(),
(v1, v2) -> v2,
LinkedHashMap::new));
Stream<User> userStream2 = …;
Map<Long, String> userNameById = userStream2.collect(
Collectors.toMap(
User::getId,
User::getName,
(v1, v2) -> v2,
TreeMap::new));
!NB: Ни в одном из описанных случаев мы не сможем добавить в мапу пару с value == null. Но такое поведение иногда нужно. Ситуация решается через использование collect() с тремя параметрами. Можно посмотреть здесь.
toUnmodifiableMap()
В целом, все то же самое, что и для toMap(), но только два перегруженных метода, с теми же параметрами и правилами игры. Отсутствует только реализация, в которой можно указать supplier. В целом, причины отсутствия варианта с Supplier’ом, очевидны – нет возможности убедиться, что будет возвращена действительно неизменяемая мапа.
Возвращает неизменяемую мапу, одну из реализаций ImmutableCollections.AbstractImmutableMap.
toConcurrentMap()
Опять же, все полностью по аналогии с toMap(). Только по умолчанию будет использоваться ConcurrentHashMap.
Вариант с supplier’ом тоже есть. В нем можно создавать любую мапу, реализующую ConcurrentMap.
Таким образом, результатом всегда будет потокобезопасная мапа. Подробнее – в разделе «Многопоточность».
joining()
Достаточно интересный Collector, результатом которого будет объединение элементов в один объект типа String. Ограничение для использования данного Collector’а – элементы должны быть строкового типа – любой реализации CharSequence.
String, StringBuilder, StringBuffer – реализуют CharSequence.
Имеет три перегруженные реализации. Разберемся на примерах.
Первая реализация – без параметров. Просто конкатенирует строки:
String s = Stream.of("1", "2", "3")
.collect(Collectors.joining()); // "123"
Вторая реализация принимает параметр-разделитель, который вставляется между элементами:
String s = Stream.of("1", "2", "3")
.collect(Collectors.joining(", ")); // "1, 2, 3"
И, наконец, третий вариант имеет три параметра: разделитель, префикс (начинает результирующую строку) и постфикс (завершает результирующую строку):
String s = Stream.of("1", "2", "3")
.collect(Collectors.joining(", ", "{", "}")); // "{1, 2, 3}"
В целом, данный Collector напоминает String.join().
groupingBy()
Collector, результатом которого будет Map, но собранная с предварительной группировкой. Имеет три перегруженные реализации.
Используя примеры из toMap(), постараемся разобраться что к чему.
Первая реализация принимает лишь один параметр – лямбду-классификатор (classifier), типа Function. Она должна описать способ получения ключа будущей мапы. Значением будет список, содержащий элементы, в которых указанное поле соответствует ключу. Если все еще непонятно – welcome в пример:
Stream<User> userStream1 = …; Map<String, List<User>> usersByName = userStream1.collect( Collectors.groupingBy(User::getName)); //ключ – имя, //значение – список юзеров с именем как в ключе Stream<User> userStream2 = …; Map<String, List<User>> usersByNameLetter = userStream2.collect( Collectors.groupingBy(user -> user.getName().charAt(0))); //ключ – первая буква имени, //значение – список юзеров с именем, начинающимся на букву, указанную в //ключе Stream<User> userStream3 = …; Map<Integer, List<User>> usersByAge = userStream3.collect( Collectors.groupingBy(User::getAge)); //ключ – возраст, //значение – список юзеров с возрастом как в ключе
Вторая реализация groupingBy() содержит еще один параметр – downstream. Данный параметр имеет тип Collector и описывает, как обработать данные, подходящие под classifier.
По сути, если groupingBy() возвращает Map<K, V>, то classifier отвечает за определение K, а downstream – за определение V.
Так, в примере выше:
Collectors.groupingBy(User::getName)
равносильно
Collectors.groupingBy(User::getName, Collectors.toList())
Но в downstream можно передавать и другие Collector’ы. Например, посчитаем, количество юзеров с каждым из имен:
Stream<User> userStream1 = …;
Map<String, Long> usersAmountByName = userStream1.collect(
Collectors.groupingBy(
User::getName,
Collectors.counting()));
//ключ – имя,
//значение – число юзеров с именем как в ключе
Collectors.counting() опишем ниже, пока, полагаю, интуитивно понятно, что он делает.
Третья реализация groupingBy() добавляет параметр типа Supplier – mapFactory. Как и в toMap(), данный параметр позволяет описать создание мапы. На случай, если вариант по умолчанию (HashMap) не подходит.
!NB: новый параметр вклинивается между классификатором и даунстримом. Обращайте внимание на порядок параметров – это одна из самых неприятных и распространенных ошибок компиляции.
Рассмотрим предыдущий пример, но результирующей мапой сделаем TreeMap. Сортировка по умолчанию – String реализует Comparable:
Stream<User> userStream1 = …;
Map<String, Long> usersAmountByName = userStream1.collect(
Collectors. groupingBy(
User::getName,
TreeMap::new,
Collectors.counting()));
//ключ – имя,
//значение – число юзеров с именем как в ключе
groupingByConcurrent()
Полностью аналогичен groupingBy(), но возвращает потокобезопасную мапу – какую-либо из реализаций ConcurrentMap. По умолчанию – ConcurrentHashMap.
Продолжение
https://telegra.ph/Stream-API-collect-Collector-Collectors-CHast-II-03-17