Módulo 03: Programación funcional
Objetivos del módulo
- Entender las ideas del paradigma funcional: funciones puras, inmutabilidad y funciones como valores
- Escribir lambdas con todas sus variantes y comprender la captura de variables effectively final
- Conocer las interfaces funcionales de
java.util.functiony crear las tuyas con@FunctionalInterface - Usar los cuatro tipos de referencias a métodos
- Componer funciones, predicados y consumidores (
andThen,compose,and,or,negate) - Escribir funciones de orden superior, aplicar currificación y evaluación perezosa
- Usar
Optionalcorrectamente y reconocer sus antipatrones - Saber cuándo una lambda y cuándo una clase anónima
- Diseñar soluciones reales con estilo funcional: validadores componibles, estrategias y eventos
1. El paradigma funcional
Hasta ahora has programado de forma imperativa: le dices al ordenador cómo hacer las cosas, paso a paso, modificando variables. La programación funcional pone el foco en qué quieres obtener, combinando funciones que transforman datos.
// Imperativo: cómo hacerlo (bucle, variable acumuladora, if)
List<String> resultado = new ArrayList<>();
for (Empleado e : empleados) {
if (e.salario() > 3000) {
resultado.add(e.nombre().toUpperCase());
}
}
// Funcional (declarativo): qué quiero
List<String> resultado = empleados.stream()
.filter(e -> e.salario() > 3000)
.map(e -> e.nombre().toUpperCase())
.toList();
📘 Concepto: La programación funcional es un paradigma que construye los programas combinando funciones. Sus ideas clave son: las funciones son valores (se pueden guardar en variables, pasar como parámetros y devolver), se prefieren funciones puras (sin efectos secundarios) y datos inmutables.
| Idea | Significado | En Java |
|---|---|---|
| Funciones como valores (first-class functions) | Una función se trata como cualquier otro dato | Lambdas y referencias a métodos, tipadas con interfaces funcionales |
| Funciones puras | Mismo resultado para la misma entrada y ningún efecto secundario | Métodos que no modifican estado externo ni dependen de él |
| Inmutabilidad | Los datos no cambian: se crean versiones nuevas | Records, List.of, String, LocalDate, final |
| Funciones de orden superior | Funciones que reciben o devuelven funciones | Stream.map(Function), Comparator.comparing(Function) |
| Composición | Construir funciones grandes a partir de pequeñas | f.andThen(g), p.and(q) |
| Evaluación perezosa | No calcular hasta que hace falta | Supplier, streams, Optional.orElseGet |
1.1. Funciones puras y efectos secundarios
// PURA: solo depende de sus parámetros y no cambia nada fuera
static double conIva(double precio) {
return precio * 1.21;
}
// IMPURA: depende de un estado externo que puede cambiar...
static double tipoIva = 21;
static double conIvaVariable(double precio) {
return precio * (1 + tipoIva / 100);
}
// ...o modifica el exterior (efecto secundario)
static List<String> log = new ArrayList<>();
static double conIvaYLog(double precio) {
log.add("calculado " + precio);
return precio * 1.21;
}
Las funciones puras son fáciles de probar (no hay que preparar estado), de razonar (el resultado solo depende de lo que ves) y de paralelizar (no compiten por datos compartidos). Por supuesto, un programa útil necesita efectos (leer ficheros, escribir en una base de datos, mostrar resultados): la idea es aislarlos en los bordes del programa y mantener puro el núcleo de la lógica.
💡 Consejo: Java no es un lenguaje funcional puro (como Haskell): es un lenguaje orientado a objetos con herramientas funcionales desde Java 8. Lo profesional es combinar ambos estilos: objetos para modelar el dominio y funciones para transformar datos y parametrizar comportamiento. Spring, Hibernate y casi cualquier biblioteca moderna esperan que domines las lambdas.
2. Expresiones lambda
📘 Concepto: Una expresión lambda es una función anónima: un bloque de código con parámetros que se puede tratar como un valor. Su sintaxis es
(parámetros) -> cuerpo. Una lambda siempre se asigna a una interfaz funcional (una interfaz con un único método abstracto), que es la que le da su tipo.
2.1. Sintaxis
| Forma | Ejemplo | Cuándo |
|---|---|---|
| Sin parámetros | () -> "Hola" | Supplier, Runnable |
| Un parámetro sin paréntesis | s -> s.length() | El caso más habitual |
| Parámetros con tipo explícito | (String s) -> s.length() | Para aclarar o si la inferencia no basta |
Parámetros con var (Java 11) | (var a, var b) -> a + b | Para añadir anotaciones: (@NonNull var s) -> ... |
| Varios parámetros | (a, b) -> a + b | BiFunction, Comparator |
| Cuerpo de bloque | (a, b) -> { int r = a + b; return r; } | Varias sentencias; return obligatorio si devuelve algo |
| Parámetro sin usar (Java 22) | (_, v) -> v * 2 | Ignorar parámetros |
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
public class Lambdas {
record Empleado(String nombre, String puesto, double salario) {}
public static void main(String[] args) {
// 1. Formas de escribir una lambda
Supplier<String> sinParametros = () -> "Hola";
Function<String, Integer> unParametro = s -> s.length(); // sin paréntesis
Function<String, Integer> conTipo = (String s) -> s.length(); // tipo explícito
BiFunction<Integer, Integer, Integer> dosParametros = (a, b) -> a + b;
BiFunction<Double, Double, Double> conBloque = (base, pct) -> {
double descuento = base * pct / 100; // varias sentencias
return base - descuento; // return obligatorio
};
System.out.println(sinParametros.get() + " " + unParametro.apply("Java") + " "
+ conTipo.apply("Lambda") + " " + dosParametros.apply(2, 3) + " " + conBloque.apply(200.0, 15.0));
// 2. Antes y después: ordenar con una clase anónima y con una lambda
List<Empleado> empleados = new ArrayList<>(List.of(
new Empleado("Pablo", "ANALISTA", 3500), new Empleado("Raquel", "PROGRAMADOR", 2800),
new Empleado("Iván", "PROGRAMADOR", 2600), new Empleado("Hugo", "TÉCNICO SISTEMAS", 2500)));
empleados.sort(new Comparator<Empleado>() { // Java 7
@Override
public int compare(Empleado a, Empleado b) {
return Double.compare(a.salario(), b.salario());
}
});
empleados.sort((a, b) -> Double.compare(a.salario(), b.salario())); // Java 8
System.out.println(empleados.stream().map(Empleado::nombre).toList());
// 3. Captura de variables: deben ser final o "effectively final"
double umbral = 2700; // no se reasigna nunca: effectively final
Predicate<Empleado> ganaMas = e -> e.salario() > umbral;
// umbral = 3000; // si descomentas esto, la lambda deja de compilar
empleados.removeIf(ganaMas.negate());
System.out.println("Ganan más de " + umbral + ": " + empleados);
// 4. Truco habitual (y su trampa): un contenedor mutable para "contar" dentro de una lambda
int[] contador = {0}; // la referencia es final, el contenido no
List.of("a", "b", "c").forEach(s -> contador[0]++);
System.out.println("Contador: " + contador[0] + " (funciona, pero en streams usa count())");
// 5. Una lambda es un objeto: se puede guardar, pasar y devolver
List<Function<Double, Double>> transformaciones = List.of(x -> x * 2, x -> x + 10, Math::sqrt);
double valor = 3;
for (Function<Double, Double> t : transformaciones) valor = t.apply(valor);
System.out.println("Resultado encadenado: " + valor);
}
}
Hola 4 6 5 170.0
[Hugo, Iván, Raquel, Pablo]
Ganan más de 2700.0: [Empleado[nombre=Raquel, puesto=PROGRAMADOR, salario=2800.0], Empleado[nombre=Pablo, puesto=ANALISTA, salario=3500.0]]
Contador: 3 (funciona, pero en streams usa count())
Resultado encadenado: 4.0
Fíjate en el punto 2: las siete líneas de la clase anónima se reducen a una lambda (y en el módulo 02 viste que aún se puede escribir mejor: Comparator.comparingDouble(Empleado::salario)). El compilador infiere los tipos de a y b porque sabe que sort espera un Comparator<Empleado>: esto se llama tipado por el destino (target typing). La misma lambda x -> x * 2 podría ser una Function<Double, Double>, un UnaryOperator<Integer> o un IntUnaryOperator: lo decide el contexto.
2.2. Captura de variables: effectively final
Una lambda puede usar variables locales del método que la contiene (captura), pero solo si son final o effectively final: variables que, aunque no lleven final, nunca se reasignan después de inicializarse. Este código no compila:
import java.util.function.Supplier;
public class ErrorCaptura {
public static void main(String[] args) {
int total = 0;
Supplier<Integer> s = () -> total + 1;
total = 5;
for (int i = 0; i < 3; i++) {
Runnable r = () -> System.out.println(i);
}
}
}
ErrorCaptura.java:6: error: local variables referenced from a lambda expression must be final or effectively final
Supplier<Integer> s = () -> total + 1;
^
ErrorCaptura.java:9: error: local variables referenced from a lambda expression must be final or effectively final
Runnable r = () -> System.out.println(i);
^
2 errors
¿Por qué esta restricción? La lambda puede ejecutarse más tarde (o en otro hilo) que la línea donde se define, cuando el método ya ha terminado y sus variables locales han desaparecido de la pila. Por eso Java copia el valor de la variable al crear la lambda. Si se permitiera reasignarla, la copia y el original divergirían y el resultado sería confuso. Con la restricción, la copia siempre coincide con el original.
⚠️ Importante: La restricción afecta a la variable, no al objeto. Una lambda puede llamar a
lista.add(...)sobre una lista capturada, o modificarcontador[0]en un array, porque la referencia no cambia. Funciona, pero es un efecto secundario: en código concurrente o con streams paralelos provoca errores difíciles de encontrar (módulos 04 y 06). Úsalo con moderación.
| Se puede capturar… | ¿Restricción? |
|---|---|
| Variables locales y parámetros | Deben ser effectively final |
Atributos de instancia (this.campo) | Ninguna: se accede a través de this, que sí es final |
| Atributos estáticos | Ninguna |
La variable de un for-each (for (String s : lista)) | Es una variable nueva en cada vuelta: sí se puede |
La variable de un for clásico (int i) | No (se modifica con i++); copia en otra variable: int j = i; |
3. Interfaces funcionales
📘 Concepto: Una interfaz funcional es una interfaz con exactamente un método abstracto (puede tener todos los métodos
defaultystaticque quiera). Es el “tipo” de una lambda. La anotación@FunctionalInterfacees opcional, pero hace que el compilador compruebe que solo hay un método abstracto.
Ya conocías algunas: Runnable (run()), Comparable<T> (compareTo), Comparator<T> (compare). El paquete java.util.function define 43 interfaces genéricas para que no tengas que crear las tuyas en la mayoría de los casos.
Figura 1. Las interfaces funcionales básicas y sus variantes primitivas.
| Interfaz | Método | Recibe | Devuelve | Uso típico |
|---|---|---|---|---|
Supplier<T> | get() | nada | T | Fábricas, valores por defecto, evaluación perezosa |
Consumer<T> | accept(t) | T | nada | Imprimir, guardar, notificar (forEach) |
BiConsumer<T, U> | accept(t, u) | T, U | nada | Map.forEach((k, v) -> ...) |
Function<T, R> | apply(t) | T | R | Transformar (map) |
BiFunction<T, U, R> | apply(t, u) | T, U | R | Combinar dos valores (Map.merge) |
Predicate<T> | test(t) | T | boolean | Condiciones (filter, removeIf) |
BiPredicate<T, U> | test(t, u) | T, U | boolean | Condiciones sobre dos valores |
UnaryOperator<T> | apply(t) | T | T | Transformar sin cambiar de tipo (replaceAll) |
BinaryOperator<T> | apply(a, b) | T, T | T | Acumular (reduce) |
IntPredicate, IntFunction<R>, IntUnaryOperator, IntBinaryOperator… | int | … | Versiones sin autoboxing para int | |
ToIntFunction<T>, ToDoubleFunction<T>, ToLongFunction<T> | applyAsInt(t)… | T | primitivo | mapToInt, comparingDouble |
IntSupplier, BooleanSupplier, ObjIntConsumer<T>… | Otras combinaciones |
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.DoubleUnaryOperator;
import java.util.function.Function;
import java.util.function.IntBinaryOperator;
import java.util.function.IntPredicate;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
import java.util.function.UnaryOperator;
public class InterfacesFuncionales {
record Producto(String nombre, double precio, int stock) {}
// Interfaz funcional propia: exactamente UN método abstracto
@FunctionalInterface
interface CalculadoraPrecio {
double calcular(Producto p, int unidades);
// Puede tener métodos default y static (no cuentan como abstractos)
default CalculadoraPrecio conIva(double iva) {
return (p, u) -> calcular(p, u) * (1 + iva / 100);
}
static CalculadoraPrecio basica() {
return (p, u) -> p.precio() * u;
}
}
public static void main(String[] args) {
Producto raton = new Producto("Ratón inalámbrico", 19.90, 200);
Producto pack = new Producto("Pack teclado y ratón", 34.50, 0);
// Function<T, R>: transforma un T en un R
Function<Producto, String> etiqueta = p -> p.nombre().toUpperCase();
// BiFunction<T, U, R>: dos entradas, una salida
BiFunction<Producto, Integer, Double> importe = (p, u) -> p.precio() * u;
// Supplier<T>: no recibe nada, produce un T (fábricas, valores perezosos)
Supplier<LocalDate> hoy = () -> LocalDate.of(2026, 10, 4);
// Consumer<T>: recibe un T y no devuelve nada (efectos: imprimir, guardar...)
Consumer<Producto> imprimir = p -> System.out.println(" · " + p.nombre() + " " + p.precio() + " €");
// BiConsumer<T, U>
BiConsumer<String, Integer> log = (msg, nivel) -> System.out.println(" [" + nivel + "] " + msg);
// Predicate<T>: condición sobre un T
Predicate<Producto> agotado = p -> p.stock() == 0;
// BiPredicate<T, U>
BiPredicate<Producto, Integer> hayStock = (p, u) -> p.stock() >= u;
// UnaryOperator<T>: Function<T, T>
UnaryOperator<String> recortar = s -> s.length() > 10 ? s.substring(0, 10) + "…" : s;
// BinaryOperator<T>: BiFunction<T, T, T>
BinaryOperator<Double> mayor = Math::max;
System.out.println(etiqueta.apply(raton));
System.out.println(importe.apply(raton, 3));
System.out.println(hoy.get());
imprimir.accept(pack);
log.accept("Stock revisado", 2);
System.out.println("¿Pack agotado? " + agotado.test(pack) + " · ¿hay 5 ratones? " + hayStock.test(raton, 5));
System.out.println(recortar.apply(pack.nombre()) + " · mayor=" + mayor.apply(19.9, 34.5));
// Variantes primitivas: evitan el autoboxing de int/long/double
IntPredicate par = n -> n % 2 == 0;
IntBinaryOperator mcd = (a, b) -> { while (b != 0) { int t = b; b = a % b; a = t; } return a; };
ToDoubleFunction<Producto> precio = Producto::precio;
DoubleUnaryOperator redondear = d -> Math.round(d * 100) / 100.0;
System.out.println("par(10)=" + par.test(10) + " mcd(84, 36)=" + mcd.applyAsInt(84, 36)
+ " precio=" + precio.applyAsDouble(raton) + " redondeo=" + redondear.applyAsDouble(2.34567));
// Interfaz propia con métodos default y static
CalculadoraPrecio conIva = CalculadoraPrecio.basica().conIva(21);
System.out.printf("3 ratones con IVA: %.2f €%n", conIva.calcular(raton, 3));
// Runnable y Callable también son interfaces funcionales (sin y con resultado)
List<Runnable> tareas = new ArrayList<>();
tareas.add(() -> System.out.println(" tarea 1"));
tareas.add(() -> System.out.println(" tarea 2"));
tareas.forEach(Runnable::run);
}
}
RATÓN INALÁMBRICO
59.699999999999996
2026-10-04
· Pack teclado y ratón 34.5 €
[2] Stock revisado
¿Pack agotado? true · ¿hay 5 ratones? true
Pack tecla… · mayor=34.5
par(10)=true mcd(84, 36)=12 precio=19.9 redondeo=2.35
3 ratones con IVA: 72,24 €
tarea 1
tarea 2
⚠️ Importante: Observa
59.699999999999996: 19,90 × 3 endoubleno da exactamente 59,70, porquedoubleusa representación binaria y 19,9 no tiene representación exacta. No es un error de Java sino de la aritmética de coma flotante. Para importes de dinero en aplicaciones reales usaBigDecimal(o céntimos en unlong); en los ejemplos del curso usamosdoublepor simplicidad y redondeamos al mostrar.
3.1. Crear tus propias interfaces funcionales
Crea una interfaz propia cuando el nombre del método aporte significado al dominio (calcular, validar, notificar) o cuando necesites métodos default específicos, como conIva en el ejemplo. Si solo necesitas “una función de A en B”, usa las de java.util.function.
💡 Consejo: Las interfaces funcionales estándar no pueden lanzar excepciones comprobadas:
Function.applyno declarathrows. Si tu lambda llama a un método que lanzaIOException(por ejemplo,Files.readString), tienes que capturarla dentro de la lambda y relanzarla comoUncheckedIOException, o definir tu propia interfaz funcional conthrows:@FunctionalInterface interface FuncionConExcepcion<T, R> { R aplicar(T t) throws Exception; }
4. Referencias a métodos
📘 Concepto: Una referencia a método (
Clase::metodo) es una forma abreviada de escribir una lambda que se limita a llamar a un método que ya existe.s -> s.length()es lo mismo queString::length. Es más corta y, a menudo, más legible.
| Tipo | Sintaxis | Lambda equivalente | Ejemplo |
|---|---|---|---|
| Método estático | Clase::metodoEstatico | x -> Clase.metodoEstatico(x) | Integer::parseInt, Math::max |
| Método de instancia de un objeto concreto | objeto::metodo | x -> objeto.metodo(x) | System.out::println, formateador::formatear |
| Método de instancia de un objeto cualquiera del tipo | Clase::metodoInstancia | (obj, x) -> obj.metodo(x) | String::length, String::compareToIgnoreCase |
| Constructor | Clase::new | x -> new Clase(x) | ArrayList::new, int[]::new |
El tercer tipo es el que más cuesta al principio: en String::length, el primer parámetro de la lambda se convierte en el objeto sobre el que se llama al método. Por eso String::startsWith encaja con una BiFunction<String, String, Boolean>: (s, prefijo) -> s.startsWith(prefijo).
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
public class Referencias {
record Cliente(String nombre, String ciudad) {
static boolean esDeMadrid(Cliente c) { return c.ciudad().equals("Madrid"); } // método estático
String saludo() { return "Hola, " + nombre; } // método de instancia
}
static class Formateador {
private final String prefijo;
Formateador(String prefijo) { this.prefijo = prefijo; }
String formatear(Cliente c) { return prefijo + c.nombre() + " (" + c.ciudad() + ")"; }
}
public static void main(String[] args) {
List<Cliente> clientes = List.of(new Cliente("Academia Nova", "Madrid"),
new Cliente("Hotel Mar Azul S.L.", "Palma"), new Cliente("María José Herrera", "Madrid"));
// 1. Referencia a método ESTÁTICO: Clase::metodoEstatico (c -> Cliente.esDeMadrid(c))
System.out.println(clientes.stream().filter(Cliente::esDeMadrid).count() + " clientes de Madrid");
Function<String, Integer> aEntero = Integer::parseInt; // s -> Integer.parseInt(s)
System.out.println(aEntero.apply("42") + 1);
// 2. Método de instancia de un objeto CONCRETO: objeto::metodo (c -> fmt.formatear(c))
Formateador fmt = new Formateador("» ");
clientes.stream().map(fmt::formatear).forEach(System.out::println); // System.out también es un objeto
// 3. Método de instancia de un objeto CUALQUIERA del tipo: Clase::metodoInstancia
// el primer parámetro de la lambda se convierte en el objeto sobre el que se llama
clientes.stream().map(Cliente::saludo).forEach(System.out::println); // c -> c.saludo()
BiFunction<String, String, Boolean> empiezaPor = String::startsWith; // (s, p) -> s.startsWith(p)
System.out.println("¿'Madrid' empieza por 'Ma'? " + empiezaPor.apply("Madrid", "Ma"));
// 4. Referencia a CONSTRUCTOR: Clase::new
Supplier<List<String>> nuevaLista = ArrayList::new; // () -> new ArrayList<>()
BiFunction<String, String, Cliente> crear = Cliente::new; // (n, c) -> new Cliente(n, c)
Function<Integer, int[]> crearArray = int[]::new; // n -> new int[n]
List<String> lista = nuevaLista.get();
lista.add("nueva");
System.out.println(lista + " " + crear.apply("Farmacia Central", "Oviedo") + " longitud=" + crearArray.apply(5).length);
}
}
2 clientes de Madrid
43
» Academia Nova (Madrid)
» Hotel Mar Azul S.L. (Palma)
» María José Herrera (Madrid)
Hola, Academia Nova
Hola, Hotel Mar Azul S.L.
Hola, María José Herrera
¿'Madrid' empieza por 'Ma'? true
[nueva] Cliente[nombre=Farmacia Central, ciudad=Oviedo] longitud=5
💡 Consejo: Usa referencias a métodos cuando la lambda solo llama a un método con los mismos parámetros. Si tienes que transformar algo antes (
e -> e.nombre().toUpperCase()), la lambda es más clara que cualquier truco. Y si una lambda crece más de dos o tres líneas, extráela a un método con nombre y usa una referencia:filter(this::esClientePreferente)se lee como una frase.
5. Composición de funciones
Las interfaces funcionales traen métodos default para combinarse entre sí y formar funciones más complejas a partir de piezas pequeñas y probadas.
Figura 2. f.andThen(g) aplica primero f; f.compose(g) aplica primero g.
| Interfaz | Método de composición | Resultado |
|---|---|---|
Function<T, R> | f.andThen(g) | g(f(x)): primero f, después g |
Function<T, R> | f.compose(g) | f(g(x)): primero g, después f |
Function | Function.identity() | x -> x |
Predicate<T> | p.and(q), p.or(q), p.negate() | Y, O, NO lógicos (con cortocircuito) |
Predicate | Predicate.not(p) (Java 11), Predicate.isEqual(x) | Negación como método estático; igualdad con x |
Consumer<T> | c1.andThen(c2) | Ejecuta c1 y después c2 con el mismo valor |
BinaryOperator | BinaryOperator.minBy(cmp), maxBy(cmp) | Elige el menor o mayor según un comparador |
Comparator<T> | thenComparing, reversed | Visto en el módulo 02 |
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
public class Composicion {
record Cliente(String nombre, String ciudad, double credito, boolean tieneComercial) {}
public static void main(String[] args) {
// ----- Function: andThen y compose -----
Function<Double, Double> conIva = x -> x * 1.21;
Function<Double, Double> conDescuento = x -> x * 0.90;
Function<Double, Double> ivaYLuegoDescuento = conIva.andThen(conDescuento); // descuento(iva(x))
Function<Double, Double> descuentoYLuegoIva = conIva.compose(conDescuento); // iva(descuento(x))
System.out.printf("andThen: %.2f compose: %.2f%n",
ivaYLuegoDescuento.apply(100.0), descuentoYLuegoIva.apply(100.0));
Function<String, String> limpiar = String::strip;
Function<String, String> pipeline = limpiar.andThen(String::toLowerCase).andThen(s -> s.replace(' ', '-'));
System.out.println("slug: " + pipeline.apply(" Curso de Java Avanzado "));
System.out.println("identity: " + Function.<String>identity().apply("sin cambios"));
// Cuando el orden importa: (x + 1) * 2 frente a x * 2 + 1
Function<Integer, Integer> mas1 = x -> x + 1, por2 = x -> x * 2;
System.out.println("mas1.andThen(por2)(5)=" + mas1.andThen(por2).apply(5)
+ " mas1.compose(por2)(5)=" + mas1.compose(por2).apply(5));
// ----- Predicate: and, or, negate, not, isEqual -----
List<Cliente> clientes = List.of(
new Cliente("Talleres Martín S.L.", "Valencia", 10000, true),
new Cliente("Laura Benítez Sanz", "Bilbao", 1000, false),
new Cliente("Instituto Tecnológico Sur", "Granada", 20000, true),
new Cliente("Carlos Ibáñez Rey", "Valencia", 1500, false),
new Cliente("Hotel Mar Azul S.L.", "Palma", 15000, true));
Predicate<Cliente> deValencia = c -> c.ciudad().equals("Valencia");
Predicate<Cliente> creditoAlto = c -> c.credito() >= 10000;
Predicate<Cliente> conComercial = Cliente::tieneComercial;
System.out.println("Valencia Y crédito alto: " + nombres(clientes, deValencia.and(creditoAlto)));
System.out.println("Valencia O crédito alto: " + nombres(clientes, deValencia.or(creditoAlto)));
System.out.println("Sin comercial: " + nombres(clientes, conComercial.negate()));
System.out.println("not(...) (Java 11): " + nombres(clientes, Predicate.not(creditoAlto)));
Predicate<String> esPalma = Predicate.isEqual("Palma");
System.out.println("isEqual: " + esPalma.test("Palma"));
// ----- Consumer.andThen, UnaryOperator, BinaryOperator.minBy/maxBy -----
Consumer<Cliente> avisar = c -> System.out.print("Aviso a " + c.nombre());
Consumer<Cliente> registrar = c -> System.out.println(" · registrado");
avisar.andThen(registrar).accept(clientes.get(1));
UnaryOperator<String> mayus = String::toUpperCase;
System.out.println(mayus.andThen(s -> s + "!").apply("hecho"));
BinaryOperator<Cliente> masCredito = BinaryOperator.maxBy(java.util.Comparator.comparingDouble(Cliente::credito));
System.out.println("Más crédito: " + clientes.stream().reduce(masCredito).orElseThrow().nombre());
}
static List<String> nombres(List<Cliente> clientes, Predicate<Cliente> filtro) {
return clientes.stream().filter(filtro).map(c -> c.nombre().split(" ")[0]).toList();
}
}
andThen: 108,90 compose: 108,90
slug: curso-de-java-avanzado
identity: sin cambios
mas1.andThen(por2)(5)=12 mas1.compose(por2)(5)=11
Valencia Y crédito alto: [Talleres]
Valencia O crédito alto: [Talleres, Instituto, Carlos, Hotel]
Sin comercial: [Laura, Carlos]
not(...) (Java 11): [Laura, Carlos]
isEqual: true
Aviso a Laura Benítez Sanz · registrado
HECHO!
Más crédito: Instituto Tecnológico Sur
Fíjate en la primera línea: con IVA y descuento el orden no importa (multiplicar es conmutativo), así que andThen y compose dan lo mismo. Con mas1 y por2 sí importa: (5 + 1) × 2 = 12 frente a 5 × 2 + 1 = 11. En la práctica se usa casi siempre andThen, porque se lee en el orden en que ocurre.
💡 Consejo:
Predicate.not(...)brilla con referencias a métodos, donde no puedes escribir!:filter(Predicate.not(String::isBlank))en lugar defilter(s -> !s.isBlank()).
6. Funciones de orden superior y currificación
📘 Concepto: Una función de orden superior es la que recibe funciones como parámetro, devuelve una función, o ambas cosas.
Stream.map,Comparator.comparingoList.removeIfson funciones de orden superior. Te permiten escribir algoritmos genéricos cuyo comportamiento concreto se decide al llamarlos.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.IntUnaryOperator;
import java.util.function.Predicate;
import java.util.function.Supplier;
public class OrdenSuperior {
// 1. Recibe una función como parámetro
static <T> List<T> filtrar(List<T> lista, Predicate<T> condicion) {
List<T> resultado = new ArrayList<>();
for (T t : lista) if (condicion.test(t)) resultado.add(t);
return resultado;
}
static int aplicarNVeces(IntUnaryOperator f, int n, int valor) {
for (int i = 0; i < n; i++) valor = f.applyAsInt(valor);
return valor;
}
// 2. Devuelve una función ("fábrica de funciones")
static Function<Double, Double> multiplicador(double factor) {
return x -> x * factor; // la lambda captura "factor"
}
static Predicate<String> longitudEntre(int min, int max) {
return s -> s.length() >= min && s.length() <= max;
}
// 3. Recibe y devuelve funciones: decorador que mide el tiempo
static <T, R> Function<T, R> cronometrado(String nombre, Function<T, R> f) {
return t -> {
long inicio = System.nanoTime();
R r = f.apply(t);
System.out.printf(" %s tardó %s%n", nombre, (System.nanoTime() - inicio) < 1_000_000_000 ? "< 1 s" : "> 1 s");
return r;
};
}
// 4. Memoización: recuerda resultados ya calculados
static <T, R> Function<T, R> memoizar(Function<T, R> f) {
Map<T, R> cache = new HashMap<>();
return t -> cache.computeIfAbsent(t, f);
}
// 5. Evaluación perezosa con Supplier: el valor solo se calcula si hace falta
static String describir(boolean detallado, Supplier<String> informeCaro) {
return detallado ? informeCaro.get() : "resumen";
}
public static void main(String[] args) {
List<String> palabras = List.of("sol", "estrella", "luna", "constelación", "astro");
System.out.println(filtrar(palabras, longitudEntre(4, 8)));
System.out.println("duplicar 10 veces 1 = " + aplicarNVeces(x -> x * 2, 10, 1));
Function<Double, Double> doble = multiplicador(2), iva = multiplicador(1.21);
System.out.println(doble.apply(50.0) + " " + iva.apply(100.0));
// Currificación: una función de 2 parámetros convertida en una cadena de funciones de 1
BiFunction<Double, Double, Double> precioFinal = (precio, descuento) -> precio * (1 - descuento);
Function<Double, Function<Double, Double>> precioCurry = precio -> descuento -> precio * (1 - descuento);
Function<Double, Double> monitor = precioCurry.apply(329.0); // aplicación parcial
System.out.println(precioFinal.apply(329.0, 0.05) + " = " + monitor.apply(0.05) + " · con 10 %: " + monitor.apply(0.10));
Function<Integer, Function<Integer, Function<Integer, Integer>>> volumen = a -> b -> c -> a * b * c;
System.out.println("volumen 2x3x4 = " + volumen.apply(2).apply(3).apply(4));
Function<Long, Long> lento = n -> { long s = 0; for (long i = 0; i < n; i++) s += i % 7; return s; };
Function<Long, Long> medido = cronometrado("suma", lento);
System.out.println("resultado = " + medido.apply(10_000_000L));
int[] llamadas = {0};
Function<Integer, Integer> cuadrado = memoizar(n -> { llamadas[0]++; return n * n; });
cuadrado.apply(12); cuadrado.apply(12); cuadrado.apply(13);
System.out.println("cálculos reales: " + llamadas[0] + " de 3 llamadas");
System.out.println(describir(false, () -> { throw new IllegalStateException("no debería ejecutarse"); }));
}
}
[estrella, luna, astro]
duplicar 10 veces 1 = 1024
100.0 121.0
312.55 = 312.55 · con 10 %: 296.1
volumen 2x3x4 = 24
suma tardó < 1 s
resultado = 29999994
cálculos reales: 2 de 3 llamadas
resumen
Las cinco técnicas del ejemplo:
- Recibir funciones:
filtraryaplicarNVecesson algoritmos genéricos; el criterio o la operación los decide quien llama. - Devolver funciones:
multiplicador(2)“fabrica” una función nueva. La lambda devuelta recuerda el valor defactoraunquemultiplicadorya haya terminado: es un cierre (closure). - Decoradores:
cronometradoenvuelve cualquier función añadiéndole comportamiento (medir tiempo, registrar, reintentar) sin modificarla. Es la idea que hay detrás de la programación orientada a aspectos de Spring (módulo 17). - Memoización:
memoizarconvierte cualquier función pura en una que recuerda sus resultados. Solo es correcta con funciones puras: si el resultado dependiera de algo externo, la caché devolvería datos obsoletos. - Evaluación perezosa:
describirrecibe unSuppliery solo lo ejecuta si hace falta; por eso la excepción nunca se lanza. Lo mismo hace elLoggerde Java:log.fine(() -> "mensaje caro " + calcular())solo construye el texto si ese nivel de log está activo.
6.1. Currificación y aplicación parcial
📘 Concepto: Currificar (por el matemático Haskell Curry) es transformar una función de varios parámetros en una cadena de funciones de un parámetro:
f(a, b)pasa a serf(a)(b). La ventaja es la aplicación parcial: fijar los primeros argumentos y obtener una función especializada, comomonitor = precioCurry.apply(329.0), que ya solo espera el descuento.
En Java la currificación se escribe con lambdas anidadas (a -> b -> c -> ...) y su tipo se vuelve largo enseguida (Function<Integer, Function<Integer, Function<Integer, Integer>>>). Úsala con mesura: en la práctica, la aplicación parcial se consigue de forma más legible con métodos fábrica como multiplicador(double) o longitudEntre(int, int).
7. Optional
📘 Concepto:
Optional<T>es un contenedor que puede tener un valor o ninguno. Sirve como tipo de retorno de los métodos que pueden no tener resultado, para que el que llama vea en la propia firma que debe tratar la ausencia, en lugar de recibir unnullinesperado que acabe enNullPointerException.
Optional<Empleado> buscar(int id); // la firma avisa: puede no encontrarlo
Empleado buscar(int id); // ¿devuelve null si no existe? ¿lanza excepción? Hay que leer la documentación
Figura 3. Un Optional vacío atraviesa la cadena sin ejecutar ninguna transformación.
7.1. La API de Optional
| Categoría | Método | Qué hace |
|---|---|---|
| Crear | Optional.of(v) | Con valor (lanza NullPointerException si v es null) |
Optional.ofNullable(v) | Con valor o vacío si v es null | |
Optional.empty() | Vacío | |
| Transformar | map(f) | Aplica f al valor si existe |
flatMap(f) | Como map, pero f ya devuelve un Optional (evita Optional<Optional<T>>) | |
filter(p) | Se queda vacío si el valor no cumple p | |
or(() -> otroOptional) (Java 9) | Alternativa si está vacío | |
| Consultar | isPresent(), isEmpty() (Java 11) | ¿Tiene valor? |
| Obtener | orElse(valor) | El valor o uno por defecto (que se evalúa siempre) |
orElseGet(() -> valor) | El valor o uno por defecto calculado solo si hace falta | |
orElseThrow() / orElseThrow(() -> excepción) | El valor o una excepción | |
| Actuar | ifPresent(consumidor) | Ejecuta si hay valor |
ifPresentOrElse(consumidor, runnable) (Java 9) | Una acción u otra | |
| Convertir | stream() (Java 9) | Stream de 0 o 1 elementos |
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
public class Opcionales {
record Empleado(int id, String nombre, String telefono, Integer departamento) {
Optional<String> telefonoOpcional() { return Optional.ofNullable(telefono); }
}
static final Map<Integer, Empleado> EMPLEADOS = Map.of(
100, new Empleado(100, "Ana", "600100100", 10),
123, new Empleado(123, "Sara", null, 30),
160, new Empleado(160, "Tomás", null, null));
// Un método que puede no encontrar nada devuelve Optional, no null
static Optional<Empleado> buscar(int id) {
return Optional.ofNullable(EMPLEADOS.get(id));
}
static String generarCodigo() {
System.out.println(" (generarCodigo() se ha ejecutado)");
return "SIN-CODIGO";
}
public static void main(String[] args) {
// ---- Creación ----
Optional<String> lleno = Optional.of("valor");
Optional<String> vacio = Optional.empty();
Optional<String> quizas = Optional.ofNullable(null);
System.out.println(lleno + " " + vacio + " " + quizas + " isPresent=" + lleno.isPresent() + " isEmpty=" + vacio.isEmpty());
// ---- map, filter, flatMap ----
System.out.println("Nombre del 100: " + buscar(100).map(Empleado::nombre).orElse("desconocido"));
System.out.println("Nombre del 999: " + buscar(999).map(Empleado::nombre).orElse("desconocido"));
System.out.println("¿El 100 es del dep. 10? "
+ buscar(100).filter(e -> Integer.valueOf(10).equals(e.departamento())).isPresent());
// map daría Optional<Optional<String>>; flatMap "aplana"
System.out.println("Teléfono del 100: " + buscar(100).flatMap(Empleado::telefonoOpcional).orElse("sin teléfono"));
System.out.println("Teléfono del 123: " + buscar(123).flatMap(Empleado::telefonoOpcional).orElse("sin teléfono"));
// ---- orElse frente a orElseGet ----
System.out.println("orElse con valor presente:");
String a = lleno.orElse(generarCodigo()); // ¡se ejecuta siempre!
System.out.println("orElseGet con valor presente:");
String b = lleno.orElseGet(Opcionales::generarCodigo); // solo si está vacío
System.out.println(" a=" + a + " b=" + b);
// ---- orElseThrow ----
try {
buscar(999).orElseThrow(() -> new NoSuchElementException("Empleado 999 no existe"));
} catch (NoSuchElementException e) {
System.out.println("orElseThrow: " + e.getMessage());
}
// ---- ifPresent, ifPresentOrElse, or, stream ----
buscar(123).ifPresent(e -> System.out.println("ifPresent: encontrado " + e.nombre()));
buscar(999).ifPresentOrElse(e -> System.out.println(e.nombre()),
() -> System.out.println("ifPresentOrElse: no encontrado"));
Empleado porDefecto = buscar(999).or(() -> buscar(100)).orElseThrow(); // Java 9
System.out.println("or: " + porDefecto.nombre());
List<String> nombres = List.of(100, 999, 160).stream()
.map(Opcionales::buscar)
.flatMap(Optional::stream) // Java 9: descarta los vacíos
.map(Empleado::nombre).toList();
System.out.println("Optional.stream: " + nombres);
// ---- Cadena con varios niveles posibles de ausencia ----
String depTomas = buscar(160).map(Empleado::departamento).map(d -> "dep. " + d).orElse("sin departamento");
System.out.println("Tomás: " + depTomas);
}
}
Optional[valor] Optional.empty Optional.empty isPresent=true isEmpty=true
Nombre del 100: Ana
Nombre del 999: desconocido
¿El 100 es del dep. 10? true
Teléfono del 100: 600100100
Teléfono del 123: sin teléfono
orElse con valor presente:
(generarCodigo() se ha ejecutado)
orElseGet con valor presente:
a=valor b=valor
orElseThrow: Empleado 999 no existe
ifPresent: encontrado Sara
ifPresentOrElse: no encontrado
or: Ana
Optional.stream: [Ana, Tomás]
Tomás: sin departamento
Dos detalles importantes del ejemplo:
orElsefrente aorElseGet:orElse(generarCodigo())ejecutagenerarCodigo()siempre, aunque elOptionaltenga valor, porque Java evalúa los argumentos antes de llamar al método. Si el valor por defecto es caro (una consulta, crear un objeto grande), usaorElseGet.mapcuando la función devuelvenull: el departamento de Tomás esnull, así quemap(Empleado::departamento)devuelve unOptionalvacío (no unOptionalque contienenull). Por eso la cadena termina en “sin departamento”.
7.2. Antipatrones de Optional
| Antipatrón | Problema | Mejor |
|---|---|---|
if (opt.isPresent()) { x = opt.get(); } | Es un null check con más letras | opt.map(...), orElse, ifPresent |
opt.get() sin comprobar | Lanza NoSuchElementException: el mismo error que querías evitar | orElseThrow() (que dice claramente lo que hace) |
Devolver null en un método que devuelve Optional | Rompe todo el contrato | return Optional.empty(); |
Optional como atributo de una clase o componente de record | No es serializable y no se diseñó para eso; añade una capa sin valor | Atributo anulable + getter que devuelve Optional.ofNullable(campo) (como telefonoOpcional()) |
Optional como parámetro de un método | Obliga al que llama a envolver; tres estados posibles (null, vacío, lleno) | Sobrecarga del método o parámetro anulable documentado |
Optional<List<T>> | Una colección vacía ya expresa “no hay resultados” | Devuelve List.of() |
Optional.of(x) cuando x puede ser null | NullPointerException inmediata | Optional.ofNullable(x) |
orElse(calculoCaro()) | Se calcula siempre | orElseGet(() -> calculoCaro()) |
Optional<Integer> en código crítico | Autoboxing | OptionalInt, OptionalDouble, OptionalLong |
💡 Consejo: La regla que dio Brian Goetz (arquitecto del lenguaje Java):
Optionalestá pensado para tipos de retorno que pueden no tener resultado. Lo encontrarás constantemente en Spring Data (repositorio.findById(id)devuelveOptional<T>, módulo 19) y en las operaciones de streams (findFirst,max,reduce).
8. Lambdas frente a clases anónimas
Una lambda no es simplemente una clase anónima abreviada. Hay diferencias de significado:
import java.util.function.Supplier;
public class AnonimasVsLambdas {
private final String nombre = "objeto exterior";
void probar() {
Supplier<String> anonima = new Supplier<>() {
private final String nombre = "clase anónima"; // puede tener estado propio
@Override
public String get() {
return "this.nombre = " + this.nombre + " | " + this.getClass().getSimpleName();
}
};
Supplier<String> lambda = () -> "this.nombre = " + this.nombre + " | " + this.getClass().getSimpleName();
System.out.println("Anónima: " + anonima.get());
System.out.println("Lambda: " + lambda.get());
System.out.println("Clase de la anónima: " + anonima.getClass().getName());
System.out.println("¿Lambda es clase sintética oculta? " + lambda.getClass().isHidden());
int x = 10;
Runnable r1 = new Runnable() {
public void run() {
int x = 20; // la anónima puede "tapar" x
System.out.println("x en anónima = " + x);
}
};
// Runnable r2 = () -> { int x = 20; }; // NO compila: x ya está definida
r1.run();
System.out.println("x fuera = " + x);
}
public static void main(String[] args) {
new AnonimasVsLambdas().probar();
}
}
Anónima: this.nombre = clase anónima |
Lambda: this.nombre = objeto exterior | AnonimasVsLambdas
Clase de la anónima: AnonimasVsLambdas$1
¿Lambda es clase sintética oculta? true
x en anónima = 20
x fuera = 10
(La clase anónima no tiene nombre simple, por eso getSimpleName() devuelve una cadena vacía y la primera línea termina en | .)
| Aspecto | Clase anónima | Lambda |
|---|---|---|
this | La propia instancia anónima | El objeto que contiene la lambda (ámbito léxico) |
| Estado propio (atributos) | Sí | No |
| Implementar varios métodos / interfaces con varios métodos abstractos / extender una clase | Sí | No: solo interfaces funcionales |
| Declarar variables con el mismo nombre que una local exterior | Sí (las oculta) | No: comparte el ámbito del método |
| Compilación | Genera un fichero Clase$1.class | Se crea en ejecución con invokedynamic como clase oculta (hidden class), más ligera |
| Legibilidad | Verbosa | Concisa |
💡 Consejo: Usa lambdas siempre que puedas. Recurre a una clase anónima solo cuando necesites estado propio, implementar una clase abstracta (como la
LinkedHashMapconremoveEldestEntrydel módulo 02) o una interfaz con varios métodos. Y si la lógica es compleja, ni lo uno ni lo otro: escribe una clase con nombre.
9. Ejemplos completos
9.1. Validadores componibles
Cada regla de validación es una función T → List<String> (lista de errores). Con un método default las combinamos, acumulando todos los errores en lugar de parar en el primero:
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
/** Validadores componibles: cada regla es una función Cliente -> Optional<error>. */
public class Validadores {
record Cliente(String nombre, String nif, String email, double limiteCredito) {}
@FunctionalInterface
interface Validador<T> extends Function<T, List<String>> {
// Crea un validador a partir de una condición y un mensaje de error
static <T> Validador<T> regla(Predicate<T> esValido, String mensaje) {
return t -> esValido.test(t) ? List.of() : List.of(mensaje);
}
// Combina dos validadores acumulando los errores de ambos
default Validador<T> y(Validador<T> otro) {
return t -> {
List<String> errores = new ArrayList<>(this.apply(t));
errores.addAll(otro.apply(t));
return errores;
};
}
// Aplica el validador a una parte del objeto (por ejemplo, solo al email)
static <T, C> Validador<T> sobre(Function<T, C> campo, Validador<C> validador) {
return t -> validador.apply(campo.apply(t));
}
}
static final Validador<String> NO_VACIO = Validador.regla(s -> s != null && !s.isBlank(), "vacío");
static final Validador<Cliente> VALIDADOR_CLIENTE =
Validador.sobre(Cliente::nombre, NO_VACIO.y(Validador.regla(s -> s.length() <= 40, "nombre demasiado largo")))
.y(Validador.regla(c -> c.nif() != null && c.nif().matches("[A-Z0-9]\\d{7}[A-Z0-9]"), "NIF con formato incorrecto"))
.y(Validador.regla(c -> c.email() == null || c.email().contains("@"), "email sin @"))
.y(Validador.regla(c -> c.limiteCredito() >= 0 && c.limiteCredito() <= 50_000, "límite de crédito fuera de rango"));
public static void main(String[] args) {
List<Cliente> clientes = List.of(
new Cliente("Talleres Martín S.L.", "B12345678", "compras@talleresmartin.es", 10000),
new Cliente("", "B1234", "sin-arroba", 70000),
new Cliente("Antonio Ruiz Pérez", "23456789D", null, 1500));
for (Cliente c : clientes) {
List<String> errores = VALIDADOR_CLIENTE.apply(c);
String nombre = Optional.of(c.nombre()).filter(n -> !n.isBlank()).orElse("(sin nombre)");
System.out.println(errores.isEmpty() ? "✔ " + nombre : "✘ " + nombre + " -> " + errores);
}
}
}
✔ Talleres Martín S.L.
✘ (sin nombre) -> [vacío, NIF con formato incorrecto, email sin @, límite de crédito fuera de rango]
✔ Antonio Ruiz Pérez
Fíjate en que Validador<T> extiende Function<T, List<String>>: sigue siendo una interfaz funcional (hereda un único método abstracto, apply) y además se puede usar en cualquier sitio que espere una Function. El método estático sobre aplica un validador de String a un campo concreto del cliente: así reutilizamos NO_VACIO para cualquier campo de texto. Es la misma idea que verás en Bean Validation (@NotBlank, @Size) en el módulo 20, pero construida con funciones.
9.2. Estrategia de descuentos
El patrón de diseño Estrategia (módulo 10) consiste en encapsular algoritmos intercambiables. Con funciones, cada estrategia es simplemente un UnaryOperator<Double>, y una tabla las asocia al tipo de cliente sin un solo if en la lógica de cálculo:
import java.util.List;
import java.util.Map;
import java.util.function.UnaryOperator;
/** Patrón Estrategia con funciones: cada descuento es un UnaryOperator<Double>. */
public class Descuentos {
enum Tipo { PARTICULAR, EMPRESA, INSTITUCION }
record Pedido(int id, Tipo tipoCliente, double importe, int unidades) {}
// Estrategias simples
static UnaryOperator<Double> porcentaje(double pct) { return x -> x * (1 - pct / 100); }
static UnaryOperator<Double> fijo(double euros) { return x -> Math.max(0, x - euros); }
static UnaryOperator<Double> sinDescuento() { return UnaryOperator.identity(); }
// Composición de varias estrategias en una
@SafeVarargs
static UnaryOperator<Double> encadenar(UnaryOperator<Double>... pasos) {
UnaryOperator<Double> resultado = UnaryOperator.identity();
for (UnaryOperator<Double> paso : pasos) {
UnaryOperator<Double> anterior = resultado;
resultado = x -> paso.apply(anterior.apply(x));
}
return resultado;
}
// Tabla de estrategias por tipo de cliente (sin if ni switch en la lógica de cálculo)
static final Map<Tipo, UnaryOperator<Double>> POR_TIPO = Map.of(
Tipo.PARTICULAR, sinDescuento(),
Tipo.EMPRESA, porcentaje(10),
Tipo.INSTITUCION, encadenar(porcentaje(15), fijo(50)));
static double precioFinal(Pedido p) {
UnaryOperator<Double> estrategia = POR_TIPO.get(p.tipoCliente());
if (p.unidades() >= 20) estrategia = encadenar(estrategia, porcentaje(5)); // volumen: 5 % extra
return estrategia.apply(p.importe());
}
public static void main(String[] args) {
List<Pedido> pedidos = List.of(
new Pedido(5003, Tipo.PARTICULAR, 558.90, 2),
new Pedido(5002, Tipo.EMPRESA, 8226.00, 30),
new Pedido(5010, Tipo.INSTITUCION, 13665.00, 65),
new Pedido(5004, Tipo.EMPRESA, 6529.50, 15));
for (Pedido p : pedidos) {
System.out.printf("Pedido %d %-12s %10.2f € -> %10.2f €%n",
p.id(), p.tipoCliente(), p.importe(), precioFinal(p));
}
}
}
Pedido 5003 PARTICULAR 558,90 € -> 558,90 €
Pedido 5002 EMPRESA 8226,00 € -> 7033,23 €
Pedido 5010 INSTITUCION 13665,00 € -> 10986,99 €
Pedido 5004 EMPRESA 6529,50 € -> 5876,55 €
Los importes son los de los pedidos reales de EMPRESA sin descuentos de línea. El pedido 5010 (institución, 65 unidades) aplica 15 %, resta 50 € y aplica el 5 % por volumen: 13 665 × 0,85 − 50 = 11 565,25 → × 0,95 = 10 986,99 €. ¿Por qué encadenar usa una variable anterior? Porque la lambda captura variables effectively final, y resultado se reasigna en el bucle; anterior es una variable nueva en cada vuelta.
💡 Consejo:
UnaryOperator.identity()es el “elemento neutro” de la composición, como el 0 en la suma: empezar desde la identidad hace queencadenar()sin argumentos funcione y devuelva el precio sin tocar.
9.3. Procesador de eventos
Un bus de eventos mínimo: los suscriptores son Consumer<Evento>, se pueden filtrar con un Predicate y componer con andThen.
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Predicate;
/** Bus de eventos minimalista: suscriptores como Consumer, filtros como Predicate. */
public class Eventos {
enum TipoEvento { PEDIDO_CREADO, PEDIDO_ENVIADO, PEDIDO_CANCELADO }
record Evento(TipoEvento tipo, int pedido, double importe) {}
static class BusEventos {
private final Map<TipoEvento, List<Consumer<Evento>>> suscriptores = new EnumMap<>(TipoEvento.class);
void suscribir(TipoEvento tipo, Consumer<Evento> accion) {
suscriptores.computeIfAbsent(tipo, t -> new ArrayList<>()).add(accion);
}
// Suscripción con filtro: solo se ejecuta si el evento cumple la condición
void suscribir(TipoEvento tipo, Predicate<Evento> filtro, Consumer<Evento> accion) {
suscribir(tipo, e -> { if (filtro.test(e)) accion.accept(e); });
}
void publicar(Evento e) {
suscriptores.getOrDefault(e.tipo(), List.of()).forEach(s -> s.accept(e));
}
}
public static void main(String[] args) {
BusEventos bus = new BusEventos();
List<String> auditoria = new ArrayList<>();
double[] facturado = {0};
Consumer<Evento> registrar = e -> auditoria.add(e.tipo() + "#" + e.pedido());
Consumer<Evento> correo = e -> System.out.println(" Correo: pedido " + e.pedido() + " " + e.tipo());
bus.suscribir(TipoEvento.PEDIDO_CREADO, registrar.andThen(e -> facturado[0] += e.importe()));
bus.suscribir(TipoEvento.PEDIDO_CREADO, e -> e.importe() > 5000,
e -> System.out.println(" ALERTA: pedido grande " + e.pedido() + " (" + e.importe() + " €)"));
bus.suscribir(TipoEvento.PEDIDO_ENVIADO, registrar.andThen(correo));
bus.suscribir(TipoEvento.PEDIDO_CANCELADO, registrar.andThen(correo).andThen(e -> facturado[0] -= e.importe()));
List<Evento> eventos = List.of(
new Evento(TipoEvento.PEDIDO_CREADO, 5020, 1719.44),
new Evento(TipoEvento.PEDIDO_CREADO, 5021, 2462.05),
new Evento(TipoEvento.PEDIDO_CREADO, 5016, 8945.05),
new Evento(TipoEvento.PEDIDO_ENVIADO, 5020, 1719.44),
new Evento(TipoEvento.PEDIDO_CANCELADO, 5021, 2462.05));
eventos.forEach(e -> {
System.out.println("Publicado " + e.tipo() + " " + e.pedido());
bus.publicar(e);
});
System.out.println("Auditoría: " + auditoria);
System.out.printf("Facturado neto: %.2f €%n", facturado[0]);
}
}
Publicado PEDIDO_CREADO 5020
Publicado PEDIDO_CREADO 5021
Publicado PEDIDO_CREADO 5016
ALERTA: pedido grande 5016 (8945.05 €)
Publicado PEDIDO_ENVIADO 5020
Correo: pedido 5020 PEDIDO_ENVIADO
Publicado PEDIDO_CANCELADO 5021
Correo: pedido 5021 PEDIDO_CANCELADO
Auditoría: [PEDIDO_CREADO#5020, PEDIDO_CREADO#5021, PEDIDO_CREADO#5016, PEDIDO_ENVIADO#5020, PEDIDO_CANCELADO#5021]
Facturado neto: 10664,49 €
El bus no sabe nada de correos, auditoría ni alertas: solo guarda funciones y las llama. Añadir un nuevo comportamiento es suscribir una nueva función, sin tocar el bus (principio abierto/cerrado). Es una versión en miniatura de los listeners de eventos de Spring (@EventListener) y del patrón Observador (módulo 10).
10. Buenas prácticas y errores comunes
| Situación | Evita | Prefiere |
|---|---|---|
| Lambda larga (más de 3 líneas) | Bloques enormes dentro de map(...) | Extraer a un método con nombre y usar this::metodo |
| Lambda que solo llama a un método | x -> Integer.parseInt(x) | Integer::parseInt |
| Negar una referencia a método | x -> !x.isBlank() | Predicate.not(String::isBlank) |
| Modificar variables externas desde lambdas | lista.forEach(e -> total[0] += e.salario()) | Operaciones que devuelven resultado (mapToDouble(...).sum(), módulo 04) |
| Tipos primitivos | Function<Integer, Integer> en bucles intensivos | IntUnaryOperator (sin autoboxing) |
| Excepciones comprobadas en lambdas | throws imposibles; catch vacíos | Envolver en UncheckedIOException o interfaz funcional propia con throws |
| Ausencia de valor | Devolver null | Devolver Optional<T> (solo como retorno) |
Optional.get() | Sin comprobar | orElseThrow(), orElse, map |
| Interfaz funcional propia | Duplicar Function con otro nombre sin aportar nada | Las de java.util.function, salvo que el nombre o los default aporten |
@FunctionalInterface | Omitirla en interfaces pensadas para lambdas | Ponerla: el compilador te protege si alguien añade otro método abstracto |
11. Ejercicios
Ejercicio 1: De clases anónimas a lambdas
Dado un programa que ordena productos por nombre con un Comparator anónimo, define un Predicate anónimo “agotado” y una Function anónima que pone el nombre en mayúsculas, reescríbelo con lambdas y referencias a métodos (usa Comparator.comparing y Predicate.not), comprueba que el resultado es idéntico y elimina los productos agotados con removeIf.
Ver solución
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
public class Ej1Refactor {
record Producto(String nombre, double precio, int stock) {
boolean disponible() { return stock > 0; }
}
public static void main(String[] args) {
List<Producto> productos = new ArrayList<>(List.of(
new Producto("Monitor 27 4K", 329.00, 12), new Producto("Pack teclado y ratón", 34.50, 0),
new Producto("Webcam HD", 45.00, 30), new Producto("Altavoz Inteligente", 49.00, 0),
new Producto("Disco SSD 1TB", 89.90, 70)));
// ANTES: clases anónimas
productos.sort(new Comparator<Producto>() {
@Override
public int compare(Producto a, Producto b) {
return a.nombre().compareTo(b.nombre());
}
});
Predicate<Producto> agotadoAntes = new Predicate<Producto>() {
@Override
public boolean test(Producto p) {
return !p.disponible();
}
};
Function<Producto, String> etiquetaAntes = new Function<Producto, String>() {
@Override
public String apply(Producto p) {
return p.nombre().toUpperCase();
}
};
// DESPUÉS: lambdas y referencias a métodos
productos.sort(Comparator.comparing(Producto::nombre));
Predicate<Producto> agotado = Predicate.not(Producto::disponible);
Function<Producto, String> etiqueta = p -> p.nombre().toUpperCase();
System.out.println("Ordenados: " + productos.stream().map(Producto::nombre).toList());
System.out.println("Mismo resultado: " + productos.stream().map(etiquetaAntes).toList()
.equals(productos.stream().map(etiqueta).toList()));
productos.removeIf(agotado);
System.out.println("Disponibles: " + productos.stream().map(Producto::nombre).toList());
System.out.println("¿agotadoAntes y agotado coinciden con el pack? "
+ (agotadoAntes.test(new Producto("x", 1, 0)) == agotado.test(new Producto("x", 1, 0))));
productos.forEach(System.out::println);
}
}
Ordenados: [Altavoz Inteligente, Disco SSD 1TB, Monitor 27 4K, Pack teclado y ratón, Webcam HD]
Mismo resultado: true
Disponibles: [Disco SSD 1TB, Monitor 27 4K, Webcam HD]
¿agotadoAntes y agotado coinciden con el pack? true
Producto[nombre=Disco SSD 1TB, precio=89.9, stock=70]
Producto[nombre=Monitor 27 4K, precio=329.0, stock=12]
Producto[nombre=Webcam HD, precio=45.0, stock=30]
Veinte líneas de clases anónimas se convierten en tres. Predicate.not(Producto::disponible) evita escribir p -> !p.disponible(). La etiqueta se queda como lambda porque encadena dos llamadas (nombre() y toUpperCase()): no hay referencia a método que lo exprese directamente.
Ejercicio 2: Calculadora con un mapa de operaciones
Escribe una calculadora que evalúe expresiones de texto como "3 + 4" o "raiz 81" usando un Map<String, BinaryOperator<Double>> para las operaciones binarias (+ - * / ^ max) y un Map<String, DoubleUnaryOperator> para las unarias (raiz abs neg). La división entre cero debe lanzar ArithmeticException. Demuestra que añadir una operación nueva (%) no requiere modificar el método evaluar.
Ver solución
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BinaryOperator;
import java.util.function.DoubleUnaryOperator;
public class Ej2Calculadora {
// Operaciones binarias: el símbolo es la clave, la operación es el valor
static final Map<String, BinaryOperator<Double>> BINARIAS = new LinkedHashMap<>();
static final Map<String, DoubleUnaryOperator> UNARIAS = new LinkedHashMap<>();
static {
BINARIAS.put("+", Double::sum);
BINARIAS.put("-", (a, b) -> a - b);
BINARIAS.put("*", (a, b) -> a * b);
BINARIAS.put("/", (a, b) -> {
if (b == 0) throw new ArithmeticException("División entre cero");
return a / b;
});
BINARIAS.put("^", Math::pow);
BINARIAS.put("max", Math::max);
UNARIAS.put("raiz", Math::sqrt);
UNARIAS.put("abs", Math::abs);
UNARIAS.put("neg", x -> -x);
}
static double evaluar(String expresion) {
String[] t = expresion.trim().split("\\s+");
if (t.length == 2) { // "raiz 16"
DoubleUnaryOperator op = UNARIAS.get(t[0]);
if (op == null) throw new IllegalArgumentException("Operación desconocida: " + t[0]);
return op.applyAsDouble(Double.parseDouble(t[1]));
}
if (t.length == 3) { // "3 + 4"
BinaryOperator<Double> op = BINARIAS.get(t[1]);
if (op == null) throw new IllegalArgumentException("Operación desconocida: " + t[1]);
return op.apply(Double.parseDouble(t[0]), Double.parseDouble(t[2]));
}
throw new IllegalArgumentException("Formato: 'a op b' u 'op a'");
}
public static void main(String[] args) {
System.out.println("Operaciones: " + BINARIAS.keySet() + " " + UNARIAS.keySet());
for (String e : new String[]{"3 + 4", "10 / 4", "2 ^ 10", "7 max 12", "raiz 81", "neg 5", "1 / 0", "3 % 2"}) {
try {
System.out.printf("%-9s = %s%n", e, evaluar(e));
} catch (RuntimeException ex) {
System.out.printf("%-9s -> error: %s%n", e, ex.getMessage());
}
}
// Añadir una operación nueva no requiere tocar evaluar(): solo registrarla
BINARIAS.put("%", (a, b) -> a % b);
System.out.println("3 % 2 = " + evaluar("3 % 2"));
}
}
Operaciones: [+, -, *, /, ^, max] [raiz, abs, neg]
3 + 4 = 7.0
10 / 4 = 2.5
2 ^ 10 = 1024.0
7 max 12 = 12.0
raiz 81 = 9.0
neg 5 = -5.0
1 / 0 -> error: División entre cero
3 % 2 -> error: Operación desconocida: %
3 % 2 = 1.0
Un mapa de funciones sustituye a un switch gigante: el código que decide (evaluar) está separado del que calcula (las lambdas). Double::sum, Math::pow y Math::max encajan con BinaryOperator<Double> porque reciben dos double y devuelven otro (el autoboxing hace el resto).
Ejercicio 3: Normalizador de textos componible
Define varias transformaciones de texto como UnaryOperator<String> (recortar, espacios simples, minúsculas, quitar tildes con java.text.Normalizer, espacios a guiones, quitar símbolos) y un método componer(List<UnaryOperator<String>>) que las combine en una sola Function<String, String> usando reduce y andThen. Con él crea dos normalizadores: uno para generar slugs de URL y otro para búsquedas, y aplícalos a nombres de productos y clientes de EMPRESA.
Ver solución
import java.text.Normalizer;
import java.util.List;
import java.util.function.Function;
import java.util.function.UnaryOperator;
public class Ej3Normalizador {
static final UnaryOperator<String> RECORTAR = String::strip;
static final UnaryOperator<String> ESPACIOS_SIMPLES = s -> s.replaceAll("\\s+", " ");
static final UnaryOperator<String> MINUSCULAS = String::toLowerCase;
static final UnaryOperator<String> SIN_TILDES = s ->
Normalizer.normalize(s, Normalizer.Form.NFD).replaceAll("\\p{M}", "");
static final UnaryOperator<String> GUIONES = s -> s.replace(' ', '-');
static final UnaryOperator<String> SIN_SIMBOLOS = s -> s.replaceAll("[^a-z0-9-]", "");
/** Compone una lista de transformaciones en una sola función, en el orden dado. */
static Function<String, String> componer(List<UnaryOperator<String>> pasos) {
return pasos.stream()
.map(p -> (Function<String, String>) p)
.reduce(Function.identity(), Function::andThen);
}
public static void main(String[] args) {
Function<String, String> slug = componer(List.of(RECORTAR, ESPACIOS_SIMPLES, MINUSCULAS,
SIN_TILDES, GUIONES, SIN_SIMBOLOS));
Function<String, String> busqueda = componer(List.of(RECORTAR, MINUSCULAS, SIN_TILDES));
for (String nombre : List.of(" Portátil UltraBook 14 ", "Silla Ergonómica", "Pack teclado y ratón",
"Diseños Creativos S.L.")) {
System.out.printf("%-26s slug=%-24s búsqueda=%s%n", "\"" + nombre + "\"", slug.apply(nombre), busqueda.apply(nombre));
}
// Sin pasos: la identidad
System.out.println("Sin pasos: " + componer(List.of()).apply("Igual"));
}
}
" Portátil UltraBook 14 " slug=portatil-ultrabook-14 búsqueda=portatil ultrabook 14
"Silla Ergonómica" slug=silla-ergonomica búsqueda=silla ergonomica
"Pack teclado y ratón" slug=pack-teclado-y-raton búsqueda=pack teclado y raton
"Diseños Creativos S.L." slug=disenos-creativos-sl búsqueda=disenos creativos s.l.
Sin pasos: Igual
Normalizer.Form.NFD descompone cada letra con tilde en la letra base más un carácter de acento combinable (á → a + ´), y la expresión regular \p{M} (marcas diacríticas) los elimina. El reduce(Function.identity(), Function::andThen) es la composición en una línea: parte de la identidad y va encadenando cada paso. El cast a Function<String, String> es necesario porque UnaryOperator<String>.andThen devuelve una Function, no un UnaryOperator, y reduce exige que el acumulador sea del mismo tipo que los elementos. Observa también la diferencia en el último nombre: el normalizador de búsqueda no colapsa los dos espacios de “Diseños Creativos”.
Ejercicio 4: Cadena de Optional sobre EMPRESA
Con un extracto de las tablas clientes y empleados en mapas, escribe Optional<String> telefonoDelComercial(int idCliente) que siga el camino cliente → comercial asignado → empleado → teléfono, donde cada paso puede faltar, usando solo flatMap. Escribe también String diagnostico(int idCliente) que explique por qué no hay teléfono en cada caso, y usa or() para ofrecer el teléfono de la centralita como alternativa.
Ver solución
import java.util.Map;
import java.util.Optional;
public class Ej4Optional {
record Empleado(int id, String nombre, String telefono) {
Optional<String> telefonoOpt() { return Optional.ofNullable(telefono); }
}
record Cliente(int id, String nombre, Integer comercial) {
Optional<Integer> comercialOpt() { return Optional.ofNullable(comercial); }
}
// Extracto de la base de datos EMPRESA
static final Map<Integer, Empleado> EMPLEADOS = Map.of(
110, new Empleado(110, "Carmen", "600100110"),
111, new Empleado(111, "David", "600100111"),
113, new Empleado(113, "Sergio", null)); // en este extracto, sin teléfono
static final Map<Integer, Cliente> CLIENTES = Map.of(
1001, new Cliente(1001, "Talleres Martín S.L.", 110),
1004, new Cliente(1004, "Academia Nova", 111),
1009, new Cliente(1009, "Laura Benítez Sanz", null),
1011, new Cliente(1011, "Panadería La Espiga", 113));
static Optional<Cliente> buscarCliente(int id) { return Optional.ofNullable(CLIENTES.get(id)); }
static Optional<Empleado> buscarEmpleado(int id) { return Optional.ofNullable(EMPLEADOS.get(id)); }
/** Teléfono del comercial que atiende a un cliente, si existe todo el camino. */
static Optional<String> telefonoDelComercial(int idCliente) {
return buscarCliente(idCliente)
.flatMap(Cliente::comercialOpt)
.flatMap(Ej4Optional::buscarEmpleado)
.flatMap(Empleado::telefonoOpt);
}
/** Explica por qué no hay teléfono. */
static String diagnostico(int idCliente) {
Optional<Cliente> c = buscarCliente(idCliente);
if (c.isEmpty()) return "el cliente no existe";
Optional<Empleado> comercial = c.flatMap(Cliente::comercialOpt).flatMap(Ej4Optional::buscarEmpleado);
return comercial.map(e -> e.telefonoOpt()
.map(t -> "llamar a " + e.nombre() + " al " + t)
.orElse(e.nombre() + " no tiene teléfono"))
.orElse("el cliente no tiene comercial asignado");
}
public static void main(String[] args) {
for (int id : new int[]{1001, 1004, 1009, 1011, 9999}) {
System.out.printf("%d: %-12s · %s%n", id, telefonoDelComercial(id).orElse("—"), diagnostico(id));
}
// Centralita como alternativa con or() (Java 9)
String tel = telefonoDelComercial(1009).or(() -> Optional.of("900 100 100 (centralita)")).get();
System.out.println("1009 con alternativa: " + tel);
}
}
1001: 600100110 · llamar a Carmen al 600100110
1004: 600100111 · llamar a David al 600100111
1009: — · el cliente no tiene comercial asignado
1011: — · Sergio no tiene teléfono
9999: — · el cliente no existe
1009 con alternativa: 900 100 100 (centralita)
Cada flatMap es un “si hay valor, sigue; si no, para”. Sin Optional, telefonoDelComercial serían cuatro if (x != null) anidados. Fíjate en que los records guardan atributos anulables y exponen Optional mediante métodos (comercialOpt(), telefonoOpt()), siguiendo la recomendación de no usar Optional como atributo. El .get() final es seguro porque or() garantiza un valor, aunque orElseThrow() expresaría lo mismo con más claridad.
Ejercicio 5: Reintentos y registro como funciones de orden superior
Escribe static <T> Supplier<T> conReintentos(Supplier<T> operacion, int intentos), que devuelva un Supplier que reintente la operación hasta intentos veces si lanza una RuntimeException (y que, si todas fallan, lance una IllegalStateException con la última excepción como causa), y static <T, R> Function<T, R> conLog(String nombre, Function<T, R> f), que muestre la entrada y la salida de cualquier función. Pruébalos con un servicio simulado que falla las dos primeras veces.
Ver solución
import java.util.function.Function;
import java.util.function.Supplier;
public class Ej5Reintentos {
/** Función de orden superior: devuelve un Supplier que reintenta hasta n veces. */
static <T> Supplier<T> conReintentos(Supplier<T> operacion, int intentos) {
return () -> {
RuntimeException ultimo = null;
for (int i = 1; i <= intentos; i++) {
try {
T r = operacion.get();
System.out.println(" intento " + i + ": OK");
return r;
} catch (RuntimeException e) {
System.out.println(" intento " + i + ": " + e.getMessage());
ultimo = e;
}
}
throw new IllegalStateException("Fallo tras " + intentos + " intentos", ultimo);
};
}
/** Decorador genérico: añade un registro antes y después de cualquier función. */
static <T, R> Function<T, R> conLog(String nombre, Function<T, R> f) {
return t -> {
System.out.println("-> " + nombre + "(" + t + ")");
R r = f.apply(t);
System.out.println("<- " + nombre + " = " + r);
return r;
};
}
// Servicio simulado que falla las primeras veces
static int llamadas = 0;
static String servicioInestable() {
llamadas++;
if (llamadas < 3) throw new RuntimeException("tiempo de espera agotado");
return "tipo de cambio EUR/USD = 1,17";
}
public static void main(String[] args) {
System.out.println("Servicio que se recupera:");
System.out.println(conReintentos(Ej5Reintentos::servicioInestable, 5).get());
System.out.println("Servicio que nunca responde:");
try {
conReintentos(() -> { throw new RuntimeException("conexión rechazada"); }, 2).get();
} catch (IllegalStateException e) {
System.out.println(e.getMessage() + " (causa: " + e.getCause().getMessage() + ")");
}
Function<Double, Double> iva = conLog("iva", x -> Math.round(x * 1.21 * 100) / 100.0);
iva.andThen(conLog("redondear", Math::rint)).apply(99.99);
}
}
Servicio que se recupera:
intento 1: tiempo de espera agotado
intento 2: tiempo de espera agotado
intento 3: OK
tipo de cambio EUR/USD = 1,17
Servicio que nunca responde:
intento 1: conexión rechazada
intento 2: conexión rechazada
Fallo tras 2 intentos (causa: conexión rechazada)
-> iva(99.99)
<- iva = 120.99
-> redondear(120.99)
<- redondear = 121.0
Los dos son decoradores: reciben una función y devuelven otra con comportamiento añadido, sin modificar la original. Como devuelven el mismo tipo que reciben, se pueden combinar libremente (conLog sobre conReintentos, andThen entre funciones decoradas). Bibliotecas como Resilience4j (usada con Spring Boot) ofrecen exactamente este estilo: Retry.decorateSupplier(retry, supplier).
Ejercicio 6: Filtros de búsqueda configurables
Crea un record Filtro(Double precioMin, Double precioMax, Integer categoria, boolean soloConStock, String texto) donde un componente null significa “no filtrar por esto”, con un método Predicate<Producto> comoPredicado() que construya una lista con un predicado por cada criterio presente y los combine con reduce y Predicate::and. Úsalo para hacer varias búsquedas sobre la tabla productos de EMPRESA.
Ver solución
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public class Ej6Filtros {
record Producto(int id, String nombre, int categoria, double precio, int stock) {}
/** Criterios opcionales de búsqueda: null = "no filtrar por esto". */
record Filtro(Double precioMin, Double precioMax, Integer categoria, boolean soloConStock, String texto) {
Predicate<Producto> comoPredicado() {
List<Predicate<Producto>> condiciones = new ArrayList<>();
if (precioMin != null) condiciones.add(p -> p.precio() >= precioMin);
if (precioMax != null) condiciones.add(p -> p.precio() <= precioMax);
if (categoria != null) condiciones.add(p -> p.categoria() == categoria);
if (soloConStock) condiciones.add(p -> p.stock() > 0);
if (texto != null) condiciones.add(p -> p.nombre().toLowerCase().contains(texto.toLowerCase()));
// Combinamos todas con AND; sin condiciones, se acepta todo
return condiciones.stream().reduce(p -> true, Predicate::and);
}
}
static final List<Producto> PRODUCTOS = List.of(
new Producto(1, "Portátil UltraBook 14", 2, 899.00, 25), new Producto(2, "Portátil Gaming 16", 2, 1499.00, 10),
new Producto(3, "Portátil Básico 15", 2, 499.00, 40), new Producto(6, "Ratón inalámbrico", 5, 19.90, 200),
new Producto(7, "Teclado mecánico", 5, 79.90, 60), new Producto(8, "Pack teclado y ratón", 5, 34.50, 0),
new Producto(9, "Monitor 24 FHD", 6, 139.00, 35), new Producto(10, "Monitor 27 4K", 6, 329.00, 12),
new Producto(14, "Altavoz Inteligente", 9, 49.00, 0), new Producto(17, "Webcam HD", 4, 45.00, 30));
static void buscar(String titulo, Filtro f) {
List<String> encontrados = PRODUCTOS.stream().filter(f.comoPredicado()).map(Producto::nombre).toList();
System.out.println(titulo + ": " + encontrados);
}
public static void main(String[] args) {
buscar("Portátiles de menos de 1000 €", new Filtro(null, 1000.0, 2, false, null));
buscar("Ratones y teclados (cat. 5) con stock", new Filtro(null, null, 5, true, null));
buscar("Entre 40 y 150 € con stock", new Filtro(40.0, 150.0, null, true, null));
buscar("Que contengan 'ratón'", new Filtro(null, null, null, false, "RATÓN"));
buscar("Sin criterios", new Filtro(null, null, null, false, null));
}
}
Portátiles de menos de 1000 €: [Portátil UltraBook 14, Portátil Básico 15]
Ratones y teclados (cat. 5) con stock: [Ratón inalámbrico, Teclado mecánico]
Entre 40 y 150 € con stock: [Teclado mecánico, Monitor 24 FHD, Webcam HD]
Que contengan 'ratón': [Ratón inalámbrico, Pack teclado y ratón]
Sin criterios: [Portátil UltraBook 14, Portátil Gaming 16, Portátil Básico 15, Ratón inalámbrico, Teclado mecánico, Pack teclado y ratón, Monitor 24 FHD, Monitor 27 4K, Altavoz Inteligente, Webcam HD]
Es la misma técnica que usa la Criteria API de JPA y las Specifications de Spring Data (módulos 16 y 19) para construir consultas dinámicas: una lista de condiciones que se combinan con AND solo si el criterio está presente. El predicado neutro p -> true hace que un filtro vacío lo acepte todo, igual que la identidad en la composición de funciones. Las lambdas capturan los componentes del record (precioMin, categoria…), que son final, así que no hay problema de captura.
Ejercicio 7 (propuesto): Interfaz funcional con throws
Define @FunctionalInterface interface FuncionIO<T, R> { R aplicar(T t) throws IOException; } y un método estático static <T, R> Function<T, R> sinExcepcion(FuncionIO<T, R> f) que la convierta en una Function normal envolviendo la IOException en una UncheckedIOException. Úsalo para leer con Files.readString el contenido de varios ficheros de texto a partir de una lista de rutas, y prueba qué ocurre si uno no existe.
Ejercicio 8 (propuesto): Memoización de Fibonacci
Implementa Fibonacci recursivo y mide cuánto tarda fib(40). Después crea una versión memoizada con un Map<Integer, Long> y una Function<Integer, Long> (cuidado: computeIfAbsent no admite que la función modifique el mismo mapa de forma recursiva en un HashMap; investiga qué excepción lanza y resuélvelo con un get + put manual). Compara tiempos.
Ejercicio 9 (propuesto): Pipeline de transformación de pedidos
Representa un pedido como un record inmutable y define varias transformaciones UnaryOperator<Pedido> (aplicar IVA, aplicar descuento por volumen, redondear importes, añadir gastos de envío si el total es menor de 50 €). Permite que el usuario elija por teclado qué transformaciones aplicar y en qué orden, y compónlas con andThen. Comprueba que el pedido original no cambia.
Ejercicio 10 (propuesto): Comparadores con funciones de orden superior
Escribe static <T, U extends Comparable<? super U>> Comparator<T> por(Function<T, U> clave, boolean descendente) y static <T> Comparator<T> combinar(List<Comparator<T>> criterios) (que encadene con thenComparing usando reduce). Úsalos para ordenar los empleados de EMPRESA según una lista de criterios elegida en tiempo de ejecución, por ejemplo ["departamento:asc", "salario:desc"].
Ejercicio 11 (propuesto): Bus de eventos con desuscripción
Amplía el BusEventos de la sección 9.3 para que suscribir devuelva un objeto Suscripcion con un método cancelar() (que se puede implementar con una lambda que elimina el consumidor de la lista). Añade un suscriptor “una sola vez” que se cancele automáticamente tras el primer evento. ¿Qué problema aparece si un suscriptor se cancela a sí mismo mientras se recorre la lista? Resuélvelo.
Ejercicio 12 (propuesto): Optional frente a excepciones
Implementa dos versiones de un método que convierte un texto en un LocalDate: una que lanza excepción (DateTimeParseException) y otra que devuelve Optional<LocalDate>. Procesa con ambas una lista de 10 fechas, algunas mal escritas, y muestra solo las válidas. Razona en un comentario cuándo es preferible cada enfoque (pista: ¿es la ausencia un caso normal o excepcional?).
Resumen
| Concepto | Sintaxis / API | Idea clave |
|---|---|---|
| Lambda | (a, b) -> a + b, x -> { ...; return r; } | Función anónima tipada por una interfaz funcional |
| Captura | Variables locales effectively final | Se copia el valor; los atributos no tienen restricción |
| Interfaz funcional | @FunctionalInterface, un solo método abstracto | Puede tener métodos default y static |
Supplier<T> / Consumer<T> | get() / accept(t) | Producir / consumir |
Function<T,R> / BiFunction | apply(t) | Transformar |
Predicate<T> | test(t) | Condiciones; and, or, negate, not |
UnaryOperator<T> / BinaryOperator<T> | apply | Mismo tipo de entrada y salida |
| Primitivas | IntPredicate, ToDoubleFunction… | Sin autoboxing |
| Referencias a métodos | Clase::estatico, obj::metodo, Clase::instancia, Clase::new | Atajo cuando la lambda solo llama a un método |
| Composición | f.andThen(g), f.compose(g), Function.identity() | Construir funciones con piezas pequeñas |
| Orden superior | Recibir o devolver funciones | Algoritmos genéricos, decoradores, fábricas |
| Currificación | a -> b -> a * b | Aplicación parcial |
| Evaluación perezosa | Supplier, orElseGet | Calcular solo si hace falta |
Optional<T> | ofNullable, map, flatMap, filter, orElse, orElseGet, orElseThrow, ifPresentOrElse | Solo como tipo de retorno; nunca get() sin comprobar |
| Lambda frente a clase anónima | this léxico, sin estado propio | Lambda siempre que se pueda |