Пишем свой Spliterator
Многие из вас уже попробовали на вкус Stream API — потоки Java 8. Наверняка у некоторых возникло желание не только пользоваться готовыми потоками от коллекций, массивов, случайных чисел, но и создать какой-то принципиально новый поток. Для этого вам потребуется написать свой сплитератор. Spliterator — это начинка потока, публичная часть его внутренней логики. В этой статье я расскажу, как и зачем я писал сплитератор.Что же такое сплитераторСплитератор — это интерфейс, который содержит 8 методов, причём четыре из них уже имеют реализацию по умолчанию. Оставшиеся методы — это tryAdvance, trySplit, estimateSize и characteristics. Существуют также специальные модификации сплитератора для примитивных типов int, long и double: они добавляют несколько дополнительных методов, чтобы избежать боксинга. Сплитератор похож на обычный итератор. Основное отличие — умение разделиться (split) на две части — лежит в основе параллельной работы потоков. Также в целях оптимизации сплитератор имеет ряд флагов-характеристик и может сообщить точно или приблизительно свой размер. Наконец, сплитератор никогда не модифицирует источник данных: у него нет метода remove как у итератора. Рассмотрим методы подробнее: tryAdvance (Consumer) — объединение методов итератора hasNext () и next (). Если у сплитератора есть следующий элемент, он должен вызвать переданную функцию с этим элементом и вернуть true, иначе функцию не вызывать и вернуть false.
trySplit () — попытаться поделиться надвое. Метод возвращает новый сплитератор, который будет пробегать по первой половине исходного набора данных, при этом сам текущий сплитератор перепрыгивает на вторую половину. Лучше всего, когда половины примерно равны, но это не обязательно. Особенно неравномерно делятся сплитераторы с бесконечным набором данных: после деления один из сплитераторов обрабатывает конечный объём, а второй остаётся бесконечным. Метод trySplit () имеет законное право не делиться и вернуть null (не случайно там try). Обычно это делается, когда в текущем сплитераторе осталось мало данных (скажем, только один элемент).
characteristics () — возвращает битовую маску характеристик сплитератора. Их на данный момент восемь: ORDERED — если порядок данных имеет значение. К примеру, сплитератор от HashSet не имеет этой характеристики, потому что порядок данных в HashSet зависит от реализации. Отсутствие этой характеристики автоматически переведёт параллельный поток в неупорядоченный режим, благодаря чему он сможет работать быстрее. Раз в источнике данных порядка не было, то и дальше можно за ним не следить.
DISTINCT — если элементы заведомо уникальны. Любой Set или поток после операции distinct () создаёт сплитератор с такой характеристикой. Например, операция distinct () на потоке из Set выполняться не будет вообще и, стало быть, времени лишнего не займёт.
SORTED — если элементы сортированы. В таком случае обязательно вернуть и ORDERED и переопределить метод getComparator (), вернув компаратор сортировки или null для «естественного порядка». Сортированные коллекции (например, TreeSet) создают сплитератор с такой характеристикой, и с ней потоковая операция sorted () может быть пропущена.
SIZED — если известно точное количество элементов сплитератора. Такую характеристику возвращают сплитераторы всех коллекций. После некоторых потоковых операций (например, map () или sorted ()) она сохраняется, а после других (скажем, filter () или distinct ()) — теряется. Она полезна для сортировки или, скажем, операции toArray (): можно заранее выделить массив нужного размера, а не гадать, сколько элементов понадобится.
SUBSIZED — если известно, что все дочерние сплитераторы также будут знать свой размер. Эту характеристику возвращает сплитератор от ArrayList, потому что при делении он просто разбивает диапазон значений на два диапазона известной длины. А вот HashSet её не вернёт, потому что он разбивает хэш-таблицу, для которой не известно, сколько содержится элементов в каждой половине. Соответственно дочерние сплитераторы уже не будут возвращать и SIZED.
NONNULL — если известно, что среди элементов нет null. Эту характеристику возвращает, например, сплитератор, созданный ConcurrentSkipListSet: в эту структуру данных null поместить нельзя. Также её возвращают все сплитераторы, созданные на примитивных типах.
IMMUTABLE — если известно, что источник данных в процессе обхода заведомо не может измениться. Сплитераторы от обычных коллекций такую характеристику не возвращают, но её выдаёт, например, сплитератор от Collections.singletonList (), потому что этот список изменить нельзя.
CONCURRENT — если известно, что сплитератор остаётся рабочим после любых изменений источника. Такую характеристику сообщают сплитераторы коллекций из java.util.concurrent. Если сплитератор не имеет характеристик IMMUTABLE и CONCURRENT, то хорошо бы заставить его работать в fail-fast режиме, чтобы он кидал ConcurrentModificationException, если заметит, что источник изменился.
Насколько мне известно, последние три характеристики сейчас потоками никак не используются (в том числе в коде Java 9).
estimateSize () — метод должен возвращать количество оставшихся элементов для SIZED-сплитераторов и как можно более точную оценку в остальных случаях. Например, если мы создадим сплитератор от HashSet и разделим его с помощью trySplit (), estimateSize () будет возвращать половину от исходного размера коллекции, хотя реальное количество элементов в половине хэш-таблицы может отличаться. Если элементов бесконечное количество или посчитать их слишком трудозатратно, можно вернуть Long.MAX_VALUE.
Создать поток по имеющемуся сплитератору очень легко — надо вызвать StreamSupport.stream ().Когда сплитератор писать не надо Главное понимать, что сам по себе сплитератор вам не нужен, вам нужен поток. Если вы можете создать поток, используя существующий функционал, то стоит сделать именно так. К примеру, хотите вы подружить потоки с XML DOM и создавать поток по NodeList. Стандартного такого метода нет, но его легко написать без дополнительных сплитераторов:
public class XmlStream {
static Stream
Если вы реализуете стандартный интерфейс Iterable или Collection, то вам совершенно бесплатно выдаётся default-метод spliterator (). Стоит, конечно, посмотреть, нельзя ли его улучшить. Так или иначе, свои сплитераторы требуется писать весьма редко. Это может пригодиться, если вы разрабатываете свою структуру данных (например, коллекцию на примитивах, как делает leventov).
И всё-таки напишем
Мы напишем новый сплитератор для решения такой задачи: по заданному потоку создать поток пар из соседних значений исходного потока. Так как общепринятого типа для представления пары значений в Java нет и возможных вариантов слишком много (использовать массив из двух значений, список из двух значений, Map.Entry с одинаковым типом ключа и значения и т. д.), мы отдадим это на откуп пользователю: пусть сам решает, как объединить два значения. То есть мы хотим создать метод с такой сигнатурой:
public static
public static
Разобравшись с этим, напишем конструкторы:
class PairSpliterator
public PairSpliterator (BiFunction
public PairSpliterator (BiFunction
@Override
public boolean tryAdvance (Consumer super R> action) {
if (! hasPrev) { // мы в самом начале: считаем один элемент из источника
if (! source.tryAdvance (this: setCur)) {
return false; // источник вообще пустой — выходим
}
hasPrev = true;
}
T prev = cur; // запоминаем предыдущий элемент
if (! source.tryAdvance (this: setCur)) { // вычитываем следующий из источника
if (! hasLast)
return false; // совсем всё закончилось — выходим
hasLast = false; // обрабатываем пару на стыке двух кусков
cur = last;
}
action.accept (mapper.apply (prev, cur)); // передаём в action результат mapper’а return true;
}
А вот и метод trySplit ():
public Spliterator
List
Алгоритм n = 10 000 n = 100 000 n = 1 000 000 naiveIterator 97.7 904.0 10592.7 naiveGet 99.8 1084.4 11424.2 collector 112.5 1404.9 14387.2 reduce 112.1 1139.5 12001.5 stream 146.4 1624.1 16600.9 streamEx 115.2 1247.1 12967.0 streamParallel 56.9 582.3 6120.5 streamExParallel 53.4 516.7 5353.4 Видно, что версия с pairMap (streamEx) обгоняет и традиционный потоковый вариант (stream), и версию с коллектором, уступая только неправильному reduce. При этом параллельная версия streamEx также быстрее параллельной версии stream и существенно обгоняет все последовательные версии даже для небольшого набора данных. Это согласуется с эмпирическим правилом из Stream Parallel Guidance: имеет смысл параллелить задачу, если она выполняется не менее 100 микросекунд.Если вы хотите создавать свои потоки, помните, что от хорошего сплитератора зависит, как будет параллелиться ваша задача. Если вы не хотите заморачиваться с делением, не пишите сплитератор вообще, а воспользуйтесь утилитными методами. Также не стоит писать новый сплитератор, если возможно создать поток, используя существующий функционал JDK. Если у вас сплитератор хороший, то даже не очень сложная задача может ускориться при параллельной обработке.