Съдържание на курса

Урок 4 — Колекции, генерични типове и стриймове

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 2 × 90 мин (две сесии; границата е между раздели 4.2 и 4.3)

Какво изграждаш: списъка с услуги и отчета на revisor: сега програмата пази услугите си в колекция, обобщава резултатите със стрийм (stream) и ги предава в таблица с ред за обобщение

Какво научаваш: List, Set и Map; неизменяеми колекции и защитни копия; генерични типове (generics) и тяхното изтриване; ламбди и стриймове с Collectors; Comparator; и Optional като върната стойност

Откъде идваш. Носиш проекта от Урок 3: седем файла, които спазват ясна уговорка. Estado е enum с три стойности; Respuesta е запечатан интерфейс, който може да бъде само Respondio (с кода и времето си) или SinRespuesta (с причината си); Servicio и Resultado са records; Clasificador решава състоянието с изчерпателен switch; Formato сглобява редовете; а Revisor.java записва на ръка четирите услуги и ги проверява една по една. Таблицата излиза добре, но има три пролуки. Няма списък: услугите са четири отделни променливи. Няма обобщение: никой ред не казва колко са работещите. А симулираните данни живеят вътре в един switch, смесени с логиката. Този урок запълва трите пролуки с инструментите, които Java носи за съхранение и преобразуване на много стойности. Работиш в папката си ~/curso-java/revisor/ и изпълняваш всичко с java.

Какво не прави този урок. Не чете файлове и не прихваща грешки: списъкът с услуги остава написан в Revisor.java и да го махнеш оттам е работа на Урок 5. Не пита и никаква мрежа: Simulador, който пишеш днес, играе ролята на мрежата до урок 8. И не използва Maven: файловете остават разпилени в една папка, както в урок 3.

За изпълненията. За да изпълниш една фигура, влез в папката на урока в хранилището (programas/04-colecciones-genericos-streams/) и стартирай java fig04_01.java, с номера на фигурата, която искаш. Тези, които живеят в собствена папка (тази на проекта от раздел 4.5), се изпълняват отвътре. Всички изходи са получени с Java 25 (Temurin 25.0.4) и език на терминала UTF-8; в Linux Mint това е нормалното; ако видиш въпросителни знаци там, където трябва да има ударения, echo $LANG ти казва какъв език има терминалът ти. Следите на грешките съдържат редове от самия JDK (at java.base/...) с номера на редове, които се менят от една актуализация на друга: нормално е твоите да се различават в тези номера.

След урока ще можеш да

  • Избереш между List, Set и Map според това, което трябва да съхраниш, и кажеш какъв ред на обхождане има всяка имплементация.
  • Създадеш неизменяема колекция с List.of, разпознаеш UnsupportedOperationException, което излиза при опит да я промениш, и защитиш един record със защитно копие.
  • Обясниш какво са генеричните типове, какво гарантира компилаторът с тях и какво се губи при изтриването на типовете.
  • Напишеш ламбда и препратка към метод и сглобиш с тях стрийм от filter, map и collect.
  • Обобщиш списък от резултати с Collectors.groupingBy, counting и average и го наредиш с Comparator.
  • Върнеш Optional вместо null и го прочетеш, без да извикваш get() на сляпо.

Защо, преди как

Отвори своя Revisor.java от урок 3. Четирите услуги са написани като четири отделни извиквания на revisar и дотам стига всичко, което програмата знае за тях: не може да каже колко са, не може да ги обходи в цикъл, не може да добави една, без да напише още един ред. Сега си представи, че от теб искат реда, който затваря отчета: „4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms“ (4 услуги · 2 работят · 1 бавна · 1 паднала · средно 843ms). За да го произведеш с това, което имаш досега, ти трябват четири брояча (arriba, lento, caido, total), една сума, един делител и да обновяваш всичко всеки път, когато някоя услуга смени състоянието си. Всяка нова услуга и всяко ново състояние би засягало няколко места в програмата.

Всички езици са решили това по един и същи начин: с колекции, структури, които пазят много стойности и умеят сами да се обхождат, търсят и броят. Ако идваш от Python, познаваш списъка, множеството и речника; ако идваш от Go, slice, мапа и навика да се използва map[string]struct{} като множество; ако идваш от TypeScript, масива, Set и Map. Java ги има и трите и ги организира по начин, който прилича по-малко на другите езици, отколкото изглежда: те са интерфейси (List, Set, Map) с няколко взаимозаменяеми имплементации и това разделение обяснява почти всичко, което ще видиш днес, от това защо има три различни мапа до това защо съществуват неизменяемите колекции.

Има още един проблем. Колекцията пази стойности, но от какъв тип? Ако пази каквото и да е, компилаторът не може да ти помогне: ще трябва на ръка да проверяваш, че онова, което вадиш от списъка, е услугата, която си очаквал. Java решава това с генеричните типове: ти декларираш, че списъкът е от Servicio, и компилаторът не позволява да влезе нещо друго. Това е една от най-полезните идеи на езика и има подробност, която изненадва почти всички, които идват от другаде: генеричните типове съществуват само при компилация (при изпълнение се „изтриват“) и урок 0 вече го загатна.

И остава въпросът как да преобразуваш една колекция в друга: да филтрираш бавните, да превърнеш резултатите в текстови редове, да броиш по състояние. Java го прави със стриймове (streams), начин да опишеш преобразуването като верига от стъпки. А понеже не всяка услуга има време (падналата не е отговорила), трябва да се реши и как се представя „няма стойност“, без да се връщаме към null от урок 2: за това съществува Optional.

Пътят има две сесии. Първата покрива раздели 4.1 и 4.2: колекциите, неизменяемите и генеричните типове; когато я завършиш, умееш да пазиш и защитаваш услугите на revisor. Втората покрива 4.3, 4.4 и 4.5: стриймовете, Optional и пълния отчет; когато я завършиш, програмата отпечатва таблицата с обобщението ѝ. Ето крайната цел, за да имаш пред очите си мястото на пристигане:

SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión

4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms

Таблицата вече я познаваше; новото е редът в края и това, че сега идва от истински списък, а не от четири извиквания, написани на ръка.

Понятията

4.1 Колекции

4.1.1 Списък: List и ArrayList

Списъкът е подредена последователност от стойности, където всяка има позиция, която в Java започва от 0. В Java List не е клас, а интерфейс: договор, който казва какво може да се прави със списък (добавяне, търсене, махане, обхождане), без да казва как се съхранява отвътре. Имплементацията, която ще използваш почти винаги, се казва ArrayList и пази стойностите във вътрешен масив, който расте, когато се напълни. Моделът на употреба е да декларираш променливата с интерфейса и да създадеш обекта с имплементацията:

List<String> nombres = new ArrayList<>();

Този ред има три части, които е добре да разделиш. List<String> е типът на променливата: списък от текстове (това, което е между знаците < и >, е типът на елементите му; раздел 4.2 обяснява защо). new ArrayList<>() създава празен списък от конкретния клас. А празното <> се нарича диамант: казва на компилатора „типът на елементите е същият, който вече декларирах отляво, не ме карай да го повтарям“. Защо да декларираш променливата като List, а не като ArrayList? Защото останалата част от програмата има нужда само от договора. Ако утре смениш имплементацията с друга, сменяш една дума на един ред и нищо повече. Това е същата причина, поради която в урок 3 Respuesta беше интерфейс, а не клас.

Виж всичко, което може да се прави със списък, в програмата fig04_01. Преди да я прочетеш, едно уточнение: тя е компактен файл, като тези от урок 2, и затова не се нуждае от import за List, нито за ArrayList. Компактните файлове импортират сами целия модул java.base, който включва java.util, където живеят колекциите. Когато обикновен клас има нужда от тях, както става в раздел 4.5, ще ги импортира с един ред в началото на файла.

// fig04_01.java
void main() {
  List<String> nombres = new ArrayList<>();
  nombres.add("catalogo");
  nombres.add("inventario");
  nombres.add("pagos");
  IO.println(nombres);
  IO.println("tamaño: " + nombres.size());
  IO.println("el segundo: " + nombres.get(1));
  IO.println("¿está pagos? " + nombres.contains("pagos"));
  IO.println("posición de pagos: " + nombres.indexOf("pagos"));
  IO.println("primero y último: " + nombres.getFirst() + ", " + nombres.getLast());

  nombres.add(1, "reportes");
  nombres.remove("pagos");
  nombres.set(0, "catalogo-v2");
  IO.println(nombres);

  for (String nombre : nombres) {
    IO.println("servicio: " + nombre);
  }
  for (int i = 0; i < nombres.size(); i++) {
    IO.println(i + " -> " + nombres.get(i));
  }
}

Стартирай програмата и сравни с това, което си предположил:

$ java fig04_01.java
[catalogo, inventario, pagos]
tamaño: 3
el segundo: inventario
¿está pagos? true
posición de pagos: 2
primero y último: catalogo, pagos
[catalogo-v2, reportes, inventario]
servicio: catalogo-v2
servicio: reportes
servicio: inventario
0 -> catalogo-v2
1 -> reportes
2 -> inventario

Три подробности от този изход. Първо, println умее да отпечата цял списък: записва го в квадратни скоби с елементите, разделени със запетаи, защото ArrayList дефинира собствено представяне като текст. Второ, remove("pagos") махна елемента, чиято стойност е „pagos“; с текстове съмнение няма, но remove има две версии (една, която получава стойност, и друга, която получава позиция) и със списъци от числа те се бъркат. Разделът „Какво се прави погрешно“ показва клопката. Трето, getFirst() и getLast() са сравнително нови: дошли са с Java 21 заедно с последователните колекции (JEP 431) и избягват стария get(lista.size() - 1) за последния елемент.

Двата начина за обхождане на списък в програмата заслужават обяснение, защото курсът не ги е използвал досега. Подобреният for (for (String nombre : nombres)) се чете „за всяко nombre в nombres“: на всяка итерация nombre има стойността на следващия елемент и няма индекс за водене. Това е обичайната форма. Класическият for (for (int i = 0; i < nombres.size(); i++)) води индекса на ръка: започва от 0 (int i = 0), повтаря, докато условието е вярно (i < nombres.size()), и напредва в края на всяка итерация (i++, което значи „прибави едно към i“). Използвай го само когато ти трябва позицията. Ако сгрешиш с нея, списъкът ти го казва с изключение: ако поискаш nombres.get(10) в списък от три елемента, хвърля IndexOutOfBoundsException: Index 10 out of bounds for length 3, а съобщението носи индекса и размера, което е всичко, от което се нуждаеш, за да разбереш какво е станало.

Едно последно сравнение с езиците, които вече познаваш, защото въпросът се появява винаги. Java има и масиви (String[], с фиксиран размер, които служат за малко неща и които ще видиш в урок 5 и в 6, когато пишеш String[] args), но в ежедневния код се използват списъци: растат, търсят се, подават се на функции и се комбинират със стриймове. Каквото в Go е slice, а в Python list, в Java е List<T>.

4.1.2 Множества и мапове

Множеството (Set) пази стойности без повторение: ако добавиш такава, която вече я има, не се случва нищо. Мапът (Map) пази двойки от ключ и стойност и търси стойността по ключа. Програмата fig04_02 използва и двете:

// fig04_02.java
void main() {
  Set<String> vistos = new LinkedHashSet<>();
  IO.println(vistos.add("catalogo"));
  IO.println(vistos.add("pagos"));
  IO.println(vistos.add("catalogo"));
  IO.println(vistos + ", tamaño " + vistos.size());

  Map<String, Long> tiempos = new LinkedHashMap<>();
  tiempos.put("catalogo", 142L);
  tiempos.put("inventario", 2300L);
  tiempos.put("pagos", 87L);
  IO.println(tiempos);
  IO.println(tiempos.get("pagos"));
  IO.println(tiempos.get("reportes"));
  IO.println(tiempos.getOrDefault("reportes", 0L));
  IO.println(tiempos.containsKey("reportes"));
  IO.println(tiempos.put("pagos", 90L));
  IO.println(tiempos);

  for (Map.Entry<String, Long> par : tiempos.entrySet()) {
    IO.println(par.getKey() + " tardó " + par.getValue() + " ms");
  }

  Map<String, Long> ordenado = new TreeMap<>(tiempos);
  IO.println(ordenado);
}
$ java fig04_02.java
true
true
false
[catalogo, pagos], tamaño 2
{catalogo=142, inventario=2300, pagos=87}
87
null
0
false
87
{catalogo=142, inventario=2300, pagos=90}
catalogo tardó 142 ms
inventario tardó 2300 ms
pagos tardó 90 ms
{catalogo=142, inventario=2300, pagos=90}

Прочети изхода спокойно, защото съдържа няколко урока. При множеството add връща boolean: true, ако стойността е влязла, и false, ако вече е била там. Затова първите три реда казват true, true, false: това е евтин начин да попиташ „виждал ли съм го вече?“ и ще го използваш в урок 5, за да откриваш повтарящи се имена в конфигурацията.

При мапа put записва двойка и връща предишната стойност на този ключ или null, ако не е имало. В изхода put("pagos", 90L) отпечата 87, стойността, която току-що беше заменила. get търси по ключ и връща null, ако ключът не съществува: tiempos.get("reportes") отпечата null. Този null е същият източник на грешки от урок 2 и затова съществува getOrDefault("reportes", 0L): връща стойността по подразбиране, която ти посочиш, когато ключът липсва. containsKey отговаря на въпроса „има ли този ключ?“, без да докосва стойността.

За да обходиш един мап, обхождаш множеството от двойките му с entrySet(); всяка двойка е Map.Entry, от която getKey() дава ключа, а getValue() стойността. Ще видиш, че типът се пише дълъг (Map.Entry<String, Long>); в истинския код се съкращава с var, което вече познаваш от урок 2: for (var par : tiempos.entrySet()).

Въпросът, който следва, е най-важният в раздела: в какъв ред излизат елементите? Отговорът зависи от имплементацията и затова Java има няколко за всеки интерфейс:

Имплементация Ред на обхождане Кога да я използваш
HashSet, HashMap никакъв, който можеш да обещаеш когато има значение само бързото търсене
LinkedHashSet, LinkedHashMap редът, в който са вмъкнати когато отчетът трябва да излиза винаги еднакъв
TreeSet, TreeMap подредени по ключ когато ти трябва естественият ред (азбучен, числов)

Фигурата използва LinkedHashSet и LinkedHashMap, за да е изходът винаги този, който виждаш, и завърши с копиране на мапа в TreeMap, който го пренареди по азбучен ред (тук съвпада с реда на вмъкване, защото имената вече бяха подредени). За revisor това не е подробност: програмата на курса обещава отчет „винаги в един и същ ред“, а таблица, която излиза в различен ред всеки път, когато стартираш програмата, е безполезен отчет. Практическото правило е: ако редът има значение за този, който чете резултата, избери имплементация, която го гарантира, и не разчитай, че един HashMap „изглежда“ подреден на твоята машина. Всъщност неизменяемите колекции, които ще видиш в следващия раздел (Set.of, Map.of), имат ред, който се променя от едно изпълнение на друго нарочно: измерих същия ред, Set.of("catalogo", "inventario", "pagos", "reportes"), в три поредни изпълнения на същата програма и излезе [pagos, reportes, catalogo, inventario], после [catalogo, inventario, pagos, reportes] и после [catalogo, reportes, pagos, inventario]. Документацията предупреждава: „the iteration order of set elements is unspecified and is subject to change“. Редът не е част от договора и JDK не си прави труда да го поддържа стабилен.

Остава един въпрос: откъде знае едно Set, че две стойности са „една и съща“? Използва два метода: equals, който вече познаваш, и hashCode, който връща цяло число, обобщаващо съдържанието на обекта (два равни обекта трябва да дават същото число и това позволява на множеството да търси, без да сравнява с всички). С текстове и числа работи, без да правиш нищо. С твои собствени типове зависи от това как са дефинирани и тук records от урок 3 изплащат това, което обещаха: генерират equals и hashCode от всичките си компоненти. Програмата fig04_03 го показва: две Direccion със същия сървър и същия порт са равни, макар да са два различни обекта, и затова множеството не повтаря, а мапът намира стойността с току-що конструиран ключ.

// fig04_03.java
record Direccion(String servidor, int puerto) {}

void main() {
  Set<Direccion> direcciones = new LinkedHashSet<>();
  direcciones.add(new Direccion("catalogo.example", 443));
  direcciones.add(new Direccion("pagos.example", 443));
  direcciones.add(new Direccion("catalogo.example", 443));
  IO.println(direcciones.size());
  IO.println(direcciones);

  Map<Direccion, String> duenos = new HashMap<>();
  duenos.put(new Direccion("pagos.example", 443), "equipo de cobranza");
  IO.println(duenos.get(new Direccion("pagos.example", 443)));
  IO.println(duenos.get(new Direccion("pagos.example", 8443)));
}
$ java fig04_03.java
2
[Direccion[servidor=catalogo.example, puerto=443], Direccion[servidor=pagos.example, puerto=443]]
equipo de cobranza
null

Множеството завърши с два елемента, а не с три, мапът намери „equipo de cobranza“ (екипа по събиране на вземания) с нов ключ, а ключът с друг порт (8443) не съществува, така че get върна null. Ако вместо record беше написал обикновен клас без да дефинираш equals и hashCode, всеки обект би бил равен само на себе си и множеството би запазило и трите. Това е една от причините курсът да предпочита records за данните.

4.1.3 Неизменяеми колекции

Дотук колекциите можеха да се променят: добавяне, махане, заместване. Много пъти точно това не искаш. Списъкът с услуги, който чете revisor, не бива да се променя, докато някой друг го обхожда, а отчет, който вече е изчислен, не бива да може да бъде променян след това. Java решава това с неизменяемите колекции, които се създават с три метода: List.of(...), Set.of(...) и Map.of(...). (Наричат се статични фабрични методи: не ти трябва new, а само името на интерфейса, една точка и стойностите.) Виж какво става, когато се опиташ да промениш такава:

// fig04_04.java
void main() {
  List<String> nombres = List.of("catalogo", "inventario", "pagos");
  IO.println(nombres);
  nombres.add("reportes");
  IO.println(nombres);
}
$ java fig04_04.java
[catalogo, inventario, pagos]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
	at fig04_04.main(fig04_04.java:5)

Програмата отпечатва списъка веднъж, а add хвърля java.lang.UnsupportedOperationException („неподдържана операция“). Това е изключение без съобщение и не се нуждае от такова: поисканата операция просто не съществува в този списък. Следата се чете отгоре надолу, обратно на това в Python: първият ред казва какво е хвърлено, вторият къде се е родило вътре в JDK (ImmutableCollections.uoe, „unsupported operation exception“), третият кой метод си извикал (add), а последният, fig04_04.main(fig04_04.java:5), кой ред от твоята програма го е направил. Винаги търси първия ред, който споменава твоя файл: там е твоята грешка. (Числата 159 и 164 са от този JDK и ще се променят в твоя.)

Неизменяемостта на тези колекции е истинска и пълна, що се отнася до колекцията: не могат да се добавят, махат или заместват елементи. Авторите на JDK са добавили и други правила, които се появяват в документацията на List и които проверих, докато пишех този урок:

  • Не допускат null. List.of("a", null) хвърля NullPointerException при създаването. Това е решение в полза на по-малко изненади: null вътре в колекция е почти винаги грешка.
  • Set.of и Map.of отхвърлят повторенията. Map.of("a", 1, "a", 2) хвърля IllegalArgumentException: duplicate key: a, а Set.of("a", "a") хвърля IllegalArgumentException: duplicate element: a. Повтарящ се ключ в мап с фиксирани данни е почти сигурно недоглеждане и се открива при стартиране, а не когато някой пита.
  • Редът им не е стабилен между изпълненията, както беше измерено по-горе.
  • Map.of приема до десет двойки. За повече съществува Map.ofEntries.

Две уточнения, за да не бъркаш какво обещава неизменяемостта. Първото: неизменяем не значи, че елементите са неизменяеми. List.of(new ArrayList<>(), new ArrayList<>()) не позволява да добавяш или махаш списъци, но всеки вътрешен списък продължава да може да се променя. С records от прости компоненти (текстове, числа, други records), като тези на revisor, проблемът не съществува; с изменяеми елементи съществува. Второто: урок 3 вече спомена повърхностната неизменяемост на records (компонент масив остава изменяем), а това е същото с друго име: record-ът не може да смени списъка, но ако списъкът, който пази, е изменяем, списъкът се променя. Това води към следващия раздел.

4.1.4 Защитни копия

Тук има клопка, която се учи най-добре, като я видиш. Виж програмата fig04_05. Дефинира два records с една и съща форма: ReporteIngenuo, който пази списъка така, както му го дадат, и ReporteSeguro, който в компактния си конструктор (този от урок 3) заменя списъка с List.copyOf(nombres), неизменяемо копие. После им подава един и същ списък и го променя след това:

// fig04_05.java
record ReporteIngenuo(List<String> nombres) {}

record ReporteSeguro(List<String> nombres) {
  ReporteSeguro {
    nombres = List.copyOf(nombres);
  }
}

void main() {
  List<String> nombres = new ArrayList<>(List.of("catalogo", "pagos"));
  ReporteIngenuo ingenuo = new ReporteIngenuo(nombres);
  ReporteSeguro seguro = new ReporteSeguro(nombres);
  nombres.add("reportes");
  IO.println("ingenuo: " + ingenuo);
  IO.println("seguro:  " + seguro);

  List<String> vista = Collections.unmodifiableList(nombres);
  List<String> copia = List.copyOf(nombres);
  nombres.add("facturacion");
  IO.println("vista: " + vista);
  IO.println("copia: " + copia);
}
$ java fig04_05.java
ingenuo: ReporteIngenuo[nombres=[catalogo, pagos, reportes]]
seguro:  ReporteSeguro[nombres=[catalogo, pagos]]
vista: [catalogo, pagos, reportes, facturacion]
copia: [catalogo, pagos, reportes]

Наивният отчет се промени сам, без никой да го докосва: запази референция към оригиналния списък, а не копие, и когато първоначалният собственик добави reportes, вече изчисленият „отчет“ се промени. Безопасният запази двете имена, които е имал в момента на създаването си. Това е защитно копие: конструкторът се защитава от това, че някой друг продължава да има референция към данни, които вече са негови. Това е от онези неща, които не гърмят: връщат грешно число седмица по-късно.

Последните два реда на фигурата показват друга разлика, която обърква. Collections.unmodifiableList(nombres) не копира нищо: това е изглед, който не позволява промяна през него, но отразява всичко, което първоначалният собственик промени (затова vista завърши с facturacion). List.copyOf(nombres) прави независимо копие (затова copia го няма). Ако искаш никой да не може да промени данните ти, ти трябва копието; изгледът само пречи да пише този, който го получава.

Тази техника не е стилово украшение. Това, което току-що видя, е точно причината Reporte от раздел 4.5 да носи resultados = List.copyOf(resultados); в конструктора си. И не е мания на този курс: анализаторите на код познават пролуката. В урок 10 ще опознаеш SpotBugs, инструмент, който преглежда компилираната програма за вероятни дефекти и който нарича този шаблон EI_EXPOSE_REP и EI_EXPOSE_REP2 (от exposing internal representation, „излагане на вътрешното представяне“). С едно условие, което е добре да знаеш отсега, защото е измерено: SpotBugs го отчита само в класовете, които се виждат отвън на своя пакет. Reporte на revisor не е публичен и ако му махнеш копието, инструментът мълчи. Копието не е там, за да удовлетвори инструмент: там е, защото списъкът е на record-а, а това, което инструментът не проверява, не престава да бъде дефект.

Бележка за ефективността: List.copyOf е хитър. Ако списъкът, който му подаваш, вече е неизменяем (например този, създаден от List.of), не го копира, а връща същия. Има смисъл: няма какво да се защитава. Документацията го казва с израза „if the given Collection is an unmodifiable List, calling copyOf will generally not create a copy“.

4.2 Генерични типове

4.2.1 Какво са и какво ти дават

Всеки път, когато си написал List<String> или Map<String, Long>, си използвал генерични типове: клас или интерфейс, дефиниран за „някакъв тип“, който ти конкретизираш, когато го използваш. List<E> се дефинира веднъж, с тип E, който предстои да се попълни, а ти решаваш дали е списък от String, от Integer или от Servicio. Печелиш проверка при компилация: компилаторът знае какъв тип елемент има списъкът и отхвърля онова, което не пасва. Да видим какво казва, когато се опиташ да вкараш текст в списък от числа:

// fig04_06.java
void main() {
  List<Integer> tiempos = new ArrayList<>();
  tiempos.add(142);
  tiempos.add("87 ms");
  IO.println(tiempos);
}
$ java fig04_06.java
fig04_06.java:5: error: no suitable method found for add(String)
  tiempos.add("87 ms");
         ^
    method List.add(Integer) is not applicable
      (argument mismatch; String cannot be converted to Integer)
    method List.add(int,Integer) is not applicable
      (actual and formal argument lists differ in length)
1 error
error: compilation failed

Съобщението е по-дълго, отколкото би очаквал. Компилаторът казва no suitable method found for add(String) („не намерих метод add, който да върши работа за String“), защото List има два метода, наречени add: един, който получава елемент (add(Integer)), и друг, който получава позиция и елемент (add(int, Integer)). Опитал е и двата и обяснява защо никой не върши работа: първият, защото String не се превръща в Integer (argument mismatch, „аргументът не съвпада“), вторият, защото дори няма същия брой параметри. Винаги чети реда в скоби: там е истинската причина. Ако пък се опиташ да извадиш стойност и да я присвоиш на грешната променлива (String texto = tiempos.get(0);), съобщението е най-простото: incompatible types: Integer cannot be converted to String.

Без генерични типове нито една от двете грешки не би съществувала при компилация: списъкът би приемал всичко, а първото предупреждение би дошло при изпълнение, далеч от мястото, където е допусната грешката. Това е разликата между език, който те пази, и такъв, който ти позволява да пишеш всичко. (Раздел 4.2.3 показва какво става, когато някой нарочно прескочи защитата.)

4.2.2 Генеричните типове приемат само референции: обвивките

Напиши List<int> и виж какво казва компилаторът:

// fig04_07.java
void main() {
  List<int> tiempos = new ArrayList<>();
  tiempos.add(142);
  IO.println(tiempos);
}
$ java fig04_07.java
fig04_07.java:3: error: unexpected type
  List<int> tiempos = new ArrayList<>();
       ^
  required: reference
  found:    int
1 error
error: compilation failed

Съобщението е unexpected type; required: reference; found: int: поискана е референция, а е намерен примитив. Генеричните типове работят само с референтни типове, а int е примитив (урок 2). Решението е да използваш обвивката на примитива: Integer за int, Long за long, Double за double, Boolean за boolean. Това са типовете, които вече познаваш от урок 2 и които сега имат причина да съществуват. Java превръща между примитив и неговата обвивка сама, в двете посоки, когато е нужно: това се нарича autoboxing (да вкараш 142 в Integer) и unboxing (да го извадиш). Удобно е, но има две клопки, които вече познаваш под друго име и които заслужават по една фигура.

Първата: == между обвивки сравнява референции, както при String. Програмата fig04_08 го измерва:

// fig04_08.java
void main() {
  List<Integer> tiempos = List.of(87, 87, 1000, 1000);
  IO.println(tiempos.get(0) == tiempos.get(1));
  IO.println(tiempos.get(2) == tiempos.get(3));
  IO.println(tiempos.get(2).equals(tiempos.get(3)));
}
$ java fig04_08.java
true
false
true

Двете 87 са „същият“ обект, а двете 1000 не са. Причината е, че autoboxing използва Integer.valueOf, а документацията му обещава, че „винаги кешира стойностите от −128 до 127 и може да кешира други“: за малките преизползва същия обект, а за 1000 от тази фигура на практика създава нов всеки път. Това е документирано поведение, не грешка, и затова е опасно: програма, която сравнява с ==, работи с малките времена на тестовете и се проваля с тези в продукция. Правилото е това от урок 2, разширено: сравнявай стойности с equals, никога с ==, винаги когато типът е референция, а обвивките са такива.

Втората клопка е unboxing на стойност, която я няма. Програмата fig04_09 иска от Map<String, Long> време и го записва в променлива long:

// fig04_09.java
void main() {
  Map<String, Long> tiempos = Map.of("catalogo", 142L, "pagos", 87L);
  long tiempoPagos = tiempos.get("pagos");
  IO.println("pagos: " + tiempoPagos);
  long tiempoReportes = tiempos.get("reportes");
  IO.println("reportes: " + tiempoReportes);
}
$ java fig04_09.java
pagos: 87
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null
	at fig04_09.main(fig04_09.java:6)

Първото търсене работи. Второто се проваля с NullPointerException, чието съобщение е бижу на диагностиката: Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is null. Чете се така: „не можах да извикам longValue(), защото онова, което върна Map.get, е null“. Никой не е писал longValue(): вмъкнал го е компилаторът, като превръщал Long в long, и се е провалило, защото get("reportes") е върнал null (ключът не е съществувал). Съобщенията на NullPointerException винаги ти казват какво е било нулево, а в този случай дори кой метод го е върнал. Два начина да избегнеш грешката: да поискаш getOrDefault("reportes", 0L) или да запишеш резултата в Long (не long) и да попиташ if (tiempo != null). Раздел 4.4 носи трети, по-добър.

4.2.3 Изтриването на типовете

Урок 0 вече го загатна: генеричните типове се изтриват. Компилаторът използва List<String>, за да провери кода ти, и след като го провери, маха типовете: в байткода (bytecode) List<String> и List<Integer> са един и същ List. Така са останали от Java 5: изтриването гарантира, че не се създава нов клас за всеки параметризиран тип, така че генеричните типове не струват нищо при изпълнение, а кодът, написан, преди да съществуват (почти всички колекции бяха такива), продължи да работи. Програмата fig04_10 го потвърждава и показва какво струва:

// fig04_10.java
void main() {
  List<String> nombres = new ArrayList<>();
  List<Integer> tiempos = new ArrayList<>();
  IO.println(nombres.getClass() == tiempos.getClass());
  IO.println(nombres.getClass().getName());

  List crudo = nombres;
  crudo.add(142);
  IO.println("agregué un número a una lista de String y nadie se quejó");
  String primero = nombres.get(0);
  IO.println(primero);
}
$ java fig04_10.java
fig04_10.java:9: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
  crudo.add(142);
           ^
  where E is a type-variable:
    E extends Object declared in interface List
1 warning
true
java.util.ArrayList
agregué un número a una lista de String y nadie se quejó
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
	at fig04_10.main(fig04_10.java:11)

Тук има няколко неща за четене. Компилаторът отпечата предупреждение (warning: [unchecked] unchecked call to add(E) as a member of the raw type List), преди да изпълни програмата: предупреждава те, че на ред 9 си направил нещо, което той не може да провери. Причината е на ред 8: List crudo = nombres;. List без <...> е суров тип (raw type), същият тип като преди Java 5. Компилаторът приема да запишеш в него списък от String и приема да му добавиш 142, защото със суровия тип не проверява нищо, само предупреждава. После програмата отпечатва „agregué un número a una lista de String y nadie se quejó“ (добавих число към списък от String и никой не се оплака) и се проваля на ред 11, а не на 9:

ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

Грешката се появи при вадене на елемента, два реда след този, който го вкара. В истинска програма биха могли да са два различни класа и десет минути дебъгване. Това е последствието от това, че генеричните типове се проверяват само при компилация: компилаторът е могъл да ти обещае, че списъкът е от String, само докато никой не прескочи системата от типове, а суровият тип е пролуката. Правилото: никога не използвай сурови типове в нов код; ако видиш предупреждение [unchecked], това е компилаторът, който ти казва да оправиш нещо.

Първите два реда на изхода са другата страна на изтриването. nombres.getClass() == tiempos.getClass() е true и името на класа е java.util.ArrayList, без следа от <String>: по време на изпълнение списъкът не знае от какво е. Оттук излизат три ограничения, които ще срещнеш на практика и които е добре да знаеш отсега, за да не губиш час в търсене: не можеш да пишеш new T() вътре в генеричен метод, не можеш да питаш if (lista instanceof List<String>) (само List<?>) и не можеш да претовариш два метода, които се различават единствено по генеричния тип (void f(List<String>) и void f(List<Integer>) се сблъскват, защото след изтриването са един и същ метод). Официалният урок на Java за изтриването изброява пълните правила.

4.2.4 Как да напишеш генеричен метод и да прочетеш сигнатура

Почти никога няма да дефинираш генеричен клас, но ще четеш сигнатурите на тези от JDK и от време на време ще пишеш генеричен метод. Виж един във fig04_11:

// fig04_11.java
static <T> T primeroO(List<T> lista, T porOmision) {
  if (lista.isEmpty()) {
    return porOmision;
  }
  return lista.get(0);
}

void main() {
  List<String> nombres = List.of("catalogo", "pagos");
  List<Long> tiempos = List.of();
  String primerNombre = primeroO(nombres, "ninguno");
  Long primerTiempo = primeroO(tiempos, 0L);
  IO.println(primerNombre);
  IO.println(primerTiempo);
}
$ java fig04_11.java
catalogo
0

<T> преди върнатия тип декларира променлива на типа: „в този метод T е тип, който предстои да се определи“. Компилаторът го извежда от аргументите: при първото извикване T е String, а при второто Long и затова резултатът може да се запише в String и в Long без никакво преобразуване. Ако го беше написал с Object вместо T, щеше да те задължи да преобразуваш на ръка.

