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

For і For-Each Loop: оповідання про те, як я ітерувався, ітерувався, та не витірувався

Стаття з групи Random UA

Вступ

Цикли - це одна з базових структур мов програмування. Наприклад, на сайті 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 масиви є динамічно створюваними об'єктами. Про це нам говорить специфікація мови:У загальному випадку, масиви це трохи 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 є функціональним інтерфейсом, що означає, що всередині інтерфейсу є лише один нереалізований абстрактний метод, що вимагає обов'язкової реалізації в тих класах, які вкажуть 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 в 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
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