Java
September 1, 2023

Заменят ли потоки данных циклы в Java?

Выпуск версии Java 8 стал знаменательным событием в истории Java. В нем были представлены потоки данных (англ. Streams) и лямбда-выражения, которые сейчас широко применяются. Если вы не знакомы с потоками данных или никогда не слышали о них, то ничего страшного. В большинстве случаев можно обойтись без них, задействуя циклы.

И зачем тогда, спрашивается, нужны потоки данных? Есть ли у них преимущества перед циклами? Могут ли они их заменить? В статье мы изучим соответствующий код, сравним производительность и посмотрим, смогут ли потоки данных стать полноценной заменой циклов.

Сравнение кода

Потоки данных увеличивают сложность кода, поскольку им нужны классы, интерфейсы и импорт. В отличие от них, циклы изначально встроены. В каких-то случаях это действительно так, но не всегда. Сложность кода не сводится только к объемам требуемых знаний для его понимания. В большей мере она определяется степенью читаемости кода. Обратимся к примерам.

Список имен элементов с заданным типом

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

List<String> getItemNamesOfType(List<Item> items, Item.Type type) {    List<String> itemNames = new ArrayList<>();    for (Item item : items) {        if (item.type() == type) {            itemNames.add(item.name());        }    }    return itemNames;}

Читаем код и видим, что требуется создать новый ArrayList и в каждом цикле выполнять проверку типов и вызов add(). Теперь посмотрим, как с этой же задачей справляется поток данных:

List<String> getItemNamesOfTypeStream(List<Item> items, Item.Type type) {    return items.stream()            .filter(item -> item.type() == type)            .map(item -> item.name())            .toList();}

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

Генерация случайного списка

Переходим к другому примеру. В следующих разделах мы рассмотрим ключевые методы потоков данных и сравним время их выполнения с циклами. Для этого потребуется случайный список Item. Ниже представлен фрагмент кода со статическим методом, который генерирует такой список:

public record Item(Type type, String name) {    public enum Type {        WEAPON, ARMOR, HELMET, GLOVES, BOOTS,    }    private static final Random random = new Random();    private static final String[] NAMES = {            "beginner",            "knight",            "king",            "dragon",    };    public static Item random() {        return new Item(                Type.values()[random.nextInt(Type.values().length)],                NAMES[random.nextInt(NAMES.length)]);    }}

Теперь создаем случайный список Item с помощью циклов. Пишем следующий код:

List<Item> items = new ArrayList<>(100);for (int i = 0; i < 100; i++) {    items.add(Item.random());}

Код с потоками данных выглядит следующим образом:

List<Item> items = Stream.generate(Item::random).limit(length).toList();

Превосходный и легко читаемый код. Более того, List, возвращаемый методом toList(), представляет собой неизменяемый список. Так что вы можете пользоваться неизменяемостью List и размещать его в любых местах кода, не беспокоясь о побочных эффектах. Такой подход снижает вероятность появления ошибок в коде и облегчает его понимание.

Потоки данных предоставляют множество полезных методов, способствующих написанию лаконичного кода. Перечислим самые востребованные из них:

  • allMatch();
  • anyMatch();
  • count();
  • filter();
  • findFirst();
  • forEach();
  • map();
  • reduce();
  • sorted();
  • limit() и многие другие методы, с описанием которых можно ознакомиться по ссылке на документацию Stream Javadoc.

Производительность

При обычных обстоятельствах потоки данных ведут себя как циклы и практически не влияют на время выполнения. Сравним основные варианты поведения потоков с реализацией циклов.

Итерация элементов

Во многих случаях при работе с коллекциями элементов мы выполняем итерацию всех элементов внутри этих коллекций. В потоках Streams эту задачу решают такие методы, как forEach(), map(), reduce() и filter().

Подсчитаем каждый тип элемента в списке. Код с циклом for выглядит так:

public Map<Item.Type, Integer> loop(List<Item> items) {    Map<Item.Type, Integer> map = new HashMap<>();    for (Item item : items) {        map.compute(item.type(), (key, value) -> {            if (value == null) return 1;            return value + 1;        });    }    return map;}

Вариант кода с потоком данных:

public Map<Item.Type, Integer> stream(List<Item> items) {    return items.stream().collect(Collectors.toMap(            Item::type,            value -> 1,            Integer::sum));}

Варианты кода выглядят по-разному. Теперь выясним производительность каждого из них. По ссылке представлена таблица среднего времени выполнения 100 попыток.

Как видно, потоки данных и циклы демонстрируют незначительную разницу во времени выполнения итерации по всему списку. В большинстве случаев то же самое можно сказать и о других методах Stream, а именно map(), forEach(), reduce() и т. д.

Оптимизация с помощью параллельных потоков parallelStream()

Итак, мы выяснили, что при итерации списка потоки данных работают не лучше и не хуже циклов. Однако у потоков есть отличное преимущество перед циклами: они позволяют легко выполнять многопоточные вычисления. Для этого нужно всего лишь использовать parallelStream() вместо stream().

Убедимся в эффективности этого приема. Рассмотрим пример, в котором мы имитируем задачу с длительным временем выполнения:

private void longTask() {    // Имитация задачи с длительным временем выполнения.    try {        Thread.sleep(1);    } catch (InterruptedException e) {        throw new RuntimeException(e);    }}

Код циклической итерации по списку:

protected void loop(List<Item> items) {    for (Item item : items) {        longTask();    }}

Код потока данных:

protected void stream(List<Item> items) {    items.stream().forEach(item -> longTask());}

Код параллельных потоков parallelStream():

protected void parallel(List<Item> items) {    items.parallelStream().forEach(item -> longTask());}

Обратите внимание, что единственным изменением стала замена stream() на parallelStream().

Сравнительная таблица — по ссылке.

Как и ожидалось, наблюдается незначительная разница между показателями циклов и потоков данных. Посмотрим на результаты параллельных потоков. Они экономят более 80% времени выполнения по сравнению с другими реализациями. Как это возможно?

Когда дело касается задач, которые требуют длительного времени выполнения и должны применяться к каждому элементу списка в индивидуальном порядке, то они могут выполняться одновременно. В этом случаем мы получаем значительное улучшение результатов. Именно так работают параллельные потоки. Они распределяют задачи по нескольким потокам и обеспечивают их одновременное выполнение.

Однако параллельные потоки эффективны только при работе с независимыми друг от друга задачами. Поэтому мы не можем повсеместно задействовать их вместо циклов или потоков данных. Если же задачи не являются независимыми и совместно используют одни и те же ресурсы, следует обезопасить их блокировкой посредством ключевого слова Java synchronized и замедлить их выполнение по сравнению с обычными итерациями.

Ограничения

В применении потоков данных есть и свои ограничения. К ним относятся условные циклы и повторения. Рассмотрим их.

Условные циклы

Когда ситуация требует повторять итерации до выполнения условия true, но при этом количество совершаемых итераций неизвестно, то обычно используется цикл while:

boolean condition = true;while (condition) {    ...    condition = doSomething();}

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

Stream.iterate(true, condition -> condition, condition -> doSomething())        .forEach(unused -> ...);

Как видно, наличие шаблонного кода затрудняет чтение. Например, condition -> condition для проверки выполнения условия true и параметр unused внутри forEach(). С учетом этого делаем вывод, что условные циклы лучше писать в циклах while.

Повторения

Повторение — это одна из главных причин существования цикла for. Допустим, нужно повторить процесс 10 раз. С помощью цикла for легко пишем код для этой задачи:

for (int i = 0; i < 10; i++) {  ...}

В потоках данных одним из вариантов решения этой задачи станет создание IntStream, содержащего [0, 1, 2, ... , 9], и его итерация.

IntStream.range(0, 10).forEach(i -> ...);

Код выглядит лаконичным и корректным. Но в нем больший упор делается на значениях в диапазоне от 0 до 10 (без включения), тогда как в цикле for — на повторении 10 раз. Как правило, операцию повторения прописывают так: начинают от 0 и заканчивают количеством повторений.

Заключение

В статье мы сравнили потоки данных и циклы. Могут ли потоки заменить циклы? Как всегда, зависит от ситуации! Однако, как правило, потоки данных способствуют написанию более лаконичного, понятного и оптимизированного кода.

Время писать код с потоками Stream!

Ссылка на код из статьи: GitHub.

Источник