Статьи

Введение в написание пользовательских коллекторов в Java 8

Java 8 представила концепцию коллекционеров. Большую часть времени мы едва используем фабричные методы из  Collectors класса, например  collect(toList())toSet() или, может быть, что-то более причудливое, например  counting() или  groupingBy(). Не многие из нас на самом деле удосуживаются посмотреть, как коллекторы определены и реализованы. Давайте начнем с анализа того, что на  Collector<T, A, R> самом деле и как это работает.

Collector<T, A, R> работает как « приемник » для потоков — поток помещает элементы (один за другим) в коллектор, который должен в конечном итоге создать некоторое « собранное » значение. В большинстве случаев это означает создание коллекции (например  toList()) путем накопления элементов или уменьшения потока до чего-то меньшего (например,  counting() сборщик, который едва ли считает элементы). Каждый коллекционер принимает предметы типа T и производит агрегированное (накопленное) значение типа  R (например  R = List<T>). Универсальный тип  A просто определяет тип промежуточной изменяемой структуры данных, которую мы собираемся использовать для накопления элементов типа  T за это время. Тип  A может, но не должен совпадать с  R — простыми словами, изменяемая структура данных, которую мы используем для сбора элементов из входных данных,  Stream<T> может отличаться от фактической выходной коллекции / значения. При этом каждый сборщик должен реализовывать следующие методы:

interface Collector<T,A,R> {
    Supplier<A>          supplier()
    BiConsumer<A,T>      acumulator() 
    BinaryOperator<A>    combiner() 
    Function<A,R>        finisher()
    Set<Characteristics> characteristics()
} 
  • supplier() возвращает функцию, которая создает экземпляр изменяемой структуры данных, которую мы будем использовать для накопления элементов ввода типа  T.
  • accumulator() возвращает функцию, которая возьмет аккумулятор и один элемент типа  T, мутирующий аккумулятор.
  • combiner() используется для объединения двух аккумуляторов в один. Он используется, когда сборщик выполняется параллельно, Stream<T> сначала разделяя входные данные  и собирая их независимо.
  • finisher() берет аккумулятор  A и превращает его в значение результата, например, коллекцию, типа  R. Все это звучит довольно абстрактно, поэтому давайте сделаем простой пример.

Очевидно, что в Java 8 нет встроенного сборщика для  ImmutableSet<T> Guava. Однако создать его очень просто. Помните, что для итеративной сборки ImmutableSet мы используем  ImmutableSet.Builder<T> — это будет наш аккумулятор.

import com.google.common.collect.ImmutableSet;

public class ImmutableSetCollector<T> 
        implements Collector<T, ImmutableSet.Builder<T>, ImmutableSet<T>> {
    @Override
    public Supplier<ImmutableSet.Builder<T>> supplier() {
        return ImmutableSet::builder;
    }

    @Override
    public BiConsumer<ImmutableSet.Builder<T>, T> accumulator() {
        return (builder, t) -> builder.add(t);
    }

    @Override
    public BinaryOperator<ImmutableSet.Builder<T>> combiner() {
        return (left, right) -> {
            left.addAll(right.build());
            return left;
        };
    }

    @Override
    public Function<ImmutableSet.Builder<T>, ImmutableSet<T>> finisher() {
        return ImmutableSet.Builder::build;
    }

    @Override
    public Set<Characteristics> characteristics() {
        return EnumSet.of(Characteristics.UNORDERED);
    }
}

Прежде всего, внимательно посмотрите на общие типы. Наш  ImmutableSetCollector принимает входные элементы типа  T, поэтому он работает для любого  Stream<T>. В конце концов он будет производить ImmutableSet<T> — как и ожидалось. ImmutableSet.Builder<T> будет нашей промежуточной структурой данных. 

  • supplier() возвращает функцию, которая создает новый  ImmutableSet.Builder<T>. Если вы не знакомы с лямбдами в Java 8,  ImmutableSet::builder это сокращение от  () -> ImmutableSet.builder().
  • accumulator() возвращает функцию, которая принимает  builder и один элемент типа  T. Это просто добавляет указанный элемент к строителю.
  • combiner() возвращает функцию, которая примет двух компоновщиков и превратит их в одного, добавив все элементы из одного из них в другой — и вернув последний. Наконец,  finisher() возвращает функцию, которая превратится  ImmutableSet.Builder<T>в  ImmutableSet<T>. Опять же, это сокращенная синтаксис:  builder -> builder.build().
  • И последнее, но не менее важное,  characteristics() сообщает JDK о возможностях нашего коллекционера Например, если бы  ImmutableSet.Builder<T> был потокобезопасным (это не так), мы могли бы также сказать  Characteristics.CONCURRENT .

Теперь мы можем использовать наш собственный сборщик везде  collect()

final ImmutableSet<Integer> set = Arrays
        .asList(1, 2, 3, 4)
        .stream()
        .collect(new ImmutableSetCollector<>());

Однако создание нового экземпляра немного многословно, поэтому я предлагаю создать статический метод фабрики, аналогичный тому, что делает JDK:

public class ImmutableSetCollector<T> implements Collector<T, ImmutableSet.Builder<T>, ImmutableSet<T>> {

    //...

    public static <T> Collector<T, ?, ImmutableSet<T>> toImmutableSet() {
        return new ImmutableSetCollector<>();
    }
}

Теперь мы можем воспользоваться всеми преимуществами нашего самодельного коллектору просто набрав: collect(toImmutableSet()). Во второй части мы научимся писать более сложные и полезные сборщики.