Заменят ли потоки данных циклы в 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.