JavaRush /Java блог /Random /For и For-Each Loop: сказ о том, как я итерировался, итер...
Viacheslav
3 уровень

For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался

Статья из группы Random

Вступление

Циклы — это одна из базовых структур языков программирования. Например, на сайте Oracle есть раздел "Lesson: Language Basics", в котором циклам отведён отдельный урок "The for Statement". Освежим в памяти основное: Цикл состоит из трёх выражений (statements): инициализация (initialization), условие (termination) и инкремент (increment):
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 1
Интересно, что все они являются опциональными, то есть мы можем, если захотим, написать:

for (;;){
}
Правда, в таком случае мы получим бесконечный цикл, т.к. у нас не указано условие выхода из цикла (termination). Выражение инициализации выполняется только один раз, перед выполнением всего цикла. Стоит всегда помнить, что у цикла своя область видимости. Это значит, что initialization, termination, increment и тело цикла видят одни и те же переменные. Область видимости всегда легко определить по фигурным скобкам. Всё, что внутри скобок не видно снаружи скобок, однако всё, что снаружи скобок видно внутри скобок. Инициализация — просто выражение. Например, вместо инициализации переменной можно вообще сделать вызов метода, который ничего не будет возвращать. Или просто пропустить, оставив перед первой точкой с запятой пустое место. Следующее выражение указывает условие выхода (termintation). Пока оно true, цикл выполняется. А если false — новая итерация не начнётся. Если посмотреть на картинку ниже, на ней мы при компиляции получаем ошибку и IDE будет ругаться: наше выражение в цикле недостижимо. Поскольку у нас не будет ни одной итерации в цикле, мы сразу выйдем, т.к. false:
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 2
За выражением в termination statement стоит следить: от этого напрямую зависит, не будет ли в вашем приложении бесконечных циклов. Инкремент — самое простое выражение. Оно выполняется после каждой успешной итерации цикла. И это выражение также можно пропустить. Например:

int outerVar = 0;
for (;outerVar < 10;) {
	outerVar += 2;
	System.out.println("Value = " + outerVar);
}
Как видно из примера, каждую итерацию цикла мы будем делать прирост с шагом 2, но только пока значение outerVar меньше 10. Кроме того, поскольку выражение в increment statement на самом деле просто выражение, в нём может быть всё что угодно. Поэтому вместо инкремента никто не запрещает использовать декремент, т.е. уменьшать значение. Стоит всегда следить за написанием инкремента. += выполняет сначала увеличение, а потом присвоение, а вот если в примере выше написать наоборот, мы получим бесконечный цикл, ведь переменная outerVar никогда не получит изменённое значение: оно в случае =+ будет вычисляться после присвоения. Кстати, с инкрементом вида ++ так же. Например, у нас был цикл:

String[] names = {"John","Sara","Jack"};
for (int i = 0; i < names.length; ++i) {
	System.out.println(names[i]);
}
Цикл работал и проблем не было. Но тут пришёл человек-рефакторинг. Он не разобрался с инкрементом и просто сделал так:

String[] names = {"John","Sara","Jack"};
for (int i = 0; i < names.length;) {
	System.out.println(names[++i]);
}
Если знак инкремента стоит перед значением, это значит, что сначала оно увеличится, а потом вернётся в то место, где указано. В данном примере из массива мы сразу начнём доставать элемент под индексом 1, пропустив первый. А потом на индексе 3 упадём с ошибкой "java.lang.ArrayIndexOutOfBoundsException". Как вы догадались, раньше это работало просто потому, что инкремент вызывается после выполнения итерации. При переносе этого выражения в итерацию всё сломалось. Как оказывается, даже в простом цикле можно наломать дров ) Если есть массив, может можно как-то проще вывести все элементы?
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 3

For each loop

Начиная с Java 1.5 разработчики Java дали нам конструкцию for each loop, описанную на сайте Oracle в Guide под названием "The For-Each Loop" или для версии 1.5.0. В общем случае, будет выглядеть следующим образом:
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 4
В Java Language Specification (JLS) можно ознакомиться с описанием этой конструкции, чтобы точно убедиться, что это никакая не магия. Описана данная конструкция в главе "14.14.2. The enhanced for statement". Как видно, for each loop можно использовать с массивами и с теми, кто реализует интерфейс java.lang.Iterable. То есть, если очень хочется, вы можете реализовать интерфейс java.lang.Iterable и for each loop можно будет использовать и с вашим классом. Вы сразу скажете "Так, объект итерирования, но ведь массив не объект. Вроде". И будете не правы, т.к. в Java массивы являются динамически создаваемыми объектами. Об этом нам говорит спецификация языка: "In the Java programming language, arrays are objects". В общем случае, массивы это немного JVM магии, т.к. то, как устроен массив внутри, неизвестно и находится где-то внутри виртуальной машины Java. Кому интересно, может почитать ответы на stackoverflow: "How does array class work in Java?". Получается, что если мы используем не массив, то мы должны использовать что-то, что реализует Iterable. Например:

List<String> names = Arrays.asList("John", "Sara", "Jack");
for (String name : names) {
	System.out.println("Name = " + name);
}
Тут можно просто запомнить, что если мы используем коллекции (java.util.Collection), благодаря этому получаем точно и Iterable. Если объект имеет класс, реализующий Iterable, он обязуется предоставлять при вызове метода iterator некий Итератор, который будет выполнять итерацию по содержимому этого объекта. Код выше, например, будет иметь примерно следующий байткод (в IntelliJ Idea вы можете выполнить "View" -> "Show bytecode" :
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 5
Как видите, действительно используется итератор. Если бы не for each loop, нам пришлось бы писать самим что-то вроде:

List<String> names = Arrays.asList("John", "Sara", "Jack");
for (Iterator i = names.iterator(); /* continue if */ i.hasNext(); /* skip increment */) {
	String name = (String) i.next();
	System.out.println("Name = " + name);
}

Iterator

Как мы видели выше, интерфейс Iterable говорит, что для экземпляров некоторого объекта можно получить итератор, с помощью которого можно итерироваться по содержимому. Опять же, можно сказать, что это принцип единственной ответственности из SOLID. Структура данных сама по себе не должна управлять обходом, но может дать того, кто должен. Базовая реализация Iterator'а сводится к тому, что он объявлен обычно как внутренний класс, который имеет доступ к содержимому внешнего и предоставляет нужный элемент, содержащийся во внешнем классе. Вот пример из класса ArrayList того, как итератор возвращает элемент:

public E next() {
            checkForComodification();
            int i = cursor;
            if (i >= size)
                throw new NoSuchElementException();
            Object[] elementData = ArrayList.this.elementData;
            if (i >= elementData.length)
                throw new ConcurrentModificationException();
            cursor = i + 1;
            return (E) elementData[lastRet = i];
}
Как мы видим, при помощи ArrayList.this итератор получает доступ ко внешнему классу и его переменной elementData, после чего возвращает элемент от туда. Итак, получение итератора происходит очень просто:

List<String> names = Arrays.asList("John", "Sara", "Jack");
Iterator<String> iterator = names.iterator();
Работа его сводится к тому, что мы можем проверить, есть ли элементы дальше (метод hasNext), получить следующий элемент (метод next) и метод remove, который удаляет последний полученный через next элемент. Метод remove является опциональным и то, что он будет реализован, не гарантировано. На самом деле, с развитием Java дорабатываются и интерфейсы. Поэтому, в Java 8 появился ещё и метод forEachRemaining, позволяющий выполнить некоторые действия над оставшимися непосещёнными итератором элементами какое-то действие. Что в итераторе и в коллекциях есть интересного? Например, есть класс AbstractList. Это абстрактный класс, который является родительским для ArrayList и LinkedList. И интересен он нам из-за такого поля, как modCount. Каждое изменение содержимого списка изменяется. И что нам с того? А то, что итератор следит, чтобы при работе не произошло изменение коллекции, по которой он итерируется. Как вы понимаете, реализация итератора для списков находится там же, где и modcount, то есть в классе AbstractList. Рассмотрим простой пример:

List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
names.add("modcount++");
System.out.println(iterator.next());
Тут первая интересность, хоть и не по теме. На самом деле Arrays.asList возвращает свой особый ArrayList (java.util.Arrays.ArrayList). У него не реализованы методы добавления, по этому он является немодифицируемым. Про него написано в JavaDoc: fixed-size. Но на самом деле, он более чем fixed-size. Он в том числе immutable, то есть неизменяемый; remove на нём тоже не сработает. А еще мы получим ошибку, т.к. создав итератор, мы запомнили в нём modcount. Потом мы изменили "снаружи" (т.е. не через итератор) состояние коллекции и выполнили метод итератора. Поэтому, получим ошибку: java.util.ConcurrentModificationException. Чтобы это избежать, изменение при итерировании нужно выполнять через сам итератор, а не через обращение к коллекции:

List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next();
iterator.remove();
System.out.println(iterator.next());
Как вы понимаете, если перед iterator.remove() не выполнить iterator.next(), то т.к. итератор не указывает ни на один элемент, то мы получим ошибку. В примере же итератор перейдёт на элемент John, удалит его, а затем получит элемент Sara. И тут всё бы было хорошо, но вот ведь незадача, опять есть "нюансы" ) java.util.ConcurrentModificationException будет только тогда, когда hasNext() возвращает true. То есть удалить через саму коллекцию последний элемент, то итератор не упадёт. Подробнее тут лучше посмотреть доклад про Java паззлеры с "#ITsubbotnik Секция JAVA: Java пазлеры". Столь подробный разговор мы завели по той простой причине, что ровно все те же нюансы действуют при for each loop, т.к. внутри "под капотом" используется наш любимый итератор. И все эти нюансы действуют и там. Единственное, у нас не будет доступа к итератору, и мы не сможем безопасно удалить элемент. Кстати, как вы понимаете, состояние запоминается в момент создания итератора. И безопасное удаление работает только там, где вызван. То есть такой вариант не пройдёт:

