JavaRush /Java блог /Random UA /Лямбди та посилання на методи в ArrayList.forEach - як це...
Anonymous #2633326
20 рівень

Лямбди та посилання на методи в ArrayList.forEach - як це працює

Стаття з групи Random UA
Знайомство з лямбда-виразами у квесті Java Syntax Zero починається з вкрай специфічного прикладу:
ArrayList<string> list = new ArrayList<>();
Collections.addAll(list, "Вітання", "як", "дела?");

list.forEach( (s) -> System.out.println(s) );
Автори лекції розбирають лямбди та посилання на методи за допомогою стандартної функції forEach класу ArrayList. Особисто мені було важко зрозуміти зміст того, що відбувається, оскільки реалізація цієї функції, як і пов'язаного з нею інтерфейсу, залишається «під капотом». Звідки береться аргумент (s) , куди передається функція println() - питання, які нам доведеться відповідати самостійно. На щастя, за допомогою IntelliJ IDEA ми можемо легко зазирнути у внутрішній пристрій класу ArrayList і розмотати цю локшину від самого початку. Якщо ви теж нічого не зрозуміли і хочете розібратися, намагатимусь вам у цьому хоч трохи допомогти. Лямбда-вираз і ArrayList.forEach - як це працює З лекції ми вже знаємо, щоЛямбда-вираз - це реалізація функціонального інтерфейсу . Тобто ми оголошуємо інтерфейс з однією функцією, а за допомогою лямбди описуємо, що ця функція робить. Для цього необхідно: 1. Створити багатофункціональний інтерфейс; 2. Створити змінну, тип якої відповідає функціональному інтерфейсу; 3. Присвоїти цій змінній лямбда-вираз, що описує реалізацію функції; 4. Викликати функцію через звернення до змінної (можливо, я грубуватий у термінології, але так найзрозуміліше). Наведу найпростіший приклад з гугла, додавши його докладними коментарями (спасибі авторам сайту metanit.com):
interface Operationable {
    int calculate(int x, int y);
    // Единственная функция в интерфейсе — значит, это функциональный интерфейс,
    // который можно реализовать с помощью лямбды
}

public class LambdaApp {

    public static void main(String[] args) {

        // Создаём переменную operation типа Operationable (так называется наш функциональный интерфейс)
        Operationable operation;
        // Прописываем реализацию функции calculate с помощью лямбды, на вход подаём x и y, на выходе возвращаем их сумму
        operation = (x,y)->x+y;

        // Теперь мы можем обратиться к функции calculate через переменную operation
        int result = operation.calculate(10, 20);
        System.out.println(result); //30
    }
}
Тепер повернемося, наприклад, з лекції. У колекцію list додаються кілька елементів типу String. Потім елементи виводяться за допомогою стандартної функції forEach , яка викликається у об'єкта list . Як аргумент у функцію передається лямбда-вираз із якимось дивним параметром s .
ArrayList<string> list = new ArrayList<>();
Collections.addAll(list, "Вітання", "як", "дела?");

list.forEach( (s) -> System.out.println(s) );
Якщо ви відразу не зрозуміли, що тут сталося, то ви не самотні. На щастя, у IntelliJ IDEA є чудова комбінація клавіш Ctrl+Ліва_Кнопка_Миші . Якщо навести курсор на forEach і натиснути цю комбінацію, відкриється вихідний стандартний клас ArrayList, в якому ми побачимо реалізацію методу forEach :
public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final Object[] es = elementData;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i));
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
Ми бачимо, що на вхід подається аргумент action типу Consumer . Наведемо курсор на слово Consumer і знову натиснемо чарівну комбінацію Ctrl+LMB . Відкриється опис інтерфейсу Consumer . Якщо прибрати з нього default-реалізацію (нам вона зараз не важлива), побачимо такий код:
public interface Consumer<t> {
   void accept(T t);
}
Отже. У нас є інтерфейс Consumer з єдиною функцією accept , яка приймає один аргумент будь-якого типу. Якщо функція одна, значить функціональний інтерфейс, і його реалізацію можна прописати через лямбда-вираз. Ми вже з'ясували, що в ArrayList є функція forEach , яка приймає на вхід реалізацію інтерфейсу Consumer як аргумент action . Крім того, у функції forEach ми знаходимо такий код:
for (int i = 0; modCount == expectedModCount && i < size; i++)
    action.accept(elementAt(es, i));
