Лямбда-выражения в Java
Такой код не скомпилируется, хотя выглядит абсолютно нормально:
int count = 0;
cars.forEach(car -> count++); // error: local variables referenced from
// a lambda expression must be final or effectively final Лямбда похожа на обычный блок кода внутри метода, но живёт по своим правилам. Разберёмся, что такое лямбда-выражения, как они устроены и где их применяют.
Лямбда-выражение в Java — это компактная запись анонимной функции, которую можно передать в метод как аргумент или сохранить в переменную. Лямбда-выражения появились в Java 8 (2014) и остаются одной из базовых возможностей языка во всех последующих релизах, включая современные LTS-версии. Они позволяют писать код в функциональном стиле: вместо объекта с методом передаётся само поведение.
1. Зачем нужны лямбда-выражения
Все примеры ниже работают с простым классом автомобиля:
public enum CarTypes {
COMPACT, SPORT, SUV
}
public class Car {
private final CarTypes type;
private final int cost;
public Car(CarTypes type, int cost) {
this.type = type;
this.cost = cost;
}
public CarTypes getType() {
return type;
}
public int getCost() {
return cost;
}
} Рассмотрим метод, который подсчитывает количество только компактных автомобилей:
private int getCompactCarsNumber(Car[] cars) {
int result = 0;
for (Car car : cars) {
if (car.getType().equals(CarTypes.COMPACT)) {
result++;
}
}
return result;
} Предположим, добавляется ещё одна задача — подсчитать количество автомобилей, у которых стоимость более 20 000:
private int getExpensiveCarsNumber(Car[] cars) {
int result = 0;
for (Car car : cars) {
if (car.getCost() > 20000) {
result++;
}
}
return result;
} Два метода отличаются ровно одной строкой — условием отбора. Вынесем это условие в отдельный интерфейс, чтобы не писать новый метод под каждый новый критерий:
public interface Searchable {
boolean test(Car car);
} public class CompactCarSearch implements Searchable {
public boolean test(Car car) {
return car.getType().equals(CarTypes.COMPACT);
}
} public class ExpensiveCarSearch implements Searchable {
public boolean test(Car car) {
return car.getCost() > 20000;
}
} public class CarDemo2 {
public static void main(String[] args) {
Car[] cars = {
new Car(CarTypes.COMPACT, 34000),
new Car(CarTypes.SPORT, 44000),
new Car(CarTypes.COMPACT, 14000),
new Car(CarTypes.COMPACT, 10000),
};
CarDemo2 carDemo = new CarDemo2();
System.out.println(carDemo.getCarsNumber(cars, new CompactCarSearch()));
System.out.println(carDemo.getCarsNumber(cars, new ExpensiveCarSearch()));
System.out.println(carDemo.getCarsNumber(cars, new Searchable() {
@Override
public boolean test(Car car) {
return car.getType().equals(CarTypes.COMPACT);
}
}));
}
private int getCarsNumber(Car[] cars, Searchable s) {
int result = 0;
for (Car car : cars) {
if (s.test(car)) {
result++;
}
}
return result;
}
} В классе CarDemo2 шесть строк анонимного класса
carDemo.getCarsNumber(cars, new Searchable() {
@Override
public boolean test(Car car) {
return car.getType().equals(CarTypes.COMPACT);
}
}) сжимаются в одну строку лямбда-выражения:
carDemo.getCarsNumber(cars, car -> car.getType() == CarTypes.COMPACT); Логика осталась той же, но исчез весь служебный обвес: имя класса, ключевое слово new, аннотация @Override, сигнатура метода. Компилятор сам понимает, что от него хотят, потому что у интерфейса Searchable ровно один абстрактный метод.
Мелочь, которая часто встречается в чужом коде
Константы перечислений корректнее сравнивать через ==, а не через equals(): значения enum существуют в единственном экземпляре, а == дополнительно безопасен при null слева. Вариант car.getType().equals(CarTypes.COMPACT) рабочий, но бросит NullPointerException, если тип не задан.
2. Синтаксис лямбда-выражения
Общая форма записи:
(параметры) -> тело Например:
(Object arg1, Object arg2) -> arg1.equals(arg2) Лямбда-выражение состоит из трёх частей: список параметров, стрелка (->) и тело. Тело бывает двух видов: выражение (без фигурных скобок, его значение возвращается автоматически) или блок (в фигурных скобках, с явным return, если метод возвращает значение).
Основные правила записи:
- Лямбда-выражение может принимать ноль и более параметров:
(int a1, int a2) -> { return a1 - a2; } (String s) -> { System.out.println(s); } () -> 89 - Тип параметра можно указать явно или доверить компилятору — он выведет его из целевого типа, то есть из сигнатуры того функционального интерфейса, куда лямбда передаётся:
можно переписать так:(String s) -> { System.out.println(s); }(s) -> { System.out.println(s); } - Если параметров нет или их больше одного, круглые скобки обязательны:
(a1, a2) -> a1 + a2 (int a1, int a2) -> a1 + a2 () -> 42 - Единственный параметр можно писать без скобок, но тогда нельзя указать его тип явно:
a1 -> 2 * a1 - Если тело состоит из одного выражения, фигурные скобки и
returnне нужны — значение возвращается само:() -> 4 (int a) -> a * 6 - Если тело содержит несколько инструкций, фигурные скобки обязательны, а для возврата значения нужен явный
return:() -> { System.out.println("Hi"); return 4; } (int a) -> { System.out.println(a); return a * 6; } - Лямбда может ничего не возвращать. Тело-блок без
returnи тело-выражение, результат которого отбрасывается, совместимы сvoid-методом функционального интерфейса:() -> System.out.println("Hi") () -> { System.out.println("Hi"); return; } - Начиная с Java 11 в списке параметров можно использовать
var— это удобно, когда нужна аннотация на параметре. Смешиватьvarс явными типами или с их отсутствием нельзя:(var a1, var a2) -> a1 + a2 (@NonNull var s) -> s.trim()
Важно
Все формы выше показаны как фрагменты. Сама по себе лямбда не является выражением с самостоятельным типом: ей всегда нужен целевой тип — переменная функционального интерфейса, параметр метода или возвращаемое значение. Поэтому var f = () -> 42; не компилируется, а Supplier<Integer> f = () -> 42; — вполне.
3. Лямбда vs анонимный класс
Лямбда — это не «синтаксический сахар над анонимным классом». Различия видны и на уровне поведения, и на уровне байт-кода.
| Критерий | Лямбда-выражение | Анонимный класс |
|---|---|---|
Что означает this | Объект внешнего класса | Сам экземпляр анонимного класса |
| Что можно реализовать | Только функциональный интерфейс | Любой интерфейс или класс, в том числе с несколькими методами |
| Собственное состояние | Полей нет | Можно объявлять поля и блоки инициализации |
| Байт-код | Инструкция invokedynamic, отдельный class-файл не создаётся | Создаётся отдельный файл Outer$1.class |
| Имена переменных | Нельзя объявить переменную с именем локальной переменной метода | Можно затенить (shadowing) внешнюю переменную |
public class ThisDemo {
private final String name = "внешний объект";
public void run() {
Runnable lambda = () -> System.out.println(this.name); // "внешний объект"
Runnable anonymous = new Runnable() {
private final String name = "анонимный класс";
@Override
public void run() {
System.out.println(this.name); // "анонимный класс"
}
};
lambda.run();
anonymous.run();
}
} 4. На чём чаще всего ошибаются
Переменные должны быть effectively final
Лямбда захватывает значение локальной переменной, а не саму переменную. Поэтому переменная должна быть final или effectively final — фактически не изменяться после инициализации. Именно на этом падает пример из начала урока:
int count = 0;
cars.forEach(car -> count++); // ошибка компиляции Правильный способ посчитать — попросить результат у стрима, а не менять переменную снаружи:
long count = cars.stream()
.filter(car -> car.getType() == CarTypes.COMPACT)
.count(); Если счётчик всё же нужен (например, в многопоточном коде), берут AtomicInteger или LongAdder: сама ссылка на объект не меняется, меняется его содержимое.
AtomicInteger counter = new AtomicInteger();
cars.forEach(car -> counter.incrementAndGet()); На поля класса это ограничение не распространяется — их лямбда может изменять свободно.
Проверяемые исключения не проходят «сквозь» лямбду
Лямбда может бросить только те исключения, которые объявлены в сигнатуре метода функционального интерфейса. У Consumer.accept() нет throws, поэтому такой код не скомпилируется:
List<String> paths = List.of("a.txt", "b.txt");
paths.forEach(p -> Files.readAllBytes(Path.of(p))); // IOException не обработан Варианты решения: обработать исключение внутри лямбды через try/catch, обернуть его в непроверяемое (UncheckedIOException) либо объявить свой функциональный интерфейс с throws.
Лямбда с побочными эффектами в параллельном стриме
Лямбда, которая пишет во внешнюю коллекцию, в parallelStream() ведёт себя непредсказуемо: обычный ArrayList не потокобезопасен, и результат может оказаться неполным или сломанным. Собирайте результат через collect(), а не через forEach с добавлением в список.
Слишком длинная лямбда
Лямбда на пятнадцать строк теряет главное преимущество — читаемость. Вынесите тело в обычный метод и передайте ссылку на него: cars.stream().filter(CarFilters::isCheapCompact).
Вопрос с собеседования
«Почему локальная переменная в лямбде обязана быть effectively final, а поле класса — нет?» Локальные переменные живут в стеке метода и исчезают вместе с ним, поэтому лямбда получает их копию — изменять копию бессмысленно и опасно. Поля же доступны через ссылку на объект в куче, которая остаётся живой столько, сколько живёт лямбда.
5. Коротко о главном
- Лямбда-выражение — компактная запись анонимной функции:
(параметры) -> тело. - Лямбду можно присвоить только функциональному интерфейсу — интерфейсу с одним абстрактным методом.
- Тип параметров выводится из целевого типа; скобки не нужны только для единственного параметра без явного типа.
- Тело-выражение возвращает значение без
return; тело-блок требует фигурных скобок и явногоreturn, если метод неvoid. - Если лямбда просто вызывает метод, замените её ссылкой на метод.
- Лямбды повсеместно используются в Stream API: почти каждый метод стрима принимает лямбду или ссылку на метод.
- Захваченные локальные переменные должны быть effectively final;
thisвнутри лямбды указывает на внешний объект.
Часто задаваемые вопросы
Лямбда-выражение — это просто сокращённая запись анонимного класса?
Нет. Внешне они решают похожую задачу, но компилируются по-разному: для анонимного класса создаётся отдельный class-файл Outer$1.class, а лямбда компилируется в инструкцию invokedynamic и связывается во время выполнения через LambdaMetafactory. Отсюда и разница в поведении: this в лямбде ссылается на внешний объект, а в анонимном классе — на сам анонимный экземпляр.
Можно ли использовать лямбда-выражения в Java 7 и раньше?
Нет. Лямбда-выражения и метод invokedynamic-связывания появились в Java 8 (2014), вместе с функциональными интерфейсами и Stream API. При компиляции с флагом --release 7 компилятор откажется принимать стрелку. До Java 8 роль лямбд выполняли анонимные классы.
Как рекурсивно вызвать лямбду из неё самой?
Напрямую не получится: локальная переменная не считается инициализированной внутри собственного инициализатора. Обходной путь — объявить лямбду полем класса или использовать массив-держатель из одного элемента. Обычно рекурсию проще оформить обычным методом и передать ссылку на него.
Замедляют ли лямбда-выражения программу?
На практике нет. Первый вызов лямбды чуть дороже из-за связывания через invokedynamic, но дальше JIT-компилятор встраивает её тело так же, как обычный вызов метода. Заметная разница возникает только при упаковке примитивов — в таком коде используйте специализированные интерфейсы вроде IntPredicate и ToIntFunction.
Как отладить лямбда-выражение?
Точку останова можно поставить прямо на строке с лямбдой — IntelliJ IDEA и Eclipse умеют останавливаться внутри её тела. Если лямбда однострочная и брейкпоинт срабатывает не там, где нужно, временно разверните её в форму с фигурными скобками или вынесите в отдельный метод. В стеке вызовов такие кадры видны как lambda$methodName$0.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии