сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Stream API

Лямбды и функциональные интерфейсы

Лямбды: на этом языке написана вся Java после восьмёрки

Скомпилировал один файл, в котором есть лямбда и анонимный класс. На диске появилось два файла классов: сам 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, остальные разбираются в тренажёре.

  1. #lambdas_functional1 / 5
    Какой функциональный интерфейс у Predicate<T>?
    A)T -> T: принимает значение и возвращает изменённое значение того же типа, метод apply
    B)T -> void: принимает значение и выполняет побочный эффект, ничего не возвращая, метод accept
    C)T -> boolean (проверка условия), метод test
    D)() -> 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 стримов и писать свои методы, принимающие поведение.

  2. #lambdas_functional2 / 5
    Во что можно превратить лямбду x -> x.toString() короче?
    A)В анонимный класс — это более короткая запись лямбды в современной Java
    B)В статический импорт метода toString, после чего его можно звать вообще без имени объекта
    C)В default-метод функционального интерфейса, переопределив в нём поведение вызова toString
    D)В ссылку на метод Object::toString
    показать ответ и разбор
    +D)В ссылку на метод Object::toString

    // разбор: Когда лямбда лишь вызывает существующий метод, её заменяют ССЫЛКОЙ НА МЕТОД — короче и читаемее. Формы: статическая Integer::parseInt, на конкретный объект system.out::println, на произвольный объект типа String::toLowerCase (первый аргумент становится получателем), на конструктор ArrayList::new. Компилятор сам выводит, какую перегрузку взять по целевому функциональному интерфейсу. Method reference — тот же функциональный объект, просто компактная форма.

  3. #lambdas_functional3 / 5
    Чем лямбда отличается от анонимного внутреннего класса?
    A)У лямбды нет своего this и отдельного .class — this ссылается на окружающий объект
    B)Лямбда создаёт новый экземпляр на каждый вызов, а анонимный класс переиспользует один и тот же объект
    C)Лямбда может иметь собственные поля и несколько методов, а анонимный класс ограничен одним методом
    D)Внутри лямбды ключевое слово this ссылается на саму лямбду, как и в анонимном классе на его экземпляр
    показать ответ и разбор
    +A)У лямбды нет своего this и отдельного .class — this ссылается на окружающий объект

    // разбор: Ключевое отличие: в лямбде this ссылается на ОКРУЖАЮЩИЙ объект (тот, в чьём методе лямбда написана), а в анонимном классе this — это сам экземпляр анонимного класса. Ещё: анонимный класс порождает отдельный .class-файл и всегда новый объект, а лямбда компилируется через invokedynamic (без отдельного класса, экземпляр может переиспользоваться). Лямбда лаконичнее и не может иметь своего состояния/несколько методов — это ровно один функциональный метод.

  4. #lambdas_functional4 / 5
    Что делает 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. Это делает функциональные интерфейсы «строительными блоками» для декларативного кода.

  5. #lambdas_functional5 / 5
    Зачем существуют примитивные функциональные интерфейсы (IntFunction, IntPredicate)?
    A)Они работают только внутри примитивных стримов и в обычном коде их использовать запрещено компилятором
    B)Они позволяют функциям возвращать сразу несколько примитивных значений, чего обычные интерфейсы не умеют
    C)Избежать автобоксинга примитивов в обёртки при массовых операциях
    D)Они существуют лишь для обратной совместимости со старым кодом и в новых проектах не применяются
    показать ответ и разбор
    +C)Избежать автобоксинга примитивов в обёртки при массовых операциях

    // разбор: Обобщённые интерфейсы работают с ОБЪЕКТАМИ, поэтому Predicate<Integer> на каждый элемент боксит int в Integer (аллокация, разыменование) — на больших объёмах это заметная нагрузка. Примитивные варианты (IntPredicate, IntFunction, ToIntFunction, IntUnaryOperator) работают напрямую с примитивами без боксинга. Их же используют примитивные стримы (IntStream), давая и скорость, и специализированные операции (sum, average). Это важная деталь производительности в горячем коде.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.