Optional en Java
En esta lección
Si llevas un tiempo programando en Java, ya te habrás encontrado con el error más famoso del lenguaje: NullPointerException. Aparece cuando usas una variable que no apunta a ningún objeto, y casi siempre ocurre porque algún método devolvió null y nadie lo comprobó. Optional es la herramienta que Java ofrece desde la versión 8 para decir de forma explícita “este método puede no devolver nada”. En esta lección aprenderás a crearlo, a sacar su valor de forma segura, a transformarlo con map y filter, a usarlo con streams y, muy importante, cuándo no usarlo.
El problema de null
Imagina el programa de un gimnasio con una lista de socios. Un método busca un socio por su DNI y, si no lo encuentra, devuelve null:
record Socio(String dni, String nombre, String entrenador, int edad) {}
static final List<Socio> SOCIOS = List.of(
new Socio("111A", "Marta", "Rubén", 34),
new Socio("222B", "Gonzalo", null, 19), // aún no tiene entrenador
new Socio("333C", "Irene", "Sonia", 52));
static Socio buscarSocioViejo(String dni) {
for (Socio s : SOCIOS) {
if (s.dni().equals(dni)) {
return s;
}
}
return null; // no encontrado
}
Mientras el DNI exista, todo va bien. Pero quien llama al método no tiene ninguna pista de que puede recibir null: la firma solo dice “devuelve un Socio”. Si se le olvida comprobarlo:
Socio s1 = buscarSocioViejo("111A");
System.out.println(s1.nombre()); // Marta
Socio s2 = buscarSocioViejo("999Z");
System.out.println(s2.nombre().toUpperCase());
// Exception in thread "main" java.lang.NullPointerException:
// Cannot invoke "Socio.nombre()" because "s2" is null
Desde Java 14 el mensaje te dice qué variable era null, lo que ayuda a depurar, pero el programa se ha detenido igualmente. El inventor de la referencia nula, Tony Hoare, la llamó su “error del billón de dólares” por la cantidad de fallos que ha causado.
La solución clásica es comprobar if (s2 != null) en todas partes. Funciona, pero es fácil olvidarlo, y llena el código de comprobaciones. Optional ataca el problema desde otro ángulo: hace visible en el tipo que el valor puede faltar.
Qué es Optional
Un Optional<T> es una caja que puede estar en dos estados:
- Llena: contiene exactamente un valor de tipo
T. - Vacía: no contiene nada.
Un método que devuelve Optional<Socio> está diciendo claramente: “puede que haya socio y puede que no; tú decides qué hacer en cada caso”. El compilador no te deja usar el Socio directamente: primero tienes que abrir la caja.
Crear un Optional
| Método | Cuándo se usa |
|---|---|
Optional.of(valor) | Tienes un valor y seguro que no es null |
Optional.ofNullable(valor) | El valor puede ser null (si lo es, la caja queda vacía) |
Optional.empty() | Quieres devolver una caja vacía |
Optional<String> lleno = Optional.of("café");
Optional<String> vacio = Optional.empty();
String quizas = null;
Optional<String> desdeNull = Optional.ofNullable(quizas);
System.out.println(lleno); // Optional[café]
System.out.println(vacio); // Optional.empty
System.out.println(desdeNull); // Optional.empty
Optional.of(quizas); // NullPointerException: of no admite null
Ahora reescribimos la búsqueda para que devuelva un Optional:
static Optional<Socio> buscarSocio(String dni) {
for (Socio s : SOCIOS) {
if (s.dni().equals(dni)) {
return Optional.of(s);
}
}
return Optional.empty();
}
Con un stream queda todavía más corto, porque findFirst() ya devuelve un Optional:
static Optional<Socio> buscarSocio(String dni) {
return SOCIOS.stream()
.filter(s -> s.dni().equals(dni))
.findFirst();
}
Comprobar si hay valor
isPresent() devuelve true si la caja está llena; isEmpty() (Java 11) justo lo contrario. Y get() saca el valor:
Optional<Socio> marta = buscarSocio("111A");
Optional<Socio> nadie = buscarSocio("999Z");
System.out.println(marta.isPresent() + " " + marta.isEmpty()); // true false
System.out.println(nadie.isPresent() + " " + nadie.isEmpty()); // false true
if (marta.isPresent()) {
System.out.println("Hola, " + marta.get().nombre()); // Hola, Marta
}
nadie.get(); // NoSuchElementException: No value present
Esta forma funciona, pero es casi lo mismo que el if (x != null) de siempre, y get() sobre una caja vacía lanza una excepción. Los métodos que vienen a continuación son más seguros y más cortos.
Cuidado: si te ves escribiendo
if (opt.isPresent()) { ... opt.get() ... }muchas veces, probablemente hay un método deOptionalque lo hace mejor.get()sin comprobar antes es el error más común conOptional.
Sacar el valor con un plan B
orElse: valor por defecto
orElse(x) devuelve el contenido de la caja o, si está vacía, x:
String nombre1 = buscarSocio("333C").map(Socio::nombre).orElse("Invitado");
String nombre2 = buscarSocio("999Z").map(Socio::nombre).orElse("Invitado");
System.out.println(nombre1 + " / " + nombre2); // Irene / Invitado
El map(Socio::nombre) transforma la caja de Socio en una caja de String; lo verás enseguida.
orElseGet: valor por defecto calculado
orElseGet recibe una lambda (un Supplier) que solo se ejecuta si la caja está vacía. La diferencia con orElse es sutil pero importante: el argumento de orElse se calcula siempre, haga falta o no. Lo vemos con un método que avisa cuando se ejecuta:
static String nombrePorDefecto() {
System.out.println(" (calculando nombre por defecto...)");
return "Invitado";
}
System.out.println("orElse con socio existente:");
String a = buscarSocio("111A").map(Socio::nombre).orElse(nombrePorDefecto());
System.out.println(" -> " + a);
System.out.println("orElseGet con socio existente:");
String b = buscarSocio("111A").map(Socio::nombre).orElseGet(() -> nombrePorDefecto());
System.out.println(" -> " + b);
// orElse con socio existente:
// (calculando nombre por defecto...)
// -> Marta
// orElseGet con socio existente:
// -> Marta
Con orElse, nombrePorDefecto() se ha ejecutado aunque Marta existía y el resultado se ha tirado a la basura. Si ese cálculo fuera una consulta a una base de datos o crear un objeto pesado, estarías malgastando recursos en cada llamada.
| Método | El valor por defecto se calcula… | Úsalo con… |
|---|---|---|
orElse(x) | Siempre | Constantes y valores ya calculados: "Invitado", 0 |
orElseGet(() -> ...) | Solo si la caja está vacía | Llamadas a métodos, objetos nuevos, cálculos |
orElseThrow: si no hay valor, es un error
A veces que falte el valor no es una situación normal, sino un error que hay que avisar. orElseThrow lanza una excepción si la caja está vacía:
Socio irene = buscarSocio("333C").orElseThrow(); // NoSuchElementException si falta
System.out.println(irene.nombre()); // Irene
Socio x = buscarSocio("999Z")
.orElseThrow(() -> new IllegalArgumentException("No existe el socio 999Z"));
// IllegalArgumentException: No existe el socio 999Z
La versión sin argumentos (Java 10) hace lo mismo que get(), pero su nombre deja claro que puede lanzar una excepción. Si sabes que el valor tiene que estar, prefiérela a get().
Actuar solo si hay valor: ifPresent
Cuando no quieres un resultado sino hacer algo con el valor, usa ifPresent. Si la caja está vacía, simplemente no pasa nada:
buscarSocio("222B").ifPresent(s -> System.out.println("Bienvenido, " + s.nombre()));
// Bienvenido, Gonzalo
buscarSocio("999Z").ifPresent(s -> System.out.println("Bienvenido, " + s.nombre()));
// (no imprime nada)
Si también quieres hacer algo en el caso vacío, ifPresentOrElse (Java 9) recibe dos acciones:
buscarSocio("999Z").ifPresentOrElse(
s -> System.out.println("Bienvenido, " + s.nombre()),
() -> System.out.println("DNI no registrado"));
// DNI no registrado
Transformar sin abrir la caja: map, filter y flatMap
Lo mejor de Optional es que puedes trabajar con el valor sin sacarlo, igual que con un stream. Si la caja está vacía, estas operaciones no hacen nada y la caja sigue vacía.
map
Aplica una función al contenido y devuelve una caja con el resultado:
Optional<String> mayus = buscarSocio("111A")
.map(Socio::nombre)
.map(String::toUpperCase);
System.out.println(mayus); // Optional[MARTA]
Si la función devuelve null, map devuelve una caja vacía. Eso permite recorrer atributos que pueden faltar sin un solo if:
for (String dni : List.of("111A", "222B", "999Z")) {
String entrenador = buscarSocio(dni)
.map(Socio::entrenador)
.orElse("sin asignar");
System.out.println(dni + ": " + entrenador);
}
// 111A: Rubén
// 222B: sin asignar
// 999Z: sin asignar
Gonzalo existe pero no tiene entrenador; el DNI 999Z ni siquiera existe. En los dos casos el resultado es el mismo plan B, sin NullPointerException.
filter
Deja el valor solo si cumple una condición; si no, la caja queda vacía. Por ejemplo, un descuento para mayores de 50:
Optional<Socio> senior = buscarSocio("333C").filter(s -> s.edad() >= 50);
Optional<Socio> noSenior = buscarSocio("111A").filter(s -> s.edad() >= 50);
System.out.println(senior.map(Socio::nombre) + " " + noSenior.map(Socio::nombre));
// Optional[Irene] Optional.empty
String descuento = buscarSocio("333C")
.filter(s -> s.edad() >= 50)
.map(s -> "20 %")
.orElse("0 %");
System.out.println("descuento: " + descuento); // descuento: 20 %
flatMap
Si la función que aplicas ya devuelve un Optional, map te dejaría una caja dentro de otra caja. flatMap lo aplana:
static Optional<String> entrenadorDe(Socio s) {
return Optional.ofNullable(s.entrenador());
}
System.out.println(buscarSocio("111A").map(Opt::entrenadorDe)); // Optional[Optional[Rubén]]
System.out.println(buscarSocio("111A").flatMap(Opt::entrenadorDe)); // Optional[Rubén]
System.out.println(buscarSocio("222B").flatMap(Opt::entrenadorDe)); // Optional.empty
(Opt es el nombre de la clase donde está el método entrenadorDe.)
or: otra caja como alternativa
or (Java 9) se parece a orElseGet, pero el plan B es otro Optional. Sirve para encadenar búsquedas: “busca aquí; si no está, busca allí”:
Optional<Socio> s = buscarSocio("999Z").or(() -> buscarSocio("222B"));
System.out.println(s.map(Socio::nombre).orElse("-")); // Gonzalo
Optional en los streams
Ya lo intuiste en lambdas y streams: las operaciones finales que pueden no tener resultado devuelven un Optional. Si el stream está vacío o ningún elemento cumple el filtro, no hay “primero” ni “máximo”.
| Operación | Devuelve |
|---|---|
findFirst(), findAny() | Optional<T> |
max(comp), min(comp) | Optional<T> |
reduce(operacion) | Optional<T> |
average() en IntStream/DoubleStream | OptionalDouble |
max(), min() en IntStream | OptionalInt |
List<Integer> tiempos = List.of(48, 52, 45, 60); // minutos en una carrera
Optional<Integer> mejor = tiempos.stream().min(Integer::compare);
System.out.println("Mejor tiempo: " + mejor.orElseThrow() + " min"); // Mejor tiempo: 45 min
Optional<Socio> mayor = SOCIOS.stream().max(Comparator.comparingInt(Socio::edad));
System.out.println(mayor.map(Socio::nombre).orElse("nadie")); // Irene
Optional<String> conR = SOCIOS.stream()
.map(Socio::nombre)
.filter(n -> n.startsWith("R"))
.findFirst();
System.out.println(conR); // Optional.empty
Para los números primitivos existen OptionalInt, OptionalLong y OptionalDouble, con getAsInt(), getAsDouble()… en lugar de get():
OptionalDouble media = SOCIOS.stream().mapToInt(Socio::edad).average();
System.out.println(media); // OptionalDouble[35.0]
System.out.println(media.orElse(0)); // 35.0
OptionalDouble mediaVacia = IntStream.empty().average();
System.out.println(mediaVacia.orElse(0)); // 0.0
Y al revés: Optional tiene un método stream() (Java 9) que da un stream de cero o un elemento. Con flatMap sirve para quedarte solo con los valores que existen:
List<String> entrenadores = SOCIOS.stream()
.map(Opt::entrenadorDe) // Stream<Optional<String>>
.flatMap(Optional::stream) // descarta los vacíos
.toList();
System.out.println(entrenadores); // [Rubén, Sonia]
Cuándo NO usar Optional
Optional se diseñó con un propósito muy concreto: el valor de retorno de un método que puede no tener resultado. Fuera de ahí suele empeorar el código:
- Atributos de una clase. No declares
private Optional<String> entrenador;.Optionalno es serializable y añade un objeto extra por cada atributo. Guarda el atributo normal (puede sernull) y, si quieres, haz que su getter devuelvaOptional.ofNullable(entrenador). - Parámetros de métodos.
void apuntar(Optional<String> clase)obliga a quien llama a envolver sus datos y, además, podría pasartenullen lugar de unOptional. Mejor dos versiones del método (sobrecarga) o un parámetro que admitanulldocumentado. - Colecciones. No devuelvas
Optional<List<Socio>>. Una lista vacía ya significa “no hay resultados”: devuelveList.of(). - Para sustituir cualquier
if.Optional.ofNullable(x).ifPresent(...)solo para evitar unif (x != null)en un método corto no aporta nada. - Nunca devuelvas
nullen un método que devuelveOptional. Rompe la única promesa que hace el tipo. DevuelveOptional.empty().
| Situación | Mejor opción |
|---|---|
| Método que busca y puede no encontrar | Optional<T> |
| Método que devuelve varios resultados | Lista (vacía si no hay) |
| Atributo que puede faltar | Atributo normal + getter con Optional |
| Parámetro opcional | Sobrecarga de métodos |
Errores frecuentes
- Llamar a
get()sin comprobar. En una caja vacía lanzaNoSuchElementException. UsaorElse,orElseGet,orElseThrowoifPresent. - Usar
Optional.ofcon un valor que puede sernull. LanzaNullPointerException; usaofNullable. - Poner un cálculo costoso en
orElse. Se ejecuta siempre; usaorElseGet. - Devolver
nulldesde un método que devuelveOptional. DevuelveOptional.empty(). - Usar
mapcon una función que devuelveOptional. ObtienesOptional<Optional<T>>; usaflatMap. - Usar
Optionalen atributos, parámetros o colecciones. Solo como tipo de retorno. - Comparar un
Optionalconnull(if (opt == null)). UnOptionalbien usado nunca esnull; preguntaopt.isEmpty().
Resumen
| Concepto | Código |
|---|---|
| Crear | Optional.of(x), Optional.ofNullable(x), Optional.empty() |
| Consultar | isPresent(), isEmpty() |
| Valor o defecto | orElse(x), orElseGet(() -> ...) |
| Valor o excepción | orElseThrow(), orElseThrow(() -> new ...) |
| Hacer algo si hay valor | ifPresent(...), ifPresentOrElse(..., ...) |
| Transformar | map(f), flatMap(f), filter(p), or(() -> ...) |
| En streams | findFirst(), max(c), min(c), average() |
| Regla de oro | Solo como tipo de retorno; nunca get() sin comprobar |
En la siguiente lección aprenderás a trabajar con fechas, horas y duraciones con la API moderna de Java: fechas con java.time.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.