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

Расскажу про collector’ы. Это такие объекты с помощью которых, можно привести результат обработки элементов к определенному виду. Существует несколько встроенных, в классе java.util.stream.Collectors. Например, там есть toList, он создает список из элементов потока. Есть и более сложные, например, groupingBy, группирующий элементы по определенному критерию. Мы же напишем свой, который будет группировать строки по начальным буквам.

И так, для начала, ознакомимся с интерфейсом java.util.stream.Collector от которого будет наследоваться:

interface Collector<T,A,R> {
    Supplier<A>          supplier()
    BiConsumer<A,T>      acumulator() 
    BinaryOperator<A>    combiner() 
    Function<A,R>        finisher()
    Set<Characteristics> characteristics()
}

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

  1. supplier создает вспомогательный объект производящий основные манипуляции над элементами.
  2. accumulator функция суммирующая предыдущий результат операций с текущий элементом. Принимает в качестве аргумента вспомогательный объект и следующий элемент потока.
  3. combiner — если стрим работает в многопоточном режиме, то эта функция служит для агрегирования результатов выполненных в разных потоках.
  4. finisher возвращает итоговый результат операций.
  5. characteristics возвращает характеристики реализуемого класса. Существует несколько характеристик, их стоит перечислить:
    • CONCURRENT возможность выполнения в разных потоках, если она указана, то функция combiner должна иметь осмысленную реализацию.
    • UNORDERED указывает, что результат выполнения не сохраняет порядок входящих элементов, т.е. если мы на вход получим отсортированный поток, то на выходе будет не отсортированный, или имеющий измененный порядок.
    • IDENTITY_FINISH указывает что функция finisher может быть пропущена, перед получением итогового результата.

Приступим к реализации. Для того, чтобы было более понятно, начнем с вспомогательного класса.

public final class CustomCollectorBuilder {
    private final Map<Character, Set<String>> set;
 
    public CustomCollectorBuilder() {
        this.set = new HashMap<>();
    }
  
    public void add(String str) {
        Set<String> subSet = getSubset(str);
        
        if (subSet == null) 
            return;
        
        subSet.add(str);
    }
  
    public CustomCollectorBuilder addAll(Map<Character, Set<String>> collection) {
        for (Map.Entry<Character, Set<String>> entry : collection.entrySet()) {
            Set<String> subSet = getSubset(entry.getKey());
            subSet.addAll(entry.getValue());
        }
        return this;
    }
            
    public Map<Character, Set<String>> build() {
        return this.set;
    }
  
    private Set<String> getSubset(String str) {
        if (str == null || str.length() == 0) 
            return null;
        return getSubset(str.charAt(0));
    }
    private Set<String> getSubset(Character key) {
        Set<String> subSet = set.get(key);
        if (subSet == null) {
            subSet = new HashSet<>();
            set.put(key, subSet);
        }
        return subSet;
    }
}

Думаю, здесь все очевидно и трудностей в понимании кода не возникнет.

Теперь сам коллектор. Пааарааам.

public final class CustomCollector implements Collector<String, CustomCollector.CustomCollectorBuilder, Map<Character, Set<String>>> {

        @Override
        public Supplier<CustomCollectorBuilder> supplier() {
            return CustomCollectorBuilder::new;
        }

        @Override
        public BiConsumer<CustomCollectorBuilder, String> accumulator() {
            return CustomCollectorBuilder::add;
        }

        @Override
        public BinaryOperator<CustomCollectorBuilder> combiner() {
            return (first, second) -> first.addAll(second.build());
        }

        @Override
        public Function<CustomCollectorBuilder, Map<Character, Set<String>>> finisher() {
            return CustomCollectorBuilder::build;
        }

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

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

Собственно все. Теперь можно использовать, например, так:

Collection<String> strings = Arrays.asList("apple", "orange", "banana", "pear", "peach");
Map<Character, Set<String>> result = strings
                                            .stream()
                                            .collect(new CustomCollector());

for (Character character : result.keySet())
    System.out.println(result.get(character));

Результат выполнения будет следующий:

[pear, peach]
[apple]
[banana]
[orange]