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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Hyuni Kim: Can Streams Replace Loops in Java?

Предыдущая статьяПерехват сетевых запросов из мобильного приложения
Следующая статьяРост производительности машинного обучения с Rust. Часть 2