Статьи

Сборщики. Часть 2. Предоставленные сборщики и демонстрация потоков Java 8

Сегодня мы продолжим с того места, где остановилась последняя статья. В этом мы рассмотрели коллекторы, в частности операции сокращения и короткого замыкания. Сегодня мы рассмотрим функцию сбора, а затем закончим более содержательным примером, демонстрирующим мощь потоковой передачи Java 8.

Сборщик собирает результаты и завершает поток. Это может также сделать сокращения. Класс Collectors предоставляет ряд полезных коллекционеров, готовых к использованию. Мы начнем с рассмотрения тех, которые выполняют те же операции, что и в предыдущей статье (count, sum, medium, max, min и summaryStatistics).

public class Collectors
{
  public static void main(String args[])
  {
    Integer[] numbersArray = new Integer[] { 1, 2, 3, 4, 5 };
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(Collectors.counting()));
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(
                    Collectors.summingInt((Integer x) -> x)));
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(
                    Collectors.averagingInt((Integer x) -> x)));
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(
                    Collectors.maxBy(Integer::compare)).get());
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(
                    Collectors.minBy(Integer::compare)).get());
 
    System.out.println(Arrays.stream(numbersArray)
                             .collect(
                    Collectors.summarizingInt((Integer x) -> x)));
  }
}

Обратите внимание, что мы передаем Integer, а не int, поэтому нам нужно передать функцию ToIntFunction для сборщиков суммы, среднего и суммирования. ToIntFunction применяет функцию к типу и возвращает int. Учитывая, что поток имеет форму объекта, нам нужно помочь компилятору и указать, что параметр для лямбды действительно является целым числом. Авто-распаковка сделает все остальное.

Пока все относительно просто, но теперь становится немного сложнее. Проблема, с которой мы сталкиваемся с остальной частью API, состоит в том, что функции имеют несколько перегруженных версий, и в спецификации есть много обобщений, затрудняющих чтение.

Возьмите этот раздел из раздела, который не самый сложный для понимания:

Collector<T, ?, Map<Boolean, D>> 
   partitioningBy(Predicate<? super T> predicate,
                  Collector<? super T, A, D> downstream) {

Мы можем ясно видеть, что partitioningBy принимает предикат и коллектор и возвращает коллектор, который может использовать функция сбора. Проблема заключается в том, чтобы выяснить, что это за типы, даже с помощью документации. К счастью, я приведу несколько примеров, которые помогут легко начать.

Когда я исследовал, я сделал простые примеры, подобные тем, которые я представляю:

  • Попробуйте самую простую версию с наименьшими параметрами. Обычно это будет легче понять.
  • Как только вы получите этот рабочий взгляд на реализацию. Часто более простой будет передавать свои собственные параметры «по умолчанию» в более сложную версию, давая понять, что это ожидает.
  • Попробуйте более сложный, но замените параметр ‘default’ чем-то другим. Посмотри что получится. Компилируется ли и выполняется ли то, что ожидается, если нет, то почему?
  • Попробуйте пройтись по коду библиотеки и посмотрите, как код использует параметры.

Надеюсь, с примерами я легко справлюсь с этим, но вы можете узнать намного больше, экспериментируя самостоятельно.

Собирать звуки, как то, для чего предназначены коллекции, и на самом деле есть несколько способов создания коллекций с результатами, полученными из потока. Мы можем создать общий список, общий набор, общую карту или мы можем создать определенный тип коллекции или карты:

public class CollectInCollections
{
  public static void main(String args[])
  {
    Character[] chars = new Character[]
                        { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
 
    // First a list
    List<Character> l = Arrays.stream(chars)
                              .collect(Collectors.toList());
 
    System.out.println(l);
 
    // toList gives us a generic list (code creates an ArrayList)
    // Let's get a linked list
    List<Character> ll = Arrays.stream(chars)
                               .collect(
                       Collectors.toCollection(LinkedList::new));
 
    System.out.println(ll);
 
    // toSet gives us a generic set (code creates a HashSet)
    Set<Character> s = Arrays.stream(chars)
                                   .collect(Collectors.toSet());
 
    System.out.println(s);
 
    // and now a generic map (code creates a HashMap)
    Map<Character, Character> m = 
                        Arrays.stream(chars).collect(
                  Collectors.toMap((Character k) -> 
                                       Character.toUpperCase(k),
                                   Function.identity()));
 
    System.out.println(m);
 
    // What happens if keys clash?
    try
    {
      Arrays.stream(chars).collect(
                  Collectors.toMap((Character k) -> 'a', 
                                   Function.identity()));
    }
    catch (IllegalStateException e)
    {
      System.out.println("Caught duplicate key");
    }
 
    // Let's provide a function to resolve this
    // we'll keep the first
    Map<Character, Character> m2 =
                        Arrays.stream(chars).collect(
               Collectors.toMap((Character k) -> 'a',
                                Function.identity(),
                               (v1, v2) -> v1));
 
    System.out.println(m2);
 
    // If we return null from our merge function,
    // the latest is kept
    Map<Character, Character> m3 =
                        Arrays.stream(chars).collect(
                Collectors.toMap((Character k) -> 'a',
                                 Function.identity(),
                                 (v1, v2) -> null));
 
    System.out.println(m3);
 
    // We can also request a different type of map
    Map<Character, Character> m4 =
                        Arrays.stream(chars).collect(
                Collectors.toMap(
                   (Character k) -> Character.toUpperCase(k),
                                 Function.identity(),
                                 (v1, v2) -> v1,
                                 TreeMap::new));
 
    System.out.println(m4);
  }
}

Заметки:

  • Map поставляется с несколькими перегруженными версиями, которые помогают нам создавать ключи для наших ценностей и решать конфликты. Как мы знаем, если мы пытаемся поместить пару ключ-значение в карту, где ключ уже существует, мы перезаписываем существующее значение.
    Первые два параметра отображают наше значение на ключ и значение соответственно. Для одного из них (часто это значение) мы не хотим ничего менять, поэтому передача Function.identity () (или лямбда v -> v) сохранит значение таким же.
  • По умолчанию toMap использует throwingMerger (), который генерирует исключение IllegalStateException, если два ключа конфликтуют. Мы можем увидеть это в действии, если заставить ключи использовать одно значение. Если мы указываем третий параметр, мы можем указать BiFunction (два параметра, один выход), чтобы вместо этого иметь дело с конфликтом. Если результат этой функции нулевой, последний сохраняется.
  • Если мы указываем четвертый параметр toMap, мы можем указать конкретный тип карты.
  • В документации не указывается, какой тип коллекции возвращается для toList (), toSet () и версий toMap () с 2 и 3 параметрами. Идея состоит в том, что функциональное программирование предоставляет богатый набор функций для нескольких коллекций, а не для множества коллекций. Поэтому мы не должны делать какие-либо предположения и, где это важно, использовать toCollection или версию toMap с четырьмя параметрами, чтобы быть уверенными, или преобразовывать позже.
  • Существуют также параллельные версии toMap, называемые toConcurrentMap, которые могут обеспечить более высокую производительность в параллельных потоках, когда мы не заботимся о порядке.

Теперь перейдем к остальным операциям, которые предназначены для соединения строк, группировки и разбиения:

class JoiningGroupingAndPartitioning
{
  public static void main(String args[])
  {
    Character[] chars =
           new Character[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g' };
 
    // Join them all together
    System.out.println(
           Arrays.stream(chars).map(x -> x.toString())
                               .collect(Collectors.joining()));
 
    // Join with a ,
    System.out.println(
           Arrays.stream(chars).map(x -> x.toString())
                            .collect(Collectors.joining(",")));
 
    // Join with a , and surround the whole thing with []
    System.out.println(Arrays.stream(chars)
                             .map(x -> x.toString())
                             .collect(
                           Collectors.joining(",", "[", "]")));
 
    // Group into two groups
    Map<String, List<Character>> group1 =
           Arrays.stream(chars).collect(
                           Collectors.groupingBy(
          (Character x) -> x < 'd' ? "Before_D" : "D_Onward"));
 
    System.out.println(group1);
 
    // As before, but group values with like keys in a set
    Map<String, Set<Character>> group2 =
           Arrays.stream(chars).collect(
                           Collectors.groupingBy(
          (Character x) -> x < 'd' ? "Before_D" : "D_Onward",
                                         Collectors.toSet()));
 
    System.out.println(group2);
 
    // Put the whole grouping structure in a TreeMap
    Map<String, Set<Character>> group3 =
           Arrays.stream(chars).collect(
                           Collectors.groupingBy(
          (Character x) -> x < 'd' ? "Before_D" : "D_Onward",
                             TreeMap::new,
                             Collectors.toSet()));
 
    System.out.println(group3);
 
    // Partition into two lists
    Map<Boolean, List<Character>> partition1 =
           Arrays.stream(chars).collect(
                            Collectors.partitioningBy(
                                 (Character x) -> x < 'd'));
 
    System.out.println(partition1);
 
    // Partition into two sets
    Map<Boolean, Set<Character>> partition2 =
           Arrays.stream(chars).collect(
                            Collectors.partitioningBy(
                                 (Character x) -> x < 'd',
                                       Collectors.toSet()));
 
    System.out.println(partition2);
  }
}

Первый тип коллектора в этом примере — объединяющий коллектор. Это используется для объединения строк (в примере мы отображаем символы в строки). Версия без параметров просто объединяет строки, а версия с одним параметром позволяет нам указать строку, которая будет помещена между любыми строками, к которым мы присоединяемся. Версия с тремя параметрами также позволяет нам указать начальную и конечную строку, чтобы обвести собранную строку. Это может быть полезно для создания отладочной или удобочитаемой информации

Второй — это группировка по коллектору. Это позволяет нам группировать элементы, имеющие одинаковую классификацию, в карту. Для классификации мы передаем функцию классификации, которая берет элемент и возвращает тип, который мы используем для ключей карты. GroupingBy имеет три версии: первая просто берет функцию классификации, вторая позволяет нам указать тип коллекции для значений с дублирующимися ключами (по умолчанию это общий список). Третий такой же, как второй, но также имеет другой параметр (2-й), который позволяет нам указать тип карты (в отличие от обычного). Мы передаем конструктор, используя :: нотацию, и конструктор обозначается функцией ‘new’.

Третий набор — это сборщик разделов. Это похоже на groupingBy, но вместо передачи функции для указания ключа мы передаем предикат, который определяет ключ (true или false) для каждого значения. В версии с одним параметром значения, имеющие один и тот же ключ, организованы в общий список, где, как и в версии с двумя параметрами, мы можем указать тип коллекции для значений.

Итак, мы видели множество примеров стиля Hello World, но я думаю, что я должен вам кое-что более реалистичное. Давай смоделируем клуб. Каждый член клуба имеет членство, которое отслеживает их имя, возраст и пол. Также возможно зарегистрировать двух участников как пару. Существует три типа членства: младшее членство до 18 лет, старшее членство до 60 лет и старше, в противном случае вступление для взрослых.

Нам поручено следующее:

  • Получить имена всех участников в виде строки, разделенной
  • Найти средний возраст (с округлением до ближайшего целого числа)
  • Разделите список участников на всех мужчин и женщин.
  • Классифицируйте участников в зависимости от их типов членства
  • Получить все пары в виде списка

Это пример разумного размера, который может легко пройти домашнюю работу / экзамен по программированию в университете или более длительное программирование на собеседовании. Возможность выбить такой код из описания, скажем, за 30 минут, очень поможет вам.

Давайте подумаем, как подойти к этому. Прежде чем вы начнете перебирать коллекции, используя ‘for’ и имеющие много изменяемого состояния, мы решаем без какого-либо изменяемого состояния. Ну кроме как в одном месте для удобства (регистрация пар). Данные не будут подвергаться дальнейшему мутированию в этом примере, если мы все равно настроим их. Это будет означать, что мы с меньшей вероятностью будем делать ошибки из-за мутаций, происходящих некорректно, иметь одноразовые ошибки, и мы можем делегировать больше того, как сделать это механизмом, библиотекам Java, оставляя нас сосредоточиться на том, чтобы организовать это для решения проблемы. Это будут наши первые шаги в мышлении, как у функционального программиста, но не слишком далеко от того, что понимает программист OO.

Сначала мы собираемся создать тип ClubMember:

public class ClubMember
{
  private String name;
  private boolean male;
  private int age;
  private ClubMember partner;
 
  public ClubMember(String name, boolean male, int age)
  {
    this.name = name;
    this.male = male;
    this.age = age;
  }
 
  public String getName()
  {
    return this.name;
  }
 
  public int getAge()
  {
    return age;
  }
 
  public ClubMember getPartner()
  {
    return partner;
  }
 
  @Override
  public String toString()
  {
    return name;
  }
}

Все довольно просто и не выглядит неуместно в объектно-ориентированном коде. У нас есть конструктор, несколько методов получения и метод toString. У нас нет метода получения isMale (), но мы узнаем почему через секунду. Хотя есть много котельной плиты, так что, если котел заставит вашу кровь закипеть [мне позволят остроумие], то вы можете взглянуть на  Project Lombok .

Мы также собираемся как-то зарегистрировать пару, и давайте предположим, что этот клуб осуждает многоженство. Мы будем использовать статический метод, чтобы сделать это. Почему статический? Статический метод может установить поле партнера обоих членов в одном методе. Если бы это был обычный метод, мы бы либо полагались на пользователя нашего класса, чтобы вызывать его для обоих членов — что они могли бы забыть сделать. Что, если мы создали метод регистрации члена, вызвав внутри него метод register переданного члена? В этом случае нам нужно будет решить, как мы собираемся остановить пинг-понг бесконечного цикла между двумя экземплярами. В конце концов, этот метод снова вызовет первый.

Итак, вот статический метод:

public static void registerPartners(ClubMember cm1,
                                    ClubMember cm2)
{
  cm1.partner = cm2;
  cm2.partner = cm1;
}

Я также хочу добавить два статических поля. Вместо геттера для isMale и теста для партнера, являющегося участником, я хочу вместо этого использовать предикаты, чтобы с ними можно было выполнять некоторые функциональные действия:

public static final Predicate<ClubMember> isMale =
                                               m -> m.male;
 
public static final Predicate<ClubMember> isPartnerMember =
                                    m -> m.partner != null;

Мы закончили с классом ClubMember.

Было бы полезно иметь тип Tuple2 для управления обоими возрастными диапазонами для наших членов и пар. К сожалению, стандартная Java пока не имеет Tuple2, поэтому мы сделаем нашу собственную (  для такой функциональности лучше использовать библиотеку, подобную  Guava, было бы лучше не изобретать велосипед):

public class Tuple2<T1, T2>
{
  public T1 t1;
  public T2 t2;
 
  public Tuple2(T1 first, T2 second)
  {
    t1 = first;
    t2 = second;
  }
 
  @Override
  public String toString()
  {
    return "(" + t1 + ", " + t2 + " " + ")";
  }
}

Пара Tuple2 имеет два типа ClubMember, возрастной диапазон Tuple2 имеет два типа Integer. Поскольку мы, вероятно, будем использовать два целочисленных типа Tuple2 в другом месте, мы определим IntegerRange специально:

public class IntegerRange extends Tuple2<Integer, Integer>
{
  public IntegerRange(Integer start, Integer end)
  {
    super(start, end);
  }
 
  public Integer getStart()
  {
    return t1;
  }
 
  public Integer getEnd()
  {
    return t2;
  }
 
  public final Predicate<Integer> inRange = 
                                i -> i >= t1 && i < t2;
}

Это также позволяет нам определить другой предикат, чтобы определить, находится ли значение в диапазоне. Мы примем конечную точку как не включающую.

Теперь перейдем к нашему основному классу:

public class ClubMembers
{
  private List<ClubMember> members;
 
  private static final IntegerRange juniors =
                              new IntegerRange(0, 18);
  private static final IntegerRange adult =
                              new IntegerRange(18, 60);
  private static final IntegerRange seniors = 
               new IntegerRange(60, Integer.MAX_VALUE);
 
  private static final String juniorMembership =
                                   "Junior Membership";
  private static final String adultMembership =
                                    "Adult Membership";
  private static final String seniorMembership =
                                   "Senior Membership";
 
  public ClubMembers(List<ClubMember> members)
  {
    this.members = members;
  }
}

Начнем с поля для хранения списка участников и конструктора для его инициализации. Мы также определим возрастные диапазоны для наших типов членства и некоторые строки для их представления. Мы добавим весь остальной код в этот класс.

Итак, первое, что мы хотим сделать, это собрать всех участников. Давайте присоединиться к ним вместе с запятой. Звучит ли это как работа для коллекционеров? Вы держите пари!

public String getAllMembers()
{
return members.stream().map(m -> m.getName())
.collect(Collectors.joining(", "));
}

Это было просто. Мы берем участника, сопоставляем его только с его именем, а затем собираем с присоединением. Крутая вещь в том, что нам не нужно беспокоиться о том, добавлять ли запятую или нет, так как о нас позаботились. Больше не нужно определять изменяемые переменные состояния, такие как «first», чтобы не ставить запятую перед именем.

Теперь о среднем возрасте. Снова просто, так как мы видели это раньше:

public OptionalDouble getAverageAge()
{
return members.stream().map(m -> m.getAge())
.mapToInt((Integer x) -> x)
.average();
}

Далее нам нужно найти всех мужчин и женщин. Хм, две группы, это звучит как работа для Collectors.partitioningBy.

Примечание. Одна вещь, которую мы должны делать при программировании в функциональном стиле, это стараться использовать как можно больше. Вместо того, чтобы класс был шаблоном, который мы специализируем, мы используем функцию [часто в качестве параметра], чтобы придать ему особое поведение.

Давайте разделим членов по произвольному предикату:

private Map<Boolean, List<ClubMember>> partitionMembers(
Predicate<ClubMember> p)
{
return members.stream().collect(
Collectors.partitioningBy(p));
}

Теперь мы можем специализировать эту функцию, передав соответствующий предикат, удобно определенный в ClubMember:

public Map<Boolean, List<ClubMember>> getMembersByGender()
{
return partitionMembers(ClubMember.isMale);
}

Итак, теперь у нас есть две группы: ложная группа женская, истинная группа мужская.

Как насчет нашего членства? На этот раз у нас есть три группы, и поэтому Collectors.partitioningBy не очень подходит, поэтому давайте использовать Collectors.groupingBy. Опять же, давайте воспользуемся тем же приемом: универсальной функцией классификации, которой мы передаем классификатор. Этот классификатор — просто функция, принимающая ClubMember и классифицирующая по группам произвольного типа:

private <T> Map<T, List<ClubMember>>
classifyMembers(Function<ClubMember, T> classifier)
{
return members.stream().collect(
Collectors.groupingBy(classifier));
}

Нам нужна эта классификационная функция, чтобы взять ClubMember и вернуть подробности их типа членства в виде строки:

private static final Function<ClubMember, String>
resolveMembershipType =
m -> juniors.inRange.test(m.getAge()) ? juniorMembership :
adult.inRange.test(m.getAge()) ? adultMembership :
seniorMembership;

Мы определили строки, мы определили возрастные диапазоны, мы определили тест inRange. Это был просто случай, когда все сложилось. Теперь мы просто специализируемся на classifyMembers:

private Map<String, List<ClubMember>> classifyMemberships()
{
return classifyMembers(resolveMembershipType);
}

Видите, как легко это может быть?

Последняя пара членов. Что значит быть членом пары?

Что ж, мы ожидаем, что предикат isPartnerMember, определенный в ClubMember, вернет true. Это фильтр, который нам нужен, чтобы проверить это.

Теперь, если мы проведем через всех наших участников с партнерами, мы вернем пары дважды: один раз для personA, personB и один раз для personB, personA. Это может быть то, что мы могли бы хотеть, но в этом случае это не так. Давайте сделаем предположение, что оба партнера имеют разные имена (чтобы быть надежными, мы, вероятно, должны иметь идентификаторы участников для такого рода вещей). Нам нужно произвольно выбрать одного из партнеров в качестве нашего первого партнера, поэтому давайте использовать его, если имя personA по сравнению с именем personB возвращает <0. String имеет для нас функцию сравнения, поэтому мы можем это сделать. Для этого понадобится еще один фильтр.

Мы также хотим вернуть пару, а не только одного из участников, поэтому давайте воспользуемся Tuple2 для этого. Для этого понадобится карта.

Наконец, нам нужен список пар, поэтому Collectors.toList () прекрасно справится с этой задачей:

public List<Tuple2<ClubMember, ClubMember>> getCouples()
{
return members.stream()
.filter(ClubMember.isPartnerMember)
.filter(m -> m.getName().
compareTo(m.getPartner().getName()) < 0)
.map(m -> new Tuple2<>(m, m.getPartner()))
.collect(Collectors.toList());
}

Я надеюсь, что это заставляет вас улыбаться — немного подумать о проблеме, и мы можем легко решить ее, собрав блоки.

Давайте напишем основной метод драйвера в ClubMembers. Чтобы упростить проверку, мы будем называть пары по именам, а всех по именам:

public static void main(String args[])
{
ClubMember cm1 = new ClubMember("Johnny", true, 13);
ClubMember cm2 = new ClubMember("Jenny", false, 9);
ClubMember cm3 = new ClubMember("Dave", true, 21);
ClubMember cm4 = new ClubMember("Penny", false, 28);
ClubMember cm5 = new ClubMember("Mrs. Smith", false, 36);
ClubMember cm6 = new ClubMember("Mr. Smith", true, 45);
ClubMember cm7 = new ClubMember("Mr. Watts", true, 59);
ClubMember cm8 = new ClubMember("Mrs. Watts", false, 60);
ClubMember cm9 = new ClubMember("Bill", true, 68);
ClubMember.registerPartners(cm5, cm6);
ClubMember.registerPartners(cm7, cm8);
ClubMember[] membersArray = new ClubMember[]
{ cm1, cm2, cm3, cm4, cm5, cm6, cm7, cm8, cm9 };
ClubMembers members = new ClubMembers(
Arrays.asList(membersArray));
System.out.println("Members: " + members.getAllMembers());
System.out.println("Average age: " +
new Double(members.getAverageAge().orElse(0))
.intValue());
System.out.println("Membership by gender (true is male): " +
members.getMembersByGender());
System.out.println("Memberships: " +
members.classifyMemberships());
System.out.println("Couples: " + members.getCouples());
}

Давайте запустим это:

Members: Johnny, Jenny, Dave, Penny, Mrs. Smith, Mr. Smith, Mr. Watts, Mrs. Watts, Bill
Average age: 37
Members are male: {false=[Jenny, Penny, Mrs. Smith, Mrs. Watts], true=[Johnny, Dave, Mr. Smith, Mr. Watts, Bill]}
Memberships: {Senior Membership=[Mrs. Watts, Bill], Junior Membership=[Johnny, Jenny], Adult Membership=[Dave, Penny, Mrs. Smith, Mr. Smith, Mr. Watts]}
Couples: [(Mr. Smith, Mrs. Smith ), (Mr. Watts, Mrs. Watts )]

Что вы можете заметить, когда пишете такой код самостоятельно, так это то, что как только вы справляетесь со всеми ошибками компиляции, он работает впервые из коробки. Это очень отличается от того, чтобы делать это в процедурном стиле с использованием итераторов, где вы часто сталкиваетесь с ошибками, нулевыми указателями и множеством других проблем. Исключая возможность их создания, мы можем писать код быстрее и быть более продуктивным. Java 8 Streams и поставляемые сборщики облегчают нам жизнь.