Iterator<String> iterator1 = names.iterator();
Iterator<String> iterator2 = names.iterator();
iterator1.next();
iterator1.remove();
System.out.println(iterator2.next());
Потому что для iterator2 удаление через iterator1 было "внешним", то есть выполнялось где-то снаружи и он про это ничего не знает. В тему итераторов хотелось бы ещё отметить вот что. Специально для реализаций интерфейса List сделали особый, расширенный итератор. И назвали его ListIterator. Он позволяет продвигаться не только вперёд, но и назад, а также позволяет узнавать индекс предыдущего элемента и следующего. Кроме того, позволяет заменить текущий элемент или вставить новый на позицию, между текущим положением итератора и следующим. Как вы догадались, ListIterator позволено сделать так как для List реализован доступ по индексу.
For и For-Each Loop: сказ о том, как я итерировался, итерировался, да не выитерировался - 6

Java 8 и итерирование

Выход в свет Java 8 упростила многим жизнь. Не обошли стороной и итерацию по содержимому объектов. Чтобы понять, как это работает, надо сказать пару слов вот про что. В Java 8 появился класс java.util.function.Consumer. Вот пример:

Consumer consumer = new Consumer() {
	@Override
	public void accept(Object o) {
		System.out.println(o);
	}
};
Consumer является функциональным интерфейсом, что означает, что внутри интерфейса есть всего 1 нереализованный абстрактный метод, требующий обязательной реализации в тех классах, которые укажут implements данного интерфейса. Это позволяет использовать такую магическую штуку как лямбда. Эта статья не про это, но надо понимать, почему мы можем это использовать. Так вот, при помощи лямбд, выше указанный Consumer можно переписать вот так: Consumer consumer = (obj) -> System.out.println(obj); Это означает, что Java видит, что на вход будет передано нечто под именем obj, а дальше для этого obj будет выполнено выражение после ->. А что касается итерирования, то теперь мы можем делать так:

List<String> names = Arrays.asList("John", "Sara", "Jack");
Consumer consumer = (obj) -> System.out.println(obj);
names.forEach(consumer);
Если вы перейдёте в метод forEach, то увидите, что всё до безумия просто. Там всеми любимый нами for-each loop:

default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
}
Также есть возможность при помощи итератора красиво удалить элемент, например:

List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Predicate predicate = (obj) -> obj.equals("John");
names.removeIf(predicate);
В данном случае метод removeIf принимает на вход не Consumer, а Predicate. Он возвращает boolean. В данном случае если предикат говорит "true", тогда выполнится удаление элемента. Интересно, что не всё очевидно и здесь )) Ну а как вы хотите? Надо же людям дать простор для создания паззлеров на конференции. Например, возьмём такой код удаления всего, до чего сможет дойти итератор после какого-то итерирования:

List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next(); // Курсор на John
while (iterator.hasNext()) {
    iterator.next(); // Следующий элемент
    iterator.remove(); // Удалили его
}
System.out.println(names);
Хорошо, тут всё работает. Но мы помним, что Java 8 ведь. Поэтому, попробуем упростить код:

List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next(); // Курсор на John
iterator.forEachRemaining(obj -> iterator.remove());
System.out.println(names);
Правда же стало красивее? Однако, тут будет java.lang.IllegalStateException. И причиной тут... ошибка в Java. Оказывается, она исправлена, но в JDK 9. Вот ссылка на таск в OpenJDK: Iterator.forEachRemaining vs. Iterator.remove. Естественно, это уже обсуждалось: Why iterator.forEachRemaining doesnt remove element in the Consumer lambda? Ну и ещё один способ — непосредственно через Stream API:

List<String> names = new ArrayList(Arrays.asList("John", "Sara", "Jack"));
Stream<String> stream = names.stream();
stream.forEach(obj -> System.out.println(obj));

Выводы

Как мы видели из всего материала выше, цикл for-each loop — всего лишь "синтаксический сахар" над итератором. Однако, он теперь много где используется. Кроме того, нужно любое средство использовать с осторожностью. Например, безобидный forEachRemaining может крыть неприятные сюрпризы. И это ещё раз доказывает, что unit тесты нужны. Хороший тест смог бы выявить такой кейс использования в вашем коде. Что можно посмотреть/почитать по теме: #Viacheslav
Комментарии (22)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Максим Li Уровень 36
5 декабря 2023
Хорошая статья!
dim11981 Уровень 47
29 августа 2023
видео с ITsubbotnik недоступно
8 мая 2023

int outerVar = 0;
for (;outerVar < 10;) {
	outerVar += 2;
	System.out.println("Value = " + outerVar);
}
В приведенном коде используется оператор += для увеличения значения переменной outerVar на 2 в каждой итерации цикла. Оператор += означает "увеличить переменную на заданное значение и присвоить ей результат". То есть, выражение outerVar += 2 можно интерпретировать как outerVar = outerVar + 2. При этом важно понимать, что порядок операций в этом выражении очень важен. Если бы мы написали outerVar =+ 2 вместо outerVar += 2, то это выражение будет интерпретироваться как outerVar = +2, то есть, переменной outerVar будет присвоено значение 2. В этом случае цикл будет бесконечным, так как условие outerVar < 10 никогда не будет выполнено.
8 мая 2023
В Java есть различие между выражениями (expressions) и операторами (statements). Выражение (expression) - это любой код, который может быть вычислен в определенное значение. Выражения могут содержать переменные, литералы, операторы и вызовы методов, и их результат можно присвоить переменной или использовать в качестве аргумента метода. Примеры выражений в Java:

int x = 10;
int y = 5;
int z = x + y; // z является выражением, которое вычисляет сумму x и y
String s = "Hello, " + "world!"; // конкатенация двух строк - это тоже выражение
boolean b = (x > y); // сравнение двух значений - это выражение, которое возвращает булево значение true или false
Оператор (statement) - это фрагмент кода, который выполняет определенное действие. Операторы могут содержать выражения, но они также могут включать в себя управляющие конструкции, такие как if, for, while, switch и т. д. Операторы не возвращают значения, и поэтому их нельзя использовать в качестве аргументов методов или присваивать значения переменным. Примеры операторов в Java:

int x = 10;
int y = 5;
if (x > y) {
    System.out.println("x is greater than y"); // if является оператором, который выполняет действие в зависимости от условия
}
for (int i = 0; i < 10; i++) {
    System.out.println(i); // for является оператором, который выполняет действие несколько раз в цикле
}
while (x > 0) {
    x--; // while является оператором, который выполняет действие до тех пор, пока условие истинно
}
Важно отметить, что выражения могут быть частью операторов. Например, условие в операторе if или в циклах for и while - это выражение. Также можно использовать выражения в качестве аргументов методов, например:

int x = 10;
int y = Math.max(x, 5); // Math.max(x, 5) - это выражение, которое возвращает большее из двух значений
НИКОЛАЙ Уровень 33
3 мая 2023
в настоящий момент, при созданиии СПИЦИАЛЬНОГО списка путем добавления объектов с помощью метода Arrays.asList(): Да, в него НОВЫЙ элемент добавить мутем имя.add(); - НЕЛЬЗЯ (ошибка не выскакивает, но элемент (объект) не добавляется), т.е. всетаки size у списка становится неизменяемый в сторону увеличения....а вот удалить по индексу или по элементу с помощью remove(); - Всегда пожалуйста!!! Попробуйте сами!!!
Gleb Уровень 24 Expert
18 марта 2023
Клёвая статья, но качество картинок 10 шакалов из 10
Aлександр 52 Уровень 21
12 декабря 2022
Крутейшая статья!
normalosos Уровень 34
8 октября 2022
Статья хорошая. Спасибо. Но заметил несколько грамматических ошибок и пару опечаток.
VadimEfim Уровень 27
2 сентября 2022
статья очень крутая и интересная, спасибо
White Rabbit Уровень 22
24 июля 2022
Статья очень интересная. Но на данном этапе не понятно абсолютно нихрена о чём вообще речь. А так 10/10