И за да четеш документацията, още един символ: ?, заместителят (wildcard). Ще видиш сигнатури като List.copyOf(Collection<? extends E> coll). Чете се „колекция от някакъв тип, който е E или подклас на E“. Думата extends тук значи „е E или наследява от него“, а заместителят позволява да му подадеш, например, списък от Respondio там, където се очаква колекция от Respuesta. Още не ти трябва да пишеш заместители; трябва да можеш да ги четеш, без да се плашиш, защото се появяват в Collectors, в Comparator и в почти цялата библиотека за стриймове, която следва.

С това завършва първата сесия. Преди да продължиш, провери, че можеш да отговориш, без да гледаш: каква е разликата между List.copyOf и Collections.unmodifiableList? Защо Set.of отпечатва в друг ред всеки път? Какво се проваля при компилация с List<int> и какво се проваля при изпълнение със суров списък? Ако се колебаеш за някой, върни се към неговата фигура, преди да отвориш сесия две.

4.3 Стриймове

Започва втората сесия. Досега пазеше стойности; сега ще ги преобразуваш. Конкретният въпрос на revisor е: при даден списък от резултати, колко са работещи, колко бавни, колко паднали и какво е средното на времената на онези, които са отговорили? С това, което знаеш, можеш да напишеш for с четири брояча. Стриймовете са другият отговор и за да ги разбереш, първо трябва да разбереш инструмента, с който се пишат: ламбдите.

4.3.1 Ламбди и препратки към метод

Ламбдата е малка функция, без име, която пишеш на мястото, където ти трябва, и подаваш като стойност. Формата ѝ е параметри -> тяло. Стрелката вече я видя в switch от урок 2, но тук значи нещо друго: не „ако случаят съвпадне, върни това“, а „при този параметър, изчисли това“. Ето формите, които съществуват:

  • medicion -> medicion.tiempoMs() >= 1000: един параметър, тяло, което е израз (резултатът е стойността на израза).
  • (a, b) -> a + b: два параметъра, в скоби.
  • () -> "revisando...": никакъв, с празни скоби.
  • medicion -> { ...varias líneas...; return valor; }: блок във фигурни скоби, с return, ако трябва да върне нещо.

А какъв е типът на една ламбда? В Java ламбдата винаги се присвоява на функционален интерфейс: интерфейс с един-единствен абстрактен метод. Ламбдата е имплементацията на този метод. JDK носи няколко, които се използват непрекъснато: Predicate<T> (един метод, test, който получава T и връща boolean), Function<T, R> (apply, получава T, връща R), Supplier<T> (get, не получава нищо и връща T) и Comparator<T> (compare, който получава два и връща цяло число). Програмата fig04_12 ги използва един по един:

// fig04_12.java
record Medicion(String nombre, long tiempoMs) {}

void main() {
  Predicate<Medicion> esLenta = medicion -> medicion.tiempoMs() >= 1000;
  Function<Medicion, String> etiqueta = medicion -> medicion.nombre() + ":" + medicion.tiempoMs();
  Function<Medicion, String> soloNombre = Medicion::nombre;
  Supplier<String> saludo = () -> "revisando...";

  Medicion inventario = new Medicion("inventario", 2300);
  Medicion pagos = new Medicion("pagos", 87);
  IO.println(esLenta.test(inventario));
  IO.println(esLenta.test(pagos));
  IO.println(etiqueta.apply(pagos));
  IO.println(soloNombre.apply(inventario));
  IO.println(saludo.get());
}
$ java fig04_12.java
true
false
pagos:87
inventario
revisando...

esLenta е Predicate<Medicion>: подава му се измерване с test и отговаря true или false. etiqueta е Function<Medicion, String>: превръща измерване в текст. saludo е Supplier<String>: не получава нищо и произвежда стойност. Когато пишеш ламбда вътре в стрийм, не назоваваш тези типове; компилаторът ги извежда от метода, на който я подаваш. Но познаването им е онова, което ти позволява да четеш документацията: когато filter иска Predicate, иска „функция, която получава елемент и отговаря да или не“.

soloNombre показва втория инструмент: препратката към метод. Medicion::nombre е краткият вид на medicion -> medicion.nombre(): „извикай метода nombre на елемента, който ти дадат“. Чете се по-добре, когато ламбдата само извиква метод, и ще я виждаш навсякъде (Resultado::estado, Revision::revisar, Formato::linea). Използвай тази, която се чете по-ясно; компилаторът ги третира еднакво.

Има правило за ламбдите, което изненадва този, който идва от JavaScript или Python. Виж тази програма, която се опитва да сумира времената с ламбда, променяща външна променлива:

// fig04_13.java
void main() {
  List<Long> tiempos = List.of(142L, 2300L, 87L);
  long suma = 0;
  tiempos.forEach(tiempo -> suma += tiempo);
  IO.println(suma);
}
$ java fig04_13.java
fig04_13.java:5: error: local variables referenced from a lambda expression must be final or effectively final
  tiempos.forEach(tiempo -> suma += tiempo);
                            ^
1 error
error: compilation failed

Грешката казва local variables referenced from a lambda expression must be final or effectively final: една ламбда може да използва външни локални променливи само ако те никога не се преприсвояват (са ефективно final). Тук suma се променя на всяка итерация и компилаторът го отхвърля. Причината е, че ламбда може да се изпълни по-късно или в друга нишка и Java не иска ламбдата и външният код да споделят променлива, която се променя. В урок 9 ще видиш защо това е ценна защита. Изходът от този проблем не е да заобиколиш правилото, а да използваш правилния инструмент: mapToLong(...).sum(), което е точно това, което идва в следващия раздел. Ако се хванеш, че пишеш ламбда, която променя нещо отвън, почти винаги е знак, че ѝ липсва collect или sum.

4.3.2 Стриймът е тръба

Стриймът (stream) (поток) не е колекция: той е описание на последователност от стъпки, които се прилагат към елементите на един източник. Има три части. Източник, който почти винаги е колекция и се получава с .stream(). Нула или повече междинни операции (filter, map, sorted...), които преобразуват потока в друг поток. И терминална операция (toList, count, sum, collect...), която произвежда крайния резултат и е тази, която наистина пуска всичко в ход. Стъпките се пишат верижно, по една на ред, и се четат отгоре надолу като рецепта. Виж програмата fig04_14, която върши същата работа два пъти: първо с цикъл, после със стрийм.

// fig04_14.java
record Medicion(String nombre, long tiempoMs) {}

void main() {
  List<Medicion> mediciones =
      List.of(
          new Medicion("catalogo", 142),
          new Medicion("inventario", 2300),
          new Medicion("pagos", 87));

  List<String> conBucle = new ArrayList<>();
  for (Medicion medicion : mediciones) {
    if (medicion.tiempoMs() >= 100) {
      conBucle.add(medicion.nombre() + ":" + medicion.tiempoMs());
    }
  }

  List<String> conStream =
      mediciones.stream()
          .filter(medicion -> medicion.tiempoMs() >= 100)
          .map(medicion -> medicion.nombre() + ":" + medicion.tiempoMs())
          .toList();

  IO.println(conBucle);
  IO.println(conStream);
  IO.println(conBucle.equals(conStream));

  List<String> nombres = mediciones.stream().map(Medicion::nombre).toList();
  long suma = mediciones.stream().mapToLong(Medicion::tiempoMs).sum();
  long rapidos = mediciones.stream().filter(medicion -> medicion.tiempoMs() < 100).count();
  IO.println(nombres);
  IO.println("suma: " + suma + " ms");
  IO.println("rápidos: " + rapidos);
}
$ java fig04_14.java
[catalogo:142, inventario:2300]
[catalogo:142, inventario:2300]
true
[catalogo, inventario, pagos]
suma: 2529 ms
rápidos: 1

Цикълът и стриймът произвеждат същия списък (последният ред проверява с equals, че са равни). Стриймът се чете така: „от измерванията задръж тези, които изпълняват tiempoMs() >= 100 (filter), превърни всяко в текст (map) и събери резултата в списък (toList)“. Обърни внимание на това, което липсва: няма празен списък, който се пълни, нито условие, обвито в if, нито променлива, която се променя. Който чете, описва какво иска, а не как да се направи.

Операциите, които ще използваш най-често:

Операция Вид Какво прави
filter(Predicate) междинна пропуска само елементите, които изпълняват условието
map(Function) междинна превръща всеки елемент в друга стойност
mapToLong(...) междинна превръща всеки елемент в примитивен long (без обвивки)
sorted(Comparator) междинна подрежда (раздел 4.3.5)
toList() терминална събира елементите в неизменяем списък
count() терминална брои елементите
sum(), average(), max() терминална агрегират; съществуват върху потоците от примитиви
collect(Collector) терминална събира елементите по рецепта (раздел 4.3.4)

Две уточнения към таблицата. mapToLong произвежда специализиран поток (LongStream), който умее да сумира и да осреднява, без да обвива всяко число в Long; съществуват и братята му IntStream и DoubleStream. А toList() връща неизменяем списък, като List.of: ако по-късно ти трябва изменяем, го копираш с new ArrayList<>(lista). Разделът „Грешката, която ще видиш“ ще покаже изключението, което излиза, когато се забрави.

4.3.3 Мързеливи и за еднократна употреба

Има две свойства на стриймовете, които не се отгатват и които пораждат грешки, ако ги пренебрегнеш. Първото е, че са мързеливи: сглобяването на потока не прави нищо. Според документацията на пакета java.util.stream междинните операции „са винаги мързеливи“: filter не филтрира нищо, когато го извикаш, а само създава нов поток, който ще филтрира, когато някой го обходи. Работата се случва, когато дойде терминалната операция. Програмата fig04_15 го прави видимо с peek, операция, която пропуска всеки елемент и между другото изпълнява действие (документацията ѝ казва, че съществува „основно за дебъгване“ и само за това я използваме тук):

// fig04_15.java
record Medicion(String nombre, long tiempoMs) {}

void main() {
  List<Medicion> mediciones =
      List.of(
          new Medicion("catalogo", 142),
          new Medicion("inventario", 2300),
          new Medicion("pagos", 87));

  Stream<String> armado =
      mediciones.stream()
          .peek(medicion -> IO.println("miro " + medicion.nombre()))
          .filter(medicion -> medicion.tiempoMs() >= 100)
          .peek(medicion -> IO.println("  pasa " + medicion.nombre()))
          .map(Medicion::nombre);
  IO.println("el stream está armado; todavía no pasó nada");

  List<String> resultado = armado.toList();
  IO.println(resultado);
}
$ java fig04_15.java
el stream está armado; todavía no pasó nada
miro catalogo
  pasa catalogo
miro inventario
  pasa inventario
miro pagos
[catalogo, inventario]

Виж реда. „el stream está armado; todavía no pasó nada“ (стриймът е сглобен; още нищо не е станало) излиза първо, преди което и да е „miro“ (гледам). peek и filter не се изпълниха нито веднъж, когато потокът беше сглобен: изчакаха toList(). А когато най-после се изпълняват, не го правят стъпка по стъпка върху целия списък, а елемент по елемент: catalogo минава през peek, filter, peek и map, преди inventario да влезе в потока, а pagos, който не изпълнява филтъра, остава на първия peek. Това има практически последствия: поток без терминална операция е мъртъв код (компилаторът не предупреждава), а междинна операция със страничен ефект, като отпечатване, се изпълнява в ред, който не е „първо всички, после всички“.

