Знакомство с лямбда-выражениями в квесте 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 принимает аргумент s и выводит его на экран. Лямбда-выражение было передано в функцию 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: руководство для начинающих" под авторством Роберта Шилдта — на мой взгляд, лямбды и ссылки на функции в ней описаны довольно толково.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
ClassName::instanceMethodName