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

Stream

Проблем

Отчет в цикъл

Поръчките вече имат и град:

Order
class Order {
private String customer;
private String city;
private double total;
public Order(String customer, String city, double total) {
this.customer = customer;
this.city = city;
this.total = total;
}
public String getCustomer() {
return customer;
}
public String getCity() {
return city;
}
public double getTotal() {
return total;
}
}
List<Order> orders = List.of(
new Order("Мария", "София", 75.50),
new Order("Георги", "Варна", 12.90),
new Order("Елена", "София", 40.00),
new Order("Димитър", "Пловдив", 120.00),
new Order("Ана", "Варна", 64.00));

Искаш клиентите с поръчки поне 50 €, подредени по азбучен ред:

List<String> result = new ArrayList<>();
for (Order o : orders) {
if (o.getTotal() >= 50) {
result.add(o.getCustomer());
}
}
Collections.sort(result);
System.out.println(result);
Изход
[Ана, Димитър, Мария]

Осем реда, а въпросът е един. Кодът казва как: направи празен списък, обиколи, провери, добави, подреди. За да разбереш какво прави, трябва да го изпълниш наум. Добавиш ли „само първите трима“ или „без повторения“, цикълът ще расте.

Идеята

Поток: поредица от стъпки

Същият отчет, написан като стъпки:

List<String> result = orders.stream()
.filter(o -> o.getTotal() >= 50)
.map(Order::getCustomer)
.sorted()
.toList();
System.out.println(result);
Изход
[Ана, Димитър, Мария]

Чете се отгоре надолу: вземи поръчките, остави тези поне за 50 €, вземи клиента, подреди, събери в списък.

orders.stream() прави поток (на английски stream) от списъка. Потокът не пази данни. Той е конвейер, по който елементите минават един по един. Всяка стъпка приема ламбда от предишния урок:

  • filter(условие) пропуска нататък само елементите, за които условието е вярно. Това е Predicate.
  • map(функция) превръща всеки елемент в друг. Това е Function: от Order прави String.
  • sorted() подрежда по естествения ред, а sorted(компаратор) по твоето правило.
  • toList() събира каквото е стигнало до края в нов списък.

Списъкът orders не се променя. Потокът само го чете.

По-точно, за любопитните
  • filter, map, sorted са междинни операции: всяка връща нов поток. toList, count, collect, forEach са крайни: те искат резултат и пускат конвейера.
  • toList() е от Java 16 и връща списък, който не може да се променя. В по-стар код ще видиш collect(Collectors.toList()). Той прави същото, но не обещава какъв точно списък ще върне. Ако ти трябва ArrayList, който да променяш, пиши collect(Collectors.toCollection(ArrayList::new)).
  • Поток се прави и от масив: Arrays.stream(prices), и от отделни стойности: Stream.of("кафе", "чай").

Виж го

Какво минава през филтъра

List<Integer> prices = List.of(12, 75, 40, 130, 58);
List<String> labels = prices.stream()
.filter(p -> p >= 50)
.map(p -> p + " €")
.toList();
System.out.println(labels);
Стъпка по стъпка, с думи
  1. Пет цени. stream() само подготвя пътя: още нищо не е минало.
  2. 12 не минава p >= 50 и отпада. До map не стига.
  3. 75 минава filter, map го превръща в текста „75 €“, toList() го събира.
  4. 40 също отпада. Всеки елемент минава целия път, преди да тръгне следващият.
  5. 130 и 58 минават. Списъкът prices не се променя.
  6. Резултатът е нов списък с три текста.

Петте цени тръгват по конвейера една по една. 12 стига до filter и отпада. 75 минава, map го прави на текста „75 €“ и той влиза в списъка. После 40 отпада, а 130 и 58 минават.

Всеки елемент изминава целия път, преди да тръгне следващият. И нищо не тръгва, докато крайната операция toList() не поиска резултат.

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

Това се казва мързеливо изпълнение. Междинните операции само записват какво трябва да се направи. Виж го с отпечатване във всяка стъпка:

Stream<Integer> stream = List.of(12, 75, 40).stream()
.filter(p -> {
System.out.println("filter " + p);
return p >= 50;
})
.map(p -> {
System.out.println("map " + p);
return p * 2;
});
System.out.println("Потокът е готов, нищо не е минало през него.");
System.out.println(stream.toList());
Изход
Потокът е готов, нищо не е минало през него.
filter 12
filter 75
map 75
filter 40
[150]
  • Първият ред на изхода е отпечатан, преди през потока да е минал и един елемент.
  • map 75 е веднага след filter 75: елементите не чакат всички да минат през filter.
  • Затова limit(3) спира конвейера, щом три елемента стигнат до края. Останалите дори не се проверяват.
  • parallelStream() пуска елементите в няколко нишки едновременно. Помага при много данни и тежки сметки. При малки списъци е по-бавен, а ламбдите не трябва да пипат общи данни. За нишките е модул „Програми в истинския свят“.

Идеята

Сбор, брой, средно

