Hammerhall Сколько стоит Войти

Стрим вместо цикла: конвейер для коллекций

Статья 1 · читать минут 20

Для кого. Ты знаешь Java по курсу: классы, методы, if, циклы, списки. Код вида list.stream().filter(…).toList() видел, но читаешь его с трудом — или пока не видел вовсе.

Что будет. Чем конвейер отличается от цикла; из каких трёх частей он состоит; почему без последней части не происходит ничего; почему стрим одноразовый и чем toList() отличается от collect(Collectors.toList()), который ты увидишь в интернете.

Откуда серия. Её пишет команда платформы Hammerhall. Задачи на стримы — в кузнице Stream Forge на платформе; здесь — то, что нужно знать, чтобы их решать. Примеры учебные: каждый запускается одним файлом.


Зачем стримы#

Вечер в кофейне «Наковальня». В программе — список заказов за день, и хозяйка спрашивает: сколько заказов было на 500 ₽ и больше?

У заказа в программе есть метод total() — сумма, и guest() — имя гостя; как устроен заказ, разберём в части 1. На курсе такое считают циклом:

int big = 0;                                    // счётчик крупных заказов
for (Order order : orders) {
    if (order.total() >= 500) {                 // сумма заказа — от 500 ₽
        big++;
    }
}

Следом второй вопрос: а кто их сделал? Ещё цикл:

List<String> guests = new ArrayList<>();        // пустой список под ответ
for (Order order : orders) {
    if (order.total() >= 500) {                 // то же условие, второй раз
        guests.add(order.guest());
    }
}

Оба работают. Но в каждом цикле из пяти-шести строк о самом вопросе — одна: «сумма от 500». Остальное — служебное: счётчик, пустой список, add. Третий вопрос — третий цикл с тем же условием, и поправить условие придётся в трёх местах.

Те же два ответа стримом:

long big = orders.stream()
        .filter(order -> order.total() >= 500)  // оставить заказы от 500 ₽
        .count();                               // посчитать

List<String> guests = orders.stream()
        .filter(order -> order.total() >= 500)  // оставить заказы от 500 ₽
        .map(order -> order.guest())            // от заказа — имя гостя
        .toList();                              // собрать в список

Это читается сверху вниз, как фраза: «из заказов — те, что от 500, — их гостей — списком». Нет счётчика, нет пустого списка, который нужно не забыть создать. Что нужно получить, написано; как пройти по списку, решает Java. Стрелку -> внутри скобок разберём в части 1.

🔑 Цикл отвечает на вопрос «как пройти по списку», стрим — на вопрос «что получить». В задачах на работе — и в кузнице — второй вопрос важнее.

В этой статье — как устроен такой код и три вещи, на которых спотыкаются в первую неделю. Сквозной пример всей серии — заказы кофейни.


Часть 1. Первый стрим#

Данные: заказы кофейни#

Заказ — номер, имя гостя и позиции: латте, круассан, чизкейк. Позиция знает, что взяли, почём и сколько штук.

record Item(String name, int price, int qty) {}          // позиция: что, цена за штуку, сколько

record Order(int number, String guest, List<Item> items) {
    int total() {                                        // сумма заказа в рублях
        int sum = 0;
        for (Item item : items) {
            sum += item.price() * item.qty();
        }
        return sum;
    }
}

Запись (record) — класс, который просто хранит данные. Поля перечисляют в скобках после имени, а конструктор, методы-чтения number(), guest(), items(), сравнение и печать Java пишет сама. Подробно о записях — в статье 0 рубрики «Статьи». Метод total() пока считает сумму циклом; в одной из следующих статей серии он станет стримом.

Заказов за день десять, вот начало списка:

List<Order> orders = List.of(
        new Order(1, "Анна", List.of(new Item("латте", 250, 1), new Item("круассан", 180, 1))),
        new Order(2, "Борис", List.of(new Item("эспрессо", 150, 2))),
        // …
        new Order(10, "Жанна", List.of(new Item("какао", 200, 1))));

List.of(…) собирает список из перечисленного. Весь список — в «Примере целиком» в конце статьи: его можно скопировать в один файл и запустить.

stream, filter, count#

Разберём первый стрим по словам:

long big = orders.stream()
        .filter(order -> order.total() >= 500)
        .count();

Каждый следующий вызов пишут с новой строки, с точкой в начале: так видно, из каких шагов состоит конвейер.

count() возвращает long, а не int: источник стрима — не обязательно список, и элементов в нём может быть больше, чем помещается в int. Ответ здесь — 4, как и у цикла.

Лямбда — функция без имени#

Самое непривычное в этом коде — условие внутри filter:

order -> order.total() >= 500

Это лямбда — короткая функция без имени, которую передают в метод как значение. Слева от стрелки -> — параметр, справа — что вернуть. Читается «для заказа order — правда ли, что его сумма от 500». Тип параметра писать не нужно: Java знает, что по стриму едут заказы.

filter вызывает эту функцию для каждого заказа и смотрит на ответ: true — заказ едет дальше, false — отсеян. Имя параметра — любое: order, o, x. Хорошее имя называет, что едет по конвейеру.

Подробно о лямбдах — и о форме ещё короче, Order::guest, — в следующей статье. Пока достаточно читать их как «параметр → результат».


Часть 2. Конвейер: источник, промежуточные операции, конечная

Три части#

Стрим похож на конвейер на фабрике: детали по одной едут по ленте мимо станков, и каждый станок что-то с ними делает — отбраковывает, красит, упаковывает. В конце — готовый результат.

У любого стрима три части:

часть что делает во втором примере
источник откуда берутся элементы orders.stream()
промежуточные операции превращают стрим в новый стрим; их сколько угодно, в том числе ни одной .filter(…), .map(…)
конечная операция запускает конвейер и выдаёт результат — число, список, один элемент; она одна и всегда последняя .toList()

Промежуточная операция возвращает стрим, поэтому за ней можно поставить следующую. Конечная возвращает уже не стрим, а ответ: count() — число, toList() — список. После неё конвейер закончен.

map — превратить каждый элемент

Второй пример добавляет шаг:

List<String> guests = orders.stream()
        .filter(order -> order.total() >= 500)
        .map(order -> order.guest())
        .toList();

map превращает каждый элемент в другой: заказ — в имя гостя. Сколько элементов вошло в map, столько и вышло, только другого вида. toList() собирает доехавшее в список:

[Глеб, Дина, Вера, Борис]

Заметь, что тип того, что едет по конвейеру, меняется. До map это заказы, после — строки. Java записывает это так: Stream<Order> и Stream<String> — в угловых скобках сказано, что внутри. Отсюда и тип ответа: List<String>, список имён.

Порядок шагов важен#

Поменяем filter и map местами:

List<String> guests = orders.stream()
        .map(order -> order.guest())
        .filter(order -> order.total() >= 500)    // так не скомпилируется
        .toList();

Компилятор ответит:

Coffee.java:60: error: cannot find symbol
            .filter(order -> order.total() >= 500)
                                  ^
  symbol:   method total()
  location: variable order of type String
1 error
error: compilation failed

После map по конвейеру едут строки — имена гостей. У строки нет суммы заказа, и то, что параметр назван order, ничего не меняет: строка location: так и говорит — order здесь String. Сначала отбирают по тому, что есть у заказа, потом превращают.

🔑 Читая стрим, держи в голове, что сейчас едет по конвейеру. Каждая промежуточная операция может это поменять — и ошибки компиляции в стримах почти всегда об этом.


Часть 3. Пока нет конечной операции, ничего не происходит

Конвейер без конечной операции

Поставим внутрь filter печать, чтобы видеть, когда он работает:

Stream<Order> lazy = orders.stream()
        .filter(order -> {
            IO.println("проверяю заказ " + order.number());
            return order.total() >= 500;
        });
IO.println("конвейер собран, конечной операции нет");

Здесь лямбда делает два дела — печатает и отвечает, поэтому у неё тело в фигурных скобках и явный return, как у обычного метода. IO.println — то же, что System.out.println: в Java 25 класс IO доступен без импорта, и в примерах серии пишем так.

Запускаем — и видим одну строку:

конвейер собран, конечной операции нет

Ни одного «проверяю заказ». filter не проверил ни одного заказа: он только записал, что проверять нужно. Документация Java говорит об этом прямо: промежуточные операции ленивые — обход источника не начинается, пока не вызвана конечная операция.

С конечной операцией — по одному элементу через весь конвейер

Теперь печать и в filter, и в map, а в конце — toList():

List<String> traced = orders.stream()
        .filter(order -> {
            IO.println("filter: заказ " + order.number());
            return order.total() >= 500;
        })
        .map(order -> {
            IO.println("map:    заказ " + order.number());
            return order.guest();
        })
        .toList();

Вывод — начало:

filter: заказ 1
filter: заказ 2
filter: заказ 3
filter: заказ 4
map:    заказ 4
filter: заказ 5
filter: заказ 6
map:    заказ 6
…

Не «сначала все заказы через filter, потом все через map», как было бы с двумя циклами подряд. Заказ 4 прошёл filter и сразу поехал в map, и только потом конвейер взял заказ 5. Каждый элемент проходит конвейер целиком, промежуточных списков нет.

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

