Лямбды и функциональные интерфейсы
Скомпилировал один файл, в котором есть лямбда и анонимный класс. На диске появилось два файла классов: сам L1 и L1$1 - это анонимный класс. Лямбда не породила ничего.
Отсюда первый вывод: лямбда - другой механизм, а не короткая запись анонимного класса. На собесе проверяют словарь (чем Function отличается от Predicate), правила захвата переменных и вот эту пару тонкостей про устройство.
// Формулировки: «что такое функциональный интерфейс?», «почему переменная в лямбде должна быть effectively final?», «чем лямбда отличается от анонимного класса?».
Что такое функциональный интерфейс
Загляни в байткод того же файла. На месте лямбды стоит инструкция invokedynamic, на месте анонимного класса - new L1$1. Анонимный класс создаётся как обычный объект заранее известного класса; лямбда собирается рантаймом при первом исполнении, и отдельного класса под неё в jar-файле нет.
Функциональный интерфейс - интерфейс ровно с одним абстрактным методом, то есть методом без тела. Именно по нему компилятор понимает, во что превращать лямбду: раз метод один, догадываться не о чем. Аннотация @FunctionalInterface - страховка от коллеги, который добавит второй метод и сломает все лямбды разом.
Готовый словарь лежит в java.util.function и покрывает почти все сигнатуры: Function<T,R> преобразует одно в другое, Predicate<T> проверяет условие и возвращает да или нет, Consumer<T> что-то делает со значением и ничего не возвращает, Supplier<T> наоборот - выдаёт значение, ничего не принимая. Плюс Bi-версии на два аргумента и UnaryOperator, когда тип на входе и выходе один.
Runnable lambda = () -> System.out.println("л");
Runnable anon = new Runnable() { public void run() { System.out.println("а"); } };
// после javac на диске: L1.class и L1$1.class - второй это анонимный класс
// в байткоде: invokedynamic #7 // лямбда
// new #11 // class L1$1 - анонимный класс- функциональный интерфейс
- интерфейс ровно с одним абстрактным методом
- invokedynamic
- инструкция, которой JVM собирает лямбду на лету, без отдельного класса
Ссылка на метод и подмена перегрузки
Взял список из одного массива символов {'д','а'} и применил map(String::valueOf). Получил [да]. А если бы тот же массив ушёл в String.valueOf((Object) chars), вышло бы [C@6989da5e - адрес объекта вместо текста.
Разница в том, какую из перегрузок метода выбрал компилятор: у String.valueOf есть версия под char[] и версия под Object, и выбирается она по типу, известному на компиляции. Ссылка на метод (её пишут через двойное двоеточие) этот выбор не показывает - его надо держать в голове.
Форм ссылки четыре. Тип::статическийМетод. объект::метод - на конкретном уже существующем объекте. Тип::методЭкземпляра - тут первый аргумент становится тем объектом, у которого метод зовут: String::length читается как s -> s.length(), а String::compareTo как (a, b) -> a.compareTo(b). И Тип::new - конструктор. Собранное поведение при этом передаётся как обычное значение: f.andThen(g) сделает сначала f, потом g, f.compose(g) наоборот, а у предикатов есть and, or и negate.
- ссылка на метод
- запись через двойное двоеточие вместо лямбды-обёртки
- andThen / compose
- склейка функций: после / до
Захват переменных и почему счётчик не работает
Попробовал собрать счётчик прямо в лямбде - int count = 0; list.forEach(x -> count++). Компилятор отказал дословно: local variables referenced from a lambda expression must be final or effectively final.
Effectively final означает «фактически неизменяемая»: переменной присвоили значение один раз и больше не переприсваивали, даже если слово final не написано. Требование не про строгость, а про время жизни. Локальная переменная лежит в стеке метода, а лямбда может исполниться позже, когда метод давно закончился, или вообще в другом потоке. Поэтому захватывается копия значения, и компилятор следит, чтобы копия не разошлась с оригиналом.
Известная лазейка - завести массив из одного элемента: int[] counter = {0}, ссылка-то не меняется. Я прогнал такой счётчик на 200 000 параллельных увеличений три раза: 194517, потом 200000, потом снова 200000. То есть гонка тут настоящая, но показывается через раз - и это худший вид ошибки, который проходит тесты и падает на проде. Честные способы посчитать - обычный цикл, reduce или счётный коллектор.
int count = 0;
list.forEach(x -> count++);
// error: local variables referenced from a lambda expression
// must be final or effectively final
int[] counter = {0}; // компилируется, но это гонка
// 200 000 увеличений параллельно: 194517, 200000, 200000- effectively final
- переменная, которой присвоили значение один раз и не меняли
Как отвечать: «Почему в лямбде нельзя менять локальную переменную снаружи?»
Потому что лямбда захватывает не саму переменную, а её значение. Локальная переменная живёт в стеке метода, в его фрейме - так называют кусок стека, который метод занимает на время работы и освобождает на выходе. Лямбда может сработать позже, когда фрейм уже освобождён, или в другом потоке. Компилятор поэтому требует effectively final - чтобы переменную присвоили один раз и не меняли, тогда захваченная копия гарантированно совпадает с оригиналом. Формулировка ошибки прямая: local variables referenced from a lambda expression must be final or effectively final. Обходной приём с массивом из одного элемента компилируется, потому что неизменна ссылка, но я так не делаю: на параллельном стриме это гонка, и я её ловил - из 200 тысяч увеличений один прогон дал 194 тысячи, а два соседних отработали правильно. Нужен счётчик или накопитель - беру reduce или коллектор, они спроектированы под корректное слияние.
Почему это сильный ответ: названа причина через время жизни стека и потоки, а не пересказано правило; разобран популярный обходной приём и показано, чем он опасен именно на практике.
На чём валят
- −Менять локальную переменную из лямбды. Не скомпилируется, а приём с массивом из одного элемента даёт гонку, которая проявляется через раз.
- −Ждать, что checked-исключение вылетит наружу из стрима. Function его не объявляет, компилятор скажет unreported exception - оборачивай внутри лямбды.
- −Путать this. Внутри лямбды это объект окружающего класса, внутри анонимного класса - сам анонимный объект.
- −Ссылка на метод с перегрузками. String::valueOf на массиве символов даёт текст, а через Object дал бы адрес объекта - выбор перегрузки в записи не виден.
- −Побочные действия в лямбдах параллельных операций. Конвейер должен быть чистым, иначе получаешь гонку.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
- Какой функциональный интерфейс у Predicate<T>?A)T -> T: принимает значение и возвращает изменённое значение того же типа, метод applyB)T -> void: принимает значение и выполняет побочный эффект, ничего не возвращая, метод acceptC)T -> boolean (проверка условия), метод testD)() -> T: не принимает аргументов и порождает новое значение по запросу, метод get
показать ответ и разбор
+C)T -> boolean (проверка условия), метод test// разбор: Базовые функциональные интерфейсы java.util.function: Predicate<T> — T→boolean (test, условия/фильтры); Function<T,R> — T→R (apply, преобразование); Consumer<T> — T→void (accept, побочный эффект); Supplier<T> — ()→T (get, ленивое порождение). Плюс BiFunction, UnaryOperator, BinaryOperator и примитивные варианты (IntPredicate) во избежание боксинга. Знание этих сигнатур нужно, чтобы читать API стримов и писать свои методы, принимающие поведение.
- Во что можно превратить лямбду x -> x.toString() короче?A)В анонимный класс — это более короткая запись лямбды в современной JavaB)В статический импорт метода toString, после чего его можно звать вообще без имени объектаC)В default-метод функционального интерфейса, переопределив в нём поведение вызова toStringD)В ссылку на метод Object::toString
показать ответ и разбор
+D)В ссылку на метод Object::toString// разбор: Когда лямбда лишь вызывает существующий метод, её заменяют ССЫЛКОЙ НА МЕТОД — короче и читаемее. Формы: статическая Integer::parseInt, на конкретный объект system.out::println, на произвольный объект типа String::toLowerCase (первый аргумент становится получателем), на конструктор ArrayList::new. Компилятор сам выводит, какую перегрузку взять по целевому функциональному интерфейсу. Method reference — тот же функциональный объект, просто компактная форма.
- Чем лямбда отличается от анонимного внутреннего класса?A)У лямбды нет своего this и отдельного .class — this ссылается на окружающий объектB)Лямбда создаёт новый экземпляр на каждый вызов, а анонимный класс переиспользует один и тот же объектC)Лямбда может иметь собственные поля и несколько методов, а анонимный класс ограничен одним методомD)Внутри лямбды ключевое слово this ссылается на саму лямбду, как и в анонимном классе на его экземпляр
показать ответ и разбор
+A)У лямбды нет своего this и отдельного .class — this ссылается на окружающий объект// разбор: Ключевое отличие: в лямбде this ссылается на ОКРУЖАЮЩИЙ объект (тот, в чьём методе лямбда написана), а в анонимном классе this — это сам экземпляр анонимного класса. Ещё: анонимный класс порождает отдельный .class-файл и всегда новый объект, а лямбда компилируется через invokedynamic (без отдельного класса, экземпляр может переиспользоваться). Лямбда лаконичнее и не может иметь своего состояния/несколько методов — это ровно один функциональный метод.
- Что делает Function.andThen(g) у функции f?A)Выполняет f и g параллельно в разных потоках и возвращает результат того, что завершился раньшеB)Композиция: сначала применяет f, затем g к её результатуC)Применяет сначала g, а потом f к результату g — то есть в обратном порядке относительно имёнD)Выбирает между f и g в зависимости от того, вернула ли f значение или бросила исключение
показать ответ и разбор
+B)Композиция: сначала применяет f, затем g к её результату// разбор: Function композируется двумя способами: f.andThen(g) даёт x → g(f(x)) (сначала f, потом g), а f.compose(g) — x → f(g(x)) (сначала g, потом f). Так из мелких функций собирают конвейеры преобразований без промежуточных переменных. Аналогично Predicate имеет and/or/negate, Consumer — andThen. Это делает функциональные интерфейсы «строительными блоками» для декларативного кода.
- Зачем существуют примитивные функциональные интерфейсы (IntFunction, IntPredicate)?A)Они работают только внутри примитивных стримов и в обычном коде их использовать запрещено компиляторомB)Они позволяют функциям возвращать сразу несколько примитивных значений, чего обычные интерфейсы не умеютC)Избежать автобоксинга примитивов в обёртки при массовых операцияхD)Они существуют лишь для обратной совместимости со старым кодом и в новых проектах не применяются
показать ответ и разбор
+C)Избежать автобоксинга примитивов в обёртки при массовых операциях// разбор: Обобщённые интерфейсы работают с ОБЪЕКТАМИ, поэтому Predicate<Integer> на каждый элемент боксит int в Integer (аллокация, разыменование) — на больших объёмах это заметная нагрузка. Примитивные варианты (IntPredicate, IntFunction, ToIntFunction, IntUnaryOperator) работают напрямую с примитивами без боксинга. Их же используют примитивные стримы (IntStream), давая и скорость, и специализированные операции (sum, average). Это важная деталь производительности в горячем коде.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.