Не так давно удалось перевести на 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