Цикл for фактично перебирає всі елементи колекції ArrayList. Усередині циклу ми бачимо виклик функції accept об'єкта action — пам'ятаєте, як ми зверталися до operation.calculate? У функцію accept передається поточний елемент колекції. Тепер ми нарешті можемо повернутися до вихідного лямбда-виразу і зрозуміти, що воно робить. Зберемо весь код в одну купу:
public interface Consumer<t> {
   void accept(T t); // Функция, которую мы реализуем лямбда-выражением
}

public void forEach(Consumer<? super E> action) // В action хранится об'єкт Consumer, в котором функция accept реализована нашей лямбдой {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final Object[] es = elementData;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i)); // Вызываем нашу реализацию функции accept интерфейса Consumer для каждого елемента коллекции
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

//...

list.forEach( (s) -> System.out.println(s) );
Наше лямбда-вираз - це реалізація функції accept , описаної в інтерфейсі Consumer . За допомогою лямбди ми прописали, що функція accept приймає аргумент і виводить його на екран. Лямбда-вираз було передано в функцію forEach в якості її аргументу action , який якраз і зберігає в собі реалізацію інтерфейсу Consumer . Тепер функція forEach може викликати нашу реалізацію інтерфейсу Consumer за допомогою такого рядка:
action.accept(elementAt(es, i));
Таким чином, вхідний аргумент s у лямбда-вираженні - це черговий елемент колекції ArrayList , який передається в нашу реалізацію інтерфейсу Consumer . На цьому все: логіку роботи лямбда-вираження в ArrayList.forEach ми розібрали. Посилання на метод ArrayList.forEach — а це як працює? Наступним кроком у лекції розбираються посилання методи. Щоправда, розбираються вони зовсім дивно — після прочитання лекції в мене не було жодних шансів зрозуміти, що ж робить цей код:
list.forEach( System.out::println );
Спочатку знову трохи теорії. Посилання метод — це, якщо дуже грубо, реалізація функціонального інтерфейсу, описана інший функцією . Знову ж почну із простого прикладу:
public interface Operationable {
    int calculate(int x, int y);
    // Единственная функция в интерфейсе — значит, это функциональный интерфейс
}

public static class Calculator {
    // Создадим статический класс Calculator и пропишем в нём метод methodReference.
    // Именно он будет реализовывать функцию calculate из интерфейса Operationable.
    public static int methodReference(int x, int y) {
        return x+y;
    }
}

public static void main(String[] args) {
    // Создаём переменную operation типа Operationable (так называется наш функциональный интерфейс)
    Operationable operation;
    // Теперь реализацией интерфейса будет не лямбда-выражение, а метод methodReference из нашего класса Calculator
    operation = Calculator::methodReference;

    // Теперь мы можем обратиться к функции интерфейса через переменную operation
    int result = operation.calculate(10, 20);
    System.out.println(result); //30
}
Повернемося, наприклад, з лекції:
list.forEach( System.out::println );
Нагадаю, що System.out - це об'єкт типу PrintStream, який має функцію println . Давайте наведемо курсор на println і натисніть Ctrl+LMB :
public void println(String x) {
    if (getClass() == PrintStream.class) {
        writeln(String.valueOf(x));
    } else {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
}
Відзначимо дві ключові особливості: 1. Функція println не повертає нічого (void). 2. Функція println отримує один аргумент на вхід. Нічого не нагадує?
public interface Consumer<t> {
   void accept(T t);
}
Все правильно - сигнатура функції accept - це загальний випадок сигнатури методу println ! Отже, останній можна з успіхом використовувати як посилання на метод — тобто println стає конкретною реалізацією функції accept :
list.forEach( System.out::println );
Ми передали функцію println об'єкта System.out як аргумент функції forEach . Принцип той самий, як і з лямбдою: тепер forEach може передати елемент колекції у функцію println через звернення action.accept(elementAt(es, i)) . Фактично тепер це можна читати як System.out.println(elementAt(es, i)) .
public void forEach(Consumer<? super E> action) // В action хранится об'єкт Consumer, в котором функция accept реализована методом println {
        Objects.requireNonNull(action);
        final int expectedModCount = modCount;
        final Object[] es = elementData;
        final int size = this.size;
        for (int i = 0; modCount == expectedModCount && i < size; i++)
            action.accept(elementAt(es, i)); // Функция accept теперь реализована методом System.out.println!
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
Сподіваюся, що я хоч трохи прояснив ситуацію для тих, хто вперше знайомиться з лямбдами та посиланнями на методи. Насамкінець порекомендую найвідомішу книгу "Java: керівництво для початківців" під авторством Роберта Шилдта - на мій погляд, лямбди і посилання на функції в ній описані досить розумно.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