- PVSM.RU - https://www.pvsm.ru -
Когда-то я считал, что священный грааль итераций — это старый добрый цикл for. Да, тот самый, с индексами, который шагал по массивам как бравый солдат по полю. Я обожал мои циклы и охотно избегал всего, что могло их заменить. Но потом появился он — Stream API. Новомодный, загадочный, пугающий, словно неведомый зверь из функционального леса. И вот я, стоя по колено в коде, задумался: "Как перестать бояться и полюбить filter-map-reduce?"
В этой статье я расскажу свою историю о том, как подружился со Stream API. Будет место и самоиронии, и неожиданным поворотам. Приготовьтесь: сейчас мы вместе отправимся в путешествие по реке данных...
Моё первое знакомство со Stream API произошло внезапно. Представьте: обычный рабочий день, я открываю код, а там метод list.stream().filter(...).map(...).collect(...). Сказать, что я опешил — ничего не сказать. "Стрим... это что, какой-то поток? Мне теперь с многопоточностью возиться?!" — пронеслось у меня в голове.
Stream API, конечно, переводится как "стрим", то есть поток, но не путайте его с потоками ввода-вывода или потоками оперативной памяти. Здесь имеется в виду поток данных. Проще говоря, Stream в Java — это специальный объект, который позволяет вам обрабатывать последовательность элементов (например, коллекцию) удобной цепочкой методов.
Давайте сразу к ключевым понятиям:
Stream: абстракция над источником данных, позволяющая применять к ним ряд операций. Можно представить его как конвейер или даже как волшебную речку, по которой плывут ваши объекты.
Источник стрима: откуда берутся элементы. Чаще всего — коллекция (List, Set и т.д.). Например, вызов myList.stream() создаёт стрим, питающийся элементами списка.
Промежуточные операции (intermediate operations): методы, которые трансформируют стрим, возвращая новый стрим. Важное слово — ленивые (о лени чуть позже). Примеры: filter, map, sorted, limit.
Терминальные операции (terminal operations): финальные методы, после которых стрим исчерпывается (закрывается) и уже не годится к употреблению. Они либо возвращают конкретный результат, либо имеют побочный эффект. Примеры: forEach, collect, reduce, count. Как только вызвали терминальную операцию — всё, стрим больше не работает (попытка использовать его снова приведёт к ошибке).
filter, map, reduce: три кита Stream API, о которых мы сейчас поговорим подробнее. Это как три мушкетёра функционального мира, всегда вместе и творят чудеса с данными.
Важно: Stream API — это про декларативный стиль программирования. То есть вы описываете, что хотите сделать с данными (отфильтровать, преобразовать, агрегировать), а не как это делать шаг за шагом. Первое время у меня был когнитивный диссонанс: "Как это — не указывать явно, как бежать по элементам? Кто вообще бежит? Куда бежит?!" Оказывается, всю тяжёлую работу по перебору элементов берёт на себя стрим. Мы лишь задаём правила обработки.
Пришло время познакомиться с тремя главными героями любой стрим-истории: filter, map и reduce. Они часто идут рука об руку, превращая унылый код на циклах в изящный потоковый pipeline. Попробую объяснить их суть на простых примерах:
filter — это как суровый охранник на входе в клуб данных. Он пропускает только тех, кто соответствует критериям. Если элемент "не одет" по дресс-коду (не удовлетворяет условию), filter его отбрасывает. На языке Java это метод, принимающий предикат (функцию, возвращающую boolean). Пример: stream.filter(x -> x > 0) пропустит только положительные x. Все отрицательные и нулевые выкинет за фейс-контроль.
map — а это уже художник-преобразователь, который проводит эксперименты над каждым элементом. Он берёт элемент и что-то с ним делает, возвращая новый элемент. Например, stream.map(x -> x * 2) умножит каждый x на 2. Если продолжать клубную аналогию, то представьте, что после охранника стоит стилист, который меняет каждому проходящему гостю костюм на что-то повеселее. Было число — стало число в два раза больше. Map трансформирует, не меняя количество элементов (если было 10 элементов, после map их всё так же 10, просто все прошли через "салон красоты").
reduce — это как главный бармен клуба, который собрал все напитки, которые ему принесли посетители, и смешал их в один особенный коктейль. Каждый гость приносит свою часть — кто-то добавляет немного джина, кто-то — пару капель сока, и в итоге всё это сливается в единую, уникальную смесь. Он работает с элементами по очереди, аккуратно их соединяя и превращая в нечто большее. Если в начале было много ингредиентов, после reduce остаётся только один вкусный напиток. В случае с кодом это как сведение всех данных в одно итоговое значение.
Давайте посмотрим на код, чтобы все улеглось. Представим, у нас есть список целых чисел, и мы хотим взять только чётные, удвоить их и получить их сумму. Сначала — по-старинке, через цикл, а потом — с помощью стрима:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
int sumOfDoubledEvens = 0;
for (int x : numbers) {
if (x % 2 == 0) { // если число чётное
int y = x * 2; // удваиваем его
sumOfDoubledEvens += y; // добавляем к сумме
}
}
System.out.println(sumOfDoubledEvens); // вывод: 24
Работает безотказно: в sumOfDoubledEvens получится 24 (чётные числа из списка: 2, 4, 6; удвоенные: 4, 8, 12; сумма = 24).
Теперь то же самое сделаем через Stream API:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
int sumOfDoubledEvens = numbers.stream()
.filter(x -> x % 2 == 0) // оставляем только чётные
.map(x -> x * 2) // каждый чётный элемент удваиваем
.reduce(0, Integer::sum); // суммируем удвоенные числа, 0 - начальное значение
System.out.println(sumOfDoubledEvens); // вывод: 24
Вуаля, одна цепочка из трёх этапов вместо нескольких строк с логикой. Читается почти как постановка задачи: "извлеки числа, отфильтруй чётные, удвой их, потом сложи". Для меня это стало откровением: код как будто сам рассказывает историю, а не прячет её в пузатых фигурах цикла.
Настал час расплаты... вернее, сравнения. Я представляю это как дуэль на закате между Старым Добрым Циклом и Новым Модным Стримом. Они выходят спина к спине, отходят десять шагов, разворачиваются — у каждого в руке по кусочку кода.
Преимущества Stream API перед циклами:
Лаконичность и выразительность. Меньше шаблонного кода, больше сконцентрированности на сути обработки данных. Мне больше не нужно вручную создавать список для результатов, внутри цикла что-то добавлять, фильтровать — достаточно написать последовательность операций. Код становится короче и часто понятнее. Я будто перешёл от рубки дров топором к нажатию кнопки на электропиле — результат тот же, усилий меньше.
Читаемость. Признаюсь, поначалу стримы казались нечитаемыми, особенно когда в них вклиниваются лямбда-выражения на несколько строк. Но как только я привык, код со стримами стал восприниматься как декларативное описание: "что делаем". А цикл — это всегда подробный рецепт: "сделай раз, сделай два". При сложной многошаговой фильтрации и преобразовании данных стримы позволяют избежать вложенных условий и промежуточных хранилищ.
Легче параллелизм. Об этом мы еще поговорим подробнее, но спойлер: переключиться на многопоточную обработку в Stream API часто проще, чем распараллеливать обычные циклы. Грубо говоря, стримы из коробки умеют распределять работу между ядрами процессора, стоит только попросить.
Функциональный стиль и меньше побочных эффектов. В цикле мы часто мутируем внешние переменные или изменяем элементы коллекции. Со стримами же операции обычно не имеют побочных эффектов (если мы сами не начнём печатать из forEach или использовать что-то внешнее). Это снижает вероятность багов, связанных с изменением состояния, и упрощает понимание: входящие данные не меняются, мы получаем новый результат.
Недостатки и когда цикл всё же выигрывает:
Отладка и обучение. Когда что-то идёт не так, отлаживать стрим с парой лямбда-функций может быть сложнее, чем простенький цикл с println в нужном месте. Шагать дебаггером внутри стрима — то ещё удовольствие (хотя есть способ через .peek() подсматривать элементы в промежутке). К тому же порог входа выше: новичку в Java проще понять цикл, чем сразу вникнуть в потоки данных, лямбды и прочую функциональную алхимию.
Производительность на мелких задачах. Стримы добавляют свой оверхед: нужно создать объекты стрима, возможно, дополнительные объекты-обёртки для примитивов (если вы не используете специализированные стримы вроде IntStream). Для небольших коллекций или в простых сценариях традиционный цикл может работать быстрее и потреблять меньше памяти. То есть, если у вас пять элементов и тривиальная обработка, старый цикл может отработать эффективнее, чем стрим с его внутренней кухней.
Гибкость логики. Не всё удобно выразить через стримы. Например, сложные алгоритмы с множеством шагов, вложенными циклами или прерываниями (break/continue) проще написать императивно. В стриме, конечно, есть методы типа takeWhile или можно бросить исключение, чтобы экстренно прервать поток, но если вы делаете что-то очень нестандартное, классический цикл может оказаться понятнее.
Если бы Stream API был человеком, он был бы, пожалуй, гением-лентяем. Знаете, есть такие разработчики: код почти не пишут, пока жареный петух не клюнет, а потом за ночь выдают шедевр. Вот стримы — из той же оперы. Они ленивы (lazy) в хорошем смысле. Что это значит?
Промежуточные операции (filter, map и прочие) не выполняются сразу при вызове. Вместо этого стрим запоминает, что с ним собираются сделать, и терпеливо ждёт терминальной операции, чтобы разом всё выполнить. Это как составить список дел на день и не делать ничего, пока начальник не спросит результат. Начальник в мире стримов — терминальная операция. Только она способна дать пинка ленивому стриму.
Stream<Integer> lazyStream = numbers.stream()
.filter(x -> {
System.out.println("Фильтруем " + x);
return x % 2 == 0;
})
.map(x -> {
System.out.println("Удваиваем " + x);
return x * 2;
});
System.out.println("Мы тут вообще-то ещё ничего не считали!");
Если выполнить этот код, на консоли ничего не напечатается про фильтрацию и удваивание. Потому что до терминальной операции стрим даже не думает перебирать элементы. Но стоит добавить, например, .findFirst() или .collect(Collectors.toList()) в конец цепочки, как стрим проснётся и бросится выполнять всё, что мы ему поназадавали. И при этом он сделает это умно: поэлементно, проходясь по конвейеру операций.
Ленивость позволяет стримам делать классные оптимизации:
Минимум проходов по данным. В цикле, если нужно сначала отфильтровать, потом преобразовать, мы либо делаем всё в одном цикле (мешая логику вперемешку), либо в два прохода (сначала фильтр — сохраняем результат, потом пробегаемся и мапим). Стрим же под капотом объединяет операции и проходит по данным один раз. То есть для нашего примера filter+map это будет единый проход: сразу фильтрует каждый элемент и тут же, если элемент прошёл фильтр, мапит его.
Отложенное выполнение экономит ресурсы, если конечный результат не требует полного обхода. Скажем, вы ищете первый подходящий элемент: findFirst(). Стрим будет фильтровать и мапить, пока не найдёт первый удовлетворяющий условию элемент — и на этом остановится. Никаких лишних телодвижений. Если сравнить с циклом, то это аналог break при нахождении результата, только прописывать ничего не надо.
Оптимизация порядка операций. Допустим, вы сначала применяете map, а потом filter. В некоторых случаях стриму может быть всё равно, и он выполнит сначала фильтр, чтобы поменьше работать (хотя порядок вызовов обычно сохраняется, но, например, сортировку и фильтрацию он может оптимизировать по-разному). Конечно, он не волшебник и всегда надо думать, но сам факт, что под капотом может что-то переупорядочиться для эффективности — впечатляет. Впрочем, обычно лучше писать в логическом порядке самому, чтобы не сбивать будущих читателей кода с толку.
Стоит упомянуть peek() — этакий лазутчик, которого можно вставить в цепочку для отладки или побочных эффектов. Это промежуточная операция, которая ничего не меняет, а просто выполняет заданное действие с каждым элементом. Я, например, мог бы переписать пример выше, использовав peek(x -> System.out.println("Элемент: " + x)). Peek полезен, когда хочется подсмотреть, что происходит внутри стрима, не разрушая его (ведь если вставить System.out.println прямо в map, это тоже сработает, но смешает отладку с логикой). Однако важно помнить: если не вызвать терминальную операцию, peek тоже не выполнится — он не заставит стрим перестать лениться.
Поначалу эта "ленивая" модель исполнения сбивала меня с толку. Я привык, что если вызвал функцию — она тут же что-то сделала. А тут как будто вызываешь методы, а эффект ноль, пока не "довызовёшь" до конца. Но потом я оценил: ленивость позволяет работать эффективнее и не делать лишнего.
На самом деле, это только начало. Стоит вам почувствовать всю мощь стримов, как вы будете с улыбкой смотреть на свой старый код с циклами. А что дальше? Возможности открываются ещё шире. В следующей статье я расскажу о многопоточности с ParallelStream, интеграции со Spring и RxJava.
|
Понятие |
Описание |
|
Stream |
Абстракция над последовательностью данных (коллекцией и т.д.), позволяющая декларативно описывать операции (filter, map, reduce...) |
|
Источник стрима (List, Set…) |
Точка, откуда стрим начинает своё путешествие (например, myList.stream()) |
|
Промежуточные операции |
Методы, которые возвращают новый стрим (filter, map, limit, sorted...). Ленивые, не исполняются сразу. |
|
Терминальные операции |
Завершают стрим (forEach, reduce, collect, count...). При их вызове стрим «исчерпывается». |
|
filter |
Отбрасывает элементы, не проходящие по условию (предикату). |
|
map |
Трансформирует каждый элемент, выдавая новый стрим того же размера (если было 10 элементов, останется 10, только «преображённых»). |
|
reduce |
Агрегирует все элементы потока в одно итоговое значение, используя аккумуляторную функцию. |
|
Ленивость |
Промежуточные операции не исполняются до тех пор, пока не будет вызвана терминальная. |
|
Плюсы стримов |
Короткий и выразительный код, декларативный стиль, удобная фильтрация и трансформация, встроенная возможность параллелить. |
|
Минусы стримов |
Сложнее дебажить, есть оверхед по производительности на малых задачах, в некоторых случаях императивный цикл проще. |
Спасибо, что дочитали до конца! До новых встреч!
П.С. А если хочется продолжения банкета, заходите в мой Telegram-канал [1]— там мы вместе будем дальше шутить, рассуждать и, может быть, даже иногда серьёзно говорить о Java и не только.
Автор: George_Prikashchenkov
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/411745
Ссылки в тексте:
[1] Telegram-канал : https://t.me/JavaZavrOfficial
[2] Источник: https://habr.com/ru/articles/885298/?utm_source=habrahabr&utm_medium=rss&utm_campaign=885298
Нажмите здесь для печати.