Если коротко: обычный
for это счетчик, которым вы управляете сами, а
for-each это короткая запись обхода «от начала до конца», появившаяся в Java 5. Она работает с массивами и с любым объектом, который реализует
Iterable, а под капотом для коллекций разворачивается в обычный итератор. Отсюда и все ее ограничения: нет доступа к индексу, нельзя идти назад и нельзя безопасно удалять элементы по ходу обхода.
Кратко
- Обычный
for это три необязательных выражения: инициализация, условие выхода и шаг. Пропустить можно любое, а for (;;) даст бесконечный цикл.
for-each появился в Java 5 и работает только с массивами и с объектами, которые реализуют Iterable.
- Для коллекций
for-each это синтаксический сахар над итератором: компилятор разворачивает его в вызовы iterator(), hasNext() и next().
- Итератор запоминает счетчик изменений
modCount. Если поменять коллекцию в обход итератора, получите ConcurrentModificationException.
- Безопасно удалить элемент во время обхода можно только через
iterator.remove(), а в for-each доступа к итератору нет.
- В Java 8 добавились
forEach(Consumer), removeIf(Predicate) и Stream API, но подводные камни есть и у них.
Вступление
Циклы это одна из базовых структур языков программирования. Например, на сайте Oracle есть раздел "
Lesson: Language Basics", в котором циклам отведен отдельный урок "
The for Statement".
Освежим в памяти основное:
Цикл состоит из трех выражений (statements):
инициализация (initialization),
условие (termination) и
инкремент (increment):
Интересно, что все они являются опциональными, то есть мы можем, если захотим, написать:
for (;;){
}
Правда, в таком случае мы получим бесконечный цикл, т.к. у нас не указано условие выхода из цикла (termination).
Выражение инициализации выполняется только один раз, перед выполнением всего цикла. Стоит всегда помнить, что у цикла своя область видимости. Это значит, что
initialization,
termination,
increment и тело цикла видят одни и те же переменные. Область видимости всегда легко определить по фигурным скобкам. Все, что внутри скобок, не видно снаружи скобок, однако все, что снаружи скобок, видно внутри скобок.
Инициализация это просто выражение. Например, вместо инициализации переменной можно вообще сделать вызов метода, который ничего не будет возвращать. Или просто пропустить, оставив перед первой точкой с запятой пустое место.
Следующее выражение указывает
условие выхода (termination). Пока оно
true, цикл выполняется. А если
false, новая итерация не начнется. Если посмотреть на картинку ниже, на ней мы при компиляции получаем ошибку и IDE будет ругаться: наше выражение в цикле недостижимо. Поскольку у нас не будет ни одной итерации в цикле, мы сразу выйдем, т.к. false:
За выражением в
termination statement стоит следить: от этого напрямую зависит, не будет ли в вашем приложении бесконечных циклов.
Инкремент это самое простое выражение. Оно выполняется после каждой успешной итерации цикла. И это выражение также можно пропустить. Например:
int outerVar = 0;
for (;outerVar < 10;) {
outerVar += 2;
System.out.println("Value = " + outerVar);
}
Как видно из примера, каждую итерацию цикла мы будем делать прирост с шагом 2, но только пока значение
outerVar меньше 10. Кроме того, поскольку выражение в
increment statement на самом деле просто выражение, в нем может быть все что угодно.
Поэтому вместо инкремента никто не запрещает использовать декремент, т.е. уменьшать значение.
Стоит всегда следить за написанием инкремента.
+= прибавляет к переменной значение справа и записывает результат обратно. А вот если в примере выше написать наоборот, мы получим бесконечный цикл, но по другой причине, чем кажется: составного оператора
=+ в Java просто нет. Компилятор читает такую запись как
outerVar = +2, то есть как обычное присваивание числа со знаком плюс. Каждая итерация будет заново класть в переменную двойку, условие никогда не станет ложным.
Кстати, с инкрементом вида
++ так же. Например, у нас был цикл:
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 each loop
Начиная с Java 1.5 разработчики Java дали нам конструкцию
for each loop, описанную на сайте Oracle в Guide под названием "
The For-Each Loop" или для версии
1.5.0.
В общем случае, будет выглядеть следующим образом:
В 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 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. Каждое структурное изменение списка увеличивает этот счетчик. И что нам с того? А то, что итератор следит, чтобы при работе не произошло изменение коллекции, по которой он итерируется. Тут стоит уточнить: в
AbstractList лежит само поле
modCount, и наследники его получают, а вот реализация итератора у каждого списка своя. У
ArrayList, например, это собственный внутренний класс
Itr, который этот унаследованный счетчик и проверяет.
Рассмотрим простой пример:
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.
А вот неизменяемым (
immutable) он при этом не является, и тут легко ошибиться.
Документация метода говорит прямым текстом: «The list returned by this method is modifiable». Метод
set у него работает и пишет прямо в исходный массив, а вот
add и
remove бросят
UnsupportedOperationException, потому что меняют размер.
А еще мы получим ошибку, т.к. создав итератор, мы запомнили в нем
modcount. Потом мы изменили "снаружи" (т.е. не через итератор) состояние коллекции и выполнили метод итератора. Поэтому, получим ошибку:
java.util.ConcurrentModificationException. Про то, какие бывают стратегии поведения при такой модификации, есть отдельный
разбор fail-fast и fail-safe.
Чтобы этого избежать, изменение при итерировании нужно выполнять через сам итератор, а не через обращение к коллекции:
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. А
hasNext() у
ArrayList это всего лишь проверка
cursor != size. Отсюда неочевидное следствие: если в цикле по трем элементам удалить через саму коллекцию предпоследний, размер сравняется с позицией курсора,
hasNext() вернет
false, цикл тихо закончится и никакого исключения не будет. А вот удаление последнего элемента исключение как раз даст: курсор к тому моменту уже обогнал новый размер. Подробнее тут лучше посмотреть доклад про 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. Он позволяет продвигаться не только вперед, но и назад, а также позволяет узнавать индекс предыдущего элемента и следующего. Кроме того, позволяет заменить текущий элемент или вставить новый на позицию, между текущим положением итератора и следующим. Как вы догадались, такое возможно потому, что для
List реализован доступ по индексу.
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 и for-each: в чем разница
Все сказанное выше складывается в короткое сравнение.
|
Обычный for |
for-each |
| С чем работает |
С чем угодно: условием может быть любое выражение |
Только с массивом или объектом, реализующим Iterable |
| Доступ к индексу |
Есть, переменная счетчика под рукой |
Нет, доступен только сам элемент |
| Направление обхода |
Любое: вперед, назад, с любым шагом |
Только вперед и по одному элементу |
| Удаление по ходу обхода |
Можно, но индекс придется поправить вручную |
Нельзя: доступа к итератору нет, будет ConcurrentModificationException |
| Что происходит под капотом |
Ничего лишнего, те самые три выражения |
Для массива обход по индексу, для Iterable вызов iterator() |
Выводы
Как мы видели из всего материала выше, цикл
for-each loop это всего лишь "синтаксический сахар" над итератором. Однако, он теперь много где используется. Кроме того, нужно любое средство использовать с осторожностью. Например, безобидный
forEachRemaining может скрывать неприятные сюрпризы. И это еще раз доказывает, что unit-тесты нужны. Хороший тест смог бы выявить такой кейс использования в вашем коде.
Что можно посмотреть/почитать по теме:
Вопросы и ответы
Чем for-each отличается от обычного for?
Обычный
for дает вам счетчик и полный контроль: можно идти назад, через один, останавливаться по любому условию и обращаться к элементу по индексу.
for-each проходит коллекцию от начала до конца по одному элементу и индекс не показывает. Взамен он короче и в нем невозможно ошибиться с границами массива. Если индекс нужен, берите обычный
for, во всех остальных случаях
for-each читается лучше.
С какими типами работает for-each?
С массивами и с любым объектом, чей класс реализует интерфейс
java.lang.Iterable. Все коллекции из
java.util под это подходят, потому что
Collection наследует
Iterable. Свой класс тоже можно научить работать с
for-each: достаточно реализовать
Iterable и отдавать из метода
iterator() рабочий итератор.
Почему в for-each нельзя удалять элементы?
Потому что удалять во время обхода безопасно умеет только сам итератор, через метод
iterator.remove(), а в
for-each итератор спрятан, доступа к нему у вас нет. Если позвать
list.remove() прямо внутри цикла, вы поменяете коллекцию в обход итератора, счетчик изменений разойдется с запомненным, и полетит
ConcurrentModificationException.
Что такое ConcurrentModificationException и когда он возникает?
Это исключение, которым итератор сообщает, что коллекцию изменили у него за спиной. При создании итератор запоминает значение поля
modCount, счетчика структурных изменений коллекции, и сверяет его на каждом шаге. Любое добавление или удаление в обход итератора этот счетчик увеличивает, и на следующем вызове
next() проверка не сходится. Название вводит в заблуждение: потоки тут ни при чем, хватит и одного.
Что произойдет, если написать for (;;)?
Получится бесконечный цикл. Все три выражения в заголовке
for необязательны, и если условие выхода не указано, оно считается истинным всегда. Такая запись встречается в реальном коде, например в серверных циклах обработки, но тогда выход организуют изнутри, через
break или
return.
Как безопасно удалить элементы из списка во время перебора?
Способов три. Первый: явно взять итератор и удалять через
iterator.remove(), предварительно вызвав
next(). Второй, начиная с Java 8, метод
removeIf(Predicate): он сам обходит коллекцию и удаляет все, что подходит под условие, одной строкой. Третий: собрать новый список через Stream API, не трогая исходный. А вот
for-each с вызовом
list.remove() внутри не подойдет ни при каких обстоятельствах.
Читайте также
#Viacheslav
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