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

Урок 2 — Типове, низове и управление на потока

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

Време: 2 × 90 мин (две сесии; прекъсването е между раздели 2.2 и 2.3)

Какво изграждаш: базовите функции на revisor: да реши дали една услуга работи, е бавна или е паднала, да форматира времето и да сглоби редовете на таблицата, всичко в един компактен файл

Какво научаваш: примитивни и референтни типове, обвиващи типове и var; String е неизменяем и защо == не е equals; форматиране със String.format и Locale.ROOT; текстови блокове (text blocks); if, && срещу & и операторът ? :; switch като израз; методи и константи в компактен файл; null и как да четеш съобщението на NullPointerException

Откъде идваш. Носиш средата от Урок 1: JDK 25, който отговаря на java --version, навика да изпълняваш файл с java Archivo.java и един ~/curso-java/revisor/Revisor.java от един-единствен ред, който отпечатва „revisor: todavía no revisa nada“ (revisor: още нищо не проверява). Умееш и да четеш първата грешка на компилатора: файлът, номерът на реда, ^, който сочи мястото, и редовете symbol и location. Този урок използва това умение непрекъснато, защото Java е език, който те мъмри, преди да изпълниш, а да разбираш тези мъмрения е половината от занаята.

Какво не прави този урок. Не използва списъци, нито цикли, нито твои собствени класове, нито пита някаква мрежа. Данните на четирите услуги на revisor са записани в два успоредни switch, нарочно: това е неудобно решение и Урок 3 го заменя с типове, които изразяват какво означава „една услуга отговори“ и „една услуга не отговори“. Колекциите идват в Урок 4, изключенията — в Урок 5. Тук, когато една програма пропада нарочно, го използваме, за да прочетем грешката, а не за да я прихванем.

За изпълненията. Всяка фигура е файл fig02_NN.java, който се изпълнява с java fig02_NN.java, от папката, където си го записал. Вече ги имаш написани в хранилището, в programas/02-tipos-cadenas-control/, но ще научиш повече, ако ги въведеш сам. Всички изходи на този урок са получени с Temurin 25.0.4 и регионалната настройка C.UTF-8. Ако в терминала ти на мястото на ударенията или на тирето „—“ се виждат ? или други странни знаци, проблемът не е в програмата ти, а в кодирането: провери регионалната настройка с командата locale (стойностите ѝ трябва да завършват на UTF-8, което е нормалното в Mint) и кодирането на терминала си. Като ориентир: на машината, където е мерен курсът, при LANG=C Java отпечата ? вместо тези знаци; в друга система симптомът може да е различен. Ако кръпката ти на Java е друга, съобщенията за грешка са същите; единственото, което се променя между актуализациите, са номерата на редовете в at java.base/... на една следа на стека (stack trace), които са на JDK, не твои. Фигурите, които пропадат нарочно, носят грешката си до себе си, във файл fig02_NN.error-esperado.txt.

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

  • Избираш между int, long, double и boolean за дадена стойност и обясняваш какво става с int, който надхвърли максимума си.
  • Разграничаваш примитивна стойност от референция и от обвиващ тип (int срещу Integer) и казваш кога една променлива може да е null.
  • Използваш var там, където помага, и знаеш какъв тип ѝ е дал компилаторът.
  • Обясняваш защо s.strip(); не променя s и защо == между два String може да даде false, макар да изглеждат еднакви.
  • Форматираш текст със String.format и Locale.ROOT и пишеш текстов блок от няколко реда.
  • Пишеш switch като израз със стрелки и обясняваш защо && и & не са едно и също.
  • Четеш съобщението на NullPointerException, знаеш коя променлива е била null и защо понякога съобщението казва <local1> вместо името ѝ.

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

Идваш в Java с един език на гърба и този език ти остави навици. Някои вършат работа тук такива, каквито са: if е if. Други са капани, защото Java прилича достатъчно на това, което знаеш, за да не се усъмниш. Помисли за три израза, които в езика, който носиш, може би значат едно, а в Java значат друго:

  • Сравняваш два текста с ==. В Python или в Go сравнява съдържанието им. В Java сравнява дали са един и същ обект и понякога ти казва true по случайност.
  • Извикваш texto.strip() и мислиш, че texto е почистен. В Java текстът никога не се променя: методът ти връща друг и ако не го запазиш, се губи без предупреждение.
  • Пишеш x != null & x.length() > 0, като вярваш, че е същото като с &&. Гърми с NullPointerException точно когато x е null, тоест точно когато проверката е била нужна.

Има едно проучване със стотици хиляди студенти по Java (Браун и Алтадмри, с данните на BlueJ), което е измерило кои грешки отнемат най-много време, за да се поправят. Участвалите преподаватели не познаха: интуицията им за това кои са трудните не съвпадна с данните. Най-бавно се поправят точно тези три: да бъркаш == с equals, да бъркаш & с && и да не използваш стойността, която връща един метод. Затова този урок не ги споменава мимоходом: всяка от тях се предизвиква нарочно, вижда се как пропада и се обяснява. Можеш да прочетеш статията на страницата на Университета на Кент.

Пътят има две сесии. Първата покрива 2.1 и 2.2: типовете и низовете. Като я завършиш, ще знаеш какво пази всяка променлива и какво прави един текст, когато го „променяш“. Втората покрива 2.3 до 2.5: управлението на потока, null и стъпката на проекта. Като я завършиш, ще имаш revisor, който отпечатва тази таблица, изчислена от твои функции:

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

Тази таблица е червената нишка на целия курс. Обърни внимание на последния ред: reportes не отговори, затова няма нито време, нито код. Как да представиш „няма данни“ в език със строги типове е въпросът, който пронизва целия урок, а лошият му отговор —null— е причината за половината от грешките, които ще видиш.

Понятията

2.1 Типове

2.1.1 Примитиви: самостоятелни стойности с фиксиран размер

В Java всяка променлива има тип, който пишеш ти, пред името, и който никога не се променя. int codigo = 200; декларира променлива на име codigo, която може да пази само цели числа от този размер. Ако по-късно се опиташ да запишеш текст в нея, компилаторът отказва. Ще го познаеш от Go или Rust; ако идваш от Python или JavaScript, това е най-голямата промяна: грешките на типовете се появяват преди изпълнението, не по време на него.

Типовете на Java се делят на две семейства. Първото са примитивите: осем типа, вградени в езика, които пазят стойността директно, без обект помежду. В revisor ти трябват само четири, но е добре да видиш всичките осем:

Тип Какво пази Размер Диапазон или пример
byte много малко цяло число 8 бита от −128 до 127
short малко цяло число 16 бита от −32 768 до 32 767
int цяло число (обичайното) 32 бита от −2 147 483 648 до 2 147 483 647
long голямо цяло число 64 бита до около 9.2 × 10¹⁸
float десетично число с единична точност 32 бита рядко се използва
double десетично число с двойна точност 64 бита обичайното десетично
boolean истина или лъжа не е фиксиран true или false
char една UTF-16 единица текст 16 бита 'c', в единични кавички

Диапазоните и размерите не ги решава твоят компютър, нито операционната ти система: фиксира ги езикът и затова int е с еднакъв размер навсякъде. Имената се пишат с малка буква; това те предупреждава, че не са класове.

Виж програмата fig02_01.java. Тя декларира по една стойност от всеки от четирите типа, които ще използва revisor (плюс един char, за да е пълна картината) и прави малко аритметика. Една бележка за синтаксиса: + между текст и каквото и да е друго нещо превръща това нещо в текст и го долепя, така се сглобява всеки ред от изхода. А IO.println е онази, която видя в Урок 1; нейната посестрима IO.print прави същото, но без да минава на нов ред накрая.

// fig02_01.java
void main() {
  int codigo = 200;
  long tiempoMs = 2300L;
  double segundos = tiempoMs / 1000.0;
  boolean lento = tiempoMs >= 1000;
  char inicial = 'c';

  IO.println("código:    " + codigo);
  IO.println("tiempo:    " + tiempoMs);
  IO.println("segundos:  " + segundos);
  IO.println("lento:     " + lento);
  IO.println("inicial:   " + inicial);

  IO.println("7 / 2      = " + 7 / 2);
  IO.println("7 / 2.0    = " + 7 / 2.0);
  IO.println("2300 / 1000 = " + 2300 / 1000);
  IO.println("0.1 + 0.2  = " + (0.1 + 0.2));
}
$ java fig02_01.java
código:    200
tiempo:    2300
segundos:  2.3
lento:     true
inicial:   c
7 / 2      = 3
7 / 2.0    = 3.5
2300 / 1000 = 2
0.1 + 0.2  = 0.30000000000000004

Има три неща в този изход, при които си струва да спреш.

Целочислено деление отхвърля десетичната част. 7 / 2 дава 3, не 3.5, защото двата операнда са int и резултатът е int. За да получиш 3.5, достатъчно е единият да е double: 7 / 2.0. Същото става с 2300 / 1000, което дава 2, не 2.3. Това изненадва този, който идва от Python 3 (където / винаги дава десетично) или от JavaScript (където всичко е десетично), но не и този, който идва от Go или Rust. Това е най-честата тиха грешка в сметките с време: ако изчислиш секундите като tiempoMs / 1000, получаваш цели секунди, а 999 ms са 0 s.

Променливата tiempoMs е long и стойността ѝ завършва на L. 2300L е литерал от тип long; без L, 2300 би бил int. При малки числа разликата не личи, защото Java превръща int в long сама (преобразуване, което нищо не губи). При големи числа L е задължително, както ще видиш след малко.

0.1 + 0.2 дава 0.30000000000000004. Това не е недостатък на Java: така всеки компютър пази двоични десетични числа (стандартът IEEE 754) и ти се случва по същия начин в Python, JavaScript, Go и Rust. Важното е следствието: никога не сравнявай десетични числа с == и никога не пази пари в double. revisor пази времената си в цели милисекунди именно затова: това е число, което не се нуждае от десетични знаци.

Сега капанът, който е собствен на Java и на езиците, споделящи модела ѝ на цели числа с фиксиран размер. Какво става, когато едно число надхвърли максимума си? Виж fig02_02.java:

// fig02_02.java
void main() {
  int maximo = Integer.MAX_VALUE;
  IO.println("máximo de un int: " + maximo);
  IO.println("máximo + 1:       " + (maximo + 1));

  long mayor = Long.MAX_VALUE;
  IO.println("máximo de un long: " + mayor);

  int milisegundosEnUnDia = 24 * 60 * 60 * 1000;
  IO.println("ms en un día:      " + milisegundosEnUnDia);
  int milisegundosEnUnMes = 30 * 24 * 60 * 60 * 1000;
  IO.println("ms en 30 días (int):  " + milisegundosEnUnMes);
  long enUnMes = 30L * 24 * 60 * 60 * 1000;
  IO.println("ms en 30 días (long): " + enUnMes);
}
$ java fig02_02.java
máximo de un int: 2147483647
máximo + 1:       -2147483648
máximo de un long: 9223372036854775807
ms en un día:      86400000
ms en 30 días (int):  -1702967296
ms en 30 días (long): 2592000000

int, който стигне максимума си и получи още едно, се завърта и се превръща в минимума, без никакво предупреждение: няма грешка, няма изключение, само отрицателно число там, където си очаквал огромно. В програмата тридесет дни в милисекунди не се събират в int (правилният резултат е 2 592 000 000, което е повече от максимума 2 147 483 647) и стойността, която излиза, е -1702967296. Поправката е сметката да се направи в long: достатъчно е да напишеш L на едно от числата, 30L * 24 * 60 * 60 * 1000, и цялото изчисление се прави в long. Забележи разликата между двата последни реда: тя е точно една буква.

Една подробност за четенето на тази фигура: Integer.MAX_VALUE и Long.MAX_VALUE са константи, които носят класовете Integer и Long, за които говорим веднага. А първият ред на main във всяка фигура на този урок е онзи, който вече познаваш: void main(), компактният файл от Урок 1.

2.1.2 Референции, обвиващи типове и null

Второто семейство типове са референтните: всичко, което не е един от осемте примитива. String е референтен тип. Такива са и Integer, Long, URI или всеки клас, който напишеш. Разликата с примитива е дълбока и се разбира с един образ. Примитивната променлива е кутия със стойността вътре: int codigo = 200 е кутия, на която пише 200. Референтната променлива е кутия с адрес вътре: адреса на мястото, където е обектът. Две референтни променливи могат да имат един и същ адрес (и тогава са един и същ обект) или различни адреси, които сочат обекти, изглеждащи еднакви. Тъкмо тази разлика трябва да се разбере в 2.2.2.

А една референтна променлива може да има, вместо адрес, никакъв. Това „никакъв“ се пише null. Примитивите не могат да са null: един int винаги съдържа някакво число. Тази разлика е важна за revisor, защото reportes не отговори и HTTP кодът му не е „нула“, нито „минус едно“: не съществува. С int няма честен начин да се каже това. С референция има.

Затова Java носи обвиващ тип за всеки примитив: Integer за int, Long за long, Double за double, Boolean за boolean, Character за char. Обвиващият тип е обект, чиято единствена работа е да съдържа примитивна стойност и понеже е референтен, може да е null. Java преобразува между примитива и обвиващия му тип автоматично, в двете посоки; казва се autoboxing (автоматично опаковане), когато стойността се слага в кутията (от int към Integer), и unboxing (разопаковане), когато се вади. Виж fig02_03.java, който събира казаното току-що:

// fig02_03.java
void main() {
  int codigo = 200;
  Integer codigoCaja = 200;
  Integer sinRespuesta = null;

  IO.println("primitivo:  " + codigo);
  IO.println("envoltorio: " + codigoCaja);
  IO.println("nulo:       " + sinRespuesta);
  IO.println("¿iguales?   " + codigoCaja.equals(codigo));

  Integer a = 127;
  Integer b = 127;
  IO.println("127 == 127:   " + (a == b));
  Integer c = 2300;
  Integer d = 2300;
  IO.println("2300 == 2300: " + (c == d));
  IO.println("2300 equals:  " + c.equals(d));
}
$ java fig02_03.java
primitivo:  200
envoltorio: 200
nulo:       null
¿iguales?   true
127 == 127:   true
2300 == 2300: false
2300 equals:  true

Три реда заслужават коментар. Integer sinRespuesta = null; е напълно валидна декларация и при отпечатване излиза null. codigoCaja.equals(codigo) извиква метода equals на обвиващия тип с int, а Java го слага в кутия вместо теб, за да го сравни: дава true. А двете сравнения с == са изненадата на този раздел: 127 == 127 дава true, а 2300 == 2300 дава false, макар на двата реда да се сравняват два обвиващи типа с една и съща стойност. Това е, защото == между две референции пита „един и същ обект ли са?“, а не „равни ли са по стойност?“. Java преизползва обектите Integer от −128 до 127 (това е гаранция на спецификацията), така че 127 и 127 са един и същ обект, а 2300 и 2300 са два различни обекта. С == между обвиващи типове програмата ти работи с малките HTTP кодове и пропада с големите времена, което е най-лошият вид грешка: онази, която зависи от данните.

Практическото правило е кратко. Между примитиви == сравнява стойности. Между референции сравнява идентичност, а за да сравниш стойности, се използва equals. И едно предупреждение, свойствено на обвиващите типове: Long.equals сравнява и типа. Един Long със 142 никога не е равен на Integer със 142; equals връща false, ако му подадеш другия тип. Когато сравняваш Long с литерал, пиши L.

В revisor ще използваш обвиващите типове точно за това, за което служат: Integer codigoDe(String nombre) връща HTTP кода на услугата или null, ако услугата не е отговорила; Long tiempoDe(String nombre) прави същото с времето. Урок 3 ще замени този трик с нещо по-добро, но за да разбереш защо е по-добро, трябва да си го изстрадал.

2.1.3 Типовете се проверяват преди изпълнението

Понеже всяка променлива има фиксиран тип, компилаторът проверява дали това, което ѝ присвояваш, се събира. Ще те смъмри със съобщение, което е добре да разпознаваш, защото ще го виждаш често. Виж fig02_04.java, където един long се опитва да влезе в int:

// fig02_04.java
void main() {
  long tiempoMs = 2300L;
  int tiempoEntero = tiempoMs;
  IO.println(tiempoEntero);
}
$ java fig02_04.java
fig02_04.java:4: error: incompatible types: possible lossy conversion from long to int
  int tiempoEntero = tiempoMs;
                     ^
1 error
error: compilation failed

Прочети го с това, което научи в Урок 1. Първо файлът и редът (fig02_04.java:4), после видът грешка (incompatible types), след това подробността („преобразуване, което може да загуби информация, от long към int“) и ^, което сочи мястото. Стартерът затваря с error: compilation failed.

Съобщението казва нещо важно с думата възможно (possible). Java не знае дали стойността се събира: вижда типа, не съдържанието. Един long би могъл да има стойност 3 000 000 000, а int не го понася, така че компилаторът те задължава да решиш. Имаш три изхода и правилният зависи от случая. Да смениш целевия тип на long е почти винаги най-доброто. Да напишеш преобразуване (cast) като (int) tiempoMs казва на компилатора „аз знам какво правя“ и е опасно: няма защита и (int) 3_000_000_000L дава -1294967296, същото тихо препълване от 2.1.1. Третият е да не смесваш типовете от самото начало. Преобразуването се използва, много внимателно, когато наистина искаш да отрежеш, например (int) 2.9 дава 2.

В обратната посока няма оплакване, но двете преобразувания не са еднакви. Един int влиза в long точно: всяка стойност на int съществува в long. Един long влиза и в double, без да иска разрешение, защото порядъкът на величината никога не се губи, но може да закръгли: един double пази около 15 или 16 значещи цифри, а един голям long има до 19, така че последните се приближават. За милисекунди като тези на revisor не се случва нищо и затова double segundos = tiempoMs / 1000.0 от fig02_01.java се компилира без церемонии; за дълъг числов идентификатор вече би имало значение.

2.1.4 var: нека компилаторът пише типа

Да пишеш типа на всяка променлива дава сигурност, но е повторение, когато стойността отдясно вече казва всичко. За това съществува var. Виж fig02_05.java:

// fig02_05.java
void main() {
  var nombre = "catalogo";
  var codigo = 200;
  var tiempoMs = 2300L;
  var segundos = tiempoMs / 1000.0;

  IO.println(nombre + " " + codigo + " " + tiempoMs + " " + segundos);
}
$ java fig02_05.java
catalogo 200 2300 2.3

Всяко var е променлива с фиксиран тип, точно както ако си го беше написал: nombre е String, codigo е int, tiempoMs е long (заради L) и segundos е double. var не прави Java динамична: компилаторът извежда типа веднъж, от стойността, с която инициализираш, и оттам нататък променливата не може да го сменя. Ако после опиташ nombre = 5;, компилаторът се оплаква както преди. Предложението, което го въведе (JEP 286, Java 10), го ограничава до локални променливи с инициализатор: не важи за полета, нито за параметри, нито за върнати типове.

Без стойност, от която да изведе, няма тип. Виж какво става с fig02_06.java:

// fig02_06.java
void main() {
  var respuesta = null;
  IO.println(respuesta);
}
$ java fig02_06.java
fig02_06.java:3: error: cannot infer type for local variable respuesta
  var respuesta = null;
      ^
  (variable initializer is 'null')
1 error
error: compilation failed

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

Кога да използваш var. В този курс го използваме, когато типът личи на същия ред (var nombre = "catalogo";), и го избягваме, когато скрива информация (var resultado = revisar(servicio); не ти казва какво е resultado, без да отидеш да прочетеш revisar). Това е критерий за четене, не правило на езика: кодът се чете много повече пъти, отколкото се пише.

2.2 Низове

2.2.1 String е обект и е неизменяем

String е клас от стандартната библиотека, така че всеки текст е обект с методи: пишат се след точка, както в Python или JavaScript. Най-използваните са length() (колко знака), strip() (маха интервалите в краищата), isBlank() (празен ли е или има само интервали?), toUpperCase(), contains(otro), startsWith(prefijo) и substring(desde, hasta) (парче; второто число не се включва). Виж как се използват във fig02_07.java и обърни внимание най-вече на първите два реда от изхода:

// fig02_07.java
void main() {
  String linea = "  catalogo  ";
  linea.strip();
  IO.println("[" + linea + "]");

  linea = linea.strip();
  IO.println("[" + linea + "]");

  IO.println("longitud:    " + linea.length());
  IO.println("mayúsculas:  " + linea.toUpperCase());
  IO.println("¿contiene 'log'? " + linea.contains("log"));
  IO.println("¿empieza con 'cat'? " + linea.startsWith("cat"));
  IO.println("recorte:     " + linea.substring(0, 3));
  IO.println("¿vacía?      " + "   ".isBlank());
}
$ java fig02_07.java
[  catalogo  ]
[catalogo]
longitud:    8
mayúsculas:  CATALOGO
¿contiene 'log'? true
¿empieza con 'cat'? true
recorte:     cat
¿vacía?      true

Първият ред от изхода е грешката. Написахме linea.strip(); и текстът остана със своите интервали: [ catalogo ]. Нямаше нито предупреждение, нито грешка. Това е игнорираната върната стойност, една от трите грешки, които отнемат най-много време да се поправят според проучването, което споменах. Причината е, че String в Java са неизменяеми: щом веднъж е създаден, един текст не се променя. Документацията го казва така: текстовете са константи и стойностите им не се променят след създаването. Всички методи, които изглеждат, че „променят“ (strip, toUpperCase, substring...), всъщност връщат нов текст и оставят оригинала недокоснат. Ако не го запазиш, си го изхвърлил.

Правилният ред е долният: linea = linea.strip();, който преназначава променливата, за да сочи към новия текст. Обърни внимание на това, което се промени: не обектът, а към кого сочи променливата. Това е моделът на кутиите с адреси от 2.1.2.

Защо е проектиран така? Защото неизменяемият текст може да се споделя без страх: ако сто части от програмата пазят един и същ String, нито една не може да го повреди за останалите. Това е свойство, което вече познаваш от Python (str) и от JavaScript (примитивните текстове) и което ще видиш пак в Java с records от Урок 3. Цената е, че програмистът трябва да помни правилото.

Естествен въпрос: ако всяка „промяна“ създава нов текст, не е ли скъпо да сглобиш дълъг текст, като долепяш парчета? В цикъл с хиляди завъртания е, и за това има отделен клас, StringBuilder. Не ти трябва в този курс: revisor сглобява къси редове и String.format (2.2.3) вече върши работата.

2.2.2 == срещу equals

Това е най-важната грешка на урока. Виж fig02_08.java, който сглобява текста inventario по два начина:

// fig02_08.java
void main() {
  String prefijo = "inven";
  String armado = prefijo + "tario";
  String literal = "inventario";
  String constante = "inven" + "tario";

  IO.println("armado == literal:       " + (armado == literal));
  IO.println("armado.equals(literal):  " + armado.equals(literal));
  IO.println("constante == literal:    " + (constante == literal));
  IO.println("mayúsculas distintas:    " + "INVENTARIO".equals(literal));
  IO.println("ignorando mayúsculas:    " + "INVENTARIO".equalsIgnoreCase(literal));
}
$ java fig02_08.java
armado == literal:       false
armado.equals(literal):  true
constante == literal:    true
mayúsculas distintas:    false
ignorando mayúsculas:    true

Първите три реда разказват история, която се чете по-добре подред.

armado == literal дава false, макар двата текста да съдържат inventario. armado е построен по време на изпълнение, като е долепена стойността на променливата prefijo към "tario", така че е нов обект на нов адрес. literal е обект, който компилаторът е създал веднъж, а == пита дали двете променливи сочат към един и същ. Не сочат. armado.equals(literal) дава true, защото equals сравнява съдържанието, знак по знак.

Третият ред е капанът в капана. constante == literal дава true, макар constante също да е написана с +. Причината: "inven" + "tario" свързва два литерала и компилаторът знае резултата преди изпълнението, затова го изчислява той и го третира като литерала "inventario" (спецификацията го нарича „константен израз“). Текстовите литерали със същото съдържание са един и същ обект в цялата програма. Затова == между текстове обикновено работи в малък примерен тест и пропада веднага щом текстът дойде отвън: от файл, от клавиатура, от мрежа, от + с променлива. Това е видът грешка, която минава всички тестове, които пишеш, и пропада в продукция.

Последните два реда показват какво се пише вместо това. equals различава главните от малките букви, а equalsIgnoreCase — не. И един навик, който струва злато: слагай литерала пръв. "INVENTARIO".equals(literal) не може да хвърли NullPointerException, защото литералът никога не е null; докато variable.equals("inventario") гърми, ако variable е null. С литерала отляво, ако променливата е null, отговорът е просто false.

Правилото на този урок, най-важното: между референции (текстове, обвиващи типове, какъвто и да е обект) == пита дали е същият обект; equals пита дали са равни по стойност. Почти винаги искаш второто. Използвай == между примитиви и между enum (Урок 3), където е правилното.

Защо Java не прави == да сравнява съдържание при текстовете, както Python? Защото Java не позволява на програмиста да променя значението на оператор, а == значи същото във всички референтни типове. Това е проектантско решение, което от десетилетия създава проблеми и няма да се промени.

2.2.3 Форматиране: String.format и Locale.ROOT

Всеки ред от таблицата на revisor има подравнени колони: името заема 12 места, състоянието 10 и така нататък. Да сглобяваш това с + и интервали на ръка е кошмар и за това Java носи String.format: получава шаблон с маркери и стойностите, които ги запълват. Маркерите, които ти трябват днес:

  • %s слага текст (или каквато и да е стойност, превърната в текст).
  • %d слага цяло число.
  • %.1f слага десетично число с един знак след точката.
  • %-12s слага текст, заемащ 12 места, подравнен отляво (без тирето — отдясно). %6s заема 6 и подравнява отдясно.

И има един проблем, който няма да видиш, докато програмата ти не тръгне на компютъра на друг човек: десетичните числа се пишат различно според езика. В Мексико десетичният разделител е точката (2.3), в Испания е запетаята (2,3). Ако помолиш String.format да форматира десетично число, без да му кажеш езика, използва този на машината, където работи. Програмата ти би излязла с точки на твоя лаптоп и със запетаи на този на колега. За да не става така, му се подава като първи аргумент Locale, обектът, който представлява език и регион. Виж fig02_09.java:

// fig02_09.java
void main() {
  double segundos = 2.3;
  Locale mexico = Locale.of("es", "MX");
  Locale espana = Locale.of("es", "ES");

  IO.println(String.format(Locale.ROOT, "%.1fs", segundos));
  IO.println(String.format(mexico, "%.1fs", segundos));
  IO.println(String.format(espana, "%.1fs", segundos));
  IO.println(String.format(Locale.ROOT, "[%-12s][%6s][%d]", "catalogo", "142ms", 200));
  IO.println("%s tardó %d ms".formatted("pagos", 87));
}
$ java fig02_09.java
2.3s
2.3s
2,3s
[catalogo    ][ 142ms][200]
pagos tardó 87 ms

Locale.of("es", "MX") построява испанския език на Мексико, а Locale.of("es", "ES") — този на Испания. С този на Испания същият %.1f дава 2,3s. Locale.ROOT е „неутралният език“, който използва най-стабилния формат (десетична точка, без разделители на хилядите) и е този, който курсът използва винаги когато се форматира число с десетични знаци. Причината не е националистическа: таблицата на revisor трябва да излиза еднаква на всяка машина, а програма, чийто изход зависи от регионалната настройка, е програма, която един тест не може да провери.

Четвъртият ред от изхода е шаблонът на редовете на таблицата в умален вид (%-12s за името, %6s за времето и %d за кода), със скоби, за да видиш колко място заема всяка колона. Последният използва formatted, вариант на същия механизъм, който се извиква върху самия текст: "...".formatted(valores) е равностойно на String.format("...", valores) (без Locale: използвай го само с текстове и цели числа).

2.2.4 Текстови блокове

Понякога ти трябва текст от няколко реда: шаблон, парче конфигурация, примерен JSON. Да го пишеш с " и \n на всеки ред е ужасно. Java има текстови блокове (text blocks), ограничени с три двойни кавички. Въведени са като окончателна функция в Java 15 (JEP 378). Виж fig02_10.java:

// fig02_10.java
void main() {
  String encabezado = """
      # nombre      url
      catalogo      https://catalogo.example
      pagos         https://pagos.example
      """;
  IO.print(encabezado);
  IO.println("renglones con salto final: " + encabezado.endsWith("\n"));

  String cita = """
      El servicio "%s" tardó %d ms.\
       Revísalo.""".formatted("inventario", 2300);
  IO.println(cita);
}
$ java fig02_10.java
# nombre      url
catalogo      https://catalogo.example
pagos         https://pagos.example
renglones con salto final: true
El servicio "inventario" tardó 2300 ms. Revísalo.

Правилото, което го прави полезен, е как третира отстъпа. Текстът на блока започва на реда под трите отварящи кавички и Java маха отстъпа, който всички редове споделят (компилаторът го изчислява по най-малко отстъпения ред, като брои и реда със затварящите """). Затова отпечатаният текст започва с # nombre, без шестте интервала, с които написахме блока в кода. Ако преместиш затварящите """ наляво, запазваш повече отстъп; ако ги оставиш в края на последния ред текст, няма нов ред накрая.

Още две подробности. Вътре в блок можеш да пишеш двойни кавички без да ги екранираш ("%s" във втория блок). А обратна наклонена черта \ в края на реда **не вмъква нов ред**: слепва реда със следващия. Във втория блок \ кара изречението да излезе на един дъх, с интервал, който идва от допълнителния отстъп на следващия ред (седем интервала, един повече от общите шест). И обърни внимание на употребата на .formatted(...) веднага след затварянето: текстовият блок е String като всеки друг. Редът с endsWith("\n") проверява, че първият блок завършва с нов ред, който в обикновен текст се пише \n; затова изходът на IO.print оставя трите реда подред и следващият IO.println започва на чист ред.

С това свършва първата сесия. Преди да продължиш, провери дали можеш да отговориш, без да гледаш: каква е разликата между int и Integer? Какво отпечатва armado == literal във fig02_08.java и защо? Какво става, ако напишеш linea.strip(); и нищо повече? Ако някой от трите въпроса те затруднява, препрочети съответния раздел преди втората сесия.

2.3 Управление на потока

2.3.1 if, условия и операторът ? :

if в Java е онзи, който вече познаваш, с две особености. Първата: условието трябва да е boolean и нищо друго. В Python или JavaScript число, текст или списък могат да служат за условие (наричат ги „истинни“ или „лъжливи“ според съдържанието им); в Java if (codigo) с int дори не се компилира:

Prueba.java:3: error: incompatible types: int cannot be converted to boolean
  if (codigo) {
      ^

Това е предимство, преоблечено като неудобство: задължава те да напишеш това, което наистина искаш да попиташ (codigo >= 400, texto != null, !texto.isBlank()), и край на грешките от типа „нулата също се брои за лъжа“. Втората особеност е стилова: винаги фигурни скоби, дори if да има един-единствен ред. Това изисква ръководството за стил на Google, което следва целият код на курса (два интервала отстъп, редове до 100 колони) и което ще проверява Checkstyle в Урок 10; ако ги пишеш от днес, няма да се налага да ги поправяш после. if без скоби е рецептата за една класическа грешка: някой добавя втори ред отдолу, с правилния отстъп, и вторият ред не е част от if.

Виж fig02_11.java. Събира if с else if, една булева променлива и тернарния оператор, който е if под формата на израз: condicion ? valorSiVerdadero : valorSiFalso.

// fig02_11.java
void main() {
  int codigo = 503;
  long tiempoMs = 95;

  if (codigo >= 400) {
    IO.println("CAIDO: el servicio contestó " + codigo);
  } else if (tiempoMs >= 1000) {
    IO.println("LENTO");
  } else {
    IO.println("ARRIBA");
  }

  boolean respondio = codigo != 0 && tiempoMs > 0;
  IO.println("¿respondió? " + respondio);
  String mensaje = respondio ? "con respuesta" : "sin respuesta";
  IO.println(mensaje);
}
$ java fig02_11.java
CAIDO: el servicio contestó 503
¿respondió? true
con respuesta

Условието codigo >= 400 е първото правило на revisor: услуга, която отговаря с код за грешка (400 или повече), е паднала, макар да е отговорила. Обърни внимание, че else if се оценява само ако първото е било лъжливо и че клоновете се изключват взаимно. Тернарният оператор се използва, за да избереш стойност, не за да изпълняваш действия; когато изразът от всяка страна стане дълъг, обикновеният if се чете по-добре.

2.3.2 && срещу &

Ето втората грешка от списъка на бавно поправяемите. Java има два оператора за „и“: && и & (и два за „или“: || и |). И двата правят едно и също с булеви стойности, с една разлика, която има значение: && спира веднага щом знае отговора. Ако лявата страна е лъжа, резултатът е лъжа независимо от дясната и дясната изобщо не се оценява. Това се нарича съкратено оценяване (short-circuit). & оценява винаги и двете страни. (При цели числа & е нещо друго: операция върху битовете. В този курс не я използваме, но обяснява защо съществува.)

Виж програмата fig02_12.java. Иска да попита: „детайлът съществува ли и освен това е празен ли?“:

// fig02_12.java
void main() {
  String detalle = null;
  if (detalle != null & detalle.isBlank()) {
    IO.println("detalle vacío");
  }
  IO.println("fin");
}
$ java fig02_12.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.isBlank()" because "<local1>" is null
	at fig02_12.main(fig02_12.java:4)

detalle е null, така че detalle != null е лъжа... но & оцени въпреки това дясната страна, detalle.isBlank(), а да извикаш метод върху null гърми. Това е проверката, която си поставил, за да избегнеш проблема, и която го причинява. С && дясната страна не се докосва, когато лявата пропадне. Ето fig02_13.java:

// fig02_13.java
void main() {
  String detalle = null;
  if (detalle != null && detalle.isBlank()) {
    IO.println("detalle vacío");
  }
  IO.println("fin sin errores");
}
$ java fig02_13.java
fin sin errores

Този шаблон, да провериш, че не е null, и само ако не е, да го използваш, е причината && да съществува в revisor. Ще го използваш в clasificar и ще го напишеш стотици пъти в живота си с Java. Правилото е: в условие — && и ||; & и | се оставят за операции върху битове. Това е от онези грешки, които компилаторът не отбелязва, защото и двата оператора са валидни; хваща ги твоето внимание.

2.3.3 switch като израз

Класическият switch на Java, наследен от C, има прочут недостатък: ако забравиш break, изпълнението продължава в случая отдолу. Виж fig02_14.java, който записва един и същ селектор по два начина:

// fig02_14.java
void main() {
  String nombre = "inventario";
  int antiguo = 0;
  switch (nombre) {
    case "catalogo":
      antiguo = 1;
    case "inventario":
      antiguo = 2;
    case "pagos":
      antiguo = 3;
      break;
    default:
      antiguo = -1;
  }
  IO.println("switch con dos puntos y sin break: " + antiguo);

  int moderno = switch (nombre) {
    case "catalogo" -> 1;
    case "inventario" -> 2;
    case "pagos" -> 3;
    default -> -1;
  };
  IO.println("switch con flecha:                " + moderno);
}
$ java fig02_14.java
switch con dos puntos y sin break: 3
switch con flecha:                2

Със стария синтаксис (двете точки) inventario влиза в случай 2, не намира break, пада в случай 3 и накрая оставя antiguo със стойност 3. С новия (стрелката ->) всеки случай е независим: няма break, нито падане. Старият синтаксис трябва да се разпознава, защото е в почти целия Java код, който ще срещнеш, но в този курс винаги се пише със стрелка. Конструкциите switch със стрелки влязоха в Java във версия 14 (JEP 361).

Втората разлика е онази, която променя начина ти на мислене: един switch може да се ползва като израз, тоест като нещо, което произвежда стойност. (Стрелката сама по себе си не го превръща в израз: може да се ползва и в обикновен switch, като инструкция, и там предимството ѝ е, че няма пропадане от един случай в следващия. Стойност произвежда switch, ползван там, където се очаква стойност.) Можеш да го присвоиш (int moderno = switch ... ;), да го върнеш с return switch ... или да го подадеш на метод. Три правила го характеризират. Един случай може да има няколко стойности, разделени със запетаи (case "catalogo", "inventario", "pagos" -> 200;). Резултатът на всеки случай е това, което е отдясно на стрелката. А ако ти трябва повече от една инструкция, случаят носи блок във фигурни скоби и връща стойността си с yield. Виж fig02_15.java:

// fig02_15.java
void main() {
  IO.println(describir(200));
  IO.println(describir(301));
  IO.println(describir(404));
  IO.println(describir(503));
  IO.println(describir(0));
}

String describir(int codigo) {
  int familia = codigo / 100;
  return switch (familia) {
    case 2 -> "correcto";
    case 3 -> "redirección";
    case 4, 5 -> {
      String quien = familia == 4 ? "cliente" : "servidor";
      yield "error del " + quien;
    }
    default -> "sin clasificar";
  };
}
$ java fig02_15.java
correcto
redirección
error del cliente
error del servidor
sin clasificar

В тази фигура има три нови неща. codigo / 100 използва целочисленото деление от 2.1.1, за да получи „семейството“ на кода (503 остава в 5). case 4, 5 -> { ... yield ...; } е блокът с yield, който е за един случай това, което е return за един метод. А default хваща останалото. Едно ограничение: switch със стойности сравнява по равенство с константи; не върши работа за диапазони като „400 или повече“. За това се използва if или, от Урок 3 нататък, switch с шаблони и условия.

Правилото, което прави switch като израз ценен, е че трябва да покрива всички възможни случаи, защото трябва да произведе стойност при какъвто и да е вход. Ако нещо липсва, компилаторът го казва. fig02_16.java забравя default:

// fig02_16.java
void main() {
  String nombre = "reportes";
  int codigo = switch (nombre) {
    case "catalogo", "inventario", "pagos" -> 200;
  };
  IO.println(codigo);
}
$ java fig02_16.java
fig02_16.java:4: error: the switch expression does not cover all possible input values
  int codigo = switch (nombre) {
               ^
1 error
error: compilation failed

Компилаторът знае, че има безкрайно много различни текстове, така че без default не може да гарантира, че ще има стойност. По-нататък, в Урок 3, ще видиш, че с enum и със запечатани типове компилаторът може да провери, че не липсва никой случай, без да му трябва default, и това е една от най-добрите причини да ги предпочиташ.

switch върху текст сравнява с equals (отвътре), не с ==, така че не попада в капана от 2.2.2. А ако текстът е null, switch хвърля NullPointerException, преди да погледне какъвто и да е случай.

2.3.4 Методи и константи

Методите на Java се декларират, като се пишат по ред типът, който връщат, името, параметрите с типа им в скоби и тялото във фигурни скоби. String marcar(long tiempoMs) е метод, който получава long и връща String. Ако не връща нищо, типът е void (както в void main()). Връща се с return и компилаторът изисква всички пътища на метода да връщат нещо: ако if без else остави път без return, това е грешка при компилация. В компактен файл методите се пишат на същата височина като main, а main ги извиква по име.

Две особености спрямо други езици. Java няма параметри със стойност по подразбиране: ако искаш кратка версия на метод, пишеш друг със същото име и по-малко параметри (Урок 3 ще го направи с records). А аргументите се подават по стойност: методът получава копие. Виж fig02_17.java, който освен това представя една константа:

// fig02_17.java
static final int UMBRAL_LENTO_MS = 1000;

void main() {
  long tiempoMs = 2300;
  IO.println(marcar(tiempoMs));
  IO.println("después de la llamada, tiempoMs sigue valiendo " + tiempoMs);
  IO.println(marcar(87));
}

String marcar(long tiempoMs) {
  String marca = tiempoMs >= UMBRAL_LENTO_MS ? "lento" : "rápido";
  tiempoMs = 0;
  return marca;
}
$ java fig02_17.java
lento
después de la llamada, tiempoMs sigue valiendo 2300
rápido

Методът marcar слага параметъра си tiempoMs на 0 и когато се върне, променливата в main продължава да е 2300. Промяната на параметъра промени копието, не оригинала. (С обекти става същото, но това, което се копира, е адресът: методът може да промени съдържанието на изменяем обект, но не може да направи така, че променливата на извикващия да сочи към друг.)

Редът в началото, static final int UMBRAL_LENTO_MS = 1000;, е константа. Всяка дума има значение. final значи „присвоява се само веднъж“; опит за преприсвояване е грешка при компилация. static значи „принадлежи на класа, а не на обект“; в компактен файл „класът“ е целият файл. Името е в MAYUSCULAS_CON_GUION_BAJO (главни букви с долна черта; така изисква конвенцията на Java) и носи мерната единица: _MS. Да го напишеш на едно място и да го използваш по име, вместо да пишеш 1000 във всяко сравнение, има практическа причина: когато прагът се промени, се променя на едно място; и една комуникационна: tiempoMs >= UMBRAL_LENTO_MS се чете, а tiempoMs >= 1000 те кара да помниш какво е хиляда.

2.4 null и съобщенията на NullPointerException

2.4.1 Какво е null и кога гърми

Казахме в 2.1.2, че една референция може да не сочи към нищо и че това се пише null. Проблемът не е, че съществува, а какво става, когато използваш null, сякаш е обект: извикваш му метод, четеш някое негово поле или, в случая на обвиващ тип, вадиш стойността му. Във всеки от тези случаи програмата спира с изключение, наречено NullPointerException (наричат го „NPE“). Тони Хоар, който измисли понятието през 1965 г., по-късно го нарече „моята грешка за милиард долара“. Java не го премахна и по-голямата част от този курс е историята как да се живее, без да зависиш от него.

Виж fig02_18.java. Методът codigoDe връща null за непозната услуга, а main се опитва да му прибави 1:

// fig02_18.java
void main() {
  Integer codigo = codigoDe("reportes");
  IO.println("código: " + codigo);
  int sumado = codigo + 1;
  IO.println("sumado: " + sumado);
}

Integer codigoDe(String nombre) {
  return switch (nombre) {
    case "catalogo", "inventario", "pagos" -> 200;
    default -> null;
  };
}
$ java fig02_18.java
código: null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local1>" is null
	at fig02_18.main(fig02_18.java:5)

Първият ред излиза добре, защото залепването на null към текст с + само записва „null“. Вторият е този, който гърми: codigo + 1 се нуждае от числото, което е вътре в Integer, тоест от unboxing (разопаковането) от 2.1.2, а числото го няма. Това е капанът на обвиващите типове: сборът изглежда като на две цели числа, но едното може да не съществува. Ако codigo беше int, нямаше да има какво да пропадне, но нямаше да има и начин да се каже „без отговор“.

2.4.2 Четене на съобщението

От няколко версии на Java насам NullPointerException не се ограничава да казва „е null“: казва какво се опитваше да направиш и какво беше null. Това е функцията „полезни съобщения за NPE“, описана в JEP 358. Съобщението на fig02_18.java гласи:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local1>" is null
	at fig02_18.main(fig02_18.java:5)

Чете се на две половини. Преди because: какво се опитваше да направи програмата. „Не може да се извика Integer.intValue()“ (Cannot invoke), тоест да се извади стойността от обвиващия тип. След because: какво беше null. „Променливата <local1>“. А редът отдолу, at fig02_18.main(fig02_18.java:5), казва къде: методът main, файл fig02_18.java, ред 5. Един NPE се поправя, като се чете това съобщение, а не като се отваря дебъгер.

Сега подробността, която учебният план обещава да изясни. Защо казва <local1>, а не codigo? Защото стартерът java Archivo.java компилира файла в паметта, без да запазва имената на локалните променливи. Тези имена съществуват само в изходния код; за да ги познава JVM, компилаторът трябва да ги включи и това му го искаш с опцията -g. Компилирай го сам, с javac -g, и го изпълни с java:

$ javac -g fig02_18.java
$ java fig02_18
código: null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "codigo" is null
	at fig02_18.main(fig02_18.java:5)

Сега да: because "codigo" is null. Това е същата програма, същата грешка и същият ред, но с много по-ясно съобщение. Когато срещнеш <local3> на работа, вече знаеш какво е: номерът на слота, който JVM е дала на тази локална променлива (в метод на инстанция слот 0 е this, а останалите се разпределят по реда, в който са декларирани; long или double заема два). А когато искаш името, компилирай с -g.

Има и още форми на съобщението. Ако null не е променлива, а резултатът от метод, съобщението го казва. Това е fig02_19.java:

// fig02_19.java
void main() {
  String texto = codigoDe("reportes").toString();
  IO.println(texto);
}

Integer codigoDe(String nombre) {
  return switch (nombre) {
    case "catalogo", "inventario", "pagos" -> 200;
    default -> null;
  };
}
$ java fig02_19.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.toString()" because the return value of "fig02_19.codigoDe(String)" is null
	at fig02_19.main(fig02_19.java:3)

„Because the return value of fig02_19.codigoDe(String) is null“ се превежда: методът codigoDe върна null, а програмата поиска toString() от тази стойност. Не е нужно да гадаеш коя от частите на израза codigoDe("reportes").toString() е била null: съобщението сочи тази, която е била. Когато една верига има няколко свързани извиквания (a.b().c().d()), това ти спестява половин час.

2.4.3 Три правила, за да живееш с null

null не изчезва от реалния код, но има начини да се опитоми и revisor ги прилага от днес:

  1. Проверявай, преди да използваш, с &&. codigo != null && tiempoMs != null е условието на revisor, за да знае дали една услуга е отговорила. Ако първата част пропадне, втората не се оценява.
  2. Слагай литерала отляво в equals. "pagos".equals(nombre) не може да гръмне; nombre.equals("pagos") може.
  3. Не връщай null, когато има по-добър начин да кажеш „няма данни“. Тук го правим, защото още нямаме друг, но в Урок 3 null изчезва от revisor и го замества запечатан тип, а в Урок 4 ще видиш Optional. Методът, който може да върне null, е метод, който задължава всички, които го извикват, да си спомнят да проверят.

2.5 Стъпката на проекта: revisor с базовите си функции

Сега събираш всичко. Искаш Revisor.java да отпечата таблицата, от която тръгна урокът, като изчислява всяка клетка с функции. Правилата са четирите обичайни на revisor:

  1. Ако услугата не е отговорила, е CAIDO (паднала).
  2. Ако е отговорила с код 400 или повече, е CAIDO (детайлът е кодът).
  3. Ако е отговорила и е отнела 1000 ms или повече, е LENTO (бавна).
  4. Иначе е ARRIBA (работи).

Данните на четирите услуги живеят, засега, в два успоредни switch: единият връща кода, другият — времето. Услуга, която не е отговорила, има null и в двата. Твоят Revisor.java става така (запиши го в ~/curso-java/revisor/; фигурата го нарича fig02_20.java, за да се различава от другите, а съдържанието му е това на твоя файл):

// fig02_20.java
static final int UMBRAL_LENTO_MS = 1000;

Integer codigoDe(String nombre) {
  return switch (nombre) {
    case "catalogo", "inventario", "pagos" -> 200;
    default -> null;
  };
}

Long tiempoDe(String nombre) {
  return switch (nombre) {
    case "catalogo" -> 142L;
    case "inventario" -> 2300L;
    case "pagos" -> 87L;
    default -> null;
  };
}

String clasificar(Integer codigo, Long tiempoMs) {
  boolean respondio = codigo != null && tiempoMs != null;
  if (!respondio) {
    return "CAIDO";
  }
  if (codigo >= 400) {
    return "CAIDO";
  }
  if (tiempoMs >= UMBRAL_LENTO_MS) {
    return "LENTO";
  }
  return "ARRIBA";
}

String formatearTiempo(Long tiempoMs) {
  if (tiempoMs == null) {
    return "—";
  }
  if (tiempoMs < UMBRAL_LENTO_MS) {
    return tiempoMs + "ms";
  }
  return String.format(Locale.ROOT, "%.1fs", tiempoMs / 1000.0);
}

String detalle(Integer codigo) {
  if (codigo == null) {
    return "sin conexión";
  }
  return String.valueOf(codigo);
}

String linea(String nombre, String estado, String tiempo, String detalle) {
  return String.format(Locale.ROOT, "%-12s%-10s%6s  %s", nombre, estado, tiempo, detalle);
}

String encabezado() {
  return linea("SERVICIO", "ESTADO", "TIEMPO", "DETALLE");
}

String revisar(String nombre) {
  Integer codigo = codigoDe(nombre);
  Long tiempoMs = tiempoDe(nombre);
  return linea(
      nombre, clasificar(codigo, tiempoMs), formatearTiempo(tiempoMs), detalle(codigo));
}

void main() {
  IO.println(encabezado());
  IO.println(revisar("catalogo"));
  IO.println(revisar("inventario"));
  IO.println(revisar("pagos"));
  IO.println(revisar("reportes"));
}
$ java fig02_20.java
SERVICIO    ESTADO    TIEMPO  DETALLE
catalogo    ARRIBA     142ms  200
inventario  LENTO       2.3s  200
pagos       ARRIBA      87ms  200
reportes    CAIDO          —  sin conexión

Мини през програмата с това, което знаеш, отгоре надолу:

  • UMBRAL_LENTO_MS е константата от 2.3.4.
  • codigoDe и tiempoDe са двата switch със стрелки от 2.3.3. Връщат Integer и Long, не int и long, за да могат да върнат null (2.1.2). Литералите носят L в tiempoDe, защото резултатът е Long, а 142 сам би бил Integer.
  • clasificar реализира четирите правила. Първият ред, codigo != null && tiempoMs != null, използва && от 2.3.2: иначе сравненията отдолу биха разопаковали null. Правилата се прилагат по ред, всяко със своя return; когато едно се изпълни, останалите не се оценяват.
  • formatearTiempo получава Long, който може да е null (чертата „—“ е сигналът за „няма данни“), а иначе избира между 142ms и 2.3s. Забележи, че използва Locale.ROOT (2.2.3).
  • detalle връща „sin conexión“ (без връзка) за null, а иначе кода като текст. String.valueOf(codigo) превръща Integer в String.
  • linea сглобява ред с шаблона %-12s%-10s%6s %s: 12 ширина за името, 10 за състоянието, 6 подравнено отдясно за времето, два интервала и детайлът. encabezado преизползва linea със заглавията, за да са с едни и същи размери.
  • revisar обединява всичко: търси кода и времето на една услуга и връща пълния ѝ ред. Извикването на linea е разделено на два реда, защото би надхвърлило 100 колони; продължението е с четири интервала повече.
  • main отпечатва заглавието и четирите реда, един по един. Няма цикъл, защото няма списък: услугите още не са данни, които могат да се обходят.

А таблицата, която излиза, е знак по знак онази, която обещахме в началото. Забележи, че reportes мина по целия път на null: codigoDe и tiempoDe му връщат null, clasificar го праща в CAIDO по правило 1, formatearTiempo му слага чертата и detalle пише „sin conexión“. Всяко парче реши само какво да прави с празнотата. Но това решение се повтори четири пъти (по веднъж във всяка функция) и за това става дума в следващия урок.

За да го стартираш от работната си папка:

$ cd ~/curso-java/revisor
$ java Revisor.java

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

Урокът има една основна грешка и четири помощни съобщения, които е добре да умееш да четеш с един поглед.

Основната грешка: ==, което дава false, когато си очаквал true. Това е тази от fig02_08.java. Когато текст, сглобен по време на изпълнение, се сравни с == срещу литерал, Java пита дали са един и същ обект и те не са:

armado == literal:       false
armado.equals(literal):  true

Няма съобщение за грешка; затова е най-трудната. Сигналът е, че сравнение, което трябва да е вярно, дава лъжа, а всичко останало изглежда наред. Ако видиш == между два текста или два обвиващи типа в един if, подозирай го преди всичко друго.

Четирите помощни съобщения:

  • error: incompatible types: possible lossy conversion from long to int: присвоил си голям тип на по-малък. Смени целевия тип или направи преобразуването изрично, като знаеш какво можеш да загубиш (fig02_04.java).
  • error: cannot infer type for local variable respuesta, с (variable initializer is 'null'): използвал си var без стойност, от която да се изведе типът (fig02_06.java).
  • error: the switch expression does not cover all possible input values: switch, който произвежда стойност и не покрива всички случаи. Добави default (или, при enum и запечатани типове, липсващия случай) (fig02_16.java).
  • NullPointerException: Cannot invoke "String.isBlank()" because "<local1>" is null: използвал си null, сякаш е обект. Частта след because казва кой е бил null; ако вместо името пише <localN>, файлът е компилиран без -g (fig02_12.java, fig02_18.java).

И един фонов ефект, който не произвежда никакво съобщение: 2,3s вместо 2.3s. Случва се на машина с испанска регионална настройка, когато се форматира десетично число без Locale.ROOT (fig02_09.java).

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

Сравняване на текстове или обвиващи типове с ==. Работи с литералите и с малките числа и пропада с данните, които идват отвън. При текстове — equals (с литерала отляво); при обвиващи типове — equals или, ако са примитиви, ==.

Извикване на метод на String без запазване на резултата. Самотното linea.strip(); не прави нищо. Текстовете не се променят: методите връщат друг.

Използване на & или | там, където трябват && или ||. И двата са валидни, а първият гърми точно когато проверката е била нужна.

int за нещо, което може да нарасне. Милисекундите на един месец не се събират в int. Ако стойността е време, брой или размер, започни с long.

double за пари или за сравнение с ==. Двоичните десетични числа не са точни (0.1 + 0.2). Времената на revisor са цели милисекунди тъкмо за да се избегне това.

Преобразуване (int), за да накараш компилатора да замълчи. Заглушава предупреждението и оставя препълването без съобщение. Преди да го напишеш, попитай се дали целевият тип не трябва да е по-голям.

String.format с десетични числа без Locale. Работи на твоята машина и пропада на чужда. Винаги Locale.ROOT.

switch със синтаксиса с двете точки. Забравен break създава грешки, които компилаторът не показва. Със стрелката целият този клас грешки изчезва.

Връщане на null по навик. Всеки null, който излиза от метод, задължава всички, които го извикват, да си спомнят да проверят. Тук го използваме, защото още нямаме друг инструмент, а Урок 3 го носи.

var там, където типът не се вижда. var r = revisar(s); задължава читателя да иде да търси какво е r. Използвай го, когато типът личи.

Упражнения

Упражнение 1 — Предвиди, после стартирай

Напиши във файл main, който отпечатва тези шест израза, по един на ред, и преди да го изпълниш, отбележи на лист какво мислиш, че ще отпечата всеки:

  1. "142" + 1
  2. 1 + 2 + "3"
  3. "1" + 2 + 3
  4. -7 / 2
  5. 0.1 + 0.2 == 0.3
  6. 'a' + 1

Изпълни го и сравни. Във всеки случай, в който си сгрешил, запиши едно изречение в своя дневник с правилото, което не си имал предвид.

Упражнение 2 — Игнорираната върната стойност

Напиши метод String normalizar(String nombre), който маха интервалите в краищата и превръща текста в малки букви, и го пробвай с " PAGOS ". Напиши го първо по грешния начин (като извикаш strip() и toLowerCase() без да запазиш резултата) и виж какво връща. После го поправи. Може ли да се поправи в един ред?

Упражнение 3 — Два начина да не гръмне

Напиши два метода: boolean vacio(String detalle), който връща true, ако детайлът е null или е празен (само интервали), и boolean contieneError(String detalle), който връща true, ако детайлът не е null и съдържа думата error. Пробвай ги с null, с " ", с "200" и с "error de red". Помисли, преди да пишеш: кой от двата използва && и кой ||? Какво би станало с грешния оператор?

Упражнение 4 — Проектът: една стойност на едно място

В Revisor.java от раздел 2.5 добави facturacion, която е отговорила с код 503 за 95 ms. Направи го на две стъпки. Първо го добави само в codigoDe (и в main, за да се отпечата редът му) и стартирай. Предвиди предварително какъв ред ще излезе. След това го добави и в tiempoDe и стартирай отново. Запиши в дневника какъв проблем има в това да пазиш една и съща информация на две места.

Решения

Решение 1

1421
33
123
-3
false
98
  1. "142" + 1 е текст, залепен с число: + превръща 1 в текст и ги слепва, "1421". Не е сбор.
  2. 1 + 2 + "3" се оценява отляво надясно: първо 1 + 2 (две числа) дава 3 и после 3 + "3" слепва, "33".
  3. "1" + 2 + 3 започва с текст, така че всичко след него се слепва: "123".
  4. -7 / 2 е целочислено деление, което отхвърля десетичната част по посока на нулата: -3, не -4.
  5. 0.1 + 0.2 == 0.3 е false, защото сборът е 0.30000000000000004, както видяхме в 2.1.1.
  6. 'a' + 1 събира char и int; char се превръща в своето число (97) и дава 98. За да получиш текста „a1“, трябва да тръгнеш от текст: "" + 'a' + 1.

Решение 2

Грешната версия, която нищо не променя:

[  PAGOS ]
false

Правилната версия се пише на един ред, като се свържат извикванията, защото всеки метод връща String, върху който може да се извика следващият:

String normalizar(String nombre) {
  return nombre.strip().toLowerCase();
}

С нея изходът е:

[pagos]
true

Решение 3

vacio използва ||: достатъчно е едно от двете неща да е изпълнено. contieneError използва &&: трябва да са изпълнени и двете, като първото пази второто.

boolean vacio(String detalle) {
  return detalle == null || detalle.isBlank();
}

boolean contieneError(String detalle) {
  return detalle != null && detalle.contains("error");
}

Тестовете дават true, true, false за vacio с null, " " и "200", и false, true за contieneError с null и с "error de red". Ако напишеш | вместо || в vacio, при null дясната страна (detalle.isBlank()) се оценява и гърми; с & вместо && в contieneError — същото. И един трети изход, без условие: "sin conexión".equals(detalle) дава false с null detalle, защото литералът отляво никога не е null.

Решение 4

Като facturacion е само в codigoDe, редът, който се появява, е този:

facturacion CAIDO          —  503

Излиза CAIDO по погрешна причина, което е опасността на този дизайн. codigoDe казва 503, но tiempoDe казва null, така че clasificar прилага правило 1 („не е отговорила“), а не 2 („отговорила е с код за грешка“) и времето излиза като черта, макар услугата да е отговорила за 95 ms. Детайлът показва 503, което противоречи на чертата. Няма никаква грешка от компилатора или от JVM. С данните и в tiempoDe правилният ред е:

facturacion CAIDO       95ms  503

Урокът, който отива в дневника: данни, които живеят на две места, накрая казват две различни неща. Това е причината следващият урок да събере кода и времето в една-единствена стойност и причината компилаторът, а не паметта ти, да те задължава да я допълниш.

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

  • java fig02_08.java отпечатва false и true на първите два реда и можеш да обясниш защо, без да гледаш урока.
  • java fig02_07.java показва [ catalogo ] на първия ред и знаеш какво би променил, за да показва [catalogo].
  • Ако махнеш реда default -> null; от codigoDe, стартерът отговаря с the switch expression does not cover all possible input values.
  • Във fig02_12.java виждаш NullPointerException с "<local1>", а с javac -g виждаш името "detalle".
  • От ~/curso-java/revisor/ java Revisor.java отпечатва заглавието и четирите реда на таблицата, с reportes в CAIDO, — и sin conexión.
  • Можеш да обясниш защо 2300 == 2300 дава false с два Integer и true с два int.
  • Упражнения 1 и 3 си ги предвидил преди да изпълниш и си записал в дневника кой от шестте израза на упражнение 1 не си познал.
  • Записал си в дневника кое от съобщенията в този урок ти е било най-трудно за четене.

Обобщение

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

  1. Каква е разликата между int и Integer и кога ти трябва вторият?
  2. Какво става, когато int надхвърли максималната си стойност? Как го избягваш в сметка с милисекунди?
  3. Какъв тип има променлива, декларирана с var, и кога се решава?
  4. Защо linea.strip(); не променя linea? Какво пишеш вместо това?
  5. Какво сравнява == между две референции? Защо armado == literal дава false, а constante == literal дава true?
  6. Защо се подава Locale.ROOT на String.format, когато има десетични числа?
  7. Каква е разликата между && и &, когато лявата страна е лъжа?
  8. Кои две неща отличават switch със стрелки от класическия? Какво става, ако не покрива всички случаи?
  9. Какво значат static и final в една константа?
  10. Как се чете NullPointerException: какво казва преди because и какво след него? Защо понякога се появява <local1>?

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

  • Ръководствата по Java, „Primitive Data Types“: осемте примитивни типа с диапазоните и стойностите им по подразбиране, в официалната документация (на английски, както почти цялата). Посетено на 7 октомври 2026 г.
  • Javadoc на String, Java 25: всички методи на класа, с правилото, че текстовете са константи. Посетено на 7 октомври 2026 г.
  • JEP 361, „Switch Expressions“ и JEP 378, „Text Blocks“: мотивацията и точните правила на две от функциите, използвани в този урок. Посетено на 7 октомври 2026 г.
  • JEP 358, „Helpful NullPointerExceptions“: какво казва всяка част от съобщението и защо липсват имената, когато се компилира без -g. Посетено на 7 октомври 2026 г.
Да обсъдим вашия проект

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