Второто свойство е, че един поток може да се обходи само веднъж. Документацията на Stream казва, че „потокът трябва да се обработва само веднъж“. Какво става, ако го пренебрегнеш:

// fig04_16.java
void main() {
  Stream<String> nombres = Stream.of("catalogo", "inventario", "pagos");
  IO.println(nombres.count());
  IO.println(nombres.count());
}
$ java fig04_16.java
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:260)
	at java.base/java.util.stream.ReferencePipeline.count(ReferencePipeline.java:750)
	at fig04_16.main(fig04_16.java:5)

Първото извикване на count() работи (3) и консумира потока. Второто хвърля IllegalStateException: stream has already been operated upon or closed („потокът вече е бил обработен или затворен“). Решението е да създаваш нов поток всеки път, когато ти трябва, от колекцията: nombres.stream() е евтин. Затова променливите в курса пазят списъка, а потокът се сглобява и консумира в същия израз.

4.3.4 Collectors: да събереш резултата по различни начини

toList() покрива един случай. За останалите има collect, който получава колектор (collector): рецепта за събиране на елементите. Класът Collectors носи най-обичайните рецепти. Програмата fig04_17 използва тези, които са нужни на обобщението на revisor:

// fig04_17.java
record Medicion(String nombre, String estado, long tiempoMs) {}

void main() {
  List<Medicion> mediciones =
      List.of(
          new Medicion("catalogo", "ARRIBA", 142),
          new Medicion("inventario", "LENTO", 2300),
          new Medicion("pagos", "ARRIBA", 87),
          new Medicion("reportes", "CAIDO", 0));

  Map<String, Long> porEstado =
      mediciones.stream()
          .collect(Collectors.groupingBy(Medicion::estado, TreeMap::new, Collectors.counting()));
  IO.println(porEstado);

  Map<String, List<String>> nombresPorEstado =
      mediciones.stream()
          .collect(
              Collectors.groupingBy(
                  Medicion::estado,
                  TreeMap::new,
                  Collectors.mapping(Medicion::nombre, Collectors.toList())));
  IO.println(nombresPorEstado);

  String todos = mediciones.stream().map(Medicion::nombre).collect(Collectors.joining(", "));
  IO.println(todos);

  OptionalDouble promedio =
      mediciones.stream()
          .filter(medicion -> !medicion.estado().equals("CAIDO"))
          .mapToLong(Medicion::tiempoMs)
          .average();
  IO.println(promedio);
  IO.println(Math.round(promedio.getAsDouble()));

  OptionalDouble sinDatos =
      mediciones.stream()
          .filter(medicion -> medicion.tiempoMs() > 9999)
          .mapToLong(Medicion::tiempoMs)
          .average();
  IO.println(sinDatos);
}
$ java fig04_17.java
{ARRIBA=2, CAIDO=1, LENTO=1}
{ARRIBA=[catalogo, pagos], CAIDO=[reportes], LENTO=[inventario]}
catalogo, inventario, pagos, reportes
OptionalDouble[843.0]
843
OptionalDouble.empty

Първата е най-важната: groupingBy(Medicion::estado, TreeMap::new, Collectors.counting()). groupingBy групира елементите по стойността, която връща функцията (тук състоянието), и на всяка група прилага друг колектор (тук counting(), който брои). Резултатът е мап от състояние към брой: {ARRIBA=2, CAIDO=1, LENTO=1}. Без втория аргумент groupingBy би върнал HashMap с необещан ред; с TreeMap::new (препратка към конструктор: „извикай new TreeMap(), когато ти трябва да създадеш мапа“) резултатът излиза подреден по азбучен ред. Това прилича на колоната „колко има от всяко състояние“ в обобщението на revisor.

Втората рецепта групира същото, но вместо да брои, пази имената на всяка група: Collectors.mapping(Medicion::nombre, Collectors.toList()) преобразува всеки елемент, преди да го събере. Това е шаблонът „групирай и после редуцирай всяка група“, който ще виждаш с различни редуктори. Collectors.joining(", ") събира текстове с разделител; той сглобява тялото на таблицата в раздел 4.5.

Последната част на фигурата е тази, която най-много учи. average() изчислява средното на поток от long и връща OptionalDouble, а не double. Отпечатва се като OptionalDouble[843.0], а getAsDouble() вади числото. Когато потокът е празен (последният случай, където филтърът не пропуска никого), средното не съществува и резултатът е OptionalDouble.empty. Това е умно решение: други езици биха върнали нула или NaN (число, което значи „не е число“), а нулата би излъгала, защото средното на нищо не е нула. Java те принуждава да решиш какво да правиш, когато няма данни. Раздел 4.4 обяснява Optional изцяло.

Една подробност за Math.round(843.0): закръглява double до най-близкото цяло число и връща long. (Ако искаш средното без закръгляване, остави double.) За revisor, който показва цели милисекунди, закръглянето е правилното.

4.3.5 Подреждане: Comparator

За да подредиш, трябва да знаеш как да сравниш два елемента. За текстовете и числата има естествен ред (азбучен, числов) и sorted() без аргументи го използва. За собствените ти типове ти казваш как да сравняват с Comparator, а JDK носи кутия с инструменти, за да ги сглобиш, без да пишеш метода compare на ръка. Програмата fig04_18 ги упражнява с четири измервания, две от които с еднакво време, за да се види разрешаването на равенство:

// fig04_18.java
record Medicion(String nombre, long tiempoMs) {}

void main() {
  List<Medicion> mediciones =
      List.of(
          new Medicion("pagos", 87),
          new Medicion("catalogo", 142),
          new Medicion("busqueda", 142),
          new Medicion("inventario", 2300));

  Comparator<Medicion> porTiempo = Comparator.comparingLong(Medicion::tiempoMs);

  List<Medicion> ascendente = mediciones.stream().sorted(porTiempo).toList();
  List<Medicion> descendente = mediciones.stream().sorted(porTiempo.reversed()).toList();
  List<Medicion> desempatado =
      mediciones.stream().sorted(porTiempo.thenComparing(Medicion::nombre)).toList();

  ascendente.forEach(medicion -> IO.println("asc  " + medicion));
  descendente.forEach(medicion -> IO.println("desc " + medicion));
  desempatado.forEach(medicion -> IO.println("tiempo y nombre " + medicion));

  Optional<Medicion> masLenta = mediciones.stream().max(porTiempo);
  IO.println(masLenta);
  IO.println(mediciones);
}
$ java fig04_18.java
asc  Medicion[nombre=pagos, tiempoMs=87]
asc  Medicion[nombre=catalogo, tiempoMs=142]
asc  Medicion[nombre=busqueda, tiempoMs=142]
asc  Medicion[nombre=inventario, tiempoMs=2300]
desc Medicion[nombre=inventario, tiempoMs=2300]
desc Medicion[nombre=catalogo, tiempoMs=142]
desc Medicion[nombre=busqueda, tiempoMs=142]
desc Medicion[nombre=pagos, tiempoMs=87]
tiempo y nombre Medicion[nombre=pagos, tiempoMs=87]
tiempo y nombre Medicion[nombre=busqueda, tiempoMs=142]
tiempo y nombre Medicion[nombre=catalogo, tiempoMs=142]
tiempo y nombre Medicion[nombre=inventario, tiempoMs=2300]
Optional[Medicion[nombre=inventario, tiempoMs=2300]]
[Medicion[nombre=pagos, tiempoMs=87], Medicion[nombre=catalogo, tiempoMs=142], Medicion[nombre=busqueda, tiempoMs=142], Medicion[nombre=inventario, tiempoMs=2300]]

Какво се прочете в изхода:

  • Comparator.comparingLong(Medicion::tiempoMs) конструира компаратор от функция, която извлича стойността за сравнение. Има версии за comparing (всеки тип с естествен ред), comparingInt, comparingLong и comparingDouble.
  • .reversed() обръща реда. .thenComparing(...) добавя разрешаване на равенство: използва се само когато двата елемента са равни по първия критерий. Затова в третия блок busqueda е преди catalogo: имат същите 142 ms и допълнителният критерий ги подрежда по азбучен ред.
  • Без допълнителен критерий, в първите два блока catalogo е преди busqueda и в двете посоки. Не е случайно: документацията на Stream.sorted гарантира, че „за подредени потоци подреждането е стабилно“: елементите, които са равни, запазват реда, в който са били.
  • sorted не пипа оригиналния списък. Последните редове го доказват: mediciones си остава в реда, в който е създаден. Ако искаш да подредиш самия списък, lista.sort(comparador) го прави на място, но работи само с изменяем списък: върху List.of хвърля отново UnsupportedOperationException.
  • max(porTiempo) връща Optional<Medicion>, а не Medicion, и се отпечатва като Optional[Medicion[nombre=inventario, tiempoMs=2300]]. Да попиташ за максимума на празен списък няма отговор и типът го казва.

forEach се появи два пъти в програмата: ascendente.forEach(medicion -> IO.println(...)). Получава ламбда, която не връща нищо (Consumer, функционалният интерфейс на „направи това с всеки елемент“), и е наличен в списъците и в потоците. За отпечатване е добре; за пълнене на друга колекция не е (виж „Какво се прави погрешно“).

4.3.6 Поток или цикъл?

Погрешното послание за стриймовете е, че „са по-добри“. Не са: те са друг инструмент. Предпочитай поток, когато преобразуваш или обобщаваш колекция (филтриране, преобразуване, броене, групиране, подреждане), защото кодът казва какво се иска. Предпочитай цикъл, когато ти трябва да излезеш по-рано с break, да водиш индекс, да променяш нещо отвън или когато тялото може да хвърли проверявано изключение (една ламбда не може да хвърля повече проверявани изключения от тези, които декларира нейният функционален интерфейс, а Function, която получава map, не декларира никакво; урок 5 го обяснява и затова revisarTodos ще стане отново for в онзи момент: не е крачка назад, а подходящият инструмент). И едно правило за четене, което спестява спорове: ако един поток вече не се побира в шест-седем реда или ако има ламбда от няколко реда, извади тази ламбда в метод с име и използвай препратката (Revision::revisar се чете по-добре от пет реда вътре в map).

4.4 Optional: стойност, която може да я няма

revisor има проблем с представянето, който се повтаря: какво връща една функция, когато няма стойност? Map.get("reportes") връщаше null; падналата услуга няма време за отговор; средното на никого не съществува. Урок 2 показа цената на null: компилаторът не го различава от стойност и грешката се появява далеч, като NullPointerException. Optional<T> е отговорът на JDK: кутийка, която има стойност или е празна, и чийто тип предупреждава този, който я получава, че трябва да реши какво да прави в двата случая.

Програмата fig04_19 обхожда речника:

// fig04_19.java
Optional<Long> tiempoDe(Map<String, Long> tiempos, String nombre) {
  return Optional.ofNullable(tiempos.get(nombre));
}

void main() {
  Map<String, Long> tiempos = Map.of("catalogo", 142L, "pagos", 87L);

  Optional<Long> hay = tiempoDe(tiempos, "pagos");
  Optional<Long> noHay = tiempoDe(tiempos, "reportes");
  IO.println(hay);
  IO.println(noHay);
  IO.println(hay.isPresent() + " " + noHay.isEmpty());

  IO.println(hay.orElse(0L));
  IO.println(noHay.orElse(0L));
  IO.println(hay.map(tiempo -> tiempo + " ms").orElse("sin tiempo"));
  IO.println(noHay.map(tiempo -> tiempo + " ms").orElse("sin tiempo"));
  hay.ifPresent(tiempo -> IO.println("pagos respondió en " + tiempo));
  noHay.ifPresent(tiempo -> IO.println("esto no se imprime"));

  OptionalLong mayor = tiempos.values().stream().mapToLong(Long::longValue).max();
  IO.println(mayor);
  IO.println(mayor.getAsLong());
}
$ java fig04_19.java
Optional[87]
Optional.empty
true true
87
0
87 ms
sin tiempo
pagos respondió en 87
OptionalLong[142]
142

Трите начина да създадеш Optional: Optional.of(valor) (стойността не може да е null; ако е, хвърля NullPointerException), Optional.ofNullable(valor) (ако стойността е null, остава празен; това е, което използва tiempoDe, за да адаптира един get, който можеше да върне null) и Optional.empty(). Начините да го консумираш; последният е този, който е добре да избягваш:

  • orElse(porOmision) връща стойността или тази, която ти даваш, ако е празен. orElseGet(proveedor) прави същото, но получава ламбда, която се изпълнява само ако е нужно. orElseThrow() хвърля изключение, ако е празен (урок 5 обяснява кои).
  • map(función) преобразува стойността, ако съществува, и оставя празния такъв както е: hay.map(tiempo -> tiempo + " ms") произвежда Optional<String> с „87 ms“ вътре, който orElse разопакова; при празния map не прави нищо, резултатът остава празен и orElse дава „sin tiempo“ (без време). Това е, което спестява да пишеш if (x != null) навсякъде.
  • ifPresent(acción) изпълнява ламбдата само ако има стойност; isPresent() и isEmpty() питат.
  • get() вади стойността, и хвърля изключение, ако няма. Съществува, но е входната врата към същия проблем, който Optional искаше да реши.

Да видим цената на get() без проверка:

// fig04_20.java
void main() {
  Optional<String> nombre = Optional.empty();
  IO.println("a punto de pedir el valor");
  IO.println(nombre.get());
}
$ java fig04_20.java
a punto de pedir el valor
Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.get(Optional.java:143)
	at fig04_20.main(fig04_20.java:5)

NoSuchElementException: No value present („няма стойност“). Грешката е честна и е същата като вечния NullPointerException, с друго име: някой е предположил, че има стойност. Сега с разликата, че типът Optional е направил риска очевиден точно на мястото, където е взето решението. Практическото правило: не извиквай get(), освен ако не си проверил в същия блок, и почти винаги има orElse или map, които казват същото по-добре.

Съществува и orElse с една подробност, която струва скъпо: аргументът му се изчислява винаги, дори да не е нужен. Програмата fig04_21 го показва със стойност по подразбиране, която съобщава кога се изчислява:

// fig04_21.java
String calcular(String quien) {
  IO.println("  (calculando el valor por omisión para " + quien + ")");
  return "ninguno";
}

void main() {
  Optional<String> hay = Optional.of("catalogo");
  Optional<String> noHay = Optional.empty();

  IO.println("orElse con valor presente:");
  IO.println(hay.orElse(calcular("orElse")));
  IO.println("orElseGet con valor presente:");
  IO.println(hay.orElseGet(() -> calcular("orElseGet")));
  IO.println("orElseGet con valor ausente:");
  IO.println(noHay.orElseGet(() -> calcular("orElseGet")));
}
$ java fig04_21.java
orElse con valor presente:
  (calculando el valor por omisión para orElse)
catalogo
orElseGet con valor presente:
catalogo
orElseGet con valor ausente:
  (calculando el valor por omisión para orElseGet)
ninguno

При налична стойност orElse изчисли стойността по подразбиране въпреки всичко (съобщението „calculando...“ (изчислявам...) излезе) и я изхвърли; orElseGet не я изчисли. За литерал "ninguno" е все едно; за нещо скъпо, като заявка или голям обект, използвай orElseGet. Това е същата идея от урок 2 с && срещу &: не оценявай онова, което не ти трябва.

И последната част, тази с правилата за проектиране. Документацията на Optional казва, в една бележка за API, че „е замислен основно за използване като тип на върната стойност на метод, където има ясна нужда да се представи никакъв резултат и където използването на null вероятно би причинило грешки“. Оттук излизат три правила:

  1. Използвай го като тип на върната стойност, не като параметър (принуждава извикващия да обвие стойност, която вече има), нито като списък от елементи (List<Optional<T>> почти винаги е filter, който липсва).
  2. Променлива Optional никога не бива да е null: документацията го казва с тези думи. null там, където се е очаквал Optional, е по-лошо от всяко от двете поотделно.
  3. За примитиви, OptionalInt, OptionalLong и OptionalDouble: без обвивки, с getAsLong() вместо get(). Използват ги average() и max() на числовите потоци и revisor за времето.

Проектът нарушава правилото „не като поле“ на едно място и е добре да се каже направо: record-ът Resumen от раздел 4.5 пази OptionalLong promedioMs. Това е неизменяем record, който се създава веднъж и се чете няколко пъти; липсващата стойност е част от данните (когато никой не е отговорил, няма средно), а алтернативите са по-лоши: Long, който може да е null, е проблемът, който току-що оставихме зад гърба си, а 0 би излъгал. Това е съзнателно изключение от едно ръководство, а не недоглеждане. В урок 8 ще видиш как тази липсваща стойност пресича границата към JSON, където форматът наистина иска null.

4.5 Стъпката на проекта: отчетът с обобщение

Сега да съберем всичко. Урок 3 остави Revisor.java, който проверяваше четири услуги, написани на ръка, и simular с един-единствен switch, който събираше данните на четирите услуги. Днес проектът се променя така:

  • Симулираните данни излизат от switch и отиват в мап в нов клас, Simulador. Търси се по име и връща Optional.
  • Revision проверява една услуга (като пита симулатора) и проверява цял списък със стрийм.
  • Resumen брои и осреднява с groupingBy и average; Reporte събира резултатите и обобщението им, със защитно копие.
  • Resultado се научава да дава времето си като OptionalLong; Formato се научава да сглобява обобщението и цялата таблица.
  • Revisor.java спира да извиква revisar четири пъти: има списък и иска отчета наведнъж.

Четири файла не се променят: Estado, Respuesta, Servicio и Clasificador. Включвам ги въпреки това, защото папката на фигурата е цялостният проект и така можеш да стартираш всичко от нея. Копирай ги такива, каквито са, от урок 3. Започни с тях:

// fig04_22/Estado.java
enum Estado {
  ARRIBA,
  LENTO,
  CAIDO
}
// fig04_22/Respuesta.java
sealed interface Respuesta permits Respuesta.Respondio, Respuesta.SinRespuesta {
  record Respondio(int codigo, long tiempoMs) implements Respuesta {}

  record SinRespuesta(String motivo) implements Respuesta {}
}
// fig04_22/Servicio.java
import java.net.URI;

record Servicio(String nombre, URI url, int timeoutMs) {
  static final int TIMEOUT_POR_OMISION_MS = 5000;

  Servicio(String nombre, URI url) {
    this(nombre, url, TIMEOUT_POR_OMISION_MS);
  }
}
// fig04_22/Clasificador.java
final class Clasificador {
  static final long UMBRAL_LENTO_MS = 1000;

  static Estado clasificar(Respuesta respuesta) {
    return switch (respuesta) {
      case Respuesta.Respondio(int codigo, long _) when codigo >= 400 -> Estado.CAIDO;
      case Respuesta.Respondio(int _, long tiempoMs) when tiempoMs >= UMBRAL_LENTO_MS ->
          Estado.LENTO;
      case Respuesta.Respondio _ -> Estado.ARRIBA;
      case Respuesta.SinRespuesta _ -> Estado.CAIDO;
    };
  }
}

Една бележка за import. Четирите файла по-горе и тези, които следват, са обикновени класове (не компактни файлове) и затова започват, когато им е нужно, с редове import java.util.List;. Един import казва на компилатора откъде идва име, което не е от java.lang (пакетът, който Java импортира сама, с String, Math и Thread). Пише се по един на клас, без заместители, както указва стиловото ръководство на Google, което курсът следва от урок 1. Компактният файл Revisor.java, напротив, импортира сам целия java.base и не се нуждае от никой.

Следва Resultado, който получава един метод. tiempoMs() връща OptionalLong: със стойност, когато е имало отговор, и празен, когато не. Това е същият изчерпателен switch от урок 3: ако някой ден някой добави трети вид Respuesta, компилаторът ще отбележи този метод и detalle() като непълни.

// fig04_22/Resultado.java
import java.util.OptionalLong;

record Resultado(Servicio servicio, Estado estado, Respuesta respuesta) {
  String detalle() {
    return switch (respuesta) {
      case Respuesta.Respondio(int codigo, long _) -> String.valueOf(codigo);
      case Respuesta.SinRespuesta(String motivo) -> motivo;
    };
  }

  OptionalLong tiempoMs() {
    return switch (respuesta) {
      case Respuesta.Respondio(int _, long tiempoMs) -> OptionalLong.of(tiempoMs);
      case Respuesta.SinRespuesta _ -> OptionalLong.empty();
    };
  }
}

Simulador е класът, който играе ролята на мрежата до урок 8. Неговият RESPUESTAS е неизменяем Map (Map.of) от име към отговор, с трите, които отговарят, и този, който не. buscar обвива get на мапа в Optional.ofNullable и е единственото място, където липсата на име се превръща в тип, който принуждава да се реши.

// fig04_22/Simulador.java
import java.util.Map;
import java.util.Optional;

final class Simulador {
  static final Map<String, Respuesta> RESPUESTAS =
      Map.of(
          "catalogo", new Respuesta.Respondio(200, 142),
          "inventario", new Respuesta.Respondio(200, 2300),
          "pagos", new Respuesta.Respondio(200, 87),
          "reportes", new Respuesta.SinRespuesta("sin conexión"));

  static Optional<Respuesta> buscar(String nombre) {
    return Optional.ofNullable(RESPUESTAS.get(nombre));
  }
}

Revision има двата метода, които произвеждат резултати. revisar иска отговора от симулатора и, ако името го няма, използва SinRespuesta("sin conexión") (orElse с евтина стойност). revisarTodos е редът, който оправдава целия урок: стрийм върху списъка с услуги, с Revision::revisar като map и toList() накрая.

// fig04_22/Revision.java
import java.util.List;

final class Revision {
  static Resultado revisar(Servicio servicio) {
    Respuesta respuesta =
        Simulador.buscar(servicio.nombre()).orElse(new Respuesta.SinRespuesta("sin conexión"));
    return new Resultado(servicio, Clasificador.clasificar(respuesta), respuesta);
  }

  static List<Resultado> revisarTodos(List<Servicio> servicios) {
    return servicios.stream().map(Revision::revisar).toList();
  }
}

