Stream API: универсальная промежуточная операция

    Я разрабатываю бесплатную библиотеку StreamEx, которая расширяет стандартное Java 8 Stream API, добавляя туда новые операции, коллекторы и источники стримов. Обычно я не добавляю всё подряд, а всесторонне рассматриваю каждую потенциальную фичу. Например, при добавлении новой промежуточной (intermediate) операции встают такие вопросы:

    1. Будет ли она действительно промежуточной, то есть не будет трогать источник до выполнения терминальной операции?
    2. Будет ли она ленивой и вытаскивать из источника не больше данных, чем требуется?
    3. Сработает ли она на бесконечном стриме? Требует ли она ограниченный объём памяти?
    4. Будет ли она хорошо параллелиться?

    Минусик по любому из этих пунктов заставляет серьёзно задуматься, добавлять ли такую операцию. Минусик по первому — это сразу нет. Например, у конкурентов в jOOλ есть операция shuffle(), которая выглядит как промежуточная, но на самом деле прямо сразу потребляет весь стрим в список, перемешивает его и создаёт новый стрим. Я такое не уважаю.

    Минусики по остальными пунктам не означают сразу нет, потому что есть и стандартные операции, которые их нарушают. Второй пункт нарушает flatMap(), третий — sorted(), четвёртый — всякие limit() и takeWhile() (в JDK-9). Но всё-таки я стараюсь этого избегать. Однако на днях я открыл для себя операцию, которая плохо параллелится и в зависимости от использования может не сработать на бесконечном стриме, но всё же слишком хороша. Через неё удаётся буквально в несколько строчек выразить как практически любую существующую промежуточную операцию, так и кучу несуществующих. Я назвал операцию headTail().

    Метод операции принимает два параметра-функции (везде ниже опускаю PECS для краткости):

    <R> StreamEx<R> headTail(BiFunction<T, StreamEx<T>, Stream<R>> mapper, Supplier<Stream<R>> supplier)

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

    Часто вторая функция должна возвращать просто пустой стрим (если исходный стрим пуст, то и результат пуст), поэтому её можно опускать:

    <R> StreamEx<R> headTail(BiFunction<T, StreamEx<T>, Stream<R>> mapper)

    Давайте посмотрим, что с этим можно сделать. Простой вариант использования может выглядеть так:

    StreamEx.of("name", "John", "Mary", "Lucy")
            .headTail((head, tail) -> tail.map(str -> head+": "+str))
            .forEach(System.out::println);

    Вывод:

    name: John
    name: Mary
    name: Lucy

    Здесь мы просто откусили первый элемент стрима и использовали его для конкатенации с последующими элементами. Так можно парсить текстовый файл, у которого в первой строке заголовки. Но это довольно скучно. Играясь с этим методом я обнаружил, что он гораздо мощнее. Давайте попробуем выразить через него основные промежуточные операции из JDK.

    Stream.map


    Операция map применяет заданную функцию ко всем элементам исходного стрима. Вот так она будет выглядеть через headTail():
    public static <T, R> StreamEx<R> map(StreamEx<T> input, Function<T, R> mapper) {
        return input.headTail((head, tail) -> map(tail, mapper).prepend(mapper.apply(head)));
    }

    Здесь мы пользуемся ещё одной простой операцией prepend, без которой бы ничего не вышло. Это вариация на тему конкатенации двух стримов (в стандартном API есть Stream.concat). Здесь мы вызываем сами себя для хвоста, а затем добавляем в начало стрима результат применения функции к головному элементу.

    Это похоже на рекурсию, а все знают, что рекурсия жрёт стек. В функциональных языках программирования порой спасает оптимизация хвостовой рекурсии, но в Java её нет и не предвидится. Однако в данном случае это не совсем рекурсия: мы не вызываем метод map внутри самого себя, а только создаём функцию, которая будет вызвана позже. Оказалось, что в данном случае можно проконтролировать глубину вызовов, если изменения каждого отдельного headTail() затрагивают только начало стрима, оставляя неизменный хвост. Я не очень творчески назвал эту фичу «оптимизацией хвостовых стримов» (tail stream optimization). Она совместима с промежуточными операциями prepend (добавить что-то в начало стрима), mapFirst (изменить первый элемент стрима, не трогая остальное) и самим headTail. В принципе её можно бы было распространить на стандартные skip и dropWhile (с JDK-9), но моя библиотека обещает, что стандартные операции полностью совместимы с оригинальным Stream API, а тут возникли бы тонкие отличия.

    Так или иначе, приведённая выше операция map не кушает стек или память больше константного размера и вполне применима для стримов любой длины. Давайте посмотрим на другие операции.

    Stream.limit


    Ограничить стрим заданной длиной. Если ограничить одним элементом, то просто создаём стрим из головы, иначе вызываем себя для хвоста с уменьшенным ограничением (обработать n <= 0 — упражнение для читателя):

    public static <T> StreamEx<T> limit(StreamEx<T> input, int n) {
        return input.headTail((head, tail) -> n > 1 ? limit(tail, n - 1).prepend(head) : Stream.of(head));
    }

    Вначале я написал немного по-другому (как и аргумент flatMap, аргумент headTail может вернуть null вместо пустого стрима):

    public static <T> StreamEx<T> limit(StreamEx<T> input, int n) {
        return input.headTail((head, tail) -> n > 0 ? limit(tail, n - 1).prepend(head) : null);
    }

    Но у такой реализации есть недостаток: она считает из источника на один элемент больше, чем надо (при n = 0 аргумент head считывается, но не используется). Иногда это может быть критично. Например, такой код должен работать:

    limit(StreamEx.of(new Random().ints(0, 1000).boxed().distinct()), 1000).forEach(System.out::println);

    Бесконечный поток случайных чисел от 0 до 999, из которого мы выбираем уникальные. 1000 уникальных есть, а вот 1001 нету, поэтому если пытаться вытащить из источника 1001-е число, то всё зависнет.

    Stream.skip


    Выкинуть n первых элементов. Если n = 0, вернём просто хвост с приклеенной головой, иначе вызовем себя с уменьшенным аргументом:

    static <T> StreamEx<T> skip(StreamEx<T> input, int n) {
        return input.headTail((head, tail) -> n > 0 ? skip(tail, n - 1) : tail.prepend(head));
    }


    Stream.flatMap


    Отобразить каждый элемент на стрим и сделать из них общий стрим. В нашем случае реализация такая же, как у map:

    public static <T, R> StreamEx<R> flatMap(StreamEx<T> input, Function<T, Stream<R>> mapper) {
        return input.headTail((head, tail) -> flatMap(tail, mapper).prepend(mapper.apply(head)));
    }

    Здесь отличие только в том, что используется другой prepend, который принимает стрим (собственно, первый prepend — это частный случай этого).

    Stream.peek


    Выполнить дополнительное действие для каждого элемента стрима и вернуть стрим как есть. Выполняем действие и приклеиваем голову к хвосту:
    public static <T> StreamEx<T> peek(StreamEx<T> input, Consumer<T> consumer) {
        return input.headTail((head, tail) -> {
            consumer.accept(head);
            return peek(tail, consumer).prepend(head);
        });
    }


    Stream.filter


    Оставить элементы удовлетворяющие предикату. Приклеиваем голову, только если предикат выполняется:
    public static <T> StreamEx<T> filter(StreamEx<T> input, Predicate<T> predicate) {
        return input.<T> headTail((head, tail) -> predicate.test(head) 
            ? filter(tail, predicate).prepend(head) 
            : filter(tail, predicate));
    }

    Stream.distinct


    Оставить уникальные элементы. Тут уже явно потребуется дополнительная память. Наивная реализация будет использовать filter (можно стандартный или объявленный выше):

    public static <T> StreamEx<T> distinct(StreamEx<T> input) {
        return input.headTail((head, tail) -> distinct(tail.filter(n -> !Objects.equals(head, n))).prepend(head));
    }

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

    private static <T> StreamEx<T> distinct(StreamEx<T> input, Set<T> observed) {
        return input.headTail((head, tail) -> observed.add(head) 
                ? distinct(tail, observed).prepend(head)
                : distinct(tail, observed));
    }

    Не забываем, что Set.add возвращает false, если элемент уже был в множестве. В этом случае голову не приклеиваем. Такая реализация стек уже не кушает и по памяти не уступает стандартной. Тут стоит добавить метод для запуска (с рекурсивными функциями часто бывает, что нужен отдельный публичный метод для запуска):

    public static <T> StreamEx<T> distinct(StreamEx<T> input) {
        return distinct(input, new HashSet<>());
    }

    Stream.sorted


    Отсортировать стрим. Операция особенная: здесь нельзя ничего выдать в результат, пока источник не прочитан полностью. Придётся всё буферизовать (например, в ArrayList) и здесь нам впервые пригодится второй аргумент headTail:

    public static <T> StreamEx<T> sorted(StreamEx<T> input) {
        return sorted(input, new ArrayList<>());
    }
    
    private static <T> StreamEx<T> sorted(StreamEx<T> input, List<T> buf) {
        return input.headTail((head, tail) -> {
            buf.add(head);
            return sorted(tail, buf);
        }, () -> {
            buf.sort(null);
            return buf.stream();
        });
    }

    Когда весь исходный стрим кончился, мы сортируем буфер и возвращаем с него поток. Замечу, что такой sorted работает похоже на стандартный и он всё же лучше, чем приведённый выше shuffle. К примеру, если вы конкатенируете два сортированных стрима, второй не будет сортироваться, пока вы полностью не прочитаете первый. Кстати, заменив buf.sort(null) на Collections.shuffle(buf) вы и shuffle можете сделать более-менее нормально. А с Collections.reverse(buf) можно перевернуть стрим.

    JDK-9 пока добавляет две новых промежуточных операции. Реализуем и их:

    Stream.takeWhile


    Обрезать стрим как только предикат вернёт false. Похоже на limit:

    public static <T> StreamEx<T> takeWhile(StreamEx<T> input, Predicate<T> predicate) {
        return input.headTail((head, tail) -> predicate.test(head) 
            ? takeWhile(tail, predicate).prepend(head) : null);
    }

    Stream.dropWhile


    Выкидывать элементы из стрима, пока предикат не вернёт false. Похоже на skip:

    public static <T> StreamEx<T> dropWhile(StreamEx<T> input, Predicate<T> predicate) {
        return input.headTail((head, tail) -> predicate.test(head) ? dropWhile(tail, predicate) : tail.prepend(head));
    }

    Ну изобретать велосипед скучно. Давайте попробуем реализовать новые операции, которых нет в Stream API.

    mirror


    Добавим в конец стрима его содержимое в обратном порядке (чтобы стрим из 1, 2, 3 превратился в 1, 2, 3, 3, 2, 1). Можно сделать просто, но без хвостовой оптимизации:

    public static <T> StreamEx<T> mirror(StreamEx<T> input) {
        return input.headTail((head, tail) -> mirror(tail).append(head).prepend(head));
    }

    С хвостовой же потребуется буфер:

    public static <T> StreamEx<T> mirror(StreamEx<T> input) {
        return mirror(input, new ArrayDeque<>());
    }
    
    private static <T> StreamEx<T> mirror(StreamEx<T> input, Deque<T> buf) {
        return input.headTail((head, tail) -> {
            buf.addFirst(head);
            return mirror(tail, buf).prepend(head);
        }, buf::stream);
    }

    Обе реализации не берут больше, чем надо: mirror(StreamEx.of(1,2,3,4,5)).limit(3) не дойдёт до точки отражения и вычитает только три элемента из источника.

    scanLeft


    Последовательно модифицируем стрим, выполняя заданную операцию. Например, scanLeft(StreamEx.of(1,2,3,4,5), Integer::sum) должен последовательно суммировать элементы и создать стрим 1, 3, 6, 10, 15.

    public static <T> StreamEx<T> scanLeft(StreamEx<T> input, BinaryOperator<T> operator) {
        return input.headTail((head, tail) -> 
            scanLeft(tail.mapFirst(cur -> operator.apply(head, cur)), operator).prepend(head));
    }

    Здесь мы воспользовались методом mapFirst, который уже есть в StreamEx. Но если б и не было, мы б его легко написали даже без всякой рекурсии:

    public static <T> StreamEx<T> mapFirst(StreamEx<T> input, UnaryOperator<T> operator) {
        return input.headTail((head, tail) -> tail.prepend(operator.apply(head)));
    }

    В любом случае хвосты оптимизируются, как с нашим mapFirst, так и с имеющимся.

    takeWhileClosed


    Название, возможно, не очень удачное. Иногда хочется модифицировать takeWhile, чтобы в поток попадали не только элементы, удовлетворяющие предикату, но и первый элемент, его нарушающий. Через существующие операции и через обычный takeWhile это нормально не выразить. А через headTail — легко:

    public static <T> StreamEx<T> takeWhileClosed(StreamEx<T> input, Predicate<T> predicate) {
        return input.headTail((head, tail) -> predicate.test(head) 
                ? takeWhileClosed(tail, predicate).prepend(head)
                : Stream.of(head));
    }

    every


    Брать элементы из стрима с заданным интервалом (например, каждый десятый), начиная с первого. Здесь удобно скомбинировать с операцией skip, но стандартный skip не оптимизирует хвосты, поэтому воспользуемся нашим переопределённым skip:

    public static <T> StreamEx<T> every(StreamEx<T> input, int n) {
        return input.headTail((head, tail) -> every(skip(tail, n - 1), n).prepend(head));
    }

    couples


    Разбить стрим на непересекающиеся пары элементов, применив к ним заданную функцию (если элементов нечётное количество, последний выкинуть). Здесь удобно вызвать headTail дважды:

    public static <T, R> StreamEx<R> couples(StreamEx<T> input, BiFunction<T, T, R> mapper) {
        return input.headTail((left, tail1) -> 
                tail1.headTail((right, tail2) -> 
                    couples(tail2, mapper).prepend(mapper.apply(left, right))));
    }

    pairMap


    А если мы хотим с пересекающимися парами то же самое? Легко, надо только вернуть правый элемент в стрим при рекурсивном вызове:

    public static <T, R> StreamEx<R> pairMap(StreamEx<T> input, BiFunction<T, T, R> mapper) {
        return input.headTail((left, tail1) -> 
            tail1.headTail((right, tail2) -> 
                pairMap(tail2.prepend(right), mapper).prepend(mapper.apply(left, right))));
    }

    Такая операция уже есть в StreamEx, и я про неё писал. Она, конечно, нормально распараллеливается в отличие от реализации через headTail().

    batches


    Ладно, с парами понятно. А если мы хотим разбить стрим на кусочки фиксированный длины (в виде списков) и не потерять нецелый кусочек в конце? Например, batches(StreamEx(1,2,3,4,5,6,7), 3) должно сделать поток из списков [1,2,3], [4,5,6], [7]. Тут поможет аргумент, содержащий промежуточный буфер:

    public static <T> StreamEx<List<T>> batches(StreamEx<T> input, int size) {
        return batches(input, size, Collections.emptyList());
    }
    
    private static <T> StreamEx<List<T>> batches(StreamEx<T> input, int size, List<T> cur) {
        return input.headTail((head, tail) -> cur.size() >= size 
                ? batches(tail, size, Collections.singletonList(head)).prepend(cur) // старый буфер приклеиваем в голову и начинаем новый
                : batches(tail, size, StreamEx.of(cur).append(head).toList()), // добавляем к старому буферу
                () -> Stream.of(cur));
    }

    Когда источник исчерпан мы отдаём в результат последний накопленный буфер с помощью () -> Stream.of(cur), чтобы не потерять хвост. Здесь для красоты реализации я каждый раз создаю новый список через StreamEx.of(cur).append(head).toList(), а не меняю существующий. Но несложно и изменяемые списки вставить, если важна производительность.

    withIndices


    Потребовалось узнать индексы элементов в стриме? Можно и это. Чтобы не заводить специальный тип вроде пары индекс-элемент, примем абстрактную функцию типа BiFunction<Integer, T, R>, которая может с индексом и элементом сделать всё, что хочет:

    public static <T, R> StreamEx<R> withIndices(StreamEx<T> input, BiFunction<Integer, T, R> mapper) {
        return withIndices(input, 0, mapper);
    }
    
    private static <T, R> StreamEx<R> withIndices(StreamEx<T> input, int idx, BiFunction<Integer, T, R> mapper) {
        return input.headTail((head, tail) -> withIndices(tail, idx + 1, mapper).prepend(mapper.apply(idx, head)));
    }


    dominators


    Более экзотическая задача: будем выкидывать элементы, следующие после данного, над которыми данный «доминирует». Доминирование определяет предикат от двух элементов. Например, dominators(numbers, (a, b) -> a >= b) оставит из исходных чисел возрастающий поднабор. Реализация похожа на every, только вместо skip используется наш dropWhile:

    public static <T> StreamEx<T> dominators(StreamEx<T> input, BiPredicate<T, T> isDominator) {
        return input.headTail((head, tail) -> dominators(dropWhile(tail, e -> isDominator.test(head, e)), isDominator)
                .prepend(head));
    }


    appendReduction


    Добавить в конец стрима ещё один элемент — результат его редукции с заданной операцией. Например, appendReduction(numbers, 0, Integer::sum) допишет в стрим чисел сумму его элементов.

    public static <T> StreamEx<T> appendReduction(StreamEx<T> input, T identity, BinaryOperator<T> op) {
        return input.headTail((head, tail) -> 
            appendReduction(tail, op.apply(identity, head), op).prepend(head),
            () -> Stream.of(identity));
    }

    Как обычно, всё лениво и хвосты оптимизируются.

    primes


    Скорее учебная задача. Сделать решето Эратосфена: ленивый поток простых чисел, который выкидывает те, что делятся на уже виденные ранее:

    public static StreamEx<Integer> sieve(StreamEx<Integer> input) {
        return sieve(StreamEx.iterate(2, x -> x+1));
    }
    
    private static StreamEx<Integer> sieve(StreamEx<Integer> input) {
        return input.headTail((head, tail) -> sieve(tail.filter(n -> n % head != 0)).prepend(head));
    }

    Здесь хвостовой оптимизации не получается, хотя аналогичная штука на функциональных языках тоже, естественно, не оптимизируется. Но выглядит просто. Со стандартными настройками JVM успевает выдать простые числа до 200 000 с лишним, пока не упадёт со StackOverflowError.

    Можно придумать и другие полезные операции. Например, повторить содержимое стрима в цикле заданное количество раз. Или сдублировать стрим, отфильтровав его двумя разными фильтрами (при этом не хранить в памяти то, что не прошло второй фильтр). Можно сделать бегущее окно (по аналогии с batches, но внахлёст). По факту что бы я ни придумал, мне удавалось это реализовать с помощью headTail весьма коротко (мои тесты здесь). Во всяком случае, для меня headTail точно короче и понятнее, чем писать Iterator или Spliterator. Как я понимаю, в мире функционального программирования подобные штуки — обычное дело. Приятно, что и на Java это возможно.

    Программируйте с удовольствием!

    Комментарии 28

      –2
      Мне кажется, проще к своему текущему Java проекту добавить Scala. У меня старый код на Java остался, а все новое в проекте я пишу на Scala и легко вызываю старые вещи писаные на Java из Scala. Там синтаксис куда короче чем Stream от Java.
        +7
        Вопрос так-то холиварный :-) Java — это Java, у неё свой путь, который отличается от Scala. Мне, как это ни странно, Java нравится больше. Пусть будет больше языков, выбор — это хорошо :-)
          +4
          Кому как, синтаксис и философия Scala достаточно сильно отличается от Java, не все Java программисты готовы перейти на Scala. Scala все-таки не Java c новыми плюшками, каким вроде бы хочет стать Kotlin, Scala это Scala.
            +3
            • НЛО прилетело и опубликовало эту надпись здесь
            0
            Чем github.com/ReactiveX/RxJava не подошла?
              +3
              Ещё есть LazySeq, jOOλ, javaslang, cyclop-streams. Это всё другие библиотеки с другой философией. Кстати, если вы используете RxJava, перепишите примеры из статьи с её использованием. Интересно посмотреть.
            0
            Чем-то напоминает синтаксис работы со списками в Prolog)
              0
              Чем-то напоминает операцию свертки (fold) в Scala, я правда не уверен, насколько она там может быть промежуточной.
                0
                fold — это очень частный случай, похоже на пример с appendReduction, только исходный стрим не надо выдавать в результат:

                public static <T> StreamEx<T> fold(StreamEx<T> input, T identity, BinaryOperator<T> op) {
                    return input.headTail((head, tail) -> fold(tail, op.apply(identity, head), op), () -> Stream.of(identity));
                }

                Получается стрим из одного элемента, результата фолдинга. Это обычно терминальная операция, но можно и промежуточную вот так сделать, если надо.
                  0
                  Почему очень частный? С помощью fold также можно выразить большинство (или даже все) операций из статьи.
                    0
                    Как, например, map выразить через fold?
                      0
                      Прочитай эту статью (тут js, но в FP все аналогично) glebbahmutov.com/blog/reduce-reigns-supreme
                        0
                        А, ну это всё-таки другое. В Stream API аналог — это комбинации коллекторов, конкретно для map — Collectors.mapping(mapper, Collectors.toList()). Это не ленивая операция, она сразу в массив собирает. Её к бесконечному стриму не применишь, короткое замыкание после неё не устроишь.
                          0
                          Для этого есть scan оператор
                            0
                            Хорошо, как же map через scan написать? Можно на js, если вам удобнее :-)
                +1
                может dropWhileInclusive более удачное имя?
                  0
                  dropWhileAndFail :)
                    0
                    takeWhileInclusive, в смысле? Да, вариант. Я как раз думаю добавить такой метод непосредственно в StreamEx и парюсь, как его назвать :-)
                    +2
                    Это все конечно хорошо для разминки мозгов, но как с производительностью? Эти кучи вызовов функций уйдут?
                      +1
                      Вопрос закономерный и оверхед, безусловно, есть. Но в вопросе производительности всегда всё относительно. К примеру, возьмём массив из случайных 10000 строк (полученных из случайных чисел):

                      new Random(1).doubles(10000).mapToObj(String::valueOf).collect(Collectors.toList());

                      Скажем, вы с этими сроками собрались сделать «почти ничего» (например, промэпить сами на себя и сложить длины):

                      input.stream().map(x -> x).collect(Collectors.summingInt(String::length))

                      В этом случае реализация map через headTail работает в 17 раз медленнее (какой ужас!):

                      HeadTailTest.htSimple    avgt   30  1371,840 ±  32,914  us/op
                      HeadTailTest.jdkSimple   avgt   30    80,933 ±   5,080  us/op

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

                      input.stream().map(x -> x.replaceAll("[123]", "")).collect(Collectors.toList());

                      Теперь версия через headTail всего лишь на 30% медленнее обычной:

                      HeadTailTest.htComplex   avgt   30  7819,082 ± 208,534  us/op
                      HeadTailTest.jdkComplex  avgt   30  5999,423 ± 148,727  us/op

                      Бенчмарк здесь, можете сами поиграться. Кстати, люди, которые пишут регекспы, даже не задумываются, насколько они тормознее, чем сделать то же самое вручную, сконвертив строку в массив символов и пройдясь по ней циклом for. Берут и пользуются, потому что удобнее.

                      Разумеется, я не призываю использовать такой подход, если аналогичная операция уже существует. Но если надо что-то новенькое, он вполне оправдан. Это не какие-то там адские нереальные тормоза, вполне разумное замедление порядка 200 наносекунд на элемент стрима.
                        0
                        Тут проблема в том, что, имея такую универсальную операцию, люди перестанут включать мозг и задумываться, что здесь большой оверхед. И применять эти функции будут не только для задач, где он незаметен, но и в примерах по типу вашего первого, оправдываясь тем, что «ну ведь выглядит красиво».

                        А потом эти кусочки, каждый медленнее на порядок, чем он может быть, собираются в большую систему и тормоза уже начинают быть заметны. И да, 30% — это очень заметные тормоза. То, что делалось бы минуту, займет минуту и 18 секунд, то, что час — час и 18 минут. У меня за 18 минут проект с исходниками на 3 Гб собирается.

                        Сама идея похвальная, но в библиотеку я бы все же такое не добавлял, чтобы не было соблазна. Regexp-ы — как раз пример такого соблазна. Слишком легко не увидеть реальной стоимости операции (в случае в Java так еще и коварность есть, т.к. метод строки split принимает regexp, а не голую строчку). Да у вас в обзоре только 2 операции, которые совершенно новые, а остальные и так есть, поэтому зачем она? Вот бы были аннотации, которыми можно было бы аннотировать код, указывая его вычислительную стоимость и второй набор, которыми можно было бы ограничивать стоимость метода, чтобы всякие неоптимальные алгоритмы не пролезали куда не нужно…

                        Такие выкрутасы хороши тогда, когда язык может гарантировать, что все эти красивости соптимизируются, хотя бы все будет заинлайнено. В Rust-е такое наверняка бы прокатило.
                          0
                          По-моему, воспользоваться этой операцией, не включая мозг, не получится :-)

                          То есть вы бы не стали добавлять в язык регулярные выражения из опасения, что люди будут пихать их где ни попадя? Всегда есть баланс между удобством и производительностью, а преждевременные оптимизации — таки зло. Это при том, что я очень много внимания уделяю производительности и StreamEx — действительно быстрая библиотека.

                          У меня в обзоре много операций, которых нет в Stream API и в StreamEx: shuffle, reverse, mirror, every, scanLeft, dominators, appendReduction. Операции couples, withIndices, slides, batches в некотором виде есть, если источник — список со случайным доступом. Для произвольного списка нету. Операции scanLeft, dominators есть в виде терминальных, они не могут быть короткозамкнутыми.

                          Аннотации производительности — вопрос интересный, но это слишком серьёзная тема, чтобы обсуждать её в комментариях. Даже написать нормальную спецификацию для подобных аннотаций (при условии, чтобы они были действительно полезны) — большая академическая работа.
                            0
                            То есть вы бы не стали добавлять в язык регулярные выражения из опасения, что люди будут пихать их где ни попадя?

                            Нет, зачем выкидывать удобный инструмент? Но его использование должно быть явным, если это тяжелая операция. В случае со split-ом — он должен принимать не строку, а объект Pattern, если нужно разделение по regexp-у. Скрывать тяжелые операции за простым фасадом можно только в приватных функциях того же класса, если говорить применительно к Java — когда легко вычислить всех потребителей этого фасада.

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

                            Операции couples, withIndices, slides, batches в некотором виде есть, если источник — список со случайным доступом. Для произвольного списка нету.

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

                            У меня в обзоре много операций, которых нет в Stream API и в StreamEx

                            Как вы сами сказали, в вашей библиотеке только операции, обладающие некоторыми критериями качества. Если их нет даже в вашей библиотеке, это ли не признак того, что они не проходят даже по вашим меркам? А если проходят, то почему их нет, раз они так востребованы?

                            Вообщем, я к тому, что если вы хотите позиционировать свою библиотеку, как обладающую хорошей производительностью, нужно очень осторожно вносить в нее операции, которые выглядят, как производительные в одном ряду с другими операциями, но таковыми не являются. Не то начнут вместо специализированных и оптимизированных алгоритмов использовать ее направо и налево. Да вы и сами это подметили для регулярных выражений. Может, даже стоит пометить ее аннотацией @Deprecated, чтобы предупреждение от компилятора было.
                              0
                              Как вы сами сказали, в вашей библиотеке только операции, обладающие некоторыми критериями качества

                              Один из важнейших критериев в том, что операция должна параллелиться нормально. Все перечисленные отсутствующие операции плохо параллелятся (но это не имеет отношения к их производительности в последовательном режиме). Тем не менее они часто нужны на практике.

                              И насчёт ада вы преувеличиваете. Тем более сегодня производительность одна, завтра другая. Я могу существенно ускорить headTail. Ценой усложнения внутренней логики StreamEx и, возможно, весьма незначительного замедления некоторых других сценариев работы (но при этом некоторые другие сценарии могут и немного ускориться как побочный эффект). Если это будет востребовано (или просто мне самому приспичит), займусь. Вот с тем же сплитом вами нелюбимым (я его, кстати, тоже не люблю). В седьмой джаве добавили быструю обработку сплита по односимвольным литералам. Когда чем-то часто пользуются, разработчики начинают больше обращать внимания на производительность и ускоряют популярные сценарии. А если пользуются редко, то и никого не волнует.
                                +1
                                Ну вот, пооптимизировал, уже не 17× и 30%, а 9× и 10%, теперь примерно 50-60 наносекунд и 152 байта аллокаций на элемент стрима оверхед (было где-то 120-200 наносекунд и 332 байта). Если по остальным сценариям не будет регрессии, включу этот код в новый релиз.

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

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое