Прокачиваем Stream API, или нужно больше сахара

в 6:56, , рубрики: java, java8, open source, Stream, stream api

Не так давно удалось перевести на 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 не дописан, но всегда можно сориентироваться по исходникам. Принимаются замечания, предложения, пулл-реквесты и всё такое.

Автор: lany

Источник

Поделиться новостью

* - обязательные к заполнению поля