Resumen е мястото, където се срещат стриймовете и колекторите. groupingBy брои колко има от всяко състояние, а average изчислява средното на времената на онези, които са отговорили. Две подробности. Първата: groupingBy създава ключ само за състоянията, които се появяват; ако никоя услуга не е паднала, porEstado.get(Estado.CAIDO) би било null. Затова се използва getOrDefault(..., 0L): без него първият отчет без паднали би съборил програмата с NullPointerException от раздел 4.2.2. intValue() превръща Long на броя в int, който иска record-ът (броевете се побират без никакъв проблем). Втората: средното брои всички, които са отговорили, какъвто и да е кодът им (503 е отговорил, макар да е отговорил зле) и затова се изчислява върху tiempoMs(), а не върху състоянието.

// fig04_22/Resumen.java
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
import java.util.OptionalLong;
import java.util.stream.Collectors;

record Resumen(int total, int arriba, int lento, int caido, OptionalLong promedioMs) {
  static Resumen de(List<Resultado> resultados) {
    Map<Estado, Long> porEstado =
        resultados.stream()
            .collect(Collectors.groupingBy(Resultado::estado, Collectors.counting()));
    OptionalDouble promedio =
        resultados.stream()
            .map(Resultado::tiempoMs)
            .filter(OptionalLong::isPresent)
            .mapToLong(OptionalLong::getAsLong)
            .average();
    OptionalLong promedioMs =
        promedio.isPresent()
            ? OptionalLong.of(Math.round(promedio.getAsDouble()))
            : OptionalLong.empty();
    return new Resumen(
        resultados.size(),
        porEstado.getOrDefault(Estado.ARRIBA, 0L).intValue(),
        porEstado.getOrDefault(Estado.LENTO, 0L).intValue(),
        porEstado.getOrDefault(Estado.CAIDO, 0L).intValue(),
        promedioMs);
  }
}

Reporte е record-ът със защитното копие от раздел 4.1.4. Фабриката му de събира списъка с резултати с обобщението му:

// fig04_22/Reporte.java
import java.util.List;

record Reporte(List<Resultado> resultados, Resumen resumen) {
  Reporte {
    resultados = List.copyOf(resultados);
  }

  static Reporte de(List<Resultado> resultados) {
    return new Reporte(resultados, Resumen.de(resultados));
  }
}

Formato расте с три метода: contar, който поставя дума в множествено число (единственото правило за множествено число в курса: добавя се „s“, ако количеството не е 1), resumen, който сглобява реда в края, и tabla, която събира заглавния ред, редовете (обединени с Collectors.joining("\n")), един празен ред и обобщението. ? : в contar е условният оператор от урок 2 (2.3.1): cantidad == 1 ? "" : "s" се чете „ако количеството е 1, нищо; иначе — едно ‚s‘“. (Същият оператор се появява в Resumen, за да реши дали има средно.) И има малка промяна в метод, който вече имаше, и е добре да се каже на глас: formatearTiempo спира да сравнява с Clasificador.UMBRAL_LENTO_MS и сравнява с 1000. Това са две различни идеи, които днес струват същото: прагът на „бавен“ е бизнес правило, а преминаването от милисекунди към секунди е правило за мерните единици. Ако утре прагът на бавен падне до 800 ms, таблицата трябва да продължи да пише 999ms, а не 1.0s; със споделената константа промяната на едното правило би променила другото, без никой да го е искал. (Урок 7 прави точно тази промяна на прага и един тест я следи.) Останалата част от файла, PATRON_LINEA, tiempo, encabezado и linea, остава както в урок 3.

// fig04_22/Formato.java
import java.util.Locale;
import java.util.stream.Collectors;

final class Formato {
  static final String PATRON_LINEA = "%-12s%-10s%6s  %s";

  static String formatearTiempo(long tiempoMs) {
    if (tiempoMs < 1000) {
      return tiempoMs + "ms";
    }
    return String.format(Locale.ROOT, "%.1fs", tiempoMs / 1000.0);
  }

  static String tiempo(Respuesta respuesta) {
    return switch (respuesta) {
      case Respuesta.Respondio(int _, long tiempoMs) -> formatearTiempo(tiempoMs);
      case Respuesta.SinRespuesta _ -> "—";
    };
  }

  static String encabezado() {
    return String.format(Locale.ROOT, PATRON_LINEA, "SERVICIO", "ESTADO", "TIEMPO", "DETALLE");
  }

  static String linea(Resultado resultado) {
    return String.format(
        Locale.ROOT,
        PATRON_LINEA,
        resultado.servicio().nombre(),
        resultado.estado(),
        tiempo(resultado.respuesta()),
        resultado.detalle());
  }

  static String contar(long cantidad, String palabra) {
    return cantidad + " " + palabra + (cantidad == 1 ? "" : "s");
  }

  static String resumen(Resumen resumen) {
    String promedio =
        resumen.promedioMs().isPresent() ? formatearTiempo(resumen.promedioMs().getAsLong()) : "—";
    return contar(resumen.total(), "servicio")
        + " · "
        + resumen.arriba()
        + " arriba · "
        + contar(resumen.lento(), "lento")
        + " · "
        + contar(resumen.caido(), "caído")
        + " · promedio "
        + promedio;
  }

  static String tabla(Reporte reporte) {
    String renglones =
        reporte.resultados().stream().map(Formato::linea).collect(Collectors.joining("\n"));
    return encabezado() + "\n" + renglones + "\n\n" + resumen(reporte.resumen());
  }
}

И Revisor.java става кратък. Има две новости, които се обясняват сами: един списък от услуги (поле на компактния файл, тоест променлива, която е извън всеки метод и която всички методи на файла могат да използват), маркиран с final, което значи „тази променлива не може да се преприсвоява“, и main от един ред:

// fig04_22/Revisor.java
final List<Servicio> servicios =
    List.of(
        new Servicio("catalogo", URI.create("https://catalogo.example")),
        new Servicio("inventario", URI.create("https://inventario.example")),
        new Servicio("pagos", URI.create("https://pagos.example")),
        new Servicio("reportes", URI.create("https://reportes.example")));

void main() {
  IO.println(Formato.tabla(Reporte.de(Revision.revisarTodos(servicios))));
}

Стартирай проекта от неговата папка:

$ cd fig04_22
$ java Revisor.java
SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión

4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms

Това е таблицата от урок 3 с онова, което ѝ липсваше: празния ред и обобщението. Виж как се чете обобщението спрямо кода: 4 servicios е contar(4, "servicio") (4 услуги), 2 arriba е броят на ARRIBA (2 работят), 1 lento и 1 caído са contar(...) със своите думи (1 бавна и 1 паднала), а 843ms е Math.round на 2529 / 3, форматирано с formatearTiempo (който вече знае, че 843 < 1000 се пише с „ms“). Когато обобщението ти даде число, което не очакваш, започни от Resumen: там се изчислява всичко.

Грешката, която ще видиш

Грешката на този урок е UnsupportedOperationException и е тази, която почти всички срещат през първата седмица с колекции. Вече я видя с List.of. Ето същата следа, за да я прочетеш ред по ред:

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
	at fig04_04.main(fig04_04.java:5)

Първи ред: какво. UnsupportedOperationException е типът; не носи съобщение, защото операцията не се поддържа и няма какво повече да се каже. Втори и трети: къде, вътре в JDK. Говорят за ImmutableCollections, класа, който имплементира колекциите на List.of, Set.of и Map.of (и на toList()). Когато видиш това име, вече знаеш, че си се опитал да промениш неизменяема колекция. Четвърти: къде, в твоя код. Първият ред, който споменава твоя файл, е този, който трябва да погледнеш.

Същата грешка се появява и по други пътища, които е добре да разпознаваш. Случва се, когато промениш онова, което е върнал toList() на един поток:

// fig04_23.java
void main() {
  List<String> nombres = Stream.of("catalogo", "inventario", "pagos").toList();
  IO.println(nombres);
  nombres.add("reportes");
}
$ java fig04_23.java
[catalogo, inventario, pagos]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
	at fig04_23.main(fig04_23.java:5)

Следата е идентична, с друг номер на ред от твоята програма. И се случва, когато извикаш sort върху неизменяем списък. Има две решения, според това, което искаш. Ако наистина ти трябва изменяем списък, направи го изрично с копие: new ArrayList<>(nombres). Ако това, което си искал, е подреден списък, не го подреждай на място: използвай stream().sorted(...).toList() и получи друг списък. Най-добрата реакция на грешката не е да направиш списъка изменяем по рефлекс, а да се запиташ дали операцията, която опитваш, е трябвало да съществува.

Какво се прави погрешно

  • Промяна на списък, докато го обхождаш с for. Това е класическата грешка и клопката е, че понякога дори не се проваля. Ако махнеш елемент вътре в подобрения for, през повечето време програмата спира с изключение:
// fig04_24.java
void main() {
  List<String> nombres = new ArrayList<>(List.of("catalogo", "inventario", "pagos", "reportes"));
  for (String nombre : nombres) {
    if (nombre.startsWith("i")) {
      nombres.remove(nombre);
    }
  }
  IO.println(nombres);
}
$ java fig04_24.java
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
	at fig04_24.main(fig04_24.java:4)

ConcurrentModificationException („едновременна промяна“; името лъже, защото няма нишки: списъкът е открил, че се е променил, докато обхождането е продължавало). Най-лошото е тихият случай: измерих, че ако елементът, който махаш, е предпоследният, обхождането завършва без оплакване и пропуска последния. С ["catalogo", "inventario", "pagos"], като махнеш "inventario", изходът е [catalogo, pagos] и програмата никога не е погледнала pagos. Цената е програма, която понякога губи елемент и не го казва. Решението: lista.removeIf(nombre -> nombre.startsWith("i")), което съществува точно за това, или поток с filter, който произвежда нов списък.

  • Бъркане на remove(int) с remove(Object). В List<Integer> lista.remove(87) тълкува 87 като позиция, а не като стойност.
// fig04_25.java
void main() {
  List<Integer> tiempos = new ArrayList<>(List.of(142, 2300, 87));
  tiempos.remove(Integer.valueOf(87));
  IO.println(tiempos);
  tiempos.remove(87);
  IO.println(tiempos);
}
$ java fig04_25.java
[142, 2300]
Exception in thread "main" java.lang.IndexOutOfBoundsException: Index 87 out of bounds for length 2
	at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:100)
	at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:106)
	at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:302)
	at java.base/java.util.Objects.checkIndex(Objects.java:365)
	at java.base/java.util.ArrayList.remove(ArrayList.java:552)
	at fig04_25.main(fig04_25.java:6)

Първото извикване (Integer.valueOf(87), което иска да махне стойността) проработи; второто потърси позиция 87 в списък от два елемента и хвърли IndexOutOfBoundsException. Това е случай, в който удобството на autoboxing от раздел 4.2.2 се обръща срещу теб: компилаторът избира метода remove(int), защото 87 е int. При списъци от текстове няма двусмислие. Когато трябва да махнеш число по неговата стойност, обвий го нарочно, както в първото извикване.

  • Използване на сурови типове (List без <...>). Както показа фигурата за изтриването, това отнема на компилатора способността да те предупреди и грешката се появява далеч от причината си, като ClassCastException. Цена: дебъгване на грешка в типа при изпълнение, която компилаторът би ти отбелязал на реда на недоглеждането. Всяко предупреждение [unchecked] е това.

  • Извикване на Optional.get() без проверка или деклариране на Optional като поле или параметър. Документацията не го препоръчва и двете практики връщат проблемите на null с друго име. Цена: NoSuchElementException в продукция и сигнатури, които принуждават извикващия да обвива и разопакова стойности без нужда.

  • Пълнене на външна колекция от forEach или map. Да напишеш mediciones.stream().forEach(m -> destino.add(m.nombre())) променя външно състояние от ламбда; работи с последователен поток и се чупи по фини начини с паралелен. Цена: крехък код, който прилича на зле написан map; правилният инструмент е collect или toList().

  • Приемане за даденост на реда на HashMap, HashSet или Set.of. Работи на твоята машина и се променя на друга или при следващото изпълнение. Цена: отчет, който понякога излиза в друг ред и който никой тест не възпроизвежда от първия път. Ако редът има значение за този, който чете, избери LinkedHashMap, TreeMap или списък.

  • Връщане или съхраняване на вътрешния списък без копиране. Това е клопката от раздел 4.1.4: друг код пази референцията и променя данните ти, без да забележиш. Цена: стойности, които се менят сами, най-трудният за локализиране вид грешка, защото се случва далеч от мястото, където се вижда ефектът.

Упражнения

Упражнение 1 — Добави две услуги към проекта

В своя Revisor.java добави към списъка facturacion (https://facturacion.example) и notificaciones (https://notificaciones.example). Преди да стартираш, предвиди какво състояние ще имат и какво ще бъде обобщението. След това стартирай. После добави и двете в Simulador (facturacion отговаря с код 503 за 95 ms; notificaciones не отговаря, с причина „tiempo agotado“ (времето изтече)), предвиди отново обобщението и стартирай. Запиши в дневника си какво е станало с реда на notificaciones.

Упражнение 2 — Подреди отчета

Направи така, че таблицата да излиза подредена от най-бавната услуга към най-бързата, с тези, които не са отговорили, накрая, и равенствата да се разрешават по име. Подсказка: Comparator.comparingLong, reversed и thenComparing; времето на този, който не е отговорил, може да се замести с orElse(-1). Подай вече подредения списък на Reporte.de.

Упражнение 3 — Коя хвърля изключение?

За всеки ред реши, без да изпълняваш, дали хвърля изключение или не и кое. След това го провери в компактен файл, като изпълняваш всеки ред сам (по един на изпълнение: първото изключение спира програмата):

  1. List.of("catalogo", null)
  2. Map.of("a", 1, "a", 2)
  3. new ArrayList<>(List.of("a")).get(5)
  4. Set.of("a").add("b")
  5. new HashMap<String, Integer>().put(null, 1)
  6. new TreeMap<String, Integer>().put(null, 1)

Упражнение 4 — Имената по състояние

Отпечатай, от резултатите на revisor, мап от всяко Estado към списъка с имената на услугите, които имат това състояние, в реда, в който са декларирани състоянията (ARRIBA, LENTO, CAIDO). Подсказка: groupingBy приема като втори аргумент функция, която произвежда мапа, а EnumMap е специализиран мап за ключове от enum, който ги подрежда по декларацията им.

Решения

Решение 1

Без да пипаш Simulador, двете нови услуги ги няма в мапа с отговори, така че buscar връща празен Optional и revisar използва SinRespuesta("sin conexión") по подразбиране: и двете излизат CAIDO. С промяната в Revisor.java (добавяне на две new Servicio(...) към списъка) таблицата е:

SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión
facturacion CAIDO          —  sin conexión
notificacionesCAIDO          —  sin conexión

6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 843ms

Те са 6 услуги, 2 работят, 1 бавна и 3 паднали; средното не се променя (843 ms), защото никоя от двете нови не отговаря. След това, в Simulador, се добавят две двойки към Map.of (който приема до десет; за повече би трябвало да се използва Map.ofEntries):

"facturacion", new Respuesta.Respondio(503, 95),
"notificaciones", new Respuesta.SinRespuesta("tiempo agotado"));

и резултатът е:

SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión
facturacion CAIDO       95ms  503
notificacionesCAIDO          —  tiempo agotado

6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms

facturacion е CAIDO, защото кодът ѝ е 503 (≥ 400), но е отговорила: има време (95 ms) и се брои в средното. Средното сега е (142 + 2300 + 87 + 95) / 4 = 656. Това е разликата между „не е отговорила“ и „отговорила е зле“ и причината Resumen да осреднява върху tiempoMs(), а не върху състоянието.

На реда на notificaciones има видим дефект: името е дълго 14 знака, а колоната 12. %-12s не отрязва, така че състоянието се залепва за името (notificacionesCAIDO). Форматът допълва, но не измисля място. Да се изчисли ширината на колоната от най-дългото име е упражнение по желание от урок 10; засега вече знаеш, че фиксираният формат е договор, който данните могат да нарушат.

Решение 2

void main() {
  List<Resultado> resultados = Revision.revisarTodos(servicios);
  Comparator<Resultado> porTiempoDescendente =
      Comparator.comparingLong((Resultado resultado) -> resultado.tiempoMs().orElse(-1))
          .reversed()
          .thenComparing(resultado -> resultado.servicio().nombre());
  List<Resultado> ordenados = resultados.stream().sorted(porTiempoDescendente).toList();
  IO.println(Formato.tabla(Reporte.de(ordenados)));
}
SERVICIO    ESTADO    TIEMPO  DETALLE
inventario  LENTO       2.3s  200
catalogo    ARRIBA     142ms  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión

4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms

Обърни внимание на типа, написан на ръка в ламбдата: (Resultado resultado) -> .... Не е украса. Ако го пропуснеш и напишеш Comparator.comparingLong(resultado -> resultado.tiempoMs().orElse(-1)).reversed(), компилаторът не може да изведе типа на resultado (защото reversed() се свързва във верига, преди да знае къде ще бъде присвоен) и го приема за Object:

Revisor.java:4: error: cannot find symbol
      Comparator.comparingLong(resultado -> resultado.tiempoMs().orElse(-1)).reversed();
                                                     ^
  symbol:   method tiempoMs()
  location: variable resultado of type Object

Това е позната грешка при извеждане на типове: компилаторът извежда типовете на една ламбда, като гледа къде отива, а веригата от методи му скрива местоназначението. С изричния тип в първия компаратор веригата работи. Завършващият thenComparing не се нуждае от тип, защото вече знае, че е върху Resultado. Услугите, които не са отговорили (reportes), остават накрая, защото техният -1 е най-малкият, а редът е низходящ. При равенства между две, които не са отговорили, thenComparing ги разрешава по име.

Решение 3

Ред Какво става
List.of("catalogo", null) NullPointerException: неизменяемите колекции не допускат null
Map.of("a", 1, "a", 2) IllegalArgumentException: duplicate key: a
new ArrayList<>(List.of("a")).get(5) IndexOutOfBoundsException: Index 5 out of bounds for length 1
Set.of("a").add("b") UnsupportedOperationException
new HashMap<String, Integer>().put(null, 1) не хвърля нищо: HashMap приема ключ null и остава {null=1}
new TreeMap<String, Integer>().put(null, 1) NullPointerException: TreeMap трябва да сравнява ключовете и не може с null

Измерих и шестте с JDK 25. Две полезни заключения. Неизменяемите колекции са строги от момента на създаването, а не при използването: List.of("catalogo", null) се проваля още при създаването. И това, че HashMap приема null като ключ, а TreeMap не, е пример за това, че имплементациите на един и същ интерфейс не са взаимозаменяеми във всичко: общият договор (Map) покрива основното, а подробностите на всяка ги казва нейната документация.

Решение 4

void main() {
  List<Resultado> resultados = Revision.revisarTodos(servicios);
  Map<Estado, List<String>> nombresPorEstado =
      resultados.stream()
          .collect(
              Collectors.groupingBy(
                  Resultado::estado,
                  () -> new EnumMap<>(Estado.class),
                  Collectors.mapping(
                      resultado -> resultado.servicio().nombre(), Collectors.toList())));
  IO.println(nombresPorEstado);
}
{ARRIBA=[catalogo, pagos], LENTO=[inventario], CAIDO=[reportes]}

groupingBy с три аргумента: функцията, която класифицира (Resultado::estado), фабриката на мапа (() -> new EnumMap<>(Estado.class), ламбда, която не получава нищо и връща нов мап) и колекторът на всяка група (mapping извлича името, а toList ги събира). EnumMap получава класа на enum (Estado.class) и обхожда ключовете си в реда на декларацията: ARRIBA, LENTO, CAIDO. С HashMap редът нямаше да бъде обещан. EnumMap е направен за ключове от enum и показва ясно на този, който чете, че са такива.

Как разбирам, че съм успял

  • java fig04_01.java отпечатва списъка, primero y último: catalogo, pagos, и можеш да кажеш защо remove("pagos") маха стойност, а не позиция.
  • java fig04_04.java завършва с UnsupportedOperationException и можеш да посочиш в следата кой ред е от твоята програма и кои са от JDK.
  • java fig04_05.java отпечатва, че наивният отчет се е променил, а безопасният не, и можеш да обясниш защо вторият се защитава с List.copyOf.
  • java fig04_10.java се проваля с ClassCastException на ред, който не е този, допуснал грешката, и знаеш какво го е направило възможно (суров тип).
  • java fig04_15.java отпечатва „el stream está armado; todavía no pasó nada“ преди което и да е „miro“ и можеш да обясниш защо.
  • От папката на проекта java Revisor.java отпечатва таблицата с четири услуги с 4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms.
  • С двете услуги от упражнение 1 в Simulador обобщението казва 6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms и знаеш защо facturacion се брои в средното, макар да е паднала.
  • Можеш да обясниш защо Resumen.de използва getOrDefault(..., 0L) и какво би станало без него.
  • Записал си в дневника един стрийм, който си пренаписал като цикъл, или обратното, и кой от двата се четеше по-добре.

Обобщение

Отговори, без да гледаш урока:

  1. Защо се декларира List<String> x = new ArrayList<>(), а не ArrayList<String> x = ...?
  2. Какъв ред на обхождане има HashMap, LinkedHashMap и TreeMap? Кой би използвал за отчет?
  3. Кои две неща връщат Set.add и Map.put и за какво служат?
  4. Какво хвърля List.of("a").add("b")? А List.of("a", null)?
  5. Каква е разликата между List.copyOf(x) и Collections.unmodifiableList(x)?
  6. Защо не съществува List<int>? Каква клопка носи сравняването на два Integer с ==?
  7. Какво значи, че генеричните типове се „изтриват“? Дай едно последствие.
  8. Какви три части има един стрийм? Кога се изпълнява работата?
  9. Какво става, ако обходиш същия стрийм два пъти?
  10. Какво връща average() и защо не double?
  11. Кога предпочиташ цикъл пред стрийм?
  12. Защо не се извиква get() без проверка върху Optional? Какво използваш вместо това?

За допълнително четене

  • Oracle, „Java Collections Framework“: официалното ръководство за интерфейсите и имплементациите, с таблицата кое кога да се използва. Посетено на 7 октомври 2026 г.
  • Oracle, „Generics“ (урок за Java): от генеричните типове до заместителите и изтриването, с по-обширни примери от този урок. Посетено на 7 октомври 2026 г.
  • Oracle, „Stream API“: ръководството за потоците, с разликата между междинни и терминални операции и колекторите. Посетено на 7 октомври 2026 г.
  • Документация на Optional: включва бележката за API за това кога да се използва. Посетено на 7 октомври 2026 г.
Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx