Excepciones y Optional

Java es casi el único lenguaje que distingue entre excepciones que el compilador te obliga a tratar y las que no. Esta lección explica cuál usar, cómo cerrar recursos sin acordarte, y cómo dejar de devolver null.

Dos familias de excepciones

Esta división es propia de Java y no la tiene casi ningún otro lenguaje. Entenderla evita discusiones estériles:

Comprobadas No comprobadas
Heredan de Exception RuntimeException
El compilador te obliga a tratarlas o declararlas no dice nada
Representan algo que puede salir mal y se puede prever un error de programación
Ejemplos IOException, SQLException NullPointerException, NumberFormatException
class SaldoInsuficiente extends Exception {          // comprobada: obliga a tratarla
    private final int faltan;
    SaldoInsuficiente(int faltan) { super("faltan " + faltan + " €"); this.faltan = faltan; }
    int faltan() { return faltan; }
}

void retirar(int saldo, int cantidad) throws SaldoInsuficiente {
    if (cantidad > saldo) throw new SaldoInsuficiente(cantidad - saldo);
}
salidacomprobada: faltan 30 € (faltan 30)
  no comprobada: For input string: "hola"

Fíjate en que la excepción propia lleva un dato, el faltan(). Eso es lo que distingue una excepción útil de un mensaje de texto: quien la recibe puede decidir con ella, no solo imprimirla.

Cuál usar. La regla práctica: si quien llama puede hacer algo sensato al respecto —reintentar, pedir otro dato, avisar al usuario—, comprobada. Si es un fallo del programa que hay que arreglar en el código, no comprobada. En la duda, no comprobada: las comprobadas se propagan por todas las firmas y acaban obligando a escribir catch vacíos, que es el peor resultado posible.

try, catch, finally

try {
    return "vale " + Integer.parseInt(s);
} catch (NumberFormatException e) {
    return "no es un número";
} catch (RuntimeException e) {
    return "otro problema: " + e.getClass().getSimpleName();
} finally {
    // se ejecuta siempre, incluso tras el return
}
salidavale 7
  no es un número
  no es un número

Dos reglas sobre los catch: van de lo más concreto a lo más general —si pones RuntimeException primero, el de abajo no compila por inalcanzable—, y varios tipos se agrupan con |: catch (IOException | SQLException e).

La tercera línea de la salida tiene miga: probar(null) no da NullPointerException, da «no es un número». Resulta que Integer.parseInt(null) lanza NumberFormatException, no NPE. No es lo que uno supondría, y es justo el tipo de cosa que solo se descubre ejecutándolo.

try-with-resources

Todo lo que haya que cerrar —archivos, conexiones, sockets— se abre así:

try (BufferedReader r = Files.newBufferedReader(p)) {
    IO.println("primera línea: " + r.readLine());
}   // se cierra solo, pase lo que pase
salidaprimera línea: una línea
  el archivo existe todavía: true

Lo que va entre paréntesis se cierra automáticamente al salir del bloque, haya excepción o no. Es la versión corta y correcta del viejo finally { if (r != null) r.close(); }, que además tenía la pega de que un fallo al cerrar podía tapar la excepción original. Vale para cualquier objeto que implemente AutoCloseable, incluidos los tuyos.

Envolver sin perder la pista

try { Integer.parseInt("x"); }
catch (NumberFormatException e) { throw new IllegalStateException("configuración inválida", e); }
salidaconfiguración inválida  <- causado por: java.lang.NumberFormatException: For input string: "x"

Al relanzar una excepción con otra más adecuada al nivel en el que estás, pásale siempre la original como segundo argumento. Eso la guarda como «causa» y hace que aparezca en la traza con un Caused by:. Sin eso, el error real desaparece y quien lo depure tendrá un mensaje genérico y ninguna pista.

Lo que nunca hay que hacer es un catch vacío, o uno que solo imprima. Tragarse una excepción no hace que el problema desaparezca: lo esconde hasta que reaparece más lejos y más raro. Si de verdad no hay nada que hacer, lo honesto es dejarla subir.

Optional: dejar de devolver null

Un método que a veces no tiene nada que devolver tenía dos malas opciones: devolver null —y confiar en que quien llama lo compruebe— o lanzar una excepción por algo que no es excepcional. Optional es la tercera:

Optional<Usuario> buscar(String nombre) {
    return nombre.equals("ana") ? Optional.of(new Usuario("ana","ana@ejemplo.com"))
                                : Optional.empty();
}
salidaOptional[Usuario[nombre=ana, email=ana@ejemplo.com]]
  Optional.empty

La firma del método ya avisa de que puede no haber nada, y el compilador no deja usar el valor sin desenvolverlo. Las formas de sacarlo:

buscar("nadie").map(Usuario::email).orElse("(sin correo)");
buscar("nadie").map(Usuario::email).orElseGet(() -> calcularCaro());
buscar("ana").ifPresentOrElse(u -> ..., () -> ...);
buscar("nadie").orElseThrow(() -> new NoSuchElementException("no hay usuario"));
salidaorElse        : (sin correo)
  orElseGet     : calculado
  ifPresentOrElse: encontrado ana
  filter        : false
  orElseThrow   : no hay usuario
  get() a secas : No value present

La diferencia entre orElse y orElseGet importa: el argumento de orElse se evalúa siempre, haya valor o no. Si es caro de calcular o tiene efectos, usa orElseGet, que solo lo ejecuta cuando hace falta.

Y get() a secas es la forma de volver al problema original: lanza si está vacío, igual que un NPE pero con otro nombre. Si lo estás escribiendo, casi siempre quieres orElseThrow con un mensaje que explique algo.

Lo que lo hace valer la pena

String dominio = buscar("ana")
    .map(Usuario::email)
    .filter(e -> e.contains("@"))
    .map(e -> e.substring(e.indexOf('@') + 1))
    .map(String::toUpperCase)
    .orElse("desconocido");
salidaEJEMPLO.COM

Cuatro pasos encadenados y ni una sola comprobación de nulo. Si en cualquier punto no hay valor, el resto se salta solo y acaba en «desconocido». Con null, esas cinco líneas serían cuatro if anidados.

Dónde sí y dónde no. Optional está pensado para devolver. No para campos de una clase, ni para parámetros —ahí complica más de lo que arregla—, ni para colecciones: una lista vacía ya dice «no hay nada», y un Optional<List> solo añade un caso más que tratar.

Lo esencial

Situación Qué se hace
Algo que el que llama puede resolver una excepción comprobada, con datos dentro
Un fallo de programación IllegalArgumentException, IllegalStateException
Hay que cerrar un recurso try (var r = ...) { }
Relanzar con otro tipo pasar la original como causa
No hay nada que hacer con el error dejarlo subir, nunca un catch vacío
El método a veces no devuelve nada Optional<T>
Sacar el valor con alternativa orElse; orElseGet si es cara

Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.