Към съдържанието
wannacode

Ламбди

Проблем

Шест реда за едно сравнение

Поръчките трябва да се подредят по сума. От модул „Колекции“ знаеш как: sort с Comparator, написан като анонимен клас.

Order
class Order {
private String customer;
private double total;
public Order(String customer, double total) {
this.customer = customer;
this.total = total;
}
public String getCustomer() {
return customer;
}
public double getTotal() {
return total;
}
}
List<Order> orders = new ArrayList<>(List.of(
new Order("Мария", 75.50),
new Order("Георги", 12.90),
new Order("Елена", 40.00)));
orders.sort(new Comparator<Order>() {
@Override
public int compare(Order a, Order b) {
return Double.compare(a.getTotal(), b.getTotal());
}
});
for (Order o : orders) {
System.out.printf("%s: %.2f €%n", o.getCustomer(), o.getTotal());
}
Изход
Георги: 12.90 €
Елена: 40.00 €
Мария: 75.50 €

Работи. Но от шестте реда на анонимния клас само един казва нещо: Double.compare(a.getTotal(), b.getTotal()). Останалото е обвивка, която пишеш всеки път: new Comparator<Order>(), @Override, public int compare, скоби. Искаш да подадеш само правилото.

Идеята

Ламбда: само правилото

Същото подреждане, на един ред:

orders.sort((a, b) -> Double.compare(a.getTotal(), b.getTotal()));

(a, b) -> Double.compare(a.getTotal(), b.getTotal()) се казва ламбда израз, накратко ламбда. Отляво на -> са параметрите, отдясно е тялото. Това е методът compare без име, без типове и без return.

Ламбдата има три вида запис:

  • Един параметър може да е без скоби: o -> o.getTotal() > 50. Без параметри е () -> ....
  • Типовете на параметрите обикновено се пропускат, защото компилаторът ги знае от sort. Може и да ги напишеш: (Order a, Order b) -> ....
  • Тяло с няколко реда е във { } и тогава return е задължителен.
Comparator<Order> byTotal = (a, b) -> Double.compare(a.getTotal(), b.getTotal());
Comparator<Order> byTotalLong = (Order a, Order b) -> {
double diff = a.getTotal() - b.getTotal();
return diff < 0 ? -1 : diff > 0 ? 1 : 0;
};
orders.sort(byTotalLong);
for (Order o : orders) {
System.out.printf("%s: %.2f €%n", o.getCustomer(), o.getTotal());
}
Изход
Георги: 12.90 €
Елена: 40.00 €
Мария: 75.50 €
Ламбдата е стойност: може да я сложиш в променлива и да я подадеш по-късно.

Идеята

Функционален интерфейс

Откъде Java знае, че (a, b) -> ... е метод compare? От типа: sort очаква Comparator, а той има един абстрактен метод. Ламбдата става неговото тяло.

Интерфейс с точно един абстрактен метод се казва функционален интерфейс. Може да си направиш свой:

interface PriceRule {
double apply(double price);
}
PriceRule student = price -> price * 0.8;
PriceRule minusFive = price -> price - 5;
System.out.println(student.apply(50));
System.out.println(minusFive.apply(50));
Изход
40.0
45.0
student е обект от тип PriceRule. Викаш го с името на метода от интерфейса: student.apply(50). Две правила за цена, нито един клас, който ги наследява.

PriceRule student = price -> price * 0.8;
PriceRule minusFive = price -> price - 5;
System.out.println(student.apply(50));
System.out.println(minusFive.apply(50));
Стъпка по стъпка, с думи
  1. Ламбдата е обект от тип PriceRule, а student сочи към него. Тялото ѝ още не се изпълнява.
  2. Второ правило, втори обект. Нито един клас не е написан.
  3. student.apply(50) подава 50 на ламбдата: price е 50, а price * 0.8 дава 40.0.
  4. minusFive.apply(50): същото число, друго правило, 45.0.
По-точно, за любопитните
  • Анотацията @FunctionalInterface над интерфейса кара компилатора да провери, че методът е точно един. Ако добавиш втори абстрактен метод:
@FunctionalInterface
interface PriceRule {
double apply(double price);
String name();
}
Грешка
Main.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
PriceRule is not a functional interface
multiple non-overriding abstract methods found in interface PriceRule
  • Методите с тяло (default и static) не се броят. Не се броят и методите от Object: в Comparator пише boolean equals(Object obj), но той е функционален.
  • Ламбдата не е анонимен клас. В нея this е обектът, в който е написана, а не самата ламбда.

Идеята

Готовите: Predicate, Function, Consumer, Supplier

Не е нужно да пишеш интерфейс за всяко правило. В java.util.function има готови за най-честите случаи:

  • Predicate<T>: приема T, връща boolean. Методът е test. Това е условие: „голяма ли е поръчката?“.
  • Function<T, R>: приема T, връща R. Методът е apply. Превръща едно в друго: поръчка в етикет.
  • Consumer<T>: приема T, не връща нищо. Методът е accept. Прави нещо с него: отпечатва го, записва го.
  • Supplier<T>: не приема нищо, връща T. Методът е get. Дава нов обект, когато поискаш.
Predicate<Order> big = o -> o.getTotal() >= 50;
Function<Order, String> label = o -> o.getCustomer().toUpperCase();
Consumer<String> print = text -> System.out.println("-> " + text);
Supplier<Order> guest = () -> new Order("Гост", 0);
Order order = new Order("Мария", 75.50);
System.out.println(big.test(order));
print.accept(label.apply(order));
System.out.println(guest.get().getCustomer());
Изход
true
-> МАРИЯ
Гост
Готовите интерфейси имат и методи, които сглобяват правила. Предикатите се свързват с and, or и negate („не“). Функциите се нареждат една след друга с andThen.
По-точно, за любопитните
  • Има варианти за два параметъра: BiFunction<T, U, R>, BiPredicate<T, U>, BiConsumer<T, U>. UnaryOperator<T> е Function<T, T>, а BinaryOperator<T> е BiFunction<T, T, T>.
  • За примитивните типове има IntPredicate, IntFunction<R>, ToDoubleFunction<T> и други. С тях int не се пакетира в Integer при всяко извикване.
  • f.andThen(g) прави първо f, после g. f.compose(g) прави обратното: първо g, после f.

Пробвай

Сглобени условия

Какво ще отпечата програмата?

Predicate<Order> big = o -> o.getTotal() >= 50;
Predicate<Order> maria = o -> o.getCustomer().equals("Мария");
Order small = new Order("Мария", 30);
Order large = new Order("Георги", 80);
System.out.println(big.and(maria).test(small));
System.out.println(big.or(maria).test(small));
System.out.println(big.negate().test(large));
Отговор

Виж го

Списъците и речниците приемат ламбди

Колекциите имат методи, които приемат правило вместо цикъл:
List<String> cart = new ArrayList<>(List.of("хляб", "", "мляко", " ", "кафе"));
cart.removeIf(item -> item.isBlank());
cart.forEach(item -> System.out.println(item));
Map<String, List<String>> byCity = new TreeMap<>();
byCity.computeIfAbsent("Пловдив", city -> new ArrayList<>()).add("Мария");
byCity.computeIfAbsent("София", city -> new ArrayList<>()).add("Георги");
byCity.computeIfAbsent("Пловдив", city -> new ArrayList<>()).add("Елена");
byCity.forEach((city, names) -> System.out.println(city + ": " + names));
Изход
хляб
мляко
кафе
Пловдив: [Мария, Елена]
София: [Георги]
  • removeIf(условие) маха всички елементи, за които условието е вярно. Това е Predicate.
  • forEach(действие) прави действието с всеки елемент. Това е Consumer.
  • computeIfAbsent(ключ, функция) връща стойността за ключа. Ако я няма, вика функцията, слага резултата в речника и връща него. Така групирането по град става на един ред, без if (!byCity.containsKey(...)).
  • forEach на речника приема ламбда с два параметъра: ключ и стойност.

TreeMap пази ключовете подредени, затова Пловдив е преди София.

Идеята

Препратка към метод: ::

Често ламбдата само подава параметъра си на един готов метод: s -> System.out.println(s). Тогава може да напишеш само кой е методът:
List<String> names = List.of("мария", "георги");
names.forEach(System.out::println);
Function<String, String> upper = String::toUpperCase;
Function<String, Integer> parse = Integer::parseInt;
Supplier<List<String>> newList = ArrayList::new;
System.out.println(upper.apply("елена"));
System.out.println(parse.apply("42") + 1);
List<String> list = newList.get();
list.add("кафе");
System.out.println(list);
Изход
мария
георги
ЕЛЕНА
43
[кафе]

Това се казва препратка към метод. Има четири вида:

  • метод на конкретен обект: System.out::println е s -> System.out.println(s);
  • метод на параметъра: String::toUpperCase е s -> s.toUpperCase();
  • статичен метод: Integer::parseInt е s -> Integer.parseInt(s);
  • конструктор: ArrayList::new е () -> new ArrayList<>().

Ако ламбдата прави нещо повече, например s -> s.trim().toUpperCase(), остави я ламбда.

Виж го

Подреждане по няколко полета

Comparator.comparing приема функция, която вади ключа за сравнение, и прави компаратор от нея. Към него се добавя следващо правило с thenComparing, а reversed() обръща посоката.

Учениците са подредени по оценка, от най-високата, а при равни оценки по име:

List<Student> students = new ArrayList<>(List.of(
new Student("Иван", 5.50),
new Student("Ана", 6.00),
new Student("Петър", 5.50),
new Student("Борис", 4.75)));
students.sort(Comparator.comparing(Student::getGrade).reversed()
.thenComparing(Student::getName));
Изход
Ана 6.0
Иван 5.5
Петър 5.5
Борис 4.75

Чете се отляво надясно: „сравни по оценка, обърни, после при равенство сравни по име“. Нито един if, нито един анонимен клас.