Не прячь в конвейер побочные действия

⚠️ Печать внутри filter — приём для того, чтобы понять, что происходит. В рабочем коде конвейер вычисляет ответ, а не печатает, не пишет в базу и не меняет чужие переменные. И вот почему это не вопрос вкуса:

long count = orders.stream()
        .map(order -> {
            IO.println("map: заказ " + order.number());
            return order.guest();
        })
        .count();

Здесь не напечатается ни одной строки, а count всё равно будет 10. map не меняет число элементов, а сколько их в списке, известно заранее, — и Java вправе не гонять конвейер вовсе. Документация count() так и предупреждает: реализация может не выполнять конвейер, если ответ известен из источника. С filter перед count() заранее ответ неизвестен — и конвейер работает, как в примере выше.


Часть 4. Стрим одноразовый, источник — нетронутый

Второй раз — исключение#

Стрим из lazy выше ещё не запущен. Посчитаем по нему дважды:

lazy.count();
lazy.count();                                  // второй раз — нельзя

Первый вызов проверит все заказы и вернёт 4. Второй упадёт:

Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

«Стрим уже использован или закрыт». Как конвейер, по которому детали уже проехали: ленту второй раз не запустишь, нужна новая партия. Новый стрим берут у источника — ещё раз orders.stream().

Поэтому стрим почти никогда не кладут в переменную. Переменная lazy в этой статье — только чтобы показать ленивость. В рабочем коде конвейер пишут целиком, от stream() до конечной операции, а в переменную кладут ответ.

Источник не меняется#

filter отсеивает заказы из стрима, а не из списка. После всех примеров orders.size() по-прежнему 10: стрим читает источник и ничего в нём не трогает. Нужен список только крупных заказов — его получают конечной операцией, отдельным новым списком.

toList() и collect(Collectors.toList())

⚠️ В интернете ты почти везде увидишь другой конец конвейера:

List<String> guests = orders.stream()
        .filter(order -> order.total() >= 500)
        .map(order -> order.guest())
        .collect(Collectors.toList());

Так писали до Java 16. С Java 16 у стрима есть свой toList(), короче. Разница не только в длине:

В новом коде пиши toList(). Нужен список, который потом дополнишь, — скопируй ответ в новый:

List<String> editable = new ArrayList<>(guests);   // копия, её можно менять
editable.add("Егор");

Про collect и другие способы собрать ответ — отдельные статьи серии: там collect раскрывается по-настоящему, группировками.


Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. Цикл и стрим дали одинаковый ответ — 4. Чем тогда стрим лучше?
  2. Назови источник, промежуточные операции и конечную операцию в примере с именами гостей.
  3. Почему код, где map(order -> order.guest()) стоит перед filter(order -> order.total() >= 500), не компилируется? Что подсказывает строка location: в ошибке?
  4. В лямбде внутри конвейера стоит печать, а в выводе её нет. Назови две причины, по которым так бывает.
  5. В каком порядке заказы проходят filter и map: сначала все через filter, потом все через map — или иначе?
  6. Почему стрим не кладут в переменную, чтобы использовать дважды? Что случится на втором использовании?
  7. Чем toList() отличается от collect(Collectors.toList()), и что делать, если в список нужно добавить элемент?
  8. Почему count() возвращает long?

Реши в кузнице#

Задачи на то же самое — filter, map, count, toList — в кузнице Stream Forge на платформе Hammerhall, на фильмах и странах мира. Первые задачи бесплатны, нужен только вход:

Что дальше#

Статья 2 — filter, map и Optional: лямбды подробно и ещё короче, Order::guest; как найти первый подходящий заказ и не бояться, что его нет.

Первоисточники#


Пример целиком#

Учебный пример — один файл. Нужен JDK 25: проверь командой java -version, первая строка должна начинаться с openjdk version "25 (или java version "25). С JDK из курса, 17 или 21, файл не запустится.

Записи стоят прямо вверху файла, а методы orders() и main — без класса вокруг: в Java 25 такой файл сам становится классом, а void main() без public static и без параметров — точкой входа. Сохрани файл как Coffee.java и запусти из его папки:

java Coffee.java

Java сама скомпилирует файл и выполнит main — ни Maven, ни проекта не нужно. Если вместо русских букв в выводе вопросы или кракозябры, запусти с явной кодировкой — аргумент в кавычках, так его поймёт и PowerShell: java "-Dstdout.encoding=UTF-8" Coffee.java; в командной строке Windows перед этим выполни chcp 65001.

// Coffee.java — учебный пример статьи 1 серии «Стримы по шагам».
// Запуск: java Coffee.java (нужен JDK 25)

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

/** Позиция заказа: напиток или десерт, цена за штуку в рублях, сколько штук. */
record Item(String name, int price, int qty) {}

/** Заказ: номер за день, имя гостя, позиции. */
record Order(int number, String guest, List<Item> items) {

    /** Сумма заказа в рублях. Пока циклом — стримом перепишем позже в серии. */
    int total() {
        int sum = 0;
        for (Item item : items) {
            sum += item.price() * item.qty();
        }
        return sum;
    }
}

/** Заказы кофейни «Наковальня» за один день. */
List<Order> orders() {
    return List.of(
            new Order(1, "Анна", List.of(new Item("латте", 250, 1), new Item("круассан", 180, 1))),
            new Order(2, "Борис", List.of(new Item("эспрессо", 150, 2))),
            new Order(3, "Вера", List.of(new Item("капучино", 220, 1))),
            new Order(4, "Глеб", List.of(new Item("латте", 250, 2), new Item("чизкейк", 320, 1))),
            new Order(5, "Анна", List.of(new Item("американо", 170, 1))),
            new Order(6, "Дина", List.of(new Item("раф", 290, 1), new Item("круассан", 180, 2))),
            new Order(7, "Егор", List.of(new Item("эспрессо", 150, 1))),
            new Order(8, "Вера", List.of(new Item("латте", 250, 1), new Item("чизкейк", 320, 2))),
            new Order(9, "Борис", List.of(new Item("капучино", 220, 3))),
            new Order(10, "Жанна", List.of(new Item("какао", 200, 1))));
}

void main() {
    List<Order> orders = orders();

    // 1. Сколько заказов на 500 ₽ и больше — циклом
    int bigByLoop = 0;
    for (Order order : orders) {
        if (order.total() >= 500) {
            bigByLoop++;
        }
    }
    IO.println("циклом: " + bigByLoop);

    // 2. То же — стримом
    long big = orders.stream()
            .filter(order -> order.total() >= 500)
            .count();
    IO.println("стримом: " + big);

    // 3. Кто сделал крупные заказы
    List<String> guests = orders.stream()
            .filter(order -> order.total() >= 500)
            .map(order -> order.guest())
            .toList();
    IO.println(guests);

    // 4. Ленивость: без конечной операции не печатается ничего
    Stream<Order> lazy = orders.stream()
            .filter(order -> {
                IO.println("проверяю заказ " + order.number());
                return order.total() >= 500;
            });
    IO.println("конвейер собран, конечной операции нет");

    // 5. Порядок: каждый заказ проходит конвейер целиком
    List<String> traced = orders.stream()
            .filter(order -> {
                IO.println("filter: заказ " + order.number());
                return order.total() >= 500;
            })
            .map(order -> {
                IO.println("map:    заказ " + order.number());
                return order.guest();
            })
            .toList();
    IO.println(traced);

    // 6. Источник не изменился
    IO.println("заказов в списке: " + orders.size());

    // 7. Список из toList() — неизменяемый; нужен изменяемый — копия
    List<String> editable = new ArrayList<>(guests);
    editable.add("Егор");
    IO.println(editable);

    // 8. count() вправе не запускать конвейер: печати не будет, ответ — 10
    long count = orders.stream()
            .map(order -> {
                IO.println("map: заказ " + order.number());
                return order.guest();
            })
            .count();
    IO.println("count = " + count);

    // Сделай руками — раскомментируй по одной строке и запусти:
    // guests.add("Егор");          // UnsupportedOperationException
    // lazy.count(); lazy.count();  // второй вызов — IllegalStateException
}

Сделай руками:

  1. Запусти файл. Первые строки — циклом: 4, стримом: 4 и [Глеб, Дина, Вера, Борис]; строки «проверяю заказ» не появятся ни разу.
  2. Найди в выводе место, где заказ 4 проходит filter и сразу map.
  3. Раскомментируй guests.add("Егор"); — увидишь UnsupportedOperationException. Верни комментарий.
  4. Раскомментируй строку с двумя lazy.count() — сначала напечатаются все «проверяю заказ», потом IllegalStateException. Верни комментарий.
  5. Поменяй местами filter и map в блоке // 3. и прочитай ошибку компилятора. Верни как было.
  6. Найди в конце вывода count = 10: строк «map: заказ» перед ним нет.

← Все статьи о стримах

Продолжение следует

Готовится в «Стримы по шагам»:

Новые тексты появятся в рубрике «Стримы».

Попробовать руками

В кузницах Hammerhall — задачи по Java, которые проверяет сервер: решаешь в своей IDE, отправляешь одной командой, проверка запускает твои тесты и закрытые. Сложность растёт вместе с решённым, первые задачи бесплатны.