1. Власні колектори: коли й як їх писати
У Java Stream API для перетворення потоку на колекцію або агрегат використовується інтерфейс Collector. Зазвичай ви користуєтеся готовими колекторами з класу Collectors (toList(), toMap(), groupingBy() тощо), але інколи потрібно щось особливе — тоді можна написати власний Collector.
Collector — це обʼєкт, який описує, як з елементів потоку зібрати підсумковий результат. Він визначає чотири (насправді пʼять) ключових компонентів:
- supplier — створює новий контейнер для збирання елементів (наприклад, новий список або мапу).
- accumulator — додає черговий елемент у контейнер.
- combiner — обʼєднує два контейнери (важливо для паралельних стрімів!).
- finisher — перетворює контейнер на підсумковий результат (наприклад, робить його незмінним або перетворює на інший тип).
- characteristics — набір прапорців, що описують властивості колектора (наприклад, чи підтримує паралелізм, чи змінює тип результату тощо).
Сигнатура:
Collector<T, A, R>
- T — тип елементів потоку,
- A — тип проміжного акумулятора,
- R — тип результату.
2. Приклад: Collector для MultiMap (Map<K, List<V>>)
Припустімо, ви хочете зібрати потік пар Pair<K, V> у Map<K, List<V>> (мульти‑мапу), де кожному ключу відповідає список значень.
Приклад реалізації:
public static <K, V> Collector<Pair<K, V>, ?, Map<K, List<V>>> toMultiMap() {
return Collector.of(
HashMap::new, // supplier
(map, pair) -> map.computeIfAbsent(pair.key(), k -> new ArrayList<>()).add(pair.value()), // accumulator
(map1, map2) -> { // combiner
map2.forEach((k, vList) -> map1.merge(k, vList, (l1, l2) -> { l1.addAll(l2); return l1; }));
return map1;
},
Function.identity(), // finisher
Collector.Characteristics.UNORDERED
);
}
Використання:
List<Pair<String, Integer>> pairs = List.of(
new Pair<>("a", 1), new Pair<>("b", 2), new Pair<>("a", 3)
);
Map<String, List<Integer>> multiMap = pairs.stream().collect(toMultiMap());
// multiMap: {a=[1, 3], b=[2]}
3. Приклад: Collector для топ‑N елементів
Припустімо, ви хочете зібрати потік у список із N найбільших елементів (наприклад, топ‑5 за спаданням).
Реалізація:
public static <T> Collector<T, ?, List<T>> topN(int n, Comparator<? super T> comparator) {
return Collector.of(
() -> new PriorityQueue<>(n, comparator), // supplier
(pq, t) -> {
pq.offer(t);
if (pq.size() > n) pq.poll(); // видаляємо найменший
},
(pq1, pq2) -> {
pq2.forEach(t -> {
pq1.offer(t);
if (pq1.size() > n) pq1.poll();
});
return pq1;
},
pq -> {
List<T> result = new ArrayList<>(pq);
result.sort(comparator.reversed()); // за спаданням
return result;
},
Collector.Characteristics.UNORDERED
);
}
Використання:
List<Integer> top3 = Stream.of(5, 1, 9, 3, 7, 2).collect(topN(3, Comparator.naturalOrder()));
// top3: [9, 7, 5]
4. Коли НЕ варто писати свій Collector
- Якщо завдання можна реалізувати комбінацією стандартних колекторів і downstream‑операцій (groupingBy, mapping, flatMapping, collectingAndThen тощо), краще використовувати їх.
- Власний Collector потрібен лише для справді нестандартних сценаріїв (особлива структура даних, складна агрегація, топ‑N, мульти‑мапи тощо).
- Не варто писати Collector заради Collector — це ускладнює підтримку й тестування.
Приклад:
// Замість власного Collector для Map<K, Set<V>>:
.collect(Collectors.groupingBy(
Pair::key,
Collectors.mapping(Pair::value, Collectors.toSet())
))
5. Власний Spliterator: навіщо й як
Spliterator — це спеціальний інтерфейс для ефективного перебору та поділу колекцій (або інших джерел даних) на частини, особливо для паралельної обробки. На відміну від звичайного ітератора, Spliterator може «ділити» (split) колекцію на незалежні шматки для паралельної обробки.
Ключові методи:
- tryAdvance(Consumer<? super T> action) — обробити наступний елемент.
- trySplit() — спробувати розділити колекцію на дві частини (повертає новий Spliterator для однієї з частин).
- estimateSize() — оцінка кількості елементів, що залишилися.
- characteristics() — бітова маска характеристик (ORDERED, SIZED, SUBSIZED тощо).
trySplit: стратегії поділу
Збалансований поділ — важливий для паралельних стрімів: trySplit має повертати приблизно рівні за розміром частини, щоб потоки були завантажені рівномірно.
Якщо ділити нічого (наприклад, замало елементів) — повертаємо null.
Приклад: Spliterator для читання файлу порціями
Припустімо, у вас є великий файл, і ви хочете обробляти його по 1 000 рядків за раз (порціями), щоб не тримати все в памʼяті.
public class ChunkedLineSpliterator implements Spliterator<List<String>> {
private final BufferedReader reader;
private final int chunkSize;
public ChunkedLineSpliterator(BufferedReader reader, int chunkSize) {
this.reader = reader;
this.chunkSize = chunkSize;
}
@Override
public boolean tryAdvance(Consumer<? super List<String>> action) {
List<String> chunk = new ArrayList<>(chunkSize);
try {
String line;
for (int i = 0; i < chunkSize && (line = reader.readLine()) != null; i++) {
chunk.add(line);
}
if (chunk.isEmpty()) return false;
action.accept(chunk);
return true;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public Spliterator<List<String>> trySplit() {
// Для потокового читання з файлу поділ не має сенсу — повертаємо null
return null;
}
@Override
public long estimateSize() {
return Long.MAX_VALUE; // невідомо заздалегідь
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
Використання:
try (BufferedReader reader = Files.newBufferedReader(Path.of("big.txt"))) {
StreamSupport.stream(new ChunkedLineSpliterator(reader, 1000), false)
.forEach(chunk -> processChunk(chunk));
}
Характеристики Spliterator
- ORDERED — елементи йдуть у визначеному порядку (наприклад, список).
- SIZED — відома точна кількість елементів.
- SUBSIZED — усі Spliterator, отримані через trySplit, теж SIZED.
- IMMUTABLE — джерело не змінюється під час обходу.
- CONCURRENT — джерело підтримує безпечну паралельну модифікацію.
- DISTINCT, SORTED, NONNULL — додаткові властивості.
Важливо: правильно вказувати характеристики — це впливає на оптимізацію стрімів.
6. Приклади
- Читання файлу порціями — дає змогу обробляти великі файли частинами, не завантажуючи все в памʼять.
- Розбір без зайвих виділень памʼяті — якщо ви розбираєте потік байтів/символів і хочете мінімізувати створення тимчасових обʼєктів, можна реалізувати Spliterator, який віддає «вікна» або «зрізи» вихідного масиву.
Приклад: Spliterator для розбору CSV рядок за рядком
public class CsvLineSpliterator implements Spliterator<String[]> {
private final BufferedReader reader;
public CsvLineSpliterator(BufferedReader reader) {
this.reader = reader;
}
@Override
public boolean tryAdvance(Consumer<? super String[]> action) {
try {
String line = reader.readLine();
if (line == null) return false;
action.accept(line.split(","));
return true;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public Spliterator<String[]> trySplit() {
return null; // послідовний розбір
}
@Override
public long estimateSize() {
return Long.MAX_VALUE;
}
@Override
public int characteristics() {
return ORDERED | NONNULL;
}
}
7. Інтеграція з parallel() — як зробити безпечно
- Якщо ваш Spliterator підтримує паралельний поділ (trySplit повертає не null), і характеристики містять SIZED/SUBSIZED, то Stream API зможе ефективно розпаралелити обробку.
- Для потокових джерел (файли, сокети) зазвичай поділ не підтримується — використовуйте послідовні стріми.
- Для колекцій і масивів — реалізуйте збалансований поділ (наприклад, діліть масив навпіл).
Приклад: Spliterator для масиву
public class ArraySpliterator<T> implements Spliterator<T> {
private final T[] array;
private int start, end;
public ArraySpliterator(T[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (start < end) {
action.accept(array[start++]);
return true;
}
return false;
}
@Override
public Spliterator<T> trySplit() {
int mid = (start + end) >>> 1;
if (mid == start) return null;
ArraySpliterator<T> split = new ArraySpliterator<>(array, start, mid);
start = mid;
return split;
}
@Override
public long estimateSize() {
return end - start;
}
@Override
public int characteristics() {
return ORDERED | SIZED | SUBSIZED | IMMUTABLE;
}
}
Використання:
String[] arr = {"a", "b", "c", "d"};
StreamSupport.stream(new ArraySpliterator<>(arr, 0, arr.length), true)
.forEach(System.out::println);
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