Apuntes · Capítulo 9Programar con tipos
Excepciones y robustez
Excepciones y robustez
Con la semana 8 cerramos la segunda parte del curso: ya saben construir jerarquías de clases, usar interfaces y dejar que el dynamic dispatch elija el método correcto. La tercera parte, programar con tipos, empieza por una pregunta que hasta ahora esquivamos: ¿qué hace un método cuando no puede cumplir lo que promete?
Ya tropezamos con la respuesta varias veces. En la semana 1, a[3] sobre un
arreglo de longitud 3 detuvo el programa con un
ArrayIndexOutOfBoundsException. En la semana 2 apareció el
NullPointerException, y en la semana 3 los constructores empezaron a rechazar
datos inválidos con IllegalArgumentException. Todas son excepciones
(exceptions). Este capítulo explica qué son, cómo se lanzan, se capturan y se
propagan, cómo se leen sus stack traces, y cómo se usan para escribir
programas robustos: que rechazan el dato malo apenas entra, que liberan sus
recursos pase lo que pase y que dejan en claro qué espera y qué garantiza cada
método.
Lectura. Este capítulo acompaña a Eck, secciones 3.7 (3.7.1 y 3.7.2), 8.2 (8.2.2 a 8.2.4), 8.3 Exceptions and try..catch y la subsección 8.4.1 Assertions. El código del capítulo está en
code/week-09y se prueba conmake testdentro del contenedor del curso.
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): Por qué excepciones y
throw.- En clase: en la sesión A, el resto de Lanzar, capturar y propagar, La jerarquía de excepciones y Excepciones propias; en la sesión B, Checked y unchecked, Liberar recursos:
finallyytry-with-resources y Contratos. En el laboratorio, Probar las excepciones.- Después de clase: Capturar para no hacer nada, Excepciones suprimidas, Assertions y Excepciones para lo excepcional, el Resumen y los Ejercicios. Para ampliar, el debate sobre las checked de la nota Lectura de ¿Cuál usar? y las secciones de Eck del principio.
Por qué excepciones
Cómo se avisa un error en C
En la semana 1 escribimos card_pay en C: devolvía 1 si se pudo cobrar y
0 si no alcanzaba el saldo. Es la convención de casi toda la biblioteca de C:
fopen devuelve NULL, malloc devuelve NULL, read devuelve -1 y deja
el motivo en errno. Funciona, pero tiene tres problemas:
- El error se puede ignorar en silencio. Nada obliga a mirar el valor de
retorno.
card_pay(&c, 250);sinifcompila sin una advertencia, y el programa sigue como si el pasaje se hubiera cobrado. - El código normal se mezcla con el de manejo. Cada llamada va seguida de
su
if (r == -1), y la lógica del programa queda enterrada entre verificaciones. - El error tiene que subir a mano. Si la función que detecta el problema no sabe qué hacer, tiene que devolver un código a quien la llamó, que lo devuelve a quien la llamó, y así hasta llegar a alguien que sí sepa. Basta con que una función del camino se olvide de pasarlo para que se pierda.
Además, a veces no queda ningún valor libre para avisar. ¿Qué devuelve una
función que convierte texto a entero si el texto es "12a"? Cualquier int
es un resultado válido.
La idea: lanzar el error
Java resuelve los tres problemas con un mecanismo del lenguaje. Cuando un método detecta que no puede seguir, lanza (throw) un objeto excepción que describe qué pasó. Desde ese momento el método deja de ejecutarse, y la JVM busca, subiendo por las llamadas, un bloque que diga que sabe capturar (catch) ese tipo de excepción. Si no lo encuentra, el programa termina y muestra el error. (En rigor termina el hilo donde ocurrió; un programa con varios hilos sigue mientras quede alguno que no sea daemon. Los programas de este curso tienen un solo hilo, así que para nosotros es lo mismo.) No hay valor de retorno que olvidar.
Integer.parseInt es el ejemplo de la pregunta de arriba:
jshell> int n = Integer.parseInt("12a")
| Exception java.lang.NumberFormatException: For input string: "12a"
| at NumberFormatException.forInputString (NumberFormatException.java:67)
| at Integer.parseInt (Integer.java:565)
| at Integer.parseInt (Integer.java:662)
| at (#1:1)
parseInt no devolvió nada: lanzó una NumberFormatException, con un mensaje
que dice qué texto no se pudo convertir y la lista de llamadas que estaban en
curso.
Idea clave. Una excepción separa dos caminos: el código normal se escribe de corrido, como si todo saliera bien, y el manejo del error va en otro lugar, donde se sepa qué hacer con él. Y un error no se puede ignorar por olvido: o alguien lo captura, o el programa (el hilo, en rigor) termina.
Lanzar, capturar y propagar
throw
Una excepción es un objeto como cualquier otro: se crea con new y se lanza
con throw. La cuenta de la semana 3 ya lo hacía para rechazar montos
inválidos. Esta es la versión de este capítulo,
BankAccount.java:
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("monto no positivo: " + amount);
}
balance += amount;
}
throw termina el método en ese punto, como un return, pero en lugar de
volver normalmente a quien llamó, empieza la búsqueda de un catch. La línea
balance += amount no se ejecuta.
try y catch
Para capturar una excepción, el código que puede lanzarla va dentro de un
bloque try, seguido de uno o más catch que dicen qué tipo capturan:
try {
Money price = Money.parse(text);
IO.println("precio: " + price);
} catch (NumberFormatException e) {
IO.println("no es un precio: " + e.getMessage());
}
Si Money.parse termina bien, el bloque try sigue hasta el final y el
catch se salta. Si lanza una NumberFormatException, el resto del try se
salta (no se imprime "precio: ...") y se ejecuta el catch, con e
apuntando al objeto excepción. Después del catch, el programa sigue con lo
que viene a continuación del bloque completo.
Un catch captura su tipo y todos sus subtipos: es el polimorfismo de la
semana 6 aplicado a los errores. Por eso el orden importa cuando hay varios:
se prueban de arriba abajo y gana el primero que coincide. Los más
específicos van primero. Si dos tipos sin relación entre sí se manejan igual,
se pueden juntar en un solo multi-catch con |:
try {
// ...
} catch (NumberFormatException | IllegalStateException e) {
IO.println("dato inválido: " + e.getMessage());
}
Error común. Poner un
catchgeneral antes que uno específico. Enerrores/CatchOrder.java, elcatch (Exception e)va antes quecatch (IOException e); como todaIOExceptiones unaException, el segundo nunca podría ejecutarse, y el compilador lo rechaza:errores/CatchOrder.java:11: error: exception IOException has already been caught } catch (IOException e) { ^ 1 error
Propagación
¿Qué pasa si el método donde se lanza la excepción no tiene un catch? La
excepción se propaga (propagates): el método termina en ese punto y la
excepción aparece en el método que lo llamó, exactamente en la línea de la
llamada. Si ese método tampoco la captura, también termina, y la excepción
sigue subiendo. Propagation.java tiene
tres niveles de llamadas:
static void charge(BankAccount account, long amount) throws InsufficientFundsException {
IO.println(" charge: retira " + amount);
account.withdraw(amount);
IO.println(" charge: listo"); // no se ejecuta si withdraw lanza
}
static void processOrder(BankAccount account, long total) throws InsufficientFundsException {
IO.println(" processOrder: total " + total);
charge(account, total);
IO.println(" processOrder: pedido pagado");
}
y main hace dos pedidos dentro de un try, con una cuenta de 500 céntimos:
try {
processOrder(account, 300);
processOrder(account, 800);
IO.println("main: no llega aquí");
} catch (InsufficientFundsException e) {
IO.println("main: " + e.getMessage());
IO.println("main: faltan " + (e.requested() - e.balance()) + " céntimos");
}
IO.println("main: saldo final " + account.balance());
$ java -cp build Propagation
processOrder: total 300
charge: retira 300
charge: listo
processOrder: pedido pagado
processOrder: total 800
charge: retira 800
main: saldo insuficiente: hay 200, se pidió 800
main: faltan 600 céntimos
main: saldo final 200
El primer pedido sale completo. En el segundo, withdraw lanza la excepción
porque quedan 200 céntimos; charge no la captura, así que "charge: listo"
no se imprime; processOrder tampoco, así que "pedido pagado" no se
imprime; y el catch de main la captura. El programa sigue y termina
normalmente.


Si nadie la captura: el stack trace
Con el argumento crash, el main de Propagation hace el pedido de 800 sin
try. Nadie captura la excepción, así que sale también de main. Termina el
hilo principal y, como es el único, termina el programa:
$ java -cp build Propagation crash
processOrder: total 800
charge: retira 800
Exception in thread "main" InsufficientFundsException: saldo insuficiente: hay 500, se pidió 800
at BankAccount.withdraw(BankAccount.java:24)
at Propagation.charge(Propagation.java:7)
at Propagation.processOrder(Propagation.java:13)
at Propagation.main(Propagation.java:21)
$ echo $?
1
Ese texto es el stack trace, y es la herramienta de depuración más útil que tienen. Se lee así:
- La primera línea dice qué pasó: el tipo de la excepción y su mensaje.
- Cada línea
ates un método que estaba en curso, con el archivo y el número de línea. La de arriba es donde se lanzó la excepción (BankAccount.java, línea 24, elthrow); cada una de las siguientes es la línea desde donde se llamó a la anterior. - La de abajo es el punto de entrada,
main.
Para encontrar un error, busquen la primera línea at que esté en su
código. Si la excepción salió de la biblioteca de Java (como el
Integer.parseInt de arriba), las primeras líneas son de la biblioteca, y la
pregunta es qué le pasó su código para que fallara.
En Java. El programa termina con código de salida 1, como un programa de C que hace
exit(1). Eso pasa porquemainera el único hilo: con otros hilos no daemon todavía corriendo, la JVM los espera. ElMakefilede la semana lo usa:make testejecutaPropagation crashy verifica que falle.
La jerarquía de excepciones
Todo lo que se puede lanzar es un Throwable. Debajo hay dos ramas:
Error: fallas graves de la JVM, como quedarse sin memoria (OutOfMemoryError) o una recursión sin fin (StackOverflowError). Un programa normal no las captura: no hay nada razonable que hacer.Exception: las condiciones que un programa sí puede querer manejar. Dentro de ella,RuntimeExceptiony sus subclases son los errores de programación:NullPointerException,IllegalArgumentException,IllegalStateException,ArrayIndexOutOfBoundsException…
Fíjense en que NumberFormatException es una IllegalArgumentException: el
argumento de parseInt no tenía el formato correcto. Un
catch (IllegalArgumentException e) también la captura.
Los métodos más útiles de cualquier excepción están en Throwable:
getMessage() devuelve el mensaje, getCause() la causa (más abajo) y
printStackTrace() imprime el stack trace sin terminar el programa.
Excepciones propias
Las excepciones de la biblioteca describen problemas generales. Para los del
dominio conviene una clase propia, con un nombre que diga qué pasó y con los
datos del error. InsufficientFundsException.java:
public class InsufficientFundsException extends Exception {
// Throwable es Serializable; -Xlint:all pide declarar la versión.
private static final long serialVersionUID = 1L;
private final long balance;
private final long requested;
public InsufficientFundsException(long balance, long requested) {
super("saldo insuficiente: hay " + balance + ", se pidió " + requested);
this.balance = balance;
this.requested = requested;
}
public long balance() {
return balance;
}
public long requested() {
return requested;
}
}
Tres decisiones:
- Extiende
Exception, así que es checked: la sección siguiente explica qué significa y por qué aquí tiene sentido. - El mensaje se arma en el constructor con
super(...), el constructor deExceptionque recibe el mensaje (la semana 5 en acción). Así todos los que la lanzan dan el mismo mensaje. - Guarda los datos del error en campos con getters. El
catchdemainenPropagationcalcula cuánto falta cone.requested() - e.balance(), sin tener que sacar números del texto del mensaje.
En Java.
ThrowableimplementaSerializable(la serialización se ve en la semana 15), yjavac -Xlint:alladvierte si una subclase no declara el camposerialVersionUID. Con-Werror, la advertencia detiene la compilación. Basta con la líneaprivate static final long serialVersionUID = 1L;.
withdraw la lanza cuando el monto supera el saldo, antes de modificar
nada:
public void withdraw(long amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("monto no positivo: " + amount);
}
if (amount > balance) {
throw new InsufficientFundsException(balance, amount);
}
balance -= amount;
assert balance >= 0 : "invariante roto: " + balance;
}
Así, si la operación falla, la cuenta queda exactamente como estaba. El
assert del final se explica más abajo.
Encadenar excepciones: la causa
A veces el error aparece en un nivel bajo, con una excepción que no dice nada
del dominio. PriceReader.java lee una
lista de precios con líneas código;precio y usa Money.parse, que con un
texto como "4,90" (con coma) lanza una NumberFormatException. Quien llamó
a PriceReader.read querría saber algo más útil: en qué línea está el
precio malo. La solución es capturar la excepción de bajo nivel y lanzar una
del dominio que la lleve adentro como causa (cause):
try {
prices.add(Money.parse(price));
} catch (NumberFormatException e) {
// Se envuelve el error de bajo nivel en uno del dominio, sin perderlo.
throw new InvalidPriceException(i + 1, price, e);
}
El constructor de InvalidPriceException pasa la causa a super(mensaje, causa). ChainDemo.java lee tres líneas,
la tercera con coma, e imprime lo que trae la excepción:
$ java -cp build ChainDemo
mensaje: línea 3: precio inválido "4,90"
línea: 3
causa: java.lang.NumberFormatException: For input string: "4,90"
InvalidPriceException: línea 3: precio inválido "4,90"
at PriceReader.read(PriceReader.java:16)
at ChainDemo.main(ChainDemo.java:9)
Caused by: java.lang.NumberFormatException: For input string: "4,90"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Long.parseLong(Long.java:594)
at java.base/java.lang.Long.parseLong(Long.java:698)
at Money.parse(Money.java:19)
at PriceReader.read(PriceReader.java:13)
... 1 more
El stack trace tiene dos partes: la excepción del dominio, y después de
Caused by: la original, con su propio recorrido hasta Money.parse, línea
19. ... 1 more significa que la última línea es la misma que la de arriba
(ChainDemo.main) y se omite.
Error común. Envolver sin pasar la causa:
throw new InvalidPriceException(i + 1, price, null), o lanzar la nueva excepción con un mensaje que solo copiae.getMessage(). El mensaje sobrevive, pero el stack trace de abajo se pierde, y con él la línea deMoney.parsedonde empezó todo.
Capturar para no hacer nada
Error común. El
catchvacío:try { account.withdraw(amount); } catch (Exception e) { }Compila, y el error desaparece. El retiro no se hizo, nadie se enteró, y el programa sigue como si se hubiera hecho. Es peor que no capturar: sin el
try, al menos el programa se detenía con un stack trace que decía qué pasó. Un pariente cercano es capturar solo para imprimir (e.printStackTrace();) y seguir como si nada. Regla: capturen una excepción solo donde puedan hacer algo con ella (reintentar, pedir otro dato, usar un valor por defecto que tenga sentido, avisar al usuario y cancelar la operación). Si no pueden, déjenla pasar.
Checked y unchecked
Java divide las excepciones en dos clases, y el compilador las trata distinto:
- Las checked son las subclases de
Exceptionque no sonRuntimeException. Si un método puede lanzar una, el compilador obliga a quien lo llama a hacer una de dos cosas: capturarla contry/catcho declararla en su encabezado conthrows, para que la responsabilidad pase a quien lo llame a él. - Las unchecked son
RuntimeException,Errory sus subclases. El compilador no exige nada.
withdraw declara throws InsufficientFundsException. En Propagation,
charge y processOrder también lo declaran, porque no la capturan, y el
main la captura. Si alguien llama a withdraw y no hace ninguna de las dos
cosas (errores/Unreported.java):
errores/Unreported.java:5: error: unreported exception InsufficientFundsException; must be caught or declared to be thrown
account.withdraw(100);
^
1 error
Es la diferencia con los códigos de error de C: aquí el compilador no deja olvidar el caso.
¿Cuál usar?
El criterio que usa la propia biblioteca de Java:
- Checked para fallas que no dependen de un error del programador y que quien llama puede esperar y manejar: un archivo que no existe, una red que no responde, un retiro que excede el saldo, una línea con un precio mal escrito en un archivo que llenó una persona. El programa correcto también se encuentra con estas situaciones, y el compilador obliga a pensar en ellas.
- Unchecked para errores de programación: una precondición violada
(un monto negativo, un índice fuera del arreglo), un
nulldonde no se permite, un objeto usado en un estado en que no se puede usar. La solución no es capturarlas: es corregir el código que llamó mal.
withdraw usa las dos: un monto no positivo es un error de quien llamó
(IllegalArgumentException, unchecked), y un saldo insuficiente es una
situación normal del negocio que el que llama debe manejar
(InsufficientFundsException, checked).
Lectura. El debate sobre las checked es viejo: muchos lenguajes posteriores no las tienen. dev.java lo resume en Unchecked Exceptions — The Controversy, y Eck da su criterio en la subsección 8.3.4.
throws y las reglas de override
Una consecuencia de que el compilador verifique las checked: al hacer
override de un método, la subclase no puede agregar excepciones checked
que el método de la superclase no declaraba. Ojo: la cláusula throws no es
parte de la firma, que es solo el nombre y los tipos de los parámetros
(semana 5). Es parte del contrato declarado del método, y el override
tiene que respetarlo
(errores/OverrideThrows.java):
class Report {
String render() {
return "reporte";
}
}
class FileReport extends Report {
@Override
String render() throws IOException {
throw new IOException("no se encontró la plantilla");
}
}
errores/OverrideThrows.java:12: error: render() in FileReport cannot override render() in Report
String render() throws IOException {
^
overridden method does not throw IOException
1 error
Es el principio de sustitución de Liskov de la semana 7: un código que llama
a report.render() con un Report no está preparado para una
IOException, y si le llega un FileReport en su lugar, recibiría una
excepción que su contrato no anunciaba. La subclase puede declarar menos
excepciones, o subtipos de las declaradas, pero no más.
Error común. Declarar
throws Exceptionen todos los métodos para que el compilador deje de quejarse. Se pierde toda la información: quien llama ya no sabe qué puede fallar y tiene que capturarException, que también captura losNullPointerExceptiony demás errores de programación. Declaren los tipos concretos.
Liberar recursos: finally y try-with-resources
Algunos objetos tienen que cerrarse cuando se termina de usarlos: un archivo abierto, una conexión de red, una conexión a una base de datos. Si se olvida, el recurso queda tomado hasta que el programa termine (o hasta que el sistema operativo se niegue a abrir más). Con excepciones hay un problema extra: si algo lanza entre la apertura y el cierre, la línea del cierre nunca se ejecuta.
finally
Un bloque finally se ejecuta siempre al salir del try: si terminó
bien, si lanzó una excepción (capturada o no) e incluso si hizo return. La
única salvedad es que la JVM se detenga en medio: si el try llama a
System.exit o a Runtime.halt, o el proceso muere, no hay salida del try y el
finally no corre. El
caso 4 de ResourceDemo.java:
try {
IO.println("en el try");
return;
} finally {
IO.println("finally se ejecuta aunque haya return");
}
-- 4. finally
en el try
finally se ejecuta aunque haya return
Antes de Java 7 los recursos se cerraban así, en un finally. Funciona, pero
con dos recursos el código se llena de try anidados y de if (x != null).
try-with-resources
La forma moderna declara los recursos entre paréntesis después del try.
Al salir del bloque, por la razón que sea, Java llama a close() en cada uno,
en orden inverso al de apertura. Sirve cualquier clase que implemente la
interfaz AutoCloseable, que tiene un solo método, close().
Gate.java es un recurso de juguete que solo
avisa cuándo se abre, se usa y se cierra:
public class Gate implements AutoCloseable {
// ...
public void use(boolean fail) {
IO.println("usa " + name);
if (fail) {
throw new IllegalStateException("falla al usar " + name);
}
}
@Override
public void close() {
IO.println("cierra " + name);
// ...
}
}
En el caso 2 de ResourceDemo, el bloque lanza una excepción:
try (Gate a = new Gate("A", false); Gate b = new Gate("B", false)) {
a.use(false);
b.use(true);
IO.println("no llega aquí");
} catch (IllegalStateException e) {
IO.println("capturada: " + e.getMessage());
}
-- 2. el bloque lanza
abre A
abre B
usa A
usa B
cierra B
cierra A
capturada: falla al usar B
Los dos recursos se cerraron, B antes que A, y antes de que corriera el
catch: cuando el catch se ejecuta, los recursos del try ya están
cerrados.


¿Por qué en orden inverso? Porque el segundo recurso puede depender del primero (un lector abierto sobre un archivo abierto): hay que cerrar primero lo que se abrió encima.
Excepciones suprimidas
¿Y si close() también lanza? En el caso 3, el bloque lanza una excepción y
el close() de A lanza otra. Java no puede propagar las dos, así que
propaga la primera, la del bloque, que es la que explica el problema, y
le adjunta la de close() como excepción suprimida (suppressed), que se
consulta con getSuppressed():
try (Gate a = new Gate("A", true)) {
a.use(true);
} catch (IllegalStateException e) {
IO.println("capturada: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
IO.println("suprimida: " + s.getMessage());
}
}
-- 3. el bloque lanza y close también
abre A
usa A
cierra A
capturada: falla al usar A
suprimida: falla al cerrar A
Con un finally escrito a mano, la excepción de close() habría
reemplazado a la del bloque, y el error original se habría perdido.
Idea clave. Todo objeto que haya que cerrar se abre en un
try-with-resources. El cierre queda garantizado, en el orden correcto, y ninguna excepción se pierde.
Contratos
En la semana 3 hablamos de invariantes: condiciones sobre los campos que valen siempre. Un método también tiene un contrato con quien lo llama:
- Precondiciones: lo que tiene que valer al entrar al método. La
responsabilidad es de quien llama.
deposit(amount)exigeamount > 0. - Postcondiciones: lo que el método garantiza al salir, si se
cumplieron las precondiciones. Después de
deposit(amount), el saldo aumentó exactamente enamount. - Invariantes de la clase: lo que vale antes y después de cada método
público. El saldo de
BankAccountnunca es negativo.
Verificar precondiciones: fail fast
Un método público no sabe quién lo va a llamar, así que verifica sus precondiciones y lanza una excepción unchecked si no se cumplen. La biblioteca ofrece tres tipos estándar, y conviene usarlos en lugar de inventar otros:
| Excepción | Cuándo |
|---|---|
IllegalArgumentException |
Un argumento tiene un valor inválido: un monto negativo, un texto vacío. |
IllegalStateException |
El argumento está bien, pero el objeto no está en un estado que lo permita: retirar de una cuenta cerrada, sacar de un stack vacío. |
NullPointerException |
Un argumento es null y no debería. Se lanza con Objects.requireNonNull(x, "nombre"). |
El constructor de BankAccount usa la tercera:
public BankAccount(String id) {
this.id = Objects.requireNonNull(id, "id");
}
requireNonNull devuelve el mismo valor si no es null, así que verifica y
asigna en una línea.
¿Por qué verificar? Porque sin la verificación, el null se guarda en el
campo sin quejarse, y el NullPointerException aparece mucho después, cuando
alguien llama a id().length(), en otra parte del programa y con un stack
trace que no dice de dónde vino el null. Es el principio de fail
fast: rechazar el dato malo donde entra, no tres llamadas
después. El stack trace apunta entonces al culpable.
Assertions
Las precondiciones de un método público se verifican siempre, porque
dependen de quien llama. Pero hay condiciones que dependen solo del propio
código: si la clase está bien escrita, nunca pueden fallar. Para esas
existe assert:
assert condición : "mensaje si falla";
Si la condición es falsa, se lanza un AssertionError (un Error: no está
pensado para capturarse). La particularidad es que las assertions están
desactivadas por defecto, y solo se evalúan si la JVM arranca con -ea
(enable assertions). AssertDemo.java
reparte un monto en partes casi iguales y tiene un error a propósito:
static long[] split(long total, int parts) {
if (parts <= 0) {
throw new IllegalArgumentException("parts debe ser positivo: " + parts);
}
long[] shares = new long[parts];
Arrays.fill(shares, total / parts); // error: se pierde el resto
assert Arrays.stream(shares).sum() == total : "el reparto no suma " + total;
return shares;
}
$ java -cp build AssertDemo
reparto de 1000 en 3: [333, 333, 333]
$ java -ea -cp build AssertDemo
Exception in thread "main" java.lang.AssertionError: el reparto no suma 1000
at AssertDemo.split(AssertDemo.java:14)
at AssertDemo.main(AssertDemo.java:19)
Sin -ea, el programa reparte 999 céntimos y nadie se entera. Con -ea, la
postcondición falla en el punto exacto. (Arrays.stream(...).sum() suma el
arreglo; los streams se ven en la semana 12.)
Fíjense en la división del trabajo: parts <= 0 es una precondición, y se
verifica siempre con IllegalArgumentException. Que el reparto sume el total
es una postcondición que depende solo de este método, y se verifica con
assert.
Error común. Usar
assertpara validar argumentos de un método público, o poner dentro delassertalgo que tenga efectos (assert list.remove(x)). Sin-eala línea completa no se ejecuta: la validación desaparece en producción y, en el segundo caso, también elremove.
En Java.
make test RUNFLAGS=-eacorre las pruebas JUnit de la semana con las assertions activadas. Es buena idea correr las pruebas siempre así: las assertions internas se vuelven pruebas gratis.
Excepciones para lo excepcional
Error común. Usar excepciones para el flujo normal del programa:
int i = 0; try { while (true) { total += prices[i++]; } } catch (ArrayIndexOutOfBoundsException e) { // se terminó el arreglo }Funciona, pero se lee mal (el final del bucle está escondido en el
catch), es más lento, y si dentro del bucle hubiera un error de índice de verdad, estecatchlo escondería. Si una condición se puede preguntar antes (i < prices.length,list.isEmpty()), se pregunta.
Probar las excepciones
Una prueba que solo cubre el happy path está a medias: los casos de error
también son comportamiento, y también se rompen. En la semana 3 usamos
assertThrows como receta. Además de verificar que se lanza la excepción
correcta, devuelve la excepción, y con ella se puede verificar el mensaje
y los datos (BankAccountTest.java):
@Test
void withdrawTooMuchKeepsDataAndBalance() {
BankAccount account = new BankAccount("C-01");
account.deposit(500);
InsufficientFundsException e =
assertThrows(InsufficientFundsException.class, () -> account.withdraw(800));
assertEquals(500, e.balance());
assertEquals(800, e.requested());
assertEquals(500, account.balance()); // el saldo no cambió
}
La última línea verifica algo que suele olvidarse: que la operación fallida
no dejó el objeto a medias. En
PriceReaderTest.java, la prueba de
la línea mala verifica además que la causa sea la excepción original, con
assertInstanceOf(NumberFormatException.class, e.getCause()).
Cuando el método probado declara una excepción checked y el caso que se
prueba no debería lanzarla, el método de prueba también tiene que declararla:
void withdrawWithEnoughBalance() throws InsufficientFundsException. Si se
lanzara, JUnit marcaría la prueba como fallida y mostraría el stack trace.
El código de la semana y make test
$ cd code/week-09
$ make test
...
== errores/CatchOrder.java (no debe compilar)
errores/CatchOrder.java:11: error: exception IOException has already been caught
...
== Propagation crash (debe terminar con error)
...
== AssertDemo con -ea (debe terminar con error)
Exception in thread "main" java.lang.AssertionError: el reparto no suma 1000
...
[ 9 tests successful ]
[ 0 tests failed ]
make test corre las demostraciones, comprueba que los tres archivos de
errores/ no compilen y que Propagation crash y AssertDemo con -ea
terminen con error, y al final corre las nueve pruebas JUnit.
Resumen
- Un método que no puede cumplir lo que promete lanza una excepción con
throw. El método termina y la excepción se propaga hacia quien lo llamó, hasta uncatchde su tipo (o de un supertipo). Si nadie la captura, el hilo termina con un stack trace; en un programa de un solo hilo, termina el programa. - El stack trace se lee de arriba abajo: el tipo y el mensaje, la línea donde
se lanzó, y las llamadas que llevaron hasta ahí.
Caused by:muestra la causa. - Los
catchse prueban en orden; los específicos van primero. El multi-catch|junta tipos que se manejan igual. Throwablese divide enError(no se captura) yException.RuntimeExceptiony sus subclases son unchecked; las demás excepciones son checked, y el compilador obliga a capturarlas o a declararlas conthrows.- Checked para fallas que quien llama puede esperar y manejar; unchecked para errores de programación. Una subclase no puede agregar checked al hacer override.
- Las excepciones propias extienden
ExceptionoRuntimeException, tienen un mensaje útil y guardan los datos del error. Al envolver una excepción de bajo nivel, se pasa como causa. try-with-resources cierra todoAutoCloseable, siempre y en orden inverso; siclose()también falla, su excepción queda suprimida.finallyse ejecuta al salir deltryde cualquier forma, salvo que la JVM se detenga antes (System.exit).- Precondiciones con
IllegalArgumentException,IllegalStateExceptionyObjects.requireNonNull, al entrar al método: fail fast.assertpara lo que solo puede fallar si la propia clase tiene un error; se activa conjava -ea. - Nunca un
catchvacío, ni excepciones para el flujo normal, nithrows Exceptionpor comodidad.
Ejercicios
-
Sin ejecutarlo, digan qué imprime este fragmento si
textvale"15", y qué imprime si vale"quince". Después compruébenlo enjshell.try { IO.println("A"); int n = Integer.parseInt(text); IO.println("B " + n); } catch (NumberFormatException e) { IO.println("C"); } finally { IO.println("D"); } IO.println("E"); -
En
Propagation.java, agreguen untry/catchenprocessOrderque capture la excepción, imprima"pedido rechazado"y no la vuelva a lanzar. Antes de ejecutarlo, escriban la salida que esperan. ¿Qué dice el compilador delcatchdemain? ¿Qué métodos pueden dejar de declararthrows? -
Ejecuten
java -cp build ChainDemodespués de cambiar la tercera línea de la lista a"A-03;4.9"y luego a"A-03"(sin precio). Lean cada stack trace e indiquen qué línea de qué archivo lanzó la excepción original en cada caso. -
Money.parse("12.-5")no lanza ninguna excepción. ¿Qué devuelve? Escriban una prueba enMoneyTestque muestre el error, véanla fallar, y después corrijanparsepara que la prueba pase. -
Para cada situación, decidan si usarían una excepción checked o unchecked, y cuál de la biblioteca (o una propia): (a) el archivo de configuración no existe; (b) se pide el elemento de la posición −1 de una lista; (c) se intenta vender un producto con stock 0; (d) se llama a
start()en un cronómetro que ya está corriendo; (e) el código de producto recibido esnull. Justifiquen cada una en una línea. -
Escriban una clase
Thermostatcon un métodosetTarget(double celsius)que acepte solo valores entre 10 y 30, y lanceIllegalArgumentExceptioncon un mensaje que incluya el valor recibido. Escriban sus pruebas JUnit, incluidos los dos bordes (10 y 30) y un valor fuera de cada lado, y verifiquen el mensaje. -
Cambien
InsufficientFundsExceptionpara que extiendaRuntimeException. ¿Qué partes decode/week-09dejan de necesitarthrows? ¿Qué se gana y qué se pierde? Después devuélvanla a como estaba. -
Agreguen un tercer recurso
Gate cal caso 2 deResourceDemo(úsenlo dentro del bloque, o-Xlint:allse queja) y hagan que suclose()también falle. Antes de ejecutarlo, escriban la salida completa y qué devuelvegetSuppressed(). Expliquen por qué la excepción que llega alcatches la del bloque y no la de unclose(). -
Reescriban el caso 2 de
ResourceDemosintry-with-resources, contry/finallyanidados, de modo que la salida sea la misma. Comparen la longitud y la claridad de las dos versiones. -
Corrijan
splitenAssertDemopara que el resto se reparta entre las primeras partes (1000 en 3 debe dar[334, 333, 333]). Verifiquen que con-eaya no falla. ¿Por qué la verificaciónparts <= 0no es unassert? -
Encuentren el problema en este método y reescríbanlo:
public static int readAge(String text) { try { return Integer.parseInt(text); } catch (Exception e) { return 0; } } -
Lean la subsección 8.3.5 de Eck, Programming with Exceptions. ¿Cuándo dice que conviene lanzar una excepción, y cómo decide si una excepción propia extiende
ExceptionoRuntimeException? Comparen su criterio con el de este capítulo y propongan dos excepciones propias para Bodega, una de cada tipo, con su justificación.