Stream API настолько большая тема, что приходится разбивать её на несколько статей. В первой части были примеры решения классических проблем при работе с коллекциями, а во второй я покажу использование редуцирующих функций.
Map/Reduce
Map/Reduce это в общем-то модный buzzword. На самом деле Map/Reduce это очень простой шаблон проектирования, описывающий работу с наборами данных в два шага: на первом шаге выполняются (параллельные) операции над набором, на втором шаге результаты первого шага объединяются.
1 2 3 4 5 6 7 8 | public Double averageAge() { Integer currentYear = Calendar.getInstance().get(Calendar.YEAR); return data .stream() .mapToInt(u -> currentYear - u.getYob()) .average() .getAsDouble(); } |
Интерфейс Stream определяет методы mapTo*(), которые возвращают особые реализации Stream, имеющие методы average(), sum(), min(), max(), которые выполняют соответствующие арифметические действия над элементами набора данных.
В случае, когда нужно обработать поток, к которому операции mapTo*() неприменимы, например из-за типа данных, можно использовать функцию reduce, которая принимает два параметра: начальное значение, оно же значение по умолчанию, и лямбда-выражение с двумя аргументами: первый хранит результат предыдущего вычисления, второй — текущее значение. Пример — вычисление списка уникальных символов в логинах пользователей:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | static String removeDuplicates(String s) { StringBuilder noDupes = new StringBuilder(); for (int i = 0; i < s.length(); i++) { String si = s.substring(i, i + 1); if (noDupes.indexOf(si) == -1) { noDupes.append(si); } } return noDupes.toString(); } public String getCommonSymbols() { return data .stream() .map(User::getLogin) .reduce("", (p,c) -> removeDuplicates(p+c)); } |
Коллекторы
Метод collect() дополняет метод reduce(). Он так же является редуцирующим методом, но, в отличие от reduce(), в ходе работы он обновляет значение результата, а не заменяет его.
Обычно метод collect() используется с уже существующими коллекторами из класса java.util.stream.Collectors, который предоставляет множество стандартных операций.
С один типом коллекторов мы сталкивались раньше, в примере Hello,Streams: объединение потока строк в строку:
1 2 3 4 5 6 | public String logins() { return data .stream() .map(User::getLogin) .collect(Collectors.joining(",", "User names: '", "'")); } |
Коллектор joining(delimiter, prefix, suffix) объединяет строки в одну, разделяя их разделителем, результирующая строка будет начинаться с префикса и заканчиваться суффиксом. Существует две упрощённых версии этого коллектора: одна принимает только разделитель, другая не принимает ничего, склеивая строки одна с другой.
Второй и, наверное, самый частой используемый тип коллекторов, это коллекторы в коллекции: Collectors.toCollection(), Collectors.toList(), Collectors.toSet() Первый принимает аргументом лямбда-выражение, возвращающее Collection, остальные два создают контейнеры для данных сами.
1 2 3 4 5 6 7 8 9 10 11 12 13 | public Collection<String> loginsCollections() { return data .stream() .map(User::getLogin) .collect(Collectors.toCollection(ArrayList::new)); } public Set<String> loginsSet() { return data .stream() .map(User::getLogin) .collect(Collectors.toSet()); } |
Очень похожи на них коллекторы работающие с Map. Проблема с Map в том, что один элемент в ней состоит из двух значений, в то время как в Stream каждый элемент есть одно значение. Поэтому методам toMap() необходимо передавать два лямбда-выражения: одно для создания значения в Map из текущего элемента Stream, другое для создания ключа из него же.
1 2 3 4 5 | public Map<Integer, String> yobLoginsMap() { return data .stream() .collect(Collectors.toMap(User::getYob, User::getLogin)); } |
Особенностью toMap() является нетерпимость к повторениям значений ключей. Если при сборке потока в Map бует обнаружено дублирующееся значение ключа, будет выброшено IllegalStateException. А если гарантировать это условие не получается, необходимо использовать расширенный вариант toMap(), в который передаётся дополнительная функция. Эта функция будет вызвана для всех значений с одинаковым ключом и должна их обработать и вернуть единственно верное значение для ключа. Например для примера с возрастами логинов можно объединять логины с одним возрастом:
1 2 3 4 5 6 7 8 | public Map<Integer, String> yobLoginsMultiKeys() { Collection<User> local = new ArrayList<>(data); local.add(new User("OLD", 2, 1970)); local.add(new User("YOUNG", 1, 1990)); return local .stream() .collect(Collectors.toMap(User::getYob, User::getLogin, (s, a) -> s + ", " + a)); } |
1 2 3 4 5 | @Test public void testYobMultiLogin() { assertThat(testedObject.yobLoginsMultiKeys().get(1970), is("LOGIN, OLD")); assertThat(testedObject.yobLoginsMultiKeys().get(1990), is("EXAMPLE, YOUNG")); } |
Третий вариант toMap() принимает ещё один параметр: функцию, возвращающий пустой экземпляр Map, позволяя тем самым указывать конкретный тип контейнера.
К трём методам toMap() есть три дополняющих методов toConcurrentMap(), принимающих теже самые аргументы, но работающие с потокобезопасной ConcurrentMap и реализующие потокобезопасный Collector, что позволяет их использовать с параллельной обработкой потоков, про которую я расскажу в следующей части.
Код примера доступен на github.