По-точно, за любопитните
  • С ламбда вместо препратка reversed() понякога не се компилира:
orders.sort(Comparator.comparing(o -> o.getTotal()).reversed());
Грешка
Main.java:26: error: cannot find symbol
orders.sort(Comparator.comparing(o -> o.getTotal()).reversed());
^
symbol: method getTotal()
location: variable o of type Object

Компилаторът извежда типа на o отвътре навън. Когато стигне до .reversed(), той още не знае, че става дума за Order, и приема Object. Поправката е препратка или тип на параметъра:

orders.sort(Comparator.comparing(Order::getTotal).reversed());
orders.sort(Comparator.comparing((Order o) -> o.getTotal()).reversed());
  • comparingInt и comparingDouble правят същото без пакетиране в Integer и Double.
  • Comparator.naturalOrder() и Comparator.reverseOrder() подреждат по естествения ред на типа: числа по големина, текст по азбука.

Пробвай

Какво обръща reversed()?

Тук reversed() е в края, след thenComparing.

Какво ще отпечата програмата?

List<Student> students = new ArrayList<>(List.of(
new Student("Иван", 5.50),
new Student("Ана", 6.00),
new Student("Петър", 5.50),
new Student("Борис", 4.75)));
students.sort(Comparator.comparing(Student::getGrade)
.thenComparing(Student::getName)
.reversed());
students.forEach(s -> System.out.println(s.getName() + " " + s.getGrade()));
Отговор

Пробвай

Намери грешката

Програмата трябва да отпечата сбора на цените. Не се компилира. На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Да се чете такава променлива е позволено:

double rate = 0.2;
PriceRule discount = price -> price * (1 - rate);
System.out.println(discount.apply(100));
Изход
80.0

Полетата на обекта не са локални променливи и ламбдата може да ги променя. Това не е заобикаляне на правилото: ако ламбдите вървят в няколко нишки едновременно, общото поле е източник на грешки. За нишките е модул „Програми в истинския свят“.

Упражнение

Правила като стойности

Реши задачите в редактора си. За Predicate, Function и останалите добави import java.util.function.*;.

1. Подреждане по няколко полета. Клас Product с име, категория и цена. Подреди продуктите по категория, в една категория от най-скъпия, а при равна цена по име. Продуктите са: мишка, аксесоари, 25 €; лаптоп, компютри, 1299 €; клавиатура, аксесоари, 49,90 €; таблет, компютри, 399 €; слушалки, аксесоари, 49,90 €.

Изход
Аксесоари | Клавиатура | 49.90 €
Аксесоари | Слушалки | 49.90 €
Аксесоари | Мишка | 25.00 €
Компютри | Лаптоп | 1299.00 €
Компютри | Таблет | 399.00 €

2. Филтър на поръчки. Добави към Order град. Напиши метод select(List<Order> orders, Predicate<Order> rule), който връща нов списък само с поръчките, за които правилото е вярно. Направи две правила, sofia и big (поне 50 €), и отпечатай поръчките с sofia.and(big), със sofia.negate() и с „София или поне 100 €“. Поръчките: Мария, София, 75,50 €; Георги, Варна, 12,90 €; Елена, София, 40 €; Димитър, Пловдив, 120 €.

Изход
София, над 50 €:
Мария, София: 75.50 €
Извън София:
Георги, Варна: 12.90 €
Димитър, Пловдив: 120.00 €
Безплатна доставка (София или над 100 €):
Мария, София: 75.50 €
Елена, София: 40.00 €
Димитър, Пловдив: 120.00 €
3. Отстъпки една след друга. Направи три отстъпки като Function<Double, Double>: студентска (−10%), за Черния петък (−20 €) и „не под нула“. Свържи ги с andThen в два реда: студентската първо и петъчната първо. Пробвай с 120 € и с 15 €. Редът има ли значение?
Изход
120.00 € -> студент, после петък: 88.00 €; петък, после студент: 90.00 €
15.00 € -> студент, после петък: 0.00 €; петък, после студент: 0.00 €

Още задачи

  • Чистене на списък. От списък с имейли махни празните и тези без @ с един removeIf, после отпечатай останалите с forEach(System.out::println).
  • Ученици по клас. Групирай ученици по клас ("10А", "10Б") в TreeMap<String, List<String>> с computeIfAbsent и отпечатай речника с forEach.
  • Свой интерфейс. Направи @FunctionalInterface interface Validator { boolean check(String value); } и три валидатора като ламбди: непразен текст, само цифри, дължина до 10. Пробвай ги на няколко низа.

Тест

Провери се

1. Къде може да стои ламбда израз?
2. Кой готов интерфейс приема поръчка и връща true или false?
3. С какво може да се замени ламбдата s -> s.toUpperCase()?
4. Comparator.comparing(Student::getGrade).thenComparing(Student::getName).reversed(). Какво обръща reversed()?
5. Защо int total = 0; list.forEach(x -> total += x); не се компилира?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·