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-09 y se prueba con make test dentro 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: finally y try-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); sin if compila 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 catch general antes que uno específico. En errores/CatchOrder.java, el catch (Exception e) va antes que catch (IOException e); como toda IOException es una Exception, 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.

Animación: a la izquierda, el call stack con main abajo y processOrder, charge y withdraw encima. withdraw lanza una InsufficientFundsException, representada por un punto rojo que baja frame por frame: withdraw, charge y processOrder quedan descartados sin terminar, y el punto llega a main, que se pone verde porque su catch la captura. A la derecha, la salida: las líneas "charge: listo" y "processOrder: pedido pagado" aparecen tachadas porque no se ejecutan, y al final aparecen los mensajes de mainAnimación: a la izquierda, el call stack con main abajo y processOrder, charge y withdraw encima. withdraw lanza una InsufficientFundsException, representada por un punto rojo que baja frame por frame: withdraw, charge y processOrder quedan descartados sin terminar, y el punto llega a main, que se pone verde porque su catch la captura. A la derecha, la salida: las líneas "charge: listo" y "processOrder: pedido pagado" aparecen tachadas porque no se ejecutan, y al final aparecen los mensajes de main
La excepción baja por el call stack hasta el primer método que la captura. Los métodos que atraviesa terminan sin ejecutar lo que les faltaba.

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í:

  1. La primera línea dice qué pasó: el tipo de la excepción y su mensaje.
  2. Cada línea at es 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, el throw); cada una de las siguientes es la línea desde donde se llamó a la anterior.
  3. 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 porque main era el único hilo: con otros hilos no daemon todavía corriendo, la JVM los espera. El Makefile de la semana lo usa: make test ejecuta Propagation crash y 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, RuntimeException y sus subclases son los errores de programación: NullPointerException, IllegalArgumentException, IllegalStateException, ArrayIndexOutOfBoundsException…
Árbol de clases: Throwable arriba, con dos hijos, Error y Exception. Bajo Error, en gris: OutOfMemoryError, StackOverflowError y AssertionError. Bajo Exception, en dorado: IOException y las dos excepciones propias de la semana, InsufficientFundsException e InvalidPriceException. También bajo Exception, en azul, RuntimeException, con IllegalArgumentException (y su subclase NumberFormatException), IllegalStateException y NullPointerException. La leyenda dice que las doradas son checked, las azules unchecked y las grises son ErrorÁrbol de clases: Throwable arriba, con dos hijos, Error y Exception. Bajo Error, en gris: OutOfMemoryError, StackOverflowError y AssertionError. Bajo Exception, en dorado: IOException y las dos excepciones propias de la semana, InsufficientFundsException e InvalidPriceException. También bajo Exception, en azul, RuntimeException, con IllegalArgumentException (y su subclase NumberFormatException), IllegalStateException y NullPointerException. La leyenda dice que las doradas son checked, las azules unchecked y las grises son Error
Las excepciones checked (doradas) son las subclases de Exception que no pasan por RuntimeException. Las unchecked (azules) son RuntimeException y sus subclases.

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 de Exception que 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 catch de main en Propagation calcula cuánto falta con e.requested() - e.balance(), sin tener que sacar números del texto del mensaje.

En Java. Throwable implementa Serializable (la serialización se ve en la semana 15), y javac -Xlint:all advierte si una subclase no declara el campo serialVersionUID. Con -Werror, la advertencia detiene la compilación. Basta con la línea private 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.

Arriba, en el heap, un objeto InvalidPriceException con los campos message, line = 3 y cause; desde cause sale una flecha a un objeto NumberFormatException, cuyo campo cause es null. Abajo, el stack trace impreso: las tres primeras líneas, marcadas en rojo, corresponden a InvalidPriceException; las que empiezan en "Caused by:", marcadas en azul, a la NumberFormatExceptionArriba, en el heap, un objeto InvalidPriceException con los campos message, line = 3 y cause; desde cause sale una flecha a un objeto NumberFormatException, cuyo campo cause es null. Abajo, el stack trace impreso: las tres primeras líneas, marcadas en rojo, corresponden a InvalidPriceException; las que empiezan en "Caused by:", marcadas en azul, a la NumberFormatException
La excepción del dominio guarda la original en su campo cause. El stack trace muestra las dos, y así no se pierde dónde empezó el problema.

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 copia e.getMessage(). El mensaje sobrevive, pero el stack trace de abajo se pierde, y con él la línea de Money.parse donde empezó todo.

Capturar para no hacer nada

Error común. El catch vací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 Exception que no son RuntimeException. Si un método puede lanzar una, el compilador obliga a quien lo llama a hacer una de dos cosas: capturarla con try/catch o declararla en su encabezado con throws, para que la responsabilidad pase a quien lo llame a él.
  • Las unchecked son RuntimeException, Error y 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 null donde 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 Exception en 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 capturar Exception, que también captura los NullPointerException y 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.

Animación: a la izquierda, el código del caso 2 con la línea en ejecución resaltada, y debajo la salida. A la derecha, los recursos abiertos: Gate A y, encima, Gate B. Cuando b.use(true) lanza una IllegalStateException, primero se cierra Gate B (marcado con 1) y después Gate A (marcado con 2); recién entonces se resalta la línea del catch y aparece "capturada: falla al usar B"Animación: a la izquierda, el código del caso 2 con la línea en ejecución resaltada, y debajo la salida. A la derecha, los recursos abiertos: Gate A y, encima, Gate B. Cuando b.use(true) lanza una IllegalStateException, primero se cierra Gate B (marcado con 1) y después Gate A (marcado con 2); recién entonces se resalta la línea del catch y aparece "capturada: falla al usar B"
Los recursos se abren en el orden en que se declaran y se cierran al revés, siempre, antes de que se ejecute el catch.

¿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) exige amount > 0.
  • Postcondiciones: lo que el método garantiza al salir, si se cumplieron las precondiciones. Después de deposit(amount), el saldo aumentó exactamente en amount.
  • Invariantes de la clase: lo que vale antes y después de cada método público. El saldo de BankAccount nunca 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 assert para validar argumentos de un método público, o poner dentro del assert algo que tenga efectos (assert list.remove(x)). Sin -ea la línea completa no se ejecuta: la validación desaparece en producción y, en el segundo caso, también el remove.

En Java. make test RUNFLAGS=-ea corre 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, este catch lo 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 un catch de 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 catch se prueban en orden; los específicos van primero. El multi-catch | junta tipos que se manejan igual.
  • Throwable se divide en Error (no se captura) y Exception. RuntimeException y sus subclases son unchecked; las demás excepciones son checked, y el compilador obliga a capturarlas o a declararlas con throws.
  • 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 Exception o RuntimeException, 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 todo AutoCloseable, siempre y en orden inverso; si close() también falla, su excepción queda suprimida. finally se ejecuta al salir del try de cualquier forma, salvo que la JVM se detenga antes (System.exit).
  • Precondiciones con IllegalArgumentException, IllegalStateException y Objects.requireNonNull, al entrar al método: fail fast. assert para lo que solo puede fallar si la propia clase tiene un error; se activa con java -ea.
  • Nunca un catch vacío, ni excepciones para el flujo normal, ni throws Exception por comodidad.

Ejercicios

  1. Sin ejecutarlo, digan qué imprime este fragmento si text vale "15", y qué imprime si vale "quince". Después compruébenlo en jshell.

    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");
  2. En Propagation.java, agreguen un try/catch en processOrder que 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 del catch de main? ¿Qué métodos pueden dejar de declarar throws?

  3. Ejecuten java -cp build ChainDemo despué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.

  4. Money.parse("12.-5") no lanza ninguna excepción. ¿Qué devuelve? Escriban una prueba en MoneyTest que muestre el error, véanla fallar, y después corrijan parse para que la prueba pase.

  5. 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 es null. Justifiquen cada una en una línea.

  6. Escriban una clase Thermostat con un método setTarget(double celsius) que acepte solo valores entre 10 y 30, y lance IllegalArgumentException con 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.

  7. Cambien InsufficientFundsException para que extienda RuntimeException. ¿Qué partes de code/week-09 dejan de necesitar throws? ¿Qué se gana y qué se pierde? Después devuélvanla a como estaba.

  8. Agreguen un tercer recurso Gate c al caso 2 de ResourceDemo (úsenlo dentro del bloque, o -Xlint:all se queja) y hagan que su close() también falle. Antes de ejecutarlo, escriban la salida completa y qué devuelve getSuppressed(). Expliquen por qué la excepción que llega al catch es la del bloque y no la de un close().

  9. Reescriban el caso 2 de ResourceDemo sin try-with-resources, con try/finally anidados, de modo que la salida sea la misma. Comparen la longitud y la claridad de las dos versiones.

  10. Corrijan split en AssertDemo para que el resto se reparta entre las primeras partes (1000 en 3 debe dar [334, 333, 333]). Verifiquen que con -ea ya no falla. ¿Por qué la verificación parts <= 0 no es un assert?

  11. 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;
        }
    }
  12. 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 Exception o RuntimeException? Comparen su criterio con el de este capítulo y propongan dos excepciones propias para Bodega, una de cada tipo, con su justificación.