Выпуск версии 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.
Читайте также:
- Циклы Java в сторону - даешь потоки!
- Лучшие практики написания кода в Spring Boot
- Не самые очевидные советы по написанию DTO на Java
Читайте нас в Telegram, VK и Дзен
Перевод статьи Hyuni Kim: Can Streams Replace Loops in Java?