5. Excepciones, null y Optional
Ejemplos completos: lessons/l05.
Excepciones comprobadas: el compilador lleva la cuenta de lo que puede fallar
Sección titulada «Excepciones comprobadas: el compilador lleva la cuenta de lo que puede fallar»En C#, todas las excepciones son no comprobadas: la firma de un método no dice qué lanza, y el compilador nunca te pide que trates nada. Java divide en dos la jerarquía de Throwable:
| Tipo | Clases | ¿Hay que capturarla o declararla? | Uso típico |
|---|---|---|---|
| comprobada (checked) | Exception y sus subclases, salvo RuntimeException |
sí | fallos que un programa correcto debe prever igualmente: E/S, red, análisis de un archivo |
| no comprobada (unchecked) | RuntimeException y sus subclases |
no | errores de programación: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException |
| no comprobada (unchecked) | Error y sus subclases |
no | la JVM está en apuros: OutOfMemoryError, StackOverflowError |
Un método que puede dejar escapar una excepción comprobada debe indicarlo con throws. Files.readString declara throws IOException, así que esto no compila:
import java.nio.file.Files;import java.nio.file.Path;
class Config { static String load() { return Files.readString(Path.of("app.properties")); }}UnreportedException.java:6: error: unreported exception IOException; must be caught or declared to be thrown return Files.readString(Path.of("app.properties")); ^1 errorLas dos soluciones son las que nombra el mensaje: capturarla, o añadir throws IOException a load() y trasladar la decisión a quienes lo llaman. Las reglas están en JLS §11.2.
static String readConfig(Path path) throws IOException { return Files.readString(path);}
static int parsePort(String text) { // NumberFormatException no es comprobada: nada obliga a quien llama a tratarla. return Integer.parseInt(text);}Las bibliotecas Java usan las excepciones comprobadas menos que antes. Las API más recientes, como java.time, solo lanzan excepciones no comprobadas, y la mayoría de los frameworks envuelven las comprobadas. Las API de E/S, reflexión y concurrencia del JDK son donde te las encontrarás a diario.
Bloques catch: orden, multi-catch y sin filtros
Sección titulada «Bloques catch: orden, multi-catch y sin filtros»La regla del orden es la misma que el CS0160 de C#: un catch no puede ir después de otro que ya trata un supertipo.
import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;
class Reader { static String read(Path path) { try { return Files.readString(path); } catch (Exception e) { return ""; } catch (IOException e) { return "I/O error"; } }}CatchOrder.java:11: error: exception IOException has already been caught } catch (IOException e) { ^1 errorLas excepciones comprobadas añaden una regla que C# no tiene motivo para tener: capturar una excepción comprobada que el cuerpo del try no puede lanzar es un error.
import java.io.IOException;
class Parser { static int parse(String text) { try { return Integer.parseInt(text); } catch (IOException e) { return -1; } }}NeverThrown.java:7: error: exception IOException is never thrown in body of corresponding try statement } catch (IOException e) { ^1 errorUn multi-catch trata varios tipos no relacionados en un solo bloque. Las alternativas no pueden ser subclases unas de otras, ya que la subclase sería redundante:
for (String text : new String[] {"8080", "http"}) { try { System.out.println("port " + parsePort(text)); } catch (NumberFormatException | NullPointerException e) { System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); }}port 8080NumberFormatException: For input string: "http"import java.io.FileNotFoundException;import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;
class Loader { static String load(Path path) { try { return Files.readString(path); } catch (FileNotFoundException | IOException e) { return ""; } }}MulticatchSubclass.java:10: error: Alternatives in a multi-catch statement cannot be related by subclassing } catch (FileNotFoundException | IOException e) { ^ Alternative FileNotFoundException is a subclass of alternative IOException1 errorJava no tiene filtros de excepción. El lado C# de esta lección captura con when (e.Message.Contains("'http'")); la misma línea en Java es un error de sintaxis:
class Filters { static int parse(String text) { try { return Integer.parseInt(text); } catch (NumberFormatException e) when (text.isBlank()) { return 0; } }}ExceptionFilter.java:5: error: '{' expected } catch (NumberFormatException e) when (text.isBlank()) { ^ExceptionFilter.java:5: error: ';' expected } catch (NumberFormatException e) when (text.isBlank()) { ^ExceptionFilter.java:9: error: reached end of file while parsing} ^3 errorsHaz la comprobación dentro del catch y vuelve a lanzar lo que no trates. A diferencia de un filtro, esto deshace la pila antes de que se ejecute la comprobación, lo que importa sobre todo a los depuradores y a los volcados de memoria.
Las excepciones comprobadas no atraviesan las lambdas
Sección titulada «Las excepciones comprobadas no atraviesan las lambdas»Las interfaces funcionales de java.util.function no declaran excepciones comprobadas: Function.apply no tiene cláusula throws. Por eso una lambda que llama a Files.readString no puede ser una Function:
import java.nio.file.Files;import java.nio.file.Path;import java.util.List;
class Configs { static List<String> loadAll(List<Path> paths) { return paths.stream().map(path -> Files.readString(path)).toList(); }}CheckedInLambda.java:7: error: unreported exception IOException; must be caught or declared to be thrown return paths.stream().map(path -> Files.readString(path)).toList(); ^1 errorEste es el coste diario de las excepciones comprobadas. Las respuestas habituales son un bucle normal, o capturar dentro de la lambda y envolver la excepción en una no comprobada. El ejercicio 1 escribe un adaptador reutilizable para lo segundo.
Envolver conserva la causa
Sección titulada «Envolver conserva la causa»Envuelve pasando la excepción original como causa, como el innerException de C#. Para IOException, el JDK ofrece UncheckedIOException:
static String loadOrWrap(Path path) { try { return readConfig(path); } catch (IOException e) { // Envuelve en una excepción no comprobada y conserva la original como causa. throw new UncheckedIOException("cannot load " + path.getFileName(), e); }}cannot load missing.propertiescaused by NoSuchFileExceptionUna traza de pila impresa por la JVM muestra la cadena en secciones Caused by:, el equivalente de las líneas ---> de excepción interna en .NET.
try-with-resources en lugar de using
Sección titulada «try-with-resources en lugar de using»Un try con recursos es el using de Java. El recurso debe implementar AutoCloseable (el IDisposable de Java). Los recursos se cierran en orden inverso a su declaración, antes de que se ejecute cualquier bloque catch o finally de la misma instrucción:
record Connection(String name, boolean failOnClose) implements AutoCloseable { Connection { System.out.println("open " + name); }
@Override public void close() { System.out.println("close " + name); if (failOnClose) { throw new IllegalStateException("close failed: " + name); } }}
try (var db = new Connection("db", false); var cache = new Connection("cache", false)) { System.out.println("using " + db.name() + " and " + cache.name());} finally { System.out.println("finally");}open dbopen cacheusing db and cacheclose cacheclose dbfinallyLos dos lenguajes difieren cuando el cuerpo lanza una excepción y después el cierre también lanza otra. Java conserva la excepción del cuerpo, la que explica qué salió mal, y le adjunta la otra como excepción suprimida:
// El cuerpo falla y luego también falla el cierre: gana la excepción del cuerpo,// y la excepción del cierre se le adjunta en lugar de sustituirla.try (var db = new Connection("db", true)) { throw new IllegalArgumentException("query failed on " + db.name());} catch (IllegalArgumentException e) { System.out.println("caught: " + e.getMessage()); for (Throwable suppressed : e.getSuppressed()) { System.out.println("suppressed: " + suppressed.getMessage()); }}open dbclose dbcaught: query failed on dbsuppressed: close failed: dbEn C#, un using es un try/finally. Una excepción lanzada por Dispose sustituye a la que estaba en curso, y query failed on db se pierde:
dispose dbcaught: dispose failed: dbEl tipo del recurso se comprueba en tiempo de compilación. No hay duck typing, y una clase con un método close() que no implementa AutoCloseable se rechaza:
class Session { void end() {}
static void run() { try (var session = new Session()) { System.out.println("working"); } }}NotAutoCloseable.java:5: error: incompatible types: try-with-resources not applicable to variable type try (var session = new Session()) { ^ (Session cannot be converted to AutoCloseable)1 errorEl propio close() puede lanzar una excepción comprobada. BufferedReader.close() declara IOException, así que la llamada implícita hay que tratarla incluso cuando el cuerpo no puede fallar:
import java.io.BufferedReader;import java.io.StringReader;
class FirstLine { static String firstLine(String text) { try (BufferedReader reader = new BufferedReader(new StringReader(text))) { return reader.readLine(); } }}ImplicitClose.java:6: error: unreported exception IOException; must be caught or declared to be thrown try (BufferedReader reader = new BufferedReader(new StringReader(text))) { ^ exception thrown from implicit call to close() on resource variable 'reader'ImplicitClose.java:7: error: unreported exception IOException; must be caught or declared to be thrown return reader.readLine(); ^2 errorsLa semántica completa, incluidos el orden de cierre y la supresión, está en JLS §14.20.3.
Volver a lanzar y finally
Sección titulada «Volver a lanzar y finally»Una excepción Java captura su traza de pila cuando se crea, no cuando se lanza. Por eso throw e en un bloque catch conserva la traza original, y Java no tiene un throw; a secas:
static void failDeep() { throw new IllegalStateException("deep failure");}
static void rethrow() { try { failDeep(); } catch (IllegalStateException e) { // En Java la traza de pila se captura cuando se crea la excepción, // así que "throw e" la conserva. C# necesita "throw;" para lo mismo. throw e; }}thrown in failDeepEl lado C# muestra por qué existe la regla de C#. throw e; reinicia la traza en el método que vuelve a lanzar, y el analizador informa de CA2200:
throw e: thrown in RethrowWithVariablethrow; thrown in FailDeepC# prohíbe return dentro de finally (CS0157). Java lo permite, y el resultado del finally sustituye en silencio al del try, o incluso a una excepción en curso. -Xlint:finally, incluido en -Xlint:all, avisa de ello:
class Totals { static int total() { try { return 1; } finally { return 2; } }}FinallyReturn.java:7: warning: [finally] finally clause cannot complete normally } ^1 warningtotal() devuelve 2. Reserva finally para la limpieza, y prefiere try-with-resources cuando la limpieza consiste en cerrar algo.
null: sin tipos de referencia que aceptan valores null
Sección titulada «null: sin tipos de referencia que aceptan valores null»Los tipos de referencia que aceptan valores null de C# 8 hacen que string y string? sean tipos distintos para el compilador. Java no tiene nada parecido. Cualquier referencia puede ser null, javac no da ninguna advertencia, y te enteras en tiempo de ejecución.
Lo que Java sí tiene es un mensaje preciso. Desde JEP 358 (Java 14, activado por defecto desde la 15), una NullPointerException nombra lo que era null:
static Customer lookup(String id) { return CUSTOMERS.get(id); // null cuando el id es desconocido}
System.out.println(lookup("grace").name().length());Cannot invoke "lessons.l05.Nulls$Customer.name()" because the return value of "lessons.l05.Nulls.lookup(String)" is nullLa misma cadena en C# (con ! para silenciar la advertencia de nulabilidad) solo da Object reference not set to an instance of an object.
Para fallar pronto, Objects.requireNonNull es el equivalente de ArgumentNullException.ThrowIfNull:
new Customer(Objects.requireNonNull(null, "name"), null);requireNonNull: nameEl análisis estático puede recuperar parte de la comprobación en tiempo de compilación. JSpecify estandariza las anotaciones @Nullable y @NullMarked, y herramientas como NullAway las hacen cumplir durante la compilación. La lección 11 las configura y muestra a NullAway rechazando este tipo de código.
Optional<T> en lugar de ?. y ??
Sección titulada «Optional<T> en lugar de ?. y ??»Optional<T> es un contenedor que guarda un valor o nada. Es una clase de biblioteca, no sintaxis del lenguaje, y sus métodos cubren los operadores de null de C#:
| C# | Optional de Java |
|---|---|
x?.Property |
.map(X::property) |
x?.Method() que devuelve T? |
.flatMap(X::method) que devuelve Optional |
x ?? fallback |
.orElse(fallback) |
x ?? Compute() |
.orElseGet(() -> compute()) |
x ?? throw new … |
.orElseThrow(() -> new …) |
x! |
.orElseThrow() — lanza NoSuchElementException |
if (x is { } value) |
.ifPresent(value -> …) |
static Optional<Customer> find(String id) { return Optional.ofNullable(CUSTOMERS.get(id));}
// C#: customer?.Address?.City ?? "unknown"for (String id : new String[] {"ada", "alan", "grace"}) { String city = find(id).map(Customer::address).map(Address::city).orElse("unknown"); System.out.println(id + " -> " + city);}ada -> Londonalan -> unknowngrace -> unknownmap convierte un resultado null en un Optional vacío, así que alan (sin dirección) y grace (sin cliente) acaban los dos en orElse. El lado C# imprime las mismas tres líneas con customers.GetValueOrDefault(id)?.Address?.City ?? "unknown".
Una diferencia con ??: orElse es un método normal, así que su argumento se evalúa antes de la llamada, incluso cuando el valor está presente. Usa orElseGet cuando el valor por defecto es costoso o tiene efectos secundarios:
System.out.println("orElse:");find("ada").map(Customer::name).orElse(defaultCity());System.out.println("orElseGet:");find("ada").map(Customer::name).orElseGet(Nulls::defaultCity);orElse: computing default cityorElseGet:Optional está pensado para valores de retorno. Su Javadoc dice que «está pensado principalmente para usarse como tipo de retorno de un método cuando hay una clara necesidad de representar “ningún resultado” y cuando usar null probablemente cause errores». No lo uses para campos, parámetros ni elementos de colecciones, y nunca devuelvas un Optional null. Para los primitivos, OptionalInt, OptionalLong y OptionalDouble evitan el boxing (lección 4).
Puntos clave
Sección titulada «Puntos clave»- Las excepciones comprobadas (
Exceptionpero noRuntimeException) deben capturarse o declararse conthrows; C# no tiene ninguna. - El multi-catch sustituye a los bloques
catchrepetidos; no hay filtroswhen. - Las lambdas para las interfaces de
java.util.functionno pueden lanzar excepciones comprobadas: envuélvelas, por ejemplo enUncheckedIOException. - try-with-resources cierra en orden inverso y conserva la excepción del cuerpo, añadiendo los fallos de cierre como excepciones suprimidas.
throw econserva la traza de pila; unreturnenfinallyse impone a todo.- No hay tipos de referencia que aceptan valores null: apóyate en los mensajes útiles de las NPE, en
requireNonNully enOptionalpara los valores de retorno.
Ejercicios
Sección titulada «Ejercicios»- Escribe un adaptador
uncheckedpara quepaths.stream().map(unchecked(Files::readString)).toList()compile. UnaIOExceptiondebe aparecer como unaUncheckedIOExceptioncon la original como causa.
Solución
@FunctionalInterfaceinterface ThrowingFunction<T, R> { R apply(T value) throws Exception;}
static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> function) { return value -> { try { return function.apply(value); } catch (IOException e) { throw new UncheckedIOException(e); } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } };}ThrowingFunction es una interfaz funcional cuyo método sí declara throws Exception, así que Files::readString encaja en ella. La cláusula RuntimeException vuelve a lanzar sin cambios las excepciones no comprobadas en lugar de envolverlas dos veces. Bibliotecas como Vavr incluyen la misma idea, pero esa docena de líneas suele ser más sencilla que una dependencia.
- Traduce este método C#. ¿Qué tipo de retorno evita el boxing?
static int? ParsePort(string text) => int.TryParse(text, out var port) && port is >= 0 and <= 65535 ? port : null;
var port = ParsePort(args[0]) ?? 8080;Solución
static OptionalInt parsePort(String text) { try { int port = Integer.parseInt(text); return port >= 0 && port <= 65535 ? OptionalInt.of(port) : OptionalInt.empty(); } catch (NumberFormatException e) { return OptionalInt.empty(); }}
int port = parsePort(args[0]).orElse(8080);OptionalInt guarda un int directamente; Optional<Integer> lo envolvería en un objeto. Java no tiene TryParse, así que la excepción es la señal de fallo. parsePort("http") y parsePort("70000") dan los dos 8080.
- Predice la salida y luego compruébala. ¿Qué excepción llega a quien llama, y en qué orden se registran las demás?
record Resource(String name, List<String> log) implements AutoCloseable { @Override public void close() { log.add("close " + name); throw new IllegalStateException("close " + name); }}
try (var first = new Resource("first", log); var second = new Resource("second", log)) { throw new IllegalArgumentException("body " + first.name() + " " + second.name());}Solución
Quien llama recibe la IllegalArgumentException con el mensaje body first second. second se cierra antes que first, así que log vale [close second, close first], y getSuppressed() contiene las dos IllegalStateException en el mismo orden: close second y después close first. Todos los recursos se cierran aunque cada close() lance una excepción. En C#, unas declaraciones using anidadas también liberarían los dos, pero quien llama solo vería close first, la última excepción lanzada.