Потокът може да завърши с число вместо със списък. Сборът на четните числа:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream().filter(n -> n % 2 == 0).mapToInt(n -> n).sum();
System.out.println("Сума на четните числа: " + sum);
Изход
Сума на четните числа: 6
mapToInt(n -> n) прави от Stream<Integer> поток от int. Той се казва IntStream и има sum(), average(), max() и min(). За double има mapToDouble и DoubleStream.

Същото за поръчките:

long big = orders.stream().filter(o -> o.getTotal() >= 50).count();
double revenue = orders.stream().mapToDouble(Order::getTotal).sum();
double average = orders.stream().mapToDouble(Order::getTotal).average().orElse(0);
double maxTotal = orders.stream().mapToDouble(Order::getTotal).max().orElse(0);
System.out.println("Поръчки над 50 €: " + big);
System.out.printf("Оборот: %.2f €%n", revenue);
System.out.printf("Средно: %.2f €%n", average);
System.out.printf("Най-голяма: %.2f €%n", maxTotal);
Изход
Поръчки над 50 €: 3
Оборот: 312.40 €
Средно: 62.48 €
Най-голяма: 120.00 €
  • count() дава броя на елементите, които са стигнали до края. Типът е long.
  • average() и max() не връщат направо double. Ако поръчки няма, средно няма. Затова връщат „може би число“, а orElse(0) казва какво да е, ако го няма. Това е следващата идея.

Идеята

Подреди, отрежи, без повторения

List<Integer> ratings = List.of(5, 3, 5, 4, 2, 4, 5);
System.out.println(ratings.stream().distinct().sorted().toList());
List<String> top = orders.stream()
.sorted(Comparator.comparing(Order::getTotal).reversed())
.limit(3)
.map(Order::getCustomer)
.toList();
System.out.println(top);
IntStream.range(1, 4).forEach(i -> System.out.println("Маса " + i));
Изход
[2, 3, 4, 5]
[Димитър, Мария, Ана]
Маса 1
Маса 2
Маса 3
  • distinct() маха повторенията. Сравнява с equals.
  • sorted(Comparator.comparing(Order::getTotal).reversed()) подрежда от най-голямата поръчка, с компаратор от предишния урок.
  • limit(3) пропуска само първите три. Заедно със sorted това е „топ 3“.
  • IntStream.range(1, 4) дава числата 1, 2, 3. Краят не се включва, като в for (int i = 1; i < 4; i++). С rangeClosed(1, 4) се включва.

Пробвай

Кое стига до края?

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

List<String> drinks = List.of("кафе", "чай", "кафе", "вода", "чай", "сок");
List<String> result = drinks.stream()
.distinct()
.sorted()
.limit(3)
.map(String::toUpperCase)
.toList();
System.out.println(result);
Отговор

Идеята

Optional: резултат, който може да липсва

Коя е най-голямата поръчка? Ако списъкът е празен, отговор няма. Методът може да върне null, но тогава викащият трябва да помни да провери. Забрави ли, програмата спира с NullPointerException някъде по-късно.

Затова max, min и findFirst връщат Optional: кутия, в която има стойност или е празна.

Optional<Order> biggest = orders.stream().max(Comparator.comparing(Order::getTotal));
biggest.ifPresent(o -> System.out.println("Най-голяма: " + o.getCustomer()));
Optional<Order> huge = orders.stream().filter(o -> o.getTotal() > 500).findFirst();
System.out.println(huge.isPresent());
System.out.println(huge.map(Order::getCustomer).orElse("няма такава"));
Изход
Най-голяма: Димитър
false
няма такава
  • ifPresent(действие) прави действието само ако има стойност.
  • isPresent() казва дали има.
  • orElse(резерва) дава стойността или резервата.
  • map(функция) превръща стойността, ако я има. Празната кутия си остава празна.

Optional помага и с твои методи, които връщат null. Optional.ofNullable слага в кутията каквото получи: стойност или нищо.

static String nickname(String user) {
// Връща прякора или null, ако човекът няма.
return user.equals("maria") ? "Мими" : null;
}
Optional<String> maria = Optional.ofNullable(nickname("maria"));
maria.ifPresent(name -> System.out.println("Прякор: " + name));
Optional<String> georgi = Optional.ofNullable(nickname("georgi"));
System.out.println(georgi.orElse("без прякор"));
Изход
Прякор: Мими
без прякор
По-точно, за любопитните
  • get() дава стойността, но ако кутията е празна, хвърля изключение:
Optional<Order> huge = orders.stream().filter(o -> o.getTotal() > 500).findFirst();
System.out.println(huge.get().getCustomer());
Грешка
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.get(Optional.java:143)

orElseThrow() прави същото, но името казва честно какво ще стане. Пиши него, ако празната кутия е грешка.

  • orElse(x) изчислява x винаги. orElseGet(() -> ...) вика функцията само ако стойност няма. Разликата се усеща, ако резервата е скъпа за смятане.
  • average() на IntStream и DoubleStream връща OptionalDouble, а max() на IntStream връща OptionalInt. Те са същото за примитивни типове: getAsDouble(), orElse(0).
  • Optional е за резултат на метод. Не го слагай като поле на клас или като параметър.
  • anyMatch, allMatch и noneMatch отговарят с true или false дали някой, всички или никой елемент не отговаря на условието.

Пробвай

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

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

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

Идеята

Групиране и съединяване

Колко поръчки има от всеки град и какъв е оборотът?

Map<String, Long> countByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new, Collectors.counting()));
System.out.println(countByCity);
Map<String, Double> revenueByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new, Collectors.summingDouble(Order::getTotal)));
revenueByCity.forEach((city, sum) -> System.out.printf("%s: %.2f €%n", city, sum));
String customers = orders.stream()
.map(Order::getCustomer)
.sorted()
.collect(Collectors.joining(", "));
System.out.println(customers);
Изход
{Варна=2, Пловдив=1, София=2}
Варна: 76.90 €
Пловдив: 120.00 €
София: 115.50 €
Ана, Георги, Димитър, Елена, Мария

collect събира потока по правило от класа Collectors:

  • groupingBy(Order::getCity, TreeMap::new, Collectors.counting()): групирай по град, сложи групите в TreeMap, за всяка група преброй.
  • summingDouble(Order::getTotal) вместо counting() сумира вместо да брои.
  • joining(", ") съединява низовете в един, със запетая между тях.

TreeMap::new е препратка към конструктор. Тя казва какъв речник да направи groupingBy. Без нея резултатът е HashMap, а в него редът на градовете не е гарантиран.

По-точно, за любопитните
  • Само с функцията, groupingBy(Order::getCity), резултатът е Map<String, List<Order>>: за всеки град списък с поръчките му.
  • Ако искаш списък с имена, а не с поръчки, mapping превръща всеки елемент, преди да влезе в групата:
Map<String, List<String>> namesByCity = orders.stream()
.collect(Collectors.groupingBy(Order::getCity, TreeMap::new,
Collectors.mapping(Order::getCustomer, Collectors.toList())));
System.out.println(namesByCity);
Изход
{Варна=[Георги, Ана], Пловдив=[Димитър], София=[Мария, Елена]}
  • Collectors.partitioningBy(условие) дели на две групи: true и false. Collectors.toMap(ключ, стойност) прави речник с една стойност за ключ и хвърля изключение, ако ключът се повтори.

Упражнение

Отчети с потоци

Реши задачите в редактора си. Импортирай java.util.stream.Collectors, а за масиви java.util.Arrays.

1. Продажби по категория. Клас Sale с продукт, категория, брой и единична цена и метод getRevenue() (брой по цена). Сумирай оборота по категория в TreeMap и отпечатай общия оборот. Продажбите: кафе на зърна, напитки, 12 бр. по 18,50 €; чай, напитки, 30 по 4,20 €; шоколад, сладки, 40 по 2,80 €; бисквити, сладки, 25 по 3,10 €; мед, храни, 8 по 12 €; пак кафе на зърна, 5 по 18,50 €.

Изход
Напитки: 440.50 €
Сладки: 189.50 €
Храни: 96.00 €
Общо: 726.00 €

2. Топ 3 продукта. Със същите продажби намери трите продукта с най-голям оборот. Кафето е в две продажби, затова първо групирай по продукт и сумирай, после подреди записите на речника по стойност. Подсказка: map.entrySet().stream() и Map.Entry.<String, Double>comparingByValue().reversed().

Изход
Кафе на зърна: 314.50 €
Чай: 126.00 €
Шоколад: 112.00 €

3. Средна оценка. Прочети един ред с оценки, разделени с интервал. Отпечатай средната, най-високата и колко са отличните (5,50 и нагоре). Подсказка: Arrays.stream(line.split(" ")).mapToDouble(Double::parseDouble).

Вход
4.50 5.75 6.00 3.00 5.50
Изход
Средно: 4.95
Най-висока: 6.00
Отлични: 3

4. Думите в текст. Прочети ред текст и отпечатай думите в него без повторения, по азбучен ред, с малки букви, разделени със запетая. Раздели по всичко, което не е буква: split("[^\\p{L}]+").

Вход
Кафе с мляко, кафе без захар. Чай с мед!
Изход
без, захар, кафе, мед, мляко, с, чай

Още задачи

  • Най-дългата дума. С max(Comparator.comparing(String::length)) намери най-дългата дума в изречение. Какво ще върне програмата за празен ред?
  • Средно по клас. Ученици с име, клас и оценка: средната оценка за всеки клас с groupingBy и Collectors.averagingDouble.
  • Големи и малки поръчки. С anyMatch провери дали има поръчка над 1000 €, а с allMatch дали всички са поне 10 €.

Тест

Провери се

1. Какво става със списъка orders след orders.stream().filter(o -> o.getTotal() > 50).toList()?
2. Кое от тези е крайна операция, която пуска потока?
3. orders.stream().max(...) връща Optional<Order>. Защо не направо Order?
4. Защо групирането е в TreeMap, а не само groupingBy(Order::getCity)?
5. Stream<Integer> s = list.stream(); s.count(); s.toList(); Какво става?

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