Не так давно удалось перевести на Java 8 один из проектов, над которым я работаю. Вначале, конечно, была эйфория от компактности и выразительности конструкций при использовании Stream API, но со временем захотелось писать ещё короче, гибче и выразительнее. Поначалу я добавлял статические методы в утилитные классы, однако это делало код только хуже. В конце концов я пришёл к мысли, что надо расширять сами интерфейсы потоков, в результате чего родилась маленькая библиотека StreamEx.

В Java 8 есть четыре интерфейса потоков — объектный Stream и три примитивных IntStream, LongStream и DoubleStream. Для полноценной замены стандартным потокам надо обернуть их все. Таким образом, у меня появились классы StreamEx, IntStreamEx, LongStreamEx и DoubleStreamEx. Чтобы сохранить исходный интерфейс, пришлось написать довольно много скучных методов вроде таких:

public class IntStreamEx implements IntStream {
    private final IntStream stream;

    @Override
    public <U> StreamEx<U> mapToObj(IntFunction<? extends U> mapper) {
        return new StreamEx<>(stream.mapToObj(mapper));
    }
    ...
}

Понадобилось также создать статические конструкторы, причём не только такие, какие уже есть в оригинальных классах, но и некоторые другие (скажем, для замены random.ints() есть метод IntStreamEx.of(random)). Зато после этого появились потоки, которые я могу расширить по своему усмотрению. Ниже представлен краткий обзор дополнительного функционала.

Сокращение популярных коллекторов


Со стандартным Stream API очень часто приходится писать .collect(Collectors.toSet()) или .collect(Collectors.toList()). Выглядит многословно, даже если импортировать Collectors статически. В StreamEx я добавил методы toSet, toList, toCollection, toMap, groupingBy с несколькими сигнатурами. Методу toMap можно не указывать функцию для ключей, если это identity. Пара примеров:

List<User> users;

public List<String> getUserNames() {
    return StreamEx.of(users).map(User::getName).toList();
}

public Map<Role, List<User>> getUsersByRole() {
    return StreamEx.of(users).groupingBy(User::getRole);
}

public Map<String, Integer> calcStringLengths(Collection<String> strings) {
    return StreamEx.of(strings).toMap(String::length);
}

Методы joining тоже соответствуют коллекторам, но перед этим содержимое потока пропускается через String::valueOf:

public String join(List<Integer> numbers) {
    return StreamEx.of(numbers).joining("; ");
}

Сокращение поиска и фильтрации


Иногда требуется выбрать в потоке только объекты определённого класса. Можно написать .filter(obj -> obj instanceof MyClass). Однако это не уточнит тип потока, поэтому придётся или приводить тип элементов вручную, или добавить ещё один шаг .map(obj -> (MyClass)obj). При использовании StreamEx это делается лаконично с помощью метода select:

public List<Element> elementsOf(NodeList nodeList) {
    return IntStreamEx.range(0, nodeList.getLength()).mapToObj(nodeList::item).select(Element.class).toList();
}

В реализации метода select, кстати, не используется шаг map, а просто после фильтрации применяется небезопасное преобразование типа потока, так что конвейер не удлинняется лишний раз.

Весьма часто приходится выкидывать null из потока, поэтому я добавил метод nonNull() на замену filter(Objects::nonNull). Ещё есть метод remove(Predicate), который удаляет из потока элементы, удовлетворяющие предикату (filter наоборот). Он позволяет чаще использовать ссылки на методы:

public List<String> readNonEmptyLines(Reader reader) {
    return StreamEx.ofLines(reader).map(String::trim).remove(String::isEmpty).toList();
}

Имеются findAny(Predicate) и findFirst(Predicate) — сокращения для filter(Predicate).findAny() и filter(Predicate).findFirst(). Метод has позволяет узнать, есть ли в потоке определённый элемент. Подобные методы добавлены и к примитивным потокам.

append и prepend


Нередко возникает необходимость добавить в поток одно-два специальных значения или склеить два потока. Использование стандартного Stream.concat не очень красиво, так как добавляет вложенные скобки и портит идею чтения программы слева направо. На замену concat я сделал append и prepend, которые позволяют добавить в конец или начало текущего потока другой поток или заданный набор значений:

public List<String> getDropDownOptions() {
    return StreamEx.of(users).map(User::getName).prepend("(none)").toList();
}

Расширять массив теперь можно так:

public int[] addValue(int[] arr, int value) {
    return IntStreamEx.of(arr).append(value).toArray();
}

Компараторы


В Java 8 значительно легче писать компараторы с использованием методов для извлечения ключа вроде Comparator.comparingInt. Для сокращения наиболее частых ситуаций сортировки, поиска максимума и минимума по одному ключу добавлено семейство методов sortingBy, maxBy и minBy:

public User getMostActiveUser() {
    return StreamEx.of(users).maxByLong(User::getNumberOfPosts).orElse(null);
}

Кстати, сортировка по компаратору добавлена и в примитивные потоки (иногда пригождается). Там, правда, под капотом происходит лишний боксинг, но можно понадеяться на агрессивные оптимизации JIT-компилятора.

Iterable


Многие хотят, чтобы Stream реализовывал интерфейс Iterable, ведь он содержит метод iterator(). Этого не сделано, в частности, потому что Iterable предполагает переиспользуемость, а у потока итератор можно взять только один раз. Хотя на Stack Overflow отмечают, что в JDK уже есть исключение из этого правила — DirectoryStream. Так или иначе иногда хочется вместо терминального forEach воспользоваться обычным циклом for. Это даёт ряд преимуществ: можно использовать любые переменные, а не только effectively final, можно кидать любые исключения, легче отлаживать, короче стектрейсы и т. д. В общем, я считаю, что большого греха нет, если вы создали поток и тут же используете его в цикле for. Конечно, надо соблюдать осторожность и не передавать его в методы, которые принимают Iterable и могут обходить его несколько раз. Пример:

public void copyNonEmptyLines(Reader reader, Writer writer) throws IOException {
    for(String line : StreamEx.ofLines(reader).remove(String::isEmpty)) {
        writer.write(line);
        writer.write(System.lineSeparator());
    }
}

Если нравится, пользуйтесь, но будьте осторожны.

Ключи и значения Map


Нередко возникает потребность обработать все ключи Map, значения которых удовлетворяют заданному условию, или наоборот. Писать такое напрямую несколько уныло: придётся возиться с Map.Entry. Я спрятал это под капот статических методов ofKeys(map, valuePredicate) и ofValues(map, keyPredicate):

Map<String, Role> nameToRole;

public Set<String> getEnabledRoleNames() {
    return StreamEx.ofKeys(nameToRole, Role::isEnabled).toSet();
}

EntryStream


Для более сложных сценариев обработки Map создан отдельный класс EntryStream — поток объектов Map.Entry. Он частично повторяет функционал StreamEx, но также содержит дополнительные методы, позволяющие по отдельности обрабатывать ключи и значения. В некоторых случаях это позволяет проще как генерировать новую Map, так и разбирать существующую. Например, вот так можно инвертировать Map-List (строки из списков значений попадают в ключи, а ключи формируют новые списки значений):

public Map<String, List<String>> invert(Map<String, List<String>> map) {
    return EntryStream.of(map).flatMapValues(List::stream).invert().grouping();
}

Здесь используется flatMapValues, который превращает поток Entry<String, List<String>> в Entry<String, String>, затем invert, который меняет местами ключи и значения, и в конце grouping — группировка по ключу в новую Map.

Вот так можно преобразовать все ключи и значения Map в строки:

public Map<String, String> stringMap(Map<Object, Object> map) {
    return EntryStream.of(map).mapKeys(String::valueOf).mapValues(String::valueOf).toMap();
}

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

Map<String, Group> nameToGroup;

public Map<String, List<User>> getGroupMembers(Collection<String> groupNames) {
    return StreamEx.of(groupNames).mapToEntry(nameToGroup::get).nonNullValues().mapValues(Group::getMembers).toMap();
}

Метод mapToEntry возвращает EntryStream с ключами из исходного потока и вычисленными значениями.

Вот такая библиотечка получилась. Надеюсь, кому-нибудь пригодится. Код — на GitHub, сборки можно взять в Maven Central. JavaDoc не дописан, но всегда можно сориентироваться по исходникам. Принимаются замечания, предложения, пулл-реквесты и всё такое.

Комментарии (8)


  1. Googolplex
    14.04.2015 12:32

    Очень и очень годно.

    Несмотря на то, что лямбды и библиотека операций над коллекциями — это очень большой шаг вперёд, он всё равно какой-то половинчатый. Многих идиом, совершенно естественных в аналогичных библиотеках в других языках, просто нет — чего только стоит отсутствие банального foldLeft и, например, возможности разделить поток на голову и хвост без преобразования в итератор. Несериализуемый Optional, который рекомендуется использовать только и исключительно в контексте коллекций. Много боли также от несовместимости примитивов и объектных типов. Такие библиотеки, как ваша, здорово с этим помогут :)


    1. shuttie
      14.04.2015 15:15

      Шепотом: а в scala это все есть из коробки!


      1. Googolplex
        14.04.2015 15:22

        Да, именно поэтому я и говорю про половинчатость :) к счастью, я давно уже работаю только на скале, но за джаву у меня, можно сказать, «душа болит») Мне не очень понятно, зачем авторы Java пошли по ущербному пути, не пользуясь всеми существующими наработками. Когда я спросил на Stackoverflow, почему в библиотеке потоков нет самой обычной последовательной свёртки, мне ответили, что

        the API doesn't provide this is that the designers don't want encourage people to write non-parallelizable code

        что, на мой взгляд, является полным BSом.


    1. dougrinch
      14.04.2015 18:14
      +4

      1. Есть «почти foldLeft» — reduce.
      2. Стримы в скале и джаве — это разные вещи. И если в первой это совершенно конкретная структура данных вида (head, => tail), то во второй это обобщенная (unordered/unsorted/undistinct/etc) штука с параллельной обработкой. И вся половинчатость как раз следствие этого. В частности, невозможность честного foldLeft, т.к. при параллельной обработке понятия left вообще не существует. И невозможность «разделить поток на голову и хвост», т.к. у неупорядоченного стрима нет головы (грубо говоря, если бы этот метод был, он имел бы полное право вернуть разные значения для двух последовательных вызовов).

      Stream API решает меньше задач, чем мог бы. Но делает это качественно. Имхо, это лучше альтернативы — делать массу всего, но «тут не всегда, здесь никогда, а вот там можно, но надо не забыть свериться с джавадоком».

      А вот если очень хочется иметь небезопасные (в общем случае) операции, то всегда можно написать библиотеку, что и сделал Автор.


      1. Googolplex
        14.04.2015 18:23
        +1

        reduce это ни разу ни foldLeft, о чём я как раз и пишу. Однородная свёртка удобна далеко не всегда.

        Стримы в Java могут быть последовательными — у них есть соответствующая характеристика.

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

        Этот метод мог бы иметь возвращаемое значение типа (T, Stream<T>) (с поправкой на то, что в джаве нету кортежей) и поглощать исходный поток — как, в общем-то, сейчас все операции над стримами и работают.

        Я вполне в курсе, почему API выглядит именно так. Про проблемы стримового API уже много писали. В частности, эта широко рекламируемая параллелизация одной строкой кода в 95% случаев не нужна, а там, где нужна, как правило гораздо лучше воспользоваться специализированным под конкретную задачу решением. Разработчикам Java нужно было бы исходить из практичности, а не из весьма сомнительных идеалов.


  1. relgames
    15.04.2015 12:20
    +2

    Библиотека хорошая, спасибо за труд!

    хочется вместо терминального forEach воспользоваться обычным циклом for

    Это специально — т.к. поток может быть параллельным.
    Если нужен итератор, лучше в конце сделать toList() и делать for уже на нем.


    1. lany Автор
      15.04.2015 12:30
      +1

      Пожалуйста!

      Да, я хотел дописать, что на параллельном потоке итератор лучше не использовать, но потом решил, что это очевидно :-)


      1. relgames
        15.04.2015 12:35
        +